@deeeed/metamask-harness 0.86.1 → 0.87.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 +41 -0
- package/dist/adapters/mobile/action-inputs.js +20 -0
- package/dist/adapters/mobile/actions.js +18 -1
- package/dist/adapters/mobile/android-screenrecord.js +270 -0
- package/dist/adapters/mobile/readiness.js +2 -1
- package/dist/adapters/mobile/recording-outcome.js +103 -0
- package/dist/adapters/mobile/recording-target.js +11 -0
- package/dist/adapters/mobile/run.js +48 -11
- package/dist/adapters/mobile/ui.js +96 -2
- package/dist/adapters/perps-order-inputs.js +43 -28
- package/dist/adapters.js +4 -1
- package/dist/commands/doctor.js +33 -14
- package/dist/commands/status-probe.js +2 -1
- package/docs/RECIPES.md +62 -0
- package/library/actions/core/perps/read_account.mjs +4 -2
- package/library/actions/core/perps/read_orders.mjs +4 -2
- package/library/actions/core/perps/read_positions.mjs +4 -2
- package/library/actions/extension/analytics/consent.mjs +7 -2
- package/library/actions/extension/perps/close_visible_position.mjs +9 -5
- package/library/actions/extension/perps/compare_provider_market.mjs +12 -8
- package/library/actions/extension/perps/mutation-receipt.mjs +142 -24
- package/library/actions/extension/perps/perps.mjs +179 -40
- package/library/actions/extension/perps/read_snapshot.mjs +6 -2
- package/library/actions/extension/platform/cdp.mjs +6 -2
- package/library/actions/extension/ui/navigate.mjs +8 -0
- package/library/actions/mobile/perps/perps.mjs +109 -109
- package/library/actions/mobile/platform/bridge.mjs +192 -12
- package/library/actions/mobile/wallet/home.mjs +7 -2
- package/library/actions/mobile/wallet/select_account.mjs +4 -0
- package/library/actions/shared/app/live-adapter-deadline.mjs +8 -0
- package/library/actions/shared/perps/order-policy.mjs +19 -0
- package/library/actions/shared/perps/rate-limit.mjs +93 -0
- package/library/actions/shared/perps/visible-state.mjs +3 -2
- package/library/manifests/extension.action-manifest.json +3 -2
- package/library/manifests/mobile.action-manifest.json +30 -5
- package/package.json +1 -1
- package/site/assets/help-recipes.json +1 -0
|
@@ -109,6 +109,85 @@ function isRetryableMobileWaitError(error) {
|
|
|
109
109
|
isRecord(error) && typeof error.code === "string" && RETRYABLE_MOBILE_WAIT_CODES.has(error.code)
|
|
110
110
|
);
|
|
111
111
|
}
|
|
112
|
+
function hiddenRouteExpression(testId) {
|
|
113
|
+
return `(function(){
|
|
114
|
+
const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
115
|
+
if (!hook || !hook.getFiberRoots) return null;
|
|
116
|
+
const hiddenAt = (props, routeKey) => {
|
|
117
|
+
const state = props.state;
|
|
118
|
+
const index = state.routes.findIndex((route) => route && route.key === routeKey);
|
|
119
|
+
if (index < 0 || typeof state.index !== 'number') return false;
|
|
120
|
+
if (state.type === 'tab') return index !== state.index;
|
|
121
|
+
if (state.type !== 'stack') return false;
|
|
122
|
+
// Mirror native-stack's getModalRouteKeys: an unset presentation after
|
|
123
|
+
// any modal-family screen is a modal too. On iOS a card pushed after a
|
|
124
|
+
// modal goes under it, so nothing hides a modal target.
|
|
125
|
+
const modal = [];
|
|
126
|
+
for (let position = 0; position <= state.index; position += 1) {
|
|
127
|
+
const descriptor = props.descriptors[state.routes[position].key];
|
|
128
|
+
if (!descriptor || !descriptor.options) return false;
|
|
129
|
+
const presentation = descriptor.options.presentation;
|
|
130
|
+
modal.push(presentation === undefined || presentation === null
|
|
131
|
+
? modal.includes(true)
|
|
132
|
+
: !['card', 'push'].includes(presentation));
|
|
133
|
+
}
|
|
134
|
+
if (modal[index]) return false;
|
|
135
|
+
for (let above = index + 1; above <= state.index; above += 1) {
|
|
136
|
+
if (!modal[above]) return true;
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
};
|
|
140
|
+
const onHiddenRoute = (fiber) => {
|
|
141
|
+
let routeKey;
|
|
142
|
+
for (let owner = fiber.return; owner; owner = owner.return) {
|
|
143
|
+
const props = owner.memoizedProps;
|
|
144
|
+
if (!props) continue;
|
|
145
|
+
if (props.route && typeof props.route.key === 'string') routeKey = props.route.key;
|
|
146
|
+
if (routeKey && props.descriptors && props.state && Array.isArray(props.state.routes)
|
|
147
|
+
&& hiddenAt(props, routeKey)) return true;
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
};
|
|
151
|
+
let matches = 0;
|
|
152
|
+
let hidden = 0;
|
|
153
|
+
const visit = (fiber) => {
|
|
154
|
+
for (; fiber; fiber = fiber.sibling) {
|
|
155
|
+
if (fiber.memoizedProps && fiber.memoizedProps.testID === ${JSON.stringify(testId)}) {
|
|
156
|
+
matches += 1;
|
|
157
|
+
try {
|
|
158
|
+
if (onHiddenRoute(fiber)) hidden += 1;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
// An unreadable navigator counts as visible: never a false absent.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
visit(fiber.child);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
const roots = new Set();
|
|
167
|
+
for (const id of hook.renderers.keys()) (hook.getFiberRoots(id) || []).forEach((root) => roots.add(root));
|
|
168
|
+
roots.forEach((root) => visit(root.current));
|
|
169
|
+
return { matches, hidden };
|
|
170
|
+
})()`;
|
|
171
|
+
}
|
|
172
|
+
const NATIVE_READ_BUDGET_MS = 2500;
|
|
173
|
+
async function hiddenRouteOnly(input, probeInput, payload, testId, deadline) {
|
|
174
|
+
const routes = await evalSync(probeInput, hiddenRouteExpression(testId));
|
|
175
|
+
if (!isRecord(routes) || Number(routes.matches) < 1 || routes.hidden !== routes.matches) return null;
|
|
176
|
+
const remainingMs = deadline - Date.now();
|
|
177
|
+
if (remainingMs < NATIVE_READ_BUDGET_MS) return null;
|
|
178
|
+
let timer;
|
|
179
|
+
const observed = await Promise.race([
|
|
180
|
+
observeNativeUi({ refs: ["ui.visible"], node: payload }, input.context),
|
|
181
|
+
new Promise((resolve) => {
|
|
182
|
+
timer = setTimeout(() => resolve(null), remainingMs);
|
|
183
|
+
})
|
|
184
|
+
]).finally(() => clearTimeout(timer));
|
|
185
|
+
if (!isRecord(observed) || Array.isArray(observed.warnings) && observed.warnings.length > 0) return null;
|
|
186
|
+
const visible = isRecord(observed.observations) ? observed.observations["ui.visible"] : void 0;
|
|
187
|
+
if (!isRecord(visible) || !Array.isArray(visible.items) || visible.truncated === true) return null;
|
|
188
|
+
const nativeVisible = visible.items.some((item) => isRecord(item) && item.test_id === testId && item.visible !== false);
|
|
189
|
+
return nativeVisible ? null : { hiddenRoute: true, nativeVisible: false };
|
|
190
|
+
}
|
|
112
191
|
async function waitForMobileTarget(input, payload) {
|
|
113
192
|
const timeoutMs = Number(payload.timeout_ms ?? payload.timeoutMs ?? 1e4);
|
|
114
193
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -162,8 +241,22 @@ async function waitForMobileTarget(input, payload) {
|
|
|
162
241
|
);
|
|
163
242
|
}
|
|
164
243
|
lastResult = result;
|
|
165
|
-
|
|
166
|
-
|
|
244
|
+
let present = result.present === true;
|
|
245
|
+
let visible = result.visible === true;
|
|
246
|
+
let cachedRoute = null;
|
|
247
|
+
if (expectsAbsent && present && testId && visibility === "tree") {
|
|
248
|
+
try {
|
|
249
|
+
cachedRoute = await hiddenRouteOnly(input, probeInput, payload, testId, deadline);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (!isRetryableMobileWaitError(error)) throw error;
|
|
252
|
+
lastBridgeError = error;
|
|
253
|
+
}
|
|
254
|
+
if (cachedRoute) {
|
|
255
|
+
present = false;
|
|
256
|
+
visible = false;
|
|
257
|
+
lastResult = { ...result, ...cachedRoute };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
167
260
|
let textMatched = !expectedText || textMatch === "contains";
|
|
168
261
|
if (expectedText && textMatch === "exact" && textExpression) {
|
|
169
262
|
try {
|
|
@@ -186,6 +279,7 @@ async function waitForMobileTarget(input, payload) {
|
|
|
186
279
|
present,
|
|
187
280
|
visible,
|
|
188
281
|
visibility,
|
|
282
|
+
...cachedRoute ?? {},
|
|
189
283
|
...isRecord(result.rect) ? { rect: result.rect } : {}
|
|
190
284
|
};
|
|
191
285
|
}
|
|
@@ -1,35 +1,50 @@
|
|
|
1
|
+
const ORDER_INPUTS = [
|
|
2
|
+
[["market", "symbol"], "market=<symbol> (or symbol=<symbol>)"],
|
|
3
|
+
[["side"], "side=long or side=short"],
|
|
4
|
+
[["amount", "notional"], "amount=<usd> or notional=<usd>"]
|
|
5
|
+
];
|
|
6
|
+
function isRecord(value) {
|
|
7
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
function opensState(value) {
|
|
10
|
+
const state = typeof value === "string" ? value.toLowerCase() : void 0;
|
|
11
|
+
return state === "open" || state === "present";
|
|
12
|
+
}
|
|
13
|
+
function hasValue(source, names) {
|
|
14
|
+
return names.some((name) => {
|
|
15
|
+
const value = source[name];
|
|
16
|
+
return value !== void 0 && value !== null && value !== "";
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
function missingOrderInputs(label, has, pathFor) {
|
|
20
|
+
return ORDER_INPUTS.filter(([names]) => !has(names)).map(([names, wanted]) => ({
|
|
21
|
+
severity: "error",
|
|
22
|
+
code: "recipe.missing_param",
|
|
23
|
+
path: pathFor(names[0]),
|
|
24
|
+
message: `${label} requires ${wanted} before it may create an order`
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
1
27
|
function perpsOrderInputFindings(nodeId, node, { ensureChecksOnly }) {
|
|
2
28
|
const action = node.action;
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
29
|
+
if (action === "metamask.perps.start_state") {
|
|
30
|
+
if (ensureChecksOnly) return [];
|
|
31
|
+
return ["positions", "orders"].flatMap((key) => {
|
|
32
|
+
const block = node[key];
|
|
33
|
+
if (!isRecord(block) || !opensState(block.state)) return [];
|
|
34
|
+
return missingOrderInputs(
|
|
35
|
+
`${action} ${key}.state=${String(block.state)}`,
|
|
36
|
+
(names) => hasValue(block, names) || names[0] !== "amount" && hasValue(node, names),
|
|
37
|
+
(name) => `workflow.nodes.${nodeId}.${key}.${name}`
|
|
38
|
+
);
|
|
11
39
|
});
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
};
|
|
20
|
-
requireOneOf(
|
|
21
|
-
["market", "symbol"],
|
|
22
|
-
`${action} requires market=<symbol> (or symbol=<symbol>) before it may create an order`
|
|
23
|
-
);
|
|
24
|
-
requireOneOf(
|
|
25
|
-
["side"],
|
|
26
|
-
`${action} requires side=long or side=short before it may create an order`
|
|
27
|
-
);
|
|
28
|
-
requireOneOf(
|
|
29
|
-
["amount", "notional"],
|
|
30
|
-
`${action} requires amount=<usd> or notional=<usd> before it may create an order`
|
|
40
|
+
}
|
|
41
|
+
const createsOrder = action === "metamask.perps.place_order" || !ensureChecksOnly && (action === "metamask.perps.ensure_positions" || action === "metamask.perps.ensure_orders") && opensState(node.state);
|
|
42
|
+
if (!createsOrder) return [];
|
|
43
|
+
return missingOrderInputs(
|
|
44
|
+
String(action),
|
|
45
|
+
(names) => hasValue(node, names),
|
|
46
|
+
(name) => `workflow.nodes.${nodeId}.${name}`
|
|
31
47
|
);
|
|
32
|
-
return findings;
|
|
33
48
|
}
|
|
34
49
|
export {
|
|
35
50
|
perpsOrderInputFindings
|
package/dist/adapters.js
CHANGED
|
@@ -118,7 +118,10 @@ async function runLiveFirst(platform, action, node, context, forceLive = false,
|
|
|
118
118
|
platform,
|
|
119
119
|
action,
|
|
120
120
|
namespace: LIBRARY_ACTION_NAMESPACE,
|
|
121
|
-
node: withImmediateCloseBudget(
|
|
121
|
+
node: withImmediateCloseBudget(
|
|
122
|
+
action,
|
|
123
|
+
await getAdapterSurface(platform).actions.liveProcessNode?.(action, node, { ...process.env, ...context.env }) ?? node
|
|
124
|
+
),
|
|
122
125
|
context,
|
|
123
126
|
prepared: preparedLiveAdapters?.get(action),
|
|
124
127
|
contextExtras: trustedMutationContextExtras(context),
|
package/dist/commands/doctor.js
CHANGED
|
@@ -85,11 +85,14 @@ async function handleDoctor({ options }) {
|
|
|
85
85
|
} catch (error2) {
|
|
86
86
|
runtimeProbeError2 = error2 instanceof Error ? error2.message : String(error2);
|
|
87
87
|
}
|
|
88
|
-
const
|
|
88
|
+
const recorder2 = runtime2?.decision === "ready" ? await readiness.recorder?.() : void 0;
|
|
89
|
+
const ready2 = doctorReady(runtime2, recorder2);
|
|
89
90
|
const block = readiness.runtimeBlock?.(target, runtime2);
|
|
90
91
|
if (block) failed.push(block.id);
|
|
92
|
+
if (recorder2?.status === "fail") failed.push(recorder2.id);
|
|
91
93
|
const nextActions = doctorFixNextActions(failed, result2.fixture, adapter, target, block);
|
|
92
|
-
if (
|
|
94
|
+
if (recorder2?.status === "fail" && recorder2.userAction) nextActions.push(recorder2.userAction);
|
|
95
|
+
if (failed.length === 0 && runtime2?.decision !== "ready" && runtime2?.nextAction) nextActions.push(runtime2.nextAction);
|
|
93
96
|
const status = result2.status === "pass" && failed.length === 0 ? "pass" : "fail";
|
|
94
97
|
const retryDoctor = `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
|
|
95
98
|
const fixUserAction = failed.includes("wallet-fixture") ? `choose one wallet fixture source from nextActions, run it, then retry: ${retryDoctor}` : nextActions[0] ?? retryDoctor;
|
|
@@ -98,15 +101,16 @@ async function handleDoctor({ options }) {
|
|
|
98
101
|
message: "doctor --fix could not make every required check pass",
|
|
99
102
|
userAction: fixUserAction
|
|
100
103
|
} : void 0;
|
|
101
|
-
if (json) console.log(JSON.stringify({ ...result2, status, fixed, failed, ready, runtime: runtime2, ...runtimeProbeError2 ? { runtimeProbeError: runtimeProbeError2 } : {}, nextActions, ...error ? { error } : {} }, null, 2));
|
|
104
|
+
if (json) console.log(JSON.stringify({ ...result2, status, fixed, failed, ready: ready2, runtime: runtime2, ...recorder2 ? { recorder: recorder2 } : {}, ...runtimeProbeError2 ? { runtimeProbeError: runtimeProbeError2 } : {}, nextActions, ...error ? { error } : {} }, null, 2));
|
|
102
105
|
else {
|
|
103
|
-
console.log(`${status} ${adapter} ${result2.compatibilityMode} ready=${
|
|
106
|
+
console.log(`${status} ${adapter} ${result2.compatibilityMode} ready=${ready2} manifest=${actionManifestPath} fixed=[${fixed.join(",")}] failed=[${failed.join(",")}]`);
|
|
104
107
|
if (runtime2) {
|
|
105
108
|
console.log(`runtime: decision=${runtime2.decision}${runtime2.reasonCode ? ` (${runtime2.reasonCode})` : ""}`);
|
|
106
109
|
for (const reason of runtime2.reasons) console.log(` ${reason}`);
|
|
107
110
|
} else if (runtimeProbeError2) {
|
|
108
111
|
console.log(`runtime probe failed: ${runtimeProbeError2}`);
|
|
109
112
|
}
|
|
113
|
+
if (recorder2) console.log(`recorder: ${recorder2.status} ${recorder2.message}`);
|
|
110
114
|
for (const action of nextActions) console.error(` Next: ${action}`);
|
|
111
115
|
}
|
|
112
116
|
return status === "pass" ? 0 : 1;
|
|
@@ -154,10 +158,12 @@ async function handleDoctor({ options }) {
|
|
|
154
158
|
runtimeProbeError = error instanceof Error ? error.message : String(error);
|
|
155
159
|
}
|
|
156
160
|
const runtimeCheck = doctorRuntimeCheck(runtime, runtimeProbeError, expectLive);
|
|
161
|
+
const recorder = runtime?.decision === "ready" ? await readiness.recorder?.() : void 0;
|
|
157
162
|
const checks = [
|
|
158
163
|
...result.checks,
|
|
159
164
|
runtimeCheck,
|
|
160
165
|
...await readiness.liveChecks?.(target) ?? [],
|
|
166
|
+
...recorder ? [recorder] : [],
|
|
161
167
|
...await platformDoctorChecks(adapter, target),
|
|
162
168
|
// Which library plugin code this process loaded: library, module and digest.
|
|
163
169
|
...adapterPluginChecks()
|
|
@@ -182,6 +188,7 @@ async function handleDoctor({ options }) {
|
|
|
182
188
|
adapter,
|
|
183
189
|
target,
|
|
184
190
|
runtime,
|
|
191
|
+
recorder,
|
|
185
192
|
doctorResult,
|
|
186
193
|
orphanMetros,
|
|
187
194
|
capture,
|
|
@@ -201,7 +208,8 @@ async function handleDoctor({ options }) {
|
|
|
201
208
|
)?.userAction ?? `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
|
|
202
209
|
const doctorError = doctorResult.status === "fail" ? { code: "DOCTOR_CHECKS_FAILED", message: "one or more required doctor checks failed", userAction: doctorUserAction } : void 0;
|
|
203
210
|
const next = doctorError?.userAction ?? discovery.deviceDiscoveryErrors?.[0]?.userAction ?? (runtime?.decision === "ready" ? void 0 : runtime?.nextAction);
|
|
204
|
-
|
|
211
|
+
const ready = doctorReady(runtime, recorder);
|
|
212
|
+
if (json) console.log(JSON.stringify({ ...doctorResult, ready, runtime, ...recorder ? { recorder } : {}, view, orphanMetros, capture, skills, update, featureFlags, devices, additionalReachableDevices, ...discovery, ...next ? { next } : {}, ...doctorError ? { error: doctorError } : {} }, null, 2));
|
|
205
213
|
else {
|
|
206
214
|
const out = (style, text) => color(style, text, { stream: process.stdout });
|
|
207
215
|
const stateStyle = (value, good) => value === good ? "ok" : "warn";
|
|
@@ -222,6 +230,7 @@ async function handleDoctor({ options }) {
|
|
|
222
230
|
} else if (runtimeProbeError) {
|
|
223
231
|
console.log(`${out("err", "runtime probe failed:")} ${out("dim", runtimeProbeError)}`);
|
|
224
232
|
}
|
|
233
|
+
if (recorder) console.log(`${out("label", "recorder:")} ${out(recorder.status === "pass" ? "ok" : "err", recorder.status)} ${recorder.message}`);
|
|
225
234
|
console.log(renderCheckoutView(view));
|
|
226
235
|
console.log(`${out("label", "feature flags:")} ${out(
|
|
227
236
|
featureFlags.error || (featureFlags.overrideCount ?? 0) > 0 ? "warn" : "dim",
|
|
@@ -246,6 +255,9 @@ async function handleDoctor({ options }) {
|
|
|
246
255
|
}
|
|
247
256
|
return doctorResult.status === "pass" ? 0 : 1;
|
|
248
257
|
}
|
|
258
|
+
function doctorReady(runtime, recorder) {
|
|
259
|
+
return runtime?.decision === "ready" && recorder?.status !== "fail";
|
|
260
|
+
}
|
|
249
261
|
async function platformDoctorChecks(adapter, target) {
|
|
250
262
|
try {
|
|
251
263
|
return await getAdapterSurface(adapter).doctor?.(target) ?? [];
|
|
@@ -297,17 +309,23 @@ function doctorFixNextActions(failed, fixture, adapter, target, block) {
|
|
|
297
309
|
if (block?.userAction && failed.includes(block.id)) actions.push(block.userAction);
|
|
298
310
|
return actions;
|
|
299
311
|
}
|
|
300
|
-
function emitExpectLiveVerdict(adapter, target, runtime, verdict) {
|
|
312
|
+
function emitExpectLiveVerdict(adapter, target, runtime, verdict, recorder) {
|
|
301
313
|
const out = (style, text) => color(style, text, { stream: process.stderr });
|
|
302
314
|
const detail = runtime ? `${runtime.decision}${runtime.reasonCode ? ` (${runtime.reasonCode})` : ""}` : "runtime probe unavailable";
|
|
303
315
|
console.error(`${out("err", verdict)} ${out("bold", adapter)} ${detail} ${out("dim", target)}`);
|
|
304
316
|
for (const reason of runtime?.reasons ?? []) console.error(` ${out("dim", reason)}`);
|
|
317
|
+
if (recorder?.status === "fail") {
|
|
318
|
+
console.error(` ${out("err", "recorder:")} ${recorder.message}${recorder.detail ? ` ${out("dim", recorder.detail)}` : ""}`);
|
|
319
|
+
console.error(` Next: ${recorder.userAction}`);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
305
322
|
console.error(` Next: ${runtime?.nextAction ?? getAdapterSurface(adapter).hints.runtimeProbeRecovery(target)}`);
|
|
306
323
|
}
|
|
307
|
-
function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture, devices, additionalReachableDevices, json, printReady, discovery) {
|
|
324
|
+
function emitExpectLive(adapter, target, runtime, recorder, result, orphanMetros, capture, devices, additionalReachableDevices, json, printReady, discovery) {
|
|
308
325
|
const live = runtime?.decision === "ready";
|
|
326
|
+
const ready = doctorReady(runtime, recorder);
|
|
309
327
|
if (printReady) {
|
|
310
|
-
if (
|
|
328
|
+
if (ready) {
|
|
311
329
|
const indicator = farmslotReadyIndicator(adapter, devices);
|
|
312
330
|
if (indicator) {
|
|
313
331
|
console.log(indicator);
|
|
@@ -316,21 +334,21 @@ function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture,
|
|
|
316
334
|
emitExpectLiveVerdict(adapter, target, runtime, "not-ready");
|
|
317
335
|
return EXIT.runtime;
|
|
318
336
|
}
|
|
319
|
-
emitExpectLiveVerdict(adapter, target, runtime, "not-live");
|
|
337
|
+
emitExpectLiveVerdict(adapter, target, runtime, live ? "not-ready" : "not-live", recorder);
|
|
320
338
|
return EXIT.runtime;
|
|
321
339
|
}
|
|
322
340
|
if (json) {
|
|
323
341
|
const userAction = runtime?.nextAction ?? getAdapterSurface(adapter).hints.runtimeProbeRecovery(target);
|
|
324
|
-
const error = live ?
|
|
325
|
-
console.log(JSON.stringify({ ...result, ready
|
|
326
|
-
return
|
|
342
|
+
const error = !live ? { code: "RUNTIME_NOT_LIVE", message: `${adapter} runtime is not live`, userAction } : !ready ? { code: "RECORDER_UNAVAILABLE", message: recorder.message, userAction: recorder.userAction } : void 0;
|
|
343
|
+
console.log(JSON.stringify({ ...result, ready, runtime, ...recorder ? { recorder } : {}, orphanMetros, capture, devices, additionalReachableDevices, ...discovery, ...error ? { error } : {} }, null, 2));
|
|
344
|
+
return ready ? EXIT.ok : EXIT.runtime;
|
|
327
345
|
}
|
|
328
346
|
const out = (style, text) => color(style, text, { stream: process.stdout });
|
|
329
|
-
if (
|
|
347
|
+
if (ready) {
|
|
330
348
|
console.log(`${out("ok", "live")} ${out("bold", adapter)} runtime ready`);
|
|
331
349
|
return EXIT.ok;
|
|
332
350
|
}
|
|
333
|
-
emitExpectLiveVerdict(adapter, target, runtime, "not-live");
|
|
351
|
+
emitExpectLiveVerdict(adapter, target, runtime, live ? "not-ready" : "not-live", recorder);
|
|
334
352
|
return EXIT.runtime;
|
|
335
353
|
}
|
|
336
354
|
function captureHealth(providers) {
|
|
@@ -457,6 +475,7 @@ async function runDoctorFix(adapter, target, manifestValidation, json) {
|
|
|
457
475
|
}
|
|
458
476
|
export {
|
|
459
477
|
doctorFixNextActions,
|
|
478
|
+
doctorReady,
|
|
460
479
|
handleDoctor,
|
|
461
480
|
skillsView
|
|
462
481
|
};
|
|
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import { fixtureSummary } from "../doctor.js";
|
|
4
4
|
import { normalizeFeatureFlagRecord } from "../feature-flags.js";
|
|
5
5
|
import { mobileBridgeScript } from "../adapters/mobile/bridge-script.js";
|
|
6
|
+
import { isAndroidEmulatorSerial } from "../../library/actions/mobile/platform/bridge.mjs";
|
|
6
7
|
const PROBE_TIMEOUT_MS = 2e3;
|
|
7
8
|
function truncateAddress(address) {
|
|
8
9
|
if (!address || address.length <= 10) return address;
|
|
@@ -51,7 +52,7 @@ function parseBridgeEntries(raw) {
|
|
|
51
52
|
function androidModelMatches(deviceName, model, device, devices) {
|
|
52
53
|
if (deviceName === model || deviceName.startsWith(`${model} -`)) return true;
|
|
53
54
|
if (!deviceName.endsWith(` ${model}`) && !deviceName.includes(` ${model} -`)) return false;
|
|
54
|
-
const physical = (d) => d.platform === "android" && !d.id
|
|
55
|
+
const physical = (d) => d.platform === "android" && !isAndroidEmulatorSerial(d.id);
|
|
55
56
|
if (!physical(device)) return false;
|
|
56
57
|
return devices.filter((d) => physical(d) && d.name === device.name).length === 1;
|
|
57
58
|
}
|
package/docs/RECIPES.md
CHANGED
|
@@ -279,6 +279,18 @@ Perps orders follow one rule on Mobile, Extension and Core
|
|
|
279
279
|
sent or closed.
|
|
280
280
|
- Core still needs `CORE_PERPS_ALLOW_MAINNET_WRITES=1` for any mainnet write.
|
|
281
281
|
|
|
282
|
+
A Hyperliquid `429 Too Many Requests` fails as `RATE_LIMITED`, naming the call,
|
|
283
|
+
the attempts and the backoff (`library/actions/shared/perps/rate-limit.mjs`).
|
|
284
|
+
Reads retry up to 3 times with exponential backoff and jitter inside the action
|
|
285
|
+
deadline; then wait a minute and rerun. Extension background placement retries
|
|
286
|
+
only when the dispatch itself got the 429 and Hyperliquid's info API then shows
|
|
287
|
+
no open order, no fill and (market orders) no position on the order's side in
|
|
288
|
+
that market since the first dispatch. The receipt records every dispatch 429
|
|
289
|
+
under `rateLimitedDispatches`, with that check or the reason it did not retry;
|
|
290
|
+
without a retry it stays `outcome-unknown`: inspect the market before any rerun.
|
|
291
|
+
A 429 after dispatch is never redispatched, and Mobile placements are not
|
|
292
|
+
retried.
|
|
293
|
+
|
|
282
294
|
On the Extension, a run without funding files gets that rule through the
|
|
283
295
|
fixture-policy lane: the runner binds the run and a run-owned execution ledger
|
|
284
296
|
(`<artifacts>/mutation-ledger`), and each mutation completes the context from
|
|
@@ -417,6 +429,56 @@ For visual PR proof, prefer a short simulator recording or GIF over still
|
|
|
417
429
|
screenshots when the claim is a UI flow (timing / transitions). Use screenshots
|
|
418
430
|
as supporting stills.
|
|
419
431
|
|
|
432
|
+
`--record-video=full-run` on a physical Android device records on the device
|
|
433
|
+
with `adb shell screenrecord`, so no other host screen-capture client can stop
|
|
434
|
+
it. screenrecord ends each file at 180 s; the harness starts the next one at
|
|
435
|
+
once and joins them with ffmpeg (required, or set `FFMPEG_PATH`). Each restart
|
|
436
|
+
leaves a gap of about half a second, which the video holds on the last frame
|
|
437
|
+
so video time keeps to wall-clock time. `videos/recipe-run.mp4.capture.json`
|
|
438
|
+
lists the files and their offsets. Screens the app marks secure (FLAG_SECURE)
|
|
439
|
+
record black. An emulator (`emulator-*`) still records its scrcpy window
|
|
440
|
+
through capture-helper, falling back to one screenrecord file when the mirror
|
|
441
|
+
is unavailable. Set `METAMASK_RECIPE_MOBILE_ANDROID_RECORDER` to
|
|
442
|
+
`screenrecord` or `mirror` to force either.
|
|
443
|
+
|
|
444
|
+
A recording that fails fails the run: the `record.video` trace entry starts
|
|
445
|
+
with `RECORDING_FAILED` or `RECORDER_INTERRUPTED`, names the cause, and says the
|
|
446
|
+
run has no video. When capture stopped mid-run but a finalized partial file was
|
|
447
|
+
kept (capture-helper 0.3.1's `stream_interrupted`, or the screenrecord files
|
|
448
|
+
finished before a failure), the video is published and its manifest entry's
|
|
449
|
+
`recorder.interrupted` gives the code, the cause and the video time up to the
|
|
450
|
+
last captured frame (`coveredMs`). `mm-harness doctor` shows the recorder for
|
|
451
|
+
the selected device and is not ready while it cannot record.
|
|
452
|
+
|
|
453
|
+
### Mobile setup and command budgets
|
|
454
|
+
|
|
455
|
+
`metamask.perps.start_state` and `teardown_state` have three budgets:
|
|
456
|
+
|
|
457
|
+
| Budget | Node field | Emulator / simulator | Physical Android | Env override |
|
|
458
|
+
|---|---|---|---|---|
|
|
459
|
+
| Whole setup | `timeout_ms` | 30 s | 60 s | `MM_HARNESS_MOBILE_SETUP_TIMEOUT_MS` |
|
|
460
|
+
| Each navigation phase | `navigation_timeout_ms` | 15 s | 30 s | `MM_HARNESS_MOBILE_NAVIGATION_TIMEOUT_MS` |
|
|
461
|
+
| Each bridge command | `cdp_timeout_ms` | 15 s | 15 s | none |
|
|
462
|
+
|
|
463
|
+
A device counts as physical when the run has an explicit adb serial (`--device <serial>`
|
|
464
|
+
or `ADB_SERIAL`) that is not `emulator-*`. Node fields win over the env overrides.
|
|
465
|
+
Each phase (unlock, account, navigation, provider, network, cleanup) runs on the
|
|
466
|
+
rest of the setup budget, or on its slice for navigation, and each bridge command
|
|
467
|
+
inside it is capped by that phase deadline. `navigate` and Perps market/home
|
|
468
|
+
navigation use the same navigation default outside setups.
|
|
469
|
+
|
|
470
|
+
Errors say which budget ran out:
|
|
471
|
+
|
|
472
|
+
- `DOMAIN_SETUP_DEADLINE`: a setup phase ran out. The message names the action and
|
|
473
|
+
phase, the time it ran against its limit, and the setup's elapsed time and
|
|
474
|
+
limit. Raise `timeout_ms` or `navigation_timeout_ms`.
|
|
475
|
+
- `ACTION_DEADLINE`: the calling action's deadline refused or stopped a bridge
|
|
476
|
+
command. Route and mode waits treat it as "no answer yet" and end with their
|
|
477
|
+
own timeout message.
|
|
478
|
+
- `CDP_TIMEOUT` with `CDP_COMMAND_TIMEOUT` in the message: one bridge command hit
|
|
479
|
+
its own limit while the action still had time. That points at the bridge or app,
|
|
480
|
+
not the budget.
|
|
481
|
+
|
|
420
482
|
### Perps loading lifecycle validation
|
|
421
483
|
|
|
422
484
|
The Mobile loading recipe shares one graph across Android and iOS. It requires
|
|
@@ -6,13 +6,15 @@ import {
|
|
|
6
6
|
redactAccount,
|
|
7
7
|
runAdapter,
|
|
8
8
|
} from './_controller.mjs';
|
|
9
|
+
import { liveAdapterDeadline } from '../../shared/app/live-adapter-deadline.mjs';
|
|
10
|
+
import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
|
|
9
11
|
|
|
10
12
|
export async function readAccount(input) {
|
|
11
13
|
const { controller, accountAddress, network } = await getCoreController(input);
|
|
12
|
-
const account = await controller.getAccountState({
|
|
14
|
+
const account = await withRateLimitRetry('PerpsController.getAccountState', () => controller.getAccountState({
|
|
13
15
|
standalone: true,
|
|
14
16
|
userAddress: accountAddress,
|
|
15
|
-
});
|
|
17
|
+
}), { deadline: liveAdapterDeadline(input.node) });
|
|
16
18
|
return {
|
|
17
19
|
action: input.action,
|
|
18
20
|
source: 'perps-controller-standalone',
|
|
@@ -7,13 +7,15 @@ import {
|
|
|
7
7
|
runAdapter,
|
|
8
8
|
selectedItems,
|
|
9
9
|
} from './_controller.mjs';
|
|
10
|
+
import { liveAdapterDeadline } from '../../shared/app/live-adapter-deadline.mjs';
|
|
11
|
+
import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
|
|
10
12
|
|
|
11
13
|
export async function readOrders(input) {
|
|
12
14
|
const { controller, accountAddress, network } = await getCoreController(input);
|
|
13
|
-
const orders = await controller.getOpenOrders({
|
|
15
|
+
const orders = await withRateLimitRetry('PerpsController.getOpenOrders', () => controller.getOpenOrders({
|
|
14
16
|
standalone: true,
|
|
15
17
|
userAddress: accountAddress,
|
|
16
|
-
});
|
|
18
|
+
}), { deadline: liveAdapterDeadline(input.node) });
|
|
17
19
|
const matching = selectedItems(input, orders);
|
|
18
20
|
return {
|
|
19
21
|
action: input.action,
|
|
@@ -7,13 +7,15 @@ import {
|
|
|
7
7
|
runAdapter,
|
|
8
8
|
selectedItems,
|
|
9
9
|
} from './_controller.mjs';
|
|
10
|
+
import { liveAdapterDeadline } from '../../shared/app/live-adapter-deadline.mjs';
|
|
11
|
+
import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
|
|
10
12
|
|
|
11
13
|
export async function readPositions(input) {
|
|
12
14
|
const { controller, accountAddress, network } = await getCoreController(input);
|
|
13
|
-
const positions = await controller.getPositions({
|
|
15
|
+
const positions = await withRateLimitRetry('PerpsController.getPositions', () => controller.getPositions({
|
|
14
16
|
standalone: true,
|
|
15
17
|
userAddress: accountAddress,
|
|
16
|
-
});
|
|
18
|
+
}), { deadline: liveAdapterDeadline(input.node) });
|
|
17
19
|
const matching = selectedItems(input, positions, 'position');
|
|
18
20
|
return {
|
|
19
21
|
action: input.action,
|
|
@@ -85,18 +85,23 @@ export function consentControllerExpression({
|
|
|
85
85
|
throw new Error('Extension consent state is unavailable.');
|
|
86
86
|
}
|
|
87
87
|
const metamask = root?.metamask ?? root ?? {};
|
|
88
|
+
// Extension 13.51+ keeps marketing consent in AnalyticsController.optedInToMarketing
|
|
89
|
+
// (migration 228); older builds expose MetaMetricsController.dataCollectionForMarketing.
|
|
90
|
+
const dataCollectionForMarketing = typeof metamask.dataCollectionForMarketing === 'boolean'
|
|
91
|
+
? metamask.dataCollectionForMarketing
|
|
92
|
+
: metamask.optedInToMarketing;
|
|
88
93
|
// Debug UI state can lag an acknowledged setter until the next UI sync.
|
|
89
94
|
// Background/snapshot reads have no such subscription to wait for.
|
|
90
95
|
if ((capturedChrome || !storeState) && (
|
|
91
96
|
typeof metamask.optedIn !== 'boolean' ||
|
|
92
|
-
typeof
|
|
97
|
+
typeof dataCollectionForMarketing !== 'boolean'
|
|
93
98
|
)) {
|
|
94
99
|
throw new Error('Extension consent state did not contain boolean consent fields.');
|
|
95
100
|
}
|
|
96
101
|
return {
|
|
97
102
|
state: {
|
|
98
103
|
optedIn: metamask.optedIn,
|
|
99
|
-
dataCollectionForMarketing
|
|
104
|
+
dataCollectionForMarketing,
|
|
100
105
|
analyticsId: metamask.analyticsId ? 'set' : null
|
|
101
106
|
},
|
|
102
107
|
source: capturedChrome
|
|
@@ -421,10 +421,13 @@ export async function closeVisiblePosition(input) {
|
|
|
421
421
|
if (partialAmount == null && (!confirmation.fullSizeSelected || !confirmation.marketOrder)) {
|
|
422
422
|
throw new Error('Receipt-bound full close requires the visible 100% market-close form.');
|
|
423
423
|
}
|
|
424
|
-
// Without a placement receipt the baseline is the in-position balance, which excludes the
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
424
|
+
// Without a placement receipt the baseline is the in-position balance, which excludes the position's
|
|
425
|
+
// margin. The close returns marginUsed (PnL is already in the balance); the modal receive cross-checks it.
|
|
426
|
+
const closeReleaseBaseUnits = placement ? null : exactBefore.marginBaseUnits;
|
|
427
|
+
const closeModalReceiveBaseUnits = placement ? null : parseUsdBaseUnits(confirmation.receive);
|
|
428
|
+
if (closeModalReceiveBaseUnits != null
|
|
429
|
+
&& BigInt(absoluteDelta(closeModalReceiveBaseUnits, closeReleaseBaseUnits)) > BigInt(balanceToleranceBaseUnits)) {
|
|
430
|
+
throw new Error('Visible close receive amount disagrees with the position margin beyond the reconciliation tolerance.');
|
|
428
431
|
}
|
|
429
432
|
const closeSizing = partialAmount == null ? { size: sizeMatch[1], side: fundingSide === 'long' ? 'short' : 'long' } : null;
|
|
430
433
|
let submissionObserver = null;
|
|
@@ -440,7 +443,8 @@ export async function closeVisiblePosition(input) {
|
|
|
440
443
|
cleanupOnly,
|
|
441
444
|
fundingBalance,
|
|
442
445
|
tradingContext,
|
|
443
|
-
...(closeReleaseBaseUnits != null ? { closeReleaseBaseUnits } : {}),
|
|
446
|
+
...(closeReleaseBaseUnits != null ? { closeReleaseBaseUnits, closeReleaseSource: 'position-margin' } : {}),
|
|
447
|
+
...(closeModalReceiveBaseUnits != null ? { closeModalReceiveBaseUnits } : {}),
|
|
444
448
|
...(acceptedFill ? { acceptedFill } : {}),
|
|
445
449
|
...(closeSizing ? { closeSizing } : {}),
|
|
446
450
|
},
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Fetch production Hyperliquid data and compare it with the opened market's displayed values.
|
|
2
2
|
// Also check the funding countdown and nonblank chart canvases.
|
|
3
3
|
import { dataTestId, marketSymbol, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
4
|
+
import { liveAdapterDeadline } from '../../shared/app/live-adapter-deadline.mjs';
|
|
5
|
+
import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
|
|
4
6
|
|
|
5
7
|
function numeric(value) {
|
|
6
8
|
const match = String(value ?? '').replace(/[$,]/gu, '').match(/-?[0-9]+(?:\.[0-9]+)?/u);
|
|
@@ -63,14 +65,16 @@ runAdapter(async (input) => {
|
|
|
63
65
|
if (endpoint !== 'https://api.hyperliquid.xyz/info') {
|
|
64
66
|
throw new Error('metamask.perps.compare_provider_market endpoint must be the Hyperliquid production info API.');
|
|
65
67
|
}
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
const payload = await withRateLimitRetry('Hyperliquid metaAndAssetCtxs', async () => {
|
|
69
|
+
const response = await fetch(endpoint, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: { 'content-type': 'application/json' },
|
|
72
|
+
body: JSON.stringify({ type: 'metaAndAssetCtxs' }),
|
|
73
|
+
signal: AbortSignal.timeout(Number(input.node?.timeout_ms ?? 15_000)),
|
|
74
|
+
});
|
|
75
|
+
if (!response.ok) throw new Error(`Hyperliquid market request failed with HTTP ${response.status}.`);
|
|
76
|
+
return response.json();
|
|
77
|
+
}, { deadline: liveAdapterDeadline(input.node) });
|
|
74
78
|
const universe = payload?.[0]?.universe;
|
|
75
79
|
const contexts = payload?.[1];
|
|
76
80
|
const index = Array.isArray(universe) ? universe.findIndex((entry) => entry?.name === market) : -1;
|