1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119 | /**
* A proxy recorder for the doubles the code under test calls into.
*
* `fakeLibp2p` already does this by hand — every method pushes onto a `calls`
* array and then does the real thing — and that array is why `rk-net.json` can
* pin dial addresses and hangUps rather than only the resulting state. This
* generalises it: wrap any double once and every method call is recorded as
*
* ["log.putEntryBlock", "<hash>", 4]
*
* (name, then each argument reduced to something JSON-stable) before delegating
* to the real implementation.
*
* THE POINT is the shared sink. Give the recorder the same array as
* helpers/det-clock.mjs and clock ops interleave with effects in ONE transcript:
*
* ["setTimeout", 1000, 3]
* ["fire", 3]
* ["client.deposit", 36]
* ["setTimeout", 2000, 4]
*
* so re-entrancy ("did a second deposit start?"), side-effect ORDER (the
* crash-safety invariant `add-retry` < cursor advance < `ingestEntry`) and the
* backoff ladder are all pinned by a single `deepEqual` over one array. Three
* separate assertions over three separate arrays cannot say anything about how
* the three interleave, and interleaving is the whole subject.
*
* Return values are NOT recorded by default: a promise's identity is not
* interesting and its resolution arrives later, out of order with the call
* transcript. Record consequences (what the double was asked to do), not
* answers.
*/
const BYTES = (v) =>
ArrayBuffer.isView(v) ? v.byteLength : v instanceof ArrayBuffer ? v.byteLength : null;
/**
* One argument, reduced to a JSON-stable value.
* string/number/boolean/null → itself
* undefined → "undefined" (distinguishable from null)
* typed array / ArrayBuffer → byteLength (bytes are pinned by the wire
* vectors; here only the SHAPE matters)
* function → "fn"
* Array → its elements, summarized
* CID-ish (has .toString on its own prototype) and Error → String(v)
* plain object → own enumerable keys, in insertion order,
* summarized
* anything else → "«ConstructorName»"
*/
export function summarize(v) {
if (v === undefined) return "undefined";
if (v === null) return null;
const t = typeof v;
if (t === "string" || t === "number" || t === "boolean") return v;
if (t === "function") return "fn";
if (t === "bigint" || t === "symbol") return String(v);
const bytes = BYTES(v);
if (bytes !== null) return bytes;
if (Array.isArray(v)) return v.map(summarize);
if (v instanceof Error) return `${v.name}: ${v.message}`;
if (v instanceof Map) return [...v.keys()].map(summarize);
if (v instanceof Set) return [...v].map(summarize);
const proto = Object.getPrototypeOf(v);
if (proto === Object.prototype || proto === null) {
const out = {};
for (const k of Object.keys(v)) out[k] = summarize(v[k]);
return out;
}
// A CID, a URL, a PeerId — anything whose class defines its own toString
if (typeof v.toString === "function" && v.toString !== Object.prototype.toString) return String(v);
return `«${proto?.constructor?.name ?? "object"}»`;
}
/**
* Wrap `target` so every method call appends `[`${prefix}.${name}`, …args]` to
* `sink` and then delegates.
*
* only record only these method names (default: all)
* skip never record these
* args per-method argument reducer, `(…args) => array`, overriding
* `summarize` where a default reduction is unhelpfully large
* props also record property READS as [`${prefix}.${name}`, "get"] — off by
* default; on for the handful of getters whose read order matters
*/
export function effectLog(target, prefix, sink, { only = null, skip = [], args = {}, props = [] } = {}) {
const skipSet = new Set(skip);
const onlySet = only ? new Set(only) : null;
const propSet = new Set(props);
const wrapped = new Map();
return new Proxy(target, {
get(obj, key, recv) {
const value = Reflect.get(obj, key, recv);
if (typeof key !== "string") return value;
if (propSet.has(key)) sink.push([`${prefix}.${key}`, "get"]);
if (typeof value !== "function") return value;
if (skipSet.has(key) || (onlySet && !onlySet.has(key))) return value.bind(obj);
if (wrapped.has(key)) return wrapped.get(key);
const reduce = args[key] ?? ((...a) => a.map(summarize));
const fn = (...a) => {
sink.push([`${prefix}.${key}`, ...reduce(...a)]);
return value.apply(obj, a);
};
wrapped.set(key, fn);
return fn;
},
});
}
/**
* A bare recorder for callbacks the code under test is handed (`onReplayed`,
* `onNotice`, `onDisplayOnly`, `onEntry`): same transcript, no object to wrap.
*/
export function effectFn(name, sink, impl = () => undefined) {
return (...a) => {
sink.push([name, ...a.map(summarize)]);
return impl(...a);
};
}
|