@deeeed/metamask-harness 0.26.4 → 0.27.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 +18 -0
- package/adapters/mobile/open-device.sh +16 -4
- package/adapters/mobile/start-metro.sh +33 -0
- package/dist/adapters/mobile/metro-env.js +9 -0
- package/dist/adapters/mobile/prepare.js +238 -37
- package/dist/adapters/mobile/runtime-decision.js +4 -1
- package/dist/adapters.js +19 -6
- package/dist/commands/device-target.js +4 -1
- package/dist/commands/doctor.js +28 -2
- package/dist/commands/mobile-device-view.js +4 -1
- package/dist/devices.js +4 -1
- package/dist/doctor.js +25 -2
- package/dist/run-recording.js +128 -92
- package/library/actions/extension/platform/cdp.mjs +170 -117
- package/library/actions/mobile/analytics/set_consent.mjs +79 -43
- package/library/actions/mobile/platform/bridge.mjs +21 -9
- package/library/actions/mobile/platform/observe-ui.mjs +416 -0
- package/library/actions/mobile/platform/tool-paths.mjs +122 -0
- package/library/actions/shared/analytics/collector.mjs +50 -5
- package/library/manifests/mobile.action-manifest.json +25 -1
- package/package.json +1 -1
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
import { mobileToolRecovery, resolveMobileToolPath } from './tool-paths.mjs';
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const VISIBLE_LIMIT = 50;
|
|
8
|
+
const HIDDEN_LIMIT = 50;
|
|
9
|
+
|
|
10
|
+
export async function observeNativeUi(payload, context) {
|
|
11
|
+
const refs = Array.isArray(payload?.refs)
|
|
12
|
+
? payload.refs.filter((ref) => typeof ref === 'string')
|
|
13
|
+
: [];
|
|
14
|
+
const supported = refs.filter((ref) => ref === 'ui.screen' || ref === 'ui.visible');
|
|
15
|
+
const warnings = refs
|
|
16
|
+
.filter((ref) => !supported.includes(ref))
|
|
17
|
+
.map((ref) => ({ ref, message: `Unsupported UI observer: ${ref}.` }));
|
|
18
|
+
if (supported.length === 0) return warnings.length ? { warnings } : {};
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const hierarchy = await readNativeHierarchy(payload, context);
|
|
22
|
+
const observations = {};
|
|
23
|
+
for (const ref of supported) {
|
|
24
|
+
observations[ref] = ref === 'ui.screen' ? hierarchy.screen : hierarchy.visible;
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
observations,
|
|
28
|
+
...(warnings.length ? { warnings } : {}),
|
|
29
|
+
};
|
|
30
|
+
} catch (error) {
|
|
31
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
32
|
+
return {
|
|
33
|
+
warnings: [
|
|
34
|
+
...warnings,
|
|
35
|
+
...supported.map((ref) => ({ ref, message })),
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readNativeHierarchy(payload, context) {
|
|
42
|
+
const node = record(payload?.node);
|
|
43
|
+
const env = {
|
|
44
|
+
...process.env,
|
|
45
|
+
...record(context?.env),
|
|
46
|
+
};
|
|
47
|
+
const platform = resolvePlatform(node, env);
|
|
48
|
+
if (platform === 'android') return readAndroidHierarchy(node, env);
|
|
49
|
+
return readIosHierarchy(node, env);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function readIosHierarchy(node, env) {
|
|
53
|
+
const idbPath = resolveMobileToolPath('idb');
|
|
54
|
+
if (!idbPath) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`iOS UI observation requires the idb client.\n Next: ${mobileToolRecovery('idb')}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const udid = await resolveIosUdid(node, env);
|
|
60
|
+
const { stdout } = await execFileAsync(
|
|
61
|
+
idbPath,
|
|
62
|
+
['ui', 'describe-all', '--udid', udid],
|
|
63
|
+
{
|
|
64
|
+
encoding: 'utf8',
|
|
65
|
+
timeout: 15_000,
|
|
66
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
67
|
+
env,
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
const parsed = parseJsonOutput(stdout);
|
|
71
|
+
const hierarchy = flattenIosHierarchy(parsed);
|
|
72
|
+
if (hierarchy.nodeCount === 0) {
|
|
73
|
+
throw new Error('idb returned an empty accessibility hierarchy.');
|
|
74
|
+
}
|
|
75
|
+
return normalizedHierarchy(
|
|
76
|
+
'idb-accessibility',
|
|
77
|
+
'ios',
|
|
78
|
+
udid,
|
|
79
|
+
hierarchy.visible,
|
|
80
|
+
hierarchy.hidden,
|
|
81
|
+
hierarchy,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function readAndroidHierarchy(node, env) {
|
|
86
|
+
const adbPath = resolveMobileToolPath('adb');
|
|
87
|
+
if (!adbPath) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Android UI observation requires Android SDK Platform-Tools.\n Next: ${mobileToolRecovery('adb')}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
const serial = await resolveAndroidSerial(adbPath, node, env);
|
|
93
|
+
const { stdout } = await execFileAsync(
|
|
94
|
+
adbPath,
|
|
95
|
+
['-s', serial, 'exec-out', 'uiautomator', 'dump', '/dev/tty'],
|
|
96
|
+
{
|
|
97
|
+
encoding: 'utf8',
|
|
98
|
+
timeout: 15_000,
|
|
99
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
100
|
+
env,
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
const hierarchy = parseAndroidHierarchy(stdout);
|
|
104
|
+
if (hierarchy.nodeCount === 0) {
|
|
105
|
+
throw new Error('adb returned an empty UIAutomator hierarchy.');
|
|
106
|
+
}
|
|
107
|
+
return normalizedHierarchy(
|
|
108
|
+
'adb-uiautomator',
|
|
109
|
+
'android',
|
|
110
|
+
serial,
|
|
111
|
+
hierarchy.visible,
|
|
112
|
+
hierarchy.hidden,
|
|
113
|
+
hierarchy,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function normalizedHierarchy(
|
|
118
|
+
provider,
|
|
119
|
+
platform,
|
|
120
|
+
deviceId,
|
|
121
|
+
nodes,
|
|
122
|
+
hiddenNodes = [],
|
|
123
|
+
metadata = {},
|
|
124
|
+
) {
|
|
125
|
+
const items = nodes.slice(0, VISIBLE_LIMIT);
|
|
126
|
+
const hiddenOrOffscreen = hiddenNodes.slice(0, HIDDEN_LIMIT);
|
|
127
|
+
const name = metadata.screenName ??
|
|
128
|
+
items.find((item) => item.label)?.label ??
|
|
129
|
+
'MetaMask';
|
|
130
|
+
return {
|
|
131
|
+
screen: {
|
|
132
|
+
provider,
|
|
133
|
+
platform,
|
|
134
|
+
deviceId,
|
|
135
|
+
name,
|
|
136
|
+
nodeCount: metadata.nodeCount ?? nodes.length + hiddenNodes.length,
|
|
137
|
+
truncated: nodes.length > VISIBLE_LIMIT || hiddenNodes.length > HIDDEN_LIMIT,
|
|
138
|
+
},
|
|
139
|
+
visible: {
|
|
140
|
+
provider,
|
|
141
|
+
items,
|
|
142
|
+
hidden_or_offscreen: hiddenOrOffscreen,
|
|
143
|
+
truncated: nodes.length > VISIBLE_LIMIT || hiddenNodes.length > HIDDEN_LIMIT,
|
|
144
|
+
limits: { items: VISIBLE_LIMIT, hidden_or_offscreen: HIDDEN_LIMIT },
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function resolveIosUdid(node, env) {
|
|
150
|
+
const requested = text(node.simulator) ?? text(node.ios_simulator) ??
|
|
151
|
+
text(env.SIM_UDID) ?? text(env.IOS_SIMULATOR) ?? 'booted';
|
|
152
|
+
if (/^[0-9a-f-]{36}$/iu.test(requested)) return requested;
|
|
153
|
+
const { stdout } = await execFileAsync(
|
|
154
|
+
'xcrun',
|
|
155
|
+
['simctl', 'list', 'devices', 'booted', '-j'],
|
|
156
|
+
{
|
|
157
|
+
encoding: 'utf8',
|
|
158
|
+
timeout: 5_000,
|
|
159
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
160
|
+
env,
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
const parsed = JSON.parse(stdout);
|
|
164
|
+
const devices = Object.values(record(parsed.devices)).flatMap((value) =>
|
|
165
|
+
Array.isArray(value) ? value : []);
|
|
166
|
+
const matched = devices.find((device) => {
|
|
167
|
+
const candidate = record(device);
|
|
168
|
+
return candidate.state === 'Booted' && (
|
|
169
|
+
requested === 'booted' ||
|
|
170
|
+
candidate.udid === requested ||
|
|
171
|
+
candidate.name === requested
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
const udid = text(record(matched).udid);
|
|
175
|
+
if (!udid) throw new Error(`No booted iOS simulator matched ${requested}.`);
|
|
176
|
+
return udid;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function resolveAndroidSerial(adbPath, node, env) {
|
|
180
|
+
const explicit = text(node.adb_serial) ?? text(node.android_serial) ??
|
|
181
|
+
text(env.ADB_SERIAL) ?? text(env.ANDROID_SERIAL);
|
|
182
|
+
if (explicit) return explicit;
|
|
183
|
+
const { stdout } = await execFileAsync(adbPath, ['devices'], {
|
|
184
|
+
encoding: 'utf8',
|
|
185
|
+
timeout: 5_000,
|
|
186
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
187
|
+
env,
|
|
188
|
+
});
|
|
189
|
+
const serials = stdout
|
|
190
|
+
.split(/\r?\n/u)
|
|
191
|
+
.map((line) => line.trim().split(/\s+/u))
|
|
192
|
+
.filter((fields) => fields.length >= 2 && fields[1] === 'device')
|
|
193
|
+
.map(([serial]) => serial);
|
|
194
|
+
if (serials.length !== 1) {
|
|
195
|
+
throw new Error(`Android UI observation requires one ready device; found ${serials.length}.`);
|
|
196
|
+
}
|
|
197
|
+
return serials[0];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function resolvePlatform(node, env) {
|
|
201
|
+
const explicit = text(node.platform) ?? text(env.MOBILE_PLATFORM) ?? text(env.PLATFORM);
|
|
202
|
+
if (explicit === 'android' || explicit === 'ios') return explicit;
|
|
203
|
+
if (text(node.adb_serial) || text(env.ADB_SERIAL) || text(env.ANDROID_SERIAL)) {
|
|
204
|
+
return 'android';
|
|
205
|
+
}
|
|
206
|
+
return 'ios';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parseJsonOutput(output) {
|
|
210
|
+
try {
|
|
211
|
+
return JSON.parse(output);
|
|
212
|
+
} catch {
|
|
213
|
+
const values = output
|
|
214
|
+
.split(/\r?\n/u)
|
|
215
|
+
.map((line) => line.trim())
|
|
216
|
+
.filter(Boolean)
|
|
217
|
+
.flatMap((line) => {
|
|
218
|
+
try {
|
|
219
|
+
return [JSON.parse(line)];
|
|
220
|
+
} catch {
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
if (values.length === 0) throw new Error('idb returned malformed accessibility JSON.');
|
|
225
|
+
return values.length === 1 ? values[0] : values;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function flattenIosHierarchy(value) {
|
|
230
|
+
const visible = [];
|
|
231
|
+
const hidden = [];
|
|
232
|
+
let nodeCount = 0;
|
|
233
|
+
let screenName;
|
|
234
|
+
let screenBounds;
|
|
235
|
+
const visit = (current) => {
|
|
236
|
+
if (Array.isArray(current)) {
|
|
237
|
+
for (const child of current) visit(child);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const item = record(current);
|
|
241
|
+
if (Object.keys(item).length === 0) return;
|
|
242
|
+
nodeCount += 1;
|
|
243
|
+
const role = text(item.role) ?? text(item.type) ?? text(item.AXRole) ??
|
|
244
|
+
text(item.role_description) ?? text(item.className);
|
|
245
|
+
const label = text(item.label) ?? text(item.AXLabel) ?? text(item.name) ??
|
|
246
|
+
text(item.title);
|
|
247
|
+
const testId = text(item.identifier) ?? text(item.accessibilityIdentifier) ??
|
|
248
|
+
text(item.AXUniqueId) ?? text(item.id);
|
|
249
|
+
const bounds = normalizeBounds(item.frame ?? item.AXFrame ?? item.bounds);
|
|
250
|
+
if (isApplicationRole(role)) {
|
|
251
|
+
screenName ??= label;
|
|
252
|
+
screenBounds ??= bounds;
|
|
253
|
+
}
|
|
254
|
+
if (isActionableRole(role)) {
|
|
255
|
+
const normalized = compactItem({
|
|
256
|
+
role,
|
|
257
|
+
label,
|
|
258
|
+
test_id: testId,
|
|
259
|
+
enabled: boolean(item.enabled ?? item.AXEnabled),
|
|
260
|
+
selected: boolean(item.selected ?? item.AXSelected),
|
|
261
|
+
focused: boolean(item.focused ?? item.AXFocused),
|
|
262
|
+
bounds,
|
|
263
|
+
});
|
|
264
|
+
if (hasVisibleBounds(bounds, screenBounds)) {
|
|
265
|
+
visible.push(normalized);
|
|
266
|
+
} else {
|
|
267
|
+
hidden.push({ ...normalized, reason: 'hidden_or_offscreen' });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
for (const key of ['children', 'AXChildren', 'elements', 'subviews']) {
|
|
271
|
+
if (item[key] !== undefined) visit(item[key]);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
visit(value);
|
|
275
|
+
return { visible, hidden, nodeCount, screenName };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function parseAndroidHierarchy(output) {
|
|
279
|
+
const xmlStart = Math.max(output.indexOf('<?xml'), output.indexOf('<hierarchy'));
|
|
280
|
+
const xml = xmlStart >= 0 ? output.slice(xmlStart) : output;
|
|
281
|
+
const visible = [];
|
|
282
|
+
const hidden = [];
|
|
283
|
+
const ancestorVisibility = [];
|
|
284
|
+
let nodeCount = 0;
|
|
285
|
+
let screenName;
|
|
286
|
+
for (const match of xml.matchAll(/<\/?node\b[^>]*>/gu)) {
|
|
287
|
+
const tag = match[0];
|
|
288
|
+
if (tag.startsWith('</')) {
|
|
289
|
+
ancestorVisibility.pop();
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const attributes = {};
|
|
293
|
+
for (const attribute of tag.matchAll(/([:\w-]+)="([^"]*)"/gu)) {
|
|
294
|
+
attributes[attribute[1]] = decodeXml(attribute[2]);
|
|
295
|
+
}
|
|
296
|
+
nodeCount += 1;
|
|
297
|
+
const className = text(attributes.class);
|
|
298
|
+
const role = className?.split('.').pop()?.toLowerCase();
|
|
299
|
+
const isEditable = role?.includes('edittext') || attributes.password === 'true';
|
|
300
|
+
const label = text(attributes['content-desc']) ??
|
|
301
|
+
(isEditable ? undefined : text(attributes.text));
|
|
302
|
+
if (!screenName && label && !isEditable) screenName = label;
|
|
303
|
+
const item = compactItem({
|
|
304
|
+
role,
|
|
305
|
+
label,
|
|
306
|
+
test_id: text(attributes['resource-id']),
|
|
307
|
+
enabled: boolean(attributes.enabled),
|
|
308
|
+
selected: boolean(attributes.selected),
|
|
309
|
+
focused: boolean(attributes.focused),
|
|
310
|
+
bounds: normalizeBounds(attributes.bounds),
|
|
311
|
+
});
|
|
312
|
+
const isVisible =
|
|
313
|
+
ancestorVisibility.every(Boolean) &&
|
|
314
|
+
attributes['visible-to-user'] !== 'false';
|
|
315
|
+
const isActionable =
|
|
316
|
+
isActionableRole(role) ||
|
|
317
|
+
attributes.clickable === 'true' ||
|
|
318
|
+
attributes['long-clickable'] === 'true';
|
|
319
|
+
if (isActionable) {
|
|
320
|
+
if (isVisible) {
|
|
321
|
+
visible.push(item);
|
|
322
|
+
} else {
|
|
323
|
+
hidden.push({ ...item, reason: 'hidden' });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (!tag.endsWith('/>')) ancestorVisibility.push(isVisible);
|
|
327
|
+
}
|
|
328
|
+
return { visible, hidden, nodeCount, screenName };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function isApplicationRole(role) {
|
|
332
|
+
return role?.toLowerCase().includes('application') ?? false;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function isActionableRole(role) {
|
|
336
|
+
const normalized = role?.toLowerCase() ?? '';
|
|
337
|
+
return ['button', 'cell', 'link', 'switch', 'textfield', 'text-field', 'edittext']
|
|
338
|
+
.some((candidate) => normalized.includes(candidate));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function hasVisibleBounds(bounds, viewport) {
|
|
342
|
+
if (bounds === undefined || bounds.width <= 0 || bounds.height <= 0) return false;
|
|
343
|
+
if (viewport === undefined) return true;
|
|
344
|
+
return (
|
|
345
|
+
bounds.x < viewport.x + viewport.width &&
|
|
346
|
+
bounds.x + bounds.width > viewport.x &&
|
|
347
|
+
bounds.y < viewport.y + viewport.height &&
|
|
348
|
+
bounds.y + bounds.height > viewport.y
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function normalizeBounds(value) {
|
|
353
|
+
if (typeof value === 'string') {
|
|
354
|
+
const numbers = value.match(/-?\d+(?:\.\d+)?/gu)?.map(Number) ?? [];
|
|
355
|
+
if (numbers.length >= 4) {
|
|
356
|
+
const [x, y, third, fourth] = numbers;
|
|
357
|
+
const endpointShape = value.includes('][');
|
|
358
|
+
return {
|
|
359
|
+
x: Math.round(x),
|
|
360
|
+
y: Math.round(y),
|
|
361
|
+
width: Math.round(endpointShape ? third - x : third),
|
|
362
|
+
height: Math.round(endpointShape ? fourth - y : fourth),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
const bounds = record(value);
|
|
368
|
+
const origin = record(bounds.origin);
|
|
369
|
+
const size = record(bounds.size);
|
|
370
|
+
const x = number(bounds.x ?? origin.x);
|
|
371
|
+
const y = number(bounds.y ?? origin.y);
|
|
372
|
+
const width = number(bounds.width ?? size.width);
|
|
373
|
+
const height = number(bounds.height ?? size.height);
|
|
374
|
+
if ([x, y, width, height].some((part) => part === undefined)) return undefined;
|
|
375
|
+
return {
|
|
376
|
+
x: Math.round(x),
|
|
377
|
+
y: Math.round(y),
|
|
378
|
+
width: Math.round(width),
|
|
379
|
+
height: Math.round(height),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function compactItem(item) {
|
|
384
|
+
return Object.fromEntries(
|
|
385
|
+
Object.entries(item).filter(([, value]) => value !== undefined && value !== ''),
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function record(value) {
|
|
390
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function text(value) {
|
|
394
|
+
if (typeof value !== 'string') return undefined;
|
|
395
|
+
const normalized = value.replace(/\s+/gu, ' ').trim();
|
|
396
|
+
return normalized || undefined;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function number(value) {
|
|
400
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function boolean(value) {
|
|
404
|
+
if (value === true || value === 'true' || value === 1 || value === '1') return true;
|
|
405
|
+
if (value === false || value === 'false' || value === 0 || value === '0') return false;
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function decodeXml(value) {
|
|
410
|
+
return value
|
|
411
|
+
.replaceAll('"', '"')
|
|
412
|
+
.replaceAll(''', "'")
|
|
413
|
+
.replaceAll('<', '<')
|
|
414
|
+
.replaceAll('>', '>')
|
|
415
|
+
.replaceAll('&', '&');
|
|
416
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { constants as fsConstants, accessSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const TOOL_ENV = {
|
|
6
|
+
adb: 'MM_HARNESS_ADB_PATH',
|
|
7
|
+
idb: 'MM_HARNESS_IDB_PATH',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const TOOL_RECOVERY = {
|
|
11
|
+
adb: 'brew install android-platform-tools',
|
|
12
|
+
idb: 'brew install idb-companion pipx python@3.13 && pipx install fb-idb --python python3.13',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const resolvedPaths = new Map();
|
|
16
|
+
|
|
17
|
+
export function mobileToolRecovery(tool) {
|
|
18
|
+
assertTool(tool);
|
|
19
|
+
return TOOL_RECOVERY[tool];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function mobileToolEnvName(tool) {
|
|
23
|
+
assertTool(tool);
|
|
24
|
+
return TOOL_ENV[tool];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resolveMobileToolPath(tool, { required = false } = {}) {
|
|
28
|
+
assertTool(tool);
|
|
29
|
+
const cached = resolvedPaths.get(tool);
|
|
30
|
+
if (cached && executablePath(cached)) return cached;
|
|
31
|
+
|
|
32
|
+
for (const candidate of candidatesFor(tool)) {
|
|
33
|
+
const resolved = executablePath(candidate);
|
|
34
|
+
if (!resolved) continue;
|
|
35
|
+
resolvedPaths.set(tool, resolved);
|
|
36
|
+
process.env[TOOL_ENV[tool]] = resolved;
|
|
37
|
+
return resolved;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
resolvedPaths.delete(tool);
|
|
41
|
+
if (!required) return null;
|
|
42
|
+
const label = tool === 'adb' ? 'Android SDK Platform-Tools (adb)' : 'Facebook idb client';
|
|
43
|
+
throw new Error(
|
|
44
|
+
`${label} is required but no executable was found.\n` +
|
|
45
|
+
` Next: ${TOOL_RECOVERY[tool]}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveAndExportMobileToolPaths(platform) {
|
|
50
|
+
if (platform === 'android') {
|
|
51
|
+
return { adb: resolveMobileToolPath('adb', { required: true }), idb: null };
|
|
52
|
+
}
|
|
53
|
+
if (platform === 'ios') {
|
|
54
|
+
return { adb: null, idb: resolveMobileToolPath('idb') };
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
adb: resolveMobileToolPath('adb'),
|
|
58
|
+
idb: resolveMobileToolPath('idb'),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function clearMobileToolPathCache() {
|
|
63
|
+
resolvedPaths.clear();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function candidatesFor(tool) {
|
|
67
|
+
const override = process.env[TOOL_ENV[tool]];
|
|
68
|
+
const pathCandidates = String(process.env.PATH ?? '')
|
|
69
|
+
.split(path.delimiter)
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.map((directory) => path.join(directory, tool));
|
|
72
|
+
const candidates = override ? [override] : [];
|
|
73
|
+
candidates.push(...pathCandidates);
|
|
74
|
+
if (tool === 'adb') {
|
|
75
|
+
for (const root of [
|
|
76
|
+
process.env.ANDROID_HOME,
|
|
77
|
+
process.env.ANDROID_SDK_ROOT,
|
|
78
|
+
path.join(os.homedir(), 'Library/Android/sdk'),
|
|
79
|
+
path.join(os.homedir(), 'Android/Sdk'),
|
|
80
|
+
]) {
|
|
81
|
+
if (root) candidates.push(path.join(root, 'platform-tools', 'adb'));
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
if (process.env.IDB_PATH) candidates.push(process.env.IDB_PATH);
|
|
85
|
+
candidates.push(
|
|
86
|
+
'/opt/homebrew/bin/idb',
|
|
87
|
+
'/usr/local/bin/idb',
|
|
88
|
+
path.join(os.homedir(), '.local/bin/idb'),
|
|
89
|
+
);
|
|
90
|
+
candidates.push(...pythonUserCandidates());
|
|
91
|
+
}
|
|
92
|
+
return [...new Set(candidates.map((candidate) => path.resolve(candidate)))];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function pythonUserCandidates() {
|
|
96
|
+
const root = path.join(os.homedir(), 'Library/Python');
|
|
97
|
+
try {
|
|
98
|
+
return readdirSync(root, { withFileTypes: true })
|
|
99
|
+
.filter((entry) => entry.isDirectory())
|
|
100
|
+
.map((entry) => path.join(root, entry.name, 'bin/idb'));
|
|
101
|
+
} catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function executablePath(candidate) {
|
|
107
|
+
try {
|
|
108
|
+
const resolved = realpathSync(candidate);
|
|
109
|
+
const stat = statSync(resolved);
|
|
110
|
+
if (!stat.isFile()) return null;
|
|
111
|
+
accessSync(resolved, fsConstants.X_OK);
|
|
112
|
+
return path.resolve(resolved);
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function assertTool(tool) {
|
|
119
|
+
if (tool !== 'adb' && tool !== 'idb') {
|
|
120
|
+
throw new Error(`Unsupported mobile device tool: ${String(tool)}.`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -226,7 +226,7 @@ function pidAlive(pid) {
|
|
|
226
226
|
}
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
async function
|
|
229
|
+
async function collectorIdentity(port) {
|
|
230
230
|
try {
|
|
231
231
|
const response = await fetch(`http://127.0.0.1:${port}/__collector`, {
|
|
232
232
|
signal: AbortSignal.timeout(1000),
|
|
@@ -241,11 +241,24 @@ async function collectorHealth(port) {
|
|
|
241
241
|
}
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
async function collectorHealth(port) {
|
|
245
|
+
const identity = await collectorIdentity(port);
|
|
246
|
+
const hosts = identity?.loopbackHosts;
|
|
247
|
+
if (
|
|
248
|
+
!Array.isArray(hosts) ||
|
|
249
|
+
!hosts.includes('127.0.0.1') ||
|
|
250
|
+
(!hosts.includes('::1') && identity?.ipv6Unavailable !== true)
|
|
251
|
+
) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
return identity;
|
|
255
|
+
}
|
|
256
|
+
|
|
244
257
|
async function stopCollector(state) {
|
|
245
258
|
if (
|
|
246
259
|
!state?.instanceId ||
|
|
247
260
|
!pidAlive(state.pid) ||
|
|
248
|
-
(await
|
|
261
|
+
(await collectorIdentity(state.port))?.instanceId !== state.instanceId
|
|
249
262
|
) {
|
|
250
263
|
return false;
|
|
251
264
|
}
|
|
@@ -346,6 +359,9 @@ export async function startCollector(input) {
|
|
|
346
359
|
const { eventsFile, stateFile } = await prepareCollectorStorage(projectRoot);
|
|
347
360
|
|
|
348
361
|
const existing = await readState(stateFile);
|
|
362
|
+
const existingIdentity = existing?.port
|
|
363
|
+
? await collectorIdentity(existing.port)
|
|
364
|
+
: null;
|
|
349
365
|
const existingHealth = existing?.port ? await collectorHealth(existing.port) : null;
|
|
350
366
|
if (
|
|
351
367
|
existing?.port === port &&
|
|
@@ -356,6 +372,14 @@ export async function startCollector(input) {
|
|
|
356
372
|
const androidDevice = await prepareAndroidAccess(input, port);
|
|
357
373
|
return { port, pid: existing.pid, eventsFile, reused: true, androidDevice };
|
|
358
374
|
}
|
|
375
|
+
if (
|
|
376
|
+
existing?.port === port &&
|
|
377
|
+
existing?.instanceId &&
|
|
378
|
+
pidAlive(existing.pid) &&
|
|
379
|
+
existingIdentity?.instanceId === existing.instanceId
|
|
380
|
+
) {
|
|
381
|
+
await stopCollector(existing);
|
|
382
|
+
}
|
|
359
383
|
if (existing?.port && existing.port !== port) {
|
|
360
384
|
const stopped = await stopCollector(existing);
|
|
361
385
|
if (!stopped && pidAlive(existing.pid)) {
|
|
@@ -365,7 +389,7 @@ export async function startCollector(input) {
|
|
|
365
389
|
}
|
|
366
390
|
}
|
|
367
391
|
|
|
368
|
-
const occupied = await
|
|
392
|
+
const occupied = await collectorIdentity(port);
|
|
369
393
|
if (occupied) {
|
|
370
394
|
throw new Error(
|
|
371
395
|
`Analytics collector port ${port} is owned by another instance. Choose another port or stop that collector.`,
|
|
@@ -461,7 +485,9 @@ function eventsFromBody(body) {
|
|
|
461
485
|
}
|
|
462
486
|
|
|
463
487
|
function serve(port, eventsFile, instanceId) {
|
|
464
|
-
|
|
488
|
+
let ipv4Ready = false;
|
|
489
|
+
let ipv6Status = 'pending';
|
|
490
|
+
const handleRequest = (request, response) => {
|
|
465
491
|
// The extension's Segment client runs in a page context, so preflight has
|
|
466
492
|
// to pass or nothing is ever delivered.
|
|
467
493
|
const cors = {
|
|
@@ -480,6 +506,11 @@ function serve(port, eventsFile, instanceId) {
|
|
|
480
506
|
collector: 'metamask-harness',
|
|
481
507
|
port,
|
|
482
508
|
instanceId,
|
|
509
|
+
loopbackHosts: [
|
|
510
|
+
...(ipv4Ready ? ['127.0.0.1'] : []),
|
|
511
|
+
...(ipv6Status === 'ready' ? ['::1'] : []),
|
|
512
|
+
],
|
|
513
|
+
...(ipv6Status === 'unavailable' ? { ipv6Unavailable: true } : {}),
|
|
483
514
|
}));
|
|
484
515
|
return;
|
|
485
516
|
}
|
|
@@ -504,8 +535,22 @@ function serve(port, eventsFile, instanceId) {
|
|
|
504
535
|
response.end('{"error":"collector_write_failed"}');
|
|
505
536
|
}
|
|
506
537
|
});
|
|
538
|
+
};
|
|
539
|
+
const ipv4Server = createServer(handleRequest);
|
|
540
|
+
const ipv6Server = createServer(handleRequest);
|
|
541
|
+
ipv6Server.on('error', (error) => {
|
|
542
|
+
if (error?.code === 'EAFNOSUPPORT' || error?.code === 'EADDRNOTAVAIL') {
|
|
543
|
+
ipv6Status = 'unavailable';
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
throw error;
|
|
547
|
+
});
|
|
548
|
+
ipv4Server.listen(port, '127.0.0.1', () => {
|
|
549
|
+
ipv4Ready = true;
|
|
550
|
+
});
|
|
551
|
+
ipv6Server.listen(port, '::1', () => {
|
|
552
|
+
ipv6Status = 'ready';
|
|
507
553
|
});
|
|
508
|
-
server.listen(port, '127.0.0.1');
|
|
509
554
|
}
|
|
510
555
|
|
|
511
556
|
function collectorServerSource() {
|
|
@@ -3044,5 +3044,29 @@
|
|
|
3044
3044
|
"additionalProperties": false
|
|
3045
3045
|
}
|
|
3046
3046
|
}
|
|
3047
|
-
}
|
|
3047
|
+
},
|
|
3048
|
+
"observers": [
|
|
3049
|
+
{
|
|
3050
|
+
"ref": "ui.screen",
|
|
3051
|
+
"default_for": [
|
|
3052
|
+
"ui.navigate",
|
|
3053
|
+
"ui.press",
|
|
3054
|
+
"ui.screenshot",
|
|
3055
|
+
"ui.scroll",
|
|
3056
|
+
"ui.set_input",
|
|
3057
|
+
"ui.wait_for"
|
|
3058
|
+
]
|
|
3059
|
+
},
|
|
3060
|
+
{
|
|
3061
|
+
"ref": "ui.visible",
|
|
3062
|
+
"default_for": [
|
|
3063
|
+
"ui.navigate",
|
|
3064
|
+
"ui.press",
|
|
3065
|
+
"ui.screenshot",
|
|
3066
|
+
"ui.scroll",
|
|
3067
|
+
"ui.set_input",
|
|
3068
|
+
"ui.wait_for"
|
|
3069
|
+
]
|
|
3070
|
+
}
|
|
3071
|
+
]
|
|
3048
3072
|
}
|