@deeeed/metamask-harness 0.14.7 → 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 +81 -6
- package/adapters/core/cleanup.sh +0 -0
- package/adapters/core/inject.sh +0 -0
- package/adapters/extension/cleanup.mjs +0 -0
- package/adapters/extension/ensure-browser.sh +4 -2
- package/adapters/extension/inject.mjs +0 -0
- package/adapters/extension/launch-browser.cjs +73 -34
- package/adapters/extension/launch.sh +0 -0
- package/adapters/extension/live.sh +21 -10
- package/adapters/extension/readiness.mjs +0 -0
- package/adapters/extension/reattach.sh +3 -3
- package/adapters/extension/refresh-build.sh +0 -0
- package/adapters/extension/seed-fixture.sh +0 -0
- package/adapters/extension/sidepanel-toggle.sh +10 -4
- package/adapters/extension/snapshot-dist.sh +0 -0
- package/adapters/extension/start-watch.sh +2 -2
- package/adapters/extension/stop-viewers.sh +0 -0
- 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/cdp-bridge.cjs +0 -0
- package/adapters/mobile/bridge-runtime/setup-wallet.sh +6 -4
- package/adapters/mobile/cleanup.sh +0 -0
- package/adapters/mobile/inject.sh +0 -0
- package/adapters/mobile/launch-metro.cjs +74 -0
- package/adapters/mobile/lib/metro-listener.sh +0 -0
- package/adapters/mobile/lib/tmux-viewer.sh +4 -4
- package/adapters/mobile/open-device.sh +3 -1
- package/adapters/mobile/prewarm-bundle.sh +0 -0
- 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/mobile/wait-for-bridge.sh +0 -0
- package/adapters/mobile/yarn-setup.sh +0 -0
- package/adapters/shared/activate-repo-node.sh +0 -0
- package/adapters/shared/activate-repo-ruby.sh +0 -0
- package/adapters/shared/cli-ux.sh +0 -0
- package/adapters/shared/ensure-runner-deps.sh +0 -0
- package/adapters/shared/harness-path.sh +0 -0
- package/adapters/shared/hash-helpers.sh +0 -0
- package/adapters/shared/install-repo-deps.sh +29 -0
- package/adapters/shared/json-field.sh +0 -0
- package/adapters/shared/open-debug.mjs +35 -4
- package/adapters/shared/open-log-window.sh +1 -1
- package/adapters/shared/reap-checkout-metros.sh +0 -0
- package/adapters/shared/resolve-farmslot-ports.mjs +0 -0
- package/adapters/shared/resolve-farmslot-ports.sh +0 -0
- package/adapters/shared/resolve-slot-ports-core.mjs +72 -1
- package/adapters/shared/resolve-slot-ports.mjs +0 -0
- package/adapters/shared/resolve-slot-ports.sh +21 -17
- package/adapters/shared/sync-wallet-fixture.sh +0 -0
- 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 +3 -0
- 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.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 +187 -12
- 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/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 +37 -9
- 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 +5 -5
- package/scripts/completions.sh +7 -4
- package/scripts/install-completions.sh +0 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { recipeRuntimeDir } from "./paths.js";
|
|
7
|
+
function ensureRuntimeContext(target, adapter) {
|
|
8
|
+
const repoRoot = fs.realpathSync(target);
|
|
9
|
+
const runtimeDir = recipeRuntimeDir();
|
|
10
|
+
const contextPath = path.join(repoRoot, runtimeDir, "agentic-runtime.json");
|
|
11
|
+
const existingState = readExistingContext(contextPath, repoRoot, runtimeDir, adapter);
|
|
12
|
+
const existing = existingState.context;
|
|
13
|
+
const defaults = localDefaults(repoRoot, adapter, existing);
|
|
14
|
+
const context = {
|
|
15
|
+
schemaVersion: 1,
|
|
16
|
+
...existing,
|
|
17
|
+
repoRoot,
|
|
18
|
+
slotId: process.env.RECIPE_SLOT_ID || existing.slotId || defaults.slotId,
|
|
19
|
+
runtimeOwner: existing.runtimeOwner && existing.runtimeOwner !== "unknown" ? existing.runtimeOwner : "mm-harness",
|
|
20
|
+
strict: existing.strict ?? true,
|
|
21
|
+
platform: existing.platform || adapter,
|
|
22
|
+
runtimeDir,
|
|
23
|
+
...runtimeResources(adapter, existing, defaults),
|
|
24
|
+
...gitIdentity(repoRoot)
|
|
25
|
+
};
|
|
26
|
+
if (!existing.createdAt) context.createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
27
|
+
removeEmptyValues(context);
|
|
28
|
+
fs.mkdirSync(path.dirname(contextPath), { recursive: true });
|
|
29
|
+
writeJsonAtomic(contextPath, context);
|
|
30
|
+
hydrateRuntimeEnv(contextPath, context);
|
|
31
|
+
return {
|
|
32
|
+
path: contextPath,
|
|
33
|
+
created: !existingState.existed || Object.keys(existing).length === 0 || existingState.quarantinedPath !== void 0,
|
|
34
|
+
quarantinedPath: existingState.quarantinedPath,
|
|
35
|
+
context
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function readExistingContext(contextPath, repoRoot, runtimeDir, adapter) {
|
|
39
|
+
if (!fs.existsSync(contextPath)) return { context: {}, existed: false };
|
|
40
|
+
let parsed;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(fs.readFileSync(contextPath, "utf8"));
|
|
43
|
+
} catch {
|
|
44
|
+
return quarantineInvalidContext(contextPath);
|
|
45
|
+
}
|
|
46
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
47
|
+
return quarantineInvalidContext(contextPath);
|
|
48
|
+
}
|
|
49
|
+
const context = parsed;
|
|
50
|
+
if (!validExistingContext(context, repoRoot, runtimeDir, adapter)) {
|
|
51
|
+
return quarantineInvalidContext(contextPath);
|
|
52
|
+
}
|
|
53
|
+
return { context, existed: true };
|
|
54
|
+
}
|
|
55
|
+
function validExistingContext(context, repoRoot, runtimeDir, adapter) {
|
|
56
|
+
if (context.schemaVersion !== void 0 && context.schemaVersion !== 1) return false;
|
|
57
|
+
if (context.repoRoot !== void 0 && (typeof context.repoRoot !== "string" || path.resolve(context.repoRoot) !== repoRoot)) return false;
|
|
58
|
+
if (context.platform !== void 0 && context.platform !== adapter) return false;
|
|
59
|
+
if (context.runtimeDir !== void 0 && context.runtimeDir !== runtimeDir) return false;
|
|
60
|
+
if (context.strict !== void 0 && typeof context.strict !== "boolean") return false;
|
|
61
|
+
if (context.gitDirty !== void 0 && typeof context.gitDirty !== "boolean") return false;
|
|
62
|
+
const stringFields = [
|
|
63
|
+
"project",
|
|
64
|
+
"slotId",
|
|
65
|
+
"machine",
|
|
66
|
+
"runtimeOwner",
|
|
67
|
+
"simulator",
|
|
68
|
+
"adbSerial",
|
|
69
|
+
"distDir",
|
|
70
|
+
"extensionId",
|
|
71
|
+
"gitHead",
|
|
72
|
+
"gitBranch",
|
|
73
|
+
"gitCommit",
|
|
74
|
+
"createdAt",
|
|
75
|
+
"session"
|
|
76
|
+
];
|
|
77
|
+
for (const field of stringFields) {
|
|
78
|
+
if (context[field] !== void 0 && (typeof context[field] !== "string" || context[field].length === 0)) return false;
|
|
79
|
+
}
|
|
80
|
+
if (context.runtimeOwner !== void 0 && !["farmslot", "mm-harness", "unknown"].includes(String(context.runtimeOwner))) return false;
|
|
81
|
+
for (const field of ["cdpPort", "watcherPort", "metroPort", "devServerPort"]) {
|
|
82
|
+
const value = context[field];
|
|
83
|
+
if (value !== void 0 && (!Number.isInteger(value) || Number(value) < 1 || Number(value) > 65535)) return false;
|
|
84
|
+
}
|
|
85
|
+
if (context.runtimeStart !== void 0) {
|
|
86
|
+
if (!context.runtimeStart || typeof context.runtimeStart !== "object" || Array.isArray(context.runtimeStart)) return false;
|
|
87
|
+
const runtimeStart = context.runtimeStart;
|
|
88
|
+
if (runtimeStart.approved !== void 0 && typeof runtimeStart.approved !== "boolean") return false;
|
|
89
|
+
for (const field of ["command", "readyUrl"]) {
|
|
90
|
+
if (runtimeStart[field] !== void 0 && (typeof runtimeStart[field] !== "string" || runtimeStart[field].length === 0)) return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const forbiddenResources = adapter === "core" ? ["cdpPort", "watcherPort", "metroPort", "devServerPort", "simulator", "adbSerial", "extensionId"] : adapter === "extension" ? ["metroPort", "simulator", "adbSerial"] : ["cdpPort", "extensionId"];
|
|
94
|
+
if (forbiddenResources.some((field) => context[field] !== void 0)) return false;
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
function quarantineInvalidContext(contextPath) {
|
|
98
|
+
const quarantinedPath = `${contextPath}.invalid-${Date.now()}-${process.pid}`;
|
|
99
|
+
fs.renameSync(contextPath, quarantinedPath);
|
|
100
|
+
return { context: {}, existed: true, quarantinedPath };
|
|
101
|
+
}
|
|
102
|
+
function localDefaults(repoRoot, adapter, existing) {
|
|
103
|
+
const digest = createHash("sha256").update(repoRoot).digest("hex");
|
|
104
|
+
const offset = Number.parseInt(digest.slice(0, 8), 16) % 1e4;
|
|
105
|
+
const defaults = { slotId: `local-${adapter}-${digest.slice(0, 8)}` };
|
|
106
|
+
if (adapter === "extension" && !numericEnv("RECIPE_CDP_PORT", "CDP_PORT") && !existing.cdpPort) {
|
|
107
|
+
defaults.cdpPort = claimLocalPort(repoRoot, "cdp", 2e4 + offset);
|
|
108
|
+
}
|
|
109
|
+
if (adapter !== "core" && !numericEnv("WATCHER_PORT", "METRO_PORT", "RECIPE_WATCHER_PORT") && !existing.watcherPort && !existing.metroPort && !existing.devServerPort) {
|
|
110
|
+
defaults.watcherPort = claimLocalPort(repoRoot, "watcher", (adapter === "extension" ? 3e4 : 4e4) + offset);
|
|
111
|
+
}
|
|
112
|
+
return defaults;
|
|
113
|
+
}
|
|
114
|
+
function claimLocalPort(repoRoot, kind, preferred) {
|
|
115
|
+
const registry = process.env.MM_HARNESS_PORT_REGISTRY ? path.resolve(process.env.MM_HARNESS_PORT_REGISTRY) : path.join(os.homedir(), ".cache", "mm-harness", "ports");
|
|
116
|
+
fs.mkdirSync(registry, { recursive: true });
|
|
117
|
+
const rangeStart = Math.floor(preferred / 1e4) * 1e4;
|
|
118
|
+
for (let step = 0; step < 1e4; step += 1) {
|
|
119
|
+
const port = rangeStart + (preferred - rangeStart + step) % 1e4;
|
|
120
|
+
const claimPath = path.join(registry, `${port}.json`);
|
|
121
|
+
const claim = { schemaVersion: 1, port, kind, repoRoot, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
122
|
+
try {
|
|
123
|
+
const fd = fs.openSync(claimPath, "wx", 384);
|
|
124
|
+
fs.writeFileSync(fd, `${JSON.stringify(claim, null, 2)}
|
|
125
|
+
`);
|
|
126
|
+
fs.closeSync(fd);
|
|
127
|
+
return port;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (error.code !== "EEXIST") throw error;
|
|
130
|
+
const owner = readPortClaim(claimPath);
|
|
131
|
+
if (owner?.repoRoot === repoRoot) return port;
|
|
132
|
+
if (!owner || !portClaimIsLive(owner, port)) {
|
|
133
|
+
fs.rmSync(claimPath, { force: true });
|
|
134
|
+
step -= 1;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`no local ${kind} port is available in ${rangeStart}-${rangeStart + 9999}`);
|
|
139
|
+
}
|
|
140
|
+
function readPortClaim(claimPath) {
|
|
141
|
+
try {
|
|
142
|
+
const value = JSON.parse(fs.readFileSync(claimPath, "utf8"));
|
|
143
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function portClaimIsLive(claim, port) {
|
|
149
|
+
if (typeof claim.repoRoot !== "string" || !fs.existsSync(claim.repoRoot)) return false;
|
|
150
|
+
const contextPath = path.join(claim.repoRoot, recipeRuntimeDir(), "agentic-runtime.json");
|
|
151
|
+
try {
|
|
152
|
+
const context = JSON.parse(fs.readFileSync(contextPath, "utf8"));
|
|
153
|
+
return context.cdpPort === port || context.watcherPort === port || context.metroPort === port || context.devServerPort === port;
|
|
154
|
+
} catch {
|
|
155
|
+
const created = typeof claim.createdAt === "string" ? Date.parse(claim.createdAt) : Number.NaN;
|
|
156
|
+
return Number.isFinite(created) && Date.now() - created < 6e4;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function runtimeResources(adapter, existing, defaults) {
|
|
160
|
+
if (adapter === "core") return {};
|
|
161
|
+
const watcherPort = numericEnv("WATCHER_PORT", "METRO_PORT", "RECIPE_WATCHER_PORT") ?? existing.watcherPort ?? existing.metroPort ?? existing.devServerPort ?? defaults.watcherPort;
|
|
162
|
+
const shared = {
|
|
163
|
+
watcherPort,
|
|
164
|
+
devServerPort: watcherPort
|
|
165
|
+
};
|
|
166
|
+
if (adapter === "extension") {
|
|
167
|
+
shared.cdpPort = numericEnv("RECIPE_CDP_PORT", "CDP_PORT") ?? existing.cdpPort ?? defaults.cdpPort;
|
|
168
|
+
} else {
|
|
169
|
+
shared.metroPort = watcherPort;
|
|
170
|
+
shared.simulator = process.env.IOS_SIMULATOR || existing.simulator;
|
|
171
|
+
shared.adbSerial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || existing.adbSerial;
|
|
172
|
+
}
|
|
173
|
+
return shared;
|
|
174
|
+
}
|
|
175
|
+
function numericEnv(...names) {
|
|
176
|
+
for (const name of names) {
|
|
177
|
+
const raw = process.env[name];
|
|
178
|
+
if (!raw || !/^\d+$/u.test(raw)) continue;
|
|
179
|
+
return Number(raw);
|
|
180
|
+
}
|
|
181
|
+
return void 0;
|
|
182
|
+
}
|
|
183
|
+
function gitIdentity(repoRoot) {
|
|
184
|
+
return {
|
|
185
|
+
gitHead: git(repoRoot, ["rev-parse", "HEAD"]),
|
|
186
|
+
gitBranch: git(repoRoot, ["branch", "--show-current"]),
|
|
187
|
+
gitDirty: Boolean(git(repoRoot, ["status", "--short"]))
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function git(repoRoot, args) {
|
|
191
|
+
try {
|
|
192
|
+
return execFileSync("git", ["-C", repoRoot, ...args], {
|
|
193
|
+
encoding: "utf8",
|
|
194
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
195
|
+
}).trim() || void 0;
|
|
196
|
+
} catch {
|
|
197
|
+
return void 0;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function removeEmptyValues(context) {
|
|
201
|
+
for (const [key, value] of Object.entries(context)) {
|
|
202
|
+
if (value === void 0 || value === null || value === "") delete context[key];
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function writeJsonAtomic(file, value) {
|
|
206
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
207
|
+
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}
|
|
208
|
+
`, { mode: 384 });
|
|
209
|
+
fs.renameSync(temporary, file);
|
|
210
|
+
}
|
|
211
|
+
function hydrateRuntimeEnv(contextPath, context) {
|
|
212
|
+
process.env.RECIPE_RUNTIME_CONTEXT = contextPath;
|
|
213
|
+
setEnv("RECIPE_SLOT_ID", context.slotId);
|
|
214
|
+
setEnv("CDP_PORT", context.cdpPort);
|
|
215
|
+
setEnv("RECIPE_CDP_PORT", context.cdpPort);
|
|
216
|
+
setEnv("WATCHER_PORT", context.watcherPort);
|
|
217
|
+
setEnv("RECIPE_WATCHER_PORT", context.watcherPort);
|
|
218
|
+
setEnv("METRO_PORT", context.metroPort ?? context.watcherPort);
|
|
219
|
+
setEnv("IOS_SIMULATOR", context.simulator);
|
|
220
|
+
setEnv("ADB_SERIAL", context.adbSerial);
|
|
221
|
+
setEnv("ANDROID_SERIAL", context.adbSerial);
|
|
222
|
+
}
|
|
223
|
+
function setEnv(name, value) {
|
|
224
|
+
if (value !== void 0 && value !== null && value !== "") process.env[name] = String(value);
|
|
225
|
+
}
|
|
226
|
+
export {
|
|
227
|
+
ensureRuntimeContext
|
|
228
|
+
};
|
package/docs/CLI-SPEC.md
CHANGED
|
@@ -286,19 +286,23 @@ Recovery is silent in human mode. With `--json`: `"recovered": ["metro.restarted
|
|
|
286
286
|
> `runOneNode` in `runner/src/cli.ts`). `set` always prints the fixture edit path;
|
|
287
287
|
> the password is read FROM the fixture, never typed. On-device seed is Tier B.
|
|
288
288
|
|
|
289
|
-
**One canonical wallet fixture per checkout** — same SSOT model as the farm installer.
|
|
289
|
+
**One canonical wallet fixture per checkout** — same SSOT model as the farm installer. `init --from` copies an existing secure team/test fixture; explicit `init --dev` creates a disposable public test wallet that must never hold real funds. **Fixture status is reported by `doctor` automatically.**
|
|
290
290
|
|
|
291
|
-
**Synopsis:** `mm-harness fixtures <sync|set|generate|finalize> [flags]` — subcommand required.
|
|
291
|
+
**Synopsis:** `mm-harness fixtures <init|sync|set|generate|finalize> [flags]` — subcommand required.
|
|
292
292
|
|
|
293
293
|
| Subcommand | Behavior |
|
|
294
294
|
|---|---|
|
|
295
|
+
| `init` | Create the canonical fixture from `--from <path>` or explicit `--dev`; refuse replacement unless `--force` is supplied |
|
|
295
296
|
| `sync` | Refresh **wallet fixture files** on the target (wallet DATA only — overlay refresh is `--heal` / `doctor --fix` territory, NOT `fixtures`; they are separate concerns) |
|
|
296
|
-
| `set` | Apply THE canonical fixture (SRP/password/accounts) to
|
|
297
|
+
| `set` | Apply THE canonical fixture (SRP/password/accounts) to Mobile/Extension; Core validates and reads the same fixture directly for signing |
|
|
297
298
|
| `generate` | Render the extension `fixture-state.json` from a wallet fixture for pre-launch profile prefill (fronts `adapters/extension/wallet-fixture-state.cjs generate`). **Extension-only** (mobile/core teach). Requires `--fixture <wallet-fixture.json>` and `--out <fixture-state.json>`. This is the public surface for farm packs that need the pre-launch fixture-state file, replacing direct reach-in to the internal leaf. |
|
|
298
299
|
| `finalize` | Seed account labels/selection into the **live** extension over CDP and validate import parity (fronts `adapters/extension/wallet-fixture-state.cjs seed-cdp`). **Extension-only, post-launch** (mobile/core teach). Requires `--fixture`, `--state`, `--cdp-port`, `--extension-dir`. This is the public surface for farm packs that finalize account labels after launch, replacing the last direct reach-in to the internal leaf. |
|
|
299
300
|
|
|
300
301
|
| Flag | Type | Default | ENV (agent) | Audience | Description |
|
|
301
302
|
|---|---|---|---|---|---|
|
|
303
|
+
| `--from <json>` | path | — | — | both | `init`: copy an existing fixture without exposing its values in arguments |
|
|
304
|
+
| `--dev` | bool | false | — | both | `init`: create a disposable public test wallet; never real funds |
|
|
305
|
+
| `--force` | bool | false | — | both | `init`: intentionally replace the canonical fixture |
|
|
302
306
|
| `--fixture <json>` | path | slot's `wallet-fixture.json` (`set`/`sync`); **required** for `generate`/`finalize` (input wallet fixture) | `RECIPE_WALLET_FIXTURE` | **agent** (`set`/`sync`); both (`generate`/`finalize`) | `set`/`sync`: override fixture path (human form = edit the canonical file directly). `generate`/`finalize`: the input wallet fixture. |
|
|
303
307
|
| `--out <json>` | path | `generate`: **required**; `finalize`: leaf default report path | — | both | `generate`: output `fixture-state.json`. `finalize`: optional validation report path. |
|
|
304
308
|
| `--state <json>` | path | — (**required** for `finalize`) | — | both | `finalize` only: the pre-launch `fixture-state.json` to seed into the live wallet |
|
|
@@ -393,9 +397,9 @@ Readiness check for a checkout without launching the app. Doctor is the single p
|
|
|
393
397
|
|
|
394
398
|
| Field | Env var set by harness | Flag override | How to customize |
|
|
395
399
|
|---|---|---|---|
|
|
396
|
-
| `slotId` | `RECIPE_SLOT_ID` | `--slot` |
|
|
397
|
-
| `extensionId` | `RECIPE_HARNESS_EXTENSION_ID` | — |
|
|
398
|
-
| `cdpPort` | `RECIPE_CDP_PORT` / `CDP_PORT` | `--cdp-port` |
|
|
400
|
+
| `slotId` | `RECIPE_SLOT_ID` | `--slot` | `doctor --fix` creates local identity; Farmslot may override |
|
|
401
|
+
| `extensionId` | `RECIPE_HARNESS_EXTENSION_ID` | — | resolved from the built extension |
|
|
402
|
+
| `cdpPort` | `RECIPE_CDP_PORT` / `CDP_PORT` | `--cdp-port` | `doctor --fix` claims one; flag overrides |
|
|
399
403
|
| `runtimeStart.approved` | `RECIPE_RUNTIME_START_APPROVED` | — | edit file (`true`/`false`) |
|
|
400
404
|
| `runtimeStart.command` | — | — | edit file |
|
|
401
405
|
| `runtimeStart.readyUrl` | `RECIPE_RUNTIME_READY_URL` | — | edit file |
|
|
@@ -412,9 +416,9 @@ Readiness check for a checkout without launching the app. Doctor is the single p
|
|
|
412
416
|
"fileExists": true,
|
|
413
417
|
"envOverride": null,
|
|
414
418
|
"fields": {
|
|
415
|
-
"slotId": { "value": "slot-1", "source": "file", "envVar": "RECIPE_SLOT_ID", "customize": "
|
|
416
|
-
"extensionId": { "value": "abc123", "source": "file", "envVar": "RECIPE_HARNESS_EXTENSION_ID", "customize": "
|
|
417
|
-
"cdpPort": { "value": "6663", "source": "file", "envVar": "CDP_PORT", "customize": "
|
|
419
|
+
"slotId": { "value": "slot-1", "source": "file", "envVar": "RECIPE_SLOT_ID", "customize": "doctor --fix creates local identity; Farmslot may override" },
|
|
420
|
+
"extensionId": { "value": "abc123", "source": "file", "envVar": "RECIPE_HARNESS_EXTENSION_ID", "customize": "resolved from the built extension" },
|
|
421
|
+
"cdpPort": { "value": "6663", "source": "file", "envVar": "CDP_PORT", "customize": "doctor --fix claims one; --cdp-port overrides" },
|
|
418
422
|
"runtimeStart.approved": { "value": "true", "source": "file", "envVar": "RECIPE_RUNTIME_START_APPROVED", "customize": "edit file (true/false)" },
|
|
419
423
|
"runtimeStart.command": { "value": null, "source": "default", "envVar": null, "customize": "edit file" },
|
|
420
424
|
"runtimeStart.readyUrl": { "value": null, "source": "default", "envVar": "RECIPE_RUNTIME_READY_URL", "customize": "edit file" }
|
|
@@ -428,7 +432,7 @@ Readiness check for a checkout without launching the app. Doctor is the single p
|
|
|
428
432
|
| `--adapter <p>` | mobile\|extension\|core | auto-detect | `RECIPE_HARNESS_PLATFORM` | both | Target · [DEFAULT-GAP] required today |
|
|
429
433
|
| `--target <repo>` | path | `$PWD` | — | both | Checkout to probe |
|
|
430
434
|
| `--action-manifest <path>` | path | bundled | — | agent | Override manifest (the file; `manifest` verb is retired) |
|
|
431
|
-
| `--fix` | bool | false | — | both |
|
|
435
|
+
| `--fix` | bool | false | — | both | Repair overlay/runtime context and require a usable canonical fixture without launching. Exit 0 = sandbox prerequisites ready; 1 = partial/failed. JSON adds `fixed`, `failed`, and numbered `nextActions`. |
|
|
432
436
|
| `--json` | bool | false | — | agent | Machine-readable (includes fixture + runtimeContext sections; with `--fix`: adds fixed/failed arrays) |
|
|
433
437
|
|
|
434
438
|
**`--fix` framing:** `doctor --fix` runs the same healing checks as `launch`/`verify`'s self-healing path but stops short of starting the app. Use it when the agent detects a broken state and wants to repair before the next `run` — or when CI needs to repair without a launch side-effect. `doctor` without `--fix` is always pure read-only.
|
|
@@ -618,7 +622,7 @@ pretty-printed fallback. `manifest` is **RETIRED**: raw protocol dump rehomes to
|
|
|
618
622
|
| Flag | Type | Default | ENV | Audience | Description |
|
|
619
623
|
|---|---|---|---|---|---|
|
|
620
624
|
| `<action>` positional | string | required | — | both | Full action name or unambiguous short name (fuzzy resolved from adapter vocabulary) |
|
|
621
|
-
| `--arg <k=v>` | string (repeatable) | — | — | both | Action field value;
|
|
625
|
+
| `--arg <k=v>` | string (repeatable) | — | — | both | Action field value; parses JSON scalars/arrays/objects. Wrap a numeric-looking string as a JSON string, e.g. `--arg 'test_id="12345"'`. |
|
|
622
626
|
| `--adapter <p>` | mobile\|extension\|core | auto-detect | `RECIPE_HARNESS_PLATFORM` | both | Target adapter |
|
|
623
627
|
| `--json` | bool | false | — | **agent PRIMARY** | `{ action, resolvedAction, args, trace, evidence, recovered, mutations, exitCode, schemaVersion }` — same shape as `run --json` (one-node subset) |
|
|
624
628
|
|
package/docs/MENTAL-MODEL.md
CHANGED
|
@@ -58,7 +58,8 @@ good defaults for easy typing. Enforced rules, visible in every table below:
|
|
|
58
58
|
auto-install it inline with a one-line notice on first run on that checkout.
|
|
59
59
|
`install` is the explicit form for CI, agents, and deterministic setup
|
|
60
60
|
(`--heal=off` on daily verbs disables the inline check and all healing).
|
|
61
|
-
- **Your
|
|
61
|
+
- **Your checkout's settings live in `agentic-runtime.json` — `doctor --fix` creates
|
|
62
|
+
local identity/resources when absent and `doctor` explains them;
|
|
62
63
|
you never hunt for the file.** `doctor` prints every field (slotId, extensionId,
|
|
63
64
|
cdpPort, runtimeStart.*), its current value, its source (file / env var /
|
|
64
65
|
default), and exactly how to change it. Arthur's rule made concrete: no
|
|
@@ -157,15 +158,15 @@ Auto-ensures runtime overlay before launching (installs inline with a one-line n
|
|
|
157
158
|
| `mm-recipe debug` | `debug` | ABSORB-LATER |
|
|
158
159
|
| `mm-recipe dev-menu` · `devmenu` | `debug --dev-menu` (flag) | ABSORB-LATER · [DEFAULT-GAP] separate verb for one console concept |
|
|
159
160
|
|
|
160
|
-
#### `fixtures` — sync fixture files + set up the wallet · REAL (wave 3)
|
|
161
|
+
#### `fixtures` — initialize/sync fixture files + set up the wallet · REAL (wave 3)
|
|
161
162
|
|
|
162
|
-
One canonical wallet fixture per checkout (SSOT). Fixture status is reported by `doctor` automatically
|
|
163
|
+
One canonical wallet fixture per checkout (SSOT). Fixture status is reported by `doctor` automatically. Initialize from an existing secure team/test fixture or explicitly choose a disposable public dev wallet; secrets are never typed into chat or command arguments.
|
|
163
164
|
|
|
164
|
-
Subcommands: `fixtures sync`, `fixtures set
|
|
165
|
+
Subcommands: `fixtures init`, `fixtures sync`, `fixtures set`, plus Extension generate/finalize helpers.
|
|
165
166
|
|
|
166
167
|
| | |
|
|
167
168
|
|---|---|
|
|
168
|
-
| **Human form** | `mm-harness fixtures sync` · `
|
|
169
|
+
| **Human form** | `mm-harness fixtures init --from <path>` or explicit `--dev` · `fixtures sync` · `fixtures set` (password read FROM the fixture, never typed) |
|
|
169
170
|
| **Agent / advanced** | `mm-harness fixtures set --fixture <json> --platform mobile` (`--fixture` is agent-only; humans edit the canonical file directly) |
|
|
170
171
|
|
|
171
172
|
| Today (real) | → mm-harness | Status |
|
|
@@ -292,4 +293,3 @@ The surface below is settled — the design passed a three-round external review
|
|
|
292
293
|
## How farmslot calls mm-harness (preflight & the fleet binding)
|
|
293
294
|
|
|
294
295
|
"Preflight" survives as a capability, not an exposed concept: it is what `launch` does before returning (readiness: Metro/bundle → app → health/bridge poll), with `launch --verify` as the prove-it tier and `doctor` as the read-only view. The fleet is just another agent-grade caller — farmslot prepare profiles keep their git / fixtures / deps phases, and the preflight/health hooks collapse to one call: `mm-harness launch <target> --verify --heal=auto --json`. Same-capability by construction: the machine contract (`--json` schemas, `--heal`, exit-code taxonomy, `recovered[]` / `mutations[]` / `recoverable:false`) is the one agents already use. Three bindings, one verb: the engineer types `mm-harness launch ios`; the slot's prepare hook calls the same verb with slot context; dispatch scales it across the fleet. (Full contract: CLI-SPEC.md Part 6.)
|
|
295
|
-
|
|
@@ -38,11 +38,15 @@ function selectedItems(input, items) {
|
|
|
38
38
|
});
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
export function positionSelectionState(input, positions) {
|
|
42
|
+
return { hasMatching: selectedItems(input, positions).length > 0 };
|
|
43
|
+
}
|
|
44
|
+
|
|
41
45
|
async function navigatePerps(input) {
|
|
42
46
|
const selected = String(input.node?.target ?? input.node?.destination ?? (input.node?.market || input.node?.symbol ? 'market' : 'home')).toLowerCase();
|
|
43
47
|
return withExtensionPage(input, async (page) => {
|
|
44
48
|
if (selected === 'home' || selected === 'perps' || selected === 'perps_home') {
|
|
45
|
-
const navigation = await page.navigateHash('
|
|
49
|
+
const navigation = await page.navigateHash('#/perps-home');
|
|
46
50
|
await page.waitForExpression('document.body && document.body.innerText.includes("Perps")', { timeoutMs: 15000 });
|
|
47
51
|
return { action: input.action, target: selected, navigation, proofPath: 'ui-navigation' };
|
|
48
52
|
}
|
|
@@ -186,6 +190,16 @@ async function requestBackground(page, method, args, timeoutMs = 20000) {
|
|
|
186
190
|
return result;
|
|
187
191
|
}
|
|
188
192
|
|
|
193
|
+
async function triggerBackground(page, method, args) {
|
|
194
|
+
return page.evaluate(`(() => {
|
|
195
|
+
const request = globalThis.stateHooks?.submitRequestToBackground;
|
|
196
|
+
if (typeof request !== 'function') throw new Error('stateHooks.submitRequestToBackground is unavailable; cannot call ${method}.');
|
|
197
|
+
const pending = request(${JSON.stringify(method)}, ${JSON.stringify(args)});
|
|
198
|
+
Promise.resolve(pending).catch(() => {});
|
|
199
|
+
return { requested: true };
|
|
200
|
+
})()`);
|
|
201
|
+
}
|
|
202
|
+
|
|
189
203
|
function isTransientClosePriceError(result) {
|
|
190
204
|
const message = String(result?.error ?? result?.message ?? '');
|
|
191
205
|
// Retry local pricing/slippage races only. Provider-level oracle rejections
|
|
@@ -422,9 +436,9 @@ export async function ensurePositions(input) {
|
|
|
422
436
|
const current = await withExtensionPage(input, async (page) => {
|
|
423
437
|
const stateResult = await page.readPositions();
|
|
424
438
|
if (!stateResult.available) throw new Error('stateHooks.submitRequestToBackground is unavailable; cannot ensure live Perps positions.');
|
|
425
|
-
return stateResult.positions;
|
|
439
|
+
return positionSelectionState(input, stateResult.positions);
|
|
426
440
|
});
|
|
427
|
-
if (
|
|
441
|
+
if (!current.hasMatching) await placeOrder(input);
|
|
428
442
|
return assertPositions(input, true);
|
|
429
443
|
}
|
|
430
444
|
throw new Error(`metamask.perps.ensure_positions received unsupported state: ${state}`);
|
|
@@ -573,14 +587,16 @@ async function readPerpsRuntimeState(page) {
|
|
|
573
587
|
const hooks = globalThis.stateHooks || {};
|
|
574
588
|
const request = hooks.submitRequestToBackground;
|
|
575
589
|
const reduxState = hooks.store?.getState?.() || {};
|
|
576
|
-
const cleanState = hooks.getCleanAppState?.() || hooks.getState?.() || {};
|
|
590
|
+
const cleanState = (await hooks.getCleanAppState?.()) || hooks.getState?.() || {};
|
|
591
|
+
const persisted = await globalThis.chrome?.storage?.local?.get?.('data');
|
|
577
592
|
// Extension flattens PerpsController state into state.metamask via ComposableObservableStore.getFlatState().
|
|
578
593
|
const metamask = reduxState.metamask || cleanState.metamask || {};
|
|
579
|
-
const nestedPerps = metamask.PerpsController || cleanState.PerpsController || cleanState.perps || {};
|
|
594
|
+
const nestedPerps = persisted?.data?.PerpsController || metamask.PerpsController || cleanState.PerpsController || cleanState.perps || {};
|
|
580
595
|
return {
|
|
581
596
|
available: typeof request === 'function',
|
|
582
597
|
activeProvider: metamask.activeProvider || nestedPerps.activeProvider || 'hyperliquid',
|
|
583
|
-
|
|
598
|
+
networkKnown: typeof nestedPerps.isTestnet === 'boolean',
|
|
599
|
+
isTestnet: Boolean(nestedPerps.isTestnet),
|
|
584
600
|
initializationState: metamask.initializationState || nestedPerps.initializationState || null,
|
|
585
601
|
isEligible: Boolean(metamask.isEligible ?? nestedPerps.isEligible),
|
|
586
602
|
};
|
|
@@ -607,8 +623,11 @@ async function ensureNetwork(input, config) {
|
|
|
607
623
|
return withExtensionPage(input, async (page) => {
|
|
608
624
|
const before = await readPerpsRuntimeState(page);
|
|
609
625
|
if (!before.available) throw new Error('stateHooks.submitRequestToBackground is unavailable; cannot enforce Perps network.');
|
|
626
|
+
if (!before.networkKnown) throw new Error('Unable to read the current Perps network from persisted controller state.');
|
|
610
627
|
if (Boolean(before.isTestnet) === expectedTestnet) return { requested: config.network, isTestnet: before.isTestnet, changed: false };
|
|
611
|
-
|
|
628
|
+
// The toggle request can remain pending after the controller state changes.
|
|
629
|
+
// Convergence is proved by the bounded state poll below, not request completion.
|
|
630
|
+
const result = await triggerBackground(page, 'perpsToggleTestnet', []);
|
|
612
631
|
const after = await waitForNetworkState(page, expectedTestnet, Number(input.node?.timeout_ms ?? 30000));
|
|
613
632
|
if (Boolean(after.isTestnet) !== expectedTestnet) {
|
|
614
633
|
throw new Error(`Expected Perps network ${config.network}, got isTestnet=${after.isTestnet}.`);
|
|
@@ -660,13 +679,15 @@ async function assertBalance(input, config) {
|
|
|
660
679
|
export async function startState(input) {
|
|
661
680
|
const params = paramsForState(input);
|
|
662
681
|
const config = mergeStateConfig(profileDefaults(params.profile), params);
|
|
682
|
+
// Enter the Perps surface first so its stream bridge/controller API exists
|
|
683
|
+
// before provider, network, order, or position convergence begins.
|
|
684
|
+
const navigation = await applyStateNavigation(input, config);
|
|
663
685
|
const provider = await ensureProvider(input, config);
|
|
664
686
|
const network = await ensureNetwork(input, config);
|
|
665
687
|
const readyToTrade = await assertReadyToTrade(input, config);
|
|
666
688
|
const balance = await assertBalance(input, config);
|
|
667
689
|
const orders = await applyOrdersState(input, config.orders);
|
|
668
690
|
const positions = await applyPositionsState(input, config.positions);
|
|
669
|
-
const navigation = await applyStateNavigation(input, config);
|
|
670
691
|
return {
|
|
671
692
|
action: input.action,
|
|
672
693
|
profile: config.profile ?? params.profile ?? 'clean_market_testnet',
|
|
@@ -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 ||
|