@deeeed/metamask-harness 0.66.3 → 0.67.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 +15 -0
- package/dist/adapters/mobile/video-recorder.js +5 -0
- package/dist/recording-target.js +7 -3
- package/dist/run-recording.js +65 -9
- package/dist/runner.js +2 -1
- package/library/actions/extension/analytics/consent.mjs +8 -2
- package/package.json +5 -5
- package/site/assets/help-recipes.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.67.0 - 2026-09-28
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Wait for asynchronous debug-page consent state updates without accepting missing values; retain immediate validation of authoritative background state.
|
|
10
|
+
- Clarify fixture lifetime and consistent evidence-package handling in the version-matched recipe guide.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Prefer capture-helper recording and concurrent screenshots for physical Android through an owned mirror; retain native frame/proof timing for Android and framed Extension, with explicit fallback provenance. Keep finalized footage when optional timing is unavailable, and reject unsafe timing sidecars.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Pin protocol 0.32.0, recipe-harness 0.20.0, agent-runtime 0.15.0 and expo-recipe 0.12.1 from npm for recording timelines and one HTML QA report.
|
|
19
|
+
|
|
5
20
|
## 0.66.3 - 2026-09-27
|
|
6
21
|
|
|
7
22
|
### Changed
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { optionalVideoTiming } from "@farmslot/recipe-harness";
|
|
4
5
|
import {
|
|
5
6
|
ANDROID_RECORDING_TARGET_PREFIX,
|
|
6
7
|
IOS_SIMULATOR_RECORDING_TARGET_PREFIX
|
|
@@ -146,6 +147,7 @@ async function startAndroidRecording(serial, request) {
|
|
|
146
147
|
);
|
|
147
148
|
}
|
|
148
149
|
const remotePath = `/sdcard/Download/mm-harness-recipe-${Date.now()}-${process.pid}.mp4`;
|
|
150
|
+
const startedAtUnixMs = Date.now();
|
|
149
151
|
const child = spawn(
|
|
150
152
|
"adb",
|
|
151
153
|
["-s", serial, ...androidScreenrecordArgs(remotePath)],
|
|
@@ -170,6 +172,7 @@ async function startAndroidRecording(serial, request) {
|
|
|
170
172
|
RECORDING_STOP_TIMEOUT_MS,
|
|
171
173
|
"Android screenrecord"
|
|
172
174
|
);
|
|
175
|
+
const stoppedAtUnixMs = Date.now();
|
|
173
176
|
fs.mkdirSync(path.dirname(request.outputPath), { recursive: true });
|
|
174
177
|
const pull = runAdb(serial, ["pull", remotePath, request.outputPath]);
|
|
175
178
|
if (pull.status !== 0) {
|
|
@@ -182,6 +185,7 @@ async function startAndroidRecording(serial, request) {
|
|
|
182
185
|
throw new Error(`Android recording output is empty: ${request.outputPath}`);
|
|
183
186
|
}
|
|
184
187
|
return {
|
|
188
|
+
...await optionalVideoTiming(request.outputPath, { startedAtUnixMs, stoppedAtUnixMs }),
|
|
185
189
|
recorder: {
|
|
186
190
|
name: "adb-screenrecord",
|
|
187
191
|
platform: "android",
|
|
@@ -195,6 +199,7 @@ async function startAndroidRecording(serial, request) {
|
|
|
195
199
|
};
|
|
196
200
|
}
|
|
197
201
|
function androidTargetSerial(request) {
|
|
202
|
+
if (request.target.kind === "android-device") return request.target.serial;
|
|
198
203
|
if (request.target.kind !== "window-id") return void 0;
|
|
199
204
|
if (!request.target.windowId.startsWith(ANDROID_RECORDING_TARGET_PREFIX)) return void 0;
|
|
200
205
|
return request.target.windowId.slice(ANDROID_RECORDING_TARGET_PREFIX.length) || void 0;
|
package/dist/recording-target.js
CHANGED
|
@@ -29,8 +29,8 @@ function createMetaMaskRecordingTargetProvider(adapter) {
|
|
|
29
29
|
);
|
|
30
30
|
}
|
|
31
31
|
return {
|
|
32
|
-
kind: "
|
|
33
|
-
|
|
32
|
+
kind: "android-device",
|
|
33
|
+
serial: androidSerial
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
36
|
const simulator = resolveMobileIosSimulatorName(context.env);
|
|
@@ -52,6 +52,9 @@ function resolveAndroidDeviceSerial(env = process.env) {
|
|
|
52
52
|
return env.ADB_SERIAL?.trim() || process.env.ADB_SERIAL?.trim() || void 0;
|
|
53
53
|
}
|
|
54
54
|
function captureHelperSupportsRecordSessionSnapshots(projectRoot) {
|
|
55
|
+
return captureHelperSupportsCapability(projectRoot, "record_session_snapshot");
|
|
56
|
+
}
|
|
57
|
+
function captureHelperSupportsCapability(projectRoot, capability) {
|
|
55
58
|
const result = spawnSync(captureHelperPath(), ["version", "--json"], {
|
|
56
59
|
cwd: projectRoot,
|
|
57
60
|
encoding: "utf8"
|
|
@@ -59,7 +62,7 @@ function captureHelperSupportsRecordSessionSnapshots(projectRoot) {
|
|
|
59
62
|
if (result.status !== 0) return false;
|
|
60
63
|
try {
|
|
61
64
|
const parsed = JSON.parse(result.stdout);
|
|
62
|
-
return Array.isArray(parsed.capabilities) && parsed.capabilities.includes(
|
|
65
|
+
return Array.isArray(parsed.capabilities) && parsed.capabilities.includes(capability);
|
|
63
66
|
} catch {
|
|
64
67
|
return false;
|
|
65
68
|
}
|
|
@@ -142,6 +145,7 @@ function readTextFile(file) {
|
|
|
142
145
|
export {
|
|
143
146
|
ANDROID_RECORDING_TARGET_PREFIX,
|
|
144
147
|
IOS_SIMULATOR_RECORDING_TARGET_PREFIX,
|
|
148
|
+
captureHelperSupportsCapability,
|
|
145
149
|
captureHelperSupportsRecordSessionSnapshots,
|
|
146
150
|
createMetaMaskRecordingTargetProvider,
|
|
147
151
|
resolveAndroidDeviceSerial,
|
package/dist/run-recording.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { optionalVideoTiming, readCaptureHelperTiming, writeRecordingTimeline } from "@farmslot/recipe-harness";
|
|
4
5
|
import {
|
|
5
6
|
captureHelperSupportsRecordSessionSnapshots,
|
|
7
|
+
captureHelperSupportsCapability,
|
|
6
8
|
resolveExtensionBrowserPid
|
|
7
9
|
} from "./recording-target.js";
|
|
8
10
|
const activeRecordingsByPid = /* @__PURE__ */ new Map();
|
|
@@ -38,6 +40,7 @@ async function startRecipeRecording(adapter, projectRoot, artifactsDir, options)
|
|
|
38
40
|
outputPath
|
|
39
41
|
);
|
|
40
42
|
let child;
|
|
43
|
+
const startedAtUnixMs = Date.now();
|
|
41
44
|
try {
|
|
42
45
|
child = spawn(captureHelperPath(), [...recordArgs, "--output", stagedPath], {
|
|
43
46
|
cwd: projectRoot,
|
|
@@ -50,6 +53,8 @@ async function startRecipeRecording(adapter, projectRoot, artifactsDir, options)
|
|
|
50
53
|
}
|
|
51
54
|
const recording = {
|
|
52
55
|
child,
|
|
56
|
+
startedAtUnixMs,
|
|
57
|
+
nativeTiming: captureHelperSupportsCapability(projectRoot, "record_session_timing_v1"),
|
|
53
58
|
outputPath,
|
|
54
59
|
stagingDir,
|
|
55
60
|
stagedPath,
|
|
@@ -78,6 +83,7 @@ async function startRecipeRecording(adapter, projectRoot, artifactsDir, options)
|
|
|
78
83
|
recording.exited = true;
|
|
79
84
|
});
|
|
80
85
|
child.on("close", (exitCode) => {
|
|
86
|
+
recording.stoppedAtUnixMs = Date.now();
|
|
81
87
|
recording.exited = true;
|
|
82
88
|
recording.exitCode = exitCode;
|
|
83
89
|
activeRecordingsByPid.delete(recording.pid);
|
|
@@ -131,14 +137,14 @@ async function stopRecipeRecording(recording, result) {
|
|
|
131
137
|
recording.finalized = true;
|
|
132
138
|
try {
|
|
133
139
|
await stopRecordingProcess(recording);
|
|
134
|
-
const validation = validateRecordingArtifact(recording);
|
|
140
|
+
const validation = await validateRecordingArtifact(recording);
|
|
135
141
|
if (validation.ok === false) {
|
|
136
142
|
throw new Error(
|
|
137
143
|
`capture-helper recording did not produce a usable video artifact: ${validation.reason}`
|
|
138
144
|
);
|
|
139
145
|
}
|
|
140
146
|
publishRecordingArtifact(recording);
|
|
141
|
-
if (result) addRecordingArtifactToManifest(result, recording);
|
|
147
|
+
if (result) await addRecordingArtifactToManifest(result, recording);
|
|
142
148
|
} catch (error) {
|
|
143
149
|
if (result) removeRecordingArtifactFromManifest(result, recording.relativePath);
|
|
144
150
|
throw error;
|
|
@@ -210,6 +216,10 @@ function handleRecordingEventLine(recording, line) {
|
|
|
210
216
|
if (event.type === "info" && typeof event.msg === "string" && /^record frames=[1-9]\d*$/.test(event.msg)) {
|
|
211
217
|
recording.frameReady = true;
|
|
212
218
|
}
|
|
219
|
+
if (event.type === "record_complete" && event.output === recording.stagedPath) {
|
|
220
|
+
recording.completedVideo = typeof event.frames === "number" && event.frames > 0;
|
|
221
|
+
if (typeof event.recording_id === "string") recording.completedRecordingId = event.recording_id;
|
|
222
|
+
}
|
|
213
223
|
const output = typeof event.output === "string" ? event.output : void 0;
|
|
214
224
|
if (!output) return;
|
|
215
225
|
const pending = recording.pendingSnapshots.get(output);
|
|
@@ -233,7 +243,7 @@ function rejectPendingSnapshots(recording, error) {
|
|
|
233
243
|
}
|
|
234
244
|
recording.pendingSnapshots.clear();
|
|
235
245
|
}
|
|
236
|
-
function validateRecordingArtifact(recording) {
|
|
246
|
+
async function validateRecordingArtifact(recording) {
|
|
237
247
|
if (!fs.existsSync(recording.stagedPath)) {
|
|
238
248
|
return { ok: false, reason: `missing output ${recording.stagedPath}` };
|
|
239
249
|
}
|
|
@@ -247,6 +257,11 @@ function validateRecordingArtifact(recording) {
|
|
|
247
257
|
}
|
|
248
258
|
const recorderOutput = `${recording.stdout}
|
|
249
259
|
${recording.stderr}`;
|
|
260
|
+
if (recording.nativeTiming) {
|
|
261
|
+
if (!recording.completedVideo) return { ok: false, reason: "Missing finalized video completion" };
|
|
262
|
+
recording.nativeTimingEvidence = await readNativeRecordingTiming(recording);
|
|
263
|
+
return { ok: true };
|
|
264
|
+
}
|
|
250
265
|
if (!recorderOutput.includes("record_complete")) {
|
|
251
266
|
return {
|
|
252
267
|
ok: false,
|
|
@@ -276,6 +291,19 @@ ${recording.stderr}`;
|
|
|
276
291
|
}
|
|
277
292
|
return { ok: true };
|
|
278
293
|
}
|
|
294
|
+
async function readNativeRecordingTiming(recording) {
|
|
295
|
+
try {
|
|
296
|
+
if (!recording.completedRecordingId) throw new Error("Missing native recording completion identity");
|
|
297
|
+
const sidecar = recording.stagedPath + ".timing.json";
|
|
298
|
+
const entry = fs.lstatSync(sidecar);
|
|
299
|
+
if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("Native timing must be a regular file");
|
|
300
|
+
const native = JSON.parse(fs.readFileSync(sidecar, "utf8"));
|
|
301
|
+
if (native.recording_id !== recording.completedRecordingId) throw new Error("Native recording identity mismatch");
|
|
302
|
+
return await readCaptureHelperTiming(recording.stagedPath);
|
|
303
|
+
} catch (error) {
|
|
304
|
+
return { timingUnavailableReason: error instanceof Error ? error.message : String(error) };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
279
307
|
function preparePrivateRecordingDestination(artifactsDir, outputPath) {
|
|
280
308
|
const outputDir = path.dirname(outputPath);
|
|
281
309
|
ensureRecordingDirectory(path.resolve(artifactsDir), outputDir);
|
|
@@ -311,26 +339,36 @@ Next: replace ${JSON.stringify(directory)} with a regular directory and retry th
|
|
|
311
339
|
);
|
|
312
340
|
}
|
|
313
341
|
function publishRecordingArtifact(recording) {
|
|
342
|
+
publishPrivateRecordingFile(recording.stagedPath, recording.outputPath);
|
|
343
|
+
if (recording.nativeTimingEvidence?.timing) {
|
|
344
|
+
try {
|
|
345
|
+
publishPrivateRecordingFile(recording.stagedPath + ".timing.json", recording.outputPath + ".timing.json");
|
|
346
|
+
} catch (error) {
|
|
347
|
+
recording.nativeTimingEvidence = { timingUnavailableReason: error instanceof Error ? error.message : String(error) };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function publishPrivateRecordingFile(sourcePath, outputPath) {
|
|
314
352
|
const noFollow = fs.constants.O_NOFOLLOW ?? 0;
|
|
315
353
|
let descriptor;
|
|
316
354
|
try {
|
|
317
|
-
descriptor = fs.openSync(
|
|
355
|
+
descriptor = fs.openSync(sourcePath, fs.constants.O_RDONLY | noFollow);
|
|
318
356
|
const stagedStat = fs.fstatSync(descriptor);
|
|
319
357
|
if (!stagedStat.isFile()) {
|
|
320
|
-
throw unsafeRecordingArtifactError(
|
|
358
|
+
throw unsafeRecordingArtifactError(sourcePath);
|
|
321
359
|
}
|
|
322
360
|
fs.fchmodSync(descriptor, 384);
|
|
323
361
|
} catch (error) {
|
|
324
362
|
if (error instanceof Error && error.message.includes("Next:")) throw error;
|
|
325
363
|
throw unsafeRecordingArtifactError(
|
|
326
|
-
|
|
364
|
+
sourcePath,
|
|
327
365
|
error instanceof Error ? error.message : String(error)
|
|
328
366
|
);
|
|
329
367
|
} finally {
|
|
330
368
|
if (descriptor !== void 0) fs.closeSync(descriptor);
|
|
331
369
|
}
|
|
332
|
-
refuseRecordingDestinationSymlink(
|
|
333
|
-
fs.renameSync(
|
|
370
|
+
refuseRecordingDestinationSymlink(outputPath);
|
|
371
|
+
fs.renameSync(sourcePath, outputPath);
|
|
334
372
|
}
|
|
335
373
|
function refuseRecordingDestinationSymlink(outputPath) {
|
|
336
374
|
try {
|
|
@@ -375,7 +413,7 @@ function restoreActiveRecordingEnvironment(recording) {
|
|
|
375
413
|
}
|
|
376
414
|
process.env[ACTIVE_EXTENSION_RECORDING_PID] = recording.previousActiveRecordingPid;
|
|
377
415
|
}
|
|
378
|
-
function addRecordingArtifactToManifest(result, recording) {
|
|
416
|
+
async function addRecordingArtifactToManifest(result, recording) {
|
|
379
417
|
const manifestPath = result.artifactManifestPath;
|
|
380
418
|
if (!manifestPath || !fs.existsSync(manifestPath)) {
|
|
381
419
|
console.error(`WARN: cannot add video artifact to missing artifact manifest: ${manifestPath ?? "<unset>"}`);
|
|
@@ -384,6 +422,23 @@ function addRecordingArtifactToManifest(result, recording) {
|
|
|
384
422
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
385
423
|
if (!Array.isArray(manifest.artifacts)) manifest.artifacts = [];
|
|
386
424
|
manifest.artifacts = manifest.artifacts.filter((artifact) => artifact.path !== recording.relativePath);
|
|
425
|
+
const timing = recording.nativeTiming ? recording.nativeTimingEvidence ?? { timingUnavailableReason: "Native recording timing unavailable" } : await optionalVideoTiming(recording.outputPath, {
|
|
426
|
+
startedAtUnixMs: recording.startedAtUnixMs,
|
|
427
|
+
stoppedAtUnixMs: recording.stoppedAtUnixMs ?? Date.now()
|
|
428
|
+
});
|
|
429
|
+
if (recording.nativeTimingEvidence?.timing) manifest.artifacts.push({ path: recording.relativePath + ".timing.json", type: "json", category: "system", label: "Native recording and screenshot timing" });
|
|
430
|
+
let timelinePath;
|
|
431
|
+
let timelineUnavailableReason = timing.timingUnavailableReason;
|
|
432
|
+
if (timing.timing) {
|
|
433
|
+
try {
|
|
434
|
+
const raw = JSON.parse(fs.readFileSync(result.tracePath, "utf8"));
|
|
435
|
+
const trace = Array.isArray(raw) ? raw : raw.entries;
|
|
436
|
+
timelinePath = await writeRecordingTimeline(path.dirname(manifestPath), recording.relativePath, timing.timing, trace);
|
|
437
|
+
manifest.artifacts.push({ path: timelinePath, type: "json", label: "Recording frames and action markers", category: "system" });
|
|
438
|
+
} catch (error) {
|
|
439
|
+
timelineUnavailableReason = error instanceof Error ? error.message : String(error);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
387
442
|
manifest.artifacts.push({
|
|
388
443
|
path: recording.relativePath,
|
|
389
444
|
type: "video",
|
|
@@ -391,6 +446,7 @@ function addRecordingArtifactToManifest(result, recording) {
|
|
|
391
446
|
category: "evidence",
|
|
392
447
|
mimeType: "video/mp4",
|
|
393
448
|
record: "full_run",
|
|
449
|
+
...timelinePath ? { timelinePath } : { timelineUnavailableReason },
|
|
394
450
|
metadata: {
|
|
395
451
|
provider: "capture-helper",
|
|
396
452
|
mode: "full_run",
|
package/dist/runner.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFileSync, execSync } from "node:child_process";
|
|
2
2
|
import { OFFICIAL_RECIPE_ACTIONS } from "@farmslot/protocol";
|
|
3
|
+
import { createAndroidMirrorVideoRecorder } from "@farmslot/recipe-harness";
|
|
3
4
|
import { createMetaMaskAdapters, createMetaMaskUiTransport } from "./adapters.js";
|
|
4
5
|
import {
|
|
5
6
|
bridgeCommand,
|
|
@@ -138,7 +139,7 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
|
|
|
138
139
|
logger,
|
|
139
140
|
recording: {
|
|
140
141
|
targetProvider: createMetaMaskRecordingTargetProvider(adapter),
|
|
141
|
-
...androidRecordingSerial ? { videoRecorder: createAndroidVideoRecorder(androidRecordingSerial) } : iosRecordingSimulator ? {
|
|
142
|
+
...androidRecordingSerial ? { videoRecorder: createAndroidMirrorVideoRecorder({ serial: androidRecordingSerial, fallback: createAndroidVideoRecorder(androidRecordingSerial) }) } : iosRecordingSimulator ? {
|
|
142
143
|
videoRecorder: createIosSimulatorVideoRecorder(iosRecordingSimulator)
|
|
143
144
|
} : {}
|
|
144
145
|
},
|
|
@@ -85,10 +85,12 @@ export function consentControllerExpression({
|
|
|
85
85
|
throw new Error('Extension consent state is unavailable.');
|
|
86
86
|
}
|
|
87
87
|
const metamask = root?.metamask ?? root ?? {};
|
|
88
|
-
|
|
88
|
+
// Debug UI state can lag an acknowledged setter until the next UI sync.
|
|
89
|
+
// Background/snapshot reads have no such subscription to wait for.
|
|
90
|
+
if ((capturedChrome || !storeState) && (
|
|
89
91
|
typeof metamask.optedIn !== 'boolean' ||
|
|
90
92
|
typeof metamask.dataCollectionForMarketing !== 'boolean'
|
|
91
|
-
) {
|
|
93
|
+
)) {
|
|
92
94
|
throw new Error('Extension consent state did not contain boolean consent fields.');
|
|
93
95
|
}
|
|
94
96
|
return {
|
|
@@ -118,6 +120,10 @@ export function consentControllerExpression({
|
|
|
118
120
|
}
|
|
119
121
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
120
122
|
} while (Date.now() < deadline);
|
|
123
|
+
if (typeof result?.state.optedIn !== 'boolean' ||
|
|
124
|
+
typeof result?.state.dataCollectionForMarketing !== 'boolean') {
|
|
125
|
+
throw new Error('Extension consent state did not contain boolean consent fields before the deadline.');
|
|
126
|
+
}
|
|
121
127
|
throw new Error(
|
|
122
128
|
'Consent state did not settle to ' + JSON.stringify(expected) +
|
|
123
129
|
'; got ' + JSON.stringify(result?.state) + '.',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deeeed/metamask-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.67.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"mm-harness": "bin/mm-harness"
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
"check:syntax": "find . -name '*.mjs' -print0 | xargs -0 -n1 node --check"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@farmslot/agent-runtime": "0.
|
|
24
|
-
"@farmslot/expo-recipe": "0.12.
|
|
23
|
+
"@farmslot/agent-runtime": "0.15.0",
|
|
24
|
+
"@farmslot/expo-recipe": "0.12.1",
|
|
25
25
|
"@farmslot/handoff": "^0.3.1",
|
|
26
|
-
"@farmslot/protocol": "0.
|
|
27
|
-
"@farmslot/recipe-harness": "0.
|
|
26
|
+
"@farmslot/protocol": "0.32.0",
|
|
27
|
+
"@farmslot/recipe-harness": "0.20.0",
|
|
28
28
|
"agent-device": "0.19.3",
|
|
29
29
|
"commander": "^12.0.0",
|
|
30
30
|
"es-module-lexer": "2.3.1",
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
"details": [
|
|
96
96
|
"Starting conditions are ordinary nodes at the beginning of workflow, not a preconditions field. Use idempotent ensure actions and independently assert their postconditions.",
|
|
97
97
|
"Do not invoke mm-harness run/call from a command node on the same checkout: the outer run owns its sandbox. Compose actions or call child recipes instead. External measurement wrappers may invoke one root run, with preparation outside it; never bypass the lock.",
|
|
98
|
-
"Inspect existing state before creating or cleaning fixtures. Establish only prerequisites required by the claim; a history or display check may already have usable records and need no trade or cleanup. Record the selected records and verify they still satisfy the proof boundary.",
|
|
98
|
+
"Inspect existing state before creating or cleaning fixtures. Establish only prerequisites required by the claim; a history or display check may already have usable records and need no trade or cleanup. Record the selected records and verify they still satisfy the proof boundary. Record fixture identity and lifetime: terminal records cannot prove active behavior, and restarting can erase in-memory history. Re-establish expired prerequisites explicitly before the proof window.",
|
|
99
99
|
"Match history-query ranges to the dates of existing visible records before declaring fixtures missing. Sample only sections needed for change detection; fetch one-time corroborating history once, and count background calls as well as graph nodes.",
|
|
100
100
|
"Actions translate UI, CDP, or controller operations. Recipes compose them. Product code owns business rules. Teams own specialized journeys in their libraries; the harness retains shared capabilities and a small set of composable examples.",
|
|
101
101
|
"Put identical cross-adapter graphs in recipes/shared/<domain>/ and platform variants in recipes/<adapter>/<domain>/. Both keep the same domain.recipe name; an adapter variant takes precedence. Keep platform differences in actions when the journey is identical. Verify discovery, execution, and callers before removing duplicates.",
|
|
@@ -232,7 +232,8 @@
|
|
|
232
232
|
"adapters": [
|
|
233
233
|
"core"
|
|
234
234
|
]
|
|
235
|
-
}
|
|
235
|
+
},
|
|
236
|
+
"Use fixtures that make the intended and incorrect outcomes differ. For changing geometry or values, declare the observation window and compare the same entity and inputs; do not select only favorable samples. Promote trace, screenshots, video and provenance as one immutable package, and inspect replacement image bytes again."
|
|
236
237
|
],
|
|
237
238
|
"commands": [
|
|
238
239
|
"mm-harness run ./proof.recipe.json --artifacts-dir temp/recipe-proof --json",
|