@deeeed/metamask-harness 0.86.2 → 0.87.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 +26 -0
- package/dist/adapters/mobile/action-inputs.js +20 -0
- package/dist/adapters/mobile/actions.js +18 -1
- package/dist/adapters/mobile/android-screenrecord.js +270 -0
- package/dist/adapters/mobile/readiness.js +2 -1
- package/dist/adapters/mobile/recording-outcome.js +103 -0
- package/dist/adapters/mobile/recording-target.js +11 -0
- package/dist/adapters/mobile/run.js +48 -11
- package/dist/adapters/mobile/ui.js +96 -2
- package/dist/adapters/perps-order-inputs.js +43 -28
- package/dist/adapters.js +4 -1
- package/dist/commands/doctor.js +33 -14
- package/dist/commands/status-probe.js +2 -1
- package/docs/RECIPES.md +62 -0
- package/library/actions/core/perps/read_account.mjs +4 -2
- package/library/actions/core/perps/read_orders.mjs +4 -2
- package/library/actions/core/perps/read_positions.mjs +4 -2
- package/library/actions/extension/analytics/consent.mjs +7 -2
- package/library/actions/extension/perps/compare_provider_market.mjs +12 -8
- package/library/actions/extension/perps/mutation-receipt.mjs +120 -23
- package/library/actions/extension/perps/perps.mjs +98 -30
- package/library/actions/extension/perps/read_snapshot.mjs +6 -2
- package/library/actions/extension/platform/cdp.mjs +6 -2
- package/library/actions/extension/ui/navigate.mjs +8 -0
- package/library/actions/mobile/perps/perps.mjs +106 -92
- package/library/actions/mobile/platform/bridge.mjs +171 -10
- package/library/actions/mobile/wallet/home.mjs +7 -2
- package/library/actions/mobile/wallet/select_account.mjs +4 -0
- package/library/actions/shared/app/live-adapter-deadline.mjs +8 -0
- package/library/actions/shared/perps/rate-limit.mjs +93 -0
- package/library/actions/shared/perps/visible-state.mjs +3 -2
- package/library/manifests/extension.action-manifest.json +2 -1
- package/library/manifests/mobile.action-manifest.json +30 -5
- package/package.json +1 -1
- package/site/assets/help-recipes.json +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.87.0 - 2026-10-10
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- `--record-video=full-run` on a physical Android device records on the device with `adb shell screenrecord`, not a scrcpy window captured through capture-helper. macOS stopped the capture-helper stream whenever another capture-helper process exited (Farmslot's browser liveness check, `mm-harness doctor`, another slot's recording), so a Pixel 6a release run lost its full-run video with ScreenCaptureKit error -3805. screenrecord ends each file at 180 s: the recorder starts the next file as soon as one ends and joins them with ffmpeg, so a physical Android recording now needs ffmpeg at start (`FFMPEG_PATH` overrides). The roughly half-second gap at each restart is kept as a held frame, so video time stays on wall-clock time and the recording timeline stays tight; `videos/recipe-run.mp4.capture.json` lists each file's start, stop and offset. If the file being stopped at the end of the run fails (stopped while screenrecord was still starting), the earlier files are kept. Device files and pulled host files are removed and the device and host recorder processes stopped when the run ends, fails, or the harness gets SIGINT, SIGTERM or SIGHUP. The recipe HUD is drawn in the app, so it is in the video. Screens the app marks secure (FLAG_SECURE) record black, as with any capture of the device screen. Emulators keep the mirror. `METAMASK_RECIPE_MOBILE_ANDROID_RECORDER=screenrecord|mirror` forces either recorder (`mirror` keeps its single-file screenrecord fallback when the mirror is unavailable); any other value fails the run.
|
|
10
|
+
- A Mobile recording that fails, or whose recorder cannot record when the run starts, now fails the run with a code. The `record.video` trace entry reads `RECORDING_FAILED: <cause>. The run has no video.` or, when capture stopped mid-run with nothing kept, `RECORDER_INTERRUPTED: <cause>. The run has no video.`, with `cause_class: "environment"`. Before, it carried the recorder's raw message (for capture-helper 0.3.0 an empty `capture-helper record failed (exit 1):`). A recording that stopped mid-run but kept a finalized partial file is published, and its manifest entry gains `recorder.interrupted` (`code: "RECORDER_INTERRUPTED"`, `cause`, and `coveredMs`, the video time up to the last captured frame, or `coverageUnavailableReason` when that cannot be measured); the run also prints a `WARN: RECORDER_INTERRUPTED` line. This covers capture-helper 0.3.1's `stream_interrupted` event on the emulator mirror (the MP4 and timing sidecar it finalized are kept; `cause` names the ScreenCaptureKit error, such as `SCStreamErrorDomain -3805`) and an on-device screenrecord that fails mid-run (the files finished before it are kept).
|
|
11
|
+
- `mm-harness doctor`, `doctor --expect-live`, `--print-ready` and `doctor --fix` on Mobile check the recorder `run --record-video` would use for the selected device once the runtime is ready, and show it (`recorder` in `--json`, a `recorder:` line otherwise). A recorder that cannot record (no ffmpeg for on-device recording, capture-helper or scrcpy unusable with no fallback, simulator not booted) makes doctor report `ready: false` with that recorder's next step: `DOCTOR_CHECKS_FAILED` from `doctor`, `RECORDER_UNAVAILABLE` from `--expect-live --json`, `not-ready` from `--print-ready`, and `recorder` in `failed` from `--fix`. Before, doctor reported ready while every recording failed.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- `mm-harness run --plan` now rejects a `metamask.perps.start_state` node that combines a Mobile `trading_state` with a `positions`, `orders` or `strategies` request other than `false`, or whose `positions` or `orders` block opens a position or order without a market and side (on the node or in the block) and an `amount` or `notional` in the block. Unresolved templates are still checked at run time.
|
|
16
|
+
- Extension `metamask.analytics.set_consent` reads marketing consent from `optedInToMarketing` when the state has no `dataCollectionForMarketing` (Extension 13.51+), so its readback no longer fails with "Extension consent state did not contain boolean consent fields before the deadline".
|
|
17
|
+
- Extension `ui.navigate page=perps` names a Perps home still loading at the deadline, with loading markers listed first in its visible test IDs: "Perps home stayed on its loading view (perps-view-loading visible) within the <n>ms budget" and "Next: check the Perps stream/provider, or raise timeout_ms".
|
|
18
|
+
- Mobile Perps `start_state` and `teardown_state` keep the setup budget apart from bridge command limits. Before, one deadline covered unlock, account, navigation and cleanup, and every nested bridge command could use all of what was left. On a physical Pixel 6a, a market-details to home navigation took about 18 s of a 30 s clean-market setup. Later commands were cut to slivers (`press-test-id` after 1258 ms, `eval-async` after 315 ms) or refused, and every one was reported as `CDP_TIMEOUT`, which reads as a bridge failure. Now each phase runs on its own deadline inside the setup budget. Navigation phases get a slice (`navigation_timeout_ms`), and each bridge command gets its own limit (`cdp_timeout_ms`, default 15 s) capped by the phase deadline. A phase that runs out fails as `DOMAIN_SETUP_DEADLINE`, with the phase, its elapsed time and limit, and the setup's elapsed time and limit. A bridge command its action deadline refused or stopped fails as `ACTION_DEADLINE`, not `CDP_TIMEOUT`. Route waits, `ensure_mode` and account switching treat it as no answer yet: a route or mode wait still ends with its own timeout message, and an account switch the deadline cut short is reported as ambiguous. A command that hits its own limit keeps `CDP_TIMEOUT`, and its message now says `CDP_COMMAND_TIMEOUT` and gives the limit.
|
|
19
|
+
- Mobile setup and navigation budgets depend on the device class. Emulators and simulators keep 30 s for setup and 15 s per navigation. A physical Android device (explicit non-`emulator-*` adb serial) gets 60 s and 30 s. `MM_HARNESS_MOBILE_SETUP_TIMEOUT_MS` and `MM_HARNESS_MOBILE_NAVIGATION_TIMEOUT_MS` override the defaults, and node `timeout_ms` and `navigation_timeout_ms` override both. The setups' live process timeout is raised to the setup budget plus 5 s, so the process timeout no longer races a 60 s setup. `start_state` and `teardown_state` schemas accept `navigation_timeout_ms` and `cdp_timeout_ms`.
|
|
20
|
+
- A Hyperliquid `429 Too Many Requests` in a Perps action is now the typed error `RATE_LIMITED`, naming the call, the attempts and the backoff used. Farm runs share the testnet account and egress IP, so the venue rate-limits them; on Extension 13.52.0 QA (testnet run 46848-1009-223034) the $12 SOL setup order failed with `429 Too Many Requests - null` and Recipe Cook stopped the case, though nothing was placed.
|
|
21
|
+
- Reads retry up to 3 times with exponential backoff and jitter (between half and all of 1 s, 2 s, 4 s), then fail `RATE_LIMITED` with `Next: wait a minute and rerun`. No retry starts unless its backoff and another attempt as long as the last one end before the action deadline, and a retry still unanswered at the deadline fails there. The deadline is the inherited deadline on Mobile, and 5 s before the runner kills the live adapter process (`liveAdapterProcessTimeoutMs`) on Extension and Core. This covers Extension position, order, account, market, size-step and snapshot reads and the `compare_provider_market` venue read, Mobile controller position, order and strategy-order reads, and Core `read_positions`, `read_orders` and `read_account`.
|
|
22
|
+
- Extension background `metamask.perps.place_order` retries a 429 only when the dispatch itself got it (no accepted order) and, after the backoff, Hyperliquid's own info API (read from the harness, not the extension) shows no open order and no fill in that market since the first dispatch (open orders read first, so an order filling between the reads still shows), and for a market order no position on its side. Up to 3 retries, and only while the backoff and those reads (5 s at most) leave the postcondition its `timeout_ms` and 5 s before the process limit. The retry's own pre-dispatch checks are bounded by that deadline too; a retry that would reach the venue after it is not sent, the receipt becomes `not-dispatched`, and the error is still `RATE_LIMITED`. Every retry reruns the pre-dispatch checks (unchanged funding balance, provider and network, CDP target, bound account); the budget cap and the order rule are the first dispatch's.
|
|
23
|
+
- Every 429 on a dispatch goes into the receipt under `rateLimitedDispatches`, with its backoff and venue verification or the reason it was not retried. Without a retry the receipt stays `outcome-unknown` and the error is `RATE_LIMITED` with that reason and still `Next: do not rerun this mutation`: a venue read that fails or shows activity, a HIP-3 market, spent retries, the deadline, a 429 on the postcondition read after dispatch (never redispatched), and every other Extension mutation. Mobile placements keep no receipt and are not retried.
|
|
24
|
+
- Mobile `metamask.perps.read_visible_state` counts the Perps home Recent activity rows (`perps-recent-activity-row-<n>`) and a market's recent trades rows (`perps-market-trades-row-<n>`) as activity. A populated home preview reported `activity: []` and failed `minimum_activity_count`.
|
|
25
|
+
- Mobile `ui.wait_for expected=absent` no longer times out on a control that only a screen hidden in the navigation stack still mounts. The app's tree query also finds test IDs in screens a navigator keeps behind the focused one (the Activity screen's `activity-screen-aggregated-checkbox` under Perps home). A tree hit now counts as absent only when every match is on a provably hidden route (an inactive tab, or a stack screen that is neither modal-presented nor inheriting a modal presentation, with a screen above it that is not modal either) and a complete native observation, read within the wait's remaining time, does not show the test ID. The result then carries `hiddenRoute: true` and `nativeVisible: false`. A modal-family screen, and any screen under a sheet or transparent modal, is never treated as hidden, even when native accessibility omits its controls; a truncated or warned native observation, or a navigator the walk cannot read, keeps the control present. Present-waits are unchanged.
|
|
26
|
+
- Extension `metamask.perps.read_snapshot` states that its `history` section is the funds ledger from `perpsGetUserHistory` (deposits, withdrawals and transfers), not trades or orders. Zero history items with trade rows on screen is expected; read those rows with `read_visible_state` activity.
|
|
27
|
+
|
|
5
28
|
## 0.86.2 - 2026-10-09
|
|
6
29
|
|
|
7
30
|
### Fixed
|
|
@@ -15,6 +38,7 @@
|
|
|
15
38
|
|
|
16
39
|
### Fixed
|
|
17
40
|
|
|
41
|
+
- Mobile `metamask.perps.start_state` refuses `trading_state` combined with an explicit `positions`, `orders` or `strategies` request (anything but `false`). The policy sets all three itself, so the request was dropped without a word: `trading_state=preserve` with `positions: { state: "open", notional: 11, leverage: 2 }` passed with `positions: { skipped: true }` and opened nothing. A later isolated-margin prerequisite then read zero positions. The action now fails before unlock, naming the ignored keys. Omit `trading_state` to apply the request, or put the request in its own `start_state` node. Profile defaults are still replaced by the policy, and `false` is still accepted.
|
|
18
42
|
- Mobile route waits (`navigate` and `waitForRoute` in the action library) now end at the calling action's deadline. Before, once a Perps `start_state` or `teardown_state` ran out of budget, the bridge refused each new `get-route` with `CDP_TIMEOUT`. The waits took that refusal for a target transition and kept polling for up to 30 s more on their own 15 s windows. They could not see a route that did arrive, and failed with "did not start before its action deadline: get-route". On a physical Pixel 6a this showed up as `CDP_TIMEOUT` in clean-market setup and teardown, while one `get-route` took 0.1 to 0.5 s. Both now stop at the deadline. A route that never matched fails with the expected route, the last route read and the limit applied; a recovery cut short by the deadline fails with its last bridge error.
|
|
19
43
|
|
|
20
44
|
## 0.86.1 - 2026-10-09
|
|
@@ -24,6 +48,8 @@
|
|
|
24
48
|
- Extension `metamask.perps.close_visible_position cleanup_only=true` reconciles the final balance against the margin the close releases. With no placement receipt, the baseline is the available balance read while the position is still open. That baseline excludes the position's margin, so a clean full close went over the $1 tolerance and left an `outcome-unknown` receipt. A 0.11 SOL testnet close moved $524.42 to $536.45 and failed this way. The action now reads the close modal's "You'll receive" amount before dispatch and records it in the receipt as `closeReleaseBaseUnits`. It then checks the final balance against baseline + receive, with the same tolerance. If the amount is not visible, the action refuses before publishing a receipt or clicking. Closes backed by a placement receipt still reconcile against the pre-order balance. Cleanup output adds `releaseBaseUnits` and `expectedBalanceAfterBaseUnits`.
|
|
25
49
|
- Mobile `metamask.perps.read_visible_state` on iOS sees Perps Text test IDs. iOS accessibility lists React Native Text as a StaticText with no identifier, so a visible Lite market price (`perps-market-header-price`) failed `require_features: ["price"]`. On a non-opaque iOS run, `ui.visible` now reads each mounted `perps-` Text's test ID, string and size over the dev bridge. It gives that test ID to the on-screen StaticText with the same label and size, when exactly one test ID matches. Offscreen StaticText stays unbound, and so does a same-text twin of another size (the collapsed header price). Other Text, such as a revealed Secret Recovery Phrase word, is not read. This reaches every iOS `ui.visible` consumer: `ui.locators`, native-navigation test-ID waits, `readVisiblePerpsTestIds`, `configure_order`, and the assets and swap-bridge `read_visible_state`. Opaque runtimes and Android are unchanged. A non-opaque run with Metro down pays one bridge attempt (at most 2 s) per observation and gets a `ui.visible` warning; a read over 500 Texts is truncated with a warning.
|
|
26
50
|
- An empty `METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT` means the bundled `cdp-bridge.cjs` everywhere the TypeScript side spawns the Mobile bridge, as `run` and the action library already treated it. `launch`'s feature-flag reads and pins (and `doctor`'s) and the `status` live probe read the variable with `??`. An empty value made them check for a script named `""`, so they reported no runtime and never spawned a bridge. All TypeScript callers now share one `mobileBridgeScript()`; the action library keeps the same rule in its own `bridgeScript()` because it cannot import `src/`.
|
|
51
|
+
- Contributors: `tests/contract/run.sh` stopped by SIGTERM signals every running test at once, from one process listing, and its main loop no longer delays the trap by a `sleep`. A runner SIGKILLed 0.3 s after its SIGTERM used to leave most running tests (and their `timeout` parents) going until their own time limit, and `/tmp/contract-run.*` behind; now each test gets its SIGTERM and cleans up, and the directory goes once they have.
|
|
52
|
+
- Contributors: a unit run's teardown also stops what the processes naming its TMPDIR started (a wrapper's child need not name it), so it no longer orphans them. The `feature-guard-run-capture` timeout test times out only once bash has set its EXIT trap, instead of hoping bash starts within 3 s; it no longer fails on a loaded machine and runs in under a second.
|
|
27
53
|
|
|
28
54
|
## 0.86.0 - 2026-10-09
|
|
29
55
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const POLICY_KEYS = ["positions", "orders", "strategies"];
|
|
2
|
+
function isTemplate(value) {
|
|
3
|
+
return typeof value === "string" && value.includes("{{");
|
|
4
|
+
}
|
|
5
|
+
function mobileActionInputFindings(nodeId, node) {
|
|
6
|
+
const policy = node.trading_state;
|
|
7
|
+
if (node.action !== "metamask.perps.start_state" || typeof policy !== "string" || isTemplate(policy)) return [];
|
|
8
|
+
const dropped = POLICY_KEYS.filter((key) => node[key] !== void 0 && node[key] !== false && !isTemplate(node[key]));
|
|
9
|
+
if (dropped.length === 0) return [];
|
|
10
|
+
const keys = dropped.join(", ");
|
|
11
|
+
return [{
|
|
12
|
+
severity: "error",
|
|
13
|
+
code: "recipe.invalid_param",
|
|
14
|
+
path: `workflow.nodes.${nodeId}.trading_state`,
|
|
15
|
+
message: `metamask.perps.start_state trading_state=${policy} sets positions, orders and strategies itself, so the requested ${keys} would be ignored. Omit trading_state to apply ${keys}, or request ${keys} in a separate metamask.perps.start_state node.`
|
|
16
|
+
}];
|
|
17
|
+
}
|
|
18
|
+
export {
|
|
19
|
+
mobileActionInputFindings
|
|
20
|
+
};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { liveAdapterProcessTimeoutMs } from "@farmslot/recipe-cli/harness";
|
|
1
2
|
import { manifestPath } from "../../paths.js";
|
|
2
3
|
import { perpsOrderInputFindings } from "../perps-order-inputs.js";
|
|
4
|
+
import { mobileActionInputFindings } from "./action-inputs.js";
|
|
3
5
|
const NATIVE_PROVIDER_UI_ACTIONS = /* @__PURE__ */ new Set([
|
|
4
6
|
"ui.press",
|
|
5
7
|
"ui.key_press",
|
|
@@ -13,6 +15,8 @@ const NATIVE_PROVIDER_UI_ACTIONS = /* @__PURE__ */ new Set([
|
|
|
13
15
|
"ui.screenshot",
|
|
14
16
|
"ui.capture_surface"
|
|
15
17
|
]);
|
|
18
|
+
const MOBILE_SETUP_ACTIONS = /* @__PURE__ */ new Set(["metamask.perps.start_state", "metamask.perps.teardown_state"]);
|
|
19
|
+
const SETUP_EXIT_ALLOWANCE_MS = 5e3;
|
|
16
20
|
const mobileActions = {
|
|
17
21
|
manifestPath: () => manifestPath("mobile"),
|
|
18
22
|
semantic: [
|
|
@@ -49,9 +53,22 @@ const mobileActions = {
|
|
|
49
53
|
}),
|
|
50
54
|
wrap: async (adapters) => (await import("./runtime-identity.js")).withMobileNativeRuntimeIdentity(adapters),
|
|
51
55
|
lifecycle: async (adapters) => (await import("./lifecycle.js")).mobileSourceAwareLifecycleAdapters(adapters),
|
|
56
|
+
// A Perps setup resolves its own budget (device-class default unless the node
|
|
57
|
+
// sets timeout_ms). Its process must outlive that budget so the setup, not the
|
|
58
|
+
// process timeout, reports which phase ran out.
|
|
59
|
+
liveProcessNode: async (action, node, env) => {
|
|
60
|
+
if (!MOBILE_SETUP_ACTIONS.has(action) || node.live_adapter_timeout_ms != null) return node;
|
|
61
|
+
const { mobileSetupTimeoutMs } = await import("../../../library/actions/mobile/platform/bridge.mjs");
|
|
62
|
+
const needed = mobileSetupTimeoutMs({ node, context: { env } }) + SETUP_EXIT_ALLOWANCE_MS;
|
|
63
|
+
return needed > liveAdapterProcessTimeoutMs(node) ? { ...node, live_adapter_timeout_ms: needed } : node;
|
|
64
|
+
},
|
|
52
65
|
// ensure_positions and ensure_orders with an open or present state place an order.
|
|
53
|
-
inputFindings: (nodeId, node) =>
|
|
66
|
+
inputFindings: (nodeId, node) => [
|
|
67
|
+
...mobileActionInputFindings(nodeId, node),
|
|
68
|
+
...perpsOrderInputFindings(nodeId, node, { ensureChecksOnly: false })
|
|
69
|
+
]
|
|
54
70
|
};
|
|
55
71
|
export {
|
|
72
|
+
SETUP_EXIT_ALLOWANCE_MS,
|
|
56
73
|
mobileActions
|
|
57
74
|
};
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createAndroidVideoRecorder } from "@farmslot/adapter-rn";
|
|
5
|
+
import { optionalVideoTiming } from "@farmslot/recipe-runner";
|
|
6
|
+
import { interruptedRecorder, recordingFailure } from "./recording-outcome.js";
|
|
7
|
+
const SEGMENT_SECONDS = 180;
|
|
8
|
+
const START_TIMEOUT_MS = 5e3;
|
|
9
|
+
const STOP_TIMEOUT_MS = 1e4;
|
|
10
|
+
const START_GRACE_MS = 1e3;
|
|
11
|
+
const HOST_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
12
|
+
function createAndroidScreenrecordRecorder(serial, options = {}) {
|
|
13
|
+
const adb = (args) => spawnSync("adb", ["-s", serial, ...args], { encoding: "utf8", timeout: 6e4 });
|
|
14
|
+
return {
|
|
15
|
+
name: "adb-screenrecord",
|
|
16
|
+
platform: "android",
|
|
17
|
+
async doctor() {
|
|
18
|
+
const device = await createAndroidVideoRecorder(serial).doctor();
|
|
19
|
+
if (!device.ok) return device;
|
|
20
|
+
const ffmpeg = spawnSync(ffmpegPath(), ["-version"], { encoding: "utf8", timeout: 1e4 });
|
|
21
|
+
if (ffmpeg.status !== 0) {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
code: "ffmpeg_unavailable",
|
|
25
|
+
message: `ffmpeg, which joins Android screenrecord files, is unavailable: ${failure(ffmpeg)}`,
|
|
26
|
+
suggestedFix: "Install ffmpeg (brew install ffmpeg) or set FFMPEG_PATH."
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return { ok: true, code: "ok", message: `Android on-device screen recording is ready on ${serial}.` };
|
|
30
|
+
},
|
|
31
|
+
start: (request) => startRecording(serial, request, adb, options)
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
async function startRecording(serial, request, adb, options) {
|
|
35
|
+
if (request.target.kind !== "android-device" || request.target.serial !== serial) {
|
|
36
|
+
throw new Error("Android recording target does not match the selected device.");
|
|
37
|
+
}
|
|
38
|
+
const pollMs = options.pollMs ?? 100;
|
|
39
|
+
const host = options.host ?? process;
|
|
40
|
+
const running = screenrecordPids(adb);
|
|
41
|
+
if (running.length > 0) {
|
|
42
|
+
throw new Error(`Android screenrecord is already running on ${serial} (pid ${running.join(", ")}). Stop that recording before retrying.`);
|
|
43
|
+
}
|
|
44
|
+
const prefix = `${options.remoteDir ?? "/sdcard/Download"}/mm-harness-recording-${Date.now()}-${process.pid}`;
|
|
45
|
+
const segments = [];
|
|
46
|
+
let stopping = false;
|
|
47
|
+
let interruption;
|
|
48
|
+
let chain = Promise.resolve();
|
|
49
|
+
const localFiles = /* @__PURE__ */ new Set();
|
|
50
|
+
const segmentFailure = (segment) => `screenrecord on ${serial} stopped with exit ${segment.exitCode ?? "error"}: ${segment.output.trim() || "no recorder output"}`;
|
|
51
|
+
const startSegment = async () => {
|
|
52
|
+
const remotePath = `${prefix}-${segments.length}.mp4`;
|
|
53
|
+
const startedAtUnixMs = Date.now();
|
|
54
|
+
const child = spawn(
|
|
55
|
+
"adb",
|
|
56
|
+
["-s", serial, "shell", "screenrecord", "--time-limit", String(options.segmentSeconds ?? SEGMENT_SECONDS), remotePath],
|
|
57
|
+
{ stdio: ["ignore", "ignore", "pipe"] }
|
|
58
|
+
);
|
|
59
|
+
const exited = new Promise((resolve) => {
|
|
60
|
+
child.once("error", () => resolve(null));
|
|
61
|
+
child.once("exit", (code) => resolve(code));
|
|
62
|
+
});
|
|
63
|
+
const segment = { remotePath, child, output: "", startedAtUnixMs, exited };
|
|
64
|
+
child.stderr?.setEncoding("utf8");
|
|
65
|
+
child.stderr?.on("data", (chunk) => {
|
|
66
|
+
segment.output = (segment.output + chunk).slice(-2e3);
|
|
67
|
+
});
|
|
68
|
+
segments.push(segment);
|
|
69
|
+
void exited.then((code) => {
|
|
70
|
+
segment.stoppedAtUnixMs = Date.now();
|
|
71
|
+
segment.exitCode = code;
|
|
72
|
+
if (!segment.pid || segment.killed || interruption || stopping) return;
|
|
73
|
+
if (code !== 0) {
|
|
74
|
+
interruption = segmentFailure(segment);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
chain = startSegment().catch((error) => {
|
|
78
|
+
interruption ??= error instanceof Error ? error.message : String(error);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
const deadline = Date.now() + (options.startTimeoutMs ?? START_TIMEOUT_MS);
|
|
82
|
+
while (Date.now() < deadline) {
|
|
83
|
+
if (segment.stoppedAtUnixMs !== void 0) {
|
|
84
|
+
throw new Error(`Android screenrecord exited before capture started on ${serial}: ${segment.output.trim() || "no recorder output"}`);
|
|
85
|
+
}
|
|
86
|
+
const pid = screenrecordPids(adb)[0];
|
|
87
|
+
if (pid) {
|
|
88
|
+
segment.pid = pid;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
await delay(pollMs);
|
|
92
|
+
}
|
|
93
|
+
child.kill("SIGTERM");
|
|
94
|
+
throw new Error(`Android screenrecord did not start on ${serial} within ${options.startTimeoutMs ?? START_TIMEOUT_MS}ms.`);
|
|
95
|
+
};
|
|
96
|
+
const abandon = () => {
|
|
97
|
+
const live = segments.filter((segment) => segment.stoppedAtUnixMs === void 0);
|
|
98
|
+
for (const segment of live) segment.killed = true;
|
|
99
|
+
if (live.length > 0) {
|
|
100
|
+
const starting = live.some((segment) => !segment.pid);
|
|
101
|
+
const graceUntil = Date.now() + (starting ? START_GRACE_MS : 0);
|
|
102
|
+
const giveUpAt = Date.now() + (options.stopTimeoutMs ?? STOP_TIMEOUT_MS);
|
|
103
|
+
const signaled = /* @__PURE__ */ new Set();
|
|
104
|
+
while (Date.now() < giveUpAt) {
|
|
105
|
+
const pids = screenrecordPids(adb).filter((pid) => starting || live.some((segment) => segment.pid === pid));
|
|
106
|
+
if (pids.length === 0 && Date.now() >= graceUntil) break;
|
|
107
|
+
for (const pid of pids) {
|
|
108
|
+
if (!signaled.has(pid)) adb(["shell", "kill", "-2", pid]);
|
|
109
|
+
signaled.add(pid);
|
|
110
|
+
}
|
|
111
|
+
sleepSync(pollMs);
|
|
112
|
+
}
|
|
113
|
+
for (const segment of live) segment.child.kill("SIGTERM");
|
|
114
|
+
}
|
|
115
|
+
if (segments.length > 0) adb(["shell", "rm", "-f", ...segments.map((segment) => segment.remotePath)]);
|
|
116
|
+
for (const localPath of localFiles) fs.rmSync(localPath, { force: true });
|
|
117
|
+
};
|
|
118
|
+
const onExit = () => abandon();
|
|
119
|
+
const signalHandlers = Object.fromEntries(
|
|
120
|
+
HOST_SIGNALS.map((signal) => [
|
|
121
|
+
signal,
|
|
122
|
+
() => {
|
|
123
|
+
stopping = true;
|
|
124
|
+
abandon();
|
|
125
|
+
removeGuards();
|
|
126
|
+
if (host.listenerCount(signal) === 0) host.kill(host.pid, signal);
|
|
127
|
+
}
|
|
128
|
+
])
|
|
129
|
+
);
|
|
130
|
+
const removeGuards = () => {
|
|
131
|
+
host.removeListener("exit", onExit);
|
|
132
|
+
for (const signal of HOST_SIGNALS) host.removeListener(signal, signalHandlers[signal]);
|
|
133
|
+
};
|
|
134
|
+
host.once("exit", onExit);
|
|
135
|
+
for (const signal of HOST_SIGNALS) host.once(signal, signalHandlers[signal]);
|
|
136
|
+
try {
|
|
137
|
+
await startSegment();
|
|
138
|
+
} catch (error) {
|
|
139
|
+
stopping = true;
|
|
140
|
+
abandon();
|
|
141
|
+
removeGuards();
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
let stopped = false;
|
|
145
|
+
return {
|
|
146
|
+
async stop() {
|
|
147
|
+
if (stopped) throw new Error(`Android screen recording on ${serial} was already stopped.`);
|
|
148
|
+
stopped = true;
|
|
149
|
+
stopping = true;
|
|
150
|
+
const stagingDir = path.dirname(request.outputPath);
|
|
151
|
+
try {
|
|
152
|
+
await chain;
|
|
153
|
+
const tail = segments.at(-1);
|
|
154
|
+
if (tail.stoppedAtUnixMs === void 0 && tail.pid) adb(["shell", "kill", "-2", tail.pid]);
|
|
155
|
+
if (!await settles(tail.exited, options.stopTimeoutMs ?? STOP_TIMEOUT_MS)) {
|
|
156
|
+
tail.killed = true;
|
|
157
|
+
tail.child.kill("SIGKILL");
|
|
158
|
+
throw new Error(`Android screenrecord on ${serial} did not stop within ${options.stopTimeoutMs ?? STOP_TIMEOUT_MS}ms.`);
|
|
159
|
+
}
|
|
160
|
+
const kept = interruption ? segments.filter((segment) => segment.exitCode === 0) : tail.exitCode === 0 ? segments : segments.slice(0, -1);
|
|
161
|
+
if (kept.length === 0) {
|
|
162
|
+
throw recordingFailure(interruption ? "RECORDER_INTERRUPTED" : "RECORDING_FAILED", interruption ?? segmentFailure(tail));
|
|
163
|
+
}
|
|
164
|
+
fs.mkdirSync(stagingDir, { recursive: true });
|
|
165
|
+
const recorded = [];
|
|
166
|
+
for (const [index, segment] of kept.entries()) {
|
|
167
|
+
const localPath = path.join(stagingDir, `${path.basename(request.outputPath)}.segment-${index}.mp4`);
|
|
168
|
+
localFiles.add(localPath);
|
|
169
|
+
const pull = adb(["pull", segment.remotePath, localPath]);
|
|
170
|
+
if (pull.status !== 0) throw new Error(`Could not pull Android recording from ${serial}: ${failure(pull)}`);
|
|
171
|
+
const bytes = fs.statSync(localPath).size;
|
|
172
|
+
if (bytes > 0) recorded.push({ ...segment, localPath, bytes });
|
|
173
|
+
}
|
|
174
|
+
if (recorded.length === 0) throw recordingFailure("RECORDING_FAILED", `Android recording output is empty: ${request.outputPath}`);
|
|
175
|
+
const startedAtUnixMs = recorded[0].startedAtUnixMs;
|
|
176
|
+
const stoppedAtUnixMs = recorded.at(-1).stoppedAtUnixMs;
|
|
177
|
+
const placed = recorded.map((segment) => ({ ...segment, offsetMs: segment.startedAtUnixMs - startedAtUnixMs }));
|
|
178
|
+
joinSegments(placed, request.outputPath);
|
|
179
|
+
const timingEvidencePath = `${path.basename(request.outputPath)}.segments.json`;
|
|
180
|
+
fs.writeFileSync(
|
|
181
|
+
path.join(stagingDir, timingEvidencePath),
|
|
182
|
+
`${JSON.stringify({
|
|
183
|
+
recorder: "adb-screenrecord",
|
|
184
|
+
serial,
|
|
185
|
+
segments: placed.map((segment) => ({
|
|
186
|
+
offsetMs: segment.offsetMs,
|
|
187
|
+
startedAtUnixMs: segment.startedAtUnixMs,
|
|
188
|
+
stoppedAtUnixMs: segment.stoppedAtUnixMs,
|
|
189
|
+
bytes: segment.bytes
|
|
190
|
+
}))
|
|
191
|
+
})}
|
|
192
|
+
`
|
|
193
|
+
);
|
|
194
|
+
const timing = await optionalVideoTiming(request.outputPath, { startedAtUnixMs, stoppedAtUnixMs });
|
|
195
|
+
const recorder = { name: "adb-screenrecord", platform: "android", target: { selector: "adb-serial", value: serial } };
|
|
196
|
+
return {
|
|
197
|
+
...timing.timing ? { timing: { ...timing.timing, clock: { ...timing.timing.clock, latestZeroUnixMs: latestVideoZero(timing.timing, placed) } } } : timing,
|
|
198
|
+
timingEvidencePath,
|
|
199
|
+
recorder: interruption ? interruptedRecorder(
|
|
200
|
+
recorder,
|
|
201
|
+
interruption,
|
|
202
|
+
timing.timing ? { coveredMs: [0, timing.timing.framesMs.at(-1)] } : { coverageUnavailableReason: timing.timingUnavailableReason ?? "video timing unavailable" }
|
|
203
|
+
) : recorder
|
|
204
|
+
};
|
|
205
|
+
} finally {
|
|
206
|
+
abandon();
|
|
207
|
+
removeGuards();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function latestVideoZero(timing, segments) {
|
|
213
|
+
return segments.reduce((latest, segment, index) => {
|
|
214
|
+
const to = segments[index + 1]?.offsetMs ?? Infinity;
|
|
215
|
+
const last = timing.framesMs.filter((time) => time >= segment.offsetMs && time < to).at(-1);
|
|
216
|
+
return last === void 0 ? latest : Math.max(latest, segment.stoppedAtUnixMs - last);
|
|
217
|
+
}, timing.clock.latestZeroUnixMs);
|
|
218
|
+
}
|
|
219
|
+
function joinSegments(segments, outputPath) {
|
|
220
|
+
if (segments.length === 1) {
|
|
221
|
+
fs.renameSync(segments[0].localPath, outputPath);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const listPath = `${outputPath}.segments.txt`;
|
|
225
|
+
const list = segments.map((segment, index) => {
|
|
226
|
+
const entry = `file '${segment.localPath.replaceAll("'", "'\\''")}'`;
|
|
227
|
+
const next = segments[index + 1];
|
|
228
|
+
return next ? `${entry}
|
|
229
|
+
duration ${((next.offsetMs - segment.offsetMs) / 1e3).toFixed(3)}` : entry;
|
|
230
|
+
});
|
|
231
|
+
fs.writeFileSync(listPath, `${list.join("\n")}
|
|
232
|
+
`);
|
|
233
|
+
try {
|
|
234
|
+
const join = spawnSync(ffmpegPath(), ["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", listPath, "-c", "copy", outputPath], {
|
|
235
|
+
encoding: "utf8"
|
|
236
|
+
});
|
|
237
|
+
if (join.status !== 0) throw new Error(`Could not join Android recording segments: ${failure(join)}`);
|
|
238
|
+
} finally {
|
|
239
|
+
fs.rmSync(listPath, { force: true });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function ffmpegPath() {
|
|
243
|
+
return process.env.FFMPEG_PATH || "ffmpeg";
|
|
244
|
+
}
|
|
245
|
+
function screenrecordPids(adb) {
|
|
246
|
+
const result = adb(["shell", "pidof", "screenrecord"]);
|
|
247
|
+
if (result.status !== 0) return [];
|
|
248
|
+
return result.stdout.trim().split(/\s+/u).filter(Boolean);
|
|
249
|
+
}
|
|
250
|
+
function settles(promise, ms) {
|
|
251
|
+
return new Promise((resolve) => {
|
|
252
|
+
const timer = setTimeout(() => resolve(false), ms);
|
|
253
|
+
void promise.then(() => {
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
resolve(true);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
function failure(result) {
|
|
260
|
+
return result.error?.message || result.stderr?.trim() || result.stdout?.trim() || `exit ${result.status ?? "unknown"}`;
|
|
261
|
+
}
|
|
262
|
+
function sleepSync(ms) {
|
|
263
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
264
|
+
}
|
|
265
|
+
function delay(ms) {
|
|
266
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
267
|
+
}
|
|
268
|
+
export {
|
|
269
|
+
createAndroidScreenrecordRecorder
|
|
270
|
+
};
|
|
@@ -18,7 +18,8 @@ const mobileReadiness = {
|
|
|
18
18
|
orphanDevServers: detectOrphanMetros,
|
|
19
19
|
captureProviders: ["simctl", "adb"],
|
|
20
20
|
fixes: [{ id: "mobile-perps-env", apply: (target) => ensureMobilePerpsEnvironment(target).length > 0 }],
|
|
21
|
-
devices: async () => (await import("./devices.js")).mobileDevices
|
|
21
|
+
devices: async () => (await import("./devices.js")).mobileDevices,
|
|
22
|
+
recorder: async () => (await import("./run.js")).mobileRecorderCheck()
|
|
22
23
|
};
|
|
23
24
|
async function mobileRuntimeDecision(target, options) {
|
|
24
25
|
const watcherPortRaw = optionString(options, "watcherPort") ?? process.env.WATCHER_PORT ?? process.env.METRO_PORT;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { errorMessage, manifestTarget, readCaptureHelperTiming, RecipeExecutionError } from "@farmslot/recipe-runner";
|
|
4
|
+
function recordingFailure(code, cause) {
|
|
5
|
+
const reason = cause.trim().replace(/[\s.:]+$/u, "");
|
|
6
|
+
return new RecipeExecutionError("environment", `${code}: ${reason}. The run has no video.`, { code, details: { cause: reason } });
|
|
7
|
+
}
|
|
8
|
+
function interruptedRecorder(recorder, cause, coverage) {
|
|
9
|
+
const kept = "coveredMs" in coverage ? `${(coverage.coveredMs[0] / 1e3).toFixed(1)}-${(coverage.coveredMs[1] / 1e3).toFixed(1)} s` : `coverage unknown: ${coverage.coverageUnavailableReason}`;
|
|
10
|
+
process.stderr.write(`WARN: RECORDER_INTERRUPTED (${cause}): kept the partial video, ${kept}.
|
|
11
|
+
`);
|
|
12
|
+
const entry = { ...recorder, interrupted: { code: "RECORDER_INTERRUPTED", cause, ...coverage } };
|
|
13
|
+
return entry;
|
|
14
|
+
}
|
|
15
|
+
function streamInterruption(text) {
|
|
16
|
+
for (const line of text.split("\n")) {
|
|
17
|
+
if (!line.includes("stream_interrupted")) continue;
|
|
18
|
+
try {
|
|
19
|
+
const event = JSON.parse(line.slice(line.indexOf("{"), line.lastIndexOf("}") + 1));
|
|
20
|
+
if (event.type !== "error" || event.code !== "stream_interrupted") continue;
|
|
21
|
+
const mediaTimeMs = typeof event.media_time_ms === "number" && event.media_time_ms >= 0 ? event.media_time_ms : void 0;
|
|
22
|
+
return {
|
|
23
|
+
cause: `stream_interrupted: ${typeof event.cause === "string" ? event.cause : "capture stream stopped"}`,
|
|
24
|
+
...mediaTimeMs !== void 0 ? { mediaTimeMs } : {}
|
|
25
|
+
};
|
|
26
|
+
} catch {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return void 0;
|
|
31
|
+
}
|
|
32
|
+
function withRecordingOutcomes(recorder) {
|
|
33
|
+
return {
|
|
34
|
+
name: recorder.name,
|
|
35
|
+
get version() {
|
|
36
|
+
return recorder.version;
|
|
37
|
+
},
|
|
38
|
+
platform: recorder.platform,
|
|
39
|
+
// The runner asks this before start; a recorder that cannot record fails
|
|
40
|
+
// the run with a code, as a failed start does.
|
|
41
|
+
async doctor() {
|
|
42
|
+
let result;
|
|
43
|
+
try {
|
|
44
|
+
result = await recorder.doctor?.();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw recordingFailure("RECORDING_FAILED", `${recorder.name} readiness check failed: ${errorMessage(error)}`);
|
|
47
|
+
}
|
|
48
|
+
if (result && !result.ok) {
|
|
49
|
+
const next = result.suggestedFix ? ` Next: ${result.suggestedFix}` : "";
|
|
50
|
+
throw recordingFailure("RECORDING_FAILED", `${recorder.name} ${result.code}: ${result.message}${next}`);
|
|
51
|
+
}
|
|
52
|
+
return result ?? { ok: true, code: "ok", message: `${recorder.name} has no readiness check.` };
|
|
53
|
+
},
|
|
54
|
+
async start(request) {
|
|
55
|
+
let active;
|
|
56
|
+
try {
|
|
57
|
+
active = await recorder.start(request);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
throw coded(error) ?? recordingFailure("RECORDING_FAILED", errorMessage(error));
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
...active.snapshot ? { snapshot: (outputPath) => active.snapshot(outputPath) } : {},
|
|
63
|
+
async stop() {
|
|
64
|
+
try {
|
|
65
|
+
return await active.stop();
|
|
66
|
+
} catch (error) {
|
|
67
|
+
const known = coded(error);
|
|
68
|
+
if (known) throw known;
|
|
69
|
+
const interruption = streamInterruption(errorMessage(error));
|
|
70
|
+
if (!interruption) throw recordingFailure("RECORDING_FAILED", errorMessage(error));
|
|
71
|
+
if (!hasBytes(request.outputPath)) throw recordingFailure("RECORDER_INTERRUPTED", interruption.cause);
|
|
72
|
+
const timing = await readCaptureHelperTiming(request.outputPath);
|
|
73
|
+
return {
|
|
74
|
+
...timing,
|
|
75
|
+
...timing.timing ? { timingEvidencePath: `${path.basename(request.outputPath)}.timing.json` } : {},
|
|
76
|
+
recorder: interruptedRecorder(
|
|
77
|
+
{ name: "capture-helper", platform: recorder.platform, target: manifestTarget(request.target) },
|
|
78
|
+
interruption.cause,
|
|
79
|
+
interruption.mediaTimeMs !== void 0 ? { coveredMs: [0, interruption.mediaTimeMs] } : { coverageUnavailableReason: "capture-helper reported no media time" }
|
|
80
|
+
)
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function coded(error) {
|
|
89
|
+
return error instanceof RecipeExecutionError && typeof error.code === "string" ? error : void 0;
|
|
90
|
+
}
|
|
91
|
+
function hasBytes(file) {
|
|
92
|
+
try {
|
|
93
|
+
return fs.statSync(file).size > 0;
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export {
|
|
99
|
+
interruptedRecorder,
|
|
100
|
+
recordingFailure,
|
|
101
|
+
streamInterruption,
|
|
102
|
+
withRecordingOutcomes
|
|
103
|
+
};
|
|
@@ -9,7 +9,18 @@ function resolveMobileIosSimulatorName(env = process.env) {
|
|
|
9
9
|
const suffix = slot.includes("-") ? slot.slice(slot.lastIndexOf("-") + 1) : slot;
|
|
10
10
|
return suffix || void 0;
|
|
11
11
|
}
|
|
12
|
+
function resolveAndroidRecorderKind(serial, env = process.env) {
|
|
13
|
+
const forced = env.METAMASK_RECIPE_MOBILE_ANDROID_RECORDER?.trim().toLowerCase();
|
|
14
|
+
if (forced === "screenrecord" || forced === "mirror") return forced;
|
|
15
|
+
if (forced) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`METAMASK_RECIPE_MOBILE_ANDROID_RECORDER must be screenrecord or mirror, not "${forced}". Next: unset METAMASK_RECIPE_MOBILE_ANDROID_RECORDER`
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
return /^emulator-\d+$/u.test(serial) ? "mirror" : "screenrecord";
|
|
21
|
+
}
|
|
12
22
|
export {
|
|
13
23
|
resolveAndroidDeviceSerial,
|
|
24
|
+
resolveAndroidRecorderKind,
|
|
14
25
|
resolveMobileIosSimulatorName
|
|
15
26
|
};
|
|
@@ -5,6 +5,7 @@ import { IOS_SIMULATOR_RECORDING_TARGET_PREFIX } from "@farmslot/adapter-rn";
|
|
|
5
5
|
import { devicePlatformFromEnv } from "../performance/device-platform.js";
|
|
6
6
|
import {
|
|
7
7
|
resolveAndroidDeviceSerial,
|
|
8
|
+
resolveAndroidRecorderKind,
|
|
8
9
|
resolveMobileIosSimulatorName
|
|
9
10
|
} from "./recording-target.js";
|
|
10
11
|
import { emitProofFailure } from "../proof-failure.js";
|
|
@@ -94,20 +95,55 @@ async function ensureMobileProofRuntime(target, _heal, state, json, deps = {}) {
|
|
|
94
95
|
}
|
|
95
96
|
const mobileRecording = {
|
|
96
97
|
target: (context) => resolveMobileRecordingTarget(context),
|
|
97
|
-
// An Android device records through the mirror, with screenrecord as the
|
|
98
|
-
// fallback; otherwise the selected iOS simulator records.
|
|
99
98
|
async videoRecorder() {
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (androidSerial) {
|
|
105
|
-
const { createAndroidMirrorVideoRecorder } = await import("@farmslot/recipe-runner");
|
|
106
|
-
return createAndroidMirrorVideoRecorder({ serial: androidSerial, fallback: createAndroidVideoRecorder(androidSerial) });
|
|
107
|
-
}
|
|
108
|
-
return createIosSimulatorVideoRecorder(iosSimulator);
|
|
99
|
+
const selected = await selectMobileRecorder();
|
|
100
|
+
if (!selected) return void 0;
|
|
101
|
+
const { withRecordingOutcomes } = await import("./recording-outcome.js");
|
|
102
|
+
return withRecordingOutcomes(selected.recorder);
|
|
109
103
|
}
|
|
110
104
|
};
|
|
105
|
+
async function selectMobileRecorder() {
|
|
106
|
+
const androidSerial = resolveAndroidDeviceSerial();
|
|
107
|
+
const iosSimulator = androidSerial ? void 0 : resolveMobileIosSimulatorName();
|
|
108
|
+
if (!androidSerial && !iosSimulator) return void 0;
|
|
109
|
+
if (androidSerial && resolveAndroidRecorderKind(androidSerial) === "screenrecord") {
|
|
110
|
+
const { createAndroidScreenrecordRecorder } = await import("./android-screenrecord.js");
|
|
111
|
+
return { recorder: createAndroidScreenrecordRecorder(androidSerial), device: androidSerial };
|
|
112
|
+
}
|
|
113
|
+
const { createAndroidVideoRecorder, createIosSimulatorVideoRecorder } = await import("@farmslot/adapter-rn");
|
|
114
|
+
if (androidSerial) {
|
|
115
|
+
const { createAndroidMirrorVideoRecorder } = await import("@farmslot/recipe-runner");
|
|
116
|
+
const mirror = createAndroidMirrorVideoRecorder({ serial: androidSerial, fallback: createAndroidVideoRecorder(androidSerial) });
|
|
117
|
+
return { recorder: mirror, device: androidSerial };
|
|
118
|
+
}
|
|
119
|
+
return { recorder: createIosSimulatorVideoRecorder(iosSimulator), device: iosSimulator };
|
|
120
|
+
}
|
|
121
|
+
async function mobileRecorderCheck() {
|
|
122
|
+
let selected;
|
|
123
|
+
try {
|
|
124
|
+
selected = await selectMobileRecorder();
|
|
125
|
+
} catch (error) {
|
|
126
|
+
return {
|
|
127
|
+
id: "recorder",
|
|
128
|
+
status: "fail",
|
|
129
|
+
required: true,
|
|
130
|
+
message: "No recorder can be selected for this device.",
|
|
131
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
132
|
+
userAction: "unset METAMASK_RECIPE_MOBILE_ANDROID_RECORDER, then rerun mm-harness doctor"
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (!selected) return void 0;
|
|
136
|
+
const { recorder, device } = selected;
|
|
137
|
+
const doctor = await recorder.doctor?.() ?? { ok: true, code: "ok", message: `${recorder.name} has no readiness check.` };
|
|
138
|
+
return {
|
|
139
|
+
id: "recorder",
|
|
140
|
+
status: doctor.ok ? "pass" : "fail",
|
|
141
|
+
required: true,
|
|
142
|
+
message: doctor.ok ? `Recording on ${device} uses ${recorder.name}.` : `Recording on ${device} uses ${recorder.name}, which cannot record (${doctor.code}).`,
|
|
143
|
+
detail: doctor.message,
|
|
144
|
+
...doctor.ok ? {} : { userAction: doctor.suggestedFix ?? "fix the recorder named above, then rerun mm-harness doctor" }
|
|
145
|
+
};
|
|
146
|
+
}
|
|
111
147
|
async function resolveMobileRecordingTarget(context) {
|
|
112
148
|
const platform = (context.env.PLATFORM ?? process.env.PLATFORM ?? "ios").trim().toLowerCase();
|
|
113
149
|
const androidSerial = resolveAndroidDeviceSerial(context.env);
|
|
@@ -176,6 +212,7 @@ export {
|
|
|
176
212
|
ensureMobileProofRuntime,
|
|
177
213
|
mobileDiagnostics,
|
|
178
214
|
mobileObservation,
|
|
215
|
+
mobileRecorderCheck,
|
|
179
216
|
mobileRecording,
|
|
180
217
|
mobileRun
|
|
181
218
|
};
|