@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
|
@@ -84,76 +84,107 @@ function resolveRelativeArtifactPath(artifactsDir, relPath) {
|
|
|
84
84
|
if (absolute !== artifactsRoot && !absolute.startsWith(`${artifactsRoot}${path.sep}`)) {
|
|
85
85
|
throw new Error(`Refusing Extension screenshot artifact path outside artifacts dir: ${relative}`);
|
|
86
86
|
}
|
|
87
|
-
return { relative: normalized, absolute };
|
|
87
|
+
return { relative: normalized, absolute, artifactsRoot };
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
async function
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
await
|
|
94
|
-
await
|
|
95
|
-
await refuseUnsafeDestination(destination.absolute);
|
|
96
|
-
const stagingDir = await mkdtemp(path.join(artifactsRoot, '.extension-evidence-'));
|
|
90
|
+
async function preparePrivateEvidenceDestination(absolute, artifactsRoot) {
|
|
91
|
+
const outputDir = path.dirname(absolute);
|
|
92
|
+
await ensureEvidenceDirectory(artifactsRoot, outputDir);
|
|
93
|
+
await refuseEvidenceDestinationSymlink(absolute);
|
|
94
|
+
const stagingDir = await mkdtemp(path.join(outputDir, '.mm-harness-extension-evidence-'));
|
|
97
95
|
await chmod(stagingDir, 0o700);
|
|
98
96
|
return {
|
|
99
|
-
|
|
97
|
+
absolute,
|
|
100
98
|
stagingDir,
|
|
101
|
-
|
|
99
|
+
stagedPath: path.join(stagingDir, 'captured.png'),
|
|
102
100
|
};
|
|
103
101
|
}
|
|
104
102
|
|
|
105
|
-
async function
|
|
106
|
-
|
|
103
|
+
async function ensureEvidenceDirectory(artifactsRoot, outputDir) {
|
|
104
|
+
await mkdir(artifactsRoot, { recursive: true });
|
|
105
|
+
await requireEvidenceDirectory(artifactsRoot);
|
|
106
|
+
const relative = path.relative(artifactsRoot, outputDir);
|
|
107
107
|
let current = artifactsRoot;
|
|
108
108
|
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
109
109
|
current = path.join(current, segment);
|
|
110
110
|
try {
|
|
111
|
-
|
|
112
|
-
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
113
|
-
throw new Error(`Refusing Extension evidence parent that is not a real directory: ${current}`);
|
|
114
|
-
}
|
|
111
|
+
await mkdir(current);
|
|
115
112
|
} catch (error) {
|
|
116
|
-
if (error?.code !== '
|
|
117
|
-
await mkdir(current, { mode: 0o700 });
|
|
113
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
118
114
|
}
|
|
115
|
+
await requireEvidenceDirectory(current);
|
|
119
116
|
}
|
|
120
117
|
}
|
|
121
118
|
|
|
122
|
-
async function
|
|
119
|
+
async function requireEvidenceDirectory(directory) {
|
|
120
|
+
const entry = await lstat(directory);
|
|
121
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) return;
|
|
122
|
+
throw new Error(
|
|
123
|
+
`Refusing unsafe Extension evidence directory: ${directory}\n` +
|
|
124
|
+
`Next: replace ${JSON.stringify(directory)} with a regular directory and retry the recipe.`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function publishPrivateEvidence(destination) {
|
|
129
|
+
let handle;
|
|
123
130
|
try {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
131
|
+
try {
|
|
132
|
+
handle = await open(
|
|
133
|
+
destination.stagedPath,
|
|
134
|
+
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
|
135
|
+
);
|
|
136
|
+
const stagedStat = await handle.stat();
|
|
137
|
+
if (!stagedStat.isFile()) {
|
|
138
|
+
throw unsafeEvidenceArtifactError(destination.stagedPath);
|
|
139
|
+
}
|
|
140
|
+
await handle.chmod(0o600);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (error instanceof Error && error.message.includes('Next:')) throw error;
|
|
143
|
+
throw unsafeEvidenceArtifactError(
|
|
144
|
+
destination.stagedPath,
|
|
145
|
+
error instanceof Error ? error.message : String(error),
|
|
146
|
+
);
|
|
147
|
+
} finally {
|
|
148
|
+
await handle?.close();
|
|
130
149
|
}
|
|
131
|
-
|
|
132
|
-
|
|
150
|
+
await refuseEvidenceDestinationSymlink(destination.absolute);
|
|
151
|
+
await rename(destination.stagedPath, destination.absolute);
|
|
152
|
+
} finally {
|
|
153
|
+
await cleanupEvidenceStaging(destination.stagingDir);
|
|
133
154
|
}
|
|
134
155
|
}
|
|
135
156
|
|
|
136
|
-
async function
|
|
137
|
-
|
|
138
|
-
stage.staged,
|
|
139
|
-
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
|
140
|
-
);
|
|
157
|
+
async function refuseEvidenceDestinationSymlink(absolute) {
|
|
158
|
+
let destination;
|
|
141
159
|
try {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
160
|
+
destination = await lstat(absolute);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (error?.code === 'ENOENT') return;
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
if (destination.isSymbolicLink()) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Refusing Extension evidence destination symlink: ${absolute}\n` +
|
|
168
|
+
`Next: rm -- ${JSON.stringify(absolute)} and retry the recipe.`,
|
|
169
|
+
);
|
|
151
170
|
}
|
|
152
|
-
|
|
171
|
+
if (!destination.isFile()) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`Refusing non-file Extension evidence destination: ${absolute}\n` +
|
|
174
|
+
`Next: remove ${JSON.stringify(absolute)} and retry the recipe.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function unsafeEvidenceArtifactError(stagedPath, detail) {
|
|
180
|
+
return new Error(
|
|
181
|
+
`Captured Extension evidence is not a safe regular file: ${stagedPath}${detail ? ` (${detail})` : ''}.\n` +
|
|
182
|
+
'Next: mm-harness doctor --adapter extension --json',
|
|
183
|
+
);
|
|
153
184
|
}
|
|
154
185
|
|
|
155
|
-
async function
|
|
156
|
-
await rm(
|
|
186
|
+
async function cleanupEvidenceStaging(stagingDir) {
|
|
187
|
+
await rm(stagingDir, { recursive: true, force: true });
|
|
157
188
|
}
|
|
158
189
|
|
|
159
190
|
function captureHelperPath() {
|
|
@@ -203,10 +234,12 @@ async function captureHelperBrowserPid(context, port) {
|
|
|
203
234
|
}
|
|
204
235
|
|
|
205
236
|
async function captureCdpViewportSnapshot(page, context, relPath, metadata, captureHelperError = null) {
|
|
206
|
-
const
|
|
237
|
+
const { relative, absolute, artifactsRoot } = resolveRelativeArtifactPath(context.artifactsDir, relPath);
|
|
238
|
+
const destination = await preparePrivateEvidenceDestination(absolute, artifactsRoot);
|
|
239
|
+
let result;
|
|
207
240
|
try {
|
|
208
241
|
const timeoutMs = Number(metadata?.cdpTimeoutMs ?? 5000);
|
|
209
|
-
|
|
242
|
+
result = await Promise.race([
|
|
210
243
|
page.session.call('Page.captureScreenshot', {
|
|
211
244
|
format: 'png',
|
|
212
245
|
fromSurface: true,
|
|
@@ -217,33 +250,41 @@ async function captureCdpViewportSnapshot(page, context, relPath, metadata, capt
|
|
|
217
250
|
if (typeof result?.data !== 'string' || result.data.length === 0) {
|
|
218
251
|
throw new Error('Chrome Page.captureScreenshot returned no image data.');
|
|
219
252
|
}
|
|
220
|
-
await writeFile(stage.staged, Buffer.from(result.data, 'base64'), {
|
|
221
|
-
flag: 'wx',
|
|
222
|
-
mode: 0o600,
|
|
223
|
-
});
|
|
224
|
-
await publishPrivateArtifact(stage);
|
|
225
|
-
return {
|
|
226
|
-
path: stage.relative,
|
|
227
|
-
type: 'screenshot',
|
|
228
|
-
nodeId: context.nodeId,
|
|
229
|
-
label: metadata?.label ?? `${context.nodeId} screenshot`,
|
|
230
|
-
category: metadata?.category ?? 'evidence',
|
|
231
|
-
mimeType: 'image/png',
|
|
232
|
-
metadata: {
|
|
233
|
-
provider: 'cdp',
|
|
234
|
-
mode: 'Page.captureScreenshot',
|
|
235
|
-
...(captureHelperError ? { fallbackFrom: 'capture-helper', captureHelperError } : {}),
|
|
236
|
-
},
|
|
237
|
-
};
|
|
238
253
|
} catch (error) {
|
|
239
|
-
await
|
|
254
|
+
await cleanupEvidenceStaging(destination.stagingDir);
|
|
240
255
|
const cdpError = error instanceof Error ? error.message : String(error);
|
|
241
256
|
return captureDomRasterSnapshot(page, context, relPath, metadata, captureHelperError, cdpError);
|
|
242
257
|
}
|
|
258
|
+
try {
|
|
259
|
+
await writeFile(
|
|
260
|
+
destination.stagedPath,
|
|
261
|
+
Buffer.from(result.data, 'base64'),
|
|
262
|
+
{ flag: 'wx', mode: 0o600 },
|
|
263
|
+
);
|
|
264
|
+
} catch (error) {
|
|
265
|
+
await cleanupEvidenceStaging(destination.stagingDir);
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
await publishPrivateEvidence(destination);
|
|
269
|
+
return {
|
|
270
|
+
path: relative,
|
|
271
|
+
type: 'screenshot',
|
|
272
|
+
nodeId: context.nodeId,
|
|
273
|
+
label: metadata?.label ?? `${context.nodeId} screenshot`,
|
|
274
|
+
category: metadata?.category ?? 'evidence',
|
|
275
|
+
mimeType: 'image/png',
|
|
276
|
+
metadata: {
|
|
277
|
+
provider: 'cdp',
|
|
278
|
+
mode: 'Page.captureScreenshot',
|
|
279
|
+
...(captureHelperError ? { fallbackFrom: 'capture-helper', captureHelperError } : {}),
|
|
280
|
+
},
|
|
281
|
+
};
|
|
243
282
|
}
|
|
244
283
|
|
|
245
284
|
async function captureDomRasterSnapshot(page, context, relPath, metadata, captureHelperError, cdpError) {
|
|
246
|
-
const
|
|
285
|
+
const { relative, absolute, artifactsRoot } = resolveRelativeArtifactPath(context.artifactsDir, relPath);
|
|
286
|
+
const destination = await preparePrivateEvidenceDestination(absolute, artifactsRoot);
|
|
287
|
+
const dataUrlPromise = page.evaluate(`(async () => {
|
|
247
288
|
const width = Math.max(1, window.innerWidth);
|
|
248
289
|
const height = Math.max(1, window.innerHeight);
|
|
249
290
|
const source = document.documentElement;
|
|
@@ -282,23 +323,24 @@ async function captureDomRasterSnapshot(page, context, relPath, metadata, captur
|
|
|
282
323
|
context.drawImage(image, 0, 0, width, height);
|
|
283
324
|
return canvas.toDataURL('image/png');
|
|
284
325
|
})()`);
|
|
285
|
-
|
|
286
|
-
throw new Error(`Extension screenshot fallbacks failed: capture-helper=${captureHelperError ?? 'not attempted'}; cdp=${cdpError}; DOM raster returned no PNG.`);
|
|
287
|
-
}
|
|
288
|
-
const stage = await preparePrivateArtifactStage(context.artifactsDir, relPath);
|
|
326
|
+
let dataUrl;
|
|
289
327
|
try {
|
|
328
|
+
dataUrl = await dataUrlPromise;
|
|
329
|
+
if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/png;base64,')) {
|
|
330
|
+
throw new Error(`Extension screenshot fallbacks failed: capture-helper=${captureHelperError ?? 'not attempted'}; cdp=${cdpError}; DOM raster returned no PNG.`);
|
|
331
|
+
}
|
|
290
332
|
await writeFile(
|
|
291
|
-
|
|
333
|
+
destination.stagedPath,
|
|
292
334
|
Buffer.from(dataUrl.slice('data:image/png;base64,'.length), 'base64'),
|
|
293
335
|
{ flag: 'wx', mode: 0o600 },
|
|
294
336
|
);
|
|
295
|
-
await publishPrivateArtifact(stage);
|
|
296
337
|
} catch (error) {
|
|
297
|
-
await
|
|
338
|
+
await cleanupEvidenceStaging(destination.stagingDir);
|
|
298
339
|
throw error;
|
|
299
340
|
}
|
|
341
|
+
await publishPrivateEvidence(destination);
|
|
300
342
|
return {
|
|
301
|
-
path:
|
|
343
|
+
path: relative,
|
|
302
344
|
type: 'screenshot',
|
|
303
345
|
nodeId: context.nodeId,
|
|
304
346
|
label: metadata?.label ?? `${context.nodeId} screenshot`,
|
|
@@ -318,59 +360,70 @@ export async function captureHelperSnapshot(page, context, relPath, metadata) {
|
|
|
318
360
|
if (process.platform !== 'darwin') {
|
|
319
361
|
return captureCdpViewportSnapshot(page, context, relPath, metadata);
|
|
320
362
|
}
|
|
321
|
-
const
|
|
363
|
+
const { relative, absolute, artifactsRoot } = resolveRelativeArtifactPath(context.artifactsDir, relPath);
|
|
364
|
+
const destination = await preparePrivateEvidenceDestination(absolute, artifactsRoot);
|
|
322
365
|
|
|
366
|
+
let pid;
|
|
367
|
+
let mode;
|
|
368
|
+
let details;
|
|
323
369
|
try {
|
|
324
|
-
|
|
370
|
+
pid = await captureHelperBrowserPid(context, page.port);
|
|
325
371
|
const timeoutMs = Number(metadata?.timeoutMs ?? 30000);
|
|
326
|
-
const sessionSnapshot = await captureActiveRecipeRecordingSnapshot(
|
|
372
|
+
const sessionSnapshot = await captureActiveRecipeRecordingSnapshot(
|
|
373
|
+
pid,
|
|
374
|
+
destination.stagedPath,
|
|
375
|
+
timeoutMs,
|
|
376
|
+
);
|
|
327
377
|
if (sessionSnapshot) {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
378
|
+
mode = 'record_session_snapshot';
|
|
379
|
+
details = sessionSnapshot;
|
|
380
|
+
} else if (
|
|
381
|
+
parsePositivePid(process.env.METAMASK_RECIPE_EXTENSION_ACTIVE_RECORDING_PID) === pid
|
|
382
|
+
) {
|
|
383
|
+
await cleanupEvidenceStaging(destination.stagingDir);
|
|
384
|
+
return captureCdpViewportSnapshot(
|
|
385
|
+
page,
|
|
386
|
+
context,
|
|
387
|
+
relPath,
|
|
388
|
+
metadata,
|
|
389
|
+
'native snapshot skipped while full-run recording owns the browser window',
|
|
390
|
+
);
|
|
391
|
+
} else {
|
|
392
|
+
const result = await runProcess(
|
|
393
|
+
captureHelperPath(),
|
|
394
|
+
['snapshot', '--pid', String(pid), '--output', destination.stagedPath],
|
|
395
|
+
{
|
|
396
|
+
cwd: context.projectRoot,
|
|
397
|
+
env: process.env,
|
|
398
|
+
timeoutMs,
|
|
341
399
|
},
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
timeoutMs,
|
|
349
|
-
});
|
|
350
|
-
if (result.exitCode !== 0) {
|
|
351
|
-
throw new Error(`capture-helper snapshot failed for pid ${pid}: ${result.stderr || result.stdout}`);
|
|
400
|
+
);
|
|
401
|
+
if (result.exitCode !== 0) {
|
|
402
|
+
throw new Error(`capture-helper snapshot failed for pid ${pid}: ${result.stderr || result.stdout}`);
|
|
403
|
+
}
|
|
404
|
+
mode = 'snapshot';
|
|
405
|
+
details = parseJsonObject(result.stdout);
|
|
352
406
|
}
|
|
353
|
-
const details = parseJsonObject(result.stdout);
|
|
354
|
-
await publishPrivateArtifact(stage);
|
|
355
|
-
return {
|
|
356
|
-
path: stage.relative,
|
|
357
|
-
type: 'screenshot',
|
|
358
|
-
nodeId: context.nodeId,
|
|
359
|
-
label: metadata?.label ?? `${context.nodeId} screenshot`,
|
|
360
|
-
category: metadata?.category ?? 'evidence',
|
|
361
|
-
mimeType: 'image/png',
|
|
362
|
-
metadata: {
|
|
363
|
-
provider: 'capture-helper',
|
|
364
|
-
mode: 'snapshot',
|
|
365
|
-
pid,
|
|
366
|
-
...(details ? { captureHelper: details } : {}),
|
|
367
|
-
},
|
|
368
|
-
};
|
|
369
407
|
} catch (error) {
|
|
370
|
-
await
|
|
408
|
+
await cleanupEvidenceStaging(destination.stagingDir);
|
|
371
409
|
const message = error instanceof Error ? error.message : String(error);
|
|
372
410
|
return captureCdpViewportSnapshot(page, context, relPath, metadata, message);
|
|
373
411
|
}
|
|
412
|
+
await publishPrivateEvidence(destination);
|
|
413
|
+
return {
|
|
414
|
+
path: relative,
|
|
415
|
+
type: 'screenshot',
|
|
416
|
+
nodeId: context.nodeId,
|
|
417
|
+
label: metadata?.label ?? `${context.nodeId} screenshot`,
|
|
418
|
+
category: metadata?.category ?? 'evidence',
|
|
419
|
+
mimeType: 'image/png',
|
|
420
|
+
metadata: {
|
|
421
|
+
provider: 'capture-helper',
|
|
422
|
+
mode,
|
|
423
|
+
pid,
|
|
424
|
+
...(details ? { captureHelper: details } : {}),
|
|
425
|
+
},
|
|
426
|
+
};
|
|
374
427
|
}
|
|
375
428
|
|
|
376
429
|
function autolaunchEnabled(input) {
|
|
@@ -6,11 +6,8 @@ const SWITCH_IDS = {
|
|
|
6
6
|
marketing: 'data-collection-switch',
|
|
7
7
|
};
|
|
8
8
|
|
|
9
|
-
function toggleExpression(testId, expected,
|
|
10
|
-
return `(
|
|
11
|
-
const deadline = Date.now() + ${JSON.stringify(timeoutMs)};
|
|
12
|
-
let invoked = false;
|
|
13
|
-
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
|
+
function toggleExpression(testId, expected, invoke) {
|
|
10
|
+
return `(() => {
|
|
14
11
|
const find = () => {
|
|
15
12
|
const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
16
13
|
const rootsFor = hook?.getFiberRoots;
|
|
@@ -28,57 +25,96 @@ function toggleExpression(testId, expected, timeoutMs) {
|
|
|
28
25
|
}
|
|
29
26
|
return null;
|
|
30
27
|
};
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
await props.onValueChange(${JSON.stringify(expected)});
|
|
43
|
-
}
|
|
44
|
-
await delay(100);
|
|
28
|
+
const target = find();
|
|
29
|
+
const props = target?.memoizedProps;
|
|
30
|
+
if (!props) return { matched: false, found: false, invoked: false };
|
|
31
|
+
if (Boolean(props.value) === ${JSON.stringify(expected)}) {
|
|
32
|
+
return { matched: true, found: true, invoked: false };
|
|
33
|
+
}
|
|
34
|
+
if (!${JSON.stringify(invoke)}) {
|
|
35
|
+
return { matched: false, found: true, invoked: false };
|
|
36
|
+
}
|
|
37
|
+
if (typeof props.onValueChange !== 'function') {
|
|
38
|
+
throw new Error('Consent switch has no onValueChange handler: ${testId}');
|
|
45
39
|
}
|
|
46
|
-
|
|
40
|
+
return Promise.resolve(
|
|
41
|
+
props.onValueChange(${JSON.stringify(expected)})
|
|
42
|
+
).then(() => ({
|
|
43
|
+
matched: false,
|
|
44
|
+
found: true,
|
|
45
|
+
invoked: true
|
|
46
|
+
}));
|
|
47
47
|
})()`;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
function stateExpression(participate, marketing
|
|
51
|
-
return `(
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
};
|
|
62
|
-
if (
|
|
50
|
+
function stateExpression(participate, marketing) {
|
|
51
|
+
return `(() => {
|
|
52
|
+
const state = globalThis.store?.getState?.();
|
|
53
|
+
const analytics = state?.engine?.backgroundState?.AnalyticsController ?? {};
|
|
54
|
+
const consent = {
|
|
55
|
+
optedIn: Boolean(analytics.optedIn),
|
|
56
|
+
dataCollectionForMarketing: Boolean(state?.security?.dataCollectionForMarketing),
|
|
57
|
+
analyticsId: analytics.analyticsId ? 'set' : null
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
ready:
|
|
63
61
|
consent.optedIn === ${JSON.stringify(participate)} &&
|
|
64
62
|
consent.dataCollectionForMarketing === ${JSON.stringify(marketing)} &&
|
|
65
|
-
(!${JSON.stringify(participate)} || consent.analyticsId === 'set')
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
throw new Error('Timed out reading back Mobile analytics consent.');
|
|
63
|
+
(!${JSON.stringify(participate)} || consent.analyticsId === 'set'),
|
|
64
|
+
consent
|
|
65
|
+
};
|
|
70
66
|
})()`;
|
|
71
67
|
}
|
|
72
68
|
|
|
69
|
+
function delay(ms) {
|
|
70
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function setSwitch(input, testId, expected, timeoutMs) {
|
|
74
|
+
const deadline = Date.now() + timeoutMs;
|
|
75
|
+
let invoked = false;
|
|
76
|
+
while (Date.now() < deadline) {
|
|
77
|
+
const result = await evalAsync(
|
|
78
|
+
input,
|
|
79
|
+
toggleExpression(testId, expected, !invoked),
|
|
80
|
+
);
|
|
81
|
+
invoked ||= Boolean(result?.invoked);
|
|
82
|
+
if (result?.matched) {
|
|
83
|
+
return { ...result, changed: invoked };
|
|
84
|
+
}
|
|
85
|
+
await delay(100);
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`Timed out setting consent switch ${testId} to ${expected}.`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function readConsent(input, participate, marketing, timeoutMs) {
|
|
91
|
+
const deadline = Date.now() + timeoutMs;
|
|
92
|
+
while (Date.now() < deadline) {
|
|
93
|
+
const result = await evalAsync(
|
|
94
|
+
input,
|
|
95
|
+
stateExpression(participate, marketing),
|
|
96
|
+
);
|
|
97
|
+
if (result?.ready) {
|
|
98
|
+
return result.consent;
|
|
99
|
+
}
|
|
100
|
+
await delay(100);
|
|
101
|
+
}
|
|
102
|
+
throw new Error('Timed out reading back Mobile analytics consent.');
|
|
103
|
+
}
|
|
104
|
+
|
|
73
105
|
runAdapter(async (input) => {
|
|
74
106
|
const { participate, marketing, timeoutMs } = consentParams(input.node);
|
|
75
107
|
|
|
76
|
-
const navigation = await navigate(input, '
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
108
|
+
const navigation = await navigate(input, 'SettingsView', {
|
|
109
|
+
screen: 'SecuritySettings',
|
|
110
|
+
}, 'SecuritySettings');
|
|
111
|
+
await setSwitch(input, SWITCH_IDS.participate, participate, timeoutMs);
|
|
112
|
+
await setSwitch(input, SWITCH_IDS.marketing, marketing, timeoutMs);
|
|
113
|
+
const consent = await readConsent(
|
|
80
114
|
input,
|
|
81
|
-
|
|
115
|
+
participate,
|
|
116
|
+
marketing,
|
|
117
|
+
timeoutMs,
|
|
82
118
|
);
|
|
83
119
|
|
|
84
120
|
return {
|
|
@@ -7,6 +7,7 @@ import os from 'node:os';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import bridgeErrors from '../../../../adapters/mobile/bridge-runtime/lib/bridge-errors.cjs';
|
|
10
|
+
import { resolveMobileToolPath } from './tool-paths.mjs';
|
|
10
11
|
|
|
11
12
|
const {
|
|
12
13
|
BRIDGE_ERROR_CODES,
|
|
@@ -72,9 +73,11 @@ function runtimeDir() {
|
|
|
72
73
|
* or the serial does not respond. Uses execFile (never shell interpolation).
|
|
73
74
|
*/
|
|
74
75
|
async function resolveAndroidModel(adbSerial) {
|
|
76
|
+
const adbPath = resolveMobileToolPath('adb');
|
|
77
|
+
if (!adbPath) return null;
|
|
75
78
|
try {
|
|
76
79
|
const { stdout } = await execFileAsync(
|
|
77
|
-
|
|
80
|
+
adbPath,
|
|
78
81
|
['-s', adbSerial, 'shell', 'getprop', 'ro.product.model'],
|
|
79
82
|
{ timeout: 5000, encoding: 'utf8' },
|
|
80
83
|
);
|
|
@@ -90,12 +93,16 @@ async function resolveAndroidModel(adbSerial) {
|
|
|
90
93
|
}
|
|
91
94
|
|
|
92
95
|
export async function bridgeEnv(input) {
|
|
96
|
+
const adbPath = resolveMobileToolPath('adb');
|
|
97
|
+
const idbPath = resolveMobileToolPath('idb');
|
|
93
98
|
/** @type {NodeJS.ProcessEnv} */
|
|
94
99
|
const env = {
|
|
95
100
|
...process.env,
|
|
96
101
|
...(input.context?.env || {}),
|
|
97
102
|
CDP_TIMEOUT: String(input.node?.cdp_timeout_ms ?? process.env.CDP_TIMEOUT ?? '10000'),
|
|
98
103
|
};
|
|
104
|
+
if (adbPath) env.MM_HARNESS_ADB_PATH = adbPath;
|
|
105
|
+
if (idbPath) env.MM_HARNESS_IDB_PATH = idbPath;
|
|
99
106
|
const target = resolveMobileTarget(input);
|
|
100
107
|
const watcherPort = target.watcherPort;
|
|
101
108
|
const simulator = target.iosSimulator;
|
|
@@ -355,11 +362,14 @@ export async function evalSync(input, expression) {
|
|
|
355
362
|
return parseMaybeJson(await bridgeCommand(input, ['eval', expression]));
|
|
356
363
|
}
|
|
357
364
|
|
|
358
|
-
export async function navigate(input, route, params = {}) {
|
|
365
|
+
export async function navigate(input, route, params = {}, expectedRoute) {
|
|
359
366
|
const navigation = await bridgeCommand(input, ['navigate', route, JSON.stringify(params)]);
|
|
360
|
-
const verifiedRoute =
|
|
361
|
-
|
|
362
|
-
|
|
367
|
+
const verifiedRoute = String(
|
|
368
|
+
expectedRoute ??
|
|
369
|
+
(navigation && typeof navigation === 'object' && navigation.navigated
|
|
370
|
+
? navigation.navigated
|
|
371
|
+
: route),
|
|
372
|
+
);
|
|
363
373
|
const currentRoute = await waitForRoute(input, verifiedRoute, Number(input.node?.navigation_timeout_ms ?? 15000));
|
|
364
374
|
return { ...navigation, currentRoute, verifiedRoute };
|
|
365
375
|
}
|
|
@@ -480,13 +490,14 @@ async function androidScreenshot(input, relPath) {
|
|
|
480
490
|
try {
|
|
481
491
|
temporaryDirectory = await createScreenshotTemporaryDirectory();
|
|
482
492
|
const adbSerial = await resolveAndroidScreenshotSerial(input);
|
|
493
|
+
const adbPath = resolveMobileToolPath('adb', { required: true });
|
|
483
494
|
let result;
|
|
484
495
|
try {
|
|
485
|
-
result = await runScreenshotProcess(
|
|
496
|
+
result = await runScreenshotProcess(adbPath, ['-s', adbSerial, 'exec-out', 'screencap', '-p'], true);
|
|
486
497
|
} catch (error) {
|
|
487
498
|
throw new Error(
|
|
488
499
|
`adb screenshot could not start for ${adbSerial}: ${error instanceof Error ? error.message : String(error)}\n` +
|
|
489
|
-
' Next: install Android SDK Platform-Tools,
|
|
500
|
+
' Next: install Android SDK Platform-Tools, then rerun the same mm-harness command.',
|
|
490
501
|
);
|
|
491
502
|
}
|
|
492
503
|
if (result.exitCode !== 0) {
|
|
@@ -538,11 +549,12 @@ async function resolveAndroidScreenshotSerial(input) {
|
|
|
538
549
|
|
|
539
550
|
let result;
|
|
540
551
|
try {
|
|
541
|
-
|
|
552
|
+
const adbPath = resolveMobileToolPath('adb', { required: true });
|
|
553
|
+
result = await runScreenshotProcess(adbPath, ['devices'], false);
|
|
542
554
|
} catch (error) {
|
|
543
555
|
throw new Error(
|
|
544
556
|
`Android screenshot could not resolve an adb serial: ${error instanceof Error ? error.message : String(error)}\n` +
|
|
545
|
-
' Next: install Android SDK Platform-Tools,
|
|
557
|
+
' Next: install Android SDK Platform-Tools, then rerun with --device <adb-serial>.',
|
|
546
558
|
);
|
|
547
559
|
}
|
|
548
560
|
if (result.exitCode !== 0) {
|