@deeeed/metamask-harness 0.33.3 → 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.
Files changed (38) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +18 -2
  3. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +83 -23
  4. package/dist/adapters/mobile/prepare.js +14 -0
  5. package/dist/adapters/mobile/video-recorder.js +282 -0
  6. package/dist/adapters.js +225 -35
  7. package/dist/command-contract.js +1 -0
  8. package/dist/commands/device-target.js +17 -6
  9. package/dist/commands/launch/index.js +27 -5
  10. package/dist/commands/launch/mobile.js +9 -2
  11. package/dist/commands/run-engine.js +34 -2
  12. package/dist/devices.js +9 -2
  13. package/dist/heal-bounds.js +10 -0
  14. package/dist/mm-harness-cli.js +4 -2
  15. package/dist/recipe-security.js +1 -0
  16. package/dist/recording-target.js +23 -5
  17. package/dist/runner.js +107 -6
  18. package/dist/runtime-context.js +33 -1
  19. package/docs/RECIPES.md +41 -0
  20. package/library/actions/mobile/app/network-control.mjs +135 -0
  21. package/library/actions/mobile/app/network.mjs +4 -0
  22. package/library/actions/mobile/perps/capture_performance.mjs +4 -0
  23. package/library/actions/mobile/perps/performance-capture.mjs +383 -0
  24. package/library/actions/mobile/perps/perps.mjs +16 -0
  25. package/library/actions/mobile/platform/bridge.mjs +57 -0
  26. package/library/actions/mobile/wallet/ensure_unlocked.mjs +27 -6
  27. package/library/actions/mobile/wallet/select_account.mjs +21 -17
  28. package/library/manifests/mobile.action-manifest.json +130 -1
  29. package/library/recipes/mobile/perps/performance.homepage.android-background-reconnect.recipe.json +159 -0
  30. package/library/recipes/mobile/perps/performance.homepage.android-background-short.recipe.json +157 -0
  31. package/library/recipes/mobile/perps/performance.homepage.android-cold-disk-cache.recipe.json +147 -0
  32. package/library/recipes/mobile/perps/performance.homepage.android-cold-no-cache.recipe.json +137 -0
  33. package/library/recipes/mobile/perps/performance.homepage.android-network-recovery.recipe.json +149 -0
  34. package/library/recipes/mobile/perps/performance.homepage.ios-background-reconnect.recipe.json +110 -0
  35. package/library/recipes/mobile/perps/performance.homepage.ios-background-short.recipe.json +108 -0
  36. package/library/recipes/mobile/perps/performance.homepage.ios-cold-disk-cache.recipe.json +122 -0
  37. package/library/recipes/mobile/perps/performance.homepage.ios-cold-no-cache.recipe.json +95 -0
  38. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
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
+
5
24
  ## 0.33.3 - 2026-08-05
6
25
 
7
26
  ### Changed
@@ -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 = tryScroll(anchor, false);
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
- return await fetchJSONOnce(url);
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(listUrl);
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 && candidates.length > 0) {
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
- (t) => t.deviceName !== simName &&
176
- (t.deviceName === androidTargetName ||
177
- (t.deviceName != null && t.deviceName.startsWith(androidTargetName))),
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; picking one silently is exactly the bug this
182
- // path exists to prevent.
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((t) => ` deviceName=${JSON.stringify(t.deviceName || '')} ws=${t.webSocketDebuggerUrl || ''}`)
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 ${androidFiltered.length} Metro targets.\n` +
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((t) => t.deviceName === androidDevice);
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
+ };