@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.
@@ -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('&quot;', '"')
412
+ .replaceAll('&apos;', "'")
413
+ .replaceAll('&lt;', '<')
414
+ .replaceAll('&gt;', '>')
415
+ .replaceAll('&amp;', '&');
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 collectorHealth(port) {
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 collectorHealth(state.port))?.instanceId !== state.instanceId
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 collectorHealth(port);
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
- const server = createServer((request, response) => {
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.26.4",
3
+ "version": "0.27.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"