@deeeed/metamask-harness 0.26.4 → 0.27.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 +18 -0
- package/adapters/mobile/open-device.sh +16 -4
- package/adapters/mobile/start-metro.sh +33 -0
- package/dist/adapters/mobile/metro-env.js +9 -0
- package/dist/adapters/mobile/prepare.js +238 -37
- package/dist/adapters/mobile/runtime-decision.js +4 -1
- package/dist/adapters.js +19 -6
- package/dist/commands/device-target.js +4 -1
- package/dist/commands/doctor.js +28 -2
- package/dist/commands/mobile-device-view.js +4 -1
- package/dist/devices.js +4 -1
- package/dist/doctor.js +25 -2
- package/dist/run-recording.js +128 -92
- package/library/actions/extension/platform/cdp.mjs +170 -117
- package/library/actions/mobile/analytics/set_consent.mjs +79 -43
- package/library/actions/mobile/platform/bridge.mjs +21 -9
- package/library/actions/mobile/platform/observe-ui.mjs +416 -0
- package/library/actions/mobile/platform/tool-paths.mjs +122 -0
- package/library/actions/shared/analytics/collector.mjs +50 -5
- package/library/manifests/mobile.action-manifest.json +25 -1
- package/package.json +1 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -131,7 +131,31 @@ async function handleDoctor({ options }) {
|
|
|
131
131
|
}
|
|
132
132
|
return status === "pass" ? 0 : 1;
|
|
133
133
|
}
|
|
134
|
-
const
|
|
134
|
+
const mobilePlatform = adapter === "mobile" && (platformOption === "ios" || platformOption === "android") ? platformOption : void 0;
|
|
135
|
+
const result = createDoctorReport(
|
|
136
|
+
adapter,
|
|
137
|
+
target,
|
|
138
|
+
manifestValidation,
|
|
139
|
+
actionManifestPath,
|
|
140
|
+
mobilePlatform
|
|
141
|
+
);
|
|
142
|
+
const missingRequiredTool = result.checks.find(
|
|
143
|
+
(check) => check.required && check.status === "fail" && check.id.startsWith("device-tool-") && check.userAction
|
|
144
|
+
);
|
|
145
|
+
if (missingRequiredTool) {
|
|
146
|
+
const error = {
|
|
147
|
+
code: "DOCTOR_CHECKS_FAILED",
|
|
148
|
+
message: missingRequiredTool.message,
|
|
149
|
+
userAction: missingRequiredTool.userAction
|
|
150
|
+
};
|
|
151
|
+
if (json) {
|
|
152
|
+
console.log(JSON.stringify({ ...result, ready: false, error }, null, 2));
|
|
153
|
+
} else {
|
|
154
|
+
console.error(`\u2717 doctor: ${error.message}
|
|
155
|
+
Next: ${error.userAction}`);
|
|
156
|
+
}
|
|
157
|
+
return EXIT.runtime;
|
|
158
|
+
}
|
|
135
159
|
let runtime;
|
|
136
160
|
let runtimeProbeError;
|
|
137
161
|
try {
|
|
@@ -178,7 +202,9 @@ async function handleDoctor({ options }) {
|
|
|
178
202
|
printReady
|
|
179
203
|
);
|
|
180
204
|
}
|
|
181
|
-
const doctorUserAction =
|
|
205
|
+
const doctorUserAction = doctorResult.checks.find(
|
|
206
|
+
(check) => check.required && check.status === "fail" && check.userAction
|
|
207
|
+
)?.userAction ?? `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
|
|
182
208
|
const doctorError = doctorResult.status === "fail" ? { code: "DOCTOR_CHECKS_FAILED", message: "one or more required doctor checks failed", userAction: doctorUserAction } : void 0;
|
|
183
209
|
const next = doctorError?.userAction ?? (runtime?.decision === "ready" ? void 0 : runtime?.nextAction);
|
|
184
210
|
if (json) console.log(JSON.stringify({ ...doctorResult, ready: runtime?.decision === "ready", runtime, orphanMetros, capture, devices, additionalReachableDevices, ...next ? { next } : {}, ...doctorError ? { error: doctorError } : {} }, null, 2));
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { resolveMobileToolPath } from "../../library/actions/mobile/platform/tool-paths.mjs";
|
|
2
3
|
import { listConnectedDevices } from "../devices.js";
|
|
3
4
|
import { renderDeviceList, scopedDevices } from "./device-target.js";
|
|
4
5
|
import { probeMobileLiveState } from "./status-probe.js";
|
|
@@ -116,8 +117,10 @@ function selectedAndroidPortHints(devices, liveMap) {
|
|
|
116
117
|
}).filter((hint) => hint.reversePorts.length > 0);
|
|
117
118
|
}
|
|
118
119
|
function androidReversePorts(serial) {
|
|
120
|
+
const adbPath = resolveMobileToolPath("adb");
|
|
121
|
+
if (!adbPath) return [];
|
|
119
122
|
try {
|
|
120
|
-
const out = execFileSync(
|
|
123
|
+
const out = execFileSync(adbPath, ["-s", serial, "reverse", "--list"], {
|
|
121
124
|
encoding: "utf8",
|
|
122
125
|
stdio: ["ignore", "pipe", "ignore"],
|
|
123
126
|
timeout: 5e3
|
package/dist/devices.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { resolveMobileToolPath } from "../library/actions/mobile/platform/tool-paths.mjs";
|
|
2
3
|
function listConnectedDevices(platform) {
|
|
3
4
|
const devices = [];
|
|
4
5
|
if (!platform || platform === "android") devices.push(...listAndroidDevices());
|
|
@@ -6,9 +7,11 @@ function listConnectedDevices(platform) {
|
|
|
6
7
|
return devices;
|
|
7
8
|
}
|
|
8
9
|
function listAndroidDevices() {
|
|
10
|
+
const adbPath = resolveMobileToolPath("adb");
|
|
11
|
+
if (!adbPath) return [];
|
|
9
12
|
let output;
|
|
10
13
|
try {
|
|
11
|
-
output = execFileSync(
|
|
14
|
+
output = execFileSync(adbPath, ["devices", "-l"], {
|
|
12
15
|
encoding: "utf8",
|
|
13
16
|
timeout: 5e3,
|
|
14
17
|
stdio: ["ignore", "pipe", "ignore"]
|
package/dist/doctor.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { color } from "./cli-color.js";
|
|
4
|
+
import {
|
|
5
|
+
mobileToolRecovery,
|
|
6
|
+
resolveMobileToolPath
|
|
7
|
+
} from "../library/actions/mobile/platform/tool-paths.mjs";
|
|
4
8
|
import { mobilePerpsEnvironment } from "./adapters/mobile/perps-env.js";
|
|
5
9
|
import { readRuntimeContextField, resolveRuntimeContextPath } from "./harness.js";
|
|
6
10
|
import { manifestPath, readJson, recipeHarnessRoot, recipeRuntimeDir, runnerDir } from "./paths.js";
|
|
@@ -128,7 +132,7 @@ function renderRuntimeContext(runtimeContext) {
|
|
|
128
132
|
}
|
|
129
133
|
return lines.join("\n");
|
|
130
134
|
}
|
|
131
|
-
function createDoctorReport(adapter, target, manifestValidation, actionManifestPath = manifestPath(adapter)) {
|
|
135
|
+
function createDoctorReport(adapter, target, manifestValidation, actionManifestPath = manifestPath(adapter), mobilePlatform) {
|
|
132
136
|
const mode = compatibilityMode(adapter, target);
|
|
133
137
|
const manifestErrors = Number(manifestValidation.summary?.errors ?? 0);
|
|
134
138
|
const checks = [
|
|
@@ -143,7 +147,8 @@ function createDoctorReport(adapter, target, manifestValidation, actionManifestP
|
|
|
143
147
|
status: mode === "unsupported/no bridge" ? "fail" : "pass",
|
|
144
148
|
required: false,
|
|
145
149
|
message: mode === "unsupported/no bridge" ? `No ${adapter} bridge is available for this checkout.` : `${adapter} compatibility mode: ${mode}.`
|
|
146
|
-
}
|
|
150
|
+
},
|
|
151
|
+
...adapter === "mobile" ? mobileToolDoctorChecks(mobilePlatform) : []
|
|
147
152
|
];
|
|
148
153
|
const requiredChecks = requiredDoctorCheckSummary(checks);
|
|
149
154
|
return {
|
|
@@ -164,6 +169,23 @@ function createDoctorReport(adapter, target, manifestValidation, actionManifestP
|
|
|
164
169
|
manifestValidation: manifestValidation.summary
|
|
165
170
|
};
|
|
166
171
|
}
|
|
172
|
+
function mobileToolDoctorChecks(platform) {
|
|
173
|
+
const specs = platform === "android" ? [{ tool: "adb", platform: "Android" }] : platform === "ios" ? [{ tool: "idb", platform: "iOS" }] : [
|
|
174
|
+
{ tool: "adb", platform: "Android" },
|
|
175
|
+
{ tool: "idb", platform: "iOS" }
|
|
176
|
+
];
|
|
177
|
+
return specs.map(({ tool, platform: platformName }) => {
|
|
178
|
+
const resolved = resolveMobileToolPath(tool);
|
|
179
|
+
return {
|
|
180
|
+
id: `device-tool-${tool}`,
|
|
181
|
+
status: resolved ? "pass" : "fail",
|
|
182
|
+
required: platform !== void 0,
|
|
183
|
+
message: resolved ? `${platformName} device tool ${tool} is ready.` : `${platformName} device tool ${tool} is unavailable.`,
|
|
184
|
+
...resolved ? { detail: resolved } : {},
|
|
185
|
+
...!resolved ? { userAction: mobileToolRecovery(tool) } : {}
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
}
|
|
167
189
|
function runnerInstallKind(runnerRoot, invokedPath, executablePath) {
|
|
168
190
|
const normalizedRoot = path.normalize(runnerRoot);
|
|
169
191
|
const nodeModulesSegment = `${path.sep}node_modules${path.sep}`;
|
|
@@ -230,6 +252,7 @@ export {
|
|
|
230
252
|
createDoctorReport,
|
|
231
253
|
fixtureFileSummary,
|
|
232
254
|
fixtureSummary,
|
|
255
|
+
mobileToolDoctorChecks,
|
|
233
256
|
renderRuntimeContext,
|
|
234
257
|
repoShape,
|
|
235
258
|
requiredDoctorCheckSummary,
|
package/dist/run-recording.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
resolveExtensionBrowserPid
|
|
7
7
|
} from "./recording-target.js";
|
|
8
8
|
const activeRecordingsByPid = /* @__PURE__ */ new Map();
|
|
9
|
+
const ACTIVE_EXTENSION_RECORDING_PID = "METAMASK_RECIPE_EXTENSION_ACTIVE_RECORDING_PID";
|
|
9
10
|
async function startRecipeRecording(adapter, projectRoot, artifactsDir, options) {
|
|
10
11
|
if (!options.record) return void 0;
|
|
11
12
|
if (process.platform !== "darwin") {
|
|
@@ -31,17 +32,27 @@ async function startRecipeRecording(adapter, projectRoot, artifactsDir, options)
|
|
|
31
32
|
}
|
|
32
33
|
const recordArgs = ["record", "--framed", "--pid", String(pid)];
|
|
33
34
|
const relativePath = "videos/full-run.mp4";
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
const outputPath = path.join(artifactsDir, relativePath);
|
|
36
|
+
const { stagingDir, stagedPath } = preparePrivateRecordingDestination(
|
|
37
|
+
artifactsDir,
|
|
38
|
+
outputPath
|
|
39
|
+
);
|
|
40
|
+
let child;
|
|
41
|
+
try {
|
|
42
|
+
child = spawn(captureHelperPath(), [...recordArgs, "--output", stagedPath], {
|
|
43
|
+
cwd: projectRoot,
|
|
44
|
+
env: process.env,
|
|
45
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
46
|
+
});
|
|
47
|
+
} catch (error) {
|
|
48
|
+
cleanupRecordingStaging(stagingDir);
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
40
51
|
const recording = {
|
|
41
52
|
child,
|
|
42
|
-
outputPath
|
|
43
|
-
|
|
44
|
-
|
|
53
|
+
outputPath,
|
|
54
|
+
stagingDir,
|
|
55
|
+
stagedPath,
|
|
45
56
|
relativePath,
|
|
46
57
|
pid,
|
|
47
58
|
stdout: "",
|
|
@@ -50,6 +61,7 @@ async function startRecipeRecording(adapter, projectRoot, artifactsDir, options)
|
|
|
50
61
|
exitCode: null,
|
|
51
62
|
frameReady: false,
|
|
52
63
|
finalized: false,
|
|
64
|
+
previousActiveRecordingPid: process.env[ACTIVE_EXTENSION_RECORDING_PID],
|
|
53
65
|
stderrBuffer: "",
|
|
54
66
|
pendingSnapshots: /* @__PURE__ */ new Map()
|
|
55
67
|
};
|
|
@@ -69,11 +81,12 @@ async function startRecipeRecording(adapter, projectRoot, artifactsDir, options)
|
|
|
69
81
|
recording.exited = true;
|
|
70
82
|
recording.exitCode = exitCode;
|
|
71
83
|
activeRecordingsByPid.delete(recording.pid);
|
|
84
|
+
restoreActiveRecordingEnvironment(recording);
|
|
72
85
|
rejectPendingSnapshots(recording, new Error(`capture-helper recording exited before snapshot completed (code=${exitCode ?? "unknown"})`));
|
|
73
86
|
});
|
|
74
87
|
await waitForRecordingReady(recording, 15e3);
|
|
75
88
|
if (recording.exited) {
|
|
76
|
-
|
|
89
|
+
cleanupRecordingStaging(recording.stagingDir);
|
|
77
90
|
throw new Error(
|
|
78
91
|
`capture-helper exited before recording its first frame (code=${recording.exitCode ?? "unknown"}): ${recording.stderr || recording.stdout}`
|
|
79
92
|
);
|
|
@@ -82,14 +95,15 @@ async function startRecipeRecording(adapter, projectRoot, artifactsDir, options)
|
|
|
82
95
|
try {
|
|
83
96
|
await stopRecordingProcess(recording);
|
|
84
97
|
} finally {
|
|
85
|
-
|
|
98
|
+
cleanupRecordingStaging(recording.stagingDir);
|
|
86
99
|
}
|
|
87
100
|
throw new Error(
|
|
88
101
|
`capture-helper did not record a frame within 15000ms: ${recording.stderr || recording.stdout || "no recorder output"}`
|
|
89
102
|
);
|
|
90
103
|
}
|
|
91
104
|
activeRecordingsByPid.set(pid, recording);
|
|
92
|
-
|
|
105
|
+
process.env[ACTIVE_EXTENSION_RECORDING_PID] = String(pid);
|
|
106
|
+
console.error(`INFO: recording recipe video with capture-helper pid=${pid} output=${outputPath}`);
|
|
93
107
|
return recording;
|
|
94
108
|
}
|
|
95
109
|
async function captureActiveRecipeRecordingSnapshot(pid, outputPath, timeoutMs = 3e4) {
|
|
@@ -117,29 +131,22 @@ async function stopRecipeRecording(recording, result) {
|
|
|
117
131
|
recording.finalized = true;
|
|
118
132
|
try {
|
|
119
133
|
await stopRecordingProcess(recording);
|
|
134
|
+
const validation = validateRecordingArtifact(recording);
|
|
135
|
+
if (validation.ok === false) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`capture-helper recording did not produce a usable video artifact: ${validation.reason}`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
publishRecordingArtifact(recording);
|
|
141
|
+
if (result) addRecordingArtifactToManifest(result, recording);
|
|
120
142
|
} catch (error) {
|
|
121
|
-
cleanupRecordingStage(recording);
|
|
122
143
|
if (result) removeRecordingArtifactFromManifest(result, recording.relativePath);
|
|
123
144
|
throw error;
|
|
145
|
+
} finally {
|
|
146
|
+
activeRecordingsByPid.delete(recording.pid);
|
|
147
|
+
restoreActiveRecordingEnvironment(recording);
|
|
148
|
+
cleanupRecordingStaging(recording.stagingDir);
|
|
124
149
|
}
|
|
125
|
-
const validation = validateRecordingArtifact(recording);
|
|
126
|
-
if (validation.ok === false) {
|
|
127
|
-
cleanupRecordingStage(recording);
|
|
128
|
-
if (result) removeRecordingArtifactFromManifest(result, recording.relativePath);
|
|
129
|
-
throw new Error(
|
|
130
|
-
`capture-helper recording did not produce a usable video artifact: ${validation.reason}`
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
try {
|
|
134
|
-
publishPrivateRecording(recording);
|
|
135
|
-
} catch (error) {
|
|
136
|
-
cleanupRecordingStage(recording);
|
|
137
|
-
if (result) removeRecordingArtifactFromManifest(result, recording.relativePath);
|
|
138
|
-
throw new Error(
|
|
139
|
-
`capture-helper recording could not safely publish ${recording.outputPath}: ${error instanceof Error ? error.message : String(error)}`
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
if (result) addRecordingArtifactToManifest(result, recording);
|
|
143
150
|
}
|
|
144
151
|
async function stopRecordingProcess(recording) {
|
|
145
152
|
if (!recording.exited && !recording.child.stdin.destroyed) {
|
|
@@ -227,117 +234,146 @@ function rejectPendingSnapshots(recording, error) {
|
|
|
227
234
|
recording.pendingSnapshots.clear();
|
|
228
235
|
}
|
|
229
236
|
function validateRecordingArtifact(recording) {
|
|
230
|
-
if (!fs.existsSync(recording.
|
|
231
|
-
return { ok: false, reason: `missing output ${recording.
|
|
237
|
+
if (!fs.existsSync(recording.stagedPath)) {
|
|
238
|
+
return { ok: false, reason: `missing output ${recording.stagedPath}` };
|
|
232
239
|
}
|
|
233
|
-
const
|
|
234
|
-
if (
|
|
235
|
-
return { ok: false, reason: `
|
|
240
|
+
const stagedStat = fs.lstatSync(recording.stagedPath);
|
|
241
|
+
if (stagedStat.isSymbolicLink() || !stagedStat.isFile()) {
|
|
242
|
+
return { ok: false, reason: `unsafe non-regular output ${recording.stagedPath}` };
|
|
236
243
|
}
|
|
237
|
-
|
|
238
|
-
|
|
244
|
+
const size = stagedStat.size;
|
|
245
|
+
if (size === 0) {
|
|
246
|
+
return { ok: false, reason: `empty output ${recording.stagedPath}` };
|
|
239
247
|
}
|
|
240
248
|
const recorderOutput = `${recording.stdout}
|
|
241
249
|
${recording.stderr}`;
|
|
242
250
|
if (!recorderOutput.includes("record_complete")) {
|
|
243
251
|
return {
|
|
244
252
|
ok: false,
|
|
245
|
-
reason: `capture-helper did not report record_complete for ${recording.
|
|
253
|
+
reason: `capture-helper did not report record_complete for ${recording.stagedPath}: ${recorderOutput.trim() || "no recorder output"}`
|
|
246
254
|
};
|
|
247
255
|
}
|
|
248
256
|
const ffprobe = spawnSync(
|
|
249
257
|
"ffprobe",
|
|
250
|
-
["-hide_banner", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", recording.
|
|
258
|
+
["-hide_banner", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", recording.stagedPath],
|
|
251
259
|
{ encoding: "utf8" }
|
|
252
260
|
);
|
|
253
261
|
if (ffprobe.error && ffprobe.error.code === "ENOENT") {
|
|
254
262
|
return { ok: true };
|
|
255
263
|
}
|
|
256
264
|
if (ffprobe.error) {
|
|
257
|
-
return { ok: false, reason: `ffprobe failed for ${recording.
|
|
265
|
+
return { ok: false, reason: `ffprobe failed for ${recording.stagedPath}: ${ffprobe.error.message}` };
|
|
258
266
|
}
|
|
259
267
|
if (ffprobe.status !== 0) {
|
|
260
268
|
return {
|
|
261
269
|
ok: false,
|
|
262
|
-
reason: `invalid MP4 ${recording.
|
|
270
|
+
reason: `invalid MP4 ${recording.stagedPath}: ${ffprobe.stderr.trim() || ffprobe.stdout.trim() || `ffprobe exited ${ffprobe.status}`}`
|
|
263
271
|
};
|
|
264
272
|
}
|
|
265
273
|
const durationSeconds = Number(ffprobe.stdout.trim());
|
|
266
274
|
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
|
267
|
-
return { ok: false, reason: `MP4 has no positive duration: ${recording.
|
|
275
|
+
return { ok: false, reason: `MP4 has no positive duration: ${recording.stagedPath}` };
|
|
268
276
|
}
|
|
269
277
|
return { ok: true };
|
|
270
278
|
}
|
|
271
|
-
function
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
277
|
-
fs.mkdirSync(artifactsRoot, { recursive: true });
|
|
278
|
-
ensureRecordingParent(artifactsRoot, path.dirname(outputPath));
|
|
279
|
-
refuseUnsafeRecordingDestination(outputPath);
|
|
280
|
-
const stagingDir = fs.mkdtempSync(path.join(artifactsRoot, ".extension-recording-"));
|
|
279
|
+
function preparePrivateRecordingDestination(artifactsDir, outputPath) {
|
|
280
|
+
const outputDir = path.dirname(outputPath);
|
|
281
|
+
ensureRecordingDirectory(path.resolve(artifactsDir), outputDir);
|
|
282
|
+
refuseRecordingDestinationSymlink(outputPath);
|
|
283
|
+
const stagingDir = fs.mkdtempSync(path.join(outputDir, ".mm-harness-recording-"));
|
|
281
284
|
fs.chmodSync(stagingDir, 448);
|
|
282
285
|
return {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
stagingDir
|
|
286
|
+
stagingDir,
|
|
287
|
+
stagedPath: path.join(stagingDir, "full-run.mp4")
|
|
286
288
|
};
|
|
287
289
|
}
|
|
288
|
-
function
|
|
289
|
-
|
|
290
|
+
function ensureRecordingDirectory(artifactsRoot, outputDir) {
|
|
291
|
+
fs.mkdirSync(artifactsRoot, { recursive: true });
|
|
292
|
+
requireRecordingDirectory(artifactsRoot);
|
|
293
|
+
const relative = path.relative(artifactsRoot, outputDir);
|
|
290
294
|
let current = artifactsRoot;
|
|
291
295
|
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
292
296
|
current = path.join(current, segment);
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
}
|
|
298
|
-
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
299
|
-
throw new Error(`Refusing recording artifact parent that is not a real directory: ${current}`);
|
|
297
|
+
try {
|
|
298
|
+
fs.mkdirSync(current);
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (error.code !== "EEXIST") throw error;
|
|
300
301
|
}
|
|
302
|
+
requireRecordingDirectory(current);
|
|
301
303
|
}
|
|
302
304
|
}
|
|
303
|
-
function
|
|
304
|
-
const
|
|
305
|
-
if (!
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
305
|
+
function requireRecordingDirectory(directory) {
|
|
306
|
+
const entry = fs.lstatSync(directory);
|
|
307
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) return;
|
|
308
|
+
throw new Error(
|
|
309
|
+
`Refusing unsafe recording artifact directory: ${directory}
|
|
310
|
+
Next: replace ${JSON.stringify(directory)} with a regular directory and retry the recipe.`
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
function publishRecordingArtifact(recording) {
|
|
314
|
+
const noFollow = fs.constants.O_NOFOLLOW ?? 0;
|
|
315
|
+
let descriptor;
|
|
316
|
+
try {
|
|
317
|
+
descriptor = fs.openSync(recording.stagedPath, fs.constants.O_RDONLY | noFollow);
|
|
318
|
+
const stagedStat = fs.fstatSync(descriptor);
|
|
319
|
+
if (!stagedStat.isFile()) {
|
|
320
|
+
throw unsafeRecordingArtifactError(recording.stagedPath);
|
|
321
|
+
}
|
|
322
|
+
fs.fchmodSync(descriptor, 384);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (error instanceof Error && error.message.includes("Next:")) throw error;
|
|
325
|
+
throw unsafeRecordingArtifactError(
|
|
326
|
+
recording.stagedPath,
|
|
327
|
+
error instanceof Error ? error.message : String(error)
|
|
328
|
+
);
|
|
329
|
+
} finally {
|
|
330
|
+
if (descriptor !== void 0) fs.closeSync(descriptor);
|
|
311
331
|
}
|
|
332
|
+
refuseRecordingDestinationSymlink(recording.outputPath);
|
|
333
|
+
fs.renameSync(recording.stagedPath, recording.outputPath);
|
|
312
334
|
}
|
|
313
|
-
function
|
|
335
|
+
function refuseRecordingDestinationSymlink(outputPath) {
|
|
314
336
|
try {
|
|
315
|
-
|
|
337
|
+
const destination = fs.lstatSync(outputPath);
|
|
338
|
+
if (destination.isSymbolicLink()) {
|
|
339
|
+
throw new Error(
|
|
340
|
+
`Refusing recording artifact destination symlink: ${outputPath}
|
|
341
|
+
Next: rm -- ${JSON.stringify(outputPath)} and retry the recipe.`
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
if (!destination.isFile()) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`Refusing non-file recording artifact destination: ${outputPath}
|
|
347
|
+
Next: remove ${JSON.stringify(outputPath)} and retry the recipe.`
|
|
348
|
+
);
|
|
349
|
+
}
|
|
316
350
|
} catch (error) {
|
|
317
|
-
if (error.code === "ENOENT") return
|
|
351
|
+
if (error.code === "ENOENT") return;
|
|
318
352
|
throw error;
|
|
319
353
|
}
|
|
320
354
|
}
|
|
321
|
-
function
|
|
322
|
-
|
|
323
|
-
recording.
|
|
324
|
-
|
|
355
|
+
function unsafeRecordingArtifactError(stagedPath, detail) {
|
|
356
|
+
return new Error(
|
|
357
|
+
`Capture-helper recording output is not a safe regular file: ${stagedPath}${detail ? ` (${detail})` : ""}.
|
|
358
|
+
Next: capture-helper doctor --json`
|
|
325
359
|
);
|
|
360
|
+
}
|
|
361
|
+
function cleanupRecordingStaging(stagingDir) {
|
|
326
362
|
try {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
refuseUnsafeRecordingDestination(recording.outputPath);
|
|
333
|
-
fs.renameSync(recording.stagedOutputPath, recording.outputPath);
|
|
334
|
-
} finally {
|
|
335
|
-
fs.closeSync(descriptor);
|
|
363
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
364
|
+
} catch (error) {
|
|
365
|
+
console.error(
|
|
366
|
+
`WARN: could not remove capture-helper staging directory ${stagingDir}: ${error instanceof Error ? error.message : String(error)}`
|
|
367
|
+
);
|
|
336
368
|
}
|
|
337
|
-
cleanupRecordingStage(recording);
|
|
338
369
|
}
|
|
339
|
-
function
|
|
340
|
-
|
|
370
|
+
function restoreActiveRecordingEnvironment(recording) {
|
|
371
|
+
if (process.env[ACTIVE_EXTENSION_RECORDING_PID] !== String(recording.pid)) return;
|
|
372
|
+
if (recording.previousActiveRecordingPid === void 0) {
|
|
373
|
+
delete process.env[ACTIVE_EXTENSION_RECORDING_PID];
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
process.env[ACTIVE_EXTENSION_RECORDING_PID] = recording.previousActiveRecordingPid;
|
|
341
377
|
}
|
|
342
378
|
function addRecordingArtifactToManifest(result, recording) {
|
|
343
379
|
const manifestPath = result.artifactManifestPath;
|