@deeeed/metamask-harness 0.33.2 → 0.34.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 +31 -0
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +18 -2
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +83 -23
- package/dist/adapters/mobile/prepare.js +14 -0
- package/dist/adapters/mobile/video-recorder.js +282 -0
- package/dist/adapters.js +225 -35
- package/dist/command-contract.js +1 -0
- package/dist/commands/device-target.js +17 -6
- package/dist/commands/launch/index.js +27 -5
- package/dist/commands/launch/mobile.js +9 -2
- package/dist/commands/run-engine.js +34 -2
- package/dist/devices.js +9 -2
- package/dist/heal-bounds.js +10 -0
- package/dist/mm-harness-cli.js +4 -2
- package/dist/recipe-security.js +1 -0
- package/dist/recording-target.js +23 -5
- package/dist/runner.js +107 -6
- package/dist/runtime-context.js +33 -1
- package/docs/RECIPES.md +41 -0
- package/library/actions/mobile/app/network-control.mjs +135 -0
- package/library/actions/mobile/app/network.mjs +4 -0
- package/library/actions/mobile/perps/capture_performance.mjs +4 -0
- package/library/actions/mobile/perps/performance-capture.mjs +383 -0
- package/library/actions/mobile/perps/perps.mjs +16 -0
- package/library/actions/mobile/platform/bridge.mjs +229 -13
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +27 -6
- package/library/actions/mobile/wallet/select_account.mjs +21 -17
- package/library/manifests/mobile.action-manifest.json +130 -1
- package/library/recipes/mobile/perps/performance.homepage.android-background-reconnect.recipe.json +159 -0
- package/library/recipes/mobile/perps/performance.homepage.android-background-short.recipe.json +157 -0
- package/library/recipes/mobile/perps/performance.homepage.android-cold-disk-cache.recipe.json +147 -0
- package/library/recipes/mobile/perps/performance.homepage.android-cold-no-cache.recipe.json +137 -0
- package/library/recipes/mobile/perps/performance.homepage.android-network-recovery.recipe.json +149 -0
- package/library/recipes/mobile/perps/performance.homepage.ios-background-reconnect.recipe.json +110 -0
- package/library/recipes/mobile/perps/performance.homepage.ios-background-short.recipe.json +108 -0
- package/library/recipes/mobile/perps/performance.homepage.ios-cold-disk-cache.recipe.json +122 -0
- package/library/recipes/mobile/perps/performance.homepage.ios-cold-no-cache.recipe.json +95 -0
- package/package.json +1 -1
- package/scripts/site-contrast.mjs +34 -1
- package/site/architecture.html +12 -2
- package/site/assets/style.css +20 -1
- package/site/cheatsheet.html +7 -6
- package/site/index.html +119 -77
- package/site/perps.html +195 -0
- package/site/recipes.html +23 -1
- package/site/reviewers.html +2 -1
- package/site/tutorials/index.html +2 -1
- package/site/tutorials/v1.html +3 -2
- package/site/tutorials/v2.html +6 -5
- package/site/tutorials/v3.html +43 -2
- package/site/tutorials/v4.html +2 -1
- package/site/tutorials/v5.html +2 -1
- package/site/tutorials/v6.html +2 -1
- package/site/tutorials/v7.html +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.34.0 - 2026-08-07
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Record Mobile Android recipe runs with the selected device's native `screenrecord` capability when `--record-video=full-run` is requested.
|
|
10
|
+
- Record iOS Simulator recipe runs with `simctl recordVideo`, avoiding hidden/offscreen Simulator-window captures that produce empty videos.
|
|
11
|
+
- Keep pinned Android commands on their requested device while its Hermes target re-registers with Metro after a runtime rotation.
|
|
12
|
+
- Make Mobile account selection wait for the selected account state without disconnecting or reinitializing the Perps controller, preventing duplicate account-change reconnections and transient Perps errors.
|
|
13
|
+
- Resolve shutdown iOS simulators to their canonical bridge-visible name while preserving the requested UDID for native launch.
|
|
14
|
+
- Keep lifecycle performance recipes free from HUD bridge timeouts while the app is backgrounded or its runtime target is rotating.
|
|
15
|
+
- Make Android coordinate swipes and scroll-into-view use the selected device consistently, including the native fallback path.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Add manifest-declared Mobile `app.lifecycle` and `app.network` actions with device targeting, runtime readiness, and Android offline/online recovery.
|
|
20
|
+
- Add reusable Perps Homepage performance capture with lifecycle, delivery-source, visible-content, and socket-pipeline validation.
|
|
21
|
+
- Bundle the device-proven Android/iOS Perps Homepage short-resume, reconnect, cold-cache, cold-no-cache, and Android network-recovery recipes with explicit cleanup and full-run evidence commands.
|
|
22
|
+
- Add wallet setup/account-selection readiness checks and source-freshness recovery evidence for Mobile recipe runs.
|
|
23
|
+
|
|
24
|
+
## 0.33.3 - 2026-08-05
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- Lead the getting-started site with what a recipe is for — proving a change inside the running app, catching its own regressions on replay, and standing as the trust layer between an agent's change and a human's review — and add copy-paste prompts for first-time setup and the shared Perps recipe library.
|
|
29
|
+
- Add a dedicated Perps workflow page covering library discovery, testnet-first live proof, platform recipe selection, evidence, composition, and drift classification.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- Raise the site's stated harness floor to 0.33+ and make first-time iOS setup pin one simulator UUID through forced Runway provisioning, install verification, launch, and fixture application.
|
|
34
|
+
- Recover Mobile navigation across typed Hermes target transitions after lifecycle foregrounding with at most one route-verified reissue.
|
|
35
|
+
|
|
5
36
|
## 0.33.2 - 2026-08-04
|
|
6
37
|
|
|
7
38
|
### Fixed
|
|
@@ -305,6 +305,7 @@ const COMMANDS = {
|
|
|
305
305
|
let testId;
|
|
306
306
|
let offset = 300;
|
|
307
307
|
let animated = false;
|
|
308
|
+
let intoView = false;
|
|
308
309
|
for (let i = 0; i < args.length; i++) {
|
|
309
310
|
if (args[i] === '--test-id' && i + 1 < args.length) {
|
|
310
311
|
testId = args[++i];
|
|
@@ -312,11 +313,13 @@ const COMMANDS = {
|
|
|
312
313
|
offset = Number(args[++i]);
|
|
313
314
|
} else if (args[i] === '--animated') {
|
|
314
315
|
animated = true;
|
|
316
|
+
} else if (args[i] === '--into-view') {
|
|
317
|
+
intoView = true;
|
|
315
318
|
} else if (args[i] === '--no-animated') {
|
|
316
319
|
animated = false;
|
|
317
320
|
}
|
|
318
321
|
}
|
|
319
|
-
const optsJson = JSON.stringify({ testId, offset, animated });
|
|
322
|
+
const optsJson = JSON.stringify({ testId, offset, animated, intoView });
|
|
320
323
|
// Try __AGENTIC__ bridge first, fall back to inline fiber walking
|
|
321
324
|
const expr = `(function() {
|
|
322
325
|
if (globalThis.__AGENTIC__?.scrollView) return globalThis.__AGENTIC__.scrollView(${optsJson});
|
|
@@ -340,6 +343,19 @@ const COMMANDS = {
|
|
|
340
343
|
}
|
|
341
344
|
return false;
|
|
342
345
|
}
|
|
346
|
+
function tryScrollNear(anchor) {
|
|
347
|
+
if (!opts.intoView && tryScroll(anchor, false)) return true;
|
|
348
|
+
var current = anchor ? anchor.return : null;
|
|
349
|
+
while (current) {
|
|
350
|
+
var sn = current.stateNode;
|
|
351
|
+
if (sn) {
|
|
352
|
+
if (typeof sn.scrollTo === 'function') { sn.scrollTo({ y: opts.offset, animated: opts.animated }); return true; }
|
|
353
|
+
if (typeof sn.scrollToOffset === 'function') { sn.scrollToOffset({ offset: opts.offset, animated: opts.animated }); return true; }
|
|
354
|
+
}
|
|
355
|
+
current = current.return;
|
|
356
|
+
}
|
|
357
|
+
return opts.intoView ? tryScroll(anchor, false) : false;
|
|
358
|
+
}
|
|
343
359
|
function findTestId(fiber) {
|
|
344
360
|
if (!fiber) return null;
|
|
345
361
|
var props = fiber.memoizedProps;
|
|
@@ -354,7 +370,7 @@ const COMMANDS = {
|
|
|
354
370
|
if (scrolled) return;
|
|
355
371
|
if (opts.testId) {
|
|
356
372
|
var anchor = findTestId(r.current);
|
|
357
|
-
if (anchor) scrolled =
|
|
373
|
+
if (anchor) scrolled = tryScrollNear(anchor);
|
|
358
374
|
} else {
|
|
359
375
|
scrolled = tryScroll(r.current);
|
|
360
376
|
}
|
|
@@ -30,17 +30,23 @@ function fetchJSONOnce(url) {
|
|
|
30
30
|
});
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
async function fetchJSON(url) {
|
|
33
|
+
async function fetchJSON(url, acceptResponse) {
|
|
34
34
|
let lastError;
|
|
35
|
+
let lastResponse;
|
|
35
36
|
for (let attempt = 1; attempt <= FETCH_RETRIES; attempt++) {
|
|
36
37
|
try {
|
|
37
|
-
|
|
38
|
+
const response = await fetchJSONOnce(url);
|
|
39
|
+
lastError = undefined;
|
|
40
|
+
lastResponse = response;
|
|
41
|
+
if (!acceptResponse || await acceptResponse(response)) return response;
|
|
38
42
|
} catch (error) {
|
|
39
43
|
lastError = error;
|
|
40
|
-
if (attempt === FETCH_RETRIES) break;
|
|
41
|
-
await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
|
|
42
44
|
}
|
|
45
|
+
if (attempt === FETCH_RETRIES) break;
|
|
46
|
+
await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
|
|
43
47
|
}
|
|
48
|
+
if (lastError !== undefined) throw lastError;
|
|
49
|
+
if (lastResponse !== undefined) return lastResponse;
|
|
44
50
|
throw lastError;
|
|
45
51
|
}
|
|
46
52
|
|
|
@@ -103,6 +109,19 @@ function rankRuntimeCandidates(targets) {
|
|
|
103
109
|
return candidates;
|
|
104
110
|
}
|
|
105
111
|
|
|
112
|
+
function targetDeviceIdentity(target) {
|
|
113
|
+
try {
|
|
114
|
+
const device = new URL(target.webSocketDebuggerUrl).searchParams.get('device');
|
|
115
|
+
if (device) return device;
|
|
116
|
+
} catch {
|
|
117
|
+
// Fall back to Metro's display identity when the debugger URL is malformed.
|
|
118
|
+
}
|
|
119
|
+
// Without Metro's device id there is no safe way to collapse same-name pages
|
|
120
|
+
// into one physical device. Keep each debugger target distinct and fail closed
|
|
121
|
+
// on ambiguity instead of silently choosing between devices.
|
|
122
|
+
return target.webSocketDebuggerUrl || target.deviceName || '';
|
|
123
|
+
}
|
|
124
|
+
|
|
106
125
|
/**
|
|
107
126
|
* Discover the Hermes CDP WebSocket URL from Metro's /json/list endpoint.
|
|
108
127
|
*
|
|
@@ -114,9 +133,34 @@ function rankRuntimeCandidates(targets) {
|
|
|
114
133
|
*/
|
|
115
134
|
async function discoverTarget(port) {
|
|
116
135
|
const listUrl = `http://localhost:${port}/json/list`;
|
|
136
|
+
const androidTargetName = loadAndroidTargetDeviceName();
|
|
137
|
+
const androidDevice = loadAndroidDevice();
|
|
138
|
+
const adbSerial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || '';
|
|
139
|
+
const androidPinned = Boolean(androidTargetName || androidDevice);
|
|
140
|
+
const simName = loadSimulatorName();
|
|
141
|
+
const matchesAndroidPin = (target) => androidTargetName
|
|
142
|
+
? target.deviceName !== simName &&
|
|
143
|
+
(target.deviceName === androidTargetName ||
|
|
144
|
+
(target.deviceName != null && target.deviceName.startsWith(androidTargetName)))
|
|
145
|
+
: target.deviceName === androidDevice;
|
|
146
|
+
let acceptedPinnedCandidate;
|
|
117
147
|
let targets;
|
|
118
148
|
try {
|
|
119
|
-
targets = await fetchJSON(
|
|
149
|
+
targets = await fetchJSON(
|
|
150
|
+
listUrl,
|
|
151
|
+
androidPinned
|
|
152
|
+
? async (response) => {
|
|
153
|
+
const pinnedCandidates = rankRuntimeCandidates(response).filter(matchesAndroidPin);
|
|
154
|
+
for (const candidate of pinnedCandidates) {
|
|
155
|
+
if (await probeTarget(candidate.webSocketDebuggerUrl)) {
|
|
156
|
+
acceptedPinnedCandidate = candidate;
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
: undefined,
|
|
163
|
+
);
|
|
120
164
|
} catch (e) {
|
|
121
165
|
throw coded(
|
|
122
166
|
new Error(`Cannot reach Metro at ${listUrl}. Is Metro running?\n ${e.message}`),
|
|
@@ -137,17 +181,11 @@ async function discoverTarget(port) {
|
|
|
137
181
|
// from the adb serial via `adb -s <serial> shell getprop ro.product.model`.
|
|
138
182
|
// Example: "Pixel 6" matches Metro deviceName "Pixel 6 - 16 - API 36".
|
|
139
183
|
// ANDROID_DEVICE — exact user-specified Metro deviceName.
|
|
140
|
-
const androidTargetName = loadAndroidTargetDeviceName();
|
|
141
|
-
const androidDevice = loadAndroidDevice();
|
|
142
|
-
const adbSerial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || '';
|
|
143
|
-
const androidPinned = Boolean(androidTargetName || androidDevice);
|
|
144
|
-
|
|
145
184
|
// Filter by simulator name if IOS_SIMULATOR is set — but never when an android
|
|
146
185
|
// pin is present: dual-platform slots carry an ambient IOS_SIMULATOR in their
|
|
147
186
|
// context, and letting it win here sends a pinned android action to the iOS
|
|
148
187
|
// target. No-match keeps the full candidate set (ambient sim configs tolerate a
|
|
149
188
|
// sim that is not currently attached).
|
|
150
|
-
const simName = loadSimulatorName();
|
|
151
189
|
if (simName && !androidPinned && candidates.length > 1) {
|
|
152
190
|
const deviceFiltered = candidates.filter(
|
|
153
191
|
(t) => t.deviceName === simName,
|
|
@@ -163,7 +201,7 @@ async function discoverTarget(port) {
|
|
|
163
201
|
// remained; the pin was ignored and the recipe drove the simulator). When a
|
|
164
202
|
// device is pinned, we MUST NOT silently fall back to another target; an
|
|
165
203
|
// unmatchable pin fails fast with diagnostics.
|
|
166
|
-
if (androidPinned
|
|
204
|
+
if (androidPinned) {
|
|
167
205
|
let androidFiltered = [];
|
|
168
206
|
|
|
169
207
|
if (androidTargetName) {
|
|
@@ -171,20 +209,19 @@ async function discoverTarget(port) {
|
|
|
171
209
|
// /json/list has no explicit platform field, so scope the ANDROID pin by
|
|
172
210
|
// excluding the one iOS identity we do know (the pinned simulator name) —
|
|
173
211
|
// a simulator named to shadow a model string must not satisfy an android pin.
|
|
174
|
-
androidFiltered = candidates.filter(
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
)
|
|
179
|
-
if (androidFiltered.length > 1) {
|
|
212
|
+
androidFiltered = candidates.filter(matchesAndroidPin);
|
|
213
|
+
const matchingDeviceIdentities = [...new Set(
|
|
214
|
+
androidFiltered.map(targetDeviceIdentity),
|
|
215
|
+
)];
|
|
216
|
+
if (matchingDeviceIdentities.length > 1) {
|
|
180
217
|
// Two same-model devices produce identical Metro model prefixes — the pin
|
|
181
|
-
// is genuinely ambiguous
|
|
182
|
-
//
|
|
218
|
+
// is genuinely ambiguous. Native + JS pages share the same Metro device
|
|
219
|
+
// identity and must remain available for probing.
|
|
183
220
|
const ambiguousList = androidFiltered
|
|
184
|
-
.map((
|
|
221
|
+
.map((target) => ` deviceName=${JSON.stringify(target.deviceName || '')} ws=${target.webSocketDebuggerUrl || ''}`)
|
|
185
222
|
.join('\n');
|
|
186
223
|
throw coded(new Error(
|
|
187
|
-
`Pinned Android device is ambiguous: model '${androidTargetName}' matches ${
|
|
224
|
+
`Pinned Android device is ambiguous: model '${androidTargetName}' matches ${matchingDeviceIdentities.length} Metro devices.\n` +
|
|
188
225
|
` Requested --device (ADB_SERIAL): ${adbSerial || '(not set)'}\n` +
|
|
189
226
|
` Matching Metro targets:\n${ambiguousList}\n` +
|
|
190
227
|
` Set ANDROID_DEVICE to the exact Metro deviceName to disambiguate.`,
|
|
@@ -206,7 +243,7 @@ async function discoverTarget(port) {
|
|
|
206
243
|
candidates = androidFiltered;
|
|
207
244
|
} else if (androidDevice) {
|
|
208
245
|
// Exact Metro deviceName match when the user set ANDROID_DEVICE directly.
|
|
209
|
-
androidFiltered = candidates.filter(
|
|
246
|
+
androidFiltered = candidates.filter(matchesAndroidPin);
|
|
210
247
|
if (androidFiltered.length === 0) {
|
|
211
248
|
// Pinned by exact Metro name but no candidate matched — fail fast.
|
|
212
249
|
const candidateList = targets
|
|
@@ -233,6 +270,17 @@ async function discoverTarget(port) {
|
|
|
233
270
|
);
|
|
234
271
|
}
|
|
235
272
|
|
|
273
|
+
if (acceptedPinnedCandidate) {
|
|
274
|
+
for (const candidate of candidates) {
|
|
275
|
+
if (candidate.webSocketDebuggerUrl === acceptedPinnedCandidate.webSocketDebuggerUrl) {
|
|
276
|
+
return {
|
|
277
|
+
wsUrl: acceptedPinnedCandidate.webSocketDebuggerUrl,
|
|
278
|
+
deviceName: acceptedPinnedCandidate.deviceName || '',
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
236
284
|
// Sort by page number descending (JS runtime has higher page number than C++
|
|
237
285
|
// native). rankRuntimeCandidates output arrives pre-sorted; this re-sort is
|
|
238
286
|
// load-bearing only when the raw targets.filter() fallback above repopulated
|
|
@@ -251,6 +299,18 @@ async function discoverTarget(port) {
|
|
|
251
299
|
}
|
|
252
300
|
}
|
|
253
301
|
|
|
302
|
+
if (androidPinned) {
|
|
303
|
+
const candidateList = candidates
|
|
304
|
+
.map((target) => ` deviceName=${JSON.stringify(target.deviceName || '')} ws=${target.webSocketDebuggerUrl || ''}`)
|
|
305
|
+
.join('\n');
|
|
306
|
+
throw coded(new Error(
|
|
307
|
+
`Pinned Android device has no responding __AGENTIC__ JS runtime after ${FETCH_RETRIES} discovery attempt(s).\n` +
|
|
308
|
+
` Requested --device (ADB_SERIAL): ${adbSerial || '(not set)'}\n` +
|
|
309
|
+
` Matching non-agentic targets:\n${candidateList}\n` +
|
|
310
|
+
` The native C++ page is not a valid recipe target; wait for Android Hermes to re-register or relaunch the Android app.`,
|
|
311
|
+
), BRIDGE_ERROR_CODES.NO_TARGET);
|
|
312
|
+
}
|
|
313
|
+
|
|
254
314
|
// Fallback: return highest page number (most likely the JS runtime)
|
|
255
315
|
return { wsUrl: candidates[0].webSocketDebuggerUrl, deviceName: candidates[0].deviceName || '' };
|
|
256
316
|
}
|
|
@@ -60,6 +60,20 @@ async function prepareMobile(target, opts = {}) {
|
|
|
60
60
|
return { status: EXIT.runtime, output: msg };
|
|
61
61
|
}
|
|
62
62
|
const iosAccessibilityChanged = platform === "ios" && report.decision !== "unknown" && enableIosAccessibility(json);
|
|
63
|
+
if (report.decision === "ready" && clearMetro) {
|
|
64
|
+
const launch = await dispatchActionSequence(
|
|
65
|
+
withAppRestart(
|
|
66
|
+
launchActions(path.resolve(target), true),
|
|
67
|
+
restartApp || iosAccessibilityChanged
|
|
68
|
+
),
|
|
69
|
+
target,
|
|
70
|
+
platform,
|
|
71
|
+
json,
|
|
72
|
+
preflightMode,
|
|
73
|
+
opts.watcherPort
|
|
74
|
+
);
|
|
75
|
+
return launch.status === 0 ? startMobileConsoleForwarder(target, platform, json, opts.watcherPort) : launch;
|
|
76
|
+
}
|
|
63
77
|
if (report.decision === "ready") {
|
|
64
78
|
if (restartApp || iosAccessibilityChanged) {
|
|
65
79
|
const launch2 = await dispatchActionSequence(
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
ANDROID_RECORDING_TARGET_PREFIX,
|
|
6
|
+
IOS_SIMULATOR_RECORDING_TARGET_PREFIX
|
|
7
|
+
} from "../../recording-target.js";
|
|
8
|
+
const RECORDING_START_TIMEOUT_MS = 5e3;
|
|
9
|
+
const RECORDING_STOP_TIMEOUT_MS = 1e4;
|
|
10
|
+
const RECORDING_TIME_LIMIT_SECONDS = 180;
|
|
11
|
+
function createAndroidVideoRecorder(serial) {
|
|
12
|
+
return {
|
|
13
|
+
name: "adb-screenrecord",
|
|
14
|
+
platform: "android",
|
|
15
|
+
doctor: async () => doctorAndroidRecorder(serial),
|
|
16
|
+
start: (request) => startAndroidRecording(serial, request)
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function createIosSimulatorVideoRecorder(simulator) {
|
|
20
|
+
return {
|
|
21
|
+
name: "simctl-recordVideo",
|
|
22
|
+
platform: "ios",
|
|
23
|
+
doctor: async () => doctorIosSimulatorRecorder(simulator),
|
|
24
|
+
start: (request) => startIosSimulatorRecording(simulator, request)
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function doctorIosSimulatorRecorder(simulator) {
|
|
28
|
+
const state = runSimctl(["getenv", simulator, "SIMULATOR_UDID"]);
|
|
29
|
+
if (state.status !== 0 || !state.stdout.trim()) {
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
code: "ios_simulator_unavailable",
|
|
33
|
+
message: `iOS simulator ${simulator} is not booted: ${commandFailure(state)}`,
|
|
34
|
+
suggestedFix: `Boot ${simulator} and run xcrun simctl getenv ${simulator} SIMULATOR_UDID.`
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
ok: true,
|
|
39
|
+
code: "ok",
|
|
40
|
+
message: `iOS simulator recording is ready on ${simulator}.`
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
async function startIosSimulatorRecording(simulator, request) {
|
|
44
|
+
const targetSimulator = iosTargetSimulator(request);
|
|
45
|
+
if (targetSimulator !== simulator) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`iOS recording target ${targetSimulator ?? "<missing>"} does not match selected simulator ${simulator}.`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
fs.mkdirSync(path.dirname(request.outputPath), { recursive: true });
|
|
51
|
+
const child = spawn(
|
|
52
|
+
"xcrun",
|
|
53
|
+
[
|
|
54
|
+
"simctl",
|
|
55
|
+
"io",
|
|
56
|
+
simulator,
|
|
57
|
+
"recordVideo",
|
|
58
|
+
"--codec=h264",
|
|
59
|
+
"--force",
|
|
60
|
+
request.outputPath
|
|
61
|
+
],
|
|
62
|
+
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
63
|
+
);
|
|
64
|
+
const output = [];
|
|
65
|
+
child.stdout?.setEncoding("utf8");
|
|
66
|
+
child.stderr?.setEncoding("utf8");
|
|
67
|
+
child.stdout?.on("data", (chunk) => output.push(String(chunk)));
|
|
68
|
+
child.stderr?.on("data", (chunk) => output.push(String(chunk)));
|
|
69
|
+
await waitForIosRecordingStart(child, output);
|
|
70
|
+
let stopped = false;
|
|
71
|
+
return {
|
|
72
|
+
async stop() {
|
|
73
|
+
if (stopped) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`iOS simulator recording on ${simulator} was already stopped.`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
stopped = true;
|
|
79
|
+
child.kill("SIGINT");
|
|
80
|
+
await waitForChildExit(
|
|
81
|
+
child,
|
|
82
|
+
RECORDING_STOP_TIMEOUT_MS,
|
|
83
|
+
"iOS simulator recordVideo"
|
|
84
|
+
);
|
|
85
|
+
const recorded = fs.statSync(request.outputPath);
|
|
86
|
+
if (!recorded.isFile() || recorded.size <= 0) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`iOS simulator recording output is empty: ${request.outputPath}`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
recorder: {
|
|
93
|
+
name: "simctl-recordVideo",
|
|
94
|
+
platform: "ios",
|
|
95
|
+
target: { selector: "simulator", value: simulator }
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function doctorAndroidRecorder(serial) {
|
|
102
|
+
const state = runAdb(serial, ["get-state"]);
|
|
103
|
+
if (state.status !== 0 || state.stdout.trim() !== "device") {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
code: "android_device_unavailable",
|
|
107
|
+
message: `Android device ${serial} is not ready: ${commandFailure(state)}`,
|
|
108
|
+
suggestedFix: `Run adb -s ${serial} get-state and reconnect or unlock the device.`
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const screenrecord = runAdb(serial, ["shell", "screenrecord", "--help"]);
|
|
112
|
+
if (screenrecord.status !== 0) {
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
code: "android_screenrecord_unavailable",
|
|
116
|
+
message: `Android screenrecord is unavailable on ${serial}: ${commandFailure(screenrecord)}`,
|
|
117
|
+
suggestedFix: `Run adb -s ${serial} shell screenrecord --help.`
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
ok: true,
|
|
122
|
+
code: "ok",
|
|
123
|
+
message: `Android screen recording is ready on ${serial}.`
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
async function startAndroidRecording(serial, request) {
|
|
127
|
+
const targetSerial = androidTargetSerial(request);
|
|
128
|
+
if (targetSerial !== serial) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`Android recording target ${targetSerial ?? "<missing>"} does not match selected device ${serial}.`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const existingPid = screenrecordPid(serial);
|
|
134
|
+
if (existingPid) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`Android screenrecord is already running on ${serial} (pid ${existingPid}). Stop that recording before retrying.`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const remotePath = `/sdcard/Download/mm-harness-recipe-${Date.now()}-${process.pid}.mp4`;
|
|
140
|
+
const child = spawn(
|
|
141
|
+
"adb",
|
|
142
|
+
[
|
|
143
|
+
"-s",
|
|
144
|
+
serial,
|
|
145
|
+
"shell",
|
|
146
|
+
"screenrecord",
|
|
147
|
+
"--time-limit",
|
|
148
|
+
String(RECORDING_TIME_LIMIT_SECONDS),
|
|
149
|
+
remotePath
|
|
150
|
+
],
|
|
151
|
+
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
152
|
+
);
|
|
153
|
+
const stderr = [];
|
|
154
|
+
child.stderr?.setEncoding("utf8");
|
|
155
|
+
child.stderr?.on("data", (chunk) => stderr.push(String(chunk)));
|
|
156
|
+
child.stdout?.resume();
|
|
157
|
+
const recorderPid = await waitForScreenrecordStart(serial, child, stderr);
|
|
158
|
+
let stopped = false;
|
|
159
|
+
return {
|
|
160
|
+
async stop() {
|
|
161
|
+
if (stopped) {
|
|
162
|
+
throw new Error(`Android screen recording on ${serial} was already stopped.`);
|
|
163
|
+
}
|
|
164
|
+
stopped = true;
|
|
165
|
+
try {
|
|
166
|
+
stopRemoteScreenrecord(serial, recorderPid);
|
|
167
|
+
await waitForChildExit(
|
|
168
|
+
child,
|
|
169
|
+
RECORDING_STOP_TIMEOUT_MS,
|
|
170
|
+
"Android screenrecord"
|
|
171
|
+
);
|
|
172
|
+
fs.mkdirSync(path.dirname(request.outputPath), { recursive: true });
|
|
173
|
+
const pull = runAdb(serial, ["pull", remotePath, request.outputPath]);
|
|
174
|
+
if (pull.status !== 0) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`Could not pull Android recording from ${serial}: ${commandFailure(pull)}`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
const output = fs.statSync(request.outputPath);
|
|
180
|
+
if (!output.isFile() || output.size <= 0) {
|
|
181
|
+
throw new Error(`Android recording output is empty: ${request.outputPath}`);
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
recorder: {
|
|
185
|
+
name: "adb-screenrecord",
|
|
186
|
+
platform: "android",
|
|
187
|
+
target: { selector: "adb-serial", value: serial }
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
} finally {
|
|
191
|
+
runAdb(serial, ["shell", "rm", "-f", remotePath]);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function androidTargetSerial(request) {
|
|
197
|
+
if (request.target.kind !== "window-id") return void 0;
|
|
198
|
+
if (!request.target.windowId.startsWith(ANDROID_RECORDING_TARGET_PREFIX)) return void 0;
|
|
199
|
+
return request.target.windowId.slice(ANDROID_RECORDING_TARGET_PREFIX.length) || void 0;
|
|
200
|
+
}
|
|
201
|
+
async function waitForScreenrecordStart(serial, child, stderr) {
|
|
202
|
+
const deadline = Date.now() + RECORDING_START_TIMEOUT_MS;
|
|
203
|
+
while (Date.now() < deadline) {
|
|
204
|
+
if (child.exitCode !== null) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`Android screenrecord exited before capture started (exit ${child.exitCode}): ${stderr.join("").trim() || "no recorder output"}`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
const pid = screenrecordPid(serial);
|
|
210
|
+
if (pid) return pid;
|
|
211
|
+
await sleep(100);
|
|
212
|
+
}
|
|
213
|
+
child.kill("SIGTERM");
|
|
214
|
+
throw new Error(
|
|
215
|
+
`Android screenrecord did not start on ${serial} within ${RECORDING_START_TIMEOUT_MS}ms.`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
function stopRemoteScreenrecord(serial, pid) {
|
|
219
|
+
const stop = runAdb(serial, ["shell", "kill", "-2", pid]);
|
|
220
|
+
if (stop.status !== 0 && screenrecordPid(serial) === pid) {
|
|
221
|
+
throw new Error(`Could not stop Android screenrecord pid ${pid}: ${commandFailure(stop)}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async function waitForChildExit(child, timeoutMs, recorderName) {
|
|
225
|
+
if (child.exitCode !== null) return;
|
|
226
|
+
await new Promise((resolve, reject) => {
|
|
227
|
+
const timeout = setTimeout(() => {
|
|
228
|
+
child.kill("SIGTERM");
|
|
229
|
+
reject(new Error(`${recorderName} did not stop within ${timeoutMs}ms.`));
|
|
230
|
+
}, timeoutMs);
|
|
231
|
+
child.once("exit", () => {
|
|
232
|
+
clearTimeout(timeout);
|
|
233
|
+
resolve();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
async function waitForIosRecordingStart(child, output) {
|
|
238
|
+
const deadline = Date.now() + RECORDING_START_TIMEOUT_MS;
|
|
239
|
+
while (Date.now() < deadline) {
|
|
240
|
+
if (child.exitCode !== null) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`iOS simulator recordVideo exited before capture started (exit ${child.exitCode}): ${output.join("").trim() || "no recorder output"}`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
if (output.join("").includes("Recording started")) return;
|
|
246
|
+
await sleep(100);
|
|
247
|
+
}
|
|
248
|
+
if (child.exitCode === null) return;
|
|
249
|
+
throw new Error(
|
|
250
|
+
`iOS simulator recordVideo did not start within ${RECORDING_START_TIMEOUT_MS}ms.`
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
function iosTargetSimulator(request) {
|
|
254
|
+
if (request.target.kind !== "window-id") return void 0;
|
|
255
|
+
if (!request.target.windowId.startsWith(
|
|
256
|
+
IOS_SIMULATOR_RECORDING_TARGET_PREFIX
|
|
257
|
+
)) {
|
|
258
|
+
return void 0;
|
|
259
|
+
}
|
|
260
|
+
return request.target.windowId.slice(IOS_SIMULATOR_RECORDING_TARGET_PREFIX.length) || void 0;
|
|
261
|
+
}
|
|
262
|
+
function screenrecordPid(serial) {
|
|
263
|
+
const result = runAdb(serial, ["shell", "pidof", "screenrecord"]);
|
|
264
|
+
if (result.status !== 0) return void 0;
|
|
265
|
+
return result.stdout.trim().split(/\s+/u).find(Boolean);
|
|
266
|
+
}
|
|
267
|
+
function runAdb(serial, args) {
|
|
268
|
+
return spawnSync("adb", ["-s", serial, ...args], { encoding: "utf8" });
|
|
269
|
+
}
|
|
270
|
+
function runSimctl(args) {
|
|
271
|
+
return spawnSync("xcrun", ["simctl", ...args], { encoding: "utf8" });
|
|
272
|
+
}
|
|
273
|
+
function commandFailure(result) {
|
|
274
|
+
return result.stderr.trim() || result.stdout.trim() || `exit ${result.status ?? "unknown"}`;
|
|
275
|
+
}
|
|
276
|
+
function sleep(durationMs) {
|
|
277
|
+
return new Promise((resolve) => setTimeout(resolve, durationMs));
|
|
278
|
+
}
|
|
279
|
+
export {
|
|
280
|
+
createAndroidVideoRecorder,
|
|
281
|
+
createIosSimulatorVideoRecorder
|
|
282
|
+
};
|