@deeeed/metamask-harness 0.2.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 +161 -0
- package/README.md +140 -0
- package/bin/mm-harness +99 -0
- package/docs/CHEATSHEET.md +61 -0
- package/docs/CLI-SPEC.md +915 -0
- package/docs/MENTAL-MODEL.md +295 -0
- package/docs/architecture.md +367 -0
- package/docs/extension-runtime-commands.md +60 -0
- package/docs/harness-cli.md +43 -0
- package/docs/live-adapter-contract.md +188 -0
- package/docs/package-boundaries.md +47 -0
- package/docs/perps-flow-catalog.md +235 -0
- package/docs/recipe-libraries.md +95 -0
- package/docs/runtime-file-conventions.md +36 -0
- package/library/actions/core/perps/_controller.mjs +727 -0
- package/library/actions/core/perps/assert_orders.mjs +53 -0
- package/library/actions/core/perps/assert_positions.mjs +52 -0
- package/library/actions/core/perps/close_orders.mjs +97 -0
- package/library/actions/core/perps/close_positions.mjs +118 -0
- package/library/actions/core/perps/ensure_orders.mjs +40 -0
- package/library/actions/core/perps/ensure_positions.mjs +37 -0
- package/library/actions/core/perps/place_order.mjs +201 -0
- package/library/actions/core/perps/read_account.mjs +30 -0
- package/library/actions/core/perps/read_orders.mjs +27 -0
- package/library/actions/core/perps/read_positions.mjs +27 -0
- package/library/actions/core/perps/start_state.mjs +92 -0
- package/library/actions/core/perps/teardown_state.mjs +86 -0
- package/library/actions/extension/perps/assert_orders.mjs +11 -0
- package/library/actions/extension/perps/assert_positions.mjs +11 -0
- package/library/actions/extension/perps/close_orders.mjs +8 -0
- package/library/actions/extension/perps/close_positions.mjs +8 -0
- package/library/actions/extension/perps/ensure_orders.mjs +4 -0
- package/library/actions/extension/perps/ensure_positions.mjs +4 -0
- package/library/actions/extension/perps/perps.mjs +730 -0
- package/library/actions/extension/perps/place_order.mjs +7 -0
- package/library/actions/extension/perps/read_orders.mjs +4 -0
- package/library/actions/extension/perps/read_positions.mjs +3 -0
- package/library/actions/extension/platform/cdp.mjs +541 -0
- package/library/actions/extension/ui/navigate.mjs +44 -0
- package/library/actions/extension/wallet/ensure_unlocked.mjs +36 -0
- package/library/actions/extension/wallet/read_state.mjs +27 -0
- package/library/actions/extension/wallet/select_account.mjs +48 -0
- package/library/actions/extension/wallet/setup.mjs +35 -0
- package/library/actions/mobile/app-overlay/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +185 -0
- package/library/actions/mobile/app-overlay/app/dev-tools/AgenticService/AgenticService.ts.patch +1662 -0
- package/library/actions/mobile/bridge-runtime/cdp-bridge.cjs +686 -0
- package/library/actions/mobile/bridge-runtime/lib/cdp-eval.cjs +110 -0
- package/library/actions/mobile/bridge-runtime/lib/config.cjs +39 -0
- package/library/actions/mobile/bridge-runtime/lib/issue-capture.cjs +446 -0
- package/library/actions/mobile/bridge-runtime/lib/target-discovery.cjs +204 -0
- package/library/actions/mobile/bridge-runtime/lib/ws-client.cjs +108 -0
- package/library/actions/mobile/bridge-runtime/setup-wallet.sh +442 -0
- package/library/actions/mobile/perps/assert_orders.mjs +11 -0
- package/library/actions/mobile/perps/assert_positions.mjs +11 -0
- package/library/actions/mobile/perps/close_orders.mjs +8 -0
- package/library/actions/mobile/perps/close_positions.mjs +8 -0
- package/library/actions/mobile/perps/ensure_orders.mjs +4 -0
- package/library/actions/mobile/perps/ensure_positions.mjs +4 -0
- package/library/actions/mobile/perps/perps.mjs +709 -0
- package/library/actions/mobile/perps/place_order.mjs +7 -0
- package/library/actions/mobile/perps/read_orders.mjs +4 -0
- package/library/actions/mobile/perps/read_positions.mjs +3 -0
- package/library/actions/mobile/platform/bridge.mjs +283 -0
- package/library/actions/mobile/ui/navigate.mjs +38 -0
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +107 -0
- package/library/actions/mobile/wallet/home.mjs +35 -0
- package/library/actions/mobile/wallet/read_state.mjs +40 -0
- package/library/actions/mobile/wallet/select_account.mjs +48 -0
- package/library/actions/mobile/wallet/setup.mjs +220 -0
- package/library/flows/perps.flows.json +64 -0
- package/library/library.json +7 -0
- package/library/manifests/core.action-manifest.json +1282 -0
- package/library/manifests/extension.action-manifest.json +1749 -0
- package/library/manifests/mobile.action-manifest.json +1753 -0
- package/library/recipes/action-validation.extension.recipe.json +417 -0
- package/library/recipes/action-validation.mobile.recipe.json +422 -0
- package/library/recipes/order-lifecycle.core.recipe.json +78 -0
- package/library/recipes/perps-lifecycle.recipe.json +194 -0
- package/library/recipes/read-markets.core.recipe.json +38 -0
- package/library/recipes/smoke.extension.recipe.json +31 -0
- package/library/recipes/smoke.mobile.recipe.json +31 -0
- package/library/recipes/trading-lifecycle.core.recipe.json +76 -0
- package/orchestration/compat-overlays/README.md +19 -0
- package/orchestration/compat-overlays/mobile/README.md +13 -0
- package/orchestration/compat-overlays/mobile/rn81-message-event-source.patch +42 -0
- package/orchestration/core/cleanup.sh +37 -0
- package/orchestration/core/inject.sh +154 -0
- package/orchestration/doctor.mjs +72 -0
- package/orchestration/extension/cleanup.mjs +60 -0
- package/orchestration/extension/console-tail.mjs +228 -0
- package/orchestration/extension/ensure-browser.sh +416 -0
- package/orchestration/extension/ensure-ready.ts +185 -0
- package/orchestration/extension/extension-id.ts +107 -0
- package/orchestration/extension/inject.mjs +266 -0
- package/orchestration/extension/launch-browser.cjs +216 -0
- package/orchestration/extension/launch.sh +175 -0
- package/orchestration/extension/live.sh +320 -0
- package/orchestration/extension/pin-remote-flags.cjs +45 -0
- package/orchestration/extension/readiness.mjs +414 -0
- package/orchestration/extension/refresh-build.sh +190 -0
- package/orchestration/extension/runtime-decision.ts +445 -0
- package/orchestration/extension/runtime.ts +407 -0
- package/orchestration/extension/seed-fixture.sh +177 -0
- package/orchestration/extension/sidepanel-toggle.sh +291 -0
- package/orchestration/extension/snapshot-dist.sh +84 -0
- package/orchestration/extension/start-watch.sh +339 -0
- package/orchestration/extension/wallet-fixture-state.cjs +1086 -0
- package/orchestration/lib/activate-repo-node.sh +144 -0
- package/orchestration/lib/cli-color.mjs +84 -0
- package/orchestration/lib/cli-commands.mjs +243 -0
- package/orchestration/lib/cli-home.mjs +354 -0
- package/orchestration/lib/cli-ux.sh +252 -0
- package/orchestration/lib/cli-version.mjs +123 -0
- package/orchestration/lib/ensure-runner-deps.sh +56 -0
- package/orchestration/lib/harness-path.sh +55 -0
- package/orchestration/lib/hash-helpers.sh +44 -0
- package/orchestration/lib/json-field.sh +23 -0
- package/orchestration/lib/log-tui.mjs +304 -0
- package/orchestration/lib/open-debug.mjs +317 -0
- package/orchestration/lib/path-defaults.json +4 -0
- package/orchestration/lib/progress.mjs +107 -0
- package/orchestration/lib/recipe-paths.mjs +26 -0
- package/orchestration/lib/resolve-farmslot-ports.sh +144 -0
- package/orchestration/manifest.json +358 -0
- package/orchestration/mobile/cleanup.sh +192 -0
- package/orchestration/mobile/deps-markers.ts +21 -0
- package/orchestration/mobile/inject.sh +681 -0
- package/orchestration/mobile/launch.sh +137 -0
- package/orchestration/mobile/live.sh +125 -0
- package/orchestration/mobile/runtime-decision.ts +292 -0
- package/orchestration/porcelain/metamask-recipe +99 -0
- package/orchestration/porcelain/mm-recipe +1591 -0
- package/orchestration/porcelain/mme-recipe +1181 -0
- package/package.json +59 -0
- package/runner/extension/verify.sh +511 -0
- package/runner/mobile/verify.sh +501 -0
- package/runner/src/adapters.ts +601 -0
- package/runner/src/cli.ts +1820 -0
- package/runner/src/commands/debug.ts +44 -0
- package/runner/src/commands/fixtures.ts +99 -0
- package/runner/src/commands/launch.ts +397 -0
- package/runner/src/commands/logs.ts +60 -0
- package/runner/src/commands/shared.ts +138 -0
- package/runner/src/completions-cache.ts +86 -0
- package/runner/src/doctor.ts +203 -0
- package/runner/src/harness.ts +516 -0
- package/runner/src/heal-bounds.ts +179 -0
- package/runner/src/index.ts +6 -0
- package/runner/src/live-adapter-contract.ts +274 -0
- package/runner/src/manifest.ts +47 -0
- package/runner/src/mm-harness-cli.ts +488 -0
- package/runner/src/paths.ts +198 -0
- package/runner/src/recording-target.ts +147 -0
- package/runner/src/run-recording.ts +329 -0
- package/runner/src/runner.ts +108 -0
- package/runner/src/types.ts +57 -0
- package/scripts/completions.sh +125 -0
- package/scripts/install-completions.sh +62 -0
package/library/actions/mobile/app-overlay/app/dev-tools/AgenticService/AgenticService.ts.patch
ADDED
|
@@ -0,0 +1,1662 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgenticService — __DEV__-only bridge for Recipe v1 runners.
|
|
3
|
+
*
|
|
4
|
+
* This file is not bundled in production builds. It exposes a stable control
|
|
5
|
+
* surface for deterministic local recipes while keeping user-visible proof
|
|
6
|
+
* flows on the real app path.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
NavigationContainerRef,
|
|
10
|
+
ParamListBase,
|
|
11
|
+
} from '@react-navigation/native';
|
|
12
|
+
import { Dimensions, Platform } from 'react-native';
|
|
13
|
+
import Logger from '../../util/Logger';
|
|
14
|
+
import ReduxService from '../../core/redux';
|
|
15
|
+
import { persistor } from '../../store';
|
|
16
|
+
import Engine from '../../core/Engine';
|
|
17
|
+
import { Engine as EngineClass } from '../../core/Engine/Engine';
|
|
18
|
+
import {
|
|
19
|
+
passwordSet,
|
|
20
|
+
setExistingUser,
|
|
21
|
+
logIn,
|
|
22
|
+
seedphraseBackedUp,
|
|
23
|
+
setMultichainAccountsIntroModalSeen,
|
|
24
|
+
} from '../../actions/user';
|
|
25
|
+
import { setCompletedOnboarding } from '../../actions/onboarding';
|
|
26
|
+
import { mnemonicPhraseToBytes } from '@metamask/key-tree';
|
|
27
|
+
import { AccountImportStrategy } from '@metamask/keyring-controller';
|
|
28
|
+
import type { AccountGroupId } from '@metamask/account-api';
|
|
29
|
+
import StorageWrapper from '../../store/storage-wrapper';
|
|
30
|
+
import {
|
|
31
|
+
OPTIN_META_METRICS_UI_SEEN,
|
|
32
|
+
PERPS_GTM_MODAL_SHOWN,
|
|
33
|
+
PREDICT_GTM_MODAL_SHOWN,
|
|
34
|
+
REWARDS_GTM_MODAL_SHOWN,
|
|
35
|
+
} from '../../constants/storage';
|
|
36
|
+
import { analytics } from '../../util/analytics/analytics';
|
|
37
|
+
import {
|
|
38
|
+
setDataCollectionForMarketing,
|
|
39
|
+
setOsAuthEnabled,
|
|
40
|
+
} from '../../actions/security';
|
|
41
|
+
import { setLockTime } from '../../actions/settings';
|
|
42
|
+
import AccountTreeInitService from '../../multichain-accounts/AccountTreeInitService';
|
|
43
|
+
import Routes from '../../constants/navigation/Routes';
|
|
44
|
+
import SecureKeychain from '../../core/SecureKeychain';
|
|
45
|
+
import AUTHENTICATION_TYPE from '../../constants/userProperties';
|
|
46
|
+
import DevLogger from '../../core/SDKConnect/utils/DevLogger';
|
|
47
|
+
import { importNewSecretRecoveryPhrase } from '../../actions/multiSrp';
|
|
48
|
+
import { bufferToHex, privateToAddress } from 'ethereumjs-util';
|
|
49
|
+
import Authentication from '../../core/Authentication';
|
|
50
|
+
import { emitStepHud } from './AgentStepHud';
|
|
51
|
+
import { Wallet as EthersWallet } from 'ethers';
|
|
52
|
+
import PerpsConnectionManager from '../../components/UI/Perps/services/PerpsConnectionManager';
|
|
53
|
+
import { getStreamManagerInstance } from '../../components/UI/Perps/providers/PerpsStreamManager';
|
|
54
|
+
|
|
55
|
+
// ─── Fiber tree types ──────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Minimal React fiber node shape used to walk the component tree
|
|
59
|
+
* via __REACT_DEVTOOLS_GLOBAL_HOOK__.
|
|
60
|
+
*/
|
|
61
|
+
interface FiberNode {
|
|
62
|
+
child: FiberNode | null;
|
|
63
|
+
sibling: FiberNode | null;
|
|
64
|
+
return: FiberNode | null;
|
|
65
|
+
memoizedProps: {
|
|
66
|
+
testID?: string;
|
|
67
|
+
onPress?: (...args: unknown[]) => unknown;
|
|
68
|
+
onChangeText?: (text: string) => void;
|
|
69
|
+
[key: string]: unknown;
|
|
70
|
+
} | null;
|
|
71
|
+
stateNode: {
|
|
72
|
+
scrollTo?: (opts: { y: number; animated: boolean }) => void;
|
|
73
|
+
scrollToOffset?: (opts: { offset: number; animated: boolean }) => void;
|
|
74
|
+
measure?: (
|
|
75
|
+
callback: (
|
|
76
|
+
x: number,
|
|
77
|
+
y: number,
|
|
78
|
+
width: number,
|
|
79
|
+
height: number,
|
|
80
|
+
pageX: number,
|
|
81
|
+
pageY: number,
|
|
82
|
+
) => void,
|
|
83
|
+
) => void;
|
|
84
|
+
measureInWindow?: (
|
|
85
|
+
callback: (x: number, y: number, width: number, height: number) => void,
|
|
86
|
+
) => void;
|
|
87
|
+
[key: string]: unknown;
|
|
88
|
+
} | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface FiberRoot {
|
|
92
|
+
current: FiberNode | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface ReactDevToolsHook {
|
|
96
|
+
renderers: Map<number, unknown>;
|
|
97
|
+
getFiberRoots?: (id: number) => Set<FiberRoot>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Shape of the __DEV__-only agentic bridge on globalThis. */
|
|
101
|
+
interface AgenticHudStep {
|
|
102
|
+
id: string;
|
|
103
|
+
status?: string;
|
|
104
|
+
intent: string;
|
|
105
|
+
progress?: { current?: number; total?: number };
|
|
106
|
+
detail?: string;
|
|
107
|
+
error?: string;
|
|
108
|
+
nodeId?: string;
|
|
109
|
+
debug?: { nodeId?: string; proofTarget?: unknown };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface AgenticBridge {
|
|
113
|
+
platform: string;
|
|
114
|
+
replayHarnessPatch?: string;
|
|
115
|
+
navigate: (name: string, params?: object) => void;
|
|
116
|
+
getRoute: () => unknown;
|
|
117
|
+
getState: () => unknown;
|
|
118
|
+
canGoBack: () => boolean;
|
|
119
|
+
goBack: () => void;
|
|
120
|
+
listAccounts: () => { id: string; address: string; name: string }[];
|
|
121
|
+
getSelectedAccount: () => { id: string; address: string; name: string };
|
|
122
|
+
pressTestId: (testId: string) => {
|
|
123
|
+
ok: boolean;
|
|
124
|
+
testId?: string;
|
|
125
|
+
error?: string;
|
|
126
|
+
};
|
|
127
|
+
pressText: (
|
|
128
|
+
text: string,
|
|
129
|
+
options?: { requiredTexts?: string[]; maxTexts?: number },
|
|
130
|
+
) => { ok: boolean; text?: string; error?: string };
|
|
131
|
+
scrollView: (options?: {
|
|
132
|
+
testId?: string;
|
|
133
|
+
offset?: number;
|
|
134
|
+
animated?: boolean;
|
|
135
|
+
}) => {
|
|
136
|
+
ok: boolean;
|
|
137
|
+
error?: string;
|
|
138
|
+
testId?: string;
|
|
139
|
+
offset?: number;
|
|
140
|
+
animated?: boolean;
|
|
141
|
+
};
|
|
142
|
+
setInput: (
|
|
143
|
+
testId: string,
|
|
144
|
+
value: string,
|
|
145
|
+
) => {
|
|
146
|
+
ok: boolean;
|
|
147
|
+
testId?: string;
|
|
148
|
+
value?: string;
|
|
149
|
+
error?: string;
|
|
150
|
+
};
|
|
151
|
+
getTextByTestId: (
|
|
152
|
+
testId: string,
|
|
153
|
+
options?: { all?: boolean },
|
|
154
|
+
) => string | string[] | null;
|
|
155
|
+
getAncestorTextsByTestId: (
|
|
156
|
+
testId: string,
|
|
157
|
+
options?: { requiredLabels?: string[]; maxTexts?: number },
|
|
158
|
+
) => string[] | null;
|
|
159
|
+
getRowValue: (
|
|
160
|
+
label: string,
|
|
161
|
+
pattern: string,
|
|
162
|
+
options?: {
|
|
163
|
+
anchorTestId?: string;
|
|
164
|
+
requiredLabels?: string[];
|
|
165
|
+
maxTexts?: number;
|
|
166
|
+
},
|
|
167
|
+
) => string | null;
|
|
168
|
+
switchAccount: (address: string) => {
|
|
169
|
+
switched: boolean;
|
|
170
|
+
id: string;
|
|
171
|
+
address: string;
|
|
172
|
+
name: string;
|
|
173
|
+
};
|
|
174
|
+
setupWallet: (fixture: {
|
|
175
|
+
password: string;
|
|
176
|
+
accounts: {
|
|
177
|
+
type: 'mnemonic' | 'privateKey';
|
|
178
|
+
value: string;
|
|
179
|
+
name?: string;
|
|
180
|
+
count?: number;
|
|
181
|
+
numberOfAccounts?: number;
|
|
182
|
+
names?: string[];
|
|
183
|
+
}[];
|
|
184
|
+
settings?: {
|
|
185
|
+
metametrics?: boolean;
|
|
186
|
+
skipGtmModals?: boolean;
|
|
187
|
+
skipPerpsTutorial?: boolean;
|
|
188
|
+
autoLockNever?: boolean;
|
|
189
|
+
deviceAuthEnabled?: boolean;
|
|
190
|
+
};
|
|
191
|
+
}) => Promise<{
|
|
192
|
+
ok: boolean;
|
|
193
|
+
error?: string;
|
|
194
|
+
step?: string;
|
|
195
|
+
accounts?: { address: string; name: string }[];
|
|
196
|
+
}>;
|
|
197
|
+
applyWalletFixture: (
|
|
198
|
+
fixture: Parameters<AgenticBridge['setupWallet']>[0],
|
|
199
|
+
) => Promise<{
|
|
200
|
+
ok: boolean;
|
|
201
|
+
error?: string;
|
|
202
|
+
accounts?: { address: string; name: string }[];
|
|
203
|
+
}>;
|
|
204
|
+
showStep: (step: AgenticHudStep) => void;
|
|
205
|
+
hideStep: () => void;
|
|
206
|
+
refreshPerpsStreams: () => Promise<{ ok: boolean; positions: number }>;
|
|
207
|
+
findFiberByTestId: (testId: string) => boolean;
|
|
208
|
+
queryUiTarget: (options: {
|
|
209
|
+
testId?: string;
|
|
210
|
+
textContains?: string;
|
|
211
|
+
visibility?: 'tree' | 'viewport';
|
|
212
|
+
}) => Promise<{
|
|
213
|
+
present: boolean;
|
|
214
|
+
visible: boolean;
|
|
215
|
+
visibility: 'tree' | 'viewport';
|
|
216
|
+
testId?: string;
|
|
217
|
+
textContains?: string;
|
|
218
|
+
textMatched?: boolean;
|
|
219
|
+
rect?: { x: number; y: number; width: number; height: number };
|
|
220
|
+
viewport?: { width: number; height: number };
|
|
221
|
+
error?: string;
|
|
222
|
+
}>;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
type WalletFixture = Parameters<AgenticBridge['setupWallet']>[0];
|
|
226
|
+
|
|
227
|
+
interface FixtureKeyringController {
|
|
228
|
+
isUnlocked: () => boolean;
|
|
229
|
+
submitPassword: (password: string) => Promise<void>;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function logFixtureStep(step: string, detail?: Record<string, unknown>) {
|
|
233
|
+
DevLogger.log(`[AgenticService] fixture ${step}`, detail);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function withFixtureTimeout<T>(
|
|
237
|
+
label: string,
|
|
238
|
+
work: Promise<T>,
|
|
239
|
+
timeoutMs = 30_000,
|
|
240
|
+
): Promise<T> {
|
|
241
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
242
|
+
try {
|
|
243
|
+
return await Promise.race([
|
|
244
|
+
work,
|
|
245
|
+
new Promise<never>((_resolve, reject) => {
|
|
246
|
+
timer = setTimeout(
|
|
247
|
+
() => reject(new Error(`Fixture step timed out: ${label}`)),
|
|
248
|
+
timeoutMs,
|
|
249
|
+
);
|
|
250
|
+
}),
|
|
251
|
+
]);
|
|
252
|
+
} finally {
|
|
253
|
+
if (timer) {
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const sleep = (ms: number): Promise<void> =>
|
|
260
|
+
new Promise((resolve) => setTimeout(resolve, ms));
|
|
261
|
+
|
|
262
|
+
async function waitForFixtureCondition(
|
|
263
|
+
label: string,
|
|
264
|
+
condition: () => boolean,
|
|
265
|
+
timeoutMs = 90_000,
|
|
266
|
+
intervalMs = 500,
|
|
267
|
+
): Promise<void> {
|
|
268
|
+
const deadline = Date.now() + timeoutMs;
|
|
269
|
+
while (Date.now() < deadline) {
|
|
270
|
+
if (condition()) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
await sleep(intervalMs);
|
|
274
|
+
}
|
|
275
|
+
throw new Error(`Fixture step timed out: ${label}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function createFixtureHdAccount(
|
|
279
|
+
label: string,
|
|
280
|
+
createAccount: () => Promise<unknown>,
|
|
281
|
+
accountExists: () => boolean,
|
|
282
|
+
): Promise<void> {
|
|
283
|
+
await withFixtureTimeout(label, createAccount(), 90_000);
|
|
284
|
+
await waitForFixtureCondition(label, accountExists, 30_000);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
declare global {
|
|
288
|
+
// eslint-disable-next-line no-var
|
|
289
|
+
var __AGENTIC__: AgenticBridge | undefined;
|
|
290
|
+
// eslint-disable-next-line no-var
|
|
291
|
+
var __REACT_DEVTOOLS_GLOBAL_HOOK__: ReactDevToolsHook | undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ─── Fiber tree helpers ─────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Walk a fiber sub-tree depth-first, calling `visitor` on each node.
|
|
298
|
+
* Returns true as soon as visitor returns true (short-circuit).
|
|
299
|
+
* Sibling traversal is iterative to avoid stack overflow on wide trees.
|
|
300
|
+
*/
|
|
301
|
+
function walkFiber(
|
|
302
|
+
fiber: FiberNode | null,
|
|
303
|
+
visitor: (f: FiberNode) => boolean,
|
|
304
|
+
): boolean {
|
|
305
|
+
let current: FiberNode | null = fiber;
|
|
306
|
+
while (current) {
|
|
307
|
+
if (visitor(current)) return true;
|
|
308
|
+
if (walkFiber(current.child, visitor)) return true;
|
|
309
|
+
current = current.sibling;
|
|
310
|
+
}
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Find the first fiber node whose `testID` prop matches.
|
|
316
|
+
*/
|
|
317
|
+
function findFiberByTestId(
|
|
318
|
+
fiber: FiberNode | null,
|
|
319
|
+
testId: string,
|
|
320
|
+
): FiberNode | null {
|
|
321
|
+
let result: FiberNode | null = null;
|
|
322
|
+
walkFiber(fiber, (f) => {
|
|
323
|
+
if (f.memoizedProps?.testID === testId) {
|
|
324
|
+
result = f;
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
return false;
|
|
328
|
+
});
|
|
329
|
+
return result;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Iterate all React renderer roots and call `visitor` on each root fiber.
|
|
334
|
+
* Returns true if any visitor call returns true.
|
|
335
|
+
*/
|
|
336
|
+
function walkFiberRoots(visitor: (rootFiber: FiberNode) => boolean): boolean {
|
|
337
|
+
const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
338
|
+
if (!hook?.renderers) return false;
|
|
339
|
+
|
|
340
|
+
for (const [id] of hook.renderers) {
|
|
341
|
+
const fiberRoots = hook.getFiberRoots?.(id);
|
|
342
|
+
if (!fiberRoots) continue;
|
|
343
|
+
let found = false;
|
|
344
|
+
fiberRoots.forEach((root) => {
|
|
345
|
+
if (!found && root.current) {
|
|
346
|
+
found = visitor(root.current);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
if (found) return true;
|
|
350
|
+
}
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ─── Shared helpers ─────────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
/** Map an internal account to the slim shape exposed by the bridge. */
|
|
357
|
+
function toAccountSummary(a: {
|
|
358
|
+
id: string;
|
|
359
|
+
address: string;
|
|
360
|
+
metadata: { name: string };
|
|
361
|
+
}): { id: string; address: string; name: string } {
|
|
362
|
+
return { id: a.id, address: a.address, name: a.metadata.name };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function getFixtureMnemonicCount(account?: {
|
|
366
|
+
count?: number;
|
|
367
|
+
numberOfAccounts?: number;
|
|
368
|
+
}): number {
|
|
369
|
+
const raw = account?.count ?? account?.numberOfAccounts ?? 1;
|
|
370
|
+
const count = Number(raw);
|
|
371
|
+
if (!Number.isInteger(count) || count < 1 || count > 100) {
|
|
372
|
+
throw new Error(`Invalid mnemonic account count: ${raw}`);
|
|
373
|
+
}
|
|
374
|
+
return count;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function getFixtureAccountNames(
|
|
378
|
+
account: { name?: string; names?: string[] } | undefined,
|
|
379
|
+
count: number,
|
|
380
|
+
): string[] {
|
|
381
|
+
return Array.from({ length: count }, (_unused, index) => {
|
|
382
|
+
const explicitName = account?.names?.[index];
|
|
383
|
+
if (typeof explicitName === 'string' && explicitName.trim()) {
|
|
384
|
+
return explicitName.trim();
|
|
385
|
+
}
|
|
386
|
+
if (
|
|
387
|
+
index === 0 &&
|
|
388
|
+
typeof account?.name === 'string' &&
|
|
389
|
+
account.name.trim()
|
|
390
|
+
) {
|
|
391
|
+
return account.name.trim();
|
|
392
|
+
}
|
|
393
|
+
return `Account ${index + 1}`;
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
interface FixtureEvmAccount {
|
|
398
|
+
id: string;
|
|
399
|
+
address: string;
|
|
400
|
+
metadata: { name: string; keyring?: { type?: string } };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function findEvmAccounts(accounts: Record<string, unknown>) {
|
|
404
|
+
return (Object.values(accounts) as FixtureEvmAccount[]).filter((account) =>
|
|
405
|
+
account.address?.startsWith('0x'),
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function isHdFixtureAccount(account: FixtureEvmAccount) {
|
|
410
|
+
return account.metadata?.keyring?.type === 'HD Key Tree';
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function normalizePrivateKey(value: string) {
|
|
414
|
+
return value.startsWith('0x') ? value.slice(2) : value;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function getPrivateKeyAddress(value: string) {
|
|
418
|
+
return bufferToHex(
|
|
419
|
+
privateToAddress(Buffer.from(normalizePrivateKey(value), 'hex')),
|
|
420
|
+
).toLowerCase();
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function isExpectedLegacyAccountTreeInitError(error: unknown) {
|
|
424
|
+
const message = String((error as Error).message || error);
|
|
425
|
+
return (
|
|
426
|
+
message.includes('Money Keyring') ||
|
|
427
|
+
message.includes('No keyringBuilder found')
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function initializeFixtureAccountTree(
|
|
432
|
+
options: {
|
|
433
|
+
allowLegacyAccountTreeInitFailure?: boolean;
|
|
434
|
+
} = {},
|
|
435
|
+
) {
|
|
436
|
+
try {
|
|
437
|
+
await AccountTreeInitService.initializeAccountTree();
|
|
438
|
+
} catch (error) {
|
|
439
|
+
if (
|
|
440
|
+
!options.allowLegacyAccountTreeInitFailure ||
|
|
441
|
+
!isExpectedLegacyAccountTreeInitError(error)
|
|
442
|
+
) {
|
|
443
|
+
throw error;
|
|
444
|
+
}
|
|
445
|
+
// Historical replay vaults can lack the multichain keyring builder. In that
|
|
446
|
+
// mode AccountsController remains the source of truth for fixture validation
|
|
447
|
+
// and account labels; group renames are skipped when no account-tree group
|
|
448
|
+
// exists.
|
|
449
|
+
Logger.log(
|
|
450
|
+
'[AgenticService] Skipping fixture account-tree refresh for historical replay fixture setup',
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function markFixtureMetricsOptInSeenIfNeeded(fixture: WalletFixture) {
|
|
456
|
+
if (fixture.settings?.metametrics !== undefined) {
|
|
457
|
+
await StorageWrapper.setItem(OPTIN_META_METRICS_UI_SEEN, 'true');
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function resetNavigationToWalletHome(
|
|
462
|
+
navRef: NavigationContainerRef<ParamListBase>,
|
|
463
|
+
) {
|
|
464
|
+
navRef.reset({
|
|
465
|
+
routes: [{ name: Routes.ONBOARDING.HOME_NAV }],
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function unlockFixtureVault(
|
|
470
|
+
fixture: WalletFixture,
|
|
471
|
+
keyringController: FixtureKeyringController,
|
|
472
|
+
) {
|
|
473
|
+
if (keyringController.isUnlocked()) {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
logFixtureStep('unlock-via-authentication');
|
|
478
|
+
await Authentication.unlockWallet({ password: fixture.password });
|
|
479
|
+
if (keyringController.isUnlocked()) {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// A previous interrupted fixture setup can leave a vault present while Redux
|
|
484
|
+
// still marks the user as new. Authentication.unlockWallet then navigates to
|
|
485
|
+
// onboarding without submitting the password. Recover that dev-only partial
|
|
486
|
+
// state before materializing accounts.
|
|
487
|
+
logFixtureStep('unlock-via-keyring-fallback');
|
|
488
|
+
await keyringController.submitPassword(fixture.password);
|
|
489
|
+
if (!keyringController.isUnlocked()) {
|
|
490
|
+
throw new Error('Fixture vault remained locked after password submit');
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const store = ReduxService.store;
|
|
494
|
+
store.dispatch(passwordSet());
|
|
495
|
+
store.dispatch(seedphraseBackedUp());
|
|
496
|
+
store.dispatch(setCompletedOnboarding(true));
|
|
497
|
+
store.dispatch(setExistingUser(true));
|
|
498
|
+
store.dispatch(logIn());
|
|
499
|
+
store.dispatch(setMultichainAccountsIntroModalSeen(true));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function getMnemonicFirstAddress(value: string) {
|
|
503
|
+
return EthersWallet.fromMnemonic(
|
|
504
|
+
value,
|
|
505
|
+
"m/44'/60'/0'/0/0",
|
|
506
|
+
).address.toLowerCase();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
interface FixtureHdWallet {
|
|
510
|
+
keyringId?: string;
|
|
511
|
+
accounts: FixtureEvmAccount[];
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function getHdFixtureWallets(
|
|
515
|
+
accountsController: {
|
|
516
|
+
state: { internalAccounts: { accounts: Record<string, unknown> } };
|
|
517
|
+
},
|
|
518
|
+
accountTreeController: {
|
|
519
|
+
state: { accountTree?: { wallets?: Record<string, unknown> } };
|
|
520
|
+
},
|
|
521
|
+
): FixtureHdWallet[] {
|
|
522
|
+
const evmById = new Map(
|
|
523
|
+
findEvmAccounts(accountsController.state.internalAccounts.accounts).map(
|
|
524
|
+
(account) => [account.id, account],
|
|
525
|
+
),
|
|
526
|
+
);
|
|
527
|
+
const wallets = accountTreeController.state.accountTree?.wallets ?? {};
|
|
528
|
+
const result: FixtureHdWallet[] = [];
|
|
529
|
+
for (const wallet of Object.values(wallets) as {
|
|
530
|
+
metadata?: { entropy?: { id?: string } };
|
|
531
|
+
groups?: Record<
|
|
532
|
+
string,
|
|
533
|
+
{ accounts?: string[]; metadata?: { entropy?: { groupIndex?: number } } }
|
|
534
|
+
>;
|
|
535
|
+
}[]) {
|
|
536
|
+
const entropyId = wallet.metadata?.entropy?.id;
|
|
537
|
+
if (!entropyId) continue;
|
|
538
|
+
const accounts = Object.values(wallet.groups ?? {})
|
|
539
|
+
.map((group) => ({
|
|
540
|
+
index: group.metadata?.entropy?.groupIndex ?? Number.MAX_SAFE_INTEGER,
|
|
541
|
+
account: group.accounts?.[0]
|
|
542
|
+
? evmById.get(group.accounts[0])
|
|
543
|
+
: undefined,
|
|
544
|
+
}))
|
|
545
|
+
.filter((entry): entry is { index: number; account: FixtureEvmAccount } =>
|
|
546
|
+
Boolean(entry.account),
|
|
547
|
+
)
|
|
548
|
+
.sort((left, right) => left.index - right.index)
|
|
549
|
+
.map((entry) => entry.account);
|
|
550
|
+
if (accounts.length > 0) {
|
|
551
|
+
result.push({ keyringId: entropyId, accounts });
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (result.length > 0) {
|
|
555
|
+
return result;
|
|
556
|
+
}
|
|
557
|
+
const legacyHdAccounts = findEvmAccounts(
|
|
558
|
+
accountsController.state.internalAccounts.accounts,
|
|
559
|
+
).filter(isHdFixtureAccount);
|
|
560
|
+
return legacyHdAccounts.length > 0 ? [{ accounts: legacyHdAccounts }] : [];
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function ensureFixtureMnemonicAccounts(
|
|
564
|
+
mnemonicAccount: WalletFixture['accounts'][number],
|
|
565
|
+
mnemonicIndex: number,
|
|
566
|
+
controllers: {
|
|
567
|
+
AccountsController: {
|
|
568
|
+
state: { internalAccounts: { accounts: Record<string, unknown> } };
|
|
569
|
+
};
|
|
570
|
+
AccountTreeController: {
|
|
571
|
+
state: { accountTree?: { wallets?: Record<string, unknown> } };
|
|
572
|
+
setAccountGroupName: (
|
|
573
|
+
accountGroupId: AccountGroupId,
|
|
574
|
+
accountGroupName: string,
|
|
575
|
+
) => void;
|
|
576
|
+
};
|
|
577
|
+
},
|
|
578
|
+
options: { allowLegacyAccountTreeInitFailure?: boolean } = {},
|
|
579
|
+
) {
|
|
580
|
+
const { AccountsController, AccountTreeController } = controllers;
|
|
581
|
+
const count = getFixtureMnemonicCount(mnemonicAccount);
|
|
582
|
+
const names = getFixtureAccountNames(mnemonicAccount, count);
|
|
583
|
+
const firstAddress = getMnemonicFirstAddress(mnemonicAccount.value);
|
|
584
|
+
const findWallet = () =>
|
|
585
|
+
getHdFixtureWallets(AccountsController, AccountTreeController).find(
|
|
586
|
+
(hdWallet) =>
|
|
587
|
+
hdWallet.accounts[0]?.address.toLowerCase() === firstAddress,
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
let wallet = findWallet();
|
|
591
|
+
if (!wallet) {
|
|
592
|
+
logFixtureStep('import-mnemonic', { mnemonicIndex });
|
|
593
|
+
await importNewSecretRecoveryPhrase(mnemonicAccount.value, {
|
|
594
|
+
shouldSelectAccount: false,
|
|
595
|
+
});
|
|
596
|
+
await initializeFixtureAccountTree(options);
|
|
597
|
+
wallet = findWallet();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (!wallet) {
|
|
601
|
+
throw new Error(
|
|
602
|
+
`No HD wallet found for fixture mnemonic ${mnemonicIndex + 1}`,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (wallet.accounts.length < count && !wallet.keyringId) {
|
|
607
|
+
throw new Error(
|
|
608
|
+
`Cannot add fixture accounts to legacy vault (no entropy source); fixture expects ${count} accounts but vault has ${wallet.accounts.length}`,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
const { MultichainAccountService } = Engine.context;
|
|
612
|
+
if (wallet.accounts.length < count) {
|
|
613
|
+
const fromGroupIndex = wallet.accounts.length;
|
|
614
|
+
const toGroupIndex = count - 1;
|
|
615
|
+
const entropySource = wallet.keyringId as string;
|
|
616
|
+
logFixtureStep('create-hd-accounts', {
|
|
617
|
+
mnemonicIndex,
|
|
618
|
+
fromGroupIndex,
|
|
619
|
+
toGroupIndex,
|
|
620
|
+
count,
|
|
621
|
+
});
|
|
622
|
+
await createFixtureHdAccount(
|
|
623
|
+
`create HD accounts ${fromGroupIndex + 1}-${count} for mnemonic ${
|
|
624
|
+
mnemonicIndex + 1
|
|
625
|
+
}`,
|
|
626
|
+
() =>
|
|
627
|
+
MultichainAccountService.createMultichainAccountGroups({
|
|
628
|
+
fromGroupIndex,
|
|
629
|
+
toGroupIndex,
|
|
630
|
+
entropySource,
|
|
631
|
+
}),
|
|
632
|
+
() => (findWallet()?.accounts.length ?? 0) >= count,
|
|
633
|
+
);
|
|
634
|
+
wallet = findWallet();
|
|
635
|
+
if (!wallet) {
|
|
636
|
+
throw new Error(
|
|
637
|
+
`No HD wallet found after adding fixture accounts ${fromGroupIndex + 1}-${count}`,
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
wallet.accounts.slice(0, count).forEach((account, index) => {
|
|
643
|
+
setFixtureAccountName(AccountTreeController, account, names[index]);
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function findAccountGroupIdByAccountId(
|
|
648
|
+
accountTreeController: {
|
|
649
|
+
state: { accountTree?: { wallets?: Record<string, unknown> } };
|
|
650
|
+
},
|
|
651
|
+
accountId: string,
|
|
652
|
+
): string | undefined {
|
|
653
|
+
const wallets = accountTreeController.state.accountTree?.wallets ?? {};
|
|
654
|
+
for (const wallet of Object.values(wallets) as {
|
|
655
|
+
groups?: Record<string, { accounts?: string[] }>;
|
|
656
|
+
}[]) {
|
|
657
|
+
for (const [groupId, group] of Object.entries(wallet.groups ?? {})) {
|
|
658
|
+
if (group.accounts?.includes(accountId)) {
|
|
659
|
+
return groupId;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return undefined;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function setFixtureAccountName(
|
|
667
|
+
accountTreeController: {
|
|
668
|
+
state: { accountTree?: { wallets?: Record<string, unknown> } };
|
|
669
|
+
setAccountGroupName: (
|
|
670
|
+
accountGroupId: AccountGroupId,
|
|
671
|
+
accountGroupName: string,
|
|
672
|
+
) => void;
|
|
673
|
+
},
|
|
674
|
+
account: { id: string; address: string },
|
|
675
|
+
name: string,
|
|
676
|
+
) {
|
|
677
|
+
Engine.setAccountLabel(account.address, name);
|
|
678
|
+
const groupId = findAccountGroupIdByAccountId(
|
|
679
|
+
accountTreeController,
|
|
680
|
+
account.id,
|
|
681
|
+
);
|
|
682
|
+
// Legacy vault fallback skips account-tree init, so no group exists yet.
|
|
683
|
+
// The account label set above is sufficient; skip the group rename instead
|
|
684
|
+
// of throwing and crashing fixture setup.
|
|
685
|
+
if (!groupId) {
|
|
686
|
+
DevLogger.log(
|
|
687
|
+
`[AgenticService] No account group for fixture account ${account.address}; skipped group rename`,
|
|
688
|
+
);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
accountTreeController.setAccountGroupName(groupId as AccountGroupId, name);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async function materializeFixtureAccounts(
|
|
695
|
+
fixture: WalletFixture,
|
|
696
|
+
controllers: {
|
|
697
|
+
KeyringController: {
|
|
698
|
+
importAccountWithStrategy: (
|
|
699
|
+
strategy: AccountImportStrategy,
|
|
700
|
+
args: string[],
|
|
701
|
+
) => Promise<unknown>;
|
|
702
|
+
};
|
|
703
|
+
AccountsController: {
|
|
704
|
+
state: { internalAccounts: { accounts: Record<string, unknown> } };
|
|
705
|
+
};
|
|
706
|
+
AccountTreeController: {
|
|
707
|
+
state: { accountTree?: { wallets?: Record<string, unknown> } };
|
|
708
|
+
setAccountGroupName: (
|
|
709
|
+
accountGroupId: AccountGroupId,
|
|
710
|
+
accountGroupName: string,
|
|
711
|
+
) => void;
|
|
712
|
+
};
|
|
713
|
+
},
|
|
714
|
+
options: { allowLegacyAccountTreeInitFailure?: boolean } = {},
|
|
715
|
+
) {
|
|
716
|
+
const { KeyringController, AccountsController, AccountTreeController } =
|
|
717
|
+
controllers;
|
|
718
|
+
const mnemonicAccounts = fixture.accounts.filter(
|
|
719
|
+
(account) => account.type === 'mnemonic',
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
for (const [mnemonicIndex, mnemonicAccount] of mnemonicAccounts.entries()) {
|
|
723
|
+
await ensureFixtureMnemonicAccounts(
|
|
724
|
+
mnemonicAccount,
|
|
725
|
+
mnemonicIndex,
|
|
726
|
+
{
|
|
727
|
+
AccountsController,
|
|
728
|
+
AccountTreeController,
|
|
729
|
+
},
|
|
730
|
+
options,
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
for (const account of fixture.accounts) {
|
|
735
|
+
if (account.type !== 'privateKey') continue;
|
|
736
|
+
const address = getPrivateKeyAddress(account.value);
|
|
737
|
+
let imported = findEvmAccounts(
|
|
738
|
+
AccountsController.state.internalAccounts.accounts,
|
|
739
|
+
).find((evmAccount) => evmAccount.address.toLowerCase() === address);
|
|
740
|
+
|
|
741
|
+
if (!imported) {
|
|
742
|
+
logFixtureStep('import-private-key', { address });
|
|
743
|
+
await withFixtureTimeout(
|
|
744
|
+
`import private key ${address}`,
|
|
745
|
+
KeyringController.importAccountWithStrategy(
|
|
746
|
+
AccountImportStrategy.privateKey,
|
|
747
|
+
[`0x${normalizePrivateKey(account.value)}`],
|
|
748
|
+
),
|
|
749
|
+
);
|
|
750
|
+
imported = findEvmAccounts(
|
|
751
|
+
AccountsController.state.internalAccounts.accounts,
|
|
752
|
+
).find((evmAccount) => evmAccount.address.toLowerCase() === address);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (!imported) {
|
|
756
|
+
throw new Error(
|
|
757
|
+
`Fixture private key import did not create account ${address}`,
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
if (account.name) {
|
|
761
|
+
setFixtureAccountName(AccountTreeController, imported, account.name);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Walk a fiber sub-tree looking for a scrollable stateNode (scrollTo or
|
|
768
|
+
* scrollToOffset). When `walkSiblings` is false only the child axis is
|
|
769
|
+
* traversed — useful when starting from a testId anchor whose siblings
|
|
770
|
+
* are unrelated components.
|
|
771
|
+
*/
|
|
772
|
+
function tryScroll(
|
|
773
|
+
start: FiberNode | null,
|
|
774
|
+
offset: number,
|
|
775
|
+
animated: boolean,
|
|
776
|
+
walkSiblings = true,
|
|
777
|
+
): boolean {
|
|
778
|
+
let current: FiberNode | null = start;
|
|
779
|
+
while (current) {
|
|
780
|
+
const sn = current.stateNode;
|
|
781
|
+
if (sn) {
|
|
782
|
+
if (typeof sn.scrollTo === 'function') {
|
|
783
|
+
sn.scrollTo({ y: offset, animated });
|
|
784
|
+
return true;
|
|
785
|
+
}
|
|
786
|
+
if (typeof sn.scrollToOffset === 'function') {
|
|
787
|
+
sn.scrollToOffset({ offset, animated });
|
|
788
|
+
return true;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
if (tryScroll(current.child, offset, animated)) return true;
|
|
792
|
+
current = walkSiblings ? current.sibling : null;
|
|
793
|
+
}
|
|
794
|
+
return false;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function targetMatches(
|
|
798
|
+
fiber: FiberNode,
|
|
799
|
+
options: { testId?: string; textContains?: string },
|
|
800
|
+
): boolean {
|
|
801
|
+
if (options.testId && fiber.memoizedProps?.testID !== options.testId) {
|
|
802
|
+
return false;
|
|
803
|
+
}
|
|
804
|
+
if (options.textContains) {
|
|
805
|
+
const needle = options.textContains.toLowerCase();
|
|
806
|
+
const texts = options.testId
|
|
807
|
+
? collectFiberTexts(fiber)
|
|
808
|
+
: collectOwnFiberTexts(fiber);
|
|
809
|
+
return texts.some((text) => text.toLowerCase().includes(needle));
|
|
810
|
+
}
|
|
811
|
+
return Boolean(options.testId);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function findUiTargetFiber(
|
|
815
|
+
rootFiber: FiberNode,
|
|
816
|
+
options: { testId?: string; textContains?: string },
|
|
817
|
+
): FiberNode | null {
|
|
818
|
+
let result: FiberNode | null = null;
|
|
819
|
+
walkFiber(rootFiber, (fiber) => {
|
|
820
|
+
if (targetMatches(fiber, options)) {
|
|
821
|
+
result = fiber;
|
|
822
|
+
return true;
|
|
823
|
+
}
|
|
824
|
+
return false;
|
|
825
|
+
});
|
|
826
|
+
return result;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function findMeasurableStateNode(
|
|
830
|
+
fiber: FiberNode | null,
|
|
831
|
+
): FiberNode['stateNode'] | null {
|
|
832
|
+
let result: FiberNode['stateNode'] | null = null;
|
|
833
|
+
walkFiber(fiber, (node) => {
|
|
834
|
+
const sn = node.stateNode;
|
|
835
|
+
if (
|
|
836
|
+
sn &&
|
|
837
|
+
(typeof sn.measureInWindow === 'function' ||
|
|
838
|
+
typeof sn.measure === 'function')
|
|
839
|
+
) {
|
|
840
|
+
result = sn;
|
|
841
|
+
return true;
|
|
842
|
+
}
|
|
843
|
+
return false;
|
|
844
|
+
});
|
|
845
|
+
return result;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function measureStateNode(
|
|
849
|
+
stateNode: FiberNode['stateNode'],
|
|
850
|
+
): Promise<{ x: number; y: number; width: number; height: number } | null> {
|
|
851
|
+
return new Promise((resolve) => {
|
|
852
|
+
if (!stateNode) {
|
|
853
|
+
resolve(null);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
let settled = false;
|
|
857
|
+
const settle = (
|
|
858
|
+
value: { x: number; y: number; width: number; height: number } | null,
|
|
859
|
+
) => {
|
|
860
|
+
if (settled) return;
|
|
861
|
+
settled = true;
|
|
862
|
+
resolve(value);
|
|
863
|
+
};
|
|
864
|
+
// Native measure callbacks never fire for detached/off-screen views;
|
|
865
|
+
// fall back to null after a short delay so callers don't hang forever.
|
|
866
|
+
const timeout = setTimeout(() => settle(null), 2500);
|
|
867
|
+
const finish = (x: number, y: number, width: number, height: number) => {
|
|
868
|
+
clearTimeout(timeout);
|
|
869
|
+
settle({ x, y, width, height });
|
|
870
|
+
};
|
|
871
|
+
try {
|
|
872
|
+
if (typeof stateNode.measureInWindow === 'function') {
|
|
873
|
+
stateNode.measureInWindow(finish);
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
if (typeof stateNode.measure === 'function') {
|
|
877
|
+
stateNode.measure((_x, _y, width, height, pageX, pageY) =>
|
|
878
|
+
finish(pageX, pageY, width, height),
|
|
879
|
+
);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
} catch (e) {
|
|
883
|
+
Logger.log(String(e), 'AgenticService.measureStateNode');
|
|
884
|
+
}
|
|
885
|
+
clearTimeout(timeout);
|
|
886
|
+
settle(null);
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
async function queryUiTarget(options: {
|
|
891
|
+
testId?: string;
|
|
892
|
+
textContains?: string;
|
|
893
|
+
visibility?: 'tree' | 'viewport';
|
|
894
|
+
}): Promise<{
|
|
895
|
+
present: boolean;
|
|
896
|
+
visible: boolean;
|
|
897
|
+
visibility: 'tree' | 'viewport';
|
|
898
|
+
testId?: string;
|
|
899
|
+
textContains?: string;
|
|
900
|
+
textMatched?: boolean;
|
|
901
|
+
rect?: { x: number; y: number; width: number; height: number };
|
|
902
|
+
viewport?: { width: number; height: number };
|
|
903
|
+
error?: string;
|
|
904
|
+
}> {
|
|
905
|
+
const visibility: 'tree' | 'viewport' =
|
|
906
|
+
options.visibility === 'viewport' ? 'viewport' : 'tree';
|
|
907
|
+
let target: FiberNode | null = null;
|
|
908
|
+
|
|
909
|
+
walkFiberRoots((rootFiber) => {
|
|
910
|
+
target = findUiTargetFiber(rootFiber, options);
|
|
911
|
+
return Boolean(target);
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
const base = {
|
|
915
|
+
present: Boolean(target),
|
|
916
|
+
visible: Boolean(target) && visibility === 'tree',
|
|
917
|
+
visibility,
|
|
918
|
+
testId: options.testId,
|
|
919
|
+
textContains: options.textContains,
|
|
920
|
+
textMatched: options.textContains
|
|
921
|
+
? Boolean(
|
|
922
|
+
target &&
|
|
923
|
+
collectFiberTexts(target).some((text) =>
|
|
924
|
+
text
|
|
925
|
+
.toLowerCase()
|
|
926
|
+
.includes(String(options.textContains).toLowerCase()),
|
|
927
|
+
),
|
|
928
|
+
)
|
|
929
|
+
: undefined,
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
if (!target || visibility === 'tree') {
|
|
933
|
+
return base;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const stateNode = findMeasurableStateNode(target);
|
|
937
|
+
if (!stateNode) {
|
|
938
|
+
return {
|
|
939
|
+
...base,
|
|
940
|
+
visible: false,
|
|
941
|
+
error:
|
|
942
|
+
'Target exists in fiber tree but no measurable native node was found',
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const rect = await measureStateNode(stateNode);
|
|
947
|
+
const viewport = Dimensions.get('window');
|
|
948
|
+
if (!rect) {
|
|
949
|
+
return {
|
|
950
|
+
...base,
|
|
951
|
+
visible: false,
|
|
952
|
+
viewport: { width: viewport.width, height: viewport.height },
|
|
953
|
+
error: 'Target exists in fiber tree but measurement returned no frame',
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const visible =
|
|
958
|
+
rect.width > 0 &&
|
|
959
|
+
rect.height > 0 &&
|
|
960
|
+
rect.x < viewport.width &&
|
|
961
|
+
rect.y < viewport.height &&
|
|
962
|
+
rect.x + rect.width > 0 &&
|
|
963
|
+
rect.y + rect.height > 0;
|
|
964
|
+
|
|
965
|
+
return {
|
|
966
|
+
...base,
|
|
967
|
+
visible,
|
|
968
|
+
rect,
|
|
969
|
+
viewport: { width: viewport.width, height: viewport.height },
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function appendTextContent(value: unknown, out: string[]) {
|
|
974
|
+
if (value === null || value === undefined) {
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
if (typeof value === 'string' || typeof value === 'number') {
|
|
978
|
+
const text = String(value).trim();
|
|
979
|
+
if (text.length > 0) {
|
|
980
|
+
out.push(text);
|
|
981
|
+
}
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
if (Array.isArray(value)) {
|
|
985
|
+
value.forEach((entry) => appendTextContent(entry, out));
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
if (typeof value === 'object' && value && 'props' in value) {
|
|
989
|
+
const maybeProps = (value as { props?: { children?: unknown } }).props;
|
|
990
|
+
if (maybeProps?.children !== undefined) {
|
|
991
|
+
appendTextContent(maybeProps.children, out);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function dedupeTexts(texts: string[]): string[] {
|
|
997
|
+
return texts.filter((text, index) => texts.indexOf(text) === index);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function collectFiberTexts(fiber: FiberNode | null): string[] {
|
|
1001
|
+
const texts: string[] = [];
|
|
1002
|
+
walkFiber(fiber, (node) => {
|
|
1003
|
+
if (node.memoizedProps?.children !== undefined) {
|
|
1004
|
+
appendTextContent(node.memoizedProps.children, texts);
|
|
1005
|
+
}
|
|
1006
|
+
return false;
|
|
1007
|
+
});
|
|
1008
|
+
return dedupeTexts(texts);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function collectOwnFiberTexts(fiber: FiberNode | null): string[] {
|
|
1012
|
+
const texts: string[] = [];
|
|
1013
|
+
if (fiber?.memoizedProps?.children !== undefined) {
|
|
1014
|
+
appendTextContent(fiber.memoizedProps.children, texts);
|
|
1015
|
+
}
|
|
1016
|
+
return dedupeTexts(texts);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function findAncestorTexts(
|
|
1020
|
+
fiber: FiberNode | null,
|
|
1021
|
+
predicate: (texts: string[]) => boolean,
|
|
1022
|
+
maxTexts = 14,
|
|
1023
|
+
): string[] | null {
|
|
1024
|
+
let current = fiber;
|
|
1025
|
+
while (current) {
|
|
1026
|
+
const texts = collectFiberTexts(current);
|
|
1027
|
+
if (texts.length > 0 && texts.length <= maxTexts && predicate(texts)) {
|
|
1028
|
+
return texts;
|
|
1029
|
+
}
|
|
1030
|
+
current = current.return;
|
|
1031
|
+
}
|
|
1032
|
+
return null;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function findRowTexts(
|
|
1036
|
+
label: string,
|
|
1037
|
+
options: {
|
|
1038
|
+
anchorTestId?: string;
|
|
1039
|
+
requiredLabels?: string[];
|
|
1040
|
+
maxTexts?: number;
|
|
1041
|
+
} = {},
|
|
1042
|
+
): string[] | null {
|
|
1043
|
+
const { anchorTestId, requiredLabels = [], maxTexts = 14 } = options;
|
|
1044
|
+
const matchesRow = (texts: string[]) =>
|
|
1045
|
+
texts.includes(label) &&
|
|
1046
|
+
requiredLabels.every((requiredLabel) => texts.includes(requiredLabel));
|
|
1047
|
+
|
|
1048
|
+
if (anchorTestId) {
|
|
1049
|
+
let anchoredMatch: string[] | null = null;
|
|
1050
|
+
walkFiberRoots((rootFiber) => {
|
|
1051
|
+
const anchor = findFiberByTestId(rootFiber, anchorTestId);
|
|
1052
|
+
if (!anchor) {
|
|
1053
|
+
return false;
|
|
1054
|
+
}
|
|
1055
|
+
anchoredMatch = findAncestorTexts(anchor, matchesRow, maxTexts);
|
|
1056
|
+
return Boolean(anchoredMatch);
|
|
1057
|
+
});
|
|
1058
|
+
if (anchoredMatch) {
|
|
1059
|
+
return anchoredMatch;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
let fallbackMatch: string[] | null = null;
|
|
1064
|
+
walkFiberRoots((rootFiber) =>
|
|
1065
|
+
walkFiber(rootFiber, (fiber) => {
|
|
1066
|
+
const texts = collectFiberTexts(fiber);
|
|
1067
|
+
if (texts.length === 0 || texts.length > maxTexts) {
|
|
1068
|
+
return false;
|
|
1069
|
+
}
|
|
1070
|
+
if (!matchesRow(texts)) {
|
|
1071
|
+
return false;
|
|
1072
|
+
}
|
|
1073
|
+
fallbackMatch = texts;
|
|
1074
|
+
return true;
|
|
1075
|
+
}),
|
|
1076
|
+
);
|
|
1077
|
+
|
|
1078
|
+
return fallbackMatch;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function getRowValue(
|
|
1082
|
+
label: string,
|
|
1083
|
+
pattern: string,
|
|
1084
|
+
options: {
|
|
1085
|
+
anchorTestId?: string;
|
|
1086
|
+
requiredLabels?: string[];
|
|
1087
|
+
maxTexts?: number;
|
|
1088
|
+
} = {},
|
|
1089
|
+
): string | null {
|
|
1090
|
+
const rowTexts = findRowTexts(label, options);
|
|
1091
|
+
if (!rowTexts) {
|
|
1092
|
+
return null;
|
|
1093
|
+
}
|
|
1094
|
+
const matcher = new RegExp(pattern);
|
|
1095
|
+
return rowTexts.find((text) => text !== label && matcher.test(text)) ?? null;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// ─── AgenticService ─────────────────────────────────────────────────────────
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* __DEV__-only service that installs the `globalThis.__AGENTIC__` bridge.
|
|
1102
|
+
*
|
|
1103
|
+
* The bridge exposes navigation primitives, account helpers, and UI
|
|
1104
|
+
* interaction methods (pressTestId, scrollView) so that AI coding agents
|
|
1105
|
+
* can inspect and drive the app remotely via Metro's Hermes CDP WebSocket.
|
|
1106
|
+
*
|
|
1107
|
+
* Consumed by the runner-owned Mobile bridge runtime.
|
|
1108
|
+
* See docs/perps/perps-agentic-feedback-loop.md for the full workflow.
|
|
1109
|
+
*/
|
|
1110
|
+
const AgenticService = {
|
|
1111
|
+
/**
|
|
1112
|
+
* Install the agentic bridge on globalThis.
|
|
1113
|
+
*
|
|
1114
|
+
* @param navRef - Raw (unwrapped) navigation container ref
|
|
1115
|
+
* @param deferredNav - The requestAnimationFrame-deferred proxy
|
|
1116
|
+
*/
|
|
1117
|
+
install(
|
|
1118
|
+
navRef: NavigationContainerRef<ParamListBase>,
|
|
1119
|
+
deferredNav: NavigationContainerRef<ParamListBase>,
|
|
1120
|
+
) {
|
|
1121
|
+
// Defensive guard against accidental production imports/calls; the bridge
|
|
1122
|
+
// must never assign globals outside development builds.
|
|
1123
|
+
if (!__DEV__) return;
|
|
1124
|
+
|
|
1125
|
+
Logger.log('[AgenticService] __AGENTIC__ bridge installed');
|
|
1126
|
+
|
|
1127
|
+
globalThis.__AGENTIC__ = {
|
|
1128
|
+
platform: Platform.OS,
|
|
1129
|
+
replayHarnessPatch: 'legacy-wallet-fixture-r2',
|
|
1130
|
+
navigate: (name: string, params?: object) =>
|
|
1131
|
+
(
|
|
1132
|
+
deferredNav as unknown as {
|
|
1133
|
+
navigate: (name: string, params?: object) => void;
|
|
1134
|
+
}
|
|
1135
|
+
).navigate(name, params),
|
|
1136
|
+
getRoute: () => navRef.getCurrentRoute(),
|
|
1137
|
+
getState: () => navRef.getState(),
|
|
1138
|
+
canGoBack: () => navRef.canGoBack(),
|
|
1139
|
+
goBack: () => deferredNav.goBack(),
|
|
1140
|
+
listAccounts: () =>
|
|
1141
|
+
Engine.context.AccountsController.listAccounts().map(toAccountSummary),
|
|
1142
|
+
getSelectedAccount: () =>
|
|
1143
|
+
toAccountSummary(
|
|
1144
|
+
Engine.context.AccountsController.getSelectedAccount(),
|
|
1145
|
+
),
|
|
1146
|
+
pressTestId: (testId: string) => {
|
|
1147
|
+
try {
|
|
1148
|
+
const found = walkFiberRoots((rootFiber) =>
|
|
1149
|
+
walkFiber(rootFiber, (f) => {
|
|
1150
|
+
if (
|
|
1151
|
+
f.memoizedProps?.testID === testId &&
|
|
1152
|
+
typeof f.memoizedProps?.onPress === 'function'
|
|
1153
|
+
) {
|
|
1154
|
+
f.memoizedProps.onPress();
|
|
1155
|
+
return true;
|
|
1156
|
+
}
|
|
1157
|
+
return false;
|
|
1158
|
+
}),
|
|
1159
|
+
);
|
|
1160
|
+
if (found) return { ok: true, testId };
|
|
1161
|
+
return {
|
|
1162
|
+
ok: false,
|
|
1163
|
+
error: `No component with testID="${testId}" found or no onPress prop`,
|
|
1164
|
+
};
|
|
1165
|
+
} catch (e) {
|
|
1166
|
+
return { ok: false, error: String(e) };
|
|
1167
|
+
}
|
|
1168
|
+
},
|
|
1169
|
+
pressText: (
|
|
1170
|
+
text: string,
|
|
1171
|
+
options: { requiredTexts?: string[]; maxTexts?: number } = {},
|
|
1172
|
+
) => {
|
|
1173
|
+
const { requiredTexts = [], maxTexts = 14 } = options;
|
|
1174
|
+
try {
|
|
1175
|
+
let bestMatch: FiberNode | null = null;
|
|
1176
|
+
let bestTextCount = Number.POSITIVE_INFINITY;
|
|
1177
|
+
walkFiberRoots((rootFiber) =>
|
|
1178
|
+
walkFiber(rootFiber, (fiber) => {
|
|
1179
|
+
if (typeof fiber.memoizedProps?.onPress !== 'function') {
|
|
1180
|
+
return false;
|
|
1181
|
+
}
|
|
1182
|
+
const texts = collectFiberTexts(fiber);
|
|
1183
|
+
if (texts.length === 0 || texts.length > maxTexts) {
|
|
1184
|
+
return false;
|
|
1185
|
+
}
|
|
1186
|
+
if (!texts.includes(text)) {
|
|
1187
|
+
return false;
|
|
1188
|
+
}
|
|
1189
|
+
if (requiredTexts.some((required) => !texts.includes(required))) {
|
|
1190
|
+
return false;
|
|
1191
|
+
}
|
|
1192
|
+
if (texts.length < bestTextCount) {
|
|
1193
|
+
bestMatch = fiber;
|
|
1194
|
+
bestTextCount = texts.length;
|
|
1195
|
+
}
|
|
1196
|
+
return false;
|
|
1197
|
+
}),
|
|
1198
|
+
);
|
|
1199
|
+
const matched = bestMatch as FiberNode | null;
|
|
1200
|
+
if (matched && typeof matched.memoizedProps?.onPress === 'function') {
|
|
1201
|
+
matched.memoizedProps.onPress();
|
|
1202
|
+
return { ok: true, text };
|
|
1203
|
+
}
|
|
1204
|
+
return {
|
|
1205
|
+
ok: false,
|
|
1206
|
+
error: `No pressable found for text="${text}"`,
|
|
1207
|
+
};
|
|
1208
|
+
} catch (e) {
|
|
1209
|
+
return { ok: false, error: String(e) };
|
|
1210
|
+
}
|
|
1211
|
+
},
|
|
1212
|
+
scrollView: (
|
|
1213
|
+
options: {
|
|
1214
|
+
testId?: string;
|
|
1215
|
+
offset?: number;
|
|
1216
|
+
animated?: boolean;
|
|
1217
|
+
} = {},
|
|
1218
|
+
) => {
|
|
1219
|
+
const {
|
|
1220
|
+
testId: scrollTestId,
|
|
1221
|
+
offset = 300,
|
|
1222
|
+
animated = false,
|
|
1223
|
+
} = options;
|
|
1224
|
+
try {
|
|
1225
|
+
const found = walkFiberRoots((rootFiber) => {
|
|
1226
|
+
if (scrollTestId) {
|
|
1227
|
+
const anchor = findFiberByTestId(rootFiber, scrollTestId);
|
|
1228
|
+
if (!anchor) return false;
|
|
1229
|
+
return tryScroll(anchor, offset, animated, false);
|
|
1230
|
+
}
|
|
1231
|
+
return tryScroll(rootFiber, offset, animated);
|
|
1232
|
+
});
|
|
1233
|
+
if (found)
|
|
1234
|
+
return { ok: true, testId: scrollTestId, offset, animated };
|
|
1235
|
+
return {
|
|
1236
|
+
ok: false,
|
|
1237
|
+
error: scrollTestId
|
|
1238
|
+
? `No scrollable found near testID="${scrollTestId}"`
|
|
1239
|
+
: 'No scrollable found in fiber tree',
|
|
1240
|
+
};
|
|
1241
|
+
} catch (e) {
|
|
1242
|
+
return { ok: false, error: String(e) };
|
|
1243
|
+
}
|
|
1244
|
+
},
|
|
1245
|
+
setInput: (testId: string, value: string) => {
|
|
1246
|
+
try {
|
|
1247
|
+
const result: {
|
|
1248
|
+
ok: boolean;
|
|
1249
|
+
testId?: string;
|
|
1250
|
+
value?: string;
|
|
1251
|
+
error?: string;
|
|
1252
|
+
} = {
|
|
1253
|
+
ok: false,
|
|
1254
|
+
error: `No component with testID="${testId}" found`,
|
|
1255
|
+
};
|
|
1256
|
+
walkFiberRoots((rootFiber) => {
|
|
1257
|
+
const target = findFiberByTestId(rootFiber, testId);
|
|
1258
|
+
if (!target) return false;
|
|
1259
|
+
// Walk the found fiber and its parents looking for onChangeText
|
|
1260
|
+
let current: FiberNode | null = target;
|
|
1261
|
+
while (current) {
|
|
1262
|
+
if (typeof current.memoizedProps?.onChangeText === 'function') {
|
|
1263
|
+
current.memoizedProps.onChangeText(value);
|
|
1264
|
+
result.ok = true;
|
|
1265
|
+
result.testId = testId;
|
|
1266
|
+
result.value = value;
|
|
1267
|
+
result.error = undefined;
|
|
1268
|
+
return true;
|
|
1269
|
+
}
|
|
1270
|
+
current = current.return;
|
|
1271
|
+
}
|
|
1272
|
+
result.error = `Component with testID="${testId}" has no onChangeText prop`;
|
|
1273
|
+
return true;
|
|
1274
|
+
});
|
|
1275
|
+
return result;
|
|
1276
|
+
} catch (e) {
|
|
1277
|
+
return { ok: false, error: String(e) };
|
|
1278
|
+
}
|
|
1279
|
+
},
|
|
1280
|
+
getTextByTestId: (testId: string, options: { all?: boolean } = {}) => {
|
|
1281
|
+
let texts: string[] | null = null;
|
|
1282
|
+
walkFiberRoots((rootFiber) => {
|
|
1283
|
+
const target = findFiberByTestId(rootFiber, testId);
|
|
1284
|
+
if (!target) {
|
|
1285
|
+
return false;
|
|
1286
|
+
}
|
|
1287
|
+
texts = collectFiberTexts(target);
|
|
1288
|
+
return true;
|
|
1289
|
+
});
|
|
1290
|
+
const collected = texts as string[] | null;
|
|
1291
|
+
if (!collected || collected.length === 0) {
|
|
1292
|
+
return null;
|
|
1293
|
+
}
|
|
1294
|
+
return options.all ? collected : collected[0];
|
|
1295
|
+
},
|
|
1296
|
+
getAncestorTextsByTestId: (
|
|
1297
|
+
testId: string,
|
|
1298
|
+
options: { requiredLabels?: string[]; maxTexts?: number } = {},
|
|
1299
|
+
) => {
|
|
1300
|
+
const { requiredLabels = [], maxTexts = 14 } = options;
|
|
1301
|
+
let texts: string[] | null = null;
|
|
1302
|
+
walkFiberRoots((rootFiber) => {
|
|
1303
|
+
const target = findFiberByTestId(rootFiber, testId);
|
|
1304
|
+
if (!target) {
|
|
1305
|
+
return false;
|
|
1306
|
+
}
|
|
1307
|
+
texts = findAncestorTexts(
|
|
1308
|
+
target,
|
|
1309
|
+
(candidateTexts) =>
|
|
1310
|
+
requiredLabels.every((label) => candidateTexts.includes(label)),
|
|
1311
|
+
maxTexts,
|
|
1312
|
+
);
|
|
1313
|
+
return Boolean(texts);
|
|
1314
|
+
});
|
|
1315
|
+
return texts;
|
|
1316
|
+
},
|
|
1317
|
+
getRowValue: (
|
|
1318
|
+
label: string,
|
|
1319
|
+
pattern: string,
|
|
1320
|
+
options: {
|
|
1321
|
+
anchorTestId?: string;
|
|
1322
|
+
requiredLabels?: string[];
|
|
1323
|
+
maxTexts?: number;
|
|
1324
|
+
} = {},
|
|
1325
|
+
) => getRowValue(label, pattern, options),
|
|
1326
|
+
switchAccount: (address: string) => {
|
|
1327
|
+
const accounts = Engine.context.AccountsController.listAccounts();
|
|
1328
|
+
const target = accounts.find(
|
|
1329
|
+
(a: { address: string }) =>
|
|
1330
|
+
a.address.toLowerCase() === address.toLowerCase(),
|
|
1331
|
+
);
|
|
1332
|
+
if (!target) {
|
|
1333
|
+
throw new Error(`No account found for address ${address}`);
|
|
1334
|
+
}
|
|
1335
|
+
Engine.setSelectedAddress(target.address);
|
|
1336
|
+
return { switched: true, ...toAccountSummary(target) };
|
|
1337
|
+
},
|
|
1338
|
+
showStep: (step: AgenticHudStep) => {
|
|
1339
|
+
emitStepHud(step);
|
|
1340
|
+
},
|
|
1341
|
+
hideStep: () => {
|
|
1342
|
+
emitStepHud(null);
|
|
1343
|
+
},
|
|
1344
|
+
refreshPerpsStreams: async () => {
|
|
1345
|
+
await PerpsConnectionManager.ensureConnected({
|
|
1346
|
+
source: 'agentic_refresh_perps_streams',
|
|
1347
|
+
suppressError: true,
|
|
1348
|
+
});
|
|
1349
|
+
const streamManager = getStreamManagerInstance();
|
|
1350
|
+
streamManager.clearAllChannels();
|
|
1351
|
+
const positions = await Engine.context.PerpsController.getPositions();
|
|
1352
|
+
return {
|
|
1353
|
+
ok: true,
|
|
1354
|
+
positions: Array.isArray(positions) ? positions.length : 0,
|
|
1355
|
+
};
|
|
1356
|
+
},
|
|
1357
|
+
findFiberByTestId: (testId: string): boolean => {
|
|
1358
|
+
let found = false;
|
|
1359
|
+
walkFiberRoots((rootFiber) => {
|
|
1360
|
+
if (findFiberByTestId(rootFiber, testId)) {
|
|
1361
|
+
found = true;
|
|
1362
|
+
return true;
|
|
1363
|
+
}
|
|
1364
|
+
return false;
|
|
1365
|
+
});
|
|
1366
|
+
return found;
|
|
1367
|
+
},
|
|
1368
|
+
queryUiTarget,
|
|
1369
|
+
applyWalletFixture: async (fixture) => {
|
|
1370
|
+
let setupStep = 'start';
|
|
1371
|
+
try {
|
|
1372
|
+
const {
|
|
1373
|
+
MultichainAccountService,
|
|
1374
|
+
KeyringController,
|
|
1375
|
+
AccountsController,
|
|
1376
|
+
AccountTreeController,
|
|
1377
|
+
} = Engine.context;
|
|
1378
|
+
// The backup subscriber reads the Engine class static, so set it on
|
|
1379
|
+
// the class (not the facade) before importing/renaming accounts to
|
|
1380
|
+
// avoid racing native keychain export during fixture apply.
|
|
1381
|
+
EngineClass.disableAutomaticVaultBackup = true;
|
|
1382
|
+
// Authentication.unlockWallet can navigate to the metrics opt-in
|
|
1383
|
+
// screen when the seen flag is absent. Persist the fixture preference
|
|
1384
|
+
// before unlock so existing-vault fixture replay stays on the wallet
|
|
1385
|
+
// setup path instead of being interrupted by onboarding UI.
|
|
1386
|
+
setupStep = 'persist-metrics-setting';
|
|
1387
|
+
await markFixtureMetricsOptInSeenIfNeeded(fixture);
|
|
1388
|
+
|
|
1389
|
+
// Unlock via the real auth flow (loginVaultCreation + dispatchLogin +
|
|
1390
|
+
// post-login) rather than a bare KeyringController.submitPassword, so
|
|
1391
|
+
// multichain services and Redux/auth state are consistent before we
|
|
1392
|
+
// mutate accounts.
|
|
1393
|
+
setupStep = 'unlock-fixture-vault';
|
|
1394
|
+
await unlockFixtureVault(fixture, KeyringController);
|
|
1395
|
+
// Existing replay vaults can have the same historical multichain
|
|
1396
|
+
// account-tree init gap as fresh legacy setup. Only the known legacy
|
|
1397
|
+
// init errors are tolerated by this option; unexpected errors still
|
|
1398
|
+
// throw from initializeFixtureAccountTree().
|
|
1399
|
+
const legacyAccountTreeInitOptions = {
|
|
1400
|
+
allowLegacyAccountTreeInitFailure: true,
|
|
1401
|
+
};
|
|
1402
|
+
await initializeFixtureAccountTree(legacyAccountTreeInitOptions);
|
|
1403
|
+
try {
|
|
1404
|
+
await withFixtureTimeout(
|
|
1405
|
+
'initialize multichain account service',
|
|
1406
|
+
MultichainAccountService.init(),
|
|
1407
|
+
60_000,
|
|
1408
|
+
);
|
|
1409
|
+
} catch (error) {
|
|
1410
|
+
if (!isExpectedLegacyAccountTreeInitError(error)) {
|
|
1411
|
+
throw error;
|
|
1412
|
+
}
|
|
1413
|
+
Logger.log(
|
|
1414
|
+
'[AgenticService] Skipping multichain account service initialization for historical replay fixture apply',
|
|
1415
|
+
);
|
|
1416
|
+
}
|
|
1417
|
+
await materializeFixtureAccounts(
|
|
1418
|
+
fixture,
|
|
1419
|
+
{
|
|
1420
|
+
KeyringController,
|
|
1421
|
+
AccountsController,
|
|
1422
|
+
AccountTreeController,
|
|
1423
|
+
},
|
|
1424
|
+
legacyAccountTreeInitOptions,
|
|
1425
|
+
);
|
|
1426
|
+
const ethAccs = findEvmAccounts(
|
|
1427
|
+
AccountsController.state.internalAccounts.accounts,
|
|
1428
|
+
).map(toAccountSummary);
|
|
1429
|
+
return { ok: true, accounts: ethAccs };
|
|
1430
|
+
} catch (e) {
|
|
1431
|
+
return {
|
|
1432
|
+
ok: false,
|
|
1433
|
+
step: setupStep,
|
|
1434
|
+
error: String((e as Error).message || e),
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
},
|
|
1438
|
+
setupWallet: async (fixture) => {
|
|
1439
|
+
let setupStep = 'start';
|
|
1440
|
+
try {
|
|
1441
|
+
setupStep = 'read-engine';
|
|
1442
|
+
const {
|
|
1443
|
+
MultichainAccountService,
|
|
1444
|
+
KeyringController,
|
|
1445
|
+
AccountsController,
|
|
1446
|
+
AccountTreeController,
|
|
1447
|
+
} = Engine.context;
|
|
1448
|
+
const store = ReduxService.store;
|
|
1449
|
+
const settings = fixture.settings ?? {};
|
|
1450
|
+
// Deliberately one-way for the dev harness process: fixture setup
|
|
1451
|
+
// rewrites vault/account state, so automatic backup must stay disabled
|
|
1452
|
+
// for the rest of this simulator session to avoid native keychain
|
|
1453
|
+
// export paths racing the synthetic setup flow.
|
|
1454
|
+
EngineClass.disableAutomaticVaultBackup = true;
|
|
1455
|
+
|
|
1456
|
+
// 1. Create wallet from the first mnemonic (same path as onboarding UI)
|
|
1457
|
+
setupStep = 'create-wallet';
|
|
1458
|
+
const mnemonicAccount = fixture.accounts.find(
|
|
1459
|
+
(a) => a.type === 'mnemonic',
|
|
1460
|
+
);
|
|
1461
|
+
let usedLegacyVaultSetup = false;
|
|
1462
|
+
if (mnemonicAccount) {
|
|
1463
|
+
const mnemonic = mnemonicPhraseToBytes(mnemonicAccount.value);
|
|
1464
|
+
try {
|
|
1465
|
+
logFixtureStep('create-wallet-restore');
|
|
1466
|
+
await withFixtureTimeout(
|
|
1467
|
+
'create multichain wallet from fixture mnemonic',
|
|
1468
|
+
MultichainAccountService.createMultichainAccountWallet({
|
|
1469
|
+
type: 'restore',
|
|
1470
|
+
password: fixture.password,
|
|
1471
|
+
mnemonic,
|
|
1472
|
+
}),
|
|
1473
|
+
60_000,
|
|
1474
|
+
);
|
|
1475
|
+
} catch (error) {
|
|
1476
|
+
if (!isExpectedLegacyAccountTreeInitError(error)) {
|
|
1477
|
+
throw error;
|
|
1478
|
+
}
|
|
1479
|
+
Logger.log(
|
|
1480
|
+
'[AgenticService] Falling back to legacy vault restore for historical replay fixture setup',
|
|
1481
|
+
);
|
|
1482
|
+
await KeyringController.createNewVaultAndRestore(
|
|
1483
|
+
fixture.password,
|
|
1484
|
+
mnemonic,
|
|
1485
|
+
);
|
|
1486
|
+
usedLegacyVaultSetup = true;
|
|
1487
|
+
}
|
|
1488
|
+
} else {
|
|
1489
|
+
try {
|
|
1490
|
+
logFixtureStep('create-wallet-empty');
|
|
1491
|
+
await withFixtureTimeout(
|
|
1492
|
+
'create multichain wallet',
|
|
1493
|
+
MultichainAccountService.createMultichainAccountWallet({
|
|
1494
|
+
type: 'create',
|
|
1495
|
+
password: fixture.password,
|
|
1496
|
+
}),
|
|
1497
|
+
60_000,
|
|
1498
|
+
);
|
|
1499
|
+
} catch (error) {
|
|
1500
|
+
if (!isExpectedLegacyAccountTreeInitError(error)) {
|
|
1501
|
+
throw error;
|
|
1502
|
+
}
|
|
1503
|
+
Logger.log(
|
|
1504
|
+
'[AgenticService] Falling back to legacy vault creation for historical replay fixture setup',
|
|
1505
|
+
);
|
|
1506
|
+
await KeyringController.createNewVaultAndKeychain(
|
|
1507
|
+
fixture.password,
|
|
1508
|
+
);
|
|
1509
|
+
usedLegacyVaultSetup = true;
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// 2. Initialize services (same as Authentication.dispatchLogin)
|
|
1514
|
+
setupStep = 'initialize-services';
|
|
1515
|
+
if (!usedLegacyVaultSetup) {
|
|
1516
|
+
try {
|
|
1517
|
+
await AccountTreeInitService.initializeAccountTree();
|
|
1518
|
+
await MultichainAccountService.init();
|
|
1519
|
+
} catch (error) {
|
|
1520
|
+
if (!isExpectedLegacyAccountTreeInitError(error)) {
|
|
1521
|
+
throw error;
|
|
1522
|
+
}
|
|
1523
|
+
Logger.log(
|
|
1524
|
+
'[AgenticService] Skipping multichain account-tree initialization for historical replay fixture setup',
|
|
1525
|
+
);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// 3. Materialize requested fixture accounts. A mnemonic account can
|
|
1530
|
+
// declare count/numberOfAccounts plus names[]; this is generic
|
|
1531
|
+
// fixture semantics, not a dev-account special case.
|
|
1532
|
+
setupStep = 'materialize-srp-accounts';
|
|
1533
|
+
await materializeFixtureAccounts(
|
|
1534
|
+
fixture,
|
|
1535
|
+
{
|
|
1536
|
+
KeyringController,
|
|
1537
|
+
AccountsController,
|
|
1538
|
+
AccountTreeController,
|
|
1539
|
+
},
|
|
1540
|
+
{ allowLegacyAccountTreeInitFailure: usedLegacyVaultSetup },
|
|
1541
|
+
);
|
|
1542
|
+
|
|
1543
|
+
// 4. Dispatch all onboarding/auth flags
|
|
1544
|
+
setupStep = 'dispatch-onboarding-flags';
|
|
1545
|
+
store.dispatch(passwordSet());
|
|
1546
|
+
store.dispatch(seedphraseBackedUp());
|
|
1547
|
+
store.dispatch(setCompletedOnboarding(true));
|
|
1548
|
+
store.dispatch(setExistingUser(true));
|
|
1549
|
+
store.dispatch(logIn());
|
|
1550
|
+
|
|
1551
|
+
// 5. Suppress post-onboarding modals if explicitly requested
|
|
1552
|
+
setupStep = 'persist-modal-settings';
|
|
1553
|
+
if (settings.skipGtmModals === true) {
|
|
1554
|
+
await Promise.all([
|
|
1555
|
+
StorageWrapper.setItem(PERPS_GTM_MODAL_SHOWN, 'true'),
|
|
1556
|
+
StorageWrapper.setItem(PREDICT_GTM_MODAL_SHOWN, 'true'),
|
|
1557
|
+
StorageWrapper.setItem(REWARDS_GTM_MODAL_SHOWN, 'true'),
|
|
1558
|
+
]);
|
|
1559
|
+
// Suppress ExperienceEnhancer (marketing consent) modal
|
|
1560
|
+
store.dispatch(setDataCollectionForMarketing(false));
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// 5b. Set metrics UI as seen (prevents Authentication.unlockWallet
|
|
1564
|
+
// from navigating to OptinMetrics after setupWallet resets to Wallet)
|
|
1565
|
+
setupStep = 'persist-metrics-setting';
|
|
1566
|
+
await markFixtureMetricsOptInSeenIfNeeded(fixture);
|
|
1567
|
+
|
|
1568
|
+
// 5c. Mark multichain accounts intro modal as seen
|
|
1569
|
+
setupStep = 'dispatch-multichain-intro-seen';
|
|
1570
|
+
store.dispatch(setMultichainAccountsIntroModalSeen(true));
|
|
1571
|
+
|
|
1572
|
+
// 6. Skip perps tutorial onboarding if requested
|
|
1573
|
+
setupStep = 'persist-perps-tutorial-setting';
|
|
1574
|
+
if (settings.skipPerpsTutorial === true) {
|
|
1575
|
+
Engine.context.PerpsController?.markTutorialCompleted();
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// 7. Set auto-lock to "Never" (-1) for agentic workflows
|
|
1579
|
+
setupStep = 'dispatch-auto-lock';
|
|
1580
|
+
if (settings.autoLockNever === true) {
|
|
1581
|
+
ReduxService.store.dispatch(setLockTime(-1));
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// 8. Enable device authentication only on Android fixture runs.
|
|
1585
|
+
// On iOS simulator, toggling OS auth during synthetic setup can drive
|
|
1586
|
+
// react-native-keychain/quick-crypto secret export on the JS runtime
|
|
1587
|
+
// and crash the app after setupWallet returns. Android is the only
|
|
1588
|
+
// harness path that stores the fixture password for auto-unlock here.
|
|
1589
|
+
setupStep = 'dispatch-device-auth';
|
|
1590
|
+
if (
|
|
1591
|
+
settings.deviceAuthEnabled === true &&
|
|
1592
|
+
Platform.OS === 'android'
|
|
1593
|
+
) {
|
|
1594
|
+
ReduxService.store.dispatch(setOsAuthEnabled(true));
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// 8b. Store password in SecureKeychain for device-auth auto-unlock on reload (Android only)
|
|
1598
|
+
if (
|
|
1599
|
+
settings.deviceAuthEnabled === true &&
|
|
1600
|
+
Platform.OS === 'android'
|
|
1601
|
+
) {
|
|
1602
|
+
DevLogger.log('[AUTO-UNLOCK] Storing password in SecureKeychain', {
|
|
1603
|
+
authType: AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION,
|
|
1604
|
+
});
|
|
1605
|
+
await SecureKeychain.setGenericPassword(
|
|
1606
|
+
fixture.password,
|
|
1607
|
+
AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION,
|
|
1608
|
+
);
|
|
1609
|
+
DevLogger.log('[AUTO-UNLOCK] SecureKeychain password stored');
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// 9. Configure MetaMetrics if specified
|
|
1613
|
+
setupStep = 'configure-metametrics';
|
|
1614
|
+
if (settings.metametrics === false) {
|
|
1615
|
+
await analytics.optOut();
|
|
1616
|
+
} else if (settings.metametrics === true) {
|
|
1617
|
+
await analytics.optIn();
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// 10. Navigate to wallet (same as Authentication.unlockWallet)
|
|
1621
|
+
setupStep = 'navigate-wallet';
|
|
1622
|
+
resetNavigationToWalletHome(navRef);
|
|
1623
|
+
|
|
1624
|
+
// 11. Collect all ETH accounts for the summary
|
|
1625
|
+
setupStep = 'collect-accounts';
|
|
1626
|
+
const ethAccs = findEvmAccounts(
|
|
1627
|
+
AccountsController.state.internalAccounts.accounts,
|
|
1628
|
+
).map(toAccountSummary);
|
|
1629
|
+
|
|
1630
|
+
return { ok: true, accounts: ethAccs };
|
|
1631
|
+
} catch (e) {
|
|
1632
|
+
return {
|
|
1633
|
+
ok: false,
|
|
1634
|
+
step: setupStep,
|
|
1635
|
+
error: String((e as Error).message || e),
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
},
|
|
1639
|
+
};
|
|
1640
|
+
|
|
1641
|
+
try {
|
|
1642
|
+
(globalThis as { store?: unknown }).store = ReduxService.store;
|
|
1643
|
+
(globalThis as { persistor?: unknown }).persistor = persistor;
|
|
1644
|
+
} catch (error) {
|
|
1645
|
+
Logger.log(
|
|
1646
|
+
String((error as Error).message || error),
|
|
1647
|
+
'AgenticService.install.exposeReduxGlobals',
|
|
1648
|
+
);
|
|
1649
|
+
}
|
|
1650
|
+
(globalThis as { Engine?: unknown }).Engine = Engine;
|
|
1651
|
+
},
|
|
1652
|
+
};
|
|
1653
|
+
|
|
1654
|
+
export default AgenticService;
|
|
1655
|
+
export {
|
|
1656
|
+
walkFiber,
|
|
1657
|
+
findFiberByTestId,
|
|
1658
|
+
walkFiberRoots,
|
|
1659
|
+
tryScroll,
|
|
1660
|
+
toAccountSummary,
|
|
1661
|
+
};
|
|
1662
|
+
export type { FiberNode, FiberRoot, ReactDevToolsHook, AgenticBridge };
|