@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 +17 -0
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +1 -0
- package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +4 -0
- package/adapters/mobile/reload-app.mjs +92 -28
- package/dist/commands/call.js +4 -5
- package/library/actions/mobile/perps/perps.mjs +34 -12
- package/package.json +1 -1
- package/site/assets/help-recipes.json +1 -0
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
61
|
-
},
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
|
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
|
|
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) };
|
package/dist/commands/call.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
496
|
+
function defaultCallArtifactsDir(target, action) {
|
|
497
497
|
const actionStem = action.replace(/[^a-zA-Z0-9._-]/gu, "_");
|
|
498
|
-
|
|
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
|
|
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
|
|
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
|
|
172
|
+
async function readModeState(input, mode, deadline) {
|
|
165
173
|
try {
|
|
166
|
-
return await
|
|
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
|
|
195
|
+
const requestedState = await readModeState(
|
|
187
196
|
input,
|
|
188
197
|
requestedMode,
|
|
189
198
|
deadline,
|
|
190
199
|
);
|
|
191
|
-
if (
|
|
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 (
|
|
201
|
-
const
|
|
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 (
|
|
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
|
-
|
|
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
|
@@ -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": []
|