@deeeed/metamask-harness 0.47.2 → 0.47.4

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,23 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.47.4 - 2026-09-05
6
+
7
+ ### Fixed
8
+
9
+ - Verify Mobile reloads through execution-context replacement when React Native retains its Hermes target ID, and advance broker runtime identity on context reset for lifecycle readiness checks.
10
+
11
+ ## 0.47.3 - 2026-09-05
12
+
13
+ ### Fixed
14
+
15
+ - Select the requested Mobile Perps mode through the first-entry chooser, verify its detail root, and restore the requested market after mode selection changes the navigation stack.
16
+ - Preserve earlier `call` evidence by assigning each invocation a fresh default artifact directory; approval retries still use the explicit directory in the printed next command.
17
+
18
+ ### Changed
19
+
20
+ - Teach authors to inspect existing state and limit setup mutations to prerequisites required by their proof.
21
+
5
22
  ## 0.47.2 - 2026-09-05
6
23
 
7
24
  ### Changed
@@ -195,6 +195,7 @@ const NESTED_ROUTE_PARENTS = {
195
195
  PerpsPnlHeroCard: 'Perps', PerpsHIP3Debug: 'Perps',
196
196
  PerpsSelectModifyAction: 'Perps', PerpsSelectAdjustMarginAction: 'Perps',
197
197
  PerpsSelectOrderType: 'Perps', PerpsTradingView: 'Perps',
198
+ PerpsModeSelection: 'PerpsModals',
198
199
  // Predict
199
200
  PredictMarketList: 'Predict', PredictMarketDetails: 'Predict',
200
201
  PredictActivityDetail: 'Predict',
@@ -862,6 +862,10 @@ function createCdpBroker({
862
862
  }
863
863
  },
864
864
  onCdpEvent(deviceId, method, params) {
865
+ if (method === 'Runtime.executionContextsCleared') {
866
+ const target = knownTargets.get(deviceId);
867
+ if (target) knownTargets.set(deviceId, { ...target, generation: target.generation + 1 });
868
+ }
865
869
  for (const socket of clients) {
866
870
  if (subscriptions.get(socket)?.has(`${deviceId}\u0000${method}`)) {
867
871
  writeMessage(socket, { type: 'event', deviceId, method, params });
@@ -1,12 +1,54 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawnSync } from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
4
6
  import { createRequire } from 'node:module';
5
7
  import { resolveMobileToolPath } from '../../library/actions/mobile/platform/tool-paths.mjs';
6
8
 
7
9
  const require = createRequire(import.meta.url);
8
10
  const WebSocket = require('ws');
9
- const { discoverTarget } = require('./bridge-runtime/lib/target-discovery.cjs');
11
+ const { discoverTarget, rankRuntimeCandidates } = require('./bridge-runtime/lib/target-discovery.cjs');
12
+ const { brokerSocketPath, createBrokerClient, deviceIdFromUrl } = require('./bridge-runtime/lib/cdp-broker.cjs');
13
+ const { createWSClient } = require('./bridge-runtime/lib/ws-client.cjs');
14
+
15
+ async function observeRuntimeReset(port, target) {
16
+ const socketPath = brokerSocketPath(
17
+ process.env.RECIPE_RUNTIME_DIR || path.join(process.cwd(), 'temp/recipe/runtime'),
18
+ port,
19
+ );
20
+ const client = fs.existsSync(socketPath)
21
+ ? await createBrokerClient(socketPath, deviceIdFromUrl(target.wsUrl), 5000)
22
+ : await createWSClient(target.wsUrl, 5000);
23
+ let armed = false;
24
+ let cleared = false;
25
+ let recreated = false;
26
+ client.on('Runtime.executionContextsCleared', () => {
27
+ if (armed) cleared = true;
28
+ });
29
+ client.on('Runtime.executionContextCreated', () => {
30
+ if (armed && cleared) recreated = true;
31
+ });
32
+ try {
33
+ await client.send('Runtime.enable');
34
+ // Finish subscription/bootstrap before the reload request can generate events.
35
+ await client.send('Runtime.evaluate', { expression: 'true', returnByValue: true });
36
+ armed = true;
37
+ } catch (error) {
38
+ client.close();
39
+ throw error;
40
+ }
41
+ return {
42
+ get recreated() { return recreated; },
43
+ async responsive(timeoutMs) {
44
+ const result = await client.send('Runtime.evaluate', {
45
+ expression: 'true', returnByValue: true,
46
+ }, timeoutMs);
47
+ return result?.result?.value === true;
48
+ },
49
+ close() { client.close(); },
50
+ };
51
+ }
10
52
 
11
53
  function parseArgs(argv) {
12
54
  const args = {
@@ -46,30 +88,38 @@ async function reload(port) {
46
88
  'The Android Hermes sampling profiler is active, so a runtime reload can abort the app. Use an explicit app.lifecycle restart.',
47
89
  );
48
90
  }
49
- await new Promise((resolve, reject) => {
50
- const socket = new WebSocket(`ws://127.0.0.1:${numericPort}/message`);
51
- const timeout = setTimeout(() => {
52
- socket.close();
53
- reject(new Error(`Metro reload timed out on port ${numericPort}`));
54
- }, 5000);
55
- socket.on('open', () => {
56
- socket.send(JSON.stringify({ method: 'reload', version: 2 }));
57
- setTimeout(() => {
58
- clearTimeout(timeout);
91
+ const observer = await observeRuntimeReset(numericPort, targetBefore);
92
+ let verification;
93
+ try {
94
+ await new Promise((resolve, reject) => {
95
+ const socket = new WebSocket(`ws://127.0.0.1:${numericPort}/message`);
96
+ const timeout = setTimeout(() => {
59
97
  socket.close();
60
- resolve();
61
- }, 50);
62
- });
63
- socket.on('error', () => {
64
- clearTimeout(timeout);
65
- reject(new Error(`Metro is not reachable on port ${numericPort}`));
98
+ reject(new Error(`Metro reload timed out on port ${numericPort}`));
99
+ }, 5000);
100
+ socket.on('open', () => {
101
+ socket.send(JSON.stringify({ method: 'reload', version: 2 }));
102
+ setTimeout(() => {
103
+ clearTimeout(timeout);
104
+ socket.close();
105
+ resolve();
106
+ }, 50);
107
+ });
108
+ socket.on('error', () => {
109
+ clearTimeout(timeout);
110
+ reject(new Error(`Metro is not reachable on port ${numericPort}`));
111
+ });
66
112
  });
67
- });
68
- const targetAfter = await waitForReload(
69
- numericPort,
70
- targetBefore.id,
71
- processBefore,
72
- );
113
+ verification = await waitForReload(
114
+ numericPort,
115
+ targetBefore,
116
+ processBefore,
117
+ observer,
118
+ );
119
+ } finally {
120
+ observer.close();
121
+ }
122
+ const { target: targetAfter, proof } = verification;
73
123
  const processAfter = processBefore ? readAndroidPid() : null;
74
124
  if (processBefore && processAfter !== processBefore) {
75
125
  throw new Error(
@@ -84,6 +134,7 @@ async function reload(port) {
84
134
  port: numericPort,
85
135
  targetBefore: targetBefore.id,
86
136
  targetAfter: targetAfter.id,
137
+ verification: proof,
87
138
  ...(processBefore
88
139
  ? {
89
140
  processBefore,
@@ -94,20 +145,33 @@ async function reload(port) {
94
145
  };
95
146
  }
96
147
 
97
- async function waitForReload(port, targetBefore, processBefore) {
148
+ async function waitForReload(port, targetBefore, processBefore, observer) {
98
149
  const deadline = Date.now() + 30_000;
150
+ let lastError;
99
151
  while (Date.now() < deadline) {
100
152
  if (processBefore && readAndroidPid() !== processBefore) {
101
153
  throw new Error(
102
154
  `Android app process ${processBefore} exited during reload. Inspect the native crash before using an explicit app.lifecycle restart.`,
103
155
  );
104
156
  }
105
- const target = await readHermesTarget(port).catch(() => null);
106
- if (target && target.id !== targetBefore) return target;
157
+ try {
158
+ const response = await fetch(`http://127.0.0.1:${port}/json/list`, {
159
+ signal: AbortSignal.timeout(Math.max(1, Math.min(1000, deadline - Date.now()))),
160
+ });
161
+ const targets = rankRuntimeCandidates(await response.json());
162
+ const target = targets.find((item) => item.deviceName === targetBefore.deviceName);
163
+ if (target && target.id !== targetBefore.id) return { target, proof: 'target-replaced' };
164
+ if (target && observer.recreated && await observer.responsive(Math.max(1, Math.min(1000, deadline - Date.now())))) {
165
+ return { target, proof: 'execution-context-recreated' };
166
+ }
167
+ } catch (error) {
168
+ // A reload temporarily removes the runtime; preserve the last failure if it never returns.
169
+ lastError = error;
170
+ }
107
171
  await new Promise((resolve) => setTimeout(resolve, 250));
108
172
  }
109
173
  throw new Error(
110
- `React Native did not expose a new Hermes target after reload on port ${port}`,
174
+ `React Native reload was not verified on port ${port}: no target replacement or responsive new execution context${lastError ? ` (${lastError.message})` : ''}.\nNext: mm-harness status --json`,
111
175
  );
112
176
  }
113
177
 
@@ -155,7 +219,7 @@ const args = parseArgs(process.argv.slice(2));
155
219
  reload(args.port)
156
220
  .then((result) => {
157
221
  if (args.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
158
- else console.log(`Reload requested through Metro :${result.port}`);
222
+ else console.log(`Reload verified through Metro :${result.port} (${result.verification})`);
159
223
  })
160
224
  .catch((error) => {
161
225
  const result = { ok: false, adapter: 'mobile', error: String(error?.message || error) };
@@ -1,5 +1,5 @@
1
1
  import fs from "node:fs";
2
- import { createHash } from "node:crypto";
2
+ import { randomUUID } from "node:crypto";
3
3
  import path from "node:path";
4
4
  import { resolveActionManifest } from "../manifest.js";
5
5
  import { importRecipeProtocol } from "../paths.js";
@@ -212,7 +212,7 @@ async function handleCall(argv) {
212
212
  }
213
213
  return EXIT.validation;
214
214
  }
215
- const artifactsDir = optionString(options, "artifactsDir") ?? defaultCallArtifactsDir(target, resolvedAction, args);
215
+ const artifactsDir = optionString(options, "artifactsDir") ?? defaultCallArtifactsDir(target, resolvedAction);
216
216
  recordCommandEvidence(artifactsDir);
217
217
  const requestedRuntimeOptions = runtimeOptionsFromCli(options);
218
218
  const inheritedSource = process.env.FARMSLOT_RECIPE_SOURCE_TRUST || process.env.FARMSLOT_RECIPE_SOURCE_KIND || process.env.FARMSLOT_RECIPE_SOURCE_NAME || process.env.FARMSLOT_RECIPE_SOURCE_DIGEST;
@@ -493,10 +493,9 @@ function renderDefaultsUsed(defaults, stream) {
493
493
  const values = Object.entries(defaults).map(([name, value]) => `${out("cmd", name)}=${out("accent", JSON.stringify(value) ?? String(value))}`).join(", ");
494
494
  return `${out("label", "Defaults used:")} ${values}`;
495
495
  }
496
- function defaultCallArtifactsDir(target, action, args) {
496
+ function defaultCallArtifactsDir(target, action) {
497
497
  const actionStem = action.replace(/[^a-zA-Z0-9._-]/gu, "_");
498
- const digest = createHash("sha256").update(JSON.stringify({ action, args })).digest("hex").slice(0, 12);
499
- return path.join(target, "temp", "recipe", "calls", `${actionStem}-${digest}`);
498
+ return path.join(target, "temp", "recipe", "calls", `${actionStem}-${randomUUID()}`);
500
499
  }
501
500
  function readCallOutput(tracePath) {
502
501
  try {
@@ -131,7 +131,7 @@ function withRemainingDeadline(input, requestedDeadline, attemptTimeoutMs) {
131
131
  };
132
132
  }
133
133
 
134
- async function isModePresent(input, mode, deadline) {
134
+ async function queryModeState(input, mode, deadline) {
135
135
  const rootTarget = JSON.stringify({
136
136
  testId: MODE_ROOT_TEST_IDS[mode],
137
137
  visibility: 'viewport',
@@ -140,6 +140,10 @@ async function isModePresent(input, mode, deadline) {
140
140
  testId: MODE_TEST_IDS[mode],
141
141
  visibility: 'tree',
142
142
  });
143
+ const choiceTarget = JSON.stringify({
144
+ testId: `perps-mode-selection-${mode}-option`,
145
+ visibility: 'viewport',
146
+ });
143
147
  const result = await evalAsync(
144
148
  withRemainingDeadline(input, deadline, READ_ATTEMPT_TIMEOUT_MS),
145
149
  `(function(){
@@ -149,21 +153,25 @@ async function isModePresent(input, mode, deadline) {
149
153
  }
150
154
  return Promise.all([
151
155
  api.queryUiTarget(${rootTarget}),
152
- api.queryUiTarget(${controlTarget})
156
+ api.queryUiTarget(${controlTarget}),
157
+ api.queryUiTarget(${choiceTarget})
153
158
  ]).then(function(values){
154
- return JSON.stringify({ root: values[0], control: values[1] });
159
+ return JSON.stringify({ root: values[0], control: values[1], choice: values[2] });
155
160
  });
156
161
  })()`,
157
162
  );
158
163
  if (result?.unsupported) {
159
164
  throw new Error('metamask.perps.ensure_mode requires UI target queries.');
160
165
  }
161
- return result?.root?.visible === true && result?.control?.present === true;
166
+ return {
167
+ present: result?.root?.visible === true && result?.control?.present === true,
168
+ choiceVisible: result?.choice?.visible === true,
169
+ };
162
170
  }
163
171
 
164
- async function readModePresence(input, mode, deadline) {
172
+ async function readModeState(input, mode, deadline) {
165
173
  try {
166
- return await isModePresent(input, mode, deadline);
174
+ return await queryModeState(input, mode, deadline);
167
175
  } catch (error) {
168
176
  if (!isTargetTransition(error)) throw error;
169
177
  return null;
@@ -181,14 +189,15 @@ export async function ensureMode(input) {
181
189
  Number(input.node?.timeout_ms ?? 30_000),
182
190
  );
183
191
  let switched = false;
192
+ let choiceSelected = false;
184
193
 
185
194
  while (Date.now() < deadline) {
186
- const requestedModePresent = await readModePresence(
195
+ const requestedState = await readModeState(
187
196
  input,
188
197
  requestedMode,
189
198
  deadline,
190
199
  );
191
- if (requestedModePresent) {
200
+ if (requestedState?.present && !requestedState.choiceVisible) {
192
201
  return {
193
202
  action: input.action,
194
203
  mode: requestedMode,
@@ -197,13 +206,23 @@ export async function ensureMode(input) {
197
206
  };
198
207
  }
199
208
  if (Date.now() >= deadline) break;
200
- if (requestedModePresent !== null && !switched) {
201
- const otherModePresent = await readModePresence(
209
+ if (requestedState?.choiceVisible && !choiceSelected) {
210
+ const pressed = await bridgeCommand(withRemainingDeadline(input, deadline), [
211
+ 'press-test-id',
212
+ `perps-mode-selection-${requestedMode}-option`,
213
+ ]);
214
+ if (pressed?.ok === false) {
215
+ throw new Error(String(pressed.error || 'Perps mode choice press failed.'));
216
+ }
217
+ choiceSelected = true;
218
+ switched = true;
219
+ } else if (requestedState !== null && !switched) {
220
+ const otherState = await readModeState(
202
221
  input,
203
222
  otherMode,
204
223
  deadline,
205
224
  );
206
- if (otherModePresent && Date.now() < deadline) {
225
+ if (otherState?.present && Date.now() < deadline) {
207
226
  // The visible control names the mode it will switch to, not the mode
208
227
  // currently selected.
209
228
  const pressed = await bridgeCommand(withRemainingDeadline(input, deadline), [
@@ -1818,7 +1837,7 @@ export async function startState(input) {
1818
1837
  const navigation = await applyStateNavigation(stateInput(), config);
1819
1838
  const provider = await ensureProvider(stateInput(), config);
1820
1839
  const network = await ensureNetwork(stateInput(), config);
1821
- const resolvedNavigation = provider.changed || network.changed
1840
+ let resolvedNavigation = provider.changed || network.changed
1822
1841
  ? await applyStateNavigation(stateInput(), config)
1823
1842
  : navigation;
1824
1843
  const modeInput = stateInput();
@@ -1833,6 +1852,9 @@ export async function startState(input) {
1833
1852
  },
1834
1853
  })
1835
1854
  : { skipped: true };
1855
+ if (marketMode.alreadySelected === false) {
1856
+ resolvedNavigation = await applyStateNavigation(stateInput(), config);
1857
+ }
1836
1858
  const tutorial = await applyTutorialState(stateInput(), config);
1837
1859
  const readyToTrade = await assertReadyToTrade(stateInput(), config);
1838
1860
  const balance = await assertBalance(stateInput(), config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.47.2",
3
+ "version": "0.47.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -52,6 +52,7 @@
52
52
  "instruction": "Use static call references for unchanged sub-journeys. Keep preparation, action, assertion, evidence, and guaranteed teardown visible as separate nodes with human-facing intent.",
53
53
  "details": [
54
54
  "Use idempotent ensure actions for required start state and independently assert their postconditions.",
55
+ "Inspect existing state before creating or cleaning fixtures. Establish only prerequisites required by the claim; a history or display check may already have usable records and need no trade or cleanup. Record the selected records and verify they still satisfy the proof boundary.",
55
56
  "Actions translate stable UI, CDP, or controller operations. Recipes compose them. Product code owns business rules."
56
57
  ],
57
58
  "commands": []