@deeeed/metamask-harness 0.92.5 → 0.92.7

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 CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.92.7 - 2026-10-15
6
+
7
+ ### Changed
8
+
9
+ - Adopt the shared ProjectRuntime, ActionCatalog and ActionHandler contracts and pin their coordinated public package versions.
10
+
11
+ ## 0.92.6 - 2026-10-15
12
+
13
+ ### Changed
14
+
15
+ - Pin adapter-web 0.10.2 and use the shared Android recorder and wallet HUD, preserving device selection, mirror fallback, recording failures and footer spacing. Remove the duplicate implementations and the unnecessary Android ffmpeg readiness check.
16
+
5
17
  ## 0.92.5 - 2026-10-15
6
18
 
7
19
  ### Fixed
@@ -111,11 +111,10 @@ async function selectMobileRecorder() {
111
111
  const androidSerial = resolveAndroidDeviceSerial();
112
112
  const iosSimulator = androidSerial ? void 0 : resolveMobileIosSimulatorName();
113
113
  if (!androidSerial && !iosSimulator) return void 0;
114
+ const { createAndroidVideoRecorder, createIosSimulatorVideoRecorder } = await import("@farmslot/adapter-rn");
114
115
  if (androidSerial && resolveAndroidRecorderKind(androidSerial) === "screenrecord") {
115
- const { createAndroidScreenrecordRecorder } = await import("./android-screenrecord.js");
116
- return { recorder: createAndroidScreenrecordRecorder(androidSerial), device: androidSerial };
116
+ return { recorder: createAndroidVideoRecorder(androidSerial), device: androidSerial };
117
117
  }
118
- const { createAndroidVideoRecorder, createIosSimulatorVideoRecorder } = await import("@farmslot/adapter-rn");
119
118
  if (androidSerial) {
120
119
  const { createAndroidMirrorVideoRecorder } = await import("@farmslot/recipe-runner");
121
120
  const mirror = createAndroidMirrorVideoRecorder({ serial: androidSerial, fallback: createAndroidVideoRecorder(androidSerial) });
@@ -9,7 +9,7 @@
9
9
  import { isAppUrl } from '@farmslot/adapter-web/origin';
10
10
  import { cdpListenerPids } from '@farmslot/adapter-web/web-dapp';
11
11
 
12
- import { readHudState, resolveSlowMo } from './adapter-web-lib.mjs';
12
+ import { hudRenderExpression, readHudState, resolveSlowMo } from './adapter-web-lib.mjs';
13
13
  import { appOrigin, cdpPort, readBrowserState, webDappRuntimeDir } from './runtime.mjs';
14
14
  import { importRecipeHarnessRuntimeCdp } from '../../harness-exports.mjs';
15
15
 
@@ -77,6 +77,14 @@ export async function selectAppTarget(input) {
77
77
  // step is drawn on the MetaMask page for the capture only, then removed. A
78
78
  // finished or absent state (--hud hide) draws nothing.
79
79
  class WalletSurfacePage extends CdpWebPage {
80
+ #origin;
81
+
82
+ constructor(session, targetUrl) {
83
+ super(session);
84
+ const target = new URL(targetUrl);
85
+ this.#origin = `${target.protocol}//${target.host}`;
86
+ }
87
+
80
88
  evaluate(expression) {
81
89
  return this.evaluateInIsolatedWorld(expression, 'web-dapp-wallet-surface');
82
90
  }
@@ -84,61 +92,16 @@ class WalletSurfacePage extends CdpWebPage {
84
92
  async screenshot(context, relPath, metadata = {}) {
85
93
  const state = context?.projectRoot ? readHudState(webDappRuntimeDir(context.projectRoot)) : null;
86
94
  const drawn = state?.current && !state.finished
87
- ? await this.evaluate(walletHudExpression(state)).then((result) => result?.hud === true, () => false)
95
+ ? await this.evaluate(hudRenderExpression(state, this.#origin, { layout: 'bottom-dock' })).then((result) => result?.hud === true, () => false)
88
96
  : false;
89
97
  try {
90
98
  return await super.screenshot(context, relPath, metadata);
91
99
  } finally {
92
- if (drawn) await this.evaluate(walletHudExpression(null)).catch(() => {});
100
+ if (drawn) await this.evaluate(hudRenderExpression(null, this.#origin, { layout: 'bottom-dock' })).catch(() => {});
93
101
  }
94
102
  }
95
103
  }
96
104
 
97
- // The wallet-page HUD: a bar docked to the bottom edge that reserves its own
98
- // height (a spacer after the content and the body's bottom padding), as the
99
- // Extension's HUD does on MetaMask pages, so the request and its footer
100
- // buttons move up instead of being covered. Inert, aria-hidden, pointer-events
101
- // none. null removes it and restores the body padding.
102
- export const WALLET_HUD_ID = 'mm-harness-wallet-hud';
103
-
104
- export function walletHudExpression(state) {
105
- const progress = state?.current?.progress;
106
- const view = state ? {
107
- badge: ['RUN', progress ? `${progress.current}/${progress.total}` : ''].filter(Boolean).join(' '),
108
- intent: String(state.current?.intent ?? ''),
109
- } : null;
110
- return `(() => {
111
- const view = ${JSON.stringify(view)};
112
- const id = ${JSON.stringify(WALLET_HUD_ID)};
113
- const old = document.getElementById(id);
114
- if (old) {
115
- document.body.style.paddingBottom = old.dataset.previousPadding || '';
116
- old.remove();
117
- document.getElementById(id + '-space')?.remove();
118
- }
119
- if (!view) return { hud: false, cleared: Boolean(old) };
120
- const bar = document.createElement('div');
121
- bar.id = id;
122
- bar.setAttribute('aria-hidden', 'true');
123
- bar.inert = true;
124
- bar.dataset.previousPadding = document.body.style.paddingBottom;
125
- Object.assign(bar.style, { position: 'fixed', left: '0', right: '0', bottom: '0', zIndex: '2147483647', pointerEvents: 'none', boxSizing: 'border-box', padding: '5px 8px', background: 'rgba(8, 10, 14, 0.9)', color: '#fff', font: '600 11px/1.3 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', borderTop: '1px solid rgba(255,255,255,0.14)' });
126
- const badge = document.createElement('span');
127
- Object.assign(badge.style, { color: '#8ab4ff', fontSize: '9px', fontWeight: '800', marginRight: '6px' });
128
- badge.textContent = view.badge;
129
- bar.append(badge, view.intent);
130
- document.body.append(bar);
131
- const height = Math.ceil(bar.getBoundingClientRect().height);
132
- const space = document.createElement('div');
133
- space.id = id + '-space';
134
- space.setAttribute('aria-hidden', 'true');
135
- Object.assign(space.style, { display: 'block', height: height + 'px', pointerEvents: 'none' });
136
- document.body.insertBefore(space, bar);
137
- document.body.style.paddingBottom = height + 'px';
138
- return { hud: true, docked: true, height };
139
- })()`;
140
- }
141
-
142
105
  // Number inputs ignore select(), so the generic setInput would append to a
143
106
  // prefilled value (the order form prefills the mid price). Clear the field with
144
107
  // the editor's selectAll command, as a user's Cmd+A would, type over the
@@ -282,7 +245,7 @@ export async function withAppPage(input, callback, deps = PAGE_DEPS) {
282
245
  await session.call('Page.enable');
283
246
  await session.call('Runtime.enable');
284
247
  if (focusBrowserRequested(deps.env)) await session.call('Page.bringToFront');
285
- const page = surface === 'wallet' ? new WalletSurfacePage(session) : new AppPage(session);
248
+ const page = surface === 'wallet' ? new WalletSurfacePage(session, target.url) : new AppPage(session);
286
249
  const result = await callback(page);
287
250
  return result && typeof result === 'object' && !Array.isArray(result)
288
251
  ? { ...result, surface, cdpPort: port, targetUrl: target.url.replace(/0x[0-9a-fA-F]{40}/gu, '<address>') }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.92.5",
3
+ "version": "0.92.7",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -21,15 +21,15 @@
21
21
  "check:syntax": "find . -name '*.mjs' -print0 | xargs -0 -n1 node --check"
22
22
  },
23
23
  "dependencies": {
24
- "@farmslot/adapter-node": "0.1.23",
25
- "@farmslot/adapter-rn": "0.22.9",
26
- "@farmslot/adapter-sdk": "0.14.0",
27
- "@farmslot/adapter-web": "0.10.1",
28
- "@farmslot/agent-runtime": "0.22.4",
24
+ "@farmslot/adapter-node": "0.1.26",
25
+ "@farmslot/adapter-rn": "0.22.13",
26
+ "@farmslot/adapter-sdk": "0.15.0",
27
+ "@farmslot/adapter-web": "0.10.5",
28
+ "@farmslot/agent-runtime": "0.23.3",
29
29
  "@farmslot/handoff": "^0.3.1",
30
- "@farmslot/protocol": "0.45.0",
31
- "@farmslot/recipe-cli": "0.18.0",
32
- "@farmslot/recipe-runner": "0.36.0",
30
+ "@farmslot/protocol": "0.47.0",
31
+ "@farmslot/recipe-cli": "0.18.4",
32
+ "@farmslot/recipe-runner": "0.37.0",
33
33
  "agent-device": "0.19.3",
34
34
  "commander": "^12.0.0",
35
35
  "esbuild": "0.28.1",
@@ -1,244 +0,0 @@
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 { 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 placed = recorded.map((segment) => ({ ...segment, offsetMs: segment.startedAtUnixMs - startedAtUnixMs }));
177
- const timingEvidencePath = `${path.basename(request.outputPath)}.segments.json`;
178
- fs.writeFileSync(path.join(stagingDir, timingEvidencePath), `${JSON.stringify({
179
- recorder: "adb-screenrecord",
180
- serial,
181
- segments: placed.map(({ offsetMs, startedAtUnixMs: startedAtUnixMs2, stoppedAtUnixMs, bytes }) => ({
182
- offsetMs,
183
- startedAtUnixMs: startedAtUnixMs2,
184
- stoppedAtUnixMs,
185
- bytes
186
- }))
187
- })}
188
- `);
189
- const recorder = { name: "adb-screenrecord", platform: "android", target: { selector: "adb-serial", value: serial } };
190
- const originals = await Promise.all(recorded.map(async (segment, index) => ({
191
- path: path.basename(segment.localPath),
192
- ...index > 0 ? { gapBefore: true } : {},
193
- ...await optionalVideoTiming(segment.localPath, {
194
- startedAtUnixMs: segment.startedAtUnixMs,
195
- stoppedAtUnixMs: segment.stoppedAtUnixMs
196
- }),
197
- timingEvidencePath,
198
- recorder
199
- })));
200
- for (const segment of recorded) localFiles.delete(segment.localPath);
201
- return {
202
- segments: originals,
203
- ...interruption ? { interruption: {
204
- frames: originals.reduce((sum, segment) => sum + (segment.timing?.framesMs.length ?? 0), 0),
205
- mediaTimeMs: placed.at(-1).offsetMs + (originals.at(-1).timing?.framesMs.at(-1) ?? 0),
206
- cause: interruption
207
- } } : {}
208
- };
209
- } finally {
210
- abandon();
211
- removeGuards();
212
- }
213
- }
214
- };
215
- }
216
- function ffmpegPath() {
217
- return process.env.FFMPEG_PATH || "ffmpeg";
218
- }
219
- function screenrecordPids(adb) {
220
- const result = adb(["shell", "pidof", "screenrecord"]);
221
- if (result.status !== 0) return [];
222
- return result.stdout.trim().split(/\s+/u).filter(Boolean);
223
- }
224
- function settles(promise, ms) {
225
- return new Promise((resolve) => {
226
- const timer = setTimeout(() => resolve(false), ms);
227
- void promise.then(() => {
228
- clearTimeout(timer);
229
- resolve(true);
230
- });
231
- });
232
- }
233
- function failure(result) {
234
- return result.error?.message || result.stderr?.trim() || result.stdout?.trim() || `exit ${result.status ?? "unknown"}`;
235
- }
236
- function sleepSync(ms) {
237
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
238
- }
239
- function delay(ms) {
240
- return new Promise((resolve) => setTimeout(resolve, ms));
241
- }
242
- export {
243
- createAndroidScreenrecordRecorder
244
- };