@deeeed/metamask-harness 0.14.4 → 0.14.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 +24 -1
- package/adapters/extension/ensure-browser.sh +20 -4
- package/adapters/extension/inject.mjs +1 -0
- package/adapters/extension/lib/slot-title.cjs +294 -0
- package/adapters/extension/readiness.mjs +7 -28
- package/adapters/extension/reattach.sh +13 -15
- package/dist/adapters/extension/ensure-ready.js +19 -0
- package/dist/adapters/mobile/surface.js +2 -1
- package/dist/cli-commands.js +1 -1
- package/dist/commands/doctor.js +47 -7
- package/dist/commands/farmslot-ready.js +25 -0
- package/dist/commands/parse-args.js +1 -0
- package/dist/mm-harness-cli.js +4 -3
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.14.7 - 2026-07-10
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- `doctor --print-ready` now implies the exit-coded live probe — Farmslot `health_check` hooks need only `--print-ready`, not `--expect-live --print-ready`.
|
|
7
|
+
- Mobile `--print-ready` uses bridge `walletState` (unlocked + live bridge), not React Navigation route names; mobile `ready_indicator` is `OK`.
|
|
8
|
+
- Removed `metamask-recipe` install symlinks from adapter inject paths (no compat aliases).
|
|
9
|
+
- `doctor --print-ready` rejects `--json` (stdout is reserved for the Farmslot indicator line).
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- Mobile `surface.runtimeStatus` infers Android from `ADB_SERIAL` / `ANDROID_SERIAL` when present.
|
|
13
|
+
|
|
14
|
+
## 0.14.6 - 2026-07-10
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- `mm-harness doctor --print-ready` — Farmslot `health_check` mode: prints `health.ready_indicator` on stdout (`extension`/`mobile`: `OK`; `core`: `ready`).
|
|
18
|
+
- `doctor --expect-live` — exit-coded liveness gate without indicator output (prepare recovery and other pass/fail callers).
|
|
19
|
+
|
|
20
|
+
## 0.14.5 - 2026-07-10
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- Extension `ensure-browser` closes disposable New Tab / blank / extensions pages after reopen, matching reattach/ensure-ready hygiene.
|
|
24
|
+
- Extension slot browser titles are stamped through one shared helper (`adapters/extension/lib/slot-title.cjs`) with a persistent `MutationObserver`, and re-applied after `ensureExtensionReady` open/prune/reopen so relaunch and recipe paths keep `<slot-id> — MetaMask` instead of a bare MetaMask window.
|
|
25
|
+
- Extension slot-title contract test is hermetic (local CDP/WebSocket stub + fake DOM); Playwright and CDP stamps share one function source so they cannot drift.
|
|
26
|
+
- Extension inject copies `scripts/lib/slot-title.cjs` with the other installed helpers so reattach/readiness/reopen do not `MODULE_NOT_FOUND` on injected checkouts.
|
|
4
27
|
|
|
5
28
|
## 0.14.4 - 2026-07-09
|
|
6
29
|
|
|
@@ -358,8 +358,9 @@ const resumeWebpack = () => {
|
|
|
358
358
|
const page = ctx.pages().find(p => p.url().includes('chrome-extension://')) || ctx.pages()[0];
|
|
359
359
|
await page.goto(homeUrl, { waitUntil: 'load', timeout: 15000 });
|
|
360
360
|
|
|
361
|
-
// Set window title to slot ID
|
|
362
|
-
|
|
361
|
+
// Set window title to slot ID (persists across SPA navigations)
|
|
362
|
+
const { applyPersistentSlotTitle } = require(path.join('${SCRIPT_DIR}', 'lib/slot-title.cjs'));
|
|
363
|
+
await page.evaluate(applyPersistentSlotTitle, SLOT_ID);
|
|
363
364
|
|
|
364
365
|
// Wait for runtime state to settle, then unlock if needed.
|
|
365
366
|
const unlockSelector = '[data-testid=\"unlock-password\"]';
|
|
@@ -393,10 +394,25 @@ const resumeWebpack = () => {
|
|
|
393
394
|
console.log('[reopen] Screen: ' + page.url());
|
|
394
395
|
}
|
|
395
396
|
|
|
396
|
-
// Close extra extension tabs
|
|
397
|
+
// Close extra extension tabs and disposable Chrome pages (New Tab / blank).
|
|
397
398
|
for (const p of ctx.pages()) {
|
|
398
|
-
if (p
|
|
399
|
+
if (p === page) continue;
|
|
400
|
+
const url = p.url();
|
|
401
|
+
if (url.includes('chrome-extension://')) {
|
|
402
|
+
await p.close().catch(() => {});
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (
|
|
406
|
+
url === 'chrome://newtab/' ||
|
|
407
|
+
url === 'chrome://new-tab-page/' ||
|
|
408
|
+
url.startsWith('chrome://new-tab-page') ||
|
|
409
|
+
url === 'about:blank' ||
|
|
410
|
+
url === 'chrome://extensions/'
|
|
411
|
+
) {
|
|
412
|
+
await p.close().catch(() => {});
|
|
413
|
+
}
|
|
399
414
|
}
|
|
415
|
+
await page.bringToFront().catch(() => {});
|
|
400
416
|
|
|
401
417
|
fs.writeFileSync(path.join(AGENT_DIR, 'extension.id'), extId);
|
|
402
418
|
console.log('[reopen] Ready \u2014 ' + SLOT_ID + (CDP_PORT ? ' CDP:' + CDP_PORT : ''));
|
|
@@ -130,6 +130,7 @@ copyFile(path.join(runnerDir, 'adapters/shared/tmux-session.sh'), path.join(harn
|
|
|
130
130
|
copyFile(path.join(runnerDir, 'adapters/shared/tmux-viewer.sh'), path.join(harnessDir, 'scripts/lib/tmux-viewer.sh'));
|
|
131
131
|
copyFile(path.join(runnerDir, 'adapters/shared/log-tui.mjs'), path.join(harnessDir, 'scripts/lib/log-tui.mjs'));
|
|
132
132
|
copyFile(path.join(runnerDir, 'adapters/extension/lib/extension-id.cjs'), path.join(harnessDir, 'scripts/lib/extension-id.cjs'));
|
|
133
|
+
copyFile(path.join(runnerDir, 'adapters/extension/lib/slot-title.cjs'), path.join(harnessDir, 'scripts/lib/slot-title.cjs'));
|
|
133
134
|
makeExecutableTree(path.join(harnessDir, 'scripts'));
|
|
134
135
|
fs.writeFileSync(path.join(harnessDir, 'installed-scripts.sha256'), `${dirContentHash(path.join(harnessDir, 'scripts'))}\n`);
|
|
135
136
|
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Operator slot title for headed extension Chrome.
|
|
5
|
+
*
|
|
6
|
+
* Chrome's window label is the active tab's document.title. Farmslot stamps
|
|
7
|
+
* home.html to "<slot-id> — MetaMask" so operators can tell which slot owns a
|
|
8
|
+
* window when several farms run side by side.
|
|
9
|
+
*
|
|
10
|
+
* Stamping is best-effort: missing slot context or an uninspectable tab must
|
|
11
|
+
* never fail launch/readiness. A MutationObserver keeps the prefix across
|
|
12
|
+
* MetaMask SPA title resets.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const http = require('node:http');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
|
|
19
|
+
function sanitizeSlotId(value) {
|
|
20
|
+
const slotId = typeof value === 'string' ? value.trim() : '';
|
|
21
|
+
return /^[A-Za-z0-9._:-]{1,64}$/u.test(slotId) ? slotId : '';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readSlotId(target, runtimeDir) {
|
|
25
|
+
for (const key of ['RECIPE_SLOT_ID', 'SLOT_ID', 'FARMSLOT_SLOT_ID']) {
|
|
26
|
+
const value = sanitizeSlotId(process.env[key]);
|
|
27
|
+
if (value) return value;
|
|
28
|
+
}
|
|
29
|
+
if (!target || !runtimeDir) return '';
|
|
30
|
+
try {
|
|
31
|
+
const runtimeContext = JSON.parse(
|
|
32
|
+
fs.readFileSync(path.join(target, runtimeDir, 'agentic-runtime.json'), 'utf8'),
|
|
33
|
+
);
|
|
34
|
+
return sanitizeSlotId(runtimeContext.slotId);
|
|
35
|
+
} catch {
|
|
36
|
+
// Standalone harness users do not need a custom browser title.
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Playwright page.evaluate callback. Must stay a plain function (no closure)
|
|
43
|
+
* so Playwright can serialize it into the page.
|
|
44
|
+
*/
|
|
45
|
+
function applyPersistentSlotTitle(slot) {
|
|
46
|
+
// Inline sanitize — this function is serialized into the page for Playwright
|
|
47
|
+
// and CDP, so it cannot call Node helpers.
|
|
48
|
+
const id =
|
|
49
|
+
typeof slot === 'string' && /^[A-Za-z0-9._:-]{1,64}$/u.test(slot.trim())
|
|
50
|
+
? slot.trim()
|
|
51
|
+
: '';
|
|
52
|
+
if (!id) return document.title;
|
|
53
|
+
window.__farmslotSlotId = id;
|
|
54
|
+
|
|
55
|
+
const stripSlotPrefixes = (title) => {
|
|
56
|
+
let base = String(title || 'MetaMask');
|
|
57
|
+
// Accept em dash, en dash, or hyphen — historical stampers mixed them.
|
|
58
|
+
for (let i = 0; i < 32; i += 1) {
|
|
59
|
+
const stripped = base.replace(/^.+?\s+[—–-]\s+/u, '');
|
|
60
|
+
if (stripped === base) break;
|
|
61
|
+
base = stripped;
|
|
62
|
+
}
|
|
63
|
+
return base || 'MetaMask';
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const desiredTitle = () => {
|
|
67
|
+
const id = window.__farmslotSlotId;
|
|
68
|
+
if (!id) return document.title;
|
|
69
|
+
return `${id} — ${stripSlotPrefixes(document.title)}`;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const setTitle = () => {
|
|
73
|
+
if (window.__farmslotSlotTitleLock) {
|
|
74
|
+
window.__farmslotSlotTitlePending = true;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const next = desiredTitle();
|
|
78
|
+
if (document.title === next) return;
|
|
79
|
+
window.__farmslotSlotTitleLock = true;
|
|
80
|
+
try {
|
|
81
|
+
document.title = next;
|
|
82
|
+
} finally {
|
|
83
|
+
// Release after the title mutation is delivered so the observer cannot
|
|
84
|
+
// re-enter and stack prefixes while React also mutates the DOM. If a
|
|
85
|
+
// mutation arrived while locked, re-apply once after unlock.
|
|
86
|
+
queueMicrotask(() => {
|
|
87
|
+
window.__farmslotSlotTitleLock = false;
|
|
88
|
+
if (window.__farmslotSlotTitlePending) {
|
|
89
|
+
window.__farmslotSlotTitlePending = false;
|
|
90
|
+
setTitle();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
setTitle();
|
|
97
|
+
if (!window.__farmslotSlotTitleObserver) {
|
|
98
|
+
let node = document.querySelector('title');
|
|
99
|
+
if (!node) {
|
|
100
|
+
node = document.createElement('title');
|
|
101
|
+
(document.head || document.documentElement).appendChild(node);
|
|
102
|
+
}
|
|
103
|
+
window.__farmslotSlotTitleObserver = new MutationObserver(setTitle);
|
|
104
|
+
// Observe only the <title> element — watching head/documentElement with
|
|
105
|
+
// subtree:true re-fires on every React DOM mutation and can livelock CDP.
|
|
106
|
+
window.__farmslotSlotTitleObserver.observe(node, {
|
|
107
|
+
childList: true,
|
|
108
|
+
characterData: true,
|
|
109
|
+
subtree: true,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return document.title;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildStampExpression(slotId) {
|
|
116
|
+
const slot = sanitizeSlotId(slotId);
|
|
117
|
+
if (!slot) return 'document.title';
|
|
118
|
+
// Single source of truth: serialize the Playwright callback for CDP evaluate.
|
|
119
|
+
return `(${applyPersistentSlotTitle.toString()})(${JSON.stringify(slot)})`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function assertLocalUrl(url) {
|
|
123
|
+
if (!/^http:\/\/127\.0\.0\.1:\d+\//u.test(url)) {
|
|
124
|
+
throw new Error(`slot-title: refusing non-local URL: ${url}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function assertLocalWebSocketUrl(url) {
|
|
129
|
+
if (!/^ws:\/\/(?:127\.0\.0\.1|localhost):\d+\//u.test(String(url))) {
|
|
130
|
+
throw new Error(`slot-title: refusing non-local websocket URL: ${url}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function httpJson(url, timeoutMs = 3000) {
|
|
135
|
+
assertLocalUrl(url);
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
const req = http.get(url, (res) => {
|
|
138
|
+
let data = '';
|
|
139
|
+
res.setEncoding('utf8');
|
|
140
|
+
res.on('data', (chunk) => {
|
|
141
|
+
data += chunk;
|
|
142
|
+
});
|
|
143
|
+
res.on('end', () => {
|
|
144
|
+
try {
|
|
145
|
+
resolve(JSON.parse(data));
|
|
146
|
+
} catch (err) {
|
|
147
|
+
reject(new Error(`invalid JSON from ${url}: ${err.message}`));
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
req.setTimeout(timeoutMs, () => {
|
|
152
|
+
req.destroy(new Error(`timeout from ${url}`));
|
|
153
|
+
});
|
|
154
|
+
req.on('error', reject);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function resolveWebSocket(target) {
|
|
159
|
+
try {
|
|
160
|
+
// Prefer the target checkout's ws, then the harness install.
|
|
161
|
+
const { createRequire } = require('node:module');
|
|
162
|
+
const req = createRequire(__filename);
|
|
163
|
+
if (target) {
|
|
164
|
+
try {
|
|
165
|
+
return req(req.resolve('ws', { paths: [target] }));
|
|
166
|
+
} catch {
|
|
167
|
+
// Fall through to harness / global.
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return req('ws');
|
|
171
|
+
} catch {
|
|
172
|
+
return typeof WebSocket === 'function' ? WebSocket : null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function cdpEvaluate(target, webSocketDebuggerUrl, expression, timeoutMs = 5000) {
|
|
177
|
+
assertLocalWebSocketUrl(webSocketDebuggerUrl);
|
|
178
|
+
const WebSocketImpl = resolveWebSocket(target);
|
|
179
|
+
if (!WebSocketImpl) return { skipped: true, reason: 'WebSocket unavailable' };
|
|
180
|
+
return new Promise((resolve, reject) => {
|
|
181
|
+
const ws = new WebSocketImpl(webSocketDebuggerUrl);
|
|
182
|
+
const timer = setTimeout(() => {
|
|
183
|
+
try {
|
|
184
|
+
ws.close();
|
|
185
|
+
} catch {
|
|
186
|
+
// Best-effort timeout cleanup.
|
|
187
|
+
}
|
|
188
|
+
reject(new Error('timeout evaluating extension page via CDP'));
|
|
189
|
+
}, timeoutMs);
|
|
190
|
+
const onOpen = () => {
|
|
191
|
+
ws.send(
|
|
192
|
+
JSON.stringify({
|
|
193
|
+
id: 1,
|
|
194
|
+
method: 'Runtime.evaluate',
|
|
195
|
+
params: { expression, awaitPromise: true, returnByValue: true },
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
};
|
|
199
|
+
const onMessage = (event) => {
|
|
200
|
+
const raw = event?.data ?? event;
|
|
201
|
+
const msg = JSON.parse(Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw));
|
|
202
|
+
if (msg.id !== 1) return;
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
ws.close();
|
|
205
|
+
if (msg.error) {
|
|
206
|
+
reject(new Error(msg.error.message || JSON.stringify(msg.error)));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
resolve(msg.result?.result?.value ?? null);
|
|
210
|
+
};
|
|
211
|
+
const onError = (err) => {
|
|
212
|
+
clearTimeout(timer);
|
|
213
|
+
reject(new Error(`CDP websocket error while stamping slot title: ${err?.message || err || 'unknown'}`));
|
|
214
|
+
};
|
|
215
|
+
if (typeof ws.on === 'function') {
|
|
216
|
+
ws.on('open', onOpen);
|
|
217
|
+
ws.on('message', onMessage);
|
|
218
|
+
ws.on('error', onError);
|
|
219
|
+
} else {
|
|
220
|
+
ws.addEventListener('open', onOpen);
|
|
221
|
+
ws.addEventListener('message', onMessage);
|
|
222
|
+
ws.addEventListener('error', onError);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function isHomePage(target, extensionId) {
|
|
228
|
+
if (!target || target.type !== 'page' || typeof target.url !== 'string') return false;
|
|
229
|
+
if (!target.url.startsWith(`chrome-extension://${extensionId}/`)) return false;
|
|
230
|
+
return target.url.includes('/home.html');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Stamp every inspectable home.html tab for this extension over CDP.
|
|
235
|
+
* Attached tabs (no webSocketDebuggerUrl) are skipped — another client owns them.
|
|
236
|
+
*/
|
|
237
|
+
async function stampHomeTabsViaCdp(options) {
|
|
238
|
+
const {
|
|
239
|
+
target = process.cwd(),
|
|
240
|
+
cdpPort,
|
|
241
|
+
extensionId,
|
|
242
|
+
slotId: slotIdOption,
|
|
243
|
+
runtimeDir,
|
|
244
|
+
} = options || {};
|
|
245
|
+
const slotId = sanitizeSlotId(slotIdOption) || readSlotId(target, runtimeDir);
|
|
246
|
+
const empty = { slotId: slotId || '', stamped: 0, skipped: 0, titles: [] };
|
|
247
|
+
if (!slotId || !cdpPort || !extensionId) return empty;
|
|
248
|
+
|
|
249
|
+
let targets;
|
|
250
|
+
try {
|
|
251
|
+
targets = await httpJson(`http://127.0.0.1:${cdpPort}/json/list`);
|
|
252
|
+
} catch {
|
|
253
|
+
return empty;
|
|
254
|
+
}
|
|
255
|
+
if (!Array.isArray(targets)) return empty;
|
|
256
|
+
|
|
257
|
+
const homes = targets.filter((t) => isHomePage(t, extensionId));
|
|
258
|
+
const titles = [];
|
|
259
|
+
let stamped = 0;
|
|
260
|
+
let skipped = 0;
|
|
261
|
+
const expression = buildStampExpression(slotId);
|
|
262
|
+
|
|
263
|
+
for (const home of homes) {
|
|
264
|
+
if (typeof home.webSocketDebuggerUrl !== 'string') {
|
|
265
|
+
skipped += 1;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
const title = await cdpEvaluate(target, home.webSocketDebuggerUrl, expression);
|
|
270
|
+
if (title && typeof title === 'object' && title.skipped) {
|
|
271
|
+
skipped += 1;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (typeof title === 'string') {
|
|
275
|
+
titles.push(title);
|
|
276
|
+
stamped += 1;
|
|
277
|
+
} else {
|
|
278
|
+
skipped += 1;
|
|
279
|
+
}
|
|
280
|
+
} catch {
|
|
281
|
+
skipped += 1;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return { slotId, stamped, skipped, titles };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
module.exports = {
|
|
289
|
+
sanitizeSlotId,
|
|
290
|
+
readSlotId,
|
|
291
|
+
applyPersistentSlotTitle,
|
|
292
|
+
buildStampExpression,
|
|
293
|
+
stampHomeTabsViaCdp,
|
|
294
|
+
};
|
|
@@ -22,6 +22,10 @@ import { fileURLToPath } from 'node:url';
|
|
|
22
22
|
|
|
23
23
|
const require = createRequire(import.meta.url);
|
|
24
24
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const {
|
|
26
|
+
readSlotId: readSlotIdFromLib,
|
|
27
|
+
buildStampExpression,
|
|
28
|
+
} = require(path.join(scriptDir, 'lib/slot-title.cjs'));
|
|
25
29
|
|
|
26
30
|
function pathDefault(key) {
|
|
27
31
|
for (const candidate of [
|
|
@@ -101,25 +105,7 @@ function readExpectedExtensionId(target) {
|
|
|
101
105
|
}
|
|
102
106
|
|
|
103
107
|
function readSlotId(target) {
|
|
104
|
-
|
|
105
|
-
const value = sanitizeSlotId(process.env[key]);
|
|
106
|
-
if (value) return value;
|
|
107
|
-
}
|
|
108
|
-
try {
|
|
109
|
-
const runtimeContext = JSON.parse(fs.readFileSync(path.join(target, recipeRuntimeDir(), 'agentic-runtime.json'), 'utf8'));
|
|
110
|
-
const value = sanitizeSlotId(runtimeContext.slotId);
|
|
111
|
-
if (value) {
|
|
112
|
-
return value;
|
|
113
|
-
}
|
|
114
|
-
} catch {
|
|
115
|
-
// No slot context: standalone harness users do not need a custom browser title.
|
|
116
|
-
}
|
|
117
|
-
return '';
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function sanitizeSlotId(value) {
|
|
121
|
-
const slotId = typeof value === 'string' ? value.trim() : '';
|
|
122
|
-
return /^[A-Za-z0-9._:-]{1,64}$/u.test(slotId) ? slotId : '';
|
|
108
|
+
return readSlotIdFromLib(target, recipeRuntimeDir());
|
|
123
109
|
}
|
|
124
110
|
|
|
125
111
|
function writeExtensionId(target, extensionId) {
|
|
@@ -407,16 +393,9 @@ async function inspectCdp(target, cdpPort, expectedExtensionId, expectedServiceW
|
|
|
407
393
|
const stampedTitle = await cdpEvaluate(
|
|
408
394
|
target,
|
|
409
395
|
pageTarget.webSocketDebuggerUrl,
|
|
410
|
-
|
|
411
|
-
const slot = ${JSON.stringify(slotId)};
|
|
412
|
-
const current = document.title || 'MetaMask';
|
|
413
|
-
const base = current.replace(/^.+?\\s+—\\s+/, '') || 'MetaMask';
|
|
414
|
-
const next = slot + ' — ' + base;
|
|
415
|
-
if (document.title !== next) document.title = next;
|
|
416
|
-
return document.title;
|
|
417
|
-
})()`,
|
|
396
|
+
buildStampExpression(slotId),
|
|
418
397
|
);
|
|
419
|
-
if (stampedTitle
|
|
398
|
+
if (typeof stampedTitle === 'string') {
|
|
420
399
|
ui.title = stampedTitle;
|
|
421
400
|
ui.slotTitleApplied = true;
|
|
422
401
|
}
|
|
@@ -123,6 +123,7 @@ let chromium; try { chromium = require('@playwright/test').chromium; } catch { c
|
|
|
123
123
|
const fs = require('node:fs');
|
|
124
124
|
const path = require('node:path');
|
|
125
125
|
const { extensionIdFromManifestFile } = require(path.join(process.env.SCRIPT_DIR, 'lib/extension-id.cjs'));
|
|
126
|
+
const { readSlotId, applyPersistentSlotTitle } = require(path.join(process.env.SCRIPT_DIR, 'lib/slot-title.cjs'));
|
|
126
127
|
|
|
127
128
|
const port = process.env.CDP_PORT;
|
|
128
129
|
const target = process.env.TARGET;
|
|
@@ -131,22 +132,13 @@ const runtimeDistDir = process.env.RUNTIME_DIST_DIR;
|
|
|
131
132
|
const startUrl = process.env.START_URL || '';
|
|
132
133
|
const settleMs = Number(process.env.SETTLE_MS || '8000') || 8000;
|
|
133
134
|
const displayMode = process.env.DISPLAY_MODE || 'fullscreen';
|
|
134
|
-
const slotId =
|
|
135
|
+
const slotId = readSlotId(target, runtimeDir);
|
|
135
136
|
|
|
136
137
|
if (!runtimeDir || !runtimeDistDir) {
|
|
137
138
|
console.error('reattach: resolved runtime paths were not provided.');
|
|
138
139
|
process.exit(2);
|
|
139
140
|
}
|
|
140
141
|
|
|
141
|
-
function readRuntimeSlotId() {
|
|
142
|
-
try {
|
|
143
|
-
const runtimeContext = JSON.parse(fs.readFileSync(path.join(target, runtimeDir, 'agentic-runtime.json'), 'utf8'));
|
|
144
|
-
return typeof runtimeContext.slotId === 'string' ? runtimeContext.slotId.trim() : '';
|
|
145
|
-
} catch {
|
|
146
|
-
return '';
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
142
|
function extensionIdFromRuntimeDist() {
|
|
151
143
|
return extensionIdFromManifestFile(path.join(target, runtimeDir, runtimeDistDir, 'manifest.json'));
|
|
152
144
|
}
|
|
@@ -204,11 +196,7 @@ async function connect() {
|
|
|
204
196
|
|
|
205
197
|
async function stampSlotTitle(page) {
|
|
206
198
|
if (!slotId || !page) return;
|
|
207
|
-
await page.evaluate((
|
|
208
|
-
const base = (document.title || 'MetaMask').replace(/^.+?\s+—\s+/, '') || 'MetaMask';
|
|
209
|
-
const next = `${slot} — ${base}`;
|
|
210
|
-
if (document.title !== next) document.title = next;
|
|
211
|
-
}, slotId).catch(() => {
|
|
199
|
+
await page.evaluate(applyPersistentSlotTitle, slotId).catch(() => {
|
|
212
200
|
// Best-effort operator affordance; reload success is verified by CDP state.
|
|
213
201
|
});
|
|
214
202
|
}
|
|
@@ -285,6 +273,16 @@ async function closePages(context, predicate) {
|
|
|
285
273
|
.catch(() => { /* wallet home is best-effort; the reload already applied */ });
|
|
286
274
|
}
|
|
287
275
|
await stampSlotTitle(home);
|
|
276
|
+
for (const page of allPages(context)) {
|
|
277
|
+
const url = page.url();
|
|
278
|
+
if (
|
|
279
|
+
page !== home &&
|
|
280
|
+
url.startsWith(`chrome-extension://${extId}/`) &&
|
|
281
|
+
url.includes('/home.html')
|
|
282
|
+
) {
|
|
283
|
+
await stampSlotTitle(page);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
288
286
|
await closeDisposableChromeTabs(context);
|
|
289
287
|
if (startUrl) {
|
|
290
288
|
const dapp = allPages(context)
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
1
2
|
import path from "node:path";
|
|
3
|
+
import { recipeRuntimeDir, runnerDir } from "../../paths.js";
|
|
2
4
|
import { resolveExtensionId } from "./extension-id.js";
|
|
5
|
+
const requireFromHarness = createRequire(import.meta.url);
|
|
6
|
+
const { stampHomeTabsViaCdp } = requireFromHarness(
|
|
7
|
+
path.join(runnerDir, "adapters/extension/lib/slot-title.cjs")
|
|
8
|
+
);
|
|
3
9
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
10
|
async function jsonList(port) {
|
|
5
11
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
@@ -161,12 +167,25 @@ async function ensureExtensionReady(target, options) {
|
|
|
161
167
|
health = await checkHealth();
|
|
162
168
|
}
|
|
163
169
|
}
|
|
170
|
+
let slotTitle;
|
|
171
|
+
if (after >= 1) {
|
|
172
|
+
const stamp = await stampHomeTabsViaCdp({
|
|
173
|
+
target: resolved,
|
|
174
|
+
cdpPort,
|
|
175
|
+
extensionId,
|
|
176
|
+
runtimeDir: recipeRuntimeDir()
|
|
177
|
+
});
|
|
178
|
+
if (stamp.slotId || stamp.stamped > 0 || stamp.skipped > 0) {
|
|
179
|
+
slotTitle = stamp;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
164
182
|
const ready = health.status === "PASS" && after === 1;
|
|
165
183
|
return base({
|
|
166
184
|
extensionId,
|
|
167
185
|
opened,
|
|
168
186
|
action,
|
|
169
187
|
homeTabs: { before, closed, after },
|
|
188
|
+
...slotTitle ? { slotTitle } : {},
|
|
170
189
|
ready,
|
|
171
190
|
reasonCode: ready ? "ready" : after !== 1 ? "tab-count" : "unhealthy",
|
|
172
191
|
health
|
|
@@ -12,7 +12,8 @@ const mobileSurface = {
|
|
|
12
12
|
},
|
|
13
13
|
async runtimeStatus(target) {
|
|
14
14
|
const watcherPort = process.env.WATCHER_PORT ? parseInt(process.env.WATCHER_PORT, 10) : void 0;
|
|
15
|
-
const
|
|
15
|
+
const platform = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL ? "android" : "ios";
|
|
16
|
+
const report = await mobileRuntimeStatus(target, { watcherPort, platform });
|
|
16
17
|
const runwayProvisioned = hasRunwayProvisionBaseline(target);
|
|
17
18
|
const depsPending = runwayProvisioned && report.checks?.deps?.status !== "current";
|
|
18
19
|
return {
|
package/dist/cli-commands.js
CHANGED
|
@@ -16,7 +16,7 @@ const SPEC = {
|
|
|
16
16
|
{ name: "debug", aliases: ["devtools", "inspect"], desc: "Open DevTools UI", flags: ["--json", "--no-open"] },
|
|
17
17
|
{ name: "fixtures", desc: "Manage the canonical wallet fixture (sync/set/generate)", args: ["sync", "set", "generate"], flags: ["--fixture", "--out", "--adapter", "--target", "--device", "--json"] },
|
|
18
18
|
{ name: "actions", desc: "List runnable recipe actions", flags: ["--json"] },
|
|
19
|
-
{ name: "doctor", desc: "Check harness/orchestration health", flags: ["--json", "--target", "--adapter", "--runtime-dir", "--expect-live", "--cdp-port", "--device"] },
|
|
19
|
+
{ name: "doctor", desc: "Check harness/orchestration health", flags: ["--json", "--target", "--adapter", "--runtime-dir", "--expect-live", "--print-ready", "--cdp-port", "--device"] },
|
|
20
20
|
{ name: "run", desc: "Execute a proof recipe (path or library name, e.g. run perps.smoke)", args: ["recipe.json|name"], flags: ["--list", "--device"] },
|
|
21
21
|
{ name: "recipe-quality", desc: "Build the recipe-quality artifact from compact JSON", args: ["build"], flags: ["--input", "--output", "--json"] },
|
|
22
22
|
{ name: "interactive", aliases: ["menu"], desc: "Interactive command menu" },
|
package/dist/commands/doctor.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
renderMobileDeviceList,
|
|
16
16
|
renderMobileLiveBlock
|
|
17
17
|
} from "./mobile-device-view.js";
|
|
18
|
+
import { farmslotReadyIndicator } from "./farmslot-ready.js";
|
|
18
19
|
import { ADAPTER_DETECT_NEXT, EXIT, usageOut } from "./shared.js";
|
|
19
20
|
import {
|
|
20
21
|
actionManifestPathOption,
|
|
@@ -40,7 +41,8 @@ async function handleDoctor({ options }) {
|
|
|
40
41
|
applyRuntimeDirOption(options);
|
|
41
42
|
const target = targetPath(options);
|
|
42
43
|
const json = optionFlag(options, "json");
|
|
43
|
-
const
|
|
44
|
+
const printReady = optionFlag(options, "printReady");
|
|
45
|
+
const expectLive = optionFlag(options, "expectLive") || printReady;
|
|
44
46
|
const allDevices = optionFlag(options, "allDevices");
|
|
45
47
|
const platformOption = optionString(options, "platform");
|
|
46
48
|
const explicitAdapter = optionString(options, "adapter") ?? (platformOption === "ios" || platformOption === "android" ? "mobile" : platformOption);
|
|
@@ -49,6 +51,14 @@ async function handleDoctor({ options }) {
|
|
|
49
51
|
return usageOut(json, "doctor", `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
|
|
50
52
|
}
|
|
51
53
|
assertAdapter(adapter);
|
|
54
|
+
if (printReady && json) {
|
|
55
|
+
return usageOut(
|
|
56
|
+
json,
|
|
57
|
+
"doctor",
|
|
58
|
+
"--print-ready owns stdout for Farmslot health_check; drop --json (use --expect-live --json for the doctor envelope)",
|
|
59
|
+
"mm-harness doctor --print-ready --adapter <adapter> --target <path>"
|
|
60
|
+
);
|
|
61
|
+
}
|
|
52
62
|
const actionManifestPath = actionManifestPathOption(options, adapter);
|
|
53
63
|
const manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
|
|
54
64
|
const manifestValidation = await validateManifest(manifest);
|
|
@@ -80,7 +90,20 @@ async function handleDoctor({ options }) {
|
|
|
80
90
|
const liveView = deviceView ? await mobileDeviceLiveView(target, deviceView) : null;
|
|
81
91
|
const devices = liveView?.devicesWithLive ?? deviceView?.devices ?? [];
|
|
82
92
|
const additionalReachableDevices = liveView?.additionalReachableDevices ?? [];
|
|
83
|
-
if (expectLive)
|
|
93
|
+
if (expectLive) {
|
|
94
|
+
return emitExpectLive(
|
|
95
|
+
adapter,
|
|
96
|
+
target,
|
|
97
|
+
runtime,
|
|
98
|
+
result,
|
|
99
|
+
orphanMetros,
|
|
100
|
+
capture,
|
|
101
|
+
devices,
|
|
102
|
+
additionalReachableDevices,
|
|
103
|
+
json,
|
|
104
|
+
printReady
|
|
105
|
+
);
|
|
106
|
+
}
|
|
84
107
|
if (json) console.log(JSON.stringify({ ...result, runtime, orphanMetros, capture, devices, additionalReachableDevices }, null, 2));
|
|
85
108
|
else {
|
|
86
109
|
const out = (style, text) => color(style, text, { stream: process.stdout });
|
|
@@ -119,8 +142,28 @@ async function handleDoctor({ options }) {
|
|
|
119
142
|
}
|
|
120
143
|
return result.status === "pass" ? 0 : 1;
|
|
121
144
|
}
|
|
122
|
-
function
|
|
145
|
+
function emitExpectLiveVerdict(adapter, target, runtime, verdict) {
|
|
146
|
+
const out = (style, text) => color(style, text, { stream: process.stderr });
|
|
147
|
+
const detail = runtime ? `${runtime.decision}${runtime.reasonCode ? ` (${runtime.reasonCode})` : ""}` : "runtime probe unavailable";
|
|
148
|
+
console.error(`${out("err", verdict)} ${out("bold", adapter)} ${detail} ${out("dim", target)}`);
|
|
149
|
+
for (const reason of runtime?.reasons ?? []) console.error(` ${out("dim", reason)}`);
|
|
150
|
+
console.error(` Next: ${getAdapterSurface(adapter).hints.relaunch}`);
|
|
151
|
+
}
|
|
152
|
+
function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture, devices, additionalReachableDevices, json, printReady) {
|
|
123
153
|
const live = runtime?.decision === "ready";
|
|
154
|
+
if (printReady) {
|
|
155
|
+
if (live) {
|
|
156
|
+
const indicator = farmslotReadyIndicator(adapter, devices);
|
|
157
|
+
if (indicator) {
|
|
158
|
+
console.log(indicator);
|
|
159
|
+
return EXIT.ok;
|
|
160
|
+
}
|
|
161
|
+
emitExpectLiveVerdict(adapter, target, runtime, "not-ready");
|
|
162
|
+
return EXIT.runtime;
|
|
163
|
+
}
|
|
164
|
+
emitExpectLiveVerdict(adapter, target, runtime, "not-live");
|
|
165
|
+
return EXIT.runtime;
|
|
166
|
+
}
|
|
124
167
|
if (json) {
|
|
125
168
|
console.log(JSON.stringify({ ...result, runtime, orphanMetros, capture, devices, additionalReachableDevices }, null, 2));
|
|
126
169
|
return live ? EXIT.ok : EXIT.runtime;
|
|
@@ -130,10 +173,7 @@ function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture,
|
|
|
130
173
|
console.log(`${out("ok", "live")} ${out("bold", adapter)} runtime ready`);
|
|
131
174
|
return EXIT.ok;
|
|
132
175
|
}
|
|
133
|
-
|
|
134
|
-
console.error(`${out("err", "not-live")} ${out("bold", adapter)} ${detail} ${out("dim", target)}`);
|
|
135
|
-
for (const reason of runtime?.reasons ?? []) console.error(` ${out("dim", reason)}`);
|
|
136
|
-
console.error(` Next: ${getAdapterSurface(adapter).hints.relaunch}`);
|
|
176
|
+
emitExpectLiveVerdict(adapter, target, runtime, "not-live");
|
|
137
177
|
return EXIT.runtime;
|
|
138
178
|
}
|
|
139
179
|
function captureHelperHealth() {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
function selectedDevice(devices) {
|
|
2
|
+
return devices.find((d) => d.selected) ?? devices[0];
|
|
3
|
+
}
|
|
4
|
+
function mobileWalletReady(device) {
|
|
5
|
+
if (device.liveState === "no-bridge" || device.liveState === "bridge-absent") return false;
|
|
6
|
+
return device.walletState === "unlocked";
|
|
7
|
+
}
|
|
8
|
+
function farmslotReadyIndicator(adapter, devices) {
|
|
9
|
+
switch (adapter) {
|
|
10
|
+
case "extension":
|
|
11
|
+
return "OK";
|
|
12
|
+
case "core":
|
|
13
|
+
return "ready";
|
|
14
|
+
case "mobile": {
|
|
15
|
+
const device = selectedDevice(devices);
|
|
16
|
+
if (!device) return "";
|
|
17
|
+
return mobileWalletReady(device) ? "OK" : "";
|
|
18
|
+
}
|
|
19
|
+
default:
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export {
|
|
24
|
+
farmslotReadyIndicator
|
|
25
|
+
};
|
package/dist/mm-harness-cli.js
CHANGED
|
@@ -171,19 +171,20 @@ Example:
|
|
|
171
171
|
sections so there is no hunting for files.
|
|
172
172
|
|
|
173
173
|
--fix Repair the overlay/runtime-context WITHOUT launching (no fixture reseed); --json adds fixed[]/failed[]
|
|
174
|
-
--expect-live Exit
|
|
174
|
+
--expect-live Exit-coded liveness gate only (human verdict on stderr). Use without --print-ready when callers need pass/fail without a Farmslot indicator line
|
|
175
|
+
--print-ready Farmslot health_check mode: implies --expect-live and prints health.ready_indicator on stdout (extension/mobile: OK; core: ready)
|
|
175
176
|
--cdp-port <port> Extension CDP port for the liveness probe (env: CDP_PORT / RECIPE_CDP_PORT)
|
|
176
177
|
--device <udid|serial|name> Mobile only: target this device (env: IOS_SIMULATOR / ADB_SERIAL). doctor reports the connected devices; it never gates on ambiguity.
|
|
177
178
|
--all-devices Mobile only: show every connected device instead of the slot-scoped target
|
|
178
179
|
--adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
|
|
179
180
|
--target <path> Checkout path (default: cwd)
|
|
180
181
|
--runtime-dir <dir> Runtime dir containing agentic-runtime.json (relative to target)
|
|
181
|
-
--json Machine-readable output
|
|
182
|
+
--json Machine-readable output (incompatible with --print-ready)
|
|
182
183
|
|
|
183
184
|
Example:
|
|
184
185
|
mm-harness doctor
|
|
185
186
|
mm-harness doctor --fix --json
|
|
186
|
-
mm-harness doctor --adapter extension --target /path/to/checkout --cdp-port 6662 --
|
|
187
|
+
mm-harness doctor --adapter extension --target /path/to/checkout --cdp-port 6662 --print-ready
|
|
187
188
|
mm-harness doctor --adapter mobile --target /path/to/checkout`
|
|
188
189
|
},
|
|
189
190
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deeeed/metamask-harness",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"mm-harness": "bin/mm-harness"
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"@farmslot/protocol": "^0.7.6",
|
|
21
21
|
"@farmslot/recipe-harness": "^0.4.3",
|
|
22
22
|
"commander": "^12.0.0",
|
|
23
|
-
"viem": "^2.54.3"
|
|
23
|
+
"viem": "^2.54.3",
|
|
24
|
+
"ws": "8.21.0"
|
|
24
25
|
},
|
|
25
26
|
"resolutions": {
|
|
26
27
|
"esbuild": "0.28.1",
|