@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/adapters/mobile/action-inputs.js +20 -0
  3. package/dist/adapters/mobile/actions.js +18 -1
  4. package/dist/adapters/mobile/android-screenrecord.js +270 -0
  5. package/dist/adapters/mobile/readiness.js +2 -1
  6. package/dist/adapters/mobile/recording-outcome.js +103 -0
  7. package/dist/adapters/mobile/recording-target.js +11 -0
  8. package/dist/adapters/mobile/run.js +48 -11
  9. package/dist/adapters/mobile/ui.js +96 -2
  10. package/dist/adapters/perps-order-inputs.js +43 -28
  11. package/dist/adapters.js +4 -1
  12. package/dist/commands/doctor.js +33 -14
  13. package/dist/commands/status-probe.js +2 -1
  14. package/docs/RECIPES.md +62 -0
  15. package/library/actions/core/perps/read_account.mjs +4 -2
  16. package/library/actions/core/perps/read_orders.mjs +4 -2
  17. package/library/actions/core/perps/read_positions.mjs +4 -2
  18. package/library/actions/extension/analytics/consent.mjs +7 -2
  19. package/library/actions/extension/perps/close_visible_position.mjs +9 -5
  20. package/library/actions/extension/perps/compare_provider_market.mjs +12 -8
  21. package/library/actions/extension/perps/mutation-receipt.mjs +142 -24
  22. package/library/actions/extension/perps/perps.mjs +179 -40
  23. package/library/actions/extension/perps/read_snapshot.mjs +6 -2
  24. package/library/actions/extension/platform/cdp.mjs +6 -2
  25. package/library/actions/extension/ui/navigate.mjs +8 -0
  26. package/library/actions/mobile/perps/perps.mjs +109 -109
  27. package/library/actions/mobile/platform/bridge.mjs +192 -12
  28. package/library/actions/mobile/wallet/home.mjs +7 -2
  29. package/library/actions/mobile/wallet/select_account.mjs +4 -0
  30. package/library/actions/shared/app/live-adapter-deadline.mjs +8 -0
  31. package/library/actions/shared/perps/order-policy.mjs +19 -0
  32. package/library/actions/shared/perps/rate-limit.mjs +93 -0
  33. package/library/actions/shared/perps/visible-state.mjs +3 -2
  34. package/library/manifests/extension.action-manifest.json +3 -2
  35. package/library/manifests/mobile.action-manifest.json +30 -5
  36. package/package.json +1 -1
  37. package/site/assets/help-recipes.json +1 -0
@@ -22,6 +22,8 @@ import path from 'node:path';
22
22
 
23
23
  import { captureActiveRecipeRecordingSnapshot, recipeHarnessPath } from '@farmslot/recipe-cli/harness';
24
24
  import { importRecipeHarnessRuntimeBrowserExtension, importRecipeHarnessRuntimeCdp, extensionIdPath, walletFixturePath } from '../../harness-exports.mjs';
25
+ import { liveAdapterDeadline } from '../../shared/app/live-adapter-deadline.mjs';
26
+ import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
25
27
 
26
28
  // Resolve the Farmslot harness through normal package dependencies by default.
27
29
  // Local Farmslot source is only a dev override handled by the harness paths module.
@@ -843,6 +845,8 @@ export async function withExtensionPage(input, callback) {
843
845
  const origin = `chrome-extension://${extensionId}`;
844
846
  const runtimeIdentity = extensionRuntimeIdentity({ port, target, browserVersion });
845
847
  const page = new ExtensionPage(session, origin, target, port, runtimeIdentity);
848
+ // Rate-limit retries on this page's reads stop before the runner's process limit.
849
+ page.actionDeadline = liveAdapterDeadline(input.node);
846
850
  const recoverVisibleWallet = input.action === 'metamask.wallet.import' && input.node?.method === 'ui';
847
851
  if (recoverVisibleWallet) {
848
852
  try {
@@ -1030,7 +1034,7 @@ export class ExtensionPage extends CdpWebPage {
1030
1034
  }
1031
1035
 
1032
1036
  async readPositions() {
1033
- return this.evaluate(`(async () => {
1037
+ return withRateLimitRetry('perpsGetPositions', () => this.evaluate(`(async () => {
1034
1038
  const request = globalThis.stateHooks?.submitRequestToBackground;
1035
1039
  const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
1036
1040
  const cached = manager?.positions?.cache;
@@ -1050,7 +1054,7 @@ export class ExtensionPage extends CdpWebPage {
1050
1054
  connected: Boolean(manager?.positions?.isConnected),
1051
1055
  positions: Array.isArray(cached) ? cached : [],
1052
1056
  };
1053
- })()`);
1057
+ })()`), { deadline: this.actionDeadline });
1054
1058
  }
1055
1059
 
1056
1060
  async screenshot(contextOrInput, relPath, metadata = {}) {
@@ -280,6 +280,8 @@ export async function openPerpsHome(page, timeoutMs) {
280
280
  await pauseForUi(page);
281
281
  }
282
282
 
283
+ // The sample is capped at 30 and the header and tabs come first in the DOM, so loading
284
+ // markers are listed first; otherwise a Perps view still loading reads as a wrong route.
283
285
  const state = await page.evaluate(`({
284
286
  href: location.href,
285
287
  title: document.title,
@@ -291,8 +293,14 @@ export async function openPerpsHome(page, timeoutMs) {
291
293
  })
292
294
  .map((element) => element.getAttribute('data-testid'))
293
295
  .filter(Boolean)
296
+ .sort((a, b) => Number(b.endsWith('-loading')) - Number(a.endsWith('-loading')))
294
297
  .slice(0, 30),
295
298
  })`);
299
+ if (await hasVisibleSelector(page, perpsLoading)) {
300
+ throw new Error(
301
+ `extension ui.navigate page=perps: Perps home stayed on its loading view (perps-view-loading visible) within the ${timeoutMs}ms budget; observed ${JSON.stringify(state)}.\nNext: check the Perps stream/provider, or raise timeout_ms; a raw hash route does not wait for Perps readiness.`,
302
+ );
303
+ }
296
304
  throw new Error(
297
305
  `extension ui.navigate page=perps could not resolve the Perps home through visible UI; observed ${JSON.stringify(state)}. Capture the current UI with mm-harness call ui.screenshot, then use a verified visible control or raw hash/path.`,
298
306
  );
@@ -4,8 +4,13 @@ import { pathToFileURL } from 'node:url';
4
4
  import {
5
5
  MOBILE_BRIDGE_RESULT_ERROR_CODES,
6
6
  bridgeCommand,
7
+ createSetupBudget,
8
+ deadlineFromTimeout,
9
+ earliestDeadline,
7
10
  evalAsync,
8
- isTargetTransition,
11
+ inheritedDeadline,
12
+ isUnansweredRead,
13
+ mobileNavigationTimeoutMs,
9
14
  navigate,
10
15
  routeSatisfiesNavigationTarget,
11
16
  runAdapter,
@@ -22,6 +27,7 @@ import {
22
27
  requireHouseNotional,
23
28
  requirePerpsOrderAllowed,
24
29
  } from '../../shared/perps/order-policy.mjs';
30
+ import { isRateLimited, rateLimitedError, withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
25
31
 
26
32
  function sleep(ms) {
27
33
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -94,23 +100,6 @@ const MODE_ROOT_TEST_IDS = {
94
100
  };
95
101
  const READ_ATTEMPT_TIMEOUT_MS = 10_000;
96
102
 
97
- function inheritedDeadline(input) {
98
- const deadline = Number(input.node?._action_deadline_epoch_ms);
99
- return Number.isFinite(deadline) ? deadline : null;
100
- }
101
-
102
- function earliestDeadline(input, deadline) {
103
- const inherited = inheritedDeadline(input);
104
- return inherited === null ? deadline : Math.min(deadline, inherited);
105
- }
106
-
107
- function deadlineFromTimeout(input, timeoutMs) {
108
- return earliestDeadline(
109
- input,
110
- Date.now() + Math.max(0, Number(timeoutMs)),
111
- );
112
- }
113
-
114
103
  async function sleepBeforeDeadline(deadline, maximumMs, wait = sleep) {
115
104
  const remainingMs = deadline - Date.now();
116
105
  if (remainingMs <= 0) return;
@@ -183,7 +172,7 @@ async function readModeState(input, mode, deadline) {
183
172
  try {
184
173
  return await queryModeState(input, mode, deadline);
185
174
  } catch (error) {
186
- if (!isTargetTransition(error)) throw error;
175
+ if (!isUnansweredRead(error)) throw error;
187
176
  return null;
188
177
  }
189
178
  }
@@ -251,6 +240,8 @@ export async function ensureMode(input) {
251
240
  throw new Error(`Perps did not reach ${requestedMode} mode before timeout.`);
252
241
  }
253
242
 
243
+ // Retries the client-not-initialized race until the deadline, and a 429 with
244
+ // the shared rate-limit backoff.
254
245
  export async function retryPerpsClientRead(
255
246
  read,
256
247
  {
@@ -258,6 +249,8 @@ export async function retryPerpsClientRead(
258
249
  intervalMs = 500,
259
250
  sleep: wait = sleep,
260
251
  deadlineEpochMs,
252
+ call = 'PerpsController read',
253
+ random,
261
254
  } = {},
262
255
  ) {
263
256
  const localDeadline = Date.now() + Math.max(0, timeoutMs);
@@ -268,7 +261,7 @@ export async function retryPerpsClientRead(
268
261
  : Math.min(localDeadline, configuredDeadline);
269
262
  while (true) {
270
263
  try {
271
- return await read();
264
+ return await withRateLimitRetry(call, read, { deadline, sleep: wait, random });
272
265
  } catch (error) {
273
266
  if (!/CLIENT_NOT_INITIALIZED/u.test(String(error?.message ?? error))) {
274
267
  throw error;
@@ -400,7 +393,7 @@ async function readPositions(input) {
400
393
  }
401
394
  return positions;
402
395
  },
403
- { deadlineEpochMs: inheritedDeadline(input) },
396
+ { deadlineEpochMs: inheritedDeadline(input), call: 'PerpsController.getPositions' },
404
397
  );
405
398
  }
406
399
 
@@ -427,7 +420,7 @@ async function readOpenOrders(input) {
427
420
  }
428
421
  return orders;
429
422
  },
430
- { deadlineEpochMs: inheritedDeadline(input) },
423
+ { deadlineEpochMs: inheritedDeadline(input), call: 'PerpsController.getOpenOrders' },
431
424
  );
432
425
  }
433
426
 
@@ -459,7 +452,7 @@ async function readStrategyOrders(input) {
459
452
  }
460
453
  return orders;
461
454
  },
462
- { deadlineEpochMs: inheritedDeadline(input) },
455
+ { deadlineEpochMs: inheritedDeadline(input), call: 'PerpsController.getTwapOrders/getChaseOrders' },
463
456
  );
464
457
  }
465
458
 
@@ -917,7 +910,7 @@ async function readCurrentRoute(input) {
917
910
  try {
918
911
  return await bridgeCommand(input, ['get-route']);
919
912
  } catch (error) {
920
- if (!isTargetTransition(error)) throw error;
913
+ if (!isUnansweredRead(error)) throw error;
921
914
  return null;
922
915
  }
923
916
  }
@@ -975,8 +968,7 @@ async function waitForPerpsUiTarget(input, testId, deadline) {
975
968
  }
976
969
 
977
970
  async function selectPerpsMarketFromHome(input, symbol, previousRoute) {
978
- const timeoutMs = Number(input.node?.navigation_timeout_ms ?? 15_000);
979
- const deadline = deadlineFromTimeout(input, timeoutMs);
971
+ const deadline = deadlineFromTimeout(input, mobileNavigationTimeoutMs(input));
980
972
  const params = { market: { symbol } };
981
973
  const rowTestId = `perps-market-row-item-${symbol}`;
982
974
  const recentlyViewedTestId = `perps-recently-viewed-tile-${symbol}`;
@@ -1059,8 +1051,7 @@ async function waitForRouteChange(input, previousRoute, deadline) {
1059
1051
  }
1060
1052
 
1061
1053
  async function navigateFromMarketDetailsToHome(input, previousRoute) {
1062
- const timeoutMs = Number(input.node?.navigation_timeout_ms ?? 15_000);
1063
- const deadline = deadlineFromTimeout(input, timeoutMs);
1054
+ const deadline = deadlineFromTimeout(input, mobileNavigationTimeoutMs(input));
1064
1055
  let visibleTestIds = await readVisiblePerpsTestIds(input, deadline);
1065
1056
  let backTestId = PERPS_MARKET_DETAILS_BACK_TEST_IDS.find((testId) =>
1066
1057
  visibleTestIds.has(testId),
@@ -1482,6 +1473,16 @@ export async function placeOrder(input) {
1482
1473
  );
1483
1474
  }
1484
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
+ }
1485
1486
  throw new Error(`Failed to place ${symbol} ${side}: ${result?.error || JSON.stringify(result)}`);
1486
1487
  }
1487
1488
  if (orderType === 'limit') {
@@ -1746,9 +1747,20 @@ function mergeStateConfig(defaults, params) {
1746
1747
  };
1747
1748
  }
1748
1749
 
1749
- export function applyTradingStatePolicy(config) {
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) {
1750
1753
  const policy = config.trading_state;
1751
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
+ }
1752
1764
  if (policy === 'preserve') {
1753
1765
  return { ...config, positions: false, orders: false, strategies: false };
1754
1766
  }
@@ -1983,16 +1995,15 @@ export async function startState(input) {
1983
1995
  const params = paramsForState(input);
1984
1996
  const config = applyTradingStatePolicy(
1985
1997
  mergeStateConfig(profileDefaults(params.profile), params),
1998
+ params,
1986
1999
  );
1987
- const timeoutMs = Math.max(1, Number(input.node?.timeout_ms ?? 30_000));
1988
- const deadline = deadlineFromTimeout(input, timeoutMs);
1989
- const stateInput = () => {
1990
- const remainingMs = deadline - Date.now();
1991
- if (remainingMs <= 0) {
1992
- throw new Error(
1993
- 'metamask.perps.start_state timed out before completing all phases.',
1994
- );
1995
- }
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;
1996
2007
  const configuredTargetMs = Number(input.node?.target_timeout_ms);
1997
2008
  const targetTimeoutMs = Math.min(
1998
2009
  remainingMs,
@@ -2007,75 +2018,78 @@ export async function startState(input) {
2007
2018
  ? configuredUnlockMs
2008
2019
  : remainingMs,
2009
2020
  );
2010
- return {
2011
- ...input,
2021
+ return ensureUnlocked({
2022
+ ...phaseInput,
2023
+ action: 'metamask.wallet.ensure_unlocked',
2012
2024
  node: {
2013
- ...input.node,
2014
- _action_deadline_epoch_ms: deadline,
2015
- timeout_ms: remainingMs,
2016
- bridge_timeout_ms: remainingMs,
2017
- cdp_timeout_ms: remainingMs,
2018
- controller_ready_timeout_ms: remainingMs,
2025
+ ...phaseInput.node,
2026
+ action: 'metamask.wallet.ensure_unlocked',
2019
2027
  target_timeout_ms: targetTimeoutMs,
2020
2028
  unlock_timeout_ms: unlockTimeoutMs,
2021
2029
  },
2022
- };
2023
- };
2024
- // Product edits require an app restart before proof. Mobile relocks on restart,
2025
- // so a deterministic Perps start state must restore the fixture-backed wallet
2026
- // prerequisite before it navigates or converges controller state.
2027
- const walletInput = stateInput();
2028
- const wallet = await ensureUnlocked({
2029
- ...walletInput,
2030
- action: 'metamask.wallet.ensure_unlocked',
2031
- node: { ...walletInput.node, action: 'metamask.wallet.ensure_unlocked' },
2030
+ });
2032
2031
  });
2033
- const accountInput = stateInput();
2034
2032
  const account = typeof config.account === 'string' && config.account.trim()
2035
- ? await selectAccount({
2036
- ...accountInput,
2033
+ ? await budget.phase('account', (phaseInput) => selectAccount({
2034
+ ...phaseInput,
2037
2035
  action: 'metamask.wallet.select_account',
2038
2036
  node: {
2039
- ...accountInput.node,
2037
+ ...phaseInput.node,
2040
2038
  action: 'metamask.wallet.select_account',
2041
2039
  ...(/^0x[0-9a-f]{40}$/iu.test(config.account.trim())
2042
2040
  ? { address: config.account.trim() }
2043
2041
  : { name: config.account.trim() }),
2044
2042
  },
2045
- })
2043
+ }))
2046
2044
  : { skipped: true };
2047
2045
  // Entering Perps initializes the provider client on a freshly launched app.
2048
2046
  // State reads before this navigation fail with CLIENT_NOT_INITIALIZED.
2049
- const navigation = await applyStateNavigation(stateInput(), config);
2050
- const provider = await ensureProvider(stateInput(), config);
2051
- const network = await ensureNetwork(stateInput(), config);
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));
2052
2056
  let resolvedNavigation = provider.changed || network.changed
2053
- ? await applyStateNavigation(stateInput(), config)
2057
+ ? await budget.phase(
2058
+ 'navigation after provider or network change',
2059
+ (phaseInput) => applyStateNavigation(phaseInput, config),
2060
+ navigationSlice,
2061
+ )
2054
2062
  : navigation;
2055
- const modeInput = stateInput();
2056
2063
  const marketMode = config.market_mode
2057
- ? await ensureMode({
2058
- ...modeInput,
2064
+ ? await budget.phase('market mode', (phaseInput) => ensureMode({
2065
+ ...phaseInput,
2059
2066
  action: 'metamask.perps.ensure_mode',
2060
2067
  node: {
2061
- ...modeInput.node,
2068
+ ...phaseInput.node,
2062
2069
  action: 'metamask.perps.ensure_mode',
2063
2070
  mode: config.market_mode,
2064
2071
  },
2065
- })
2072
+ }))
2066
2073
  : { skipped: true };
2067
2074
  if (marketMode.alreadySelected === false) {
2068
- resolvedNavigation = await applyStateNavigation(stateInput(), config);
2069
- }
2070
- const tutorial = await applyTutorialState(stateInput(), config);
2071
- const readyToTrade = await assertReadyToTrade(stateInput(), config);
2072
- const balance = await assertBalance(stateInput(), config);
2073
- const strategies = await applyStrategiesState(
2074
- stateInput(),
2075
- config.strategies,
2076
- );
2077
- const orders = await applyOrdersState(stateInput(), config.orders);
2078
- const positions = await applyPositionsState(stateInput(), config.positions);
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));
2079
2093
  return {
2080
2094
  action: input.action,
2081
2095
  profile: config.profile ?? params.profile ?? null,
@@ -2104,16 +2118,7 @@ export async function startState(input) {
2104
2118
  }
2105
2119
 
2106
2120
  export async function teardownState(input) {
2107
- const timeoutMs = Math.max(1, Number(input.node?.timeout_ms ?? 30_000));
2108
- const deadline = deadlineFromTimeout(input, timeoutMs);
2109
- const stateInput = (phase) => {
2110
- if (Date.now() >= deadline) {
2111
- throw new Error(
2112
- `metamask.perps.teardown_state timed out before ${phase}.`,
2113
- );
2114
- }
2115
- return withRemainingDeadline(input, deadline);
2116
- };
2121
+ const budget = createSetupBudget(input, 'metamask.perps.teardown_state');
2117
2122
  const params = paramsForState(input);
2118
2123
  const defaults = {
2119
2124
  provider: 'hyperliquid',
@@ -2124,28 +2129,23 @@ export async function teardownState(input) {
2124
2129
  strategies: params.market || params.symbol || params.markets || params.symbols ? { state: 'none', mode: 'matching' } : false,
2125
2130
  };
2126
2131
  const config = mergeStateConfig(defaults, params);
2127
- const environment = await assertRequestedRuntimeState(
2128
- stateInput('checking the requested environment'),
2129
- config,
2130
- );
2131
- const strategies = await applyStrategiesState(
2132
- stateInput('cleaning strategies'),
2133
- config.strategies,
2134
- );
2135
- const orders = await applyOrdersState(
2136
- stateInput('cleaning orders'),
2137
- config.orders,
2138
- );
2139
- const positions = await applyPositionsState(
2140
- stateInput('closing positions'),
2141
- config.positions,
2132
+ const environment = await budget.phase(
2133
+ 'checking the requested environment',
2134
+ (phaseInput) => assertRequestedRuntimeState(phaseInput, config),
2142
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));
2143
2142
  const navigation =
2144
2143
  config.page === false
2145
2144
  ? { skipped: true }
2146
- : await applyStateNavigation(
2147
- stateInput('restoring the requested page'),
2148
- config,
2145
+ : await budget.phase(
2146
+ 'restoring the requested page',
2147
+ (phaseInput) => applyStateNavigation(phaseInput, config),
2148
+ { sliceMs: budget.navigationMs },
2149
2149
  );
2150
2150
  return {
2151
2151
  action: input.action,