@deeeed/metamask-harness 0.34.0 → 0.34.2
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 +29 -0
- package/adapters/manifest.json +0 -8
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +236 -3
- package/adapters/mobile/bridge-runtime/console-forwarder.cjs +22 -1
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +22 -15
- package/adapters/mobile/bridge-runtime/lib/ws-client.cjs +18 -0
- package/adapters/mobile/launch-metro.cjs +6 -4
- package/adapters/mobile/open-device.sh +170 -19
- package/adapters/mobile/start-metro.sh +165 -93
- package/adapters/mobile/stop-metro.sh +1 -0
- package/dist/adapters/mobile/prepare.js +1 -4
- package/dist/adapters/mobile/video-recorder.js +12 -10
- package/dist/adapters.js +134 -6
- package/dist/commands/launch/index.js +9 -0
- package/dist/commands/launch/mobile.js +17 -3
- package/dist/live-adapter-contract.js +10 -3
- package/dist/recipe-security.js +2 -0
- package/dist/runner.js +53 -8
- package/dist/runtime-context.js +1 -0
- package/library/actions/mobile/perps/measure_homepage_visible.mjs +4 -0
- package/library/actions/mobile/perps/performance-capture.mjs +579 -111
- package/library/actions/mobile/perps/perps.mjs +221 -38
- package/library/actions/mobile/perps/prepare_local_snapshot_endpoint.mjs +58 -0
- package/library/actions/mobile/platform/bridge.mjs +102 -8
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +35 -8
- package/library/manifests/mobile.action-manifest.json +243 -511
- package/library/recipes/mobile/perps/performance.homepage.android-cold-disk-cache.recipe.json +20 -2
- package/library/recipes/mobile/perps/performance.homepage.android-cold-no-cache.recipe.json +2 -0
- package/library/recipes/mobile/perps/performance.homepage.cold-position-sample.recipe.json +120 -0
- package/library/recipes/mobile/perps/performance.homepage.ios-background-reconnect.recipe.json +7 -6
- package/library/recipes/mobile/perps/performance.homepage.ios-cold-no-cache.recipe.json +5 -5
- package/package.json +1 -1
- package/adapters/mobile/metro-config.cjs +0 -93
|
@@ -1,11 +1,92 @@
|
|
|
1
1
|
import { pathToFileURL } from 'node:url';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
bridgeCommand,
|
|
4
|
+
evalAsync,
|
|
5
|
+
navigate,
|
|
6
|
+
runAdapter,
|
|
7
|
+
} from '../platform/bridge.mjs';
|
|
3
8
|
import { ensureUnlocked } from '../wallet/ensure_unlocked.mjs';
|
|
4
9
|
|
|
5
10
|
function sleep(ms) {
|
|
6
11
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
12
|
}
|
|
8
13
|
|
|
14
|
+
export async function measureHomepageVisible(input) {
|
|
15
|
+
const wallet = await ensureUnlocked({
|
|
16
|
+
...input,
|
|
17
|
+
action: 'metamask.wallet.ensure_unlocked',
|
|
18
|
+
});
|
|
19
|
+
const timeoutMs = Math.max(1, Number(input.node?.timeout_ms ?? 30_000));
|
|
20
|
+
const targetTestId = input.node?.target_test_id;
|
|
21
|
+
const targetText = input.node?.target_text;
|
|
22
|
+
if (Number(Boolean(targetTestId)) + Number(Boolean(targetText)) !== 1) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
'metamask.perps.measure_homepage_visible requires exactly one target_test_id or target_text.',
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const options = {
|
|
29
|
+
animated: input.node?.animated === true,
|
|
30
|
+
fromTestId: input.node?.from_test_id ?? 'wallet-screen',
|
|
31
|
+
offset: Number(input.node?.offset ?? 600),
|
|
32
|
+
pollIntervalMs: Number(input.node?.poll_interval_ms ?? 100),
|
|
33
|
+
requireWalletReady: true,
|
|
34
|
+
timeoutMs,
|
|
35
|
+
scrollTestId: input.node?.scroll_test_id ?? 'wallet-screen',
|
|
36
|
+
...(targetTestId ? { targetTestId } : { targetText }),
|
|
37
|
+
...(input.node?.required_present_test_id
|
|
38
|
+
? { requiredPresentTestId: input.node.required_present_test_id }
|
|
39
|
+
: {}),
|
|
40
|
+
...(input.node?.visible_event_stage
|
|
41
|
+
? { visibleEventStage: input.node.visible_event_stage }
|
|
42
|
+
: {}),
|
|
43
|
+
...(input.node?.visible_event_grace_ms !== undefined
|
|
44
|
+
? { visibleEventGraceMs: Number(input.node.visible_event_grace_ms) }
|
|
45
|
+
: {}),
|
|
46
|
+
};
|
|
47
|
+
const measurement = await bridgeCommand(
|
|
48
|
+
{
|
|
49
|
+
...input,
|
|
50
|
+
node: {
|
|
51
|
+
...input.node,
|
|
52
|
+
bridge_timeout_ms: timeoutMs + 15_000,
|
|
53
|
+
cdp_timeout_ms: timeoutMs + 15_000,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
['measure-scroll-transition', JSON.stringify(options)],
|
|
57
|
+
);
|
|
58
|
+
if (measurement?.ok !== true) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`metamask.perps.measure_homepage_visible failed: ${String(measurement?.error ?? 'unknown measurement error')}; measurement=${JSON.stringify(measurement)}.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
action: input.action,
|
|
65
|
+
wallet,
|
|
66
|
+
measurement,
|
|
67
|
+
proofPath: 'direct-cdp-wallet-ready-to-visible',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function retryPerpsClientRead(
|
|
72
|
+
read,
|
|
73
|
+
{ timeoutMs = 20000, intervalMs = 500, sleep: wait = sleep } = {},
|
|
74
|
+
) {
|
|
75
|
+
const deadline = Date.now() + timeoutMs;
|
|
76
|
+
while (true) {
|
|
77
|
+
try {
|
|
78
|
+
return await read();
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (!/CLIENT_NOT_INITIALIZED/u.test(String(error?.message ?? error))) {
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
const remainingMs = deadline - Date.now();
|
|
84
|
+
if (remainingMs <= 0) throw error;
|
|
85
|
+
await wait(Math.min(intervalMs, remainingMs));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
9
90
|
function marketSymbol(input) {
|
|
10
91
|
return normalizeMarketSymbol(input.node?.market ?? input.node?.symbol ?? 'BTC');
|
|
11
92
|
}
|
|
@@ -104,54 +185,155 @@ function uniqueSymbols(symbols) {
|
|
|
104
185
|
}
|
|
105
186
|
|
|
106
187
|
async function readPositions(input) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
188
|
+
return retryPerpsClientRead(
|
|
189
|
+
async () => {
|
|
190
|
+
const positions = await evalAsync(
|
|
191
|
+
input,
|
|
192
|
+
`(function(){
|
|
193
|
+
var controller = Engine && Engine.context && Engine.context.PerpsController;
|
|
194
|
+
if (!controller || typeof controller.getPositions !== 'function') {
|
|
195
|
+
throw new Error('Engine.context.PerpsController.getPositions is unavailable; cannot assert live Perps positions.');
|
|
196
|
+
}
|
|
197
|
+
return controller.getPositions().then(function(r){
|
|
198
|
+
if (!Array.isArray(r)) throw new Error('PerpsController.getPositions returned a non-array result.');
|
|
199
|
+
return JSON.stringify(r);
|
|
200
|
+
});
|
|
201
|
+
})()`,
|
|
202
|
+
);
|
|
203
|
+
if (!Array.isArray(positions)) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
'PerpsController.getPositions returned a non-array result.',
|
|
206
|
+
);
|
|
113
207
|
}
|
|
114
|
-
return
|
|
115
|
-
|
|
116
|
-
return JSON.stringify(r);
|
|
117
|
-
});
|
|
118
|
-
})()`,
|
|
208
|
+
return positions;
|
|
209
|
+
},
|
|
119
210
|
);
|
|
120
|
-
if (!Array.isArray(positions)) throw new Error('PerpsController.getPositions returned a non-array result.');
|
|
121
|
-
return positions;
|
|
122
211
|
}
|
|
123
212
|
|
|
124
213
|
async function readOpenOrders(input) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
214
|
+
return retryPerpsClientRead(
|
|
215
|
+
async () => {
|
|
216
|
+
const orders = await evalAsync(
|
|
217
|
+
input,
|
|
218
|
+
`(function(){
|
|
219
|
+
var controller = Engine && Engine.context && Engine.context.PerpsController;
|
|
220
|
+
if (!controller || typeof controller.getOpenOrders !== 'function') {
|
|
221
|
+
throw new Error('Engine.context.PerpsController.getOpenOrders is unavailable; cannot assert live Perps orders.');
|
|
222
|
+
}
|
|
223
|
+
return controller.getOpenOrders().then(function(r){
|
|
224
|
+
if (!Array.isArray(r)) throw new Error('PerpsController.getOpenOrders returned a non-array result.');
|
|
225
|
+
return JSON.stringify(r);
|
|
226
|
+
});
|
|
227
|
+
})()`,
|
|
228
|
+
);
|
|
229
|
+
if (!Array.isArray(orders)) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
'PerpsController.getOpenOrders returned a non-array result.',
|
|
232
|
+
);
|
|
131
233
|
}
|
|
132
|
-
return
|
|
133
|
-
|
|
134
|
-
return JSON.stringify(r);
|
|
135
|
-
});
|
|
136
|
-
})()`,
|
|
234
|
+
return orders;
|
|
235
|
+
},
|
|
137
236
|
);
|
|
138
|
-
if (!Array.isArray(orders)) throw new Error('PerpsController.getOpenOrders returned a non-array result.');
|
|
139
|
-
return orders;
|
|
140
237
|
}
|
|
141
238
|
|
|
142
|
-
|
|
143
|
-
return
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (!bridge || typeof bridge.clearPerpsPerformanceCaches !== 'function') {
|
|
148
|
-
throw new Error('__AGENTIC__.clearPerpsPerformanceCaches is unavailable; reload the current Mobile development source.');
|
|
239
|
+
export function clearPerformanceCachesExpression() {
|
|
240
|
+
return `(function(){
|
|
241
|
+
var metroRequire = globalThis.__r;
|
|
242
|
+
if (typeof metroRequire !== 'function' || typeof metroRequire.getModules !== 'function') {
|
|
243
|
+
throw new Error('Metro module registry is unavailable; cannot clear Perps performance caches through CDP.');
|
|
149
244
|
}
|
|
150
|
-
|
|
151
|
-
|
|
245
|
+
var storageModuleId = null;
|
|
246
|
+
var perpsConfigModuleId = null;
|
|
247
|
+
var streamManagerModuleId = null;
|
|
248
|
+
metroRequire.getModules().forEach(function(module, moduleId){
|
|
249
|
+
var name = String(module && module.verboseName || '');
|
|
250
|
+
if (name === 'app/store/storage-wrapper.ts' || name.endsWith('/app/store/storage-wrapper.ts')) {
|
|
251
|
+
storageModuleId = moduleId;
|
|
252
|
+
}
|
|
253
|
+
if (name.includes('@metamask/perps-controller') && name.includes('/constants/perpsConfig.')) {
|
|
254
|
+
perpsConfigModuleId = moduleId;
|
|
255
|
+
}
|
|
256
|
+
if (name === 'app/components/UI/Perps/providers/PerpsStreamManager.tsx' || name.endsWith('/app/components/UI/Perps/providers/PerpsStreamManager.tsx')) {
|
|
257
|
+
streamManagerModuleId = moduleId;
|
|
258
|
+
}
|
|
152
259
|
});
|
|
153
|
-
|
|
154
|
-
|
|
260
|
+
if (storageModuleId === null || perpsConfigModuleId === null || streamManagerModuleId === null) {
|
|
261
|
+
throw new Error('Required Mobile storage, Perps constants, or stream manager module is unavailable in Metro.');
|
|
262
|
+
}
|
|
263
|
+
var storageExports = metroRequire(storageModuleId);
|
|
264
|
+
var storage = storageExports && (storageExports.default || storageExports);
|
|
265
|
+
var perpsConfig = metroRequire(perpsConfigModuleId);
|
|
266
|
+
var streamManagerExports = metroRequire(streamManagerModuleId);
|
|
267
|
+
var getStreamManager = streamManagerExports && streamManagerExports.getStreamManagerInstance;
|
|
268
|
+
var streamManager = typeof getStreamManager === 'function' ? getStreamManager() : null;
|
|
269
|
+
var keys = [
|
|
270
|
+
perpsConfig.PERPS_DISK_CACHE_MARKETS,
|
|
271
|
+
perpsConfig.PERPS_DISK_CACHE_USER_DATA,
|
|
272
|
+
];
|
|
273
|
+
if (!storage || typeof storage.removeItem !== 'function' || typeof storage.getItem !== 'function' || keys.some(function(key){ return typeof key !== 'string' || key.length === 0; })) {
|
|
274
|
+
throw new Error('Resolved Mobile storage or Perps cache constants are invalid.');
|
|
275
|
+
}
|
|
276
|
+
if (!streamManager) {
|
|
277
|
+
throw new Error('Resolved Mobile Perps stream manager is invalid.');
|
|
278
|
+
}
|
|
279
|
+
var engine = globalThis.Engine;
|
|
280
|
+
var controller = engine && engine.context && engine.context.PerpsController;
|
|
281
|
+
if (!controller || typeof controller.update !== 'function' || !controller.state) {
|
|
282
|
+
throw new Error('Engine.context.PerpsController cache state is unavailable.');
|
|
283
|
+
}
|
|
284
|
+
var marketEntries = Object.keys(controller.state.cachedMarketDataByProvider || {}).length;
|
|
285
|
+
var userEntries = Object.keys(controller.state.cachedUserDataByProvider || {}).length;
|
|
286
|
+
var channelNames = ['marketData', 'positions', 'orders', 'account'];
|
|
287
|
+
var quiesceChannels = function(){
|
|
288
|
+
channelNames.forEach(function(name){
|
|
289
|
+
var channel = streamManager[name];
|
|
290
|
+
if (!channel || typeof channel.disconnect !== 'function' || typeof channel.clearCache !== 'function') {
|
|
291
|
+
throw new Error('Perps stream channel ' + name + ' cannot be quiesced.');
|
|
292
|
+
}
|
|
293
|
+
channel.disconnect();
|
|
294
|
+
});
|
|
295
|
+
};
|
|
296
|
+
quiesceChannels();
|
|
297
|
+
if (typeof controller.stopMarketDataPreload === 'function') {
|
|
298
|
+
controller.stopMarketDataPreload();
|
|
299
|
+
}
|
|
300
|
+
var pendingOperations = [
|
|
301
|
+
streamManager.marketData && streamManager.marketData.fetchPromise,
|
|
302
|
+
streamManager.userDataSnapshotPromise,
|
|
303
|
+
].filter(function(value){ return value && typeof value.then === 'function'; });
|
|
304
|
+
return Promise.allSettled(pendingOperations).then(function(){
|
|
305
|
+
quiesceChannels();
|
|
306
|
+
channelNames.forEach(function(name){ streamManager[name].clearCache(); });
|
|
307
|
+
controller.update(function(state){
|
|
308
|
+
state.cachedMarketDataByProvider = {};
|
|
309
|
+
state.cachedUserDataByProvider = {};
|
|
310
|
+
});
|
|
311
|
+
return Promise.all(keys.map(function(key){ return storage.removeItem(key); }));
|
|
312
|
+
}).then(function(){
|
|
313
|
+
return new Promise(function(resolve){ setTimeout(resolve, 250); });
|
|
314
|
+
}).then(function(){
|
|
315
|
+
return Promise.all(keys.map(function(key){ return storage.getItem(key); }));
|
|
316
|
+
}).then(function(values){
|
|
317
|
+
var remainingKeys = keys.filter(function(_key, index){ return values[index] !== null && values[index] !== undefined; });
|
|
318
|
+
if (remainingKeys.length > 0) {
|
|
319
|
+
throw new Error('Perps performance cache repopulated after clear: ' + remainingKeys.join(', '));
|
|
320
|
+
}
|
|
321
|
+
return JSON.stringify({
|
|
322
|
+
ok: true,
|
|
323
|
+
method: 'metro-cdp',
|
|
324
|
+
clearedStorageKeys: keys,
|
|
325
|
+
clearedControllerMarketEntries: marketEntries,
|
|
326
|
+
clearedControllerUserEntries: userEntries,
|
|
327
|
+
quiescedChannels: channelNames,
|
|
328
|
+
awaitedPendingOperations: pendingOperations.length,
|
|
329
|
+
storageVerifiedAbsent: true,
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
})()`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function clearPerformanceCaches(input) {
|
|
336
|
+
return evalAsync(input, clearPerformanceCachesExpression());
|
|
155
337
|
}
|
|
156
338
|
|
|
157
339
|
async function waitForPositionsAbsent(input, symbols, timeoutMs = 30000) {
|
|
@@ -846,6 +1028,7 @@ const DIRECT_ACTIONS = new Map([
|
|
|
846
1028
|
['metamask.perps.ensure_positions', ensurePositions],
|
|
847
1029
|
['metamask.perps.ensure_orders', ensureOrders],
|
|
848
1030
|
['metamask.perps.clear_performance_caches', clearPerformanceCaches],
|
|
1031
|
+
['metamask.perps.measure_homepage_visible', measureHomepageVisible],
|
|
849
1032
|
['metamask.perps.start_state', startState],
|
|
850
1033
|
['metamask.perps.teardown_state', teardownState],
|
|
851
1034
|
]);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
import { runAdapter } from '../platform/bridge.mjs';
|
|
5
|
+
import { resolveMobileToolPath } from '../platform/tool-paths.mjs';
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
async function prepareLocalSnapshotEndpoint(input) {
|
|
10
|
+
const port = Number(input.node?.port);
|
|
11
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
'metamask.perps.prepare_local_snapshot_endpoint requires an integer port between 1 and 65535.',
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const serial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL;
|
|
18
|
+
if (!serial) {
|
|
19
|
+
return {
|
|
20
|
+
action: input.action,
|
|
21
|
+
applied: false,
|
|
22
|
+
reason: 'not-required-for-ios-simulator',
|
|
23
|
+
port,
|
|
24
|
+
proofPath: 'local-snapshot-endpoint-platform-check',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const adb = resolveMobileToolPath('adb');
|
|
29
|
+
if (!adb) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
'metamask.perps.prepare_local_snapshot_endpoint could not resolve adb.',
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
const mapping = `tcp:${port}`;
|
|
35
|
+
await execFileAsync(adb, ['-s', serial, 'reverse', mapping, mapping], {
|
|
36
|
+
timeout: 10_000,
|
|
37
|
+
});
|
|
38
|
+
const { stdout } = await execFileAsync(adb, ['-s', serial, 'reverse', '--list'], {
|
|
39
|
+
timeout: 10_000,
|
|
40
|
+
});
|
|
41
|
+
const applied = stdout
|
|
42
|
+
.split(/\r?\n/u)
|
|
43
|
+
.some((line) => line.includes(`${mapping} ${mapping}`));
|
|
44
|
+
if (!applied) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`metamask.perps.prepare_local_snapshot_endpoint did not observe ${mapping} after applying it to ${serial}.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
action: input.action,
|
|
51
|
+
applied: true,
|
|
52
|
+
port,
|
|
53
|
+
redacted: true,
|
|
54
|
+
proofPath: 'adb-reverse-local-snapshot-endpoint',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
runAdapter(prepareLocalSnapshotEndpoint);
|
|
@@ -23,6 +23,41 @@ export const MOBILE_BRIDGE_ERROR_CODES = BRIDGE_ERROR_CODES;
|
|
|
23
23
|
|
|
24
24
|
const execFileAsync = promisify(execFile);
|
|
25
25
|
|
|
26
|
+
let adapterActionActive = false;
|
|
27
|
+
let actionBridgeLockPath = null;
|
|
28
|
+
|
|
29
|
+
function resolveBridgeLockPath(input, env) {
|
|
30
|
+
const configuredRuntime = env.RECIPE_RUNTIME_DIR;
|
|
31
|
+
const runtime = configuredRuntime
|
|
32
|
+
? (path.isAbsolute(configuredRuntime)
|
|
33
|
+
? configuredRuntime
|
|
34
|
+
: path.resolve(input.context.projectRoot, configuredRuntime))
|
|
35
|
+
: path.join(input.context.projectRoot, 'temp', 'recipe', 'runtime');
|
|
36
|
+
return path.join(runtime, 'cdp-bridge.lock');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function acquireActionBridgeLock(input, env) {
|
|
40
|
+
if (actionBridgeLockPath) return String(process.pid);
|
|
41
|
+
const lockPath = resolveBridgeLockPath(input, env);
|
|
42
|
+
await mkdir(path.dirname(lockPath), { recursive: true });
|
|
43
|
+
await writeFile(lockPath, String(process.pid));
|
|
44
|
+
actionBridgeLockPath = lockPath;
|
|
45
|
+
return String(process.pid);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function releaseActionBridgeLock() {
|
|
49
|
+
const lockPath = actionBridgeLockPath;
|
|
50
|
+
actionBridgeLockPath = null;
|
|
51
|
+
if (!lockPath) return;
|
|
52
|
+
try {
|
|
53
|
+
if ((await readFile(lockPath, 'utf8')).trim() === String(process.pid)) {
|
|
54
|
+
await rm(lockPath, { force: true });
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// The bridge child or a newer owner may already have replaced the lock.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
26
61
|
// Recover the typed code a cdp-bridge child reported: the stderr marker is
|
|
27
62
|
// authoritative (it carries the class the bridge classified at the source), the
|
|
28
63
|
// coded exit status is the fallback for output-less failures, and a raw message
|
|
@@ -52,8 +87,14 @@ export async function writeOutput(input, output) {
|
|
|
52
87
|
|
|
53
88
|
export async function runAdapter(callback) {
|
|
54
89
|
const input = await loadInput();
|
|
55
|
-
|
|
56
|
-
|
|
90
|
+
adapterActionActive = true;
|
|
91
|
+
try {
|
|
92
|
+
const output = await callback(input);
|
|
93
|
+
await writeOutput(input, output);
|
|
94
|
+
} finally {
|
|
95
|
+
adapterActionActive = false;
|
|
96
|
+
await releaseActionBridgeLock();
|
|
97
|
+
}
|
|
57
98
|
}
|
|
58
99
|
|
|
59
100
|
function bridgeScript(input) {
|
|
@@ -127,7 +168,13 @@ export async function bridgeEnv(input) {
|
|
|
127
168
|
// (e.g. "Pixel 6" matches Metro deviceName "Pixel 6 - 16 - API 36").
|
|
128
169
|
const serialStr = adbSerial != null ? String(adbSerial) : '';
|
|
129
170
|
const deviceStr = androidDevice != null ? String(androidDevice) : '';
|
|
130
|
-
if (
|
|
171
|
+
if (
|
|
172
|
+
shouldResolveAndroidTargetModel(
|
|
173
|
+
serialStr,
|
|
174
|
+
deviceStr,
|
|
175
|
+
androidTargetDeviceName,
|
|
176
|
+
)
|
|
177
|
+
) {
|
|
131
178
|
const model = await resolveAndroidModel(serialStr);
|
|
132
179
|
if (model) {
|
|
133
180
|
env.ANDROID_TARGET_DEVICE_NAME = model;
|
|
@@ -150,6 +197,17 @@ export async function bridgeEnv(input) {
|
|
|
150
197
|
return env;
|
|
151
198
|
}
|
|
152
199
|
|
|
200
|
+
export function shouldResolveAndroidTargetModel(
|
|
201
|
+
adbSerial,
|
|
202
|
+
androidDevice,
|
|
203
|
+
androidTargetDeviceName,
|
|
204
|
+
) {
|
|
205
|
+
const serial = String(adbSerial ?? '').trim();
|
|
206
|
+
const device = String(androidDevice ?? '').trim();
|
|
207
|
+
const targetName = String(androidTargetDeviceName ?? '').trim();
|
|
208
|
+
return Boolean(serial && !targetName && (!device || device === serial));
|
|
209
|
+
}
|
|
210
|
+
|
|
153
211
|
function resolveMobileTarget(input) {
|
|
154
212
|
const contextEnv = input.context?.env || {};
|
|
155
213
|
const watcherPort = input.node?.watcher_port ?? input.node?.metro_port ?? input.node?.cdp_port ?? contextEnv.WATCHER_PORT ?? contextEnv.CDP_PORT ?? contextEnv.RECIPE_CDP_PORT ?? process.env.WATCHER_PORT ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
|
|
@@ -314,6 +372,9 @@ export async function bridgeCommand(input, args) {
|
|
|
314
372
|
const script = bridgeScript(input);
|
|
315
373
|
// bridgeEnv is async: it may call `adb getprop` to resolve the Metro device name.
|
|
316
374
|
const env = await bridgeEnv(input);
|
|
375
|
+
if (adapterActionActive) {
|
|
376
|
+
env.CDP_BRIDGE_LOCK_OWNER_PID = await acquireActionBridgeLock(input, env);
|
|
377
|
+
}
|
|
317
378
|
const result = await new Promise((resolve, reject) => {
|
|
318
379
|
const timeoutMs = Number(input.node?.bridge_timeout_ms ?? input.node?.cdp_timeout_ms ?? process.env.CDP_TIMEOUT ?? 30000);
|
|
319
380
|
const child = spawn(process.execPath, [script, ...args], {
|
|
@@ -465,14 +526,21 @@ function valueContains(actual, expected) {
|
|
|
465
526
|
return Object.is(actual, expected);
|
|
466
527
|
}
|
|
467
528
|
|
|
468
|
-
function
|
|
529
|
+
export function routeSatisfiesNavigationTarget(
|
|
530
|
+
route,
|
|
531
|
+
expectedRoute,
|
|
532
|
+
expectedParams,
|
|
533
|
+
) {
|
|
469
534
|
return routeName(route) === expectedRoute &&
|
|
470
535
|
(expectedParams === undefined || valueContains(route?.params ?? {}, expectedParams));
|
|
471
536
|
}
|
|
472
537
|
|
|
473
538
|
function routeTransitionProven(previousRoute, currentRoute, expectedRoute, expectedParams) {
|
|
474
|
-
if (
|
|
475
|
-
|
|
539
|
+
if (
|
|
540
|
+
!previousRoute ||
|
|
541
|
+
!routeSatisfiesNavigationTarget(currentRoute, expectedRoute, expectedParams)
|
|
542
|
+
) return false;
|
|
543
|
+
if (!routeSatisfiesNavigationTarget(previousRoute, expectedRoute, expectedParams)) return true;
|
|
476
544
|
return Boolean(
|
|
477
545
|
previousRoute.key &&
|
|
478
546
|
currentRoute?.key &&
|
|
@@ -502,6 +570,23 @@ export async function navigate(input, route, params = {}, expectedRoute) {
|
|
|
502
570
|
if (!isTargetTransition(error)) throw error;
|
|
503
571
|
}
|
|
504
572
|
|
|
573
|
+
if (
|
|
574
|
+
routeSatisfiesNavigationTarget(
|
|
575
|
+
previousRoute,
|
|
576
|
+
requestedRoute,
|
|
577
|
+
requestedParams,
|
|
578
|
+
)
|
|
579
|
+
) {
|
|
580
|
+
return {
|
|
581
|
+
navigated: route,
|
|
582
|
+
params,
|
|
583
|
+
previousRoute,
|
|
584
|
+
currentRoute: previousRoute,
|
|
585
|
+
verifiedRoute: requestedRoute,
|
|
586
|
+
alreadyAtRoute: true,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
505
590
|
while (navigateAttempts < 2 || recoveryPending) {
|
|
506
591
|
if (recoveryPending) {
|
|
507
592
|
const deadline = recoveryDeadline ?? Date.now() + timeoutMs;
|
|
@@ -537,7 +622,14 @@ export async function navigate(input, route, params = {}, expectedRoute) {
|
|
|
537
622
|
},
|
|
538
623
|
};
|
|
539
624
|
}
|
|
540
|
-
if (
|
|
625
|
+
if (
|
|
626
|
+
!previousRoute &&
|
|
627
|
+
routeSatisfiesNavigationTarget(
|
|
628
|
+
currentRoute,
|
|
629
|
+
requestedRoute,
|
|
630
|
+
requestedParams,
|
|
631
|
+
)
|
|
632
|
+
) {
|
|
541
633
|
break;
|
|
542
634
|
}
|
|
543
635
|
recoveryPending = false;
|
|
@@ -621,7 +713,9 @@ export async function waitForRoute(
|
|
|
621
713
|
await waitForNextRouteProbe(deadline);
|
|
622
714
|
continue;
|
|
623
715
|
}
|
|
624
|
-
if (
|
|
716
|
+
if (
|
|
717
|
+
routeSatisfiesNavigationTarget(lastRoute, expected, expectedParams)
|
|
718
|
+
) return lastRoute;
|
|
625
719
|
pollCount += 1;
|
|
626
720
|
await waitForNextRouteProbe(deadline);
|
|
627
721
|
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { pathToFileURL } from 'node:url';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
bridgeCommand,
|
|
5
|
+
runAdapter,
|
|
6
|
+
selectBridgeStatusEntry,
|
|
7
|
+
} from '../platform/bridge.mjs';
|
|
4
8
|
import { walletFixturePath } from '../../harness-exports.mjs';
|
|
5
9
|
|
|
6
10
|
async function fixturePassword(projectRoot) {
|
|
@@ -39,7 +43,30 @@ function routeName(status, input) {
|
|
|
39
43
|
const selectedStatus = selectBridgeStatusEntry;
|
|
40
44
|
|
|
41
45
|
async function status(input) {
|
|
42
|
-
return bridgeCommand(input, ['status']);
|
|
46
|
+
return bridgeCommand(input, ['status-selected']);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function statusBeforeDeadline(input, deadline) {
|
|
50
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
51
|
+
const configuredMs = Number(
|
|
52
|
+
input.node?.bridge_timeout_ms ??
|
|
53
|
+
input.node?.cdp_timeout_ms ??
|
|
54
|
+
process.env.CDP_TIMEOUT ??
|
|
55
|
+
30000,
|
|
56
|
+
);
|
|
57
|
+
const probeTimeoutMs = Math.min(
|
|
58
|
+
5000,
|
|
59
|
+
remainingMs,
|
|
60
|
+
Number.isFinite(configuredMs) && configuredMs > 0 ? configuredMs : 30000,
|
|
61
|
+
);
|
|
62
|
+
return status({
|
|
63
|
+
...input,
|
|
64
|
+
node: {
|
|
65
|
+
...input.node,
|
|
66
|
+
bridge_timeout_ms: probeTimeoutMs,
|
|
67
|
+
cdp_timeout_ms: probeTimeoutMs,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
43
70
|
}
|
|
44
71
|
|
|
45
72
|
async function waitForTargetStatus(input, timeoutMs = 20000) {
|
|
@@ -48,7 +75,7 @@ async function waitForTargetStatus(input, timeoutMs = 20000) {
|
|
|
48
75
|
let lastError = null;
|
|
49
76
|
while (Date.now() < deadline) {
|
|
50
77
|
try {
|
|
51
|
-
last = await
|
|
78
|
+
last = await statusBeforeDeadline(input, deadline);
|
|
52
79
|
const selected = selectedStatus(last, input);
|
|
53
80
|
if (selected?.agenticPresent === true) return last;
|
|
54
81
|
} catch (error) {
|
|
@@ -69,7 +96,7 @@ async function waitForWalletState(input, initialStatus, timeoutMs) {
|
|
|
69
96
|
if (Date.now() >= deadline) break;
|
|
70
97
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
71
98
|
try {
|
|
72
|
-
last = await
|
|
99
|
+
last = await statusBeforeDeadline(input, deadline);
|
|
73
100
|
} catch {
|
|
74
101
|
// A rotating Hermes target during startup is transient. The target timeout
|
|
75
102
|
// still bounds this loop and the final error reports the last wallet state.
|
|
@@ -80,13 +107,13 @@ async function waitForWalletState(input, initialStatus, timeoutMs) {
|
|
|
80
107
|
);
|
|
81
108
|
}
|
|
82
109
|
|
|
83
|
-
async function waitForUnlocked(input, timeoutMs =
|
|
110
|
+
async function waitForUnlocked(input, timeoutMs = 30000) {
|
|
84
111
|
const deadline = Date.now() + timeoutMs;
|
|
85
112
|
let last = null;
|
|
86
113
|
let lastError = null;
|
|
87
114
|
while (Date.now() < deadline) {
|
|
88
115
|
try {
|
|
89
|
-
last = await
|
|
116
|
+
last = await statusBeforeDeadline(input, deadline);
|
|
90
117
|
if (selectedAccount(last, input) && routeName(last, input) !== 'Login') return last;
|
|
91
118
|
} catch (error) {
|
|
92
119
|
lastError = error;
|
|
@@ -108,7 +135,7 @@ async function waitForStableUnlocked(input, initialStatus, stableMs = 750) {
|
|
|
108
135
|
while (Date.now() < deadline) {
|
|
109
136
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
110
137
|
try {
|
|
111
|
-
last = await
|
|
138
|
+
last = await statusBeforeDeadline(input, deadline);
|
|
112
139
|
if (!isUnlockedStatus(last, input)) {
|
|
113
140
|
if (routeName(last, input) === 'Login') return null;
|
|
114
141
|
transientDrops += 1;
|
|
@@ -152,7 +179,7 @@ export async function ensureUnlocked(input) {
|
|
|
152
179
|
const password = input.node?.password ?? await fixturePassword(input.context.projectRoot);
|
|
153
180
|
try {
|
|
154
181
|
const result = await bridgeCommand(input, ['unlock', String(password)]);
|
|
155
|
-
const after = await waitForUnlocked(input, Number(input.node?.unlock_timeout_ms ??
|
|
182
|
+
const after = await waitForUnlocked(input, Number(input.node?.unlock_timeout_ms ?? 30000));
|
|
156
183
|
const stableAfter = await waitForStableUnlocked(input, after, Number(input.node?.stable_unlocked_ms ?? 750)) ?? after;
|
|
157
184
|
return {
|
|
158
185
|
action: input.action,
|