@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +31 -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 +229 -13
  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
  39. package/scripts/site-contrast.mjs +34 -1
  40. package/site/architecture.html +12 -2
  41. package/site/assets/style.css +20 -1
  42. package/site/cheatsheet.html +7 -6
  43. package/site/index.html +119 -77
  44. package/site/perps.html +195 -0
  45. package/site/recipes.html +23 -1
  46. package/site/reviewers.html +2 -1
  47. package/site/tutorials/index.html +2 -1
  48. package/site/tutorials/v1.html +3 -2
  49. package/site/tutorials/v2.html +6 -5
  50. package/site/tutorials/v3.html +43 -2
  51. package/site/tutorials/v4.html +2 -1
  52. package/site/tutorials/v5.html +2 -1
  53. package/site/tutorials/v6.html +2 -1
  54. package/site/tutorials/v7.html +2 -1
@@ -198,7 +198,7 @@ Example:
198
198
 
199
199
  --list List actions accepted by call for this adapter; no <action> needed
200
200
  --arg k=v Action field value (repeatable; equivalent to key=value shorthand)
201
- --device <udid|serial|name> Mobile only: target this device (env: IOS_SIMULATOR / ADB_SERIAL). Without it, >1 connected mobile device fails fast and lists them.
201
+ --device <udid|serial|name> Mobile only: target this device (sets IOS_SIMULATOR / ADB_SERIAL and updates agentic-runtime.json). Without it, >1 connected mobile device fails fast and lists them. Do not shut down other devices to force selection.
202
202
  --adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
203
203
  --target <path> Checkout path (default: cwd)
204
204
  --artifacts-dir <dir> Where to write evidence (default: temp dir)
@@ -228,7 +228,7 @@ Discovery:
228
228
  --plan Validate + print execution plan, touching nothing. Exit 5 if invalid.
229
229
 
230
230
  Common overrides:
231
- --device <udid|serial|name> Mobile only: target this device (env: IOS_SIMULATOR / ADB_SERIAL). Without it, >1 connected mobile device fails fast and lists them.
231
+ --device <udid|serial|name> Mobile only: target this device (sets IOS_SIMULATOR / ADB_SERIAL and updates agentic-runtime.json). Without it, >1 connected mobile device fails fast and lists them. Do not shut down other devices to force selection.
232
232
  --adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
233
233
  --platform <ios|android> Mobile platform preference; use --adapter for the product adapter
234
234
  --target <path> Checkout path (default: cwd)
@@ -453,6 +453,7 @@ Example:
453
453
  --build is the escape hatch that always clean-builds + relaunches a fresh runtime.
454
454
 
455
455
  --build Full clean build + relaunch (escape hatch; skips extension reuse)
456
+ --clear-metro Mobile only: clear Metro's transform cache before launch
456
457
  --verify Launch then poll CDP/bridge until ready (absorbs the old \`live\`)
457
458
  --runway Post-launch runway check (mobile only; teaching error elsewhere)
458
459
  --watch Persistent webpack watcher then relaunch (extension only)
@@ -469,6 +470,7 @@ Example:
469
470
 
470
471
  Example:
471
472
  mm-harness launch ios
473
+ mm-harness launch android --clear-metro --verify
472
474
  mm-harness launch ios --build
473
475
  mm-harness launch --verify --json`
474
476
  },
@@ -28,6 +28,7 @@ const APP_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
28
28
  "metamask.perps.update_position_tpsl",
29
29
  "metamask.perps.ensure_positions",
30
30
  "metamask.perps.ensure_orders",
31
+ "metamask.perps.clear_performance_caches",
31
32
  "metamask.perps.start_state",
32
33
  "metamask.perps.teardown_state",
33
34
  // Consent changes wallet state and is therefore an app mutation.
@@ -1,6 +1,8 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ const ANDROID_RECORDING_TARGET_PREFIX = "android-device:";
5
+ const IOS_SIMULATOR_RECORDING_TARGET_PREFIX = "ios-simulator:";
4
6
  function createMetaMaskRecordingTargetProvider(adapter) {
5
7
  return {
6
8
  async resolveRecordingTarget(context) {
@@ -19,8 +21,17 @@ function createMetaMaskRecordingTargetProvider(adapter) {
19
21
  }
20
22
  if (adapter === "mobile") {
21
23
  const platform = (context.env.PLATFORM ?? process.env.PLATFORM ?? "ios").trim().toLowerCase();
22
- if (platform === "android" || context.env.ADB_SERIAL || process.env.ADB_SERIAL) {
23
- throw new Error("--record-video is not implemented for mobile Android replay yet.");
24
+ const androidSerial = resolveAndroidDeviceSerial(context.env);
25
+ if (platform === "android" || androidSerial) {
26
+ if (!androidSerial) {
27
+ throw new Error(
28
+ "Could not resolve an Android device for --record-video. Set ADB_SERIAL or pass --device to mm-harness run."
29
+ );
30
+ }
31
+ return {
32
+ kind: "window-id",
33
+ windowId: `${ANDROID_RECORDING_TARGET_PREFIX}${androidSerial}`
34
+ };
24
35
  }
25
36
  const simulator = resolveMobileIosSimulatorName(context.env);
26
37
  if (!simulator) {
@@ -28,14 +39,18 @@ function createMetaMaskRecordingTargetProvider(adapter) {
28
39
  "Could not resolve an iOS simulator name for --record-video. Set IOS_SIMULATOR or pass --simulator to the recipe hook."
29
40
  );
30
41
  }
31
- const pid = resolveMobileIosSimulatorPid(simulator);
32
- if (pid) return { kind: "pid", pid };
33
- return { kind: "app-window", appName: "Simulator", windowName: simulator };
42
+ return {
43
+ kind: "window-id",
44
+ windowId: `${IOS_SIMULATOR_RECORDING_TARGET_PREFIX}${simulator}`
45
+ };
34
46
  }
35
47
  throw new Error(`--record-video is not implemented for the ${adapter} adapter.`);
36
48
  }
37
49
  };
38
50
  }
51
+ function resolveAndroidDeviceSerial(env = process.env) {
52
+ return env.ADB_SERIAL?.trim() || process.env.ADB_SERIAL?.trim() || void 0;
53
+ }
39
54
  function captureHelperSupportsRecordSessionSnapshots(projectRoot) {
40
55
  const result = spawnSync(captureHelperPath(), ["version", "--json"], {
41
56
  cwd: projectRoot,
@@ -125,8 +140,11 @@ function readTextFile(file) {
125
140
  }
126
141
  }
127
142
  export {
143
+ ANDROID_RECORDING_TARGET_PREFIX,
144
+ IOS_SIMULATOR_RECORDING_TARGET_PREFIX,
128
145
  captureHelperSupportsRecordSessionSnapshots,
129
146
  createMetaMaskRecordingTargetProvider,
147
+ resolveAndroidDeviceSerial,
130
148
  resolveExtensionBrowserPid,
131
149
  resolveMobileIosSimulatorName,
132
150
  resolveMobileIosSimulatorPid
package/dist/runner.js CHANGED
@@ -1,17 +1,26 @@
1
1
  import { execSync } from "node:child_process";
2
2
  import { OFFICIAL_RECIPE_ACTIONS } from "@farmslot/protocol";
3
3
  import { createMetaMaskAdapters, createMetaMaskUiTransport } from "./adapters.js";
4
+ import { bridgeCommand } from "../library/actions/mobile/platform/bridge.mjs";
4
5
  import {
5
6
  mobileSourceFingerprint,
6
7
  recordMobileSourceBaseline
7
8
  } from "./adapters/mobile/source-freshness.js";
9
+ import {
10
+ createAndroidVideoRecorder,
11
+ createIosSimulatorVideoRecorder
12
+ } from "./adapters/mobile/video-recorder.js";
8
13
  import { resolveMetaMaskMobileLifecycleTarget } from "./app-lifecycle.js";
9
14
  import { loadMetaMaskExtensionActionManifest, loadMetaMaskMobileActionManifest } from "./manifest.js";
10
15
  import { metaMaskActionExecutionCapabilities } from "./recipe-security.js";
11
16
  import {
12
17
  prepareLiveAdapterScript
13
18
  } from "./live-adapter-contract.js";
14
- import { createMetaMaskRecordingTargetProvider } from "./recording-target.js";
19
+ import {
20
+ createMetaMaskRecordingTargetProvider,
21
+ resolveAndroidDeviceSerial,
22
+ resolveMobileIosSimulatorName
23
+ } from "./recording-target.js";
15
24
  import {
16
25
  importMetaMaskNativeUiProvider,
17
26
  importRecipeHarness,
@@ -85,6 +94,8 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
85
94
  options.quietStdout ? new console.Console(process.stderr) : console,
86
95
  options.suppressLibraryResolutionLogs
87
96
  );
97
+ const androidRecordingSerial = adapter === "mobile" ? resolveAndroidDeviceSerial() : void 0;
98
+ const iosRecordingSimulator = adapter === "mobile" && !androidRecordingSerial ? resolveMobileIosSimulatorName() : void 0;
88
99
  return createRecipeRunner({
89
100
  actionManifest,
90
101
  adapters: withActionProgress(
@@ -96,7 +107,10 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
96
107
  ),
97
108
  logger,
98
109
  recording: {
99
- targetProvider: createMetaMaskRecordingTargetProvider(adapter)
110
+ targetProvider: createMetaMaskRecordingTargetProvider(adapter),
111
+ ...androidRecordingSerial ? { videoRecorder: createAndroidVideoRecorder(androidRecordingSerial) } : iosRecordingSimulator ? {
112
+ videoRecorder: createIosSimulatorVideoRecorder(iosRecordingSimulator)
113
+ } : {}
100
114
  },
101
115
  runner: runnerProvenance(),
102
116
  defaultSource: {
@@ -222,16 +236,32 @@ function isAutomaticHudProgress(action, node, nodeId) {
222
236
  const progress = payload.progress;
223
237
  return nodeId === "recipe-complete" && progress !== null && typeof progress === "object" && !Array.isArray(progress) && progress.complete === true;
224
238
  }
225
- function mobileSourceAwareLifecycleAdapters(adapter, lifecycle) {
239
+ function mobileSourceAwareLifecycleAdapters(adapter, lifecycle, readinessProbe = probeMobileLifecycleRuntime, sourceFreshness = {
240
+ fingerprint: mobileSourceFingerprint,
241
+ record: recordMobileSourceBaseline
242
+ }) {
226
243
  if (adapter !== "mobile") return lifecycle;
227
244
  return lifecycle.map((entry) => ({
228
245
  ...entry,
229
246
  async execute(node, context) {
230
247
  const command = node.command ?? node.event ?? node.state;
231
248
  const reloadsSource = command === "launch" || command === "foreground" || command === "restart";
232
- const fingerprint = reloadsSource ? mobileSourceFingerprint(context.projectRoot) : void 0;
233
- const result = await entry.execute(node, context);
234
- if (fingerprint !== void 0 && !recordMobileSourceBaseline(context.projectRoot, fingerprint)) {
249
+ const fingerprint = reloadsSource ? sourceFreshness.fingerprint(context.projectRoot) : void 0;
250
+ const androidRestart = command === "restart" && isAndroidLifecycle(node, context.env ?? {});
251
+ const initialNode = androidRestart ? { ...node, settle_ms: 0 } : node;
252
+ const initialResult = await entry.execute(initialNode, context);
253
+ let result = initialResult;
254
+ if (androidRestart) {
255
+ const foregroundResult = await entry.execute(
256
+ { ...node, command: "foreground" },
257
+ context
258
+ );
259
+ result = mergeAndroidRestartResults(initialResult, foregroundResult);
260
+ }
261
+ if (reloadsSource) {
262
+ await readinessProbe(node, context);
263
+ }
264
+ if (fingerprint !== void 0 && !sourceFreshness.record(context.projectRoot, fingerprint)) {
235
265
  throw new Error(
236
266
  "Mobile source changed while the app was reloading; the loaded-source baseline was not recorded. Next: retry app.lifecycle restart."
237
267
  );
@@ -240,6 +270,77 @@ function mobileSourceAwareLifecycleAdapters(adapter, lifecycle) {
240
270
  }
241
271
  }));
242
272
  }
273
+ function isAndroidLifecycle(node, env) {
274
+ const platform = node.platform ?? env.PLATFORM ?? process.env.PLATFORM;
275
+ if (platform === "android") return true;
276
+ if (platform === "ios") return false;
277
+ return Boolean(
278
+ node.adb_serial ?? node.android_device ?? env.ADB_SERIAL ?? env.ANDROID_SERIAL ?? process.env.ADB_SERIAL ?? process.env.ANDROID_SERIAL
279
+ );
280
+ }
281
+ function mergeAndroidRestartResults(initialResult, foregroundResult) {
282
+ const initial = asRecord(initialResult);
283
+ const foreground = asRecord(foregroundResult);
284
+ const initialOutput = asRecord(initial.output);
285
+ const foregroundOutput = asRecord(foreground.output);
286
+ return {
287
+ ...initial,
288
+ output: {
289
+ ...initialOutput,
290
+ command: "restart",
291
+ calls: [
292
+ ...Array.isArray(initialOutput.calls) ? initialOutput.calls : [],
293
+ ...Array.isArray(foregroundOutput.calls) ? foregroundOutput.calls : []
294
+ ],
295
+ settleMs: foregroundOutput.settleMs ?? initialOutput.settleMs ?? 0,
296
+ foregroundEnsured: true
297
+ }
298
+ };
299
+ }
300
+ async function probeMobileLifecycleRuntime(node, context) {
301
+ const command = node.command ?? node.event ?? node.state;
302
+ const defaultTimeoutMs = command === "restart" || command === "launch" ? 6e4 : 3e4;
303
+ const timeoutMs = lifecycleReadinessTimeout(
304
+ node.runtime_ready_timeout_ms,
305
+ defaultTimeoutMs
306
+ );
307
+ const deadline = Date.now() + timeoutMs;
308
+ let lastError;
309
+ while (Date.now() < deadline) {
310
+ try {
311
+ return await bridgeCommand(
312
+ {
313
+ action: "app.lifecycle",
314
+ node: {
315
+ ...node,
316
+ cdp_timeout_ms: Math.min(8e3, Math.max(1e3, deadline - Date.now()))
317
+ },
318
+ context
319
+ },
320
+ ["get-route"]
321
+ );
322
+ } catch (error) {
323
+ lastError = error;
324
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
325
+ }
326
+ }
327
+ throw new Error(
328
+ `Mobile lifecycle ${String(command)} did not expose a usable pinned __AGENTIC__ runtime within ${timeoutMs}ms: ${lastError instanceof Error ? lastError.message : String(lastError)}`
329
+ );
330
+ }
331
+ function lifecycleReadinessTimeout(value, fallback) {
332
+ if (value === void 0) return fallback;
333
+ const timeout = Number(value);
334
+ if (!Number.isInteger(timeout) || timeout < 1e3 || timeout > 12e4) {
335
+ throw new Error(
336
+ "app.lifecycle.runtime_ready_timeout_ms must be an integer from 1000 through 120000."
337
+ );
338
+ }
339
+ return timeout;
340
+ }
341
+ function asRecord(value) {
342
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
343
+ }
243
344
  function runnerProvenance() {
244
345
  return {
245
346
  source: runnerDir,
@@ -226,6 +226,38 @@ function hydrateRuntimeEnv(contextPath, context) {
226
226
  function setEnv(name, value) {
227
227
  if (value !== void 0 && value !== null && value !== "") process.env[name] = String(value);
228
228
  }
229
+ function persistExplicitDevicePin(target, device) {
230
+ let repoRoot;
231
+ try {
232
+ repoRoot = fs.realpathSync(target);
233
+ } catch {
234
+ return;
235
+ }
236
+ const contextPath = path.join(repoRoot, recipeRuntimeDir(), "agentic-runtime.json");
237
+ if (!fs.existsSync(contextPath)) return;
238
+ let existing;
239
+ try {
240
+ const parsed = JSON.parse(fs.readFileSync(contextPath, "utf8"));
241
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
242
+ existing = parsed;
243
+ } catch {
244
+ return;
245
+ }
246
+ const next = { ...existing };
247
+ if (device.platform === "ios") {
248
+ const simulatorName = device.name ? device.name.replace(/_/gu, " ").trim() : device.id;
249
+ next.simulator = simulatorName;
250
+ next.simulatorUdid = device.id;
251
+ delete next.adbSerial;
252
+ } else {
253
+ next.adbSerial = device.id;
254
+ delete next.simulator;
255
+ delete next.simulatorUdid;
256
+ }
257
+ removeEmptyValues(next);
258
+ writeJsonAtomic(contextPath, next);
259
+ }
229
260
  export {
230
- ensureRuntimeContext
261
+ ensureRuntimeContext,
262
+ persistExplicitDevicePin
231
263
  };
package/docs/RECIPES.md CHANGED
@@ -58,6 +58,47 @@ mm-harness run recipe.json account="Account 2" --plan
58
58
  mm-harness run recipe.json account="Account 2"
59
59
  ```
60
60
 
61
+ ### Mobile device targeting
62
+
63
+ Always pass `--device <name|udid|serial>` when more than one simulator/device may
64
+ be connected. That pin:
65
+
66
+ 1. Sets `IOS_SIMULATOR` / `ADB_SERIAL` for the run.
67
+ 2. Rewrites the checkout `agentic-runtime.json` simulator/serial so the next
68
+ `resolveSlotPorts` does not revive a stale pin from another PR or slot.
69
+
70
+ Do **not** shut down other connected devices to "force" selection — selection is
71
+ by `--device`, not by exclusive boot.
72
+
73
+ For visual PR proof, prefer a short simulator recording or GIF over still
74
+ screenshots when the claim is a UI flow (timing / transitions). Use screenshots
75
+ as supporting stills.
76
+
77
+ ### Perps Homepage visible performance
78
+
79
+ The bundled `perps.performance.homepage.*` family preserves the device-proven
80
+ Homepage TTC/DFD journeys. Run every recipe with an explicit device pin,
81
+ healing disabled, and full-run video:
82
+
83
+ ```bash
84
+ mm-harness run perps.performance.homepage.android-background-short --device <serial> --heal off --record-video=full-run
85
+ mm-harness run perps.performance.homepage.android-background-reconnect --device <serial> --heal off --record-video=full-run
86
+ mm-harness run perps.performance.homepage.android-cold-no-cache --device <serial> --heal off --record-video=full-run
87
+ mm-harness run perps.performance.homepage.android-cold-disk-cache --device <serial> --heal off --record-video=full-run
88
+ mm-harness run perps.performance.homepage.android-network-recovery --device <serial> --heal off --record-video=full-run
89
+ mm-harness run perps.performance.homepage.ios-background-short --device <udid> --heal off --record-video=full-run
90
+ mm-harness run perps.performance.homepage.ios-background-reconnect --device <udid> --heal off --record-video=full-run
91
+ mm-harness run perps.performance.homepage.ios-cold-no-cache --device <udid> --heal off --record-video=full-run
92
+ mm-harness run perps.performance.homepage.ios-cold-disk-cache --device <udid> --heal off --record-video=full-run
93
+ ```
94
+
95
+ The Android variants use a deterministic Hyperliquid testnet order and cancel
96
+ it after capture. The iOS variants use the existing fixture wallet and validate
97
+ trending content without fabricating position/order state. `app.network` is
98
+ Android-only because host-wide iOS network mutation cannot isolate one
99
+ simulator safely. These recipes validate viewport-demand-to-visible TTC/DFD;
100
+ they do not relabel process-start duration as TTC.
101
+
61
102
  Rules:
62
103
 
63
104
  - Validate with `--plan` before side effects.
@@ -0,0 +1,135 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ import { resolveMobileToolPath } from '../platform/tool-paths.mjs';
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ function sleep(ms) {
9
+ return new Promise((resolve) => setTimeout(resolve, ms));
10
+ }
11
+
12
+ function selectedPlatform(input) {
13
+ const value = String(
14
+ input.node?.platform ?? input.context?.env?.PLATFORM ?? process.env.PLATFORM ?? '',
15
+ ).toLowerCase();
16
+ if (value === 'android' || value === 'ios') return value;
17
+ return selectedAndroidSerial(input) ? 'android' : 'ios';
18
+ }
19
+
20
+ function selectedAndroidSerial(input) {
21
+ const value =
22
+ input.node?.adb_serial ??
23
+ input.node?.device ??
24
+ input.context?.env?.ADB_SERIAL ??
25
+ input.context?.env?.ANDROID_SERIAL ??
26
+ process.env.ADB_SERIAL ??
27
+ process.env.ANDROID_SERIAL;
28
+ return value == null ? '' : String(value).trim();
29
+ }
30
+
31
+ function selectedAndroidPackage(input) {
32
+ return String(
33
+ input.node?.package_id ??
34
+ input.node?.app_id ??
35
+ input.context?.env?.ANDROID_PACKAGE_ID ??
36
+ process.env.ANDROID_PACKAGE_ID ??
37
+ 'io.metamask',
38
+ ).trim();
39
+ }
40
+
41
+ async function defaultRun(file, args) {
42
+ return execFileAsync(file, args, { timeout: 15_000, encoding: 'utf8' });
43
+ }
44
+
45
+ export async function setMobileNetworkState(input, deps = {}) {
46
+ const state = String(input.node?.state ?? '').toLowerCase();
47
+ if (state !== 'offline' && state !== 'online') {
48
+ throw new Error('app.network.state must be offline or online.');
49
+ }
50
+ const platform = selectedPlatform(input);
51
+ if (platform !== 'android') {
52
+ throw new Error(
53
+ 'app.network does not mutate iOS simulator host networking. A safe device-scoped iOS network capability is required before this lifecycle can be validated.',
54
+ );
55
+ }
56
+ const serial = selectedAndroidSerial(input);
57
+ if (!serial) {
58
+ throw new Error('Android app.network requires the selected ADB serial. Pass --device <serial>.');
59
+ }
60
+
61
+ const adb = deps.adb ?? resolveMobileToolPath('adb', { required: true });
62
+ const run = deps.run ?? defaultRun;
63
+ const appId = selectedAndroidPackage(input);
64
+ const airplaneMode = state === 'offline' ? 'enable' : 'disable';
65
+ await run(adb, [
66
+ '-s',
67
+ serial,
68
+ 'shell',
69
+ 'cmd',
70
+ 'connectivity',
71
+ 'airplane-mode',
72
+ airplaneMode,
73
+ ]);
74
+ const status = await run(adb, [
75
+ '-s',
76
+ serial,
77
+ 'shell',
78
+ 'settings',
79
+ 'get',
80
+ 'global',
81
+ 'airplane_mode_on',
82
+ ]);
83
+ const expected = state === 'offline' ? '1' : '0';
84
+ const observed = String(status.stdout ?? '').trim();
85
+ if (observed !== expected) {
86
+ throw new Error(
87
+ `app.network requested ${state}, but Android airplane_mode_on was ${JSON.stringify(observed)} instead of ${expected}.`,
88
+ );
89
+ }
90
+
91
+
92
+ await run(adb, [
93
+ '-s',
94
+ serial,
95
+ 'shell',
96
+ 'monkey',
97
+ '-p',
98
+ appId,
99
+ '-c',
100
+ 'android.intent.category.LAUNCHER',
101
+ '1',
102
+ ]);
103
+
104
+ const fallbackSettleMs = state === 'offline' ? 3000 : 10000;
105
+ const settleMs = Number(input.node?.settle_ms ?? fallbackSettleMs);
106
+ if (!Number.isInteger(settleMs) || settleMs < 0 || settleMs > 60_000) {
107
+ throw new Error('app.network.settle_ms must be an integer from 0 through 60000.');
108
+ }
109
+ await (deps.sleep ?? sleep)(settleMs);
110
+
111
+ const foreground = await run(adb, [
112
+ '-s',
113
+ serial,
114
+ 'shell',
115
+ 'dumpsys',
116
+ 'activity',
117
+ 'activities',
118
+ ]);
119
+ if (!String(foreground.stdout ?? '').includes(appId)) {
120
+ throw new Error(
121
+ `app.network changed Android networking but ${appId} was not foregrounded afterward.`,
122
+ );
123
+ }
124
+
125
+ return {
126
+ action: 'app.network',
127
+ platform,
128
+ deviceId: serial,
129
+ state,
130
+ appId,
131
+ airplaneMode: Number(expected),
132
+ settleMs,
133
+ foregroundEnsured: true,
134
+ };
135
+ }
@@ -0,0 +1,4 @@
1
+ import { runAdapter } from '../platform/bridge.mjs';
2
+ import { setMobileNetworkState } from './network-control.mjs';
3
+
4
+ runAdapter(setMobileNetworkState);
@@ -0,0 +1,4 @@
1
+ import { runAdapter } from '../platform/bridge.mjs';
2
+ import { captureHomepagePerformance } from './performance-capture.mjs';
3
+
4
+ runAdapter(captureHomepagePerformance);