@deeeed/metamask-harness 0.4.0 → 0.5.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/adapters/core/surface.js +53 -0
  3. package/dist/adapters/extension/ensure-ready.js +109 -0
  4. package/dist/adapters/extension/extension-id.js +62 -0
  5. package/dist/adapters/extension/runtime-decision.js +305 -0
  6. package/dist/adapters/extension/runtime.js +324 -0
  7. package/dist/adapters/extension/surface.js +69 -0
  8. package/dist/adapters/mobile/deps-markers.js +22 -0
  9. package/dist/adapters/mobile/prepare.js +146 -0
  10. package/dist/adapters/mobile/provision.js +465 -0
  11. package/dist/adapters/mobile/runtime-decision.js +315 -0
  12. package/dist/adapters/mobile/surface.js +54 -0
  13. package/dist/adapters/slot-ports.js +146 -0
  14. package/dist/adapters/surface.js +14 -0
  15. package/dist/adapters.js +485 -0
  16. package/dist/cli-color.js +79 -0
  17. package/dist/cli-commands.js +224 -0
  18. package/dist/cli-version.js +111 -0
  19. package/dist/cli.js +1571 -0
  20. package/dist/commands/debug.js +56 -0
  21. package/dist/commands/fixtures.js +153 -0
  22. package/dist/commands/launch.js +325 -0
  23. package/dist/commands/logs.js +73 -0
  24. package/dist/commands/shared.js +157 -0
  25. package/dist/commands/update.js +243 -0
  26. package/dist/completions-cache.js +53 -0
  27. package/dist/doctor.js +169 -0
  28. package/dist/harness.js +627 -0
  29. package/dist/heal-bounds.js +120 -0
  30. package/dist/index.js +25 -0
  31. package/dist/leaf-invoke.js +19 -0
  32. package/dist/live-adapter-contract.js +240 -0
  33. package/dist/manifest.js +37 -0
  34. package/dist/mm-harness-cli.js +521 -0
  35. package/dist/paths.js +179 -0
  36. package/dist/progress.js +94 -0
  37. package/dist/recording-target.js +133 -0
  38. package/dist/run-recording.js +271 -0
  39. package/dist/runner.js +88 -0
  40. package/dist/types.js +0 -0
  41. package/docs/CLI-SPEC.md +26 -3
  42. package/package.json +5 -1
  43. package/src/adapters/core/surface.ts +15 -0
  44. package/src/adapters/extension/surface.ts +20 -3
  45. package/src/adapters/mobile/provision.ts +594 -0
  46. package/src/adapters/mobile/surface.ts +16 -4
  47. package/src/adapters/slot-ports.ts +1 -1
  48. package/src/adapters/surface.ts +35 -0
  49. package/src/cli-commands.ts +1 -1
  50. package/src/cli.ts +149 -6
  51. package/src/harness.ts +140 -3
  52. package/src/mm-harness-cli.ts +52 -5
package/dist/paths.js ADDED
@@ -0,0 +1,179 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+ const runnerDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
5
+ const pathDefaults = readPathDefaults();
6
+ const DEFAULT_RECIPE_RUNTIME_DIR = pathDefaults.recipeRuntimeDir;
7
+ const DEFAULT_RECIPE_HARNESS_ROOT = pathDefaults.recipeHarnessRoot;
8
+ function recipeRuntimeDir() {
9
+ return validateRelativeRecipePath("RECIPE_RUNTIME_DIR", process.env.RECIPE_RUNTIME_DIR || DEFAULT_RECIPE_RUNTIME_DIR);
10
+ }
11
+ function recipeHarnessRoot() {
12
+ return validateRelativeRecipePath("RECIPE_HARNESS_ROOT", process.env.RECIPE_HARNESS_ROOT || DEFAULT_RECIPE_HARNESS_ROOT);
13
+ }
14
+ function readPathDefaults() {
15
+ const defaultsPath = path.join(runnerDir, "adapters/shared/path-defaults.json");
16
+ const parsed = JSON.parse(fs.readFileSync(defaultsPath, "utf8"));
17
+ return {
18
+ recipeRuntimeDir: validateRelativeRecipePath("recipeRuntimeDir", parsed.recipeRuntimeDir || ""),
19
+ recipeHarnessRoot: validateRelativeRecipePath("recipeHarnessRoot", parsed.recipeHarnessRoot || "")
20
+ };
21
+ }
22
+ function validateRelativeRecipePath(name, value) {
23
+ if (!value || path.isAbsolute(value)) throw new Error(`${name} must be a non-empty relative path: ${value}`);
24
+ if (!/^[A-Za-z0-9._/-]+$/u.test(value)) throw new Error(`${name} contains unsupported characters: ${value}`);
25
+ for (const part of value.split("/")) {
26
+ if (!part || part === "." || part === "..") throw new Error(`${name} contains unsafe path component: ${value}`);
27
+ }
28
+ return value;
29
+ }
30
+ function recipeRuntimePath(projectRoot, ...segments) {
31
+ return path.join(projectRoot, recipeRuntimeDir(), ...segments);
32
+ }
33
+ function recipeHarnessPath(projectRoot, ...segments) {
34
+ return path.join(projectRoot, recipeHarnessRoot(), ...segments);
35
+ }
36
+ function walletFixturePath(projectRoot) {
37
+ return recipeRuntimePath(projectRoot, "wallet-fixture.json");
38
+ }
39
+ function extensionIdPath(projectRoot) {
40
+ return recipeRuntimePath(projectRoot, "extension.id");
41
+ }
42
+ function recipeWatchLogCandidates() {
43
+ return [
44
+ path.join(recipeRuntimeDir(), "webpack.log"),
45
+ path.join(recipeRuntimeDir(), "recipe-harness-webpack.log")
46
+ ];
47
+ }
48
+ function resolveLocalProtocolRoot() {
49
+ const candidates = [
50
+ process.env.FARMSLOT_ROOT,
51
+ readConfiguredProtocolRoot(),
52
+ findProtocolRoot(runnerDir),
53
+ findProtocolRoot(process.cwd())
54
+ ].filter(Boolean);
55
+ const root = candidates[0];
56
+ return root ? path.resolve(root) : void 0;
57
+ }
58
+ function resolveRequiredLocalProtocolRoot(reason) {
59
+ const root = resolveLocalProtocolRoot();
60
+ if (!root) {
61
+ throw new Error(
62
+ `${reason} requires a local protocol/runtime checkout. Set FARMSLOT_ROOT or create .farmslot-root for this dev-only path.`
63
+ );
64
+ }
65
+ return root;
66
+ }
67
+ function readConfiguredProtocolRoot() {
68
+ const configPath = path.join(runnerDir, ".farmslot-root");
69
+ if (!fs.existsSync(configPath)) return void 0;
70
+ const value = fs.readFileSync(configPath, "utf8").trim();
71
+ return value || void 0;
72
+ }
73
+ function findProtocolRoot(start) {
74
+ let dir = path.resolve(start);
75
+ while (dir !== path.dirname(dir)) {
76
+ if (isProtocolRoot(dir)) return dir;
77
+ const sibling = path.join(dir, "farmslot");
78
+ if (isProtocolRoot(sibling)) return sibling;
79
+ dir = path.dirname(dir);
80
+ }
81
+ return void 0;
82
+ }
83
+ function isProtocolRoot(candidate) {
84
+ return fs.existsSync(path.join(candidate, "packages/recipe-harness/package.json")) && fs.existsSync(path.join(candidate, "packages/protocol/package.json"));
85
+ }
86
+ function assertAdapter(adapter) {
87
+ if (adapter !== "mobile" && adapter !== "extension" && adapter !== "core") {
88
+ throw new Error("Adapter must be mobile, extension, or core.");
89
+ }
90
+ }
91
+ function manifestPath(adapter) {
92
+ assertAdapter(adapter);
93
+ return path.join(runnerDir, "library/manifests", `${adapter}.action-manifest.json`);
94
+ }
95
+ function recipePath(name) {
96
+ return path.join(runnerDir, "library/recipes", name);
97
+ }
98
+ function readJson(file) {
99
+ return JSON.parse(fs.readFileSync(file, "utf8"));
100
+ }
101
+ async function importRecipeHarness() {
102
+ return importProtocolPackage(
103
+ "@farmslot/recipe-harness",
104
+ "packages/recipe-harness/src/index.ts"
105
+ );
106
+ }
107
+ async function importRecipeHarnessRuntimeCdp() {
108
+ return importProtocolPackage(
109
+ "@farmslot/recipe-harness/runtime/cdp",
110
+ "packages/recipe-harness/src/runtime/cdp.ts"
111
+ );
112
+ }
113
+ async function importRecipeHarnessRuntimeBrowserExtension() {
114
+ return importProtocolPackage(
115
+ "@farmslot/recipe-harness/runtime/browser-extension",
116
+ "packages/recipe-harness/src/runtime/browser-extension.ts"
117
+ );
118
+ }
119
+ async function importRecipeHarnessRuntimeReactNativeBridge() {
120
+ return importProtocolPackage(
121
+ "@farmslot/recipe-harness/runtime/react-native-bridge",
122
+ "packages/recipe-harness/src/runtime/react-native-bridge.ts"
123
+ );
124
+ }
125
+ async function importRecipeHarnessCli() {
126
+ return importProtocolPackage(
127
+ "@farmslot/recipe-harness/cli",
128
+ "packages/recipe-harness/src/cli/index.ts"
129
+ );
130
+ }
131
+ async function importRecipeProtocol() {
132
+ return importProtocolPackage(
133
+ "@farmslot/protocol",
134
+ "packages/protocol/src/index.ts"
135
+ );
136
+ }
137
+ async function importProtocolPackage(packageName, localSourceEntry) {
138
+ try {
139
+ return await import(packageName);
140
+ } catch (error) {
141
+ if (!isMissingPackageError(error, packageName)) throw error;
142
+ }
143
+ const root = resolveLocalProtocolRoot();
144
+ if (!root) {
145
+ throw new Error(
146
+ `${packageName} is not installed. Install @farmslot/* packages normally, or set FARMSLOT_ROOT/use npm run dev:link-farmslot while co-developing protocol packages locally.`
147
+ );
148
+ }
149
+ return import(pathToFileURL(path.join(root, localSourceEntry)).href);
150
+ }
151
+ function isMissingPackageError(error, packageName) {
152
+ if (!(error instanceof Error)) return false;
153
+ const code = error.code;
154
+ return code === "ERR_MODULE_NOT_FOUND" && error.message.includes(packageName);
155
+ }
156
+ export {
157
+ DEFAULT_RECIPE_HARNESS_ROOT,
158
+ DEFAULT_RECIPE_RUNTIME_DIR,
159
+ assertAdapter,
160
+ extensionIdPath,
161
+ importRecipeHarness,
162
+ importRecipeHarnessCli,
163
+ importRecipeHarnessRuntimeBrowserExtension,
164
+ importRecipeHarnessRuntimeCdp,
165
+ importRecipeHarnessRuntimeReactNativeBridge,
166
+ importRecipeProtocol,
167
+ manifestPath,
168
+ readJson,
169
+ recipeHarnessPath,
170
+ recipeHarnessRoot,
171
+ recipePath,
172
+ recipeRuntimeDir,
173
+ recipeRuntimePath,
174
+ recipeWatchLogCandidates,
175
+ resolveLocalProtocolRoot,
176
+ resolveRequiredLocalProtocolRoot,
177
+ runnerDir,
178
+ walletFixturePath
179
+ };
@@ -0,0 +1,94 @@
1
+ import path from "node:path";
2
+ import { parseArgs } from "node:util";
3
+ import { color } from "./cli-color.js";
4
+ function shouldEmitJson(explicit) {
5
+ if (explicit) return true;
6
+ const env = process.env.RECIPE_PROGRESS_JSON;
7
+ return env === "1" || env === "true";
8
+ }
9
+ function emitProgress(event, { json = false } = {}) {
10
+ const payload = {
11
+ schemaVersion: 1,
12
+ type: "progress",
13
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
14
+ ...event
15
+ };
16
+ if (json || shouldEmitJson(false)) {
17
+ process.stderr.write(`${JSON.stringify(payload)}
18
+ `);
19
+ return;
20
+ }
21
+ const stream = process.stderr;
22
+ const prefix = color("label", "recipe", { stream });
23
+ if (payload.phase === "done") {
24
+ const statusStyle = payload.status === "pass" ? "ok" : "err";
25
+ const statusWord = payload.status === "pass" ? "done" : "failed";
26
+ const cmd2 = payload.command ? color("cmd", `${payload.command}`, { stream }) : "";
27
+ const elapsed2 = Number.isFinite(payload.elapsedMs) ? color("comment", ` (${Math.round(payload.elapsedMs / 1e3)}s)`, { stream }) : "";
28
+ process.stderr.write(`${prefix}: ${cmd2} ${color(statusStyle, statusWord, { stream })} ${payload.message || ""}${elapsed2}
29
+ `);
30
+ return;
31
+ }
32
+ const cmd = payload.command ? `${color("cmd", payload.command, { stream })}: ` : "";
33
+ const phase = payload.phase ? `${color("accent", `[${payload.phase}]`, { stream })} ` : "";
34
+ const elapsed = Number.isFinite(payload.elapsedMs) ? color("comment", ` (${Math.round(payload.elapsedMs / 1e3)}s)`, { stream }) : "";
35
+ process.stderr.write(`${prefix}: ${cmd}${phase}${payload.message || ""}${elapsed}
36
+ `);
37
+ }
38
+ function usage() {
39
+ console.error(`Usage:
40
+ progress emit --command <name> --phase <id> --message <text> [--elapsed-ms <n>] [--json]
41
+ progress done --command <name> --status pass|fail --message <text> [--elapsed-ms <n>] [--json]`);
42
+ }
43
+ async function main() {
44
+ const [command, ...rest] = process.argv.slice(2);
45
+ if (!command || command === "--help" || command === "-h") {
46
+ usage();
47
+ process.exit(command ? 0 : 2);
48
+ }
49
+ const { values } = parseArgs({
50
+ args: rest,
51
+ options: {
52
+ command: { type: "string" },
53
+ phase: { type: "string" },
54
+ message: { type: "string" },
55
+ status: { type: "string" },
56
+ "elapsed-ms": { type: "string" },
57
+ json: { type: "boolean", default: false }
58
+ },
59
+ allowPositionals: false
60
+ });
61
+ const elapsedMs = values["elapsed-ms"] ? Number.parseInt(values["elapsed-ms"], 10) : void 0;
62
+ const json = values.json || shouldEmitJson(false);
63
+ if (command === "emit") {
64
+ emitProgress({
65
+ command: values.command || "recipe",
66
+ phase: values.phase || "working",
67
+ message: values.message || "",
68
+ elapsedMs: Number.isFinite(elapsedMs) ? elapsedMs : void 0
69
+ }, { json });
70
+ return;
71
+ }
72
+ if (command === "done") {
73
+ emitProgress({
74
+ command: values.command || "recipe",
75
+ phase: "done",
76
+ status: values.status || "pass",
77
+ message: values.message || "",
78
+ elapsedMs: Number.isFinite(elapsedMs) ? elapsedMs : void 0
79
+ }, { json });
80
+ return;
81
+ }
82
+ usage();
83
+ process.exit(2);
84
+ }
85
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename ?? "");
86
+ if (isMain) {
87
+ main().catch((error) => {
88
+ console.error(error instanceof Error ? error.message : String(error));
89
+ process.exit(1);
90
+ });
91
+ }
92
+ export {
93
+ emitProgress
94
+ };
@@ -0,0 +1,133 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ function createMetaMaskRecordingTargetProvider(adapter) {
5
+ return {
6
+ async resolveRecordingTarget(context) {
7
+ if (adapter === "extension") {
8
+ const pid = resolveExtensionBrowserPid(
9
+ context.projectRoot,
10
+ context.artifactsDir,
11
+ context.env.CDP_PORT ?? context.env.RECIPE_CDP_PORT ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT
12
+ );
13
+ if (!pid) {
14
+ throw new Error(
15
+ "Could not resolve the extension browser PID for --record-video. Ensure the slot browser is running and CDP_PORT is set."
16
+ );
17
+ }
18
+ return { kind: "pid", pid };
19
+ }
20
+ if (adapter === "mobile") {
21
+ const platform = (context.env.PLATFORM ?? process.env.PLATFORM ?? "ios").trim().toLowerCase();
22
+ if (platform === "android" || context.env.ADB_SERIAL || process.env.ADB_SERIAL) {
23
+ throw new Error("--record-video is not implemented for mobile Android replay yet.");
24
+ }
25
+ const simulator = resolveMobileIosSimulatorName(context.env);
26
+ if (!simulator) {
27
+ throw new Error(
28
+ "Could not resolve an iOS simulator name for --record-video. Set IOS_SIMULATOR or pass --simulator to the recipe hook."
29
+ );
30
+ }
31
+ const pid = resolveMobileIosSimulatorPid(simulator);
32
+ if (pid) return { kind: "pid", pid };
33
+ return { kind: "app-window", appName: "Simulator", windowName: simulator };
34
+ }
35
+ throw new Error(`--record-video is not implemented for the ${adapter} adapter.`);
36
+ }
37
+ };
38
+ }
39
+ function captureHelperSupportsRecordSessionSnapshots(projectRoot) {
40
+ const result = spawnSync(captureHelperPath(), ["version", "--json"], {
41
+ cwd: projectRoot,
42
+ encoding: "utf8"
43
+ });
44
+ if (result.status !== 0) return false;
45
+ try {
46
+ const parsed = JSON.parse(result.stdout);
47
+ return Array.isArray(parsed.capabilities) && parsed.capabilities.includes("record_session_snapshot");
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+ function resolveMobileIosSimulatorName(env = process.env) {
53
+ const explicit = env.IOS_SIMULATOR?.trim() || env.SIM_UDID?.trim();
54
+ if (explicit) return explicit;
55
+ const slot = env.FARMSLOT_SLOT_ID?.trim() || env.SLOT_ID?.trim();
56
+ if (!slot) return void 0;
57
+ const suffix = slot.includes("-") ? slot.slice(slot.lastIndexOf("-") + 1) : slot;
58
+ return suffix || void 0;
59
+ }
60
+ function resolveMobileIosSimulatorPid(simulator) {
61
+ const result = spawnSync(
62
+ captureHelperPath(),
63
+ ["resolve", "--app-name", "Simulator", "--window-name", simulator, "--json"],
64
+ { encoding: "utf8" }
65
+ );
66
+ if (result.status !== 0) return null;
67
+ try {
68
+ const parsed = JSON.parse(result.stdout);
69
+ return parsePositivePid(parsed.selected?.pid);
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+ function resolveExtensionBrowserPid(projectRoot, artifactsDir, cdpPort) {
75
+ const explicit = parsePositivePid(process.env.METAMASK_RECIPE_EXTENSION_BROWSER_PID);
76
+ if (explicit) return explicit;
77
+ if (cdpPort) {
78
+ const lsof = spawnSync("lsof", ["-nP", `-iTCP:${cdpPort}`, "-sTCP:LISTEN", "-t"], {
79
+ cwd: projectRoot,
80
+ encoding: "utf8"
81
+ });
82
+ if (lsof.status === 0) {
83
+ const pid = parsePositivePid(String(lsof.stdout).split(/\s+/u).find(Boolean));
84
+ if (pid) return pid;
85
+ }
86
+ }
87
+ for (const file of [
88
+ path.join(artifactsDir, "extension-runtime/runtime.json"),
89
+ path.join(projectRoot, "temp/recipe/runtime/runtime.json")
90
+ ]) {
91
+ const pid = parsePositivePid(readJsonFile(file)?.pid);
92
+ if (pid) return pid;
93
+ }
94
+ for (const file of [
95
+ path.join(projectRoot, "temp/recipe/runtime/chromium.pid"),
96
+ path.join(projectRoot, "temp/recipe/runtime/browser.pid")
97
+ ]) {
98
+ const pid = parsePositivePid(readTextFile(file));
99
+ if (pid) return pid;
100
+ }
101
+ return null;
102
+ }
103
+ function captureHelperPath() {
104
+ return process.env.CAPTURE_HELPER_PATH || "capture-helper";
105
+ }
106
+ function parsePositivePid(value) {
107
+ const pid = Number(String(value ?? "").trim());
108
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
109
+ }
110
+ function readJsonFile(file) {
111
+ if (!fs.existsSync(file)) return null;
112
+ try {
113
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
114
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+ function readTextFile(file) {
120
+ if (!fs.existsSync(file)) return "";
121
+ try {
122
+ return fs.readFileSync(file, "utf8");
123
+ } catch {
124
+ return "";
125
+ }
126
+ }
127
+ export {
128
+ captureHelperSupportsRecordSessionSnapshots,
129
+ createMetaMaskRecordingTargetProvider,
130
+ resolveExtensionBrowserPid,
131
+ resolveMobileIosSimulatorName,
132
+ resolveMobileIosSimulatorPid
133
+ };
@@ -0,0 +1,271 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import {
5
+ captureHelperSupportsRecordSessionSnapshots,
6
+ resolveExtensionBrowserPid
7
+ } from "./recording-target.js";
8
+ const activeRecordingsByPid = /* @__PURE__ */ new Map();
9
+ async function startRecipeRecording(adapter, projectRoot, artifactsDir, options) {
10
+ if (!options.record) return void 0;
11
+ if (process.platform !== "darwin") {
12
+ console.error(
13
+ "WARN: --record uses capture-helper and is currently supported only on macOS; continuing without video."
14
+ );
15
+ return void 0;
16
+ }
17
+ if (adapter !== "extension") return void 0;
18
+ const cdpPort = options.cdpPort ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
19
+ if (!captureHelperSupportsRecordSessionSnapshots(projectRoot)) {
20
+ console.error(
21
+ "WARN: framed extension recording requires capture-helper capability record_session_snapshot; falling back to harness --record-video."
22
+ );
23
+ return void 0;
24
+ }
25
+ const pid = resolveExtensionBrowserPid(projectRoot, artifactsDir, cdpPort);
26
+ if (!pid) {
27
+ console.error(
28
+ `WARN: framed extension recording could not resolve browser PID from CDP port ${cdpPort ?? "<unset>"}; falling back to harness --record-video.`
29
+ );
30
+ return void 0;
31
+ }
32
+ const recordArgs = ["record", "--framed", "--pid", String(pid)];
33
+ const relativePath = "videos/full-run.mp4";
34
+ const outputPath = path.join(artifactsDir, relativePath);
35
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
36
+ fs.rmSync(outputPath, { force: true });
37
+ const child = spawn(captureHelperPath(), [...recordArgs, "--output", outputPath], {
38
+ cwd: projectRoot,
39
+ env: process.env,
40
+ stdio: ["pipe", "pipe", "pipe"]
41
+ });
42
+ const recording = {
43
+ child,
44
+ outputPath,
45
+ relativePath,
46
+ pid,
47
+ stdout: "",
48
+ stderr: "",
49
+ exited: false,
50
+ exitCode: null,
51
+ stderrBuffer: "",
52
+ pendingSnapshots: /* @__PURE__ */ new Map()
53
+ };
54
+ child.stdout.on("data", (chunk) => {
55
+ recording.stdout += String(chunk);
56
+ });
57
+ child.stderr.on("data", (chunk) => {
58
+ recording.stderr += String(chunk);
59
+ handleRecordingStderr(recording, String(chunk));
60
+ });
61
+ child.on("error", (error) => {
62
+ recording.error = error;
63
+ recording.stderr += error.message;
64
+ recording.exited = true;
65
+ });
66
+ child.on("close", (exitCode) => {
67
+ recording.exited = true;
68
+ recording.exitCode = exitCode;
69
+ activeRecordingsByPid.delete(recording.pid);
70
+ rejectPendingSnapshots(recording, new Error(`capture-helper recording exited before snapshot completed (code=${exitCode ?? "unknown"})`));
71
+ });
72
+ await sleep(750);
73
+ if (recording.exited) {
74
+ console.error(
75
+ `WARN: capture-helper record exited before the recipe started (code=${recording.exitCode ?? "unknown"}): ${recording.stderr || recording.stdout}`
76
+ );
77
+ return void 0;
78
+ }
79
+ activeRecordingsByPid.set(pid, recording);
80
+ console.error(`INFO: recording recipe video with capture-helper pid=${pid} output=${outputPath}`);
81
+ return recording;
82
+ }
83
+ async function captureActiveRecipeRecordingSnapshot(pid, outputPath, timeoutMs = 3e4) {
84
+ const recording = activeRecordingsByPid.get(pid);
85
+ if (!recording || recording.exited) return void 0;
86
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
87
+ fs.rmSync(outputPath, { force: true });
88
+ return new Promise((resolve, reject) => {
89
+ const timer = setTimeout(() => {
90
+ recording.pendingSnapshots.delete(outputPath);
91
+ reject(new Error(`capture-helper record session snapshot timed out after ${timeoutMs}ms: ${outputPath}`));
92
+ }, timeoutMs);
93
+ recording.pendingSnapshots.set(outputPath, { outputPath, timer, resolve, reject });
94
+ recording.child.stdin.write(`snapshot ${outputPath}
95
+ `, (error) => {
96
+ if (!error) return;
97
+ clearTimeout(timer);
98
+ recording.pendingSnapshots.delete(outputPath);
99
+ reject(error);
100
+ });
101
+ });
102
+ }
103
+ async function stopRecipeRecording(recording, result) {
104
+ if (!recording) return;
105
+ if (!recording.exited) {
106
+ recording.child.stdin.end("stop\n");
107
+ await waitForRecordingExit(recording, 15e3);
108
+ }
109
+ if (!recording.exited) {
110
+ recording.child.kill("SIGINT");
111
+ await waitForRecordingExit(recording, 5e3);
112
+ }
113
+ if (!recording.exited) {
114
+ recording.child.kill("SIGTERM");
115
+ await waitForRecordingExit(recording, 3e3);
116
+ }
117
+ const validation = validateRecordingArtifact(recording);
118
+ if (validation.ok === false) {
119
+ try {
120
+ fs.rmSync(recording.outputPath, { force: true });
121
+ } catch (error) {
122
+ console.error(
123
+ `WARN: could not remove unusable capture-helper video ${recording.outputPath}: ${error instanceof Error ? error.message : String(error)}`
124
+ );
125
+ }
126
+ console.error(
127
+ `WARN: capture-helper recording did not produce a usable video artifact: ${validation.reason}`
128
+ );
129
+ if (result) removeRecordingArtifactFromManifest(result, recording.relativePath);
130
+ return;
131
+ }
132
+ if (result) addRecordingArtifactToManifest(result, recording);
133
+ }
134
+ async function waitForRecordingExit(recording, timeoutMs) {
135
+ if (recording.exited) return;
136
+ await new Promise((resolve) => {
137
+ const timer = setTimeout(resolve, timeoutMs);
138
+ recording.child.once("close", () => {
139
+ clearTimeout(timer);
140
+ resolve();
141
+ });
142
+ });
143
+ }
144
+ function handleRecordingStderr(recording, chunk) {
145
+ recording.stderrBuffer += chunk;
146
+ let newlineIndex = recording.stderrBuffer.indexOf("\n");
147
+ while (newlineIndex !== -1) {
148
+ const line = recording.stderrBuffer.slice(0, newlineIndex).trim();
149
+ recording.stderrBuffer = recording.stderrBuffer.slice(newlineIndex + 1);
150
+ if (line) handleRecordingEventLine(recording, line);
151
+ newlineIndex = recording.stderrBuffer.indexOf("\n");
152
+ }
153
+ }
154
+ function handleRecordingEventLine(recording, line) {
155
+ let event;
156
+ try {
157
+ const parsed = JSON.parse(line);
158
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
159
+ event = parsed;
160
+ } catch {
161
+ return;
162
+ }
163
+ const output = typeof event.output === "string" ? event.output : void 0;
164
+ if (!output) return;
165
+ const pending = recording.pendingSnapshots.get(output);
166
+ if (!pending) return;
167
+ if (event.type === "snapshot") {
168
+ clearTimeout(pending.timer);
169
+ recording.pendingSnapshots.delete(output);
170
+ pending.resolve(event);
171
+ return;
172
+ }
173
+ if (event.type === "error") {
174
+ clearTimeout(pending.timer);
175
+ recording.pendingSnapshots.delete(output);
176
+ pending.reject(new Error(String(event.message ?? `capture-helper snapshot failed: ${output}`)));
177
+ }
178
+ }
179
+ function rejectPendingSnapshots(recording, error) {
180
+ for (const pending of recording.pendingSnapshots.values()) {
181
+ clearTimeout(pending.timer);
182
+ pending.reject(error);
183
+ }
184
+ recording.pendingSnapshots.clear();
185
+ }
186
+ function validateRecordingArtifact(recording) {
187
+ if (!fs.existsSync(recording.outputPath)) {
188
+ return { ok: false, reason: `missing output ${recording.outputPath}` };
189
+ }
190
+ const size = fs.statSync(recording.outputPath).size;
191
+ if (size === 0) {
192
+ return { ok: false, reason: `empty output ${recording.outputPath}` };
193
+ }
194
+ const recorderOutput = `${recording.stdout}
195
+ ${recording.stderr}`;
196
+ if (!recorderOutput.includes("record_complete")) {
197
+ return {
198
+ ok: false,
199
+ reason: `capture-helper did not report record_complete for ${recording.outputPath}: ${recorderOutput.trim() || "no recorder output"}`
200
+ };
201
+ }
202
+ const ffprobe = spawnSync(
203
+ "ffprobe",
204
+ ["-hide_banner", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", recording.outputPath],
205
+ { encoding: "utf8" }
206
+ );
207
+ if (ffprobe.error && ffprobe.error.code === "ENOENT") {
208
+ return { ok: true };
209
+ }
210
+ if (ffprobe.error) {
211
+ return { ok: false, reason: `ffprobe failed for ${recording.outputPath}: ${ffprobe.error.message}` };
212
+ }
213
+ if (ffprobe.status !== 0) {
214
+ return {
215
+ ok: false,
216
+ reason: `invalid MP4 ${recording.outputPath}: ${ffprobe.stderr.trim() || ffprobe.stdout.trim() || `ffprobe exited ${ffprobe.status}`}`
217
+ };
218
+ }
219
+ const durationSeconds = Number(ffprobe.stdout.trim());
220
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
221
+ return { ok: false, reason: `MP4 has no positive duration: ${recording.outputPath}` };
222
+ }
223
+ return { ok: true };
224
+ }
225
+ function addRecordingArtifactToManifest(result, recording) {
226
+ const manifestPath = result.artifactManifestPath;
227
+ if (!manifestPath || !fs.existsSync(manifestPath)) {
228
+ console.error(`WARN: cannot add video artifact to missing artifact manifest: ${manifestPath ?? "<unset>"}`);
229
+ return;
230
+ }
231
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
232
+ if (!Array.isArray(manifest.artifacts)) manifest.artifacts = [];
233
+ manifest.artifacts = manifest.artifacts.filter((artifact) => artifact.path !== recording.relativePath);
234
+ manifest.artifacts.push({
235
+ path: recording.relativePath,
236
+ type: "video",
237
+ label: "Full recipe replay video",
238
+ category: "evidence",
239
+ mimeType: "video/mp4",
240
+ record: "full_run",
241
+ metadata: {
242
+ provider: "capture-helper",
243
+ mode: "full_run",
244
+ pid: recording.pid
245
+ }
246
+ });
247
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}
248
+ `);
249
+ }
250
+ function removeRecordingArtifactFromManifest(result, relativePath) {
251
+ const manifestPath = result.artifactManifestPath;
252
+ if (!manifestPath || !fs.existsSync(manifestPath)) return;
253
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
254
+ if (!Array.isArray(manifest.artifacts)) return;
255
+ const nextArtifacts = manifest.artifacts.filter((artifact) => artifact.path !== relativePath);
256
+ if (nextArtifacts.length === manifest.artifacts.length) return;
257
+ manifest.artifacts = nextArtifacts;
258
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}
259
+ `);
260
+ }
261
+ function captureHelperPath() {
262
+ return process.env.CAPTURE_HELPER_PATH || "capture-helper";
263
+ }
264
+ function sleep(ms) {
265
+ return new Promise((resolve) => setTimeout(resolve, ms));
266
+ }
267
+ export {
268
+ captureActiveRecipeRecordingSnapshot,
269
+ startRecipeRecording,
270
+ stopRecipeRecording
271
+ };