chat / client / test / helpers / effect-log.mjs
  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);
  };
}

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/chat.git