@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
|
@@ -29,6 +29,13 @@ const { resolvePort } = configModule;
|
|
|
29
29
|
// second import path into the cjs bridge-runtime.
|
|
30
30
|
export const MOBILE_BRIDGE_ERROR_CODES = BRIDGE_ERROR_CODES;
|
|
31
31
|
export const MOBILE_BRIDGE_RESULT_ERROR_CODES = BRIDGE_RESULT_ERROR_CODES;
|
|
32
|
+
// Budget exhaustion, kept apart from the bridge's CDP_TIMEOUT: ACTION_DEADLINE
|
|
33
|
+
// is a bridge command its caller's deadline refused or cut short;
|
|
34
|
+
// DOMAIN_SETUP_DEADLINE is a setup phase that ran out of its slice.
|
|
35
|
+
export const MOBILE_BUDGET_ERROR_CODES = Object.freeze({
|
|
36
|
+
ACTION_DEADLINE: 'ACTION_DEADLINE',
|
|
37
|
+
DOMAIN_SETUP_DEADLINE: 'DOMAIN_SETUP_DEADLINE',
|
|
38
|
+
});
|
|
32
39
|
|
|
33
40
|
const execFileAsync = promisify(execFile);
|
|
34
41
|
const ACTION_DEADLINE_FIELD = '_action_deadline_epoch_ms';
|
|
@@ -256,7 +263,11 @@ export function resolveMobileTarget(input) {
|
|
|
256
263
|
const iosSimulator = input.node?.simulator ?? input.node?.ios_simulator ?? contextEnv.IOS_SIMULATOR ?? process.env.IOS_SIMULATOR;
|
|
257
264
|
const androidDevice = input.node?.android_device ?? contextEnv.ANDROID_DEVICE ?? process.env.ANDROID_DEVICE;
|
|
258
265
|
const androidTargetDeviceName = input.node?.android_target_device_name ?? input.node?.androidTargetDeviceName ?? contextEnv.ANDROID_TARGET_DEVICE_NAME ?? process.env.ANDROID_TARGET_DEVICE_NAME;
|
|
259
|
-
const
|
|
266
|
+
const explicitAdbSerial = input.node?.adb_serial ?? contextEnv.ADB_SERIAL ?? contextEnv.ANDROID_SERIAL ?? process.env.ADB_SERIAL ?? process.env.ANDROID_SERIAL;
|
|
267
|
+
const adbSerial = explicitAdbSerial ?? androidDevice;
|
|
268
|
+
// Only an explicit adb serial says which device class answers: ANDROID_DEVICE
|
|
269
|
+
// alone can be an AVD or Metro device name.
|
|
270
|
+
const physicalAndroid = Boolean(explicitAdbSerial) && !isAndroidEmulatorSerial(explicitAdbSerial);
|
|
260
271
|
if (requestedPlatform === 'ios') {
|
|
261
272
|
return {
|
|
262
273
|
watcherPort,
|
|
@@ -264,6 +275,7 @@ export function resolveMobileTarget(input) {
|
|
|
264
275
|
androidDevice: undefined,
|
|
265
276
|
androidTargetDeviceName: undefined,
|
|
266
277
|
adbSerial: undefined,
|
|
278
|
+
physicalAndroid: false,
|
|
267
279
|
requestedPlatform,
|
|
268
280
|
};
|
|
269
281
|
}
|
|
@@ -274,6 +286,7 @@ export function resolveMobileTarget(input) {
|
|
|
274
286
|
androidDevice,
|
|
275
287
|
androidTargetDeviceName,
|
|
276
288
|
adbSerial,
|
|
289
|
+
physicalAndroid,
|
|
277
290
|
requestedPlatform,
|
|
278
291
|
};
|
|
279
292
|
}
|
|
@@ -283,10 +296,15 @@ export function resolveMobileTarget(input) {
|
|
|
283
296
|
androidDevice,
|
|
284
297
|
androidTargetDeviceName,
|
|
285
298
|
adbSerial,
|
|
299
|
+
physicalAndroid,
|
|
286
300
|
requestedPlatform,
|
|
287
301
|
};
|
|
288
302
|
}
|
|
289
303
|
|
|
304
|
+
export function isAndroidEmulatorSerial(serial) {
|
|
305
|
+
return String(serial ?? '').startsWith('emulator-');
|
|
306
|
+
}
|
|
307
|
+
|
|
290
308
|
export async function openExternalDeeplink(input, url, timeout = 15000) {
|
|
291
309
|
const target = resolveMobileTarget(input);
|
|
292
310
|
const requestedPlatform = String(
|
|
@@ -470,17 +488,17 @@ export async function bridgeCommand(input, args) {
|
|
|
470
488
|
? actionDeadline - Date.now()
|
|
471
489
|
: configuredTimeoutMs;
|
|
472
490
|
if (remainingActionMs <= 0) {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
`Mobile CDP bridge command did not start before its action deadline: ${String(args[0] ?? 'unknown')}`,
|
|
476
|
-
),
|
|
477
|
-
BRIDGE_ERROR_CODES.CDP_TIMEOUT,
|
|
491
|
+
const error = new Error(
|
|
492
|
+
`ACTION_DEADLINE: bridge command ${String(args[0] ?? 'unknown')} was not started; its action deadline passed ${Math.round(-remainingActionMs)}ms ago (per-command limit ${configuredTimeoutMs}ms).`,
|
|
478
493
|
);
|
|
494
|
+
error.deadlineEpochMs = actionDeadline;
|
|
495
|
+
throw coded(error, MOBILE_BUDGET_ERROR_CODES.ACTION_DEADLINE);
|
|
479
496
|
}
|
|
480
497
|
const timeoutMs = Math.max(
|
|
481
498
|
1,
|
|
482
499
|
Math.min(configuredTimeoutMs, remainingActionMs),
|
|
483
500
|
);
|
|
501
|
+
const cutByActionDeadline = timeoutMs < configuredTimeoutMs;
|
|
484
502
|
const result = await new Promise((resolve, reject) => {
|
|
485
503
|
const child = spawn(process.execPath, [script, ...args], {
|
|
486
504
|
cwd: input.context.projectRoot,
|
|
@@ -521,8 +539,20 @@ export async function bridgeCommand(input, args) {
|
|
|
521
539
|
});
|
|
522
540
|
if (result.timedOut) {
|
|
523
541
|
const command = ['node', path.relative(input.context.projectRoot, script), ...redactBridgeArgs(args)].join(' ');
|
|
542
|
+
// A timer the action deadline shortened means the caller's budget ran out,
|
|
543
|
+
// not the bridge: say so, and keep it out of the target-transition codes
|
|
544
|
+
// so route waits stop instead of retrying refused commands.
|
|
545
|
+
if (cutByActionDeadline) {
|
|
546
|
+
const error = new Error(
|
|
547
|
+
`ACTION_DEADLINE: bridge command ${String(args[0] ?? 'unknown')} was stopped after ${result.timeoutMs}ms when its action deadline ended (per-command limit ${configuredTimeoutMs}ms): ${command}`,
|
|
548
|
+
);
|
|
549
|
+
error.deadlineEpochMs = actionDeadline;
|
|
550
|
+
throw coded(error, MOBILE_BUDGET_ERROR_CODES.ACTION_DEADLINE);
|
|
551
|
+
}
|
|
524
552
|
throw coded(
|
|
525
|
-
new Error(
|
|
553
|
+
new Error(
|
|
554
|
+
`Mobile CDP bridge command timed out after ${result.timeoutMs}ms (CDP_COMMAND_TIMEOUT: per-command limit ${configuredTimeoutMs}ms${Number.isFinite(actionDeadline) ? `, action budget had ${Math.max(0, Math.round(actionDeadline - Date.now()))}ms left` : ''}): ${command}`,
|
|
555
|
+
),
|
|
526
556
|
BRIDGE_ERROR_CODES.CDP_TIMEOUT,
|
|
527
557
|
);
|
|
528
558
|
}
|
|
@@ -628,6 +658,13 @@ export function isTargetTransition(error) {
|
|
|
628
658
|
return TARGET_TRANSITION_CODES.has(error?.code);
|
|
629
659
|
}
|
|
630
660
|
|
|
661
|
+
// A read a target transition interrupted, or the action deadline cut short,
|
|
662
|
+
// has no answer yet. Polling callers treat it as "not yet" so their own loop
|
|
663
|
+
// ends at the deadline with its own timeout message.
|
|
664
|
+
export function isUnansweredRead(error) {
|
|
665
|
+
return isTargetTransition(error) || error?.code === MOBILE_BUDGET_ERROR_CODES.ACTION_DEADLINE;
|
|
666
|
+
}
|
|
667
|
+
|
|
631
668
|
function valueContains(actual, expected) {
|
|
632
669
|
if (Array.isArray(expected)) {
|
|
633
670
|
return Array.isArray(actual) &&
|
|
@@ -663,13 +700,152 @@ function routeTransitionProven(previousRoute, currentRoute, expectedRoute, expec
|
|
|
663
700
|
);
|
|
664
701
|
}
|
|
665
702
|
|
|
703
|
+
export function inheritedDeadline(input) {
|
|
704
|
+
const deadline = Number(input.node?.[ACTION_DEADLINE_FIELD]);
|
|
705
|
+
return Number.isFinite(deadline) ? deadline : null;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
export function earliestDeadline(input, deadline) {
|
|
709
|
+
const inherited = inheritedDeadline(input);
|
|
710
|
+
return inherited === null ? deadline : Math.min(deadline, inherited);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// The earlier of now + timeoutMs and the calling action's deadline. Route waits
|
|
714
|
+
// use it because past the action deadline bridgeCommand refuses to start
|
|
715
|
+
// get-route (ACTION_DEADLINE): polling on would only retry that refusal and
|
|
716
|
+
// could never see a route that did arrive.
|
|
717
|
+
export function deadlineFromTimeout(input, timeoutMs) {
|
|
718
|
+
return earliestDeadline(input, Date.now() + Math.max(0, Number(timeoutMs)));
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Default budgets by device class. A physical Android device over USB answers
|
|
722
|
+
// bridge calls in 0.1-0.5 s when idle and slower while the app is busy; a Perps
|
|
723
|
+
// market-details -> home navigation on a Pixel 6a took ~18 s. Emulators and
|
|
724
|
+
// simulators keep the shorter budgets so a real hang there still fails fast.
|
|
725
|
+
const MOBILE_BUDGET_DEFAULTS = Object.freeze({
|
|
726
|
+
virtual: { setupMs: 30_000, navigationMs: 15_000 },
|
|
727
|
+
physical: { setupMs: 60_000, navigationMs: 30_000 },
|
|
728
|
+
});
|
|
729
|
+
// Per bridge command inside a domain setup, so one hung command cannot spend
|
|
730
|
+
// the whole setup budget. Recipes override it with cdp_timeout_ms.
|
|
731
|
+
export const SETUP_COMMAND_TIMEOUT_MS = 15_000;
|
|
732
|
+
|
|
733
|
+
function positiveMs(value) {
|
|
734
|
+
const ms = Number(value);
|
|
735
|
+
return value !== null && value !== '' && Number.isFinite(ms) && ms > 0 ? ms : null;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Env overrides (MM_HARNESS_MOBILE_SETUP_TIMEOUT_MS,
|
|
739
|
+
// MM_HARNESS_MOBILE_NAVIGATION_TIMEOUT_MS) replace the device-class defaults;
|
|
740
|
+
// a node's own timeout_ms / navigation_timeout_ms still wins over both.
|
|
741
|
+
export function mobileBudgetDefaults(input) {
|
|
742
|
+
const env = { ...process.env, ...(input.context?.env || {}) };
|
|
743
|
+
const deviceClass = resolveMobileTarget(input).physicalAndroid ? 'physical' : 'virtual';
|
|
744
|
+
const defaults = MOBILE_BUDGET_DEFAULTS[deviceClass];
|
|
745
|
+
return {
|
|
746
|
+
deviceClass,
|
|
747
|
+
setupMs: positiveMs(env.MM_HARNESS_MOBILE_SETUP_TIMEOUT_MS) ?? defaults.setupMs,
|
|
748
|
+
navigationMs:
|
|
749
|
+
positiveMs(env.MM_HARNESS_MOBILE_NAVIGATION_TIMEOUT_MS) ?? defaults.navigationMs,
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export function mobileNavigationTimeoutMs(input) {
|
|
754
|
+
return positiveMs(input.node?.navigation_timeout_ms) ?? mobileBudgetDefaults(input).navigationMs;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
export function mobileSetupTimeoutMs(input) {
|
|
758
|
+
return positiveMs(input.node?.timeout_ms) ?? mobileBudgetDefaults(input).setupMs;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// One overall budget for a multi-step domain setup (unlock, navigation, state
|
|
762
|
+
// convergence). Each phase runs on its own deadline: the rest of the setup
|
|
763
|
+
// budget, or less when sliceMs bounds it. Each bridge command inside a phase
|
|
764
|
+
// gets the per-command limit, capped by that phase deadline. A phase that runs
|
|
765
|
+
// out of time fails as DOMAIN_SETUP_DEADLINE naming the phase and both limits,
|
|
766
|
+
// not as a CDP timeout of whichever command happened to be running.
|
|
767
|
+
export function createSetupBudget(input, action) {
|
|
768
|
+
const { deviceClass } = mobileBudgetDefaults(input);
|
|
769
|
+
const navigationMs = mobileNavigationTimeoutMs(input);
|
|
770
|
+
const commandMs =
|
|
771
|
+
positiveMs(input.node?.bridge_timeout_ms ?? input.node?.cdp_timeout_ms) ??
|
|
772
|
+
SETUP_COMMAND_TIMEOUT_MS;
|
|
773
|
+
const startedAt = Date.now();
|
|
774
|
+
const deadline = deadlineFromTimeout(input, mobileSetupTimeoutMs(input));
|
|
775
|
+
const limitMs = deadline - startedAt;
|
|
776
|
+
|
|
777
|
+
function deadlineError(phase, phaseStartedAt, phaseLimitMs, sliced, cause) {
|
|
778
|
+
const now = Date.now();
|
|
779
|
+
const budget = {
|
|
780
|
+
action,
|
|
781
|
+
phase,
|
|
782
|
+
deviceClass,
|
|
783
|
+
phaseElapsedMs: now - phaseStartedAt,
|
|
784
|
+
phaseLimitMs,
|
|
785
|
+
setupElapsedMs: now - startedAt,
|
|
786
|
+
setupLimitMs: limitMs,
|
|
787
|
+
};
|
|
788
|
+
const phaseText = cause === null
|
|
789
|
+
? `phase '${phase}' could not start`
|
|
790
|
+
: `phase '${phase}' ran ${budget.phaseElapsedMs}ms of its ${phaseLimitMs}ms ${sliced ? 'slice' : 'share of the setup budget'}`;
|
|
791
|
+
const error = new Error(
|
|
792
|
+
`DOMAIN_SETUP_DEADLINE: ${action} ${phaseText}; setup ran ${budget.setupElapsedMs}ms of ${limitMs}ms (${deviceClass} device). ` +
|
|
793
|
+
`Raise timeout_ms (setup) or navigation_timeout_ms (navigation slice) on the node, or MM_HARNESS_MOBILE_SETUP_TIMEOUT_MS / MM_HARNESS_MOBILE_NAVIGATION_TIMEOUT_MS.` +
|
|
794
|
+
(cause === null ? '' : `\n Cause: ${String(cause?.message ?? cause)}`),
|
|
795
|
+
cause === null ? undefined : { cause },
|
|
796
|
+
);
|
|
797
|
+
error.budget = budget;
|
|
798
|
+
return coded(error, MOBILE_BUDGET_ERROR_CODES.DOMAIN_SETUP_DEADLINE);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
return {
|
|
802
|
+
deadline,
|
|
803
|
+
limitMs,
|
|
804
|
+
navigationMs,
|
|
805
|
+
async phase(name, run, { sliceMs } = {}) {
|
|
806
|
+
const phaseStartedAt = Date.now();
|
|
807
|
+
const sliced = sliceMs !== undefined && phaseStartedAt + sliceMs < deadline;
|
|
808
|
+
const phaseDeadline = sliced ? phaseStartedAt + sliceMs : deadline;
|
|
809
|
+
const phaseLimitMs = phaseDeadline - phaseStartedAt;
|
|
810
|
+
if (phaseLimitMs <= 0) {
|
|
811
|
+
throw deadlineError(name, phaseStartedAt, phaseLimitMs, false, null);
|
|
812
|
+
}
|
|
813
|
+
const commandLimitMs = Math.min(commandMs, phaseLimitMs);
|
|
814
|
+
const phaseInput = {
|
|
815
|
+
...input,
|
|
816
|
+
node: {
|
|
817
|
+
...input.node,
|
|
818
|
+
[ACTION_DEADLINE_FIELD]: phaseDeadline,
|
|
819
|
+
timeout_ms: phaseLimitMs,
|
|
820
|
+
bridge_timeout_ms: commandLimitMs,
|
|
821
|
+
cdp_timeout_ms: commandLimitMs,
|
|
822
|
+
controller_ready_timeout_ms: phaseLimitMs,
|
|
823
|
+
navigation_timeout_ms: Math.min(navigationMs, phaseLimitMs),
|
|
824
|
+
},
|
|
825
|
+
};
|
|
826
|
+
try {
|
|
827
|
+
return await run(phaseInput);
|
|
828
|
+
} catch (error) {
|
|
829
|
+
if (error?.code === MOBILE_BUDGET_ERROR_CODES.DOMAIN_SETUP_DEADLINE) throw error;
|
|
830
|
+
// A nested wait with its own shorter deadline is not this phase running out.
|
|
831
|
+
const phaseRanOut =
|
|
832
|
+
Date.now() >= phaseDeadline ||
|
|
833
|
+
(error?.code === MOBILE_BUDGET_ERROR_CODES.ACTION_DEADLINE &&
|
|
834
|
+
error.deadlineEpochMs >= phaseDeadline);
|
|
835
|
+
if (!phaseRanOut) throw error;
|
|
836
|
+
throw deadlineError(name, phaseStartedAt, phaseLimitMs, sliced, error);
|
|
837
|
+
}
|
|
838
|
+
},
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
|
|
666
842
|
async function waitForNextRouteProbe(deadline) {
|
|
667
843
|
const remaining = deadline - Date.now();
|
|
668
844
|
if (remaining > 0) await sleep(Math.min(250, remaining));
|
|
669
845
|
}
|
|
670
846
|
|
|
671
847
|
export async function navigate(input, route, params = {}, expectedRoute) {
|
|
672
|
-
const timeoutMs =
|
|
848
|
+
const timeoutMs = mobileNavigationTimeoutMs(input);
|
|
673
849
|
let requestedRoute = String(expectedRoute ?? route);
|
|
674
850
|
let requestedParams = requestedRoute === String(route) ? params : undefined;
|
|
675
851
|
const nestedTarget = expectedRoute == null && typeof params?.screen === 'string';
|
|
@@ -712,7 +888,7 @@ export async function navigate(input, route, params = {}, expectedRoute) {
|
|
|
712
888
|
|
|
713
889
|
while (navigateAttempts < 2 || recoveryPending) {
|
|
714
890
|
if (recoveryPending) {
|
|
715
|
-
const deadline = recoveryDeadline ??
|
|
891
|
+
const deadline = recoveryDeadline ?? deadlineFromTimeout(input, timeoutMs);
|
|
716
892
|
let currentRoute = null;
|
|
717
893
|
while (Date.now() < deadline) {
|
|
718
894
|
try {
|
|
@@ -798,7 +974,7 @@ export async function navigate(input, route, params = {}, expectedRoute) {
|
|
|
798
974
|
if (!isTargetTransition(error)) throw error;
|
|
799
975
|
lastTransitionError = error;
|
|
800
976
|
transitionCodes.push(error.code);
|
|
801
|
-
recoveryDeadline =
|
|
977
|
+
recoveryDeadline = deadlineFromTimeout(input, timeoutMs);
|
|
802
978
|
recoveryPending = true;
|
|
803
979
|
await waitForNextRouteProbe(recoveryDeadline);
|
|
804
980
|
}
|
|
@@ -820,7 +996,8 @@ export async function waitForRoute(
|
|
|
820
996
|
expectedParams,
|
|
821
997
|
) {
|
|
822
998
|
const expected = String(expectedRoute);
|
|
823
|
-
const
|
|
999
|
+
const startedAt = Date.now();
|
|
1000
|
+
const deadline = deadlineFromTimeout(input, timeoutMs);
|
|
824
1001
|
// lastRoute is null when bridgeCommand returns null (transient: route not yet
|
|
825
1002
|
// settled mid-navigation). Null means "not ready yet" — keep polling.
|
|
826
1003
|
let lastRoute = null;
|
|
@@ -831,6 +1008,9 @@ export async function waitForRoute(
|
|
|
831
1008
|
try {
|
|
832
1009
|
lastRoute = await bridgeCommand(input, ['get-route']);
|
|
833
1010
|
} catch (error) {
|
|
1011
|
+
// The action deadline cut the last poll short: report the route wait
|
|
1012
|
+
// (expected and last route) rather than that one poll.
|
|
1013
|
+
if (error?.code === MOBILE_BUDGET_ERROR_CODES.ACTION_DEADLINE) break;
|
|
834
1014
|
if (!isTargetTransition(error)) throw error;
|
|
835
1015
|
lastTransitionError = error;
|
|
836
1016
|
transitionCodes.push(error.code);
|
|
@@ -857,7 +1037,7 @@ export async function waitForRoute(
|
|
|
857
1037
|
? 'empty/undefined (route transiently unavailable — bridge not yet settled)'
|
|
858
1038
|
: JSON.stringify(lastRoute);
|
|
859
1039
|
const timeoutError = new Error(
|
|
860
|
-
`Timed out waiting for Mobile route '${expected}' after ${
|
|
1040
|
+
`Timed out waiting for Mobile route '${expected}' after ${Date.now() - startedAt}ms (${pollCount} polls; limit ${deadline - startedAt}ms of ${timeoutMs}ms requested).\n` +
|
|
861
1041
|
` Expected route: ${expected}\n` +
|
|
862
1042
|
` Expected params: ${expectedParams === undefined ? 'any' : JSON.stringify(expectedParams)}\n` +
|
|
863
1043
|
` Last parsed route: ${JSON.stringify(lastRoute)}\n` +
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
// Open wallet home through WalletView, with a verified HomeNav fallback for older navigators.
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
bridgeCommand,
|
|
4
|
+
mobileNavigationTimeoutMs,
|
|
5
|
+
navigate,
|
|
6
|
+
waitForRoute,
|
|
7
|
+
} from '../platform/bridge.mjs';
|
|
3
8
|
|
|
4
9
|
/**
|
|
5
10
|
* Navigate to the wallet home across MetaMask Mobile navigation generations.
|
|
@@ -19,7 +24,7 @@ export async function navigateWalletHome(input) {
|
|
|
19
24
|
const currentRoute = await waitForRoute(
|
|
20
25
|
input,
|
|
21
26
|
'WalletView',
|
|
22
|
-
|
|
27
|
+
mobileNavigationTimeoutMs(input),
|
|
23
28
|
);
|
|
24
29
|
return {
|
|
25
30
|
navigation: {
|
|
@@ -5,14 +5,18 @@ import { pathToFileURL } from 'node:url';
|
|
|
5
5
|
import {
|
|
6
6
|
bridgeCommand,
|
|
7
7
|
MOBILE_BRIDGE_ERROR_CODES,
|
|
8
|
+
MOBILE_BUDGET_ERROR_CODES,
|
|
8
9
|
runAdapter,
|
|
9
10
|
} from '../platform/bridge.mjs';
|
|
10
11
|
|
|
11
12
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
|
+
// A switch-account write cut short by the deadline may still have landed, so
|
|
14
|
+
// ACTION_DEADLINE leaves the outcome as unknown as a transport failure does.
|
|
12
15
|
const TRANSIENT_READ_CODES = new Set([
|
|
13
16
|
MOBILE_BRIDGE_ERROR_CODES.NO_TARGET,
|
|
14
17
|
MOBILE_BRIDGE_ERROR_CODES.CDP_TIMEOUT,
|
|
15
18
|
MOBILE_BRIDGE_ERROR_CODES.WS_CLOSED,
|
|
19
|
+
MOBILE_BUDGET_ERROR_CODES.ACTION_DEADLINE,
|
|
16
20
|
]);
|
|
17
21
|
const READ_ATTEMPT_TIMEOUT_MS = 10_000;
|
|
18
22
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The epoch ms by which a live adapter action must finish. The runner kills a
|
|
2
|
+
// live adapter process liveAdapterProcessTimeoutMs(node) after starting it.
|
|
3
|
+
|
|
4
|
+
import { liveAdapterProcessTimeoutMs } from '@farmslot/recipe-cli/harness';
|
|
5
|
+
|
|
6
|
+
export function liveAdapterDeadline(node, reserveMs = 5_000) {
|
|
7
|
+
return performance.timeOrigin + liveAdapterProcessTimeoutMs(node ?? {}) - reserveMs;
|
|
8
|
+
}
|
|
@@ -123,6 +123,25 @@ export function worstCaseNotionalUsd({ amount, leverage, price, szDecimals, maxS
|
|
|
123
123
|
return (usd + step * Number(price)) * (1 + Number(maxSlippageBps) / 10_000);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
// The lot the controller submits for a market order of `amount` USDC margin (the
|
|
127
|
+
// same rounding as above), in whole size steps, its notional, and the margin one
|
|
128
|
+
// step adds at `price`. Null when the size decimals or price are unknown.
|
|
129
|
+
export function marketOrderLot({ amount, leverage, price, szDecimals }) {
|
|
130
|
+
const decimals = szDecimals == null || szDecimals === '' ? Number.NaN : Number(szDecimals);
|
|
131
|
+
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 8 || !(Number(price) > 0)) return null;
|
|
132
|
+
const multiplier = 10 ** decimals;
|
|
133
|
+
const usd = Number(amount) * Number(leverage);
|
|
134
|
+
let lots = Math.round((usd / Number(price)) * multiplier);
|
|
135
|
+
if ((lots / multiplier) * Number(price) < usd) lots += 1;
|
|
136
|
+
if (!Number.isSafeInteger(lots) || lots <= 0) return null;
|
|
137
|
+
return {
|
|
138
|
+
sizeDecimals: decimals,
|
|
139
|
+
lots,
|
|
140
|
+
notionalBaseUnits: `${Math.ceil((lots / multiplier) * Number(price) * 1_000_000)}`,
|
|
141
|
+
stepMarginBaseUnits: `${Math.ceil((Number(price) / multiplier / Number(leverage)) * 1_000_000)}`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
126
145
|
// Mainnet only: refuse before dispatch when the size the controller would submit
|
|
127
146
|
// could fill above the house notional limit. Testnet needs no bound.
|
|
128
147
|
export function requireHouseNotional({ network, symbol, amount, leverage, price, szDecimals, maxSlippageBps }) {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Classify a Hyperliquid HTTP 429 (Too Many Requests) as RATE_LIMITED and retry
|
|
2
|
+
// Perps reads a bounded number of times with exponential backoff and jitter.
|
|
3
|
+
// Farm runs share one testnet account and egress IP, so a 429 is load on the
|
|
4
|
+
// venue, not a product result. Placements never retry here: see
|
|
5
|
+
// runGuardedExternalMutation, which retries only once the venue proves nothing
|
|
6
|
+
// was placed.
|
|
7
|
+
|
|
8
|
+
export const RATE_LIMITED = 'RATE_LIMITED';
|
|
9
|
+
export const RATE_LIMIT_RETRIES = 3;
|
|
10
|
+
const BASE_BACKOFF_MS = 1_000;
|
|
11
|
+
|
|
12
|
+
const TOO_MANY_REQUESTS = /too many requests|\b(?:http|status(?:\s*code)?)\s*:?\s*429\b/iu;
|
|
13
|
+
|
|
14
|
+
export function isRateLimited(error) {
|
|
15
|
+
if (error?.code === RATE_LIMITED) return true;
|
|
16
|
+
return TOO_MANY_REQUESTS.test(error instanceof Error ? error.message : String(error ?? ''));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Retry n (1-based) waits between half and all of 1 s, 2 s, 4 s: at most 7 s over
|
|
20
|
+
// the three retries.
|
|
21
|
+
export function rateLimitBackoffMs(retry, random = Math.random) {
|
|
22
|
+
const ceiling = BASE_BACKOFF_MS * 2 ** (retry - 1);
|
|
23
|
+
return Math.round(ceiling / 2 + random() * (ceiling / 2));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The sentence naming the call, its attempts and the backoff it used.
|
|
27
|
+
export function describeRateLimit({ call, attempts, waitedMs }) {
|
|
28
|
+
return `Hyperliquid answered 429 Too Many Requests to ${call} on ${attempts} attempt${attempts === 1 ? '' : 's'} `
|
|
29
|
+
+ `after ${waitedMs} ms of backoff (exponential from ${BASE_BACKOFF_MS} ms with jitter, at most ${RATE_LIMIT_RETRIES} retries)`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function rateLimitedError({ call, attempts, waitedMs, reason = null, next = 'wait a minute and rerun' }) {
|
|
33
|
+
return Object.assign(
|
|
34
|
+
new Error(`${RATE_LIMITED}: ${describeRateLimit({ call, attempts, waitedMs })}${reason ? `; ${reason}` : ''}. Next: ${next}.`),
|
|
35
|
+
{ code: RATE_LIMITED, call, attempts, waitedMs },
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function backoffSleep(ms) {
|
|
40
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A retry that has not answered by the deadline fails there; the read itself
|
|
44
|
+
// keeps running, but the action no longer waits for it.
|
|
45
|
+
async function beforeDeadline(promise, deadline, timedOut) {
|
|
46
|
+
let timer;
|
|
47
|
+
try {
|
|
48
|
+
return await Promise.race([
|
|
49
|
+
promise,
|
|
50
|
+
new Promise((_, reject) => {
|
|
51
|
+
timer = setTimeout(() => reject(timedOut()), Math.max(0, deadline - Date.now()));
|
|
52
|
+
}),
|
|
53
|
+
]);
|
|
54
|
+
} finally {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Run a read, retrying it after a 429 up to RATE_LIMIT_RETRIES times.
|
|
61
|
+
* @param call - The request named in the error, e.g. perpsGetPositions.
|
|
62
|
+
* @param read - The read to run; any other error is rethrown at once.
|
|
63
|
+
* @param options.deadline - Epoch ms the action must finish by. A retry starts only if its backoff
|
|
64
|
+
* and another attempt as long as the last one end before it, and fails RATE_LIMITED at it.
|
|
65
|
+
* @returns The read's result, or throws RATE_LIMITED once the retries or the deadline are spent.
|
|
66
|
+
*/
|
|
67
|
+
export async function withRateLimitRetry(call, read, { deadline = null, sleep = backoffSleep, random = Math.random } = {}) {
|
|
68
|
+
let waitedMs = 0;
|
|
69
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
70
|
+
const startedAt = Date.now();
|
|
71
|
+
try {
|
|
72
|
+
if (attempt === 1 || !Number.isFinite(deadline)) return await read();
|
|
73
|
+
return await beforeDeadline(read(), deadline, () => rateLimitedError({
|
|
74
|
+
call, attempts: attempt, waitedMs, reason: 'the last retry did not answer before the action deadline',
|
|
75
|
+
}));
|
|
76
|
+
} catch (error) {
|
|
77
|
+
// A nested read already spent its retries.
|
|
78
|
+
if (error?.code === RATE_LIMITED || !isRateLimited(error)) throw error;
|
|
79
|
+
if (attempt > RATE_LIMIT_RETRIES) {
|
|
80
|
+
throw rateLimitedError({ call, attempts: attempt, waitedMs });
|
|
81
|
+
}
|
|
82
|
+
const backoffMs = rateLimitBackoffMs(attempt, random);
|
|
83
|
+
if (Number.isFinite(deadline) && Date.now() + backoffMs + (Date.now() - startedAt) >= deadline) {
|
|
84
|
+
throw rateLimitedError({
|
|
85
|
+
call, attempts: attempt, waitedMs,
|
|
86
|
+
reason: `the next ${backoffMs} ms backoff and another attempt would pass the action deadline`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
await sleep(backoffMs);
|
|
90
|
+
waitedMs += backoffMs;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -191,7 +191,7 @@ function visibleFeatures(items) {
|
|
|
191
191
|
const long = has(/(?:long-cta-button|direction-long|market-details-long-button)$/u);
|
|
192
192
|
const short = has(/(?:short-cta-button|direction-short|market-details-short-button)$/u);
|
|
193
193
|
return {
|
|
194
|
-
activity: has(/^(?:transaction-card-|perps-(?:recent-activity|activity))/u),
|
|
194
|
+
activity: has(/^(?:transaction-card-|perps-(?:recent-activity|activity|market-trades-row-))/u),
|
|
195
195
|
balance: has(/^(?:perps-balance-dropdown|perps-balance-actions-available-value|perps-market-(?:balance-value|available-balance-text))/u),
|
|
196
196
|
chart: has(/^(?:chart_surface|tv_chart_container)$/u) ||
|
|
197
197
|
has(/perps-(?:candlestick-chart|market-detail-chart|market-details-fullscreen-chart|pro-market-chart)/u),
|
|
@@ -283,7 +283,8 @@ export function normalizeVisiblePerpsState(raw, options, platform) {
|
|
|
283
283
|
options.maxItems,
|
|
284
284
|
),
|
|
285
285
|
activity: compactEntries(
|
|
286
|
-
|
|
286
|
+
// Mobile previews: Perps home Recent activity and the market's recent trades.
|
|
287
|
+
selected(unique, (item) => /^(?:transaction-card-|transaction-item(?:-|$)|perps-activity-row-|perps-(?:recent-activity|market-trades)-row-\d+$)/u.test(item.testId), options.maxItems),
|
|
287
288
|
options.maxItems,
|
|
288
289
|
),
|
|
289
290
|
orderEntry: raw.orderEntry && typeof raw.orderEntry === 'object' ? {
|
|
@@ -3788,7 +3788,7 @@
|
|
|
3788
3788
|
]
|
|
3789
3789
|
},
|
|
3790
3790
|
"metamask.perps.read_snapshot": {
|
|
3791
|
-
"description": "Read a bounded, redacted source-dev snapshot of Extension Perps state through the optional product background hook.",
|
|
3791
|
+
"description": "Read a bounded, redacted source-dev snapshot of Extension Perps state through the optional product background hook. The history section is the funds ledger from perpsGetUserHistory (deposits, withdrawals and transfers): it holds no trades or orders, so zero history items does not mean no trading history. Read trade and order rows with metamask.perps.read_visible_state activity.",
|
|
3792
3792
|
"schema": {
|
|
3793
3793
|
"type": "object",
|
|
3794
3794
|
"properties": {
|
|
@@ -3830,6 +3830,7 @@
|
|
|
3830
3830
|
},
|
|
3831
3831
|
"history": {
|
|
3832
3832
|
"type": "object",
|
|
3833
|
+
"description": "Funds-ledger window (deposits, withdrawals and transfers) passed to perpsGetUserHistory; trades and orders are not part of it.",
|
|
3833
3834
|
"properties": {
|
|
3834
3835
|
"start_time_ms": {
|
|
3835
3836
|
"type": "integer",
|
|
@@ -4465,7 +4466,7 @@
|
|
|
4465
4466
|
"identity_rounding_tolerance_base_units": {
|
|
4466
4467
|
"type": "string",
|
|
4467
4468
|
"default": "10000",
|
|
4468
|
-
"description": "Maximum visible amount/price rounding delta in six-decimal USDC base units; default 10000 and capped at 1000000."
|
|
4469
|
+
"description": "Maximum visible amount/price rounding delta in six-decimal USDC base units; default 10000 and capped at 1000000. A background market order's margin check adds one venue size step at the current price."
|
|
4469
4470
|
}
|
|
4470
4471
|
},
|
|
4471
4472
|
"additionalProperties": false
|
|
@@ -930,7 +930,7 @@
|
|
|
930
930
|
]
|
|
931
931
|
},
|
|
932
932
|
"ui.wait_for": {
|
|
933
|
-
"description": "Wait for a React Native testID/test_id or text target. expected=visible requires a measured viewport intersection; expected=present only requires the mounted tree.",
|
|
933
|
+
"description": "Wait for a React Native testID/test_id or text target. expected=visible requires a measured viewport intersection; expected=present only requires the mounted tree. expected=absent also passes when every mounted match is on a screen the navigator hides (an inactive tab, or a non-modal stack screen under a non-modal screen) and a native observation does not show the test ID.",
|
|
934
934
|
"schema": {
|
|
935
935
|
"type": "object",
|
|
936
936
|
"properties": {
|
|
@@ -960,7 +960,8 @@
|
|
|
960
960
|
"type": "boolean"
|
|
961
961
|
},
|
|
962
962
|
"timeout_ms": {
|
|
963
|
-
"type": "number"
|
|
963
|
+
"type": "number",
|
|
964
|
+
"description": "Total wait in milliseconds (default 10000). The hidden-screen check for expected=absent needs about 3 s of it left for its native read."
|
|
964
965
|
}
|
|
965
966
|
},
|
|
966
967
|
"additionalProperties": false
|
|
@@ -3544,7 +3545,7 @@
|
|
|
3544
3545
|
"trading_state": {
|
|
3545
3546
|
"type": "string",
|
|
3546
3547
|
"enum": ["preserve", "clean_selected_testnet_market"],
|
|
3547
|
-
"description": "High-level state policy
|
|
3548
|
+
"description": "High-level state policy that sets positions, orders and strategies itself: preserve leaves them untouched, and the clean policy removes matching positions, ordinary orders, TWAPs, and Chase sessions only on testnet and preserves them on mainnet. Cannot be combined with an explicit positions, orders or strategies request other than false; omit it to apply such a request."
|
|
3548
3549
|
},
|
|
3549
3550
|
"positions": {
|
|
3550
3551
|
"description": "Requested position state. Omit it or pass false to preserve current positions.",
|
|
@@ -3768,7 +3769,19 @@
|
|
|
3768
3769
|
"additionalProperties": false
|
|
3769
3770
|
},
|
|
3770
3771
|
"timeout_ms": {
|
|
3771
|
-
"type": "number"
|
|
3772
|
+
"type": "number",
|
|
3773
|
+
"minimum": 1,
|
|
3774
|
+
"description": "Overall budget for the whole start state setup: every phase runs inside it. Default 30000 on emulators and simulators, 60000 on a physical Android device (explicit non-emulator adb serial), or MM_HARNESS_MOBILE_SETUP_TIMEOUT_MS. A phase that runs out fails as DOMAIN_SETUP_DEADLINE naming the phase."
|
|
3775
|
+
},
|
|
3776
|
+
"navigation_timeout_ms": {
|
|
3777
|
+
"type": "number",
|
|
3778
|
+
"minimum": 1,
|
|
3779
|
+
"description": "Slice of the setup budget for each navigation phase. Default 15000 on emulators and simulators, 30000 on a physical Android device, or MM_HARNESS_MOBILE_NAVIGATION_TIMEOUT_MS."
|
|
3780
|
+
},
|
|
3781
|
+
"cdp_timeout_ms": {
|
|
3782
|
+
"type": "number",
|
|
3783
|
+
"minimum": 1,
|
|
3784
|
+
"description": "Limit for each bridge command inside the setup, capped by the phase deadline. Default 15000. A command that hits it fails as CDP_TIMEOUT (CDP_COMMAND_TIMEOUT in the message)."
|
|
3772
3785
|
},
|
|
3773
3786
|
"side": {
|
|
3774
3787
|
"type": "string",
|
|
@@ -4104,7 +4117,19 @@
|
|
|
4104
4117
|
"additionalProperties": false
|
|
4105
4118
|
},
|
|
4106
4119
|
"timeout_ms": {
|
|
4107
|
-
"type": "number"
|
|
4120
|
+
"type": "number",
|
|
4121
|
+
"minimum": 1,
|
|
4122
|
+
"description": "Overall budget for the whole teardown: every phase runs inside it. Default 30000 on emulators and simulators, 60000 on a physical Android device (explicit non-emulator adb serial), or MM_HARNESS_MOBILE_SETUP_TIMEOUT_MS. A phase that runs out fails as DOMAIN_SETUP_DEADLINE naming the phase."
|
|
4123
|
+
},
|
|
4124
|
+
"navigation_timeout_ms": {
|
|
4125
|
+
"type": "number",
|
|
4126
|
+
"minimum": 1,
|
|
4127
|
+
"description": "Slice of the setup budget for each navigation phase. Default 15000 on emulators and simulators, 30000 on a physical Android device, or MM_HARNESS_MOBILE_NAVIGATION_TIMEOUT_MS."
|
|
4128
|
+
},
|
|
4129
|
+
"cdp_timeout_ms": {
|
|
4130
|
+
"type": "number",
|
|
4131
|
+
"minimum": 1,
|
|
4132
|
+
"description": "Limit for each bridge command inside the setup, capped by the phase deadline. Default 15000. A command that hits it fails as CDP_TIMEOUT (CDP_COMMAND_TIMEOUT in the message)."
|
|
4108
4133
|
},
|
|
4109
4134
|
"side": {
|
|
4110
4135
|
"type": "string",
|
package/package.json
CHANGED
|
@@ -202,6 +202,7 @@
|
|
|
202
202
|
"Flake risk: wait on observable state instead of sleeping, keep device and runtime identity explicit, and never overwrite a prior run's artifacts.",
|
|
203
203
|
"Mutation: require an independent state assertion and identity-bound receipt. Bind activity to that operation's accepted ID; an old row or nonzero count is insufficient. Opening and closing orders have different IDs. Schedule authorized cleanup in teardown, verify its result, and report any residue. Testnet cleanup closes what the task opened and never silently undoes another task's live proof on a reserved account or market.",
|
|
204
204
|
"Testnet account state: on a testnet account or market the task owns for its run (the slot's fixture account, or one reserved with exclusive_testnet_market=true), converging is an explicit, ownership-scoped setup choice, not a violation. Use the team library's perps.ensure-market-state or perps.clean-market-testnet, or on Mobile and Core metamask.perps.ensure_positions / metamask.perps.ensure_orders (mode=all for strays), record what was closed in the trace, then prove. A pre-existing position or order on an account the task owns is never a reason to block, skip, or refuse, and a fixture that demands an empty account must converge rather than refuse. Extension ensure_* and teardown_state only assert; converge there through an explicitly funded leaf action. Never broaden cleanup onto an account or market the task does not own; obtain an owned one instead of blocking. On mainnet, orders follow the shared Perps order rule (PERPS_MAINNET_ORDER_REFUSED), Core needs CORE_PERPS_ALLOW_MAINNET_WRITES=1 for any write, Mobile cleanup acts on whatever network the app is on, and Extension refuses mainnet writes other than orders and `close_visible_position` in the fixture-policy lane.",
|
|
205
|
+
"Rate limits: RATE_LIMITED means Hyperliquid answered 429 Too Many Requests and bounded retries did not get through; it says nothing about the product. Wait a minute and rerun a read. A placement left outcome-unknown is never rerun before the market's positions, orders and fills are checked.",
|
|
205
206
|
{
|
|
206
207
|
"text": "An accepted order can fill before the UI updates. Preserve its receipt even when the expected resting-order assertion fails; one empty read does not prove cleanup. Check the read API's cache and freshness options before adding polling. Reconcile returned margin against the pre-placement balance. A successful close does not turn the original failed proof into a pass.",
|
|
207
208
|
"adapters": [
|