@deeeed/metamask-harness 0.14.5 → 0.15.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.
- package/CHANGELOG.md +96 -6
- package/adapters/extension/ensure-browser.sh +4 -2
- package/adapters/extension/launch-browser.cjs +73 -34
- package/adapters/extension/live.sh +21 -10
- package/adapters/extension/reattach.sh +3 -3
- package/adapters/extension/sidepanel-toggle.sh +10 -4
- package/adapters/extension/start-watch.sh +2 -2
- package/adapters/extension/verify.sh +19 -2
- package/adapters/extension/wallet-fixture-state.cjs +14 -1
- package/adapters/manifest.json +19 -3
- package/adapters/mobile/bridge-runtime/setup-wallet.sh +6 -4
- package/adapters/mobile/launch-metro.cjs +74 -0
- package/adapters/mobile/lib/tmux-viewer.sh +4 -4
- package/adapters/mobile/open-device.sh +3 -1
- package/adapters/mobile/start-metro.sh +12 -18
- package/adapters/mobile/stop-metro.sh +1 -0
- package/adapters/mobile/verify.sh +5 -5
- package/adapters/shared/install-repo-deps.sh +29 -0
- package/adapters/shared/open-debug.mjs +35 -4
- package/adapters/shared/open-log-window.sh +1 -1
- package/adapters/shared/resolve-slot-ports-core.mjs +72 -1
- package/adapters/shared/resolve-slot-ports.sh +21 -17
- package/adapters/shared/tmux-session.sh +0 -5
- package/adapters/shared/tmux-viewer.sh +7 -2
- package/dist/adapters/core/surface.js +3 -0
- package/dist/adapters/extension/ensure-ready.js +0 -7
- package/dist/adapters/extension/runtime-decision.js +93 -3
- package/dist/adapters/extension/surface.js +17 -6
- package/dist/adapters/mobile/metro-env.js +67 -0
- package/dist/adapters/mobile/perps-env.js +101 -0
- package/dist/adapters/mobile/prepare.js +32 -14
- package/dist/adapters/mobile/runtime-decision.js +21 -2
- package/dist/adapters/mobile/source-freshness.js +116 -0
- package/dist/adapters/mobile/surface.js +5 -1
- package/dist/adapters/resolve-slot-ports.js +4 -0
- package/dist/adapters/slot-ports.js +131 -49
- package/dist/adapters.js +7 -2
- package/dist/checkout-lock.js +72 -0
- package/dist/cli-commands.js +1 -1
- package/dist/cli.js +2 -0
- package/dist/commands/call.js +91 -54
- package/dist/commands/check.js +266 -46
- package/dist/commands/checklist.js +136 -0
- package/dist/commands/debug.js +21 -2
- package/dist/commands/doctor.js +233 -18
- package/dist/commands/farmslot-ready.js +25 -0
- package/dist/commands/fixtures.js +177 -42
- package/dist/commands/launch/extension.js +6 -1
- package/dist/commands/launch/index.js +54 -22
- package/dist/commands/logs.js +24 -4
- package/dist/commands/parse-args.js +1 -0
- package/dist/commands/run-engine.js +223 -7
- package/dist/commands/run.js +67 -50
- package/dist/commands/shared.js +86 -5
- package/dist/commands/stop.js +1 -2
- package/dist/doctor.js +39 -17
- package/dist/harness.js +5 -2
- package/dist/heal-bounds.js +1 -1
- package/dist/mm-harness-cli.js +41 -12
- package/dist/runner.js +35 -5
- package/dist/runtime-context.js +228 -0
- package/docs/CLI-SPEC.md +15 -11
- package/docs/MENTAL-MODEL.md +6 -6
- package/library/actions/extension/perps/perps.mjs +29 -8
- package/library/actions/extension/platform/cdp.mjs +128 -28
- package/library/actions/extension/ui/navigate.mjs +1 -1
- package/library/actions/mobile/perps/perps.mjs +13 -1
- package/library/actions/mobile/platform/bridge.mjs +302 -29
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +7 -2
- package/library/manifests/extension.action-manifest.json +32 -12
- package/library/manifests/mobile.action-manifest.json +25 -3
- package/package.json +4 -4
- package/scripts/completions.sh +7 -4
|
@@ -120,18 +120,23 @@ async function captureHelperBrowserPid(context, port) {
|
|
|
120
120
|
);
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
async function
|
|
124
|
-
if (process.platform !== 'darwin') {
|
|
125
|
-
throw new Error('Extension ui.screenshot uses capture-helper snapshot and is currently supported only on macOS.');
|
|
126
|
-
}
|
|
123
|
+
async function captureCdpViewportSnapshot(page, context, relPath, metadata, captureHelperError = null) {
|
|
127
124
|
const { relative, absolute } = resolveRelativeArtifactPath(context.artifactsDir, relPath);
|
|
128
125
|
await mkdir(path.dirname(absolute), { recursive: true });
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
126
|
+
try {
|
|
127
|
+
const timeoutMs = Number(metadata?.cdpTimeoutMs ?? 5000);
|
|
128
|
+
const result = await Promise.race([
|
|
129
|
+
page.session.call('Page.captureScreenshot', {
|
|
130
|
+
format: 'png',
|
|
131
|
+
fromSurface: true,
|
|
132
|
+
captureBeyondViewport: false,
|
|
133
|
+
}),
|
|
134
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`Chrome Page.captureScreenshot timed out after ${timeoutMs}ms.`)), timeoutMs)),
|
|
135
|
+
]);
|
|
136
|
+
if (typeof result?.data !== 'string' || result.data.length === 0) {
|
|
137
|
+
throw new Error('Chrome Page.captureScreenshot returned no image data.');
|
|
138
|
+
}
|
|
139
|
+
await writeFile(absolute, Buffer.from(result.data, 'base64'));
|
|
135
140
|
return {
|
|
136
141
|
path: relative,
|
|
137
142
|
type: 'screenshot',
|
|
@@ -140,25 +145,62 @@ async function captureHelperSnapshot(page, context, relPath, metadata) {
|
|
|
140
145
|
category: metadata?.category ?? 'evidence',
|
|
141
146
|
mimeType: 'image/png',
|
|
142
147
|
metadata: {
|
|
143
|
-
provider: '
|
|
144
|
-
mode: '
|
|
145
|
-
|
|
146
|
-
captureHelper: sessionSnapshot,
|
|
148
|
+
provider: 'cdp',
|
|
149
|
+
mode: 'Page.captureScreenshot',
|
|
150
|
+
...(captureHelperError ? { fallbackFrom: 'capture-helper', captureHelperError } : {}),
|
|
147
151
|
},
|
|
148
152
|
};
|
|
153
|
+
} catch (error) {
|
|
154
|
+
const cdpError = error instanceof Error ? error.message : String(error);
|
|
155
|
+
return captureDomRasterSnapshot(page, context, relPath, metadata, captureHelperError, cdpError);
|
|
149
156
|
}
|
|
157
|
+
}
|
|
150
158
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
);
|
|
159
|
+
async function captureDomRasterSnapshot(page, context, relPath, metadata, captureHelperError, cdpError) {
|
|
160
|
+
const { relative, absolute } = resolveRelativeArtifactPath(context.artifactsDir, relPath);
|
|
161
|
+
const dataUrl = await page.evaluate(`(async () => {
|
|
162
|
+
const width = Math.max(1, window.innerWidth);
|
|
163
|
+
const height = Math.max(1, window.innerHeight);
|
|
164
|
+
const source = document.documentElement;
|
|
165
|
+
const clone = source.cloneNode(true);
|
|
166
|
+
const sourceNodes = [source, ...source.querySelectorAll('*')];
|
|
167
|
+
const cloneNodes = [clone, ...clone.querySelectorAll('*')];
|
|
168
|
+
for (let index = 0; index < sourceNodes.length; index += 1) {
|
|
169
|
+
const sourceNode = sourceNodes[index];
|
|
170
|
+
const cloneNode = cloneNodes[index];
|
|
171
|
+
if (!(sourceNode instanceof Element) || !(cloneNode instanceof Element)) continue;
|
|
172
|
+
const computed = getComputedStyle(sourceNode);
|
|
173
|
+
cloneNode.setAttribute('style', Array.from(computed).map((name) => name + ':' + computed.getPropertyValue(name) + ';').join(''));
|
|
174
|
+
if ('value' in sourceNode && typeof sourceNode.value === 'string') cloneNode.setAttribute('value', sourceNode.value);
|
|
175
|
+
if (sourceNode instanceof HTMLCanvasElement) {
|
|
176
|
+
const image = document.createElement('img');
|
|
177
|
+
try { image.src = sourceNode.toDataURL('image/png'); } catch { image.alt = 'canvas'; }
|
|
178
|
+
image.setAttribute('style', cloneNode.getAttribute('style') || '');
|
|
179
|
+
cloneNode.replaceWith(image);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
clone.querySelectorAll('script').forEach((node) => node.remove());
|
|
183
|
+
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
|
|
184
|
+
clone.style.width = width + 'px';
|
|
185
|
+
clone.style.height = height + 'px';
|
|
186
|
+
clone.style.overflow = 'hidden';
|
|
187
|
+
const serialized = new XMLSerializer().serializeToString(clone);
|
|
188
|
+
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + width + '" height="' + height + '"><foreignObject width="100%" height="100%">' + serialized + '</foreignObject></svg>';
|
|
189
|
+
const image = new Image();
|
|
190
|
+
const loaded = new Promise((resolve, reject) => { image.onload = resolve; image.onerror = () => reject(new Error('DOM raster image load failed.')); });
|
|
191
|
+
image.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
|
|
192
|
+
await loaded;
|
|
193
|
+
const canvas = document.createElement('canvas');
|
|
194
|
+
canvas.width = width;
|
|
195
|
+
canvas.height = height;
|
|
196
|
+
const context = canvas.getContext('2d');
|
|
197
|
+
context.drawImage(image, 0, 0, width, height);
|
|
198
|
+
return canvas.toDataURL('image/png');
|
|
199
|
+
})()`);
|
|
200
|
+
if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/png;base64,')) {
|
|
201
|
+
throw new Error(`Extension screenshot fallbacks failed: capture-helper=${captureHelperError ?? 'not attempted'}; cdp=${cdpError}; DOM raster returned no PNG.`);
|
|
160
202
|
}
|
|
161
|
-
|
|
203
|
+
await writeFile(absolute, Buffer.from(dataUrl.slice('data:image/png;base64,'.length), 'base64'));
|
|
162
204
|
return {
|
|
163
205
|
path: relative,
|
|
164
206
|
type: 'screenshot',
|
|
@@ -167,14 +209,72 @@ async function captureHelperSnapshot(page, context, relPath, metadata) {
|
|
|
167
209
|
category: metadata?.category ?? 'evidence',
|
|
168
210
|
mimeType: 'image/png',
|
|
169
211
|
metadata: {
|
|
170
|
-
provider: '
|
|
171
|
-
mode: '
|
|
172
|
-
|
|
173
|
-
...(
|
|
212
|
+
provider: 'dom-raster',
|
|
213
|
+
mode: 'computed-style-viewport',
|
|
214
|
+
fallbackFrom: captureHelperError ? 'capture-helper+cdp' : 'cdp',
|
|
215
|
+
...(captureHelperError ? { captureHelperError } : {}),
|
|
216
|
+
cdpError,
|
|
174
217
|
},
|
|
175
218
|
};
|
|
176
219
|
}
|
|
177
220
|
|
|
221
|
+
export async function captureHelperSnapshot(page, context, relPath, metadata) {
|
|
222
|
+
if (process.platform !== 'darwin') {
|
|
223
|
+
return captureCdpViewportSnapshot(page, context, relPath, metadata);
|
|
224
|
+
}
|
|
225
|
+
const { relative, absolute } = resolveRelativeArtifactPath(context.artifactsDir, relPath);
|
|
226
|
+
await mkdir(path.dirname(absolute), { recursive: true });
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
const pid = await captureHelperBrowserPid(context, page.port);
|
|
230
|
+
const timeoutMs = Number(metadata?.timeoutMs ?? 30000);
|
|
231
|
+
const sessionSnapshot = await captureActiveRecipeRecordingSnapshot(pid, absolute, timeoutMs);
|
|
232
|
+
if (sessionSnapshot) {
|
|
233
|
+
return {
|
|
234
|
+
path: relative,
|
|
235
|
+
type: 'screenshot',
|
|
236
|
+
nodeId: context.nodeId,
|
|
237
|
+
label: metadata?.label ?? `${context.nodeId} screenshot`,
|
|
238
|
+
category: metadata?.category ?? 'evidence',
|
|
239
|
+
mimeType: 'image/png',
|
|
240
|
+
metadata: {
|
|
241
|
+
provider: 'capture-helper',
|
|
242
|
+
mode: 'record_session_snapshot',
|
|
243
|
+
pid,
|
|
244
|
+
captureHelper: sessionSnapshot,
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const result = await runProcess(captureHelperPath(), ['snapshot', '--pid', String(pid), '--output', absolute], {
|
|
250
|
+
cwd: context.projectRoot,
|
|
251
|
+
env: process.env,
|
|
252
|
+
timeoutMs,
|
|
253
|
+
});
|
|
254
|
+
if (result.exitCode !== 0) {
|
|
255
|
+
throw new Error(`capture-helper snapshot failed for pid ${pid}: ${result.stderr || result.stdout}`);
|
|
256
|
+
}
|
|
257
|
+
const details = parseJsonObject(result.stdout);
|
|
258
|
+
return {
|
|
259
|
+
path: relative,
|
|
260
|
+
type: 'screenshot',
|
|
261
|
+
nodeId: context.nodeId,
|
|
262
|
+
label: metadata?.label ?? `${context.nodeId} screenshot`,
|
|
263
|
+
category: metadata?.category ?? 'evidence',
|
|
264
|
+
mimeType: 'image/png',
|
|
265
|
+
metadata: {
|
|
266
|
+
provider: 'capture-helper',
|
|
267
|
+
mode: 'snapshot',
|
|
268
|
+
pid,
|
|
269
|
+
...(details ? { captureHelper: details } : {}),
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
} catch (error) {
|
|
273
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
274
|
+
return captureCdpViewportSnapshot(page, context, relPath, metadata, message);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
178
278
|
function autolaunchEnabled(input) {
|
|
179
279
|
return input.node?.launch_existing_dist === true ||
|
|
180
280
|
input.node?.autolaunch === true ||
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { pathToFileURL } from 'node:url';
|
|
2
2
|
import { evalAsync, navigate, runAdapter } from '../platform/bridge.mjs';
|
|
3
|
+
import { ensureUnlocked } from '../wallet/ensure_unlocked.mjs';
|
|
3
4
|
|
|
4
5
|
function sleep(ms) {
|
|
5
6
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -637,6 +638,17 @@ async function applyTutorialState(input, config) {
|
|
|
637
638
|
export async function startState(input) {
|
|
638
639
|
const params = paramsForState(input);
|
|
639
640
|
const config = mergeStateConfig(profileDefaults(params.profile), params);
|
|
641
|
+
// Product edits require an app restart before proof. Mobile relocks on restart,
|
|
642
|
+
// so a deterministic Perps start state must restore the fixture-backed wallet
|
|
643
|
+
// prerequisite before it navigates or converges controller state.
|
|
644
|
+
const wallet = await ensureUnlocked({
|
|
645
|
+
...input,
|
|
646
|
+
action: 'metamask.wallet.ensure_unlocked',
|
|
647
|
+
node: { ...input.node, action: 'metamask.wallet.ensure_unlocked' },
|
|
648
|
+
});
|
|
649
|
+
// Entering Perps initializes the provider client on a freshly launched app.
|
|
650
|
+
// State reads before this navigation fail with CLIENT_NOT_INITIALIZED.
|
|
651
|
+
const navigation = await applyStateNavigation(input, config);
|
|
640
652
|
const provider = await ensureProvider(input, config);
|
|
641
653
|
const network = await ensureNetwork(input, config);
|
|
642
654
|
const tutorial = await applyTutorialState(input, config);
|
|
@@ -644,11 +656,11 @@ export async function startState(input) {
|
|
|
644
656
|
const balance = await assertBalance(input, config);
|
|
645
657
|
const orders = await applyOrdersState(input, config.orders);
|
|
646
658
|
const positions = await applyPositionsState(input, config.positions);
|
|
647
|
-
const navigation = await applyStateNavigation(input, config);
|
|
648
659
|
return {
|
|
649
660
|
action: input.action,
|
|
650
661
|
profile: config.profile ?? params.profile ?? 'clean_market_testnet',
|
|
651
662
|
phase: 'start_state',
|
|
663
|
+
wallet,
|
|
652
664
|
provider,
|
|
653
665
|
network,
|
|
654
666
|
tutorial,
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants as fsConstants } from 'node:fs';
|
|
3
|
+
import { mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
4
|
import { execFile, spawn } from 'node:child_process';
|
|
3
5
|
import { promisify } from 'node:util';
|
|
4
6
|
import path from 'node:path';
|
|
@@ -405,25 +407,54 @@ export async function simulatorScreenshot(input, relPath) {
|
|
|
405
407
|
const adbSerial = targetInfo.adbSerial;
|
|
406
408
|
const platform = String(input.node?.platform ?? process.env.PLATFORM ?? '').toLowerCase();
|
|
407
409
|
if (androidTarget || adbSerial || platform === 'android') {
|
|
408
|
-
return androidScreenshot(input, relPath
|
|
410
|
+
return androidScreenshot(input, relPath);
|
|
409
411
|
}
|
|
410
412
|
const target = targetInfo.iosSimulator;
|
|
411
413
|
if (!target) {
|
|
414
|
+
const { absolute } = resolveArtifactPath(input.context.artifactsDir, relPath || `screenshots/${input.context.nodeId}.png`);
|
|
415
|
+
await rm(absolute, { force: true });
|
|
412
416
|
throw new Error('iOS screenshot requires node.simulator, node.ios_simulator, or IOS_SIMULATOR so the proof is tied to the same device as the bridge commands.');
|
|
413
417
|
}
|
|
414
418
|
const { relative, absolute } = resolveArtifactPath(input.context.artifactsDir, relPath || `screenshots/${input.context.nodeId}.png`);
|
|
415
419
|
await mkdir(path.dirname(absolute), { recursive: true });
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
420
|
+
await rm(absolute, { force: true });
|
|
421
|
+
let temporaryDirectory;
|
|
422
|
+
try {
|
|
423
|
+
temporaryDirectory = await createScreenshotTemporaryDirectory(absolute);
|
|
424
|
+
const captured = path.join(temporaryDirectory, 'captured.png');
|
|
425
|
+
await createPrivateScreenshotFile(captured);
|
|
426
|
+
let result;
|
|
427
|
+
try {
|
|
428
|
+
result = await runScreenshotProcess('xcrun', ['simctl', 'io', String(target), 'screenshot', captured], false);
|
|
429
|
+
} catch (error) {
|
|
430
|
+
throw new Error(
|
|
431
|
+
`simctl screenshot could not start for ${target}: ${error instanceof Error ? error.message : String(error)}\n` +
|
|
432
|
+
' Next: install Xcode Command Line Tools (`xcode-select --install`), then rerun the same mm-harness command.',
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
if (result.exitCode !== 0) {
|
|
436
|
+
throw new Error(
|
|
437
|
+
`simctl screenshot failed for ${target}: ${result.stderr || result.stdout}\n` +
|
|
438
|
+
` Next: mm-harness doctor --adapter mobile --device ${target} --json`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
const png = await readPrivateScreenshotFile(captured);
|
|
442
|
+
const invalid = pngStructureError(png);
|
|
443
|
+
if (invalid) {
|
|
444
|
+
throw new Error(
|
|
445
|
+
`simctl screenshot produced invalid PNG bytes for ${target}: ${invalid}\n` +
|
|
446
|
+
` Next: mm-harness doctor --adapter mobile --device ${target} --json`,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
await publishPrivateScreenshot(png, temporaryDirectory, absolute);
|
|
450
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
451
|
+
} catch (error) {
|
|
452
|
+
await removeScreenshotResidue(temporaryDirectory, absolute);
|
|
453
|
+
if (String(error?.message ?? error).includes('\n Next:')) throw error;
|
|
454
|
+
throw new Error(
|
|
455
|
+
`simctl screenshot could not publish for ${target}: ${error instanceof Error ? error.message : String(error)}\n` +
|
|
456
|
+
` Next: mm-harness doctor --adapter mobile --device ${target} --json`,
|
|
457
|
+
);
|
|
427
458
|
}
|
|
428
459
|
return {
|
|
429
460
|
path: relative,
|
|
@@ -431,35 +462,277 @@ export async function simulatorScreenshot(input, relPath) {
|
|
|
431
462
|
nodeId: input.context.nodeId,
|
|
432
463
|
label: input.node?.description || `${input.action} screenshot`,
|
|
433
464
|
category: 'evidence',
|
|
465
|
+
mimeType: 'image/png',
|
|
466
|
+
metadata: {
|
|
467
|
+
provider: 'simctl',
|
|
468
|
+
mode: 'xcrun simctl io screenshot',
|
|
469
|
+
selectedDevice: String(target),
|
|
470
|
+
},
|
|
434
471
|
};
|
|
435
472
|
}
|
|
436
473
|
|
|
437
|
-
async function androidScreenshot(input, relPath
|
|
474
|
+
async function androidScreenshot(input, relPath) {
|
|
438
475
|
const { relative, absolute } = resolveArtifactPath(input.context.artifactsDir, relPath || `screenshots/${input.context.nodeId}.png`);
|
|
439
476
|
await mkdir(path.dirname(absolute), { recursive: true });
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
const
|
|
477
|
+
await rm(absolute, { force: true });
|
|
478
|
+
let temporaryDirectory;
|
|
479
|
+
try {
|
|
480
|
+
temporaryDirectory = await createScreenshotTemporaryDirectory(absolute);
|
|
481
|
+
const adbSerial = await resolveAndroidScreenshotSerial(input);
|
|
482
|
+
let result;
|
|
483
|
+
try {
|
|
484
|
+
result = await runScreenshotProcess('adb', ['-s', adbSerial, 'exec-out', 'screencap', '-p'], true);
|
|
485
|
+
} catch (error) {
|
|
486
|
+
throw new Error(
|
|
487
|
+
`adb screenshot could not start for ${adbSerial}: ${error instanceof Error ? error.message : String(error)}\n` +
|
|
488
|
+
' Next: install Android SDK Platform-Tools, ensure adb is on PATH, then rerun the same mm-harness command.',
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
if (result.exitCode !== 0) {
|
|
492
|
+
throw new Error(
|
|
493
|
+
`adb screenshot failed for ${adbSerial}: ${result.stderr}\n` +
|
|
494
|
+
` Next: mm-harness doctor --adapter mobile --device ${adbSerial} --json`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
const invalid = pngStructureError(result.stdout);
|
|
498
|
+
if (invalid) {
|
|
499
|
+
throw new Error(
|
|
500
|
+
`adb screenshot produced invalid PNG bytes for ${adbSerial}: ${invalid}\n` +
|
|
501
|
+
` Next: mm-harness doctor --adapter mobile --device ${adbSerial} --json`,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
await publishPrivateScreenshot(result.stdout, temporaryDirectory, absolute);
|
|
505
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
506
|
+
return {
|
|
507
|
+
path: relative,
|
|
508
|
+
type: 'screenshot',
|
|
509
|
+
nodeId: input.context.nodeId,
|
|
510
|
+
label: input.node?.description || `${input.action} screenshot`,
|
|
511
|
+
category: 'evidence',
|
|
512
|
+
mimeType: 'image/png',
|
|
513
|
+
metadata: {
|
|
514
|
+
provider: 'adb',
|
|
515
|
+
mode: 'adb exec-out screencap -p',
|
|
516
|
+
selectedDevice: adbSerial,
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
} catch (error) {
|
|
520
|
+
await removeScreenshotResidue(temporaryDirectory, absolute);
|
|
521
|
+
if (String(error?.message ?? error).includes('\n Next:')) throw error;
|
|
522
|
+
throw new Error(
|
|
523
|
+
`adb screenshot could not publish: ${error instanceof Error ? error.message : String(error)}\n` +
|
|
524
|
+
' Next: mm-harness doctor --adapter mobile --json',
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function resolveAndroidScreenshotSerial(input) {
|
|
530
|
+
const contextEnv = input.context?.env || {};
|
|
531
|
+
const explicit = input.node?.adb_serial ??
|
|
532
|
+
input.node?.android_device ??
|
|
533
|
+
input.node?.device ??
|
|
534
|
+
contextEnv.ADB_SERIAL ??
|
|
535
|
+
contextEnv.ANDROID_SERIAL ??
|
|
536
|
+
contextEnv.ANDROID_DEVICE ??
|
|
537
|
+
process.env.ADB_SERIAL ??
|
|
538
|
+
process.env.ANDROID_SERIAL ??
|
|
539
|
+
process.env.ANDROID_DEVICE;
|
|
540
|
+
if (explicit !== undefined && explicit !== null && String(explicit).trim()) return String(explicit).trim();
|
|
541
|
+
|
|
542
|
+
let result;
|
|
543
|
+
try {
|
|
544
|
+
result = await runScreenshotProcess('adb', ['devices'], false);
|
|
545
|
+
} catch (error) {
|
|
546
|
+
throw new Error(
|
|
547
|
+
`Android screenshot could not resolve an adb serial: ${error instanceof Error ? error.message : String(error)}\n` +
|
|
548
|
+
' Next: install Android SDK Platform-Tools, ensure adb is on PATH, then rerun with --device <adb-serial>.',
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
if (result.exitCode !== 0) {
|
|
552
|
+
throw new Error(
|
|
553
|
+
`Android screenshot could not resolve an adb serial: ${result.stderr || result.stdout}\n` +
|
|
554
|
+
' Next: mm-harness doctor --adapter mobile --json',
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
const serials = result.stdout
|
|
558
|
+
.split(/\r?\n/u)
|
|
559
|
+
.map((line) => line.trim().split(/\s+/u))
|
|
560
|
+
.filter((fields) => fields.length >= 2 && fields[1] === 'device')
|
|
561
|
+
.map(([serial]) => serial);
|
|
562
|
+
if (serials.length === 1) return serials[0];
|
|
563
|
+
throw new Error(
|
|
564
|
+
`Android screenshot requires one exact adb serial, but ${serials.length} ready devices were discovered.\n` +
|
|
565
|
+
' Next: mm-harness doctor --adapter mobile --json # choose devices[].id and rerun with --device <id>',
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function runScreenshotProcess(command, args, binaryOutput) {
|
|
570
|
+
return new Promise((resolve, reject) => {
|
|
571
|
+
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
445
572
|
const stdout = [];
|
|
446
573
|
let stderr = '';
|
|
447
574
|
child.stdout.on('data', (chunk) => { stdout.push(Buffer.from(chunk)); });
|
|
448
575
|
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
449
576
|
child.on('error', reject);
|
|
450
|
-
child.on('close', (exitCode) => resolve({
|
|
577
|
+
child.on('close', (exitCode) => resolve({
|
|
578
|
+
exitCode,
|
|
579
|
+
stdout: binaryOutput ? Buffer.concat(stdout) : Buffer.concat(stdout).toString('utf8'),
|
|
580
|
+
stderr,
|
|
581
|
+
}));
|
|
451
582
|
});
|
|
452
|
-
|
|
453
|
-
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function pngStructureError(bytes) {
|
|
586
|
+
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
587
|
+
if (!Buffer.isBuffer(bytes) || bytes.length < signature.length || !bytes.subarray(0, signature.length).equals(signature)) {
|
|
588
|
+
return `bad signature (${Buffer.isBuffer(bytes) ? bytes.length : 0} bytes)`;
|
|
454
589
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
590
|
+
let offset = signature.length;
|
|
591
|
+
let sawHeader = false;
|
|
592
|
+
let sawPalette = false;
|
|
593
|
+
let sawImageData = false;
|
|
594
|
+
let imageDataClosed = false;
|
|
595
|
+
let colorType;
|
|
596
|
+
let bitDepth;
|
|
597
|
+
while (offset < bytes.length) {
|
|
598
|
+
if (bytes.length - offset < 12) return `truncated chunk at byte ${offset}`;
|
|
599
|
+
const length = bytes.readUInt32BE(offset);
|
|
600
|
+
const typeBytes = bytes.subarray(offset + 4, offset + 8);
|
|
601
|
+
const type = typeBytes.toString('ascii');
|
|
602
|
+
const end = offset + 12 + length;
|
|
603
|
+
if (end > bytes.length) return `truncated ${type || 'unknown'} chunk at byte ${offset}`;
|
|
604
|
+
if (![...typeBytes].every((byte) => (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122))) {
|
|
605
|
+
return `invalid chunk type at byte ${offset}`;
|
|
606
|
+
}
|
|
607
|
+
const expectedCrc = bytes.readUInt32BE(offset + 8 + length);
|
|
608
|
+
const actualCrc = pngCrc32(bytes, offset + 4, offset + 8 + length);
|
|
609
|
+
if (actualCrc !== expectedCrc) return `CRC mismatch for ${type} chunk at byte ${offset}`;
|
|
610
|
+
if (!sawHeader && (type !== 'IHDR' || length !== 13)) return `first chunk must be IHDR length 13 (got ${type} length ${length})`;
|
|
611
|
+
if (type === 'IHDR') {
|
|
612
|
+
if (sawHeader) return 'duplicate IHDR chunk';
|
|
613
|
+
const invalidHeader = pngHeaderError(bytes.subarray(offset + 8, offset + 8 + length));
|
|
614
|
+
if (invalidHeader) return invalidHeader;
|
|
615
|
+
sawHeader = true;
|
|
616
|
+
bitDepth = bytes[offset + 16];
|
|
617
|
+
colorType = bytes[offset + 17];
|
|
618
|
+
} else if (type === 'PLTE') {
|
|
619
|
+
if (sawPalette) return 'duplicate PLTE chunk';
|
|
620
|
+
if (sawImageData) return 'PLTE chunk must precede IDAT';
|
|
621
|
+
if (colorType === 0 || colorType === 4) return `PLTE chunk is forbidden for color type ${colorType}`;
|
|
622
|
+
if (length === 0 || length % 3 !== 0 || length > 768) return `invalid PLTE length ${length}`;
|
|
623
|
+
if (colorType === 3 && length / 3 > 2 ** bitDepth) return `PLTE has too many entries for bit depth ${bitDepth}`;
|
|
624
|
+
sawPalette = true;
|
|
625
|
+
} else if (type === 'IDAT') {
|
|
626
|
+
if (imageDataClosed) return 'IDAT chunks must be consecutive';
|
|
627
|
+
if (colorType === 3 && !sawPalette) return 'indexed-color PNG requires PLTE before IDAT';
|
|
628
|
+
sawImageData = true;
|
|
629
|
+
} else {
|
|
630
|
+
if (sawImageData) imageDataClosed = true;
|
|
631
|
+
if (type[0] === type[0].toUpperCase() && type !== 'IEND') return `unknown critical chunk ${type}`;
|
|
632
|
+
}
|
|
633
|
+
if (type === 'IEND') {
|
|
634
|
+
if (length !== 0) return `IEND length must be 0 (got ${length})`;
|
|
635
|
+
if (!sawImageData) return 'missing IDAT chunk';
|
|
636
|
+
if (end !== bytes.length) return `trailing bytes after IEND (${bytes.length - end})`;
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
offset = end;
|
|
640
|
+
}
|
|
641
|
+
return sawHeader ? 'missing IEND chunk' : 'missing IHDR chunk';
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function pngHeaderError(header) {
|
|
645
|
+
const width = header.readUInt32BE(0);
|
|
646
|
+
const height = header.readUInt32BE(4);
|
|
647
|
+
const bitDepth = header[8];
|
|
648
|
+
const colorType = header[9];
|
|
649
|
+
const allowedDepths = {
|
|
650
|
+
0: [1, 2, 4, 8, 16],
|
|
651
|
+
2: [8, 16],
|
|
652
|
+
3: [1, 2, 4, 8],
|
|
653
|
+
4: [8, 16],
|
|
654
|
+
6: [8, 16],
|
|
462
655
|
};
|
|
656
|
+
if (width === 0 || width > 0x7fffffff || height === 0 || height > 0x7fffffff) {
|
|
657
|
+
return `invalid IHDR dimensions ${width}x${height}`;
|
|
658
|
+
}
|
|
659
|
+
if (!allowedDepths[colorType]) return `invalid IHDR color type ${colorType}`;
|
|
660
|
+
if (!allowedDepths[colorType].includes(bitDepth)) return `invalid IHDR bit depth ${bitDepth} for color type ${colorType}`;
|
|
661
|
+
if (header[10] !== 0) return `invalid IHDR compression method ${header[10]}`;
|
|
662
|
+
if (header[11] !== 0) return `invalid IHDR filter method ${header[11]}`;
|
|
663
|
+
if (header[12] !== 0 && header[12] !== 1) return `invalid IHDR interlace method ${header[12]}`;
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const PNG_CRC_TABLE = Uint32Array.from({ length: 256 }, (_, value) => {
|
|
668
|
+
let crc = value;
|
|
669
|
+
for (let bit = 0; bit < 8; bit += 1) crc = (crc & 1) === 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
|
|
670
|
+
return crc >>> 0;
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
function pngCrc32(bytes, start, end) {
|
|
674
|
+
let crc = 0xffffffff;
|
|
675
|
+
for (let index = start; index < end; index += 1) crc = PNG_CRC_TABLE[(crc ^ bytes[index]) & 0xff] ^ (crc >>> 8);
|
|
676
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function createScreenshotTemporaryDirectory(absolute) {
|
|
680
|
+
const temporary = path.join(path.dirname(absolute), `.${path.basename(absolute)}.${process.pid}.${randomUUID()}.tmp`);
|
|
681
|
+
await mkdir(temporary, { mode: 0o700 });
|
|
682
|
+
return temporary;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async function createPrivateScreenshotFile(file) {
|
|
686
|
+
const handle = await open(
|
|
687
|
+
file,
|
|
688
|
+
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW,
|
|
689
|
+
0o600,
|
|
690
|
+
);
|
|
691
|
+
try {
|
|
692
|
+
const stats = await handle.stat();
|
|
693
|
+
if (!stats.isFile() || stats.nlink !== 1) throw new Error('temporary screenshot is not a private regular file');
|
|
694
|
+
} finally {
|
|
695
|
+
await handle.close();
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function readPrivateScreenshotFile(file) {
|
|
700
|
+
let handle;
|
|
701
|
+
try {
|
|
702
|
+
handle = await open(file, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
|
|
703
|
+
const stats = await handle.stat();
|
|
704
|
+
if (!stats.isFile() || stats.nlink !== 1) throw new Error('captured screenshot is not a private regular file');
|
|
705
|
+
return await handle.readFile();
|
|
706
|
+
} catch (error) {
|
|
707
|
+
throw new Error(`captured screenshot is not a safe regular file: ${error instanceof Error ? error.message : String(error)}`);
|
|
708
|
+
} finally {
|
|
709
|
+
await handle?.close();
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
async function publishPrivateScreenshot(png, temporaryDirectory, absolute) {
|
|
714
|
+
const publication = path.join(temporaryDirectory, 'validated.png');
|
|
715
|
+
const handle = await open(
|
|
716
|
+
publication,
|
|
717
|
+
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW,
|
|
718
|
+
0o600,
|
|
719
|
+
);
|
|
720
|
+
try {
|
|
721
|
+
const stats = await handle.stat();
|
|
722
|
+
if (!stats.isFile() || stats.nlink !== 1) throw new Error('validated screenshot is not a private regular file');
|
|
723
|
+
await handle.writeFile(png);
|
|
724
|
+
await handle.sync();
|
|
725
|
+
await rename(publication, absolute);
|
|
726
|
+
} finally {
|
|
727
|
+
await handle.close();
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
async function removeScreenshotResidue(temporaryDirectory, absolute) {
|
|
732
|
+
await Promise.all([
|
|
733
|
+
temporaryDirectory ? rm(temporaryDirectory, { recursive: true, force: true }) : Promise.resolve(),
|
|
734
|
+
rm(absolute, { force: true }),
|
|
735
|
+
]);
|
|
463
736
|
}
|
|
464
737
|
|
|
465
738
|
function resolveArtifactPath(artifactsDir, relativePath) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
2
3
|
import { bridgeCommand, runAdapter, selectBridgeStatusEntry } from '../platform/bridge.mjs';
|
|
3
4
|
import { walletFixturePath } from '../../harness-exports.mjs';
|
|
4
5
|
|
|
@@ -108,7 +109,7 @@ async function waitForStableUnlocked(input, initialStatus, stableMs = 750) {
|
|
|
108
109
|
return last;
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
|
|
112
|
+
export async function ensureUnlocked(input) {
|
|
112
113
|
const before = await waitForTargetStatus(input, Number(input.node?.target_timeout_ms ?? 20000));
|
|
113
114
|
if (!selectedAccount(before, input) && routeName(before, input) !== 'Login') {
|
|
114
115
|
throw new Error(
|
|
@@ -153,4 +154,8 @@ runAdapter(async (input) => {
|
|
|
153
154
|
proofPath: 'agentic-wallet-status-after-missing-login-input',
|
|
154
155
|
};
|
|
155
156
|
}
|
|
156
|
-
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
160
|
+
runAdapter(ensureUnlocked);
|
|
161
|
+
}
|