@deeeed/metamask-harness 0.23.1 → 0.25.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 +44 -0
- package/adapters/extension/live.sh +2 -2
- package/adapters/extension/wallet-fixture-state.cjs +57 -19
- package/adapters/shared/ensure-runner-deps.sh +64 -5
- package/bin/mm-harness +26 -8
- package/dist/adapters/extension/runtime.js +1 -10
- package/dist/adapters.js +5 -1
- package/dist/commands/run-engine.js +18 -13
- package/dist/commands/runtime-launch.js +7 -2
- package/dist/live-adapter-contract.js +4 -2
- package/dist/recipe-security.js +10 -2
- package/dist/run-recording.js +156 -39
- package/library/actions/core/perps/_controller.mjs +138 -5
- package/library/actions/core/perps/assert_orders.mjs +174 -10
- package/library/actions/core/perps/assert_positions.mjs +20 -2
- package/library/actions/core/perps/close_orders.mjs +24 -5
- package/library/actions/core/perps/close_positions.mjs +22 -8
- package/library/actions/core/perps/edit_order.mjs +331 -0
- package/library/actions/core/perps/place_order.mjs +192 -43
- package/library/actions/core/perps/update_position_tpsl.mjs +121 -15
- package/library/actions/extension/analytics/set_consent.mjs +165 -0
- package/library/actions/extension/platform/cdp.mjs +112 -14
- package/library/actions/mobile/analytics/set_consent.mjs +90 -0
- package/library/actions/shared/analytics/_adapter.mjs +24 -0
- package/library/actions/shared/analytics/assert_events.mjs +168 -0
- package/library/actions/shared/analytics/collector.mjs +505 -0
- package/library/actions/shared/analytics/consent.mjs +14 -0
- package/library/actions/shared/analytics/read_events.mjs +22 -0
- package/library/actions/shared/analytics/start_capture.mjs +24 -0
- package/library/manifests/core.action-manifest.json +468 -27
- package/library/manifests/extension.action-manifest.json +188 -1
- package/library/manifests/mobile.action-manifest.json +161 -0
- package/package.json +3 -3
package/dist/run-recording.js
CHANGED
|
@@ -31,23 +31,25 @@ async function startRecipeRecording(adapter, projectRoot, artifactsDir, options)
|
|
|
31
31
|
}
|
|
32
32
|
const recordArgs = ["record", "--framed", "--pid", String(pid)];
|
|
33
33
|
const relativePath = "videos/full-run.mp4";
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
fs.rmSync(outputPath, { force: true });
|
|
37
|
-
const child = spawn(captureHelperPath(), [...recordArgs, "--output", outputPath], {
|
|
34
|
+
const stage = preparePrivateRecordingStage(artifactsDir, relativePath);
|
|
35
|
+
const child = spawn(captureHelperPath(), [...recordArgs, "--output", stage.stagedOutputPath], {
|
|
38
36
|
cwd: projectRoot,
|
|
39
37
|
env: process.env,
|
|
40
38
|
stdio: ["pipe", "pipe", "pipe"]
|
|
41
39
|
});
|
|
42
40
|
const recording = {
|
|
43
41
|
child,
|
|
44
|
-
outputPath,
|
|
42
|
+
outputPath: stage.outputPath,
|
|
43
|
+
stagedOutputPath: stage.stagedOutputPath,
|
|
44
|
+
stagingDir: stage.stagingDir,
|
|
45
45
|
relativePath,
|
|
46
46
|
pid,
|
|
47
47
|
stdout: "",
|
|
48
48
|
stderr: "",
|
|
49
49
|
exited: false,
|
|
50
50
|
exitCode: null,
|
|
51
|
+
frameReady: false,
|
|
52
|
+
finalized: false,
|
|
51
53
|
stderrBuffer: "",
|
|
52
54
|
pendingSnapshots: /* @__PURE__ */ new Map()
|
|
53
55
|
};
|
|
@@ -69,15 +71,25 @@ async function startRecipeRecording(adapter, projectRoot, artifactsDir, options)
|
|
|
69
71
|
activeRecordingsByPid.delete(recording.pid);
|
|
70
72
|
rejectPendingSnapshots(recording, new Error(`capture-helper recording exited before snapshot completed (code=${exitCode ?? "unknown"})`));
|
|
71
73
|
});
|
|
72
|
-
await
|
|
74
|
+
await waitForRecordingReady(recording, 15e3);
|
|
73
75
|
if (recording.exited) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
cleanupRecordingStage(recording);
|
|
77
|
+
throw new Error(
|
|
78
|
+
`capture-helper exited before recording its first frame (code=${recording.exitCode ?? "unknown"}): ${recording.stderr || recording.stdout}`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (!recording.frameReady) {
|
|
82
|
+
try {
|
|
83
|
+
await stopRecordingProcess(recording);
|
|
84
|
+
} finally {
|
|
85
|
+
cleanupRecordingStage(recording);
|
|
86
|
+
}
|
|
87
|
+
throw new Error(
|
|
88
|
+
`capture-helper did not record a frame within 15000ms: ${recording.stderr || recording.stdout || "no recorder output"}`
|
|
76
89
|
);
|
|
77
|
-
return void 0;
|
|
78
90
|
}
|
|
79
91
|
activeRecordingsByPid.set(pid, recording);
|
|
80
|
-
console.error(`INFO: recording recipe video with capture-helper pid=${pid} output=${outputPath}`);
|
|
92
|
+
console.error(`INFO: recording recipe video with capture-helper pid=${pid} output=${recording.outputPath}`);
|
|
81
93
|
return recording;
|
|
82
94
|
}
|
|
83
95
|
async function captureActiveRecipeRecordingSnapshot(pid, outputPath, timeoutMs = 3e4) {
|
|
@@ -101,8 +113,36 @@ async function captureActiveRecipeRecordingSnapshot(pid, outputPath, timeoutMs =
|
|
|
101
113
|
});
|
|
102
114
|
}
|
|
103
115
|
async function stopRecipeRecording(recording, result) {
|
|
104
|
-
if (!recording) return;
|
|
105
|
-
|
|
116
|
+
if (!recording || recording.finalized) return;
|
|
117
|
+
recording.finalized = true;
|
|
118
|
+
try {
|
|
119
|
+
await stopRecordingProcess(recording);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
cleanupRecordingStage(recording);
|
|
122
|
+
if (result) removeRecordingArtifactFromManifest(result, recording.relativePath);
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
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
|
+
}
|
|
144
|
+
async function stopRecordingProcess(recording) {
|
|
145
|
+
if (!recording.exited && !recording.child.stdin.destroyed) {
|
|
106
146
|
recording.child.stdin.end("stop\n");
|
|
107
147
|
await waitForRecordingExit(recording, 15e3);
|
|
108
148
|
}
|
|
@@ -114,33 +154,33 @@ async function stopRecipeRecording(recording, result) {
|
|
|
114
154
|
recording.child.kill("SIGTERM");
|
|
115
155
|
await waitForRecordingExit(recording, 3e3);
|
|
116
156
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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;
|
|
157
|
+
if (!recording.exited) {
|
|
158
|
+
recording.child.kill("SIGKILL");
|
|
159
|
+
await waitForRecordingExit(recording, 2e3);
|
|
160
|
+
}
|
|
161
|
+
if (!recording.exited) {
|
|
162
|
+
throw new Error(`capture-helper recording process did not exit: pid=${recording.child.pid ?? "unknown"}`);
|
|
131
163
|
}
|
|
132
|
-
if (result) addRecordingArtifactToManifest(result, recording);
|
|
133
164
|
}
|
|
134
165
|
async function waitForRecordingExit(recording, timeoutMs) {
|
|
135
166
|
if (recording.exited) return;
|
|
136
167
|
await new Promise((resolve) => {
|
|
137
|
-
const
|
|
138
|
-
recording.child.once("close", () => {
|
|
168
|
+
const finish = () => {
|
|
139
169
|
clearTimeout(timer);
|
|
170
|
+
recording.child.removeListener("close", finish);
|
|
140
171
|
resolve();
|
|
141
|
-
}
|
|
172
|
+
};
|
|
173
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
174
|
+
recording.child.once("close", finish);
|
|
175
|
+
if (recording.exited) finish();
|
|
142
176
|
});
|
|
143
177
|
}
|
|
178
|
+
async function waitForRecordingReady(recording, timeoutMs) {
|
|
179
|
+
const deadline = Date.now() + timeoutMs;
|
|
180
|
+
while (!recording.frameReady && !recording.exited && Date.now() < deadline) {
|
|
181
|
+
await sleep(50);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
144
184
|
function handleRecordingStderr(recording, chunk) {
|
|
145
185
|
recording.stderrBuffer += chunk;
|
|
146
186
|
let newlineIndex = recording.stderrBuffer.indexOf("\n");
|
|
@@ -160,6 +200,9 @@ function handleRecordingEventLine(recording, line) {
|
|
|
160
200
|
} catch {
|
|
161
201
|
return;
|
|
162
202
|
}
|
|
203
|
+
if (event.type === "info" && typeof event.msg === "string" && /^record frames=[1-9]\d*$/.test(event.msg)) {
|
|
204
|
+
recording.frameReady = true;
|
|
205
|
+
}
|
|
163
206
|
const output = typeof event.output === "string" ? event.output : void 0;
|
|
164
207
|
if (!output) return;
|
|
165
208
|
const pending = recording.pendingSnapshots.get(output);
|
|
@@ -184,36 +227,39 @@ function rejectPendingSnapshots(recording, error) {
|
|
|
184
227
|
recording.pendingSnapshots.clear();
|
|
185
228
|
}
|
|
186
229
|
function validateRecordingArtifact(recording) {
|
|
187
|
-
if (!fs.existsSync(recording.
|
|
188
|
-
return { ok: false, reason: `missing output ${recording.
|
|
230
|
+
if (!fs.existsSync(recording.stagedOutputPath)) {
|
|
231
|
+
return { ok: false, reason: `missing output ${recording.stagedOutputPath}` };
|
|
189
232
|
}
|
|
190
|
-
const
|
|
191
|
-
if (
|
|
192
|
-
return { ok: false, reason: `
|
|
233
|
+
const info = fs.lstatSync(recording.stagedOutputPath);
|
|
234
|
+
if (info.isSymbolicLink() || !info.isFile()) {
|
|
235
|
+
return { ok: false, reason: `output is not a safe regular file ${recording.stagedOutputPath}` };
|
|
236
|
+
}
|
|
237
|
+
if (info.size === 0) {
|
|
238
|
+
return { ok: false, reason: `empty output ${recording.stagedOutputPath}` };
|
|
193
239
|
}
|
|
194
240
|
const recorderOutput = `${recording.stdout}
|
|
195
241
|
${recording.stderr}`;
|
|
196
242
|
if (!recorderOutput.includes("record_complete")) {
|
|
197
243
|
return {
|
|
198
244
|
ok: false,
|
|
199
|
-
reason: `capture-helper did not report record_complete for ${recording.
|
|
245
|
+
reason: `capture-helper did not report record_complete for ${recording.stagedOutputPath}: ${recorderOutput.trim() || "no recorder output"}`
|
|
200
246
|
};
|
|
201
247
|
}
|
|
202
248
|
const ffprobe = spawnSync(
|
|
203
249
|
"ffprobe",
|
|
204
|
-
["-hide_banner", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", recording.
|
|
250
|
+
["-hide_banner", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", recording.stagedOutputPath],
|
|
205
251
|
{ encoding: "utf8" }
|
|
206
252
|
);
|
|
207
253
|
if (ffprobe.error && ffprobe.error.code === "ENOENT") {
|
|
208
254
|
return { ok: true };
|
|
209
255
|
}
|
|
210
256
|
if (ffprobe.error) {
|
|
211
|
-
return { ok: false, reason: `ffprobe failed for ${recording.
|
|
257
|
+
return { ok: false, reason: `ffprobe failed for ${recording.stagedOutputPath}: ${ffprobe.error.message}` };
|
|
212
258
|
}
|
|
213
259
|
if (ffprobe.status !== 0) {
|
|
214
260
|
return {
|
|
215
261
|
ok: false,
|
|
216
|
-
reason: `invalid MP4 ${recording.
|
|
262
|
+
reason: `invalid MP4 ${recording.stagedOutputPath}: ${ffprobe.stderr.trim() || ffprobe.stdout.trim() || `ffprobe exited ${ffprobe.status}`}`
|
|
217
263
|
};
|
|
218
264
|
}
|
|
219
265
|
const durationSeconds = Number(ffprobe.stdout.trim());
|
|
@@ -222,6 +268,77 @@ ${recording.stderr}`;
|
|
|
222
268
|
}
|
|
223
269
|
return { ok: true };
|
|
224
270
|
}
|
|
271
|
+
function preparePrivateRecordingStage(artifactsDir, relativePath) {
|
|
272
|
+
const artifactsRoot = path.resolve(artifactsDir);
|
|
273
|
+
const outputPath = path.resolve(artifactsRoot, relativePath);
|
|
274
|
+
if (!outputPath.startsWith(`${artifactsRoot}${path.sep}`)) {
|
|
275
|
+
throw new Error(`Refusing recording artifact outside artifacts dir: ${relativePath}`);
|
|
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-"));
|
|
281
|
+
fs.chmodSync(stagingDir, 448);
|
|
282
|
+
return {
|
|
283
|
+
outputPath,
|
|
284
|
+
stagedOutputPath: path.join(stagingDir, "full-run.mp4"),
|
|
285
|
+
stagingDir
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
function ensureRecordingParent(artifactsRoot, parent) {
|
|
289
|
+
const relative = path.relative(artifactsRoot, parent);
|
|
290
|
+
let current = artifactsRoot;
|
|
291
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
292
|
+
current = path.join(current, segment);
|
|
293
|
+
const info = lstatIfPresent(current);
|
|
294
|
+
if (!info) {
|
|
295
|
+
fs.mkdirSync(current, { mode: 448 });
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
299
|
+
throw new Error(`Refusing recording artifact parent that is not a real directory: ${current}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function refuseUnsafeRecordingDestination(outputPath) {
|
|
304
|
+
const info = lstatIfPresent(outputPath);
|
|
305
|
+
if (!info) return;
|
|
306
|
+
if (info.isSymbolicLink()) {
|
|
307
|
+
throw new Error(`Refusing recording artifact destination symlink: ${outputPath}`);
|
|
308
|
+
}
|
|
309
|
+
if (!info.isFile()) {
|
|
310
|
+
throw new Error(`Refusing recording artifact destination that is not a regular file: ${outputPath}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function lstatIfPresent(filePath) {
|
|
314
|
+
try {
|
|
315
|
+
return fs.lstatSync(filePath);
|
|
316
|
+
} catch (error) {
|
|
317
|
+
if (error.code === "ENOENT") return void 0;
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function publishPrivateRecording(recording) {
|
|
322
|
+
const descriptor = fs.openSync(
|
|
323
|
+
recording.stagedOutputPath,
|
|
324
|
+
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)
|
|
325
|
+
);
|
|
326
|
+
try {
|
|
327
|
+
const info = fs.fstatSync(descriptor);
|
|
328
|
+
if (!info.isFile()) {
|
|
329
|
+
throw new Error(`Captured recording is not a safe regular file: ${recording.stagedOutputPath}`);
|
|
330
|
+
}
|
|
331
|
+
fs.fchmodSync(descriptor, 384);
|
|
332
|
+
refuseUnsafeRecordingDestination(recording.outputPath);
|
|
333
|
+
fs.renameSync(recording.stagedOutputPath, recording.outputPath);
|
|
334
|
+
} finally {
|
|
335
|
+
fs.closeSync(descriptor);
|
|
336
|
+
}
|
|
337
|
+
cleanupRecordingStage(recording);
|
|
338
|
+
}
|
|
339
|
+
function cleanupRecordingStage(recording) {
|
|
340
|
+
fs.rmSync(recording.stagingDir, { recursive: true, force: true });
|
|
341
|
+
}
|
|
225
342
|
function addRecordingArtifactToManifest(result, recording) {
|
|
226
343
|
const manifestPath = result.artifactManifestPath;
|
|
227
344
|
if (!manifestPath || !fs.existsSync(manifestPath)) {
|
|
@@ -618,6 +618,60 @@ export function redactPosition(position) {
|
|
|
618
618
|
};
|
|
619
619
|
}
|
|
620
620
|
|
|
621
|
+
/**
|
|
622
|
+
* Wrap a controller rejection so its typed code survives as data.
|
|
623
|
+
*
|
|
624
|
+
* `expect_error` used to match by substring against the message, which meant a
|
|
625
|
+
* message that merely mentioned a code — or contained a longer code with the
|
|
626
|
+
* expected one as a prefix — matched wrongly. Carrying the code on the error
|
|
627
|
+
* lets the comparison be exact.
|
|
628
|
+
*
|
|
629
|
+
* @param params - Rejection details.
|
|
630
|
+
* @param params.action - Action name, for the message.
|
|
631
|
+
* @param params.detail - Human context for the message.
|
|
632
|
+
* @param params.code - The controller's typed error code.
|
|
633
|
+
* @returns An error carrying `perpsErrorCode`.
|
|
634
|
+
*/
|
|
635
|
+
export function controllerRejection(params) {
|
|
636
|
+
const { action, detail, code } = params;
|
|
637
|
+
const error = new Error(`${action} was rejected: ${code}${detail ? ` (${detail})` : ''}`);
|
|
638
|
+
error.perpsErrorCode = code;
|
|
639
|
+
return error;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Read an optional node param, treating an unset template as absent.
|
|
644
|
+
*
|
|
645
|
+
* Recipes are static JSON, so an optional parameter that a caller leaves at its
|
|
646
|
+
* default arrives as an empty string rather than being omitted. Passing that
|
|
647
|
+
* through as a set-but-empty value is how a plain order ends up carrying an
|
|
648
|
+
* empty TP/SL size, which the controller then rejects as invalid. Whitespace is
|
|
649
|
+
* trimmed for the same reason.
|
|
650
|
+
*
|
|
651
|
+
* @param node - The recipe node.
|
|
652
|
+
* @param snake - snake_case param name.
|
|
653
|
+
* @param camel - camelCase alias.
|
|
654
|
+
* @returns The value as a string, or undefined when the node did not set it.
|
|
655
|
+
*/
|
|
656
|
+
export function optionalParam(node, snake, camel) {
|
|
657
|
+
const normalize = (value) => {
|
|
658
|
+
if (value === undefined || value === null) {
|
|
659
|
+
return undefined;
|
|
660
|
+
}
|
|
661
|
+
const text = String(value).trim();
|
|
662
|
+
return text.length === 0 ? undefined : text;
|
|
663
|
+
};
|
|
664
|
+
return normalize(node?.[snake]) ?? normalize(node?.[camel]);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export function optionalBooleanParam(node, snake, camel) {
|
|
668
|
+
const value = optionalParam(node, snake, camel);
|
|
669
|
+
if (value === undefined) return undefined;
|
|
670
|
+
if (value.toLowerCase() === 'true') return true;
|
|
671
|
+
if (value.toLowerCase() === 'false') return false;
|
|
672
|
+
throw new Error(`${snake} must be true or false; got ${value}.`);
|
|
673
|
+
}
|
|
674
|
+
|
|
621
675
|
export function redactOrder(order) {
|
|
622
676
|
return {
|
|
623
677
|
coin: order.coin ?? order.symbol ?? null,
|
|
@@ -691,16 +745,95 @@ async function disconnectController(exitCode) {
|
|
|
691
745
|
}
|
|
692
746
|
}
|
|
693
747
|
|
|
748
|
+
/**
|
|
749
|
+
* Read the rejection a node expects, if any.
|
|
750
|
+
*
|
|
751
|
+
* @param input - Adapter input (node.expect_error / node.expectError).
|
|
752
|
+
* @returns The expected error token, or undefined when the node expects success.
|
|
753
|
+
*/
|
|
754
|
+
function expectedErrorFor(input) {
|
|
755
|
+
const node = input?.node ?? {};
|
|
756
|
+
for (const key of ['expect_error', 'expectError']) {
|
|
757
|
+
const value = node[key];
|
|
758
|
+
if (typeof value === 'string' && value.trim().length > 0) {
|
|
759
|
+
return value.trim();
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return undefined;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Run a Core perps adapter, honouring an expected rejection.
|
|
767
|
+
*
|
|
768
|
+
* Most nodes assert that an operation succeeds. A controller's refusals are part
|
|
769
|
+
* of its contract too — that a partial size below the asset's precision, or a
|
|
770
|
+
* TP/SL linkage the venue cannot express, is rejected with a typed error and
|
|
771
|
+
* before any side effect. Without `expect_error` those can only be proven by a
|
|
772
|
+
* bespoke script, because every action here throws on failure.
|
|
773
|
+
*
|
|
774
|
+
* With `expect_error` set, a throw whose message carries the expected token is
|
|
775
|
+
* the passing outcome: the rejection is written out as evidence and the process
|
|
776
|
+
* exits 0. Anything else fails — a different error, or the operation succeeding
|
|
777
|
+
* when it should have been refused. The success case is checked explicitly
|
|
778
|
+
* rather than left to fall through, so a contract that silently stops rejecting
|
|
779
|
+
* is caught rather than reported as a pass.
|
|
780
|
+
*
|
|
781
|
+
* Actions surface a controller's `{ success: false, error }` result by throwing
|
|
782
|
+
* with the code in the message (see place_order.mjs), so a substring match
|
|
783
|
+
* covers both that shape and a genuine throw.
|
|
784
|
+
*
|
|
785
|
+
* When the param is absent this is byte-identical to the previous behaviour.
|
|
786
|
+
*
|
|
787
|
+
* @param callback - The adapter body, receiving the loaded input.
|
|
788
|
+
*/
|
|
694
789
|
export async function runAdapter(callback) {
|
|
695
790
|
const input = await loadInput();
|
|
791
|
+
const expectedError = expectedErrorFor(input);
|
|
696
792
|
let exitCode = 0;
|
|
697
793
|
try {
|
|
698
|
-
|
|
794
|
+
const output = await callback(input);
|
|
795
|
+
if (expectedError) {
|
|
796
|
+
// Not thrown: a rejection asserted here must not be satisfied by the
|
|
797
|
+
// message this branch would itself produce.
|
|
798
|
+
exitCode = 1;
|
|
799
|
+
process.stderr.write(
|
|
800
|
+
`[core/perps] ${input.action} expected rejection ${expectedError}, but it succeeded.\n`,
|
|
801
|
+
);
|
|
802
|
+
} else {
|
|
803
|
+
await writeOutput(input, output);
|
|
804
|
+
}
|
|
699
805
|
} catch (error) {
|
|
700
|
-
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
|
|
806
|
+
const message = error?.message ?? String(error);
|
|
807
|
+
// A typed proof requires the code as data. Matching on the message was a
|
|
808
|
+
// weaker check wearing the same name: an adapter's own error, or any text
|
|
809
|
+
// that merely mentioned a code, could satisfy it. Only a rejection the
|
|
810
|
+
// action tagged with the controller's code counts, compared exactly — so a
|
|
811
|
+
// proof cannot pass on a failure the controller never issued.
|
|
812
|
+
const code = error?.perpsErrorCode;
|
|
813
|
+
if (expectedError && code === expectedError) {
|
|
814
|
+
await writeOutput(input, {
|
|
815
|
+
action: input.action,
|
|
816
|
+
source: 'expect_error',
|
|
817
|
+
rejected: true,
|
|
818
|
+
matched: true,
|
|
819
|
+
matchedOn: 'code',
|
|
820
|
+
expectedError,
|
|
821
|
+
actualErrorCode: code,
|
|
822
|
+
actualError: message,
|
|
823
|
+
});
|
|
824
|
+
} else {
|
|
825
|
+
exitCode = 1;
|
|
826
|
+
if (expectedError) {
|
|
827
|
+
process.stderr.write(
|
|
828
|
+
code === undefined
|
|
829
|
+
? `[core/perps] ${input.action} expected rejection ${expectedError}, but the failure carries no controller error code — it did not come from the controller.\n`
|
|
830
|
+
: `[core/perps] ${input.action} expected rejection ${expectedError}, but the controller rejected with ${code}.\n`,
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
// Surface the failure; still tear down so a half-open WebSocket from a
|
|
834
|
+
// failed write doesn't leave the process wedged open.
|
|
835
|
+
process.stderr.write(`[core/perps] adapter failed: ${fmtError(error)}\n`);
|
|
836
|
+
}
|
|
704
837
|
} finally {
|
|
705
838
|
await disconnectController(exitCode);
|
|
706
839
|
}
|