@deeeed/metamask-harness 0.67.0 → 0.68.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.
@@ -1,4 +1,8 @@
1
+ import { trackCheckoutChild } from "../checkout-lock.js";
2
+ import { recordCommandOutput, recordCommandStage } from "../command-journal.js";
1
3
  import { spawn, spawnSync } from "node:child_process";
4
+ import { StringDecoder } from "node:string_decoder";
5
+ import { OperationOutputTail } from "@farmslot/recipe-harness/runtime/operation";
2
6
  import path from "node:path";
3
7
  import { detectAdapter } from "../harness.js";
4
8
  import { colorHumanMessage } from "../cli-color.js";
@@ -81,6 +85,7 @@ function spawnScript(script, args, cwd, json, env) {
81
85
  return { status: 1, output: message };
82
86
  }
83
87
  const { bin: invokeBin, args: spawnArgs } = resolveLeafInvoke(bin, directArgs);
88
+ recordCommandStage(path.basename(isNodeScript ? args[0] : script));
84
89
  const result = spawnSync(invokeBin, spawnArgs, {
85
90
  cwd,
86
91
  encoding: "utf8",
@@ -97,6 +102,7 @@ function spawnScript(script, args, cwd, json, env) {
97
102
  return { status: 1, output: message };
98
103
  }
99
104
  const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
105
+ recordCommandOutput(output);
100
106
  if (!json && output) process.stderr.write(output);
101
107
  return { status: result.status ?? 1, output };
102
108
  }
@@ -122,7 +128,7 @@ function spawnInherit(script, args, cwd) {
122
128
  return result.signal === "SIGINT" || result.signal === "SIGTERM" ? 0 : 1;
123
129
  }
124
130
  function spawnScriptStreaming(script, args, cwd, options) {
125
- const spawnOptions = options && ("env" in options || "timeoutMs" in options) ? options : { env: options };
131
+ const spawnOptions = options && ("env" in options || "timeoutMs" in options || "forward" in options || "capture" in options || "stdin" in options || "stage" in options) ? options : { env: options };
126
132
  const isNodeScript = script === process.execPath && args.length > 0;
127
133
  const stem = isNodeScript ? path.basename(args[0]).replace(/[^A-Za-z0-9]/gu, "_").toUpperCase() : path.basename(script).replace(/[^A-Za-z0-9]/gu, "_").toUpperCase();
128
134
  const override = process.env[`MM_HARNESS_SCRIPT_BIN_${stem}`];
@@ -131,26 +137,41 @@ function spawnScriptStreaming(script, args, cwd, options) {
131
137
  if (shellLeafMissing(bin)) {
132
138
  const message = `leaf could not start: ${path.basename(bin)} (ENOENT)
133
139
  Next: reinstall mm-harness (npm i -g @deeeed/metamask-harness) \u2014 the shell leaf is missing or not executable`;
134
- process.stderr.write(`${message}
140
+ if (spawnOptions.forward !== "none") process.stderr.write(`${message}
135
141
  `);
136
142
  return Promise.resolve({ status: 1, output: message });
137
143
  }
138
144
  const { bin: invokeBin, args: spawnArgs } = resolveLeafInvoke(bin, directArgs);
139
- return new Promise((resolve) => {
140
- const ownsProcessGroup = process.platform !== "win32" && Number(spawnOptions.timeoutMs) > 0;
145
+ recordCommandStage(spawnOptions.stage ?? path.basename(isNodeScript ? args[0] : script));
146
+ return new Promise((resolve, reject) => {
147
+ const ownsProcessGroup = process.platform !== "win32";
141
148
  const child = spawn(invokeBin, spawnArgs, {
142
149
  cwd,
143
150
  env: spawnOptions.env ? { ...process.env, ...spawnOptions.env } : process.env,
144
- stdio: ["ignore", "pipe", "pipe"],
151
+ stdio: [spawnOptions.stdin ?? "ignore", "pipe", "pipe"],
145
152
  detached: ownsProcessGroup
146
153
  });
147
- let output = "";
154
+ let untrack;
155
+ try {
156
+ untrack = child.pid ? trackCheckoutChild(cwd, child.pid, ownsProcessGroup) : void 0;
157
+ } catch (error) {
158
+ child.once("error", reject);
159
+ child.once("close", () => reject(error));
160
+ try {
161
+ if (ownsProcessGroup && child.pid) process.kill(-child.pid, "SIGKILL");
162
+ else child.kill("SIGKILL");
163
+ } catch (killError) {
164
+ if (killError.code !== "ESRCH") reject(killError);
165
+ }
166
+ return;
167
+ }
168
+ const captured = new OperationOutputTail();
148
169
  let settled = false;
149
170
  let didTimeout = false;
150
171
  let escalation;
151
172
  let forwardedParentSignal;
152
173
  let parentSignalFallback;
153
- const parentSignals = ["SIGINT", "SIGTERM"];
174
+ const parentSignals = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"];
154
175
  const removeParentSignalHandlers = () => {
155
176
  for (const signal of parentSignals) process.removeListener(signal, parentSignalHandlers[signal]);
156
177
  };
@@ -182,7 +203,7 @@ function spawnScriptStreaming(script, args, cwd, options) {
182
203
  forwardedParentSignal = signal;
183
204
  signalChildTree(signal);
184
205
  removeParentSignalHandlers();
185
- parentSignalFallback = setTimeout(() => {
206
+ if (Number(spawnOptions.timeoutMs) > 0) parentSignalFallback = setTimeout(() => {
186
207
  signalChildTree("SIGKILL");
187
208
  exitFromForwardedSignal();
188
209
  }, 1e3);
@@ -194,31 +215,35 @@ function spawnScriptStreaming(script, args, cwd, options) {
194
215
  const timer = Number.isFinite(timeoutMs) && Number(timeoutMs) > 0 ? setTimeout(() => {
195
216
  didTimeout = true;
196
217
  const message = `leaf timed out after ${String(timeoutMs)}ms: ${path.basename(script)}`;
197
- output += `${output.endsWith("\n") || output.length === 0 ? "" : "\n"}${message}
198
- `;
199
- process.stderr.write(`${message}
218
+ captured.append(`
219
+ ${message}
220
+ `);
221
+ if (spawnOptions.forward !== "none") process.stderr.write(`${message}
200
222
  `);
201
223
  signalChildTree("SIGTERM");
202
224
  escalation = setTimeout(() => {
203
225
  signalChildTree("SIGKILL");
204
- finish({ status: 1, output, timedOut: true, timeoutMs: Number(timeoutMs) });
226
+ finish({ status: 1, output: captured.toString(), timedOut: true, timeoutMs: Number(timeoutMs) });
205
227
  }, 1e3);
206
228
  }, Number(timeoutMs)) : void 0;
207
- const tee = (chunk) => {
208
- const text = chunk.toString("utf8");
209
- output += text;
210
- process.stderr.write(text);
229
+ const decoders = [new StringDecoder("utf8"), new StringDecoder("utf8")];
230
+ const tee = (chunk, stdout = false) => {
231
+ if (spawnOptions.capture !== false) captured.append(decoders[stdout ? 0 : 1].write(chunk));
232
+ recordCommandOutput(chunk);
233
+ if (spawnOptions.forward !== "none") {
234
+ (stdout && spawnOptions.forward === "original" ? process.stdout : process.stderr).write(chunk);
235
+ }
211
236
  };
212
- child.stdout?.on("data", tee);
237
+ child.stdout?.on("data", (chunk) => tee(chunk, true));
213
238
  child.stderr?.on("data", tee);
214
239
  child.on("error", (error) => {
215
240
  const leaf = isNodeScript ? path.basename(args[0]) : path.basename(script);
216
241
  const code = error.code ?? "ESPAWN";
217
242
  const message = `leaf could not start: ${leaf} (${code})
218
243
  Next: reinstall mm-harness (npm i -g @deeeed/metamask-harness) \u2014 the shell leaf is missing or not executable`;
219
- process.stderr.write(`${message}
244
+ if (spawnOptions.forward !== "none") process.stderr.write(`${message}
220
245
  `);
221
- finish({ status: 1, output: message });
246
+ finish({ status: 1, output: message, error });
222
247
  });
223
248
  child.on("exit", () => {
224
249
  if (forwardedParentSignal) {
@@ -227,13 +252,15 @@ function spawnScriptStreaming(script, args, cwd, options) {
227
252
  }
228
253
  if (!didTimeout) return;
229
254
  signalChildTree("SIGKILL");
230
- finish({ status: 1, output, timedOut: true, timeoutMs: Number(timeoutMs) });
255
+ finish({ status: 1, output: captured.toString(), timedOut: true, timeoutMs: Number(timeoutMs) });
231
256
  });
232
257
  child.on("close", (status) => {
258
+ if (status !== null) untrack?.();
259
+ if (spawnOptions.capture !== false) captured.append(decoders.map((decoder) => decoder.end()).join(""));
233
260
  if (didTimeout) signalChildTree("SIGKILL");
234
261
  finish({
235
262
  status: didTimeout ? 1 : status ?? 1,
236
- output,
263
+ output: captured.toString(),
237
264
  ...didTimeout ? { timedOut: true, timeoutMs: Number(timeoutMs) } : {}
238
265
  });
239
266
  });
@@ -118,6 +118,7 @@ function renderTaskView(view, title, explicitTask = false) {
118
118
  last.ageMs !== void 0 && `${formatDuration(last.ageMs)} ago`
119
119
  ]));
120
120
  }
121
+ lines.push(...renderOperations(view));
121
122
  lines.push("");
122
123
  if (!view.activity.ongoing) {
123
124
  lines.push(out("warn", "no ongoing task detected"));
@@ -190,6 +191,12 @@ function changedLines(previous, next) {
190
191
  ];
191
192
  }
192
193
  const lines = [];
194
+ for (const operation of next.operations) {
195
+ const before2 = previous.operations.find((entry) => entry.id === operation.id);
196
+ if (!before2 || before2.status !== operation.status || before2.stage !== operation.stage || before2.ownerAlive !== operation.ownerAlive) {
197
+ lines.push(`operation ${operation.command} ${operation.status}${operation.stage ? ` \xB7 ${operation.stage}` : ""} \xB7 log ${operation.logPath}`);
198
+ }
199
+ }
193
200
  for (const step of next.prepare?.steps ?? []) {
194
201
  if (!previous.prepare?.steps.some((prior) => prior.id === step.id && prior.status === step.status)) {
195
202
  lines.push(`prepare ${step.id} ${step.status} (${formatDuration(step.durationMs)})`);
@@ -285,6 +292,22 @@ function shortUdid(udid) {
285
292
  function joinParts(parts, separator = " \xB7 ") {
286
293
  return parts.filter((part) => Boolean(part)).join(separator);
287
294
  }
295
+ function renderOperations(view) {
296
+ const active = view.operations.filter((operation) => operation.status === "running" && operation.ownerAlive);
297
+ const selected = active.length ? active : view.operations.slice(-1);
298
+ return selected.flatMap((operation) => {
299
+ const elapsed = Date.parse(operation.finishedAt ?? (/* @__PURE__ */ new Date()).toISOString()) - Date.parse(operation.startedAt);
300
+ const state = operation.status === "running" ? operation.ownerAlive ? "owner alive" : "owner exited; outcome unknown" : operation.status;
301
+ const age = operation.lastOutputAt ? formatDuration(Date.now() - Date.parse(operation.lastOutputAt)) + " ago" : "none recorded";
302
+ const quoted = "'" + operation.logPath.replace(/'/g, "'\\''") + "'";
303
+ return [
304
+ `${operation.parentId ? " " : ""}operation ${operation.command} \xB7 ${state} \xB7 ${formatDuration(elapsed)} elapsed`,
305
+ ...operation.stage ? [` stage ${operation.stage}`] : [],
306
+ ` last output ${age}`,
307
+ ` follow: tail -n 50 -F ${quoted}`
308
+ ];
309
+ });
310
+ }
288
311
  export {
289
312
  changedLines,
290
313
  formatDuration,
@@ -215,7 +215,7 @@ function gitSourceFingerprint(topLevel, pathspecs, head) {
215
215
  }
216
216
  function sourcePathspecs(git, excludedRoots, includedRoots) {
217
217
  const exclusions = excludedRoots.flatMap((root) => {
218
- const relative = path.relative(git.topLevel, path.resolve(root));
218
+ const relative = path.relative(git.topLevel, fs.existsSync(root) ? fs.realpathSync(root) : path.resolve(root));
219
219
  return relative !== ".." && !relative.startsWith(`..${path.sep}`) ? [`:(exclude)${relative}`] : [];
220
220
  });
221
221
  const inclusions = includedRoots.length > 0 ? includedRoots.map((root) => path.join(git.pathspec === "." ? "" : git.pathspec, root)) : [git.pathspec];
package/dist/harness.js CHANGED
@@ -1,7 +1,8 @@
1
- import { execFileSync, spawnSync } from "node:child_process";
1
+ import { spawnScriptStreaming } from "./commands/shared.js";
2
+ import { execFileSync } from "node:child_process";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
- import { resolveLeafInvoke, shellLeafMissing, missingShellLeafMessage } from "./leaf-invoke.js";
5
+ import { shellLeafMissing, missingShellLeafMessage } from "./leaf-invoke.js";
5
6
  import { resolveExtensionSlotPorts } from "./adapters/slot-ports.js";
6
7
  import { recipeHarnessPath, recipeRuntimeDir, runnerDir } from "./paths.js";
7
8
  import { colorHumanMessage } from "./cli-color.js";
@@ -367,15 +368,10 @@ async function handleHarness(argv) {
367
368
  }
368
369
  return 1;
369
370
  }
370
- const { bin: dispatchBin, args: dispatchArgs } = resolveLeafInvoke(dispatch.command, [
371
- ...dispatch.prefixArgs,
372
- ...forwardArgs
373
- ]);
374
- const result = spawnSync(dispatchBin, dispatchArgs, {
375
- // Default: stream child output verbatim (byte-identical to the skill path).
376
- // --json: route child output to our stderr so stdout carries only the summary.
377
- stdio: quiet ? "ignore" : json ? ["inherit", 2, "inherit"] : "inherit",
378
- env: process.env
371
+ const result = await spawnScriptStreaming(dispatch.command, [...dispatch.prefixArgs, ...forwardArgs], target, {
372
+ forward: quiet ? "none" : json ? "stderr" : "original",
373
+ stdin: quiet ? "ignore" : "inherit",
374
+ capture: false
379
375
  });
380
376
  if (result.error) {
381
377
  if (quiet) {
@@ -1,3 +1,4 @@
1
+ import { recordCommandStage } from "./command-journal.js";
1
2
  class JsonStreamWriter {
2
3
  enabled;
3
4
  command;
@@ -32,9 +33,11 @@ class JsonStreamWriter {
32
33
  }));
33
34
  }
34
35
  phase(phase, fields = {}) {
36
+ recordCommandStage(phase);
35
37
  this.emit("phase", { phase, ...fields });
36
38
  }
37
39
  node(nodeId, action, status) {
40
+ if (status === "running") recordCommandStage(`${nodeId}: ${action}`);
38
41
  this.emit("node", { nodeId, action, status });
39
42
  }
40
43
  mutation(mutation) {
@@ -1,3 +1,5 @@
1
+ import { recordCommandOutput } from "./command-journal.js";
2
+ import { trackCheckoutChild } from "./checkout-lock.js";
1
3
  import { createHash } from "node:crypto";
2
4
  import { access, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
3
5
  import { existsSync, readFileSync } from "node:fs";
@@ -95,6 +97,7 @@ function runProcess(command, args, options) {
95
97
  env: options.env,
96
98
  stdio: [options.stdin == null ? "ignore" : "pipe", "pipe", "pipe"]
97
99
  });
100
+ if (child.pid) trackCheckoutChild(options.cwd, child.pid);
98
101
  if (options.stdin != null) {
99
102
  child.stdin.on("error", (error) => {
100
103
  if (error.code === "EPIPE" || settled) return;
@@ -107,9 +110,11 @@ function runProcess(command, args, options) {
107
110
  let stderr = "";
108
111
  child.stdout.on("data", (chunk) => {
109
112
  stdout += chunk;
113
+ recordCommandOutput(chunk);
110
114
  });
111
115
  child.stderr.on("data", (chunk) => {
112
116
  stderr += chunk;
117
+ recordCommandOutput(chunk);
113
118
  });
114
119
  const timeout = options.timeoutMs ? setTimeout(() => {
115
120
  if (settled) return;
@@ -245,6 +245,8 @@ Example:
245
245
  Example:
246
246
  mm-harness checklist mark temp/tasks/recipe-cook/<task> start
247
247
  mm-harness checklist mark temp/tasks/recipe-cook/<task> 1
248
+ mm-harness checklist mark temp/tasks/recipe-cook/<task> sub start qa-cases --step 2 --from inputs/qa-cases.md
249
+ mm-harness checklist mark temp/tasks/recipe-cook/<task> sub qa-cases 1
248
250
  mm-harness checklist mark temp/tasks/recipe-cook/<task> complete --mark-last
249
251
  mm-harness checklist closeout temp/tasks/recipe-cook/<task> --share`
250
252
  },
@@ -506,7 +508,7 @@ Example:
506
508
  replace it, and an interrupted process remains recorded as verdict=running.
507
509
 
508
510
  --target <path> Checkout path (default: cwd)
509
- --runtime-dir <dir> Runtime dir containing last-command.json
511
+ --runtime-dir <dir> Runtime dir containing command operation records
510
512
  --json Machine-readable envelope
511
513
 
512
514
  Example:
package/dist/task-view.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { readOperations, processIdentity } from "@farmslot/recipe-harness/runtime/operation";
1
2
  import { spawnSync } from "node:child_process";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
@@ -31,7 +32,12 @@ function findTaskDir(target) {
31
32
  return latestTaskDir(path.join(target, "temp", "tasks"));
32
33
  }
33
34
  function collectTaskView(target, taskDir, now) {
35
+ target = fs.existsSync(target) ? fs.realpathSync(target) : path.resolve(target);
36
+ taskDir = taskDir && (fs.existsSync(taskDir) ? fs.realpathSync(taskDir) : path.resolve(taskDir));
37
+ const taskIsRunning = taskDir && readJsonObject(path.join(taskDir, WORKER_SIGNAL_FILE))?.status === "running";
38
+ const operations = readOperations(path.join(target, recipeRuntimeDir(), "operations")).filter((operation) => !taskIsRunning || path.resolve(operation.logPath).startsWith(path.resolve(taskDir, "artifacts", "operations") + path.sep)).map((operation) => ({ ...operation, ownerAlive: operation.status === "running" && processIdentity(operation.pid) === operation.processStartedAt }));
34
39
  const base = {
40
+ operations,
35
41
  checkout: readCheckout(target),
36
42
  isolation: readIsolation(target),
37
43
  libraries: parseLibraryPathEnv(process.env.RECIPE_LIBRARY_PATH).map(
@@ -77,7 +83,8 @@ function collectTaskView(target, taskDir, now) {
77
83
  parseTime(pickString(signal, "timestamp")),
78
84
  ...readMarkEvents(signal).map((event) => Date.parse(event.checkedAt)),
79
85
  commandCounts ? commandAt : void 0,
80
- prepare ? parseTime(prepare.updatedAt) : void 0
86
+ prepare ? parseTime(prepare.updatedAt) : void 0,
87
+ ...operations.flatMap((operation) => [parseTime(operation.lastOutputAt), parseTime(operation.stageStartedAt), parseTime(operation.startedAt)])
81
88
  ],
82
89
  branch: base.checkout.branch,
83
90
  ticket,
@@ -149,6 +149,43 @@ bash tests/contract/run.sh
149
149
  Runtime changes also require real affected-platform QA and a packed installation
150
150
  test. Update `CHANGELOG.md` under `Unreleased` for user-visible behavior.
151
151
 
152
+ When upgrading the protocol or runtime, align their transitive consumers before
153
+ pinning them here. Farmslot's `workspace:*` dependencies become exact versions
154
+ when packed, so `agent-runtime` and `expo-recipe` can retain older copies until
155
+ republished. Verify the complete install with
156
+ `bash tests/contract/packaging-dist-install.test.sh` before releasing the harness.
157
+
152
158
  Docs describe only current behavior. History belongs in the changelog and Git;
153
159
  do not add handovers, audits, speculative roadmaps, or duplicate command guides
154
160
  under `docs/`.
161
+
162
+ ## Long command observations
163
+
164
+ `mm-harness status --watch` shows command ownership, stage, elapsed time, last
165
+ output, and a `tail -n 50 -F` command for each active operation. Records and logs
166
+ live under `temp/recipe/runtime/operations/`. During a running task, records and
167
+ logs also appear under its `artifacts/operations/` directory for Command Center.
168
+
169
+ Use `mm-harness last --json` to resolve the current journal path. Each invocation
170
+ writes its own journal; new commands no longer write `last-command.json`. The
171
+ reader still accepts that legacy file when no operation records exist. A running
172
+ parent remains visible while its child or a rejected competing command finishes.
173
+
174
+ An owner process being alive, recent log output, and a stage change are separate
175
+ observations. None proves application readiness. Reading status or following logs
176
+ does not refresh activity or take the checkout mutation lock.
177
+
178
+ The checkout keeps the latest 100 completed invocation records. Task-local mirrors
179
+ remain with their task artifacts. Streaming leaves keep full output on disk;
180
+ commands that need captured output for classification keep a bounded tail.
181
+ `fixtures sync --json` streams progress to stderr while reserving stdout for JSON.
182
+ Interactive harness leaves inherit stdin; their stdout/stderr are piped for logs,
183
+ so child TTY formatting may differ from a direct launch.
184
+
185
+ Streaming leaves run in tracked process groups. Ordinary interrupt, termination,
186
+ hangup and quit signals are forwarded. Non-timed leaves may finish their cleanup
187
+ without a new forced-kill deadline. SIGKILL cannot be forwarded: if the caller's
188
+ group is forcibly killed, its detached build may survive and the checkout remains
189
+ reserved until that recorded group exits. Inspect the recorded group before
190
+ manual cleanup. Group-ID reuse can conservatively retain an old crash lock; the
191
+ lock's recovery hint describes what to inspect.
package/docs/QA.md CHANGED
@@ -105,6 +105,18 @@ mm-harness debug
105
105
 
106
106
  ## Mobile
107
107
 
108
+ Physical Android devices must be awake and unlocked. Launch wakes the selected
109
+ device, requests keyguard dismissal and checks the current user's lock state.
110
+ `MOBILE_DEVICE_LOCKED` requires a human unlock; `MOBILE_DEVICE_UI_UNAVAILABLE`
111
+ means the display or lock-state check failed. These prerequisites stop launch
112
+ without an automatic recovery restart.
113
+
114
+ For dedicated powered QA devices, configure stay-awake for the power sources
115
+ actually reported by Android. An ADB-connected phone can report AC power, so a
116
+ USB-only setting may still allow sleep. `adb -s <serial> shell svc power stayon true`
117
+ enables stay-awake on external power. Record the previous setting before changing
118
+ it, and restore it when returning a device to ordinary use.
119
+
108
120
  ```bash
109
121
  cd /path/to/metamask-mobile
110
122
  mm-harness provision runway ios
@@ -159,6 +159,18 @@ export async function navigateOpaqueMobile(input, destination) {
159
159
  }
160
160
  }
161
161
 
162
+ // Release builds may omit the mode toggle when Pro is disabled. The distinct
163
+ // visible market screen still proves its mode; a chooser or conflicting screen does not.
164
+ export function visiblePerpsModeScreen(ids, mode) {
165
+ const expectedScreen = mode === 'pro' ? IDS.proMarket : IDS.market;
166
+ const oppositeScreen = mode === 'pro' ? IDS.market : IDS.proMarket;
167
+ const expectedToggle = mode === 'pro' ? IDS.modeTogglePro : IDS.modeToggleLite;
168
+ const oppositeToggle = mode === 'pro' ? IDS.modeToggleLite : IDS.modeTogglePro;
169
+ if (ids.has(IDS.modeLite) || ids.has(IDS.modePro) || ids.has(oppositeScreen) || ids.has(oppositeToggle)) return undefined;
170
+ if (ids.has(expectedScreen)) return expectedScreen;
171
+ return ids.has(expectedToggle) ? expectedToggle : undefined;
172
+ }
173
+
162
174
  export async function ensureVisibleAndroidPerpsMode(input, perpsMode) {
163
175
  if (!['lite', 'pro'].includes(perpsMode)) {
164
176
  throw new Error('Opaque Mobile Perps mode must be lite or pro.');
@@ -218,7 +230,6 @@ export async function ensureVisibleAndroidPerpsMode(input, perpsMode) {
218
230
 
219
231
  try {
220
232
  const deadline = Date.now() + Number(input.node?.timeout_ms ?? 30_000);
221
- const expectedToggle = perpsMode === 'pro' ? IDS.modeTogglePro : IDS.modeToggleLite;
222
233
  const currentToggle = perpsMode === 'pro' ? IDS.modeToggleLite : IDS.modeTogglePro;
223
234
  const option = perpsMode === 'pro' ? IDS.modePro : IDS.modeLite;
224
235
  let expectedStreak = 0;
@@ -240,12 +251,13 @@ export async function ensureVisibleAndroidPerpsMode(input, perpsMode) {
240
251
  await new Promise((resolve) => setTimeout(resolve, 250));
241
252
  continue;
242
253
  }
243
- if (ids.has(expectedToggle)) {
254
+ const observedScreen = visiblePerpsModeScreen(ids, perpsMode);
255
+ if (observedScreen) {
244
256
  expectedStreak += 1;
245
257
  if (expectedStreak >= 2) {
246
258
  return {
247
259
  mode: perpsMode,
248
- observedScreen: expectedToggle,
260
+ observedScreen,
249
261
  provider: 'android-native-accessibility',
250
262
  proofPath: 'visible-native-navigation',
251
263
  };
@@ -294,7 +306,6 @@ export async function ensureVisibleIosPerpsMode(input, perpsMode) {
294
306
  if (!['lite', 'pro'].includes(perpsMode)) {
295
307
  throw new Error('iOS Perps mode must be lite or pro.');
296
308
  }
297
- const expectedToggle = perpsMode === 'pro' ? IDS.modeTogglePro : IDS.modeToggleLite;
298
309
  const currentToggle = perpsMode === 'pro' ? IDS.modeToggleLite : IDS.modeTogglePro;
299
310
  const option = perpsMode === 'pro' ? IDS.modePro : IDS.modeLite;
300
311
  const deadline = Date.now() + Number(input.node?.timeout_ms ?? 30_000);
@@ -324,12 +335,13 @@ export async function ensureVisibleIosPerpsMode(input, perpsMode) {
324
335
  const items = observed.observations?.['ui.visible']?.items ?? [];
325
336
  const ids = new Set(items.map((item) => String(item.test_id ?? '')));
326
337
  lastObserved = [...ids].filter(Boolean).slice(0, 20);
327
- if (ids.has(expectedToggle)) {
338
+ const observedScreen = visiblePerpsModeScreen(ids, perpsMode);
339
+ if (observedScreen) {
328
340
  expectedStreak += 1;
329
341
  if (expectedStreak >= 2) {
330
342
  return {
331
343
  mode: perpsMode,
332
- observedScreen: expectedToggle,
344
+ observedScreen,
333
345
  provider: 'ios-native-accessibility',
334
346
  proofPath: 'visible-native-navigation',
335
347
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.67.0",
3
+ "version": "0.68.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -20,11 +20,11 @@
20
20
  "check:syntax": "find . -name '*.mjs' -print0 | xargs -0 -n1 node --check"
21
21
  },
22
22
  "dependencies": {
23
- "@farmslot/agent-runtime": "0.15.0",
24
- "@farmslot/expo-recipe": "0.12.1",
23
+ "@farmslot/agent-runtime": "0.15.1",
24
+ "@farmslot/expo-recipe": "0.12.2",
25
25
  "@farmslot/handoff": "^0.3.1",
26
- "@farmslot/protocol": "0.32.0",
27
- "@farmslot/recipe-harness": "0.20.0",
26
+ "@farmslot/protocol": "0.33.0",
27
+ "@farmslot/recipe-harness": "0.21.1",
28
28
  "agent-device": "0.19.3",
29
29
  "commander": "^12.0.0",
30
30
  "es-module-lexer": "2.3.1",