@deeeed/metamask-harness 0.86.2 → 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 +26 -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/compare_provider_market.mjs +12 -8
- package/library/actions/extension/perps/mutation-receipt.mjs +120 -23
- package/library/actions/extension/perps/perps.mjs +98 -30
- 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 +106 -92
- package/library/actions/mobile/platform/bridge.mjs +171 -10
- 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/rate-limit.mjs +93 -0
- package/library/actions/shared/perps/visible-state.mjs +3 -2
- package/library/manifests/extension.action-manifest.json +2 -1
- package/library/manifests/mobile.action-manifest.json +30 -5
- package/package.json +1 -1
- package/site/assets/help-recipes.json +1 -0
|
@@ -4,11 +4,13 @@ import { pathToFileURL } from 'node:url';
|
|
|
4
4
|
import {
|
|
5
5
|
MOBILE_BRIDGE_RESULT_ERROR_CODES,
|
|
6
6
|
bridgeCommand,
|
|
7
|
+
createSetupBudget,
|
|
7
8
|
deadlineFromTimeout,
|
|
8
9
|
earliestDeadline,
|
|
9
10
|
evalAsync,
|
|
10
11
|
inheritedDeadline,
|
|
11
|
-
|
|
12
|
+
isUnansweredRead,
|
|
13
|
+
mobileNavigationTimeoutMs,
|
|
12
14
|
navigate,
|
|
13
15
|
routeSatisfiesNavigationTarget,
|
|
14
16
|
runAdapter,
|
|
@@ -25,6 +27,7 @@ import {
|
|
|
25
27
|
requireHouseNotional,
|
|
26
28
|
requirePerpsOrderAllowed,
|
|
27
29
|
} from '../../shared/perps/order-policy.mjs';
|
|
30
|
+
import { isRateLimited, rateLimitedError, withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
|
|
28
31
|
|
|
29
32
|
function sleep(ms) {
|
|
30
33
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -169,7 +172,7 @@ async function readModeState(input, mode, deadline) {
|
|
|
169
172
|
try {
|
|
170
173
|
return await queryModeState(input, mode, deadline);
|
|
171
174
|
} catch (error) {
|
|
172
|
-
if (!
|
|
175
|
+
if (!isUnansweredRead(error)) throw error;
|
|
173
176
|
return null;
|
|
174
177
|
}
|
|
175
178
|
}
|
|
@@ -237,6 +240,8 @@ export async function ensureMode(input) {
|
|
|
237
240
|
throw new Error(`Perps did not reach ${requestedMode} mode before timeout.`);
|
|
238
241
|
}
|
|
239
242
|
|
|
243
|
+
// Retries the client-not-initialized race until the deadline, and a 429 with
|
|
244
|
+
// the shared rate-limit backoff.
|
|
240
245
|
export async function retryPerpsClientRead(
|
|
241
246
|
read,
|
|
242
247
|
{
|
|
@@ -244,6 +249,8 @@ export async function retryPerpsClientRead(
|
|
|
244
249
|
intervalMs = 500,
|
|
245
250
|
sleep: wait = sleep,
|
|
246
251
|
deadlineEpochMs,
|
|
252
|
+
call = 'PerpsController read',
|
|
253
|
+
random,
|
|
247
254
|
} = {},
|
|
248
255
|
) {
|
|
249
256
|
const localDeadline = Date.now() + Math.max(0, timeoutMs);
|
|
@@ -254,7 +261,7 @@ export async function retryPerpsClientRead(
|
|
|
254
261
|
: Math.min(localDeadline, configuredDeadline);
|
|
255
262
|
while (true) {
|
|
256
263
|
try {
|
|
257
|
-
return await read
|
|
264
|
+
return await withRateLimitRetry(call, read, { deadline, sleep: wait, random });
|
|
258
265
|
} catch (error) {
|
|
259
266
|
if (!/CLIENT_NOT_INITIALIZED/u.test(String(error?.message ?? error))) {
|
|
260
267
|
throw error;
|
|
@@ -386,7 +393,7 @@ async function readPositions(input) {
|
|
|
386
393
|
}
|
|
387
394
|
return positions;
|
|
388
395
|
},
|
|
389
|
-
{ deadlineEpochMs: inheritedDeadline(input) },
|
|
396
|
+
{ deadlineEpochMs: inheritedDeadline(input), call: 'PerpsController.getPositions' },
|
|
390
397
|
);
|
|
391
398
|
}
|
|
392
399
|
|
|
@@ -413,7 +420,7 @@ async function readOpenOrders(input) {
|
|
|
413
420
|
}
|
|
414
421
|
return orders;
|
|
415
422
|
},
|
|
416
|
-
{ deadlineEpochMs: inheritedDeadline(input) },
|
|
423
|
+
{ deadlineEpochMs: inheritedDeadline(input), call: 'PerpsController.getOpenOrders' },
|
|
417
424
|
);
|
|
418
425
|
}
|
|
419
426
|
|
|
@@ -445,7 +452,7 @@ async function readStrategyOrders(input) {
|
|
|
445
452
|
}
|
|
446
453
|
return orders;
|
|
447
454
|
},
|
|
448
|
-
{ deadlineEpochMs: inheritedDeadline(input) },
|
|
455
|
+
{ deadlineEpochMs: inheritedDeadline(input), call: 'PerpsController.getTwapOrders/getChaseOrders' },
|
|
449
456
|
);
|
|
450
457
|
}
|
|
451
458
|
|
|
@@ -903,7 +910,7 @@ async function readCurrentRoute(input) {
|
|
|
903
910
|
try {
|
|
904
911
|
return await bridgeCommand(input, ['get-route']);
|
|
905
912
|
} catch (error) {
|
|
906
|
-
if (!
|
|
913
|
+
if (!isUnansweredRead(error)) throw error;
|
|
907
914
|
return null;
|
|
908
915
|
}
|
|
909
916
|
}
|
|
@@ -961,8 +968,7 @@ async function waitForPerpsUiTarget(input, testId, deadline) {
|
|
|
961
968
|
}
|
|
962
969
|
|
|
963
970
|
async function selectPerpsMarketFromHome(input, symbol, previousRoute) {
|
|
964
|
-
const
|
|
965
|
-
const deadline = deadlineFromTimeout(input, timeoutMs);
|
|
971
|
+
const deadline = deadlineFromTimeout(input, mobileNavigationTimeoutMs(input));
|
|
966
972
|
const params = { market: { symbol } };
|
|
967
973
|
const rowTestId = `perps-market-row-item-${symbol}`;
|
|
968
974
|
const recentlyViewedTestId = `perps-recently-viewed-tile-${symbol}`;
|
|
@@ -1045,8 +1051,7 @@ async function waitForRouteChange(input, previousRoute, deadline) {
|
|
|
1045
1051
|
}
|
|
1046
1052
|
|
|
1047
1053
|
async function navigateFromMarketDetailsToHome(input, previousRoute) {
|
|
1048
|
-
const
|
|
1049
|
-
const deadline = deadlineFromTimeout(input, timeoutMs);
|
|
1054
|
+
const deadline = deadlineFromTimeout(input, mobileNavigationTimeoutMs(input));
|
|
1050
1055
|
let visibleTestIds = await readVisiblePerpsTestIds(input, deadline);
|
|
1051
1056
|
let backTestId = PERPS_MARKET_DETAILS_BACK_TEST_IDS.find((testId) =>
|
|
1052
1057
|
visibleTestIds.has(testId),
|
|
@@ -1468,6 +1473,16 @@ export async function placeOrder(input) {
|
|
|
1468
1473
|
);
|
|
1469
1474
|
}
|
|
1470
1475
|
if (result?.success === false || result == null) {
|
|
1476
|
+
// Mobile keeps no placement receipt, so a 429 is never retried: nothing proves the venue saw no order.
|
|
1477
|
+
if (isRateLimited(result?.error)) {
|
|
1478
|
+
throw rateLimitedError({
|
|
1479
|
+
call: 'PerpsController.placeOrder',
|
|
1480
|
+
attempts: 1,
|
|
1481
|
+
waitedMs: 0,
|
|
1482
|
+
reason: `no retry, since nothing proves the ${symbol} order was not placed`,
|
|
1483
|
+
next: `read the ${symbol} positions and orders, then wait a minute and rerun only if nothing was placed`,
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1471
1486
|
throw new Error(`Failed to place ${symbol} ${side}: ${result?.error || JSON.stringify(result)}`);
|
|
1472
1487
|
}
|
|
1473
1488
|
if (orderType === 'limit') {
|
|
@@ -1732,9 +1747,20 @@ function mergeStateConfig(defaults, params) {
|
|
|
1732
1747
|
};
|
|
1733
1748
|
}
|
|
1734
1749
|
|
|
1735
|
-
|
|
1750
|
+
// The policy owns positions, orders and strategies. An explicit request for one
|
|
1751
|
+
// of them would be dropped, so it fails before any phase runs.
|
|
1752
|
+
export function applyTradingStatePolicy(config, requested) {
|
|
1736
1753
|
const policy = config.trading_state;
|
|
1737
1754
|
if (policy === undefined) return config;
|
|
1755
|
+
const dropped = ['positions', 'orders', 'strategies'].filter(
|
|
1756
|
+
(key) => requested[key] !== undefined && requested[key] !== false,
|
|
1757
|
+
);
|
|
1758
|
+
if (dropped.length > 0) {
|
|
1759
|
+
throw new Error(
|
|
1760
|
+
`metamask.perps.start_state trading_state=${String(policy)} sets positions, orders and strategies itself, so the requested ${dropped.join(', ')} would be ignored. ` +
|
|
1761
|
+
`Omit trading_state to apply ${dropped.join(', ')}, or request ${dropped.join(', ')} in a separate metamask.perps.start_state node.`,
|
|
1762
|
+
);
|
|
1763
|
+
}
|
|
1738
1764
|
if (policy === 'preserve') {
|
|
1739
1765
|
return { ...config, positions: false, orders: false, strategies: false };
|
|
1740
1766
|
}
|
|
@@ -1969,16 +1995,15 @@ export async function startState(input) {
|
|
|
1969
1995
|
const params = paramsForState(input);
|
|
1970
1996
|
const config = applyTradingStatePolicy(
|
|
1971
1997
|
mergeStateConfig(profileDefaults(params.profile), params),
|
|
1998
|
+
params,
|
|
1972
1999
|
);
|
|
1973
|
-
const
|
|
1974
|
-
const
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
);
|
|
1981
|
-
}
|
|
2000
|
+
const budget = createSetupBudget(input, 'metamask.perps.start_state');
|
|
2001
|
+
const navigationSlice = { sliceMs: budget.navigationMs };
|
|
2002
|
+
// Product edits require an app restart before proof. Mobile relocks on restart,
|
|
2003
|
+
// so a deterministic Perps start state must restore the fixture-backed wallet
|
|
2004
|
+
// prerequisite before it navigates or converges controller state.
|
|
2005
|
+
const wallet = await budget.phase('unlock', (phaseInput) => {
|
|
2006
|
+
const remainingMs = phaseInput.node.timeout_ms;
|
|
1982
2007
|
const configuredTargetMs = Number(input.node?.target_timeout_ms);
|
|
1983
2008
|
const targetTimeoutMs = Math.min(
|
|
1984
2009
|
remainingMs,
|
|
@@ -1993,75 +2018,78 @@ export async function startState(input) {
|
|
|
1993
2018
|
? configuredUnlockMs
|
|
1994
2019
|
: remainingMs,
|
|
1995
2020
|
);
|
|
1996
|
-
return {
|
|
1997
|
-
...
|
|
2021
|
+
return ensureUnlocked({
|
|
2022
|
+
...phaseInput,
|
|
2023
|
+
action: 'metamask.wallet.ensure_unlocked',
|
|
1998
2024
|
node: {
|
|
1999
|
-
...
|
|
2000
|
-
|
|
2001
|
-
timeout_ms: remainingMs,
|
|
2002
|
-
bridge_timeout_ms: remainingMs,
|
|
2003
|
-
cdp_timeout_ms: remainingMs,
|
|
2004
|
-
controller_ready_timeout_ms: remainingMs,
|
|
2025
|
+
...phaseInput.node,
|
|
2026
|
+
action: 'metamask.wallet.ensure_unlocked',
|
|
2005
2027
|
target_timeout_ms: targetTimeoutMs,
|
|
2006
2028
|
unlock_timeout_ms: unlockTimeoutMs,
|
|
2007
2029
|
},
|
|
2008
|
-
};
|
|
2009
|
-
};
|
|
2010
|
-
// Product edits require an app restart before proof. Mobile relocks on restart,
|
|
2011
|
-
// so a deterministic Perps start state must restore the fixture-backed wallet
|
|
2012
|
-
// prerequisite before it navigates or converges controller state.
|
|
2013
|
-
const walletInput = stateInput();
|
|
2014
|
-
const wallet = await ensureUnlocked({
|
|
2015
|
-
...walletInput,
|
|
2016
|
-
action: 'metamask.wallet.ensure_unlocked',
|
|
2017
|
-
node: { ...walletInput.node, action: 'metamask.wallet.ensure_unlocked' },
|
|
2030
|
+
});
|
|
2018
2031
|
});
|
|
2019
|
-
const accountInput = stateInput();
|
|
2020
2032
|
const account = typeof config.account === 'string' && config.account.trim()
|
|
2021
|
-
? await selectAccount({
|
|
2022
|
-
...
|
|
2033
|
+
? await budget.phase('account', (phaseInput) => selectAccount({
|
|
2034
|
+
...phaseInput,
|
|
2023
2035
|
action: 'metamask.wallet.select_account',
|
|
2024
2036
|
node: {
|
|
2025
|
-
...
|
|
2037
|
+
...phaseInput.node,
|
|
2026
2038
|
action: 'metamask.wallet.select_account',
|
|
2027
2039
|
...(/^0x[0-9a-f]{40}$/iu.test(config.account.trim())
|
|
2028
2040
|
? { address: config.account.trim() }
|
|
2029
2041
|
: { name: config.account.trim() }),
|
|
2030
2042
|
},
|
|
2031
|
-
})
|
|
2043
|
+
}))
|
|
2032
2044
|
: { skipped: true };
|
|
2033
2045
|
// Entering Perps initializes the provider client on a freshly launched app.
|
|
2034
2046
|
// State reads before this navigation fail with CLIENT_NOT_INITIALIZED.
|
|
2035
|
-
const navigation = await
|
|
2036
|
-
|
|
2037
|
-
|
|
2047
|
+
const navigation = await budget.phase(
|
|
2048
|
+
'navigation',
|
|
2049
|
+
(phaseInput) => applyStateNavigation(phaseInput, config),
|
|
2050
|
+
navigationSlice,
|
|
2051
|
+
);
|
|
2052
|
+
const provider = await budget.phase('provider', (phaseInput) =>
|
|
2053
|
+
ensureProvider(phaseInput, config));
|
|
2054
|
+
const network = await budget.phase('network', (phaseInput) =>
|
|
2055
|
+
ensureNetwork(phaseInput, config));
|
|
2038
2056
|
let resolvedNavigation = provider.changed || network.changed
|
|
2039
|
-
? await
|
|
2057
|
+
? await budget.phase(
|
|
2058
|
+
'navigation after provider or network change',
|
|
2059
|
+
(phaseInput) => applyStateNavigation(phaseInput, config),
|
|
2060
|
+
navigationSlice,
|
|
2061
|
+
)
|
|
2040
2062
|
: navigation;
|
|
2041
|
-
const modeInput = stateInput();
|
|
2042
2063
|
const marketMode = config.market_mode
|
|
2043
|
-
? await ensureMode({
|
|
2044
|
-
...
|
|
2064
|
+
? await budget.phase('market mode', (phaseInput) => ensureMode({
|
|
2065
|
+
...phaseInput,
|
|
2045
2066
|
action: 'metamask.perps.ensure_mode',
|
|
2046
2067
|
node: {
|
|
2047
|
-
...
|
|
2068
|
+
...phaseInput.node,
|
|
2048
2069
|
action: 'metamask.perps.ensure_mode',
|
|
2049
2070
|
mode: config.market_mode,
|
|
2050
2071
|
},
|
|
2051
|
-
})
|
|
2072
|
+
}))
|
|
2052
2073
|
: { skipped: true };
|
|
2053
2074
|
if (marketMode.alreadySelected === false) {
|
|
2054
|
-
resolvedNavigation = await
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
config
|
|
2062
|
-
)
|
|
2063
|
-
|
|
2064
|
-
const
|
|
2075
|
+
resolvedNavigation = await budget.phase(
|
|
2076
|
+
'navigation after mode change',
|
|
2077
|
+
(phaseInput) => applyStateNavigation(phaseInput, config),
|
|
2078
|
+
navigationSlice,
|
|
2079
|
+
);
|
|
2080
|
+
}
|
|
2081
|
+
const tutorial = await budget.phase('tutorial', (phaseInput) =>
|
|
2082
|
+
applyTutorialState(phaseInput, config));
|
|
2083
|
+
const readyToTrade = await budget.phase('ready to trade', (phaseInput) =>
|
|
2084
|
+
assertReadyToTrade(phaseInput, config));
|
|
2085
|
+
const balance = await budget.phase('balance', (phaseInput) =>
|
|
2086
|
+
assertBalance(phaseInput, config));
|
|
2087
|
+
const strategies = await budget.phase('strategies', (phaseInput) =>
|
|
2088
|
+
applyStrategiesState(phaseInput, config.strategies));
|
|
2089
|
+
const orders = await budget.phase('orders', (phaseInput) =>
|
|
2090
|
+
applyOrdersState(phaseInput, config.orders));
|
|
2091
|
+
const positions = await budget.phase('positions', (phaseInput) =>
|
|
2092
|
+
applyPositionsState(phaseInput, config.positions));
|
|
2065
2093
|
return {
|
|
2066
2094
|
action: input.action,
|
|
2067
2095
|
profile: config.profile ?? params.profile ?? null,
|
|
@@ -2090,16 +2118,7 @@ export async function startState(input) {
|
|
|
2090
2118
|
}
|
|
2091
2119
|
|
|
2092
2120
|
export async function teardownState(input) {
|
|
2093
|
-
const
|
|
2094
|
-
const deadline = deadlineFromTimeout(input, timeoutMs);
|
|
2095
|
-
const stateInput = (phase) => {
|
|
2096
|
-
if (Date.now() >= deadline) {
|
|
2097
|
-
throw new Error(
|
|
2098
|
-
`metamask.perps.teardown_state timed out before ${phase}.`,
|
|
2099
|
-
);
|
|
2100
|
-
}
|
|
2101
|
-
return withRemainingDeadline(input, deadline);
|
|
2102
|
-
};
|
|
2121
|
+
const budget = createSetupBudget(input, 'metamask.perps.teardown_state');
|
|
2103
2122
|
const params = paramsForState(input);
|
|
2104
2123
|
const defaults = {
|
|
2105
2124
|
provider: 'hyperliquid',
|
|
@@ -2110,28 +2129,23 @@ export async function teardownState(input) {
|
|
|
2110
2129
|
strategies: params.market || params.symbol || params.markets || params.symbols ? { state: 'none', mode: 'matching' } : false,
|
|
2111
2130
|
};
|
|
2112
2131
|
const config = mergeStateConfig(defaults, params);
|
|
2113
|
-
const environment = await
|
|
2114
|
-
|
|
2115
|
-
config,
|
|
2116
|
-
);
|
|
2117
|
-
const strategies = await applyStrategiesState(
|
|
2118
|
-
stateInput('cleaning strategies'),
|
|
2119
|
-
config.strategies,
|
|
2120
|
-
);
|
|
2121
|
-
const orders = await applyOrdersState(
|
|
2122
|
-
stateInput('cleaning orders'),
|
|
2123
|
-
config.orders,
|
|
2124
|
-
);
|
|
2125
|
-
const positions = await applyPositionsState(
|
|
2126
|
-
stateInput('closing positions'),
|
|
2127
|
-
config.positions,
|
|
2132
|
+
const environment = await budget.phase(
|
|
2133
|
+
'checking the requested environment',
|
|
2134
|
+
(phaseInput) => assertRequestedRuntimeState(phaseInput, config),
|
|
2128
2135
|
);
|
|
2136
|
+
const strategies = await budget.phase('cleaning strategies', (phaseInput) =>
|
|
2137
|
+
applyStrategiesState(phaseInput, config.strategies));
|
|
2138
|
+
const orders = await budget.phase('cleaning orders', (phaseInput) =>
|
|
2139
|
+
applyOrdersState(phaseInput, config.orders));
|
|
2140
|
+
const positions = await budget.phase('closing positions', (phaseInput) =>
|
|
2141
|
+
applyPositionsState(phaseInput, config.positions));
|
|
2129
2142
|
const navigation =
|
|
2130
2143
|
config.page === false
|
|
2131
2144
|
? { skipped: true }
|
|
2132
|
-
: await
|
|
2133
|
-
|
|
2134
|
-
config,
|
|
2145
|
+
: await budget.phase(
|
|
2146
|
+
'restoring the requested page',
|
|
2147
|
+
(phaseInput) => applyStateNavigation(phaseInput, config),
|
|
2148
|
+
{ sliceMs: budget.navigationMs },
|
|
2135
2149
|
);
|
|
2136
2150
|
return {
|
|
2137
2151
|
action: input.action,
|
|
@@ -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) &&
|
|
@@ -675,19 +712,140 @@ export function earliestDeadline(input, deadline) {
|
|
|
675
712
|
|
|
676
713
|
// The earlier of now + timeoutMs and the calling action's deadline. Route waits
|
|
677
714
|
// use it because past the action deadline bridgeCommand refuses to start
|
|
678
|
-
// get-route (
|
|
679
|
-
// never see a route that did arrive.
|
|
715
|
+
// get-route (ACTION_DEADLINE): polling on would only retry that refusal and
|
|
716
|
+
// could never see a route that did arrive.
|
|
680
717
|
export function deadlineFromTimeout(input, timeoutMs) {
|
|
681
718
|
return earliestDeadline(input, Date.now() + Math.max(0, Number(timeoutMs)));
|
|
682
719
|
}
|
|
683
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
|
+
|
|
684
842
|
async function waitForNextRouteProbe(deadline) {
|
|
685
843
|
const remaining = deadline - Date.now();
|
|
686
844
|
if (remaining > 0) await sleep(Math.min(250, remaining));
|
|
687
845
|
}
|
|
688
846
|
|
|
689
847
|
export async function navigate(input, route, params = {}, expectedRoute) {
|
|
690
|
-
const timeoutMs =
|
|
848
|
+
const timeoutMs = mobileNavigationTimeoutMs(input);
|
|
691
849
|
let requestedRoute = String(expectedRoute ?? route);
|
|
692
850
|
let requestedParams = requestedRoute === String(route) ? params : undefined;
|
|
693
851
|
const nestedTarget = expectedRoute == null && typeof params?.screen === 'string';
|
|
@@ -850,6 +1008,9 @@ export async function waitForRoute(
|
|
|
850
1008
|
try {
|
|
851
1009
|
lastRoute = await bridgeCommand(input, ['get-route']);
|
|
852
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;
|
|
853
1014
|
if (!isTargetTransition(error)) throw error;
|
|
854
1015
|
lastTransitionError = error;
|
|
855
1016
|
transitionCodes.push(error.code);
|
|
@@ -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
|
+
}
|