@githolon/testing 0.3.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/src/index.mjs ADDED
@@ -0,0 +1,449 @@
1
+ // @githolon/testing — LAW TDD for Nomos domains.
2
+ //
3
+ // `testHolon({ deploy })` boots THE ENGINE PLANE in-process (the vendored
4
+ // cloud/holon-host/src/engine.mjs — the exact machinery every cloud DO, heavy
5
+ // container and web client runs) over the one wasm32-wasip1 artifact, installs
6
+ // your compiled law at genesis, and hands back a holon you drive from a test:
7
+ // dispatch directives, read rows/queries/counts/sums, assert TYPED REFUSALS
8
+ // (invariants are real law — a violating dispatch REJECTS with the invariant's
9
+ // own reject code), fork() for what-ifs, verify() the chain.
10
+ //
11
+ // Nothing here is a simulator: every dispatch runs the real author gate
12
+ // (payload zod, plan re-run, invariant oracle, kernel fold) and every fork /
13
+ // verify replays the same bytes the cloud would replay on a push.
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { dirname, join, resolve } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import {
19
+ author,
20
+ count as engineCount,
21
+ createEngine,
22
+ extremum as engineExtremum,
23
+ installPayload,
24
+ mountWorkspace,
25
+ qById,
26
+ query as engineQuery,
27
+ sha256hex,
28
+ snapshot,
29
+ sum as engineSum,
30
+ verifyChainLocal,
31
+ warmEngine,
32
+ } from "../vendor/engine/engine.mjs";
33
+ import { deserializeTree } from "../vendor/engine/tree.mjs";
34
+
35
+ const HERE = dirname(fileURLToPath(import.meta.url));
36
+ const HOLON_HOST = join(HERE, "..", "..", "cloud", "holon-host"); // the monorepo sibling
37
+ const DEFAULT_CLOUD = "https://nomos.captainapp.co.uk";
38
+ const WS = "test"; // the in-engine mount name — a chain binds to no workspace name
39
+ const UNBORN = { remote: "https://unborn.invalid/", headers: {} }; // degrades to a fresh in-memory tree
40
+
41
+ const dec = new TextDecoder();
42
+
43
+ // ── the typed refusal ─────────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * A dispatch the gate REFUSED — payload validation, a missing instance, or a
47
+ * declared invariant (the law's own reject code rides `message` verbatim, so
48
+ * `expect(...).rejects.toThrow(/seats-exceed-capacity/)` asserts the law).
49
+ */
50
+ export class Refusal extends Error {
51
+ constructor(refusal, { domain, directiveId }) {
52
+ super(`${domain}/${directiveId} refused: ${refusal}`);
53
+ this.name = "Refusal";
54
+ this.domain = domain;
55
+ this.directiveId = directiveId;
56
+ /** The gate's verdict, verbatim. */
57
+ this.refusal = refusal;
58
+ }
59
+ }
60
+
61
+ // ── runtime acquisition (the wasm + baked packages/manifests) ─────────────────
62
+ //
63
+ // Resolution order (first hit wins):
64
+ // 1. opts.runtimeDir / GITHOLON_RUNTIME_DIR — a cache-shaped dir you control
65
+ // ({holon.wasm, manifests.json, packages.json});
66
+ // 2. the monorepo sibling cloud/holon-host (vendored wasm + baked domains) —
67
+ // fully offline for anyone working inside the nomos2 repo;
68
+ // 3. the shared on-disk cache ~/.holon/runtime (HOLON_CONFIG_DIR overrides) —
69
+ // the SAME cache the githolon cli populates, so any prior `githolon ledger`
70
+ // run already warmed it;
71
+ // 4. the cloud's /v1/runtime endpoints (one fetch, then cached → offline after).
72
+ // `opts.offline` / GITHOLON_TESTING_OFFLINE=1 forbids step 4 (fail with the story
73
+ // instead of a surprise network call).
74
+
75
+ const cacheRoot = () => join(process.env.HOLON_CONFIG_DIR ?? join(homedir(), ".holon"), "runtime");
76
+ const cloudBase = (flag) => (flag ?? process.env.NOMOS_CLOUD ?? DEFAULT_CLOUD).replace(/\/+$/, "");
77
+
78
+ function fromRuntimeDir(dir) {
79
+ const wasm = join(dir, "holon.wasm");
80
+ const manifests = join(dir, "manifests.json");
81
+ const packages = join(dir, "packages.json");
82
+ if (!existsSync(wasm) || !existsSync(manifests) || !existsSync(packages)) return null;
83
+ const m = JSON.parse(readFileSync(manifests, "utf8"));
84
+ const p = JSON.parse(readFileSync(packages, "utf8"));
85
+ return {
86
+ wasmBytes: readFileSync(wasm),
87
+ bootstrapPkg: p.bootstrap,
88
+ nomosPkg: p.nomos,
89
+ baked: { read: m.domainManifests, identity: m.identityManifests },
90
+ source: dir,
91
+ };
92
+ }
93
+
94
+ function fromHolonHost() {
95
+ const wasm = process.env.GITHOLON_TESTING_WASM ?? join(HOLON_HOST, "vendor", "wasm_git_holon.wasm");
96
+ const domains = join(HOLON_HOST, "domains");
97
+ if (!existsSync(wasm) || !existsSync(join(domains, "domain_nomos.package.usda"))) return null;
98
+ return {
99
+ wasmBytes: readFileSync(wasm),
100
+ bootstrapPkg: readFileSync(join(domains, "domain_bootstrap.package.usda"), "utf8"),
101
+ nomosPkg: readFileSync(join(domains, "domain_nomos.package.usda"), "utf8"),
102
+ baked: {
103
+ read: readFileSync(join(domains, "domain_manifests.txt"), "utf8"),
104
+ identity: readFileSync(join(domains, "identity_manifests.txt"), "utf8"),
105
+ },
106
+ source: wasm,
107
+ };
108
+ }
109
+
110
+ async function fromCloud(cloud, cache) {
111
+ const get = async (path) => {
112
+ const r = await fetch(`${cloud}${path}`);
113
+ if (!r.ok) throw new Error(`${cloud}${path} → ${r.status}`);
114
+ return r;
115
+ };
116
+ const wasmBytes = new Uint8Array(await (await get("/v1/runtime/holon.wasm")).arrayBuffer());
117
+ const manifests = await (await get("/v1/runtime/manifests")).text();
118
+ const packages = await (await get("/v1/runtime/packages")).text();
119
+ mkdirSync(cache, { recursive: true });
120
+ writeFileSync(join(cache, "holon.wasm"), wasmBytes);
121
+ writeFileSync(join(cache, "manifests.json"), manifests, "utf8");
122
+ writeFileSync(join(cache, "packages.json"), packages, "utf8");
123
+ const m = JSON.parse(manifests);
124
+ const p = JSON.parse(packages);
125
+ return {
126
+ wasmBytes,
127
+ bootstrapPkg: p.bootstrap,
128
+ nomosPkg: p.nomos,
129
+ baked: { read: m.domainManifests, identity: m.identityManifests },
130
+ source: cloud,
131
+ };
132
+ }
133
+
134
+ const runtimeMemo = new Map(); // key → Promise<runtime+compiled module>
135
+
136
+ async function resolveRuntime(opts) {
137
+ const explicit = opts.runtimeDir ?? process.env.GITHOLON_RUNTIME_DIR;
138
+ if (explicit !== undefined) {
139
+ const r = fromRuntimeDir(resolve(explicit));
140
+ if (r === null) throw new Error(`runtimeDir ${explicit} must contain holon.wasm, manifests.json and packages.json (the ~/.holon/runtime cache shape)`);
141
+ return r;
142
+ }
143
+ const repo = fromHolonHost();
144
+ if (repo !== null) return repo;
145
+ const cached = fromRuntimeDir(cacheRoot());
146
+ if (cached !== null) return cached;
147
+ const offline = opts.offline === true || process.env.GITHOLON_TESTING_OFFLINE !== undefined;
148
+ if (offline) {
149
+ throw new Error(
150
+ `offline and no runtime artifacts found. Provide one of: (a) runtimeDir pointing at {holon.wasm, manifests.json, packages.json}; ` +
151
+ `(b) run inside the nomos2 monorepo (cloud/holon-host vendors the wasm); ` +
152
+ `(c) warm the shared cache once while online (any testHolon() run, or \`githolon ledger verify\`, populates ${cacheRoot()}).`,
153
+ );
154
+ }
155
+ return fromCloud(cloudBase(opts.cloud), cacheRoot());
156
+ }
157
+
158
+ function loadRuntime(opts) {
159
+ const key = JSON.stringify([opts.runtimeDir ?? process.env.GITHOLON_RUNTIME_DIR ?? null, opts.cloud ?? null, opts.offline === true]);
160
+ let p = runtimeMemo.get(key);
161
+ if (p === undefined) {
162
+ p = (async () => {
163
+ const r = await resolveRuntime(opts);
164
+ return { ...r, wasmModule: await WebAssembly.compile(r.wasmBytes) };
165
+ })();
166
+ runtimeMemo.set(key, p);
167
+ }
168
+ return p;
169
+ }
170
+
171
+ // ── the law (a nomos-compile deploy body) ─────────────────────────────────────
172
+
173
+ function loadBody(opts) {
174
+ if (opts.deploy !== undefined) {
175
+ const d = opts.deploy;
176
+ if (typeof d === "string") return JSON.parse(readFileSync(resolve(d), "utf8"));
177
+ if (d instanceof URL) return JSON.parse(readFileSync(d, "utf8"));
178
+ return d;
179
+ }
180
+ if (opts.law !== undefined) {
181
+ return {
182
+ packageUsda: opts.law,
183
+ readManifest: opts.manifests?.readManifest,
184
+ identityManifests: opts.manifests?.identityManifests,
185
+ };
186
+ }
187
+ throw new Error("testHolon needs law: pass { deploy: './build/<name>.deploy.json' } (the nomos-compile output) or { law: usdaText, manifests }");
188
+ }
189
+
190
+ /** Baked ∪ overlay manifest strings — the lean twin of the worker's `_mergeManifests`
191
+ * (LAST DEPLOY WINS per read-declaration id — the law-upgrade overlay rule). */
192
+ function mergeManifests(baked, deployBody) {
193
+ const read = JSON.parse(baked.read);
194
+ const identity = JSON.parse(baked.identity);
195
+ for (const [k, v] of Object.entries(deployBody?.identityManifests ?? {})) identity[k] = v;
196
+ const r = deployBody?.readManifest;
197
+ if (r !== undefined) {
198
+ for (const [t, schema] of Object.entries(r.aggregateFieldKinds ?? {})) read.aggregateFieldKinds[t] = schema;
199
+ for (const key of ["queries", "counts", "sums", "mins", "maxes", "spatials", "deriveds", "combineds"]) {
200
+ for (const d of r[key] ?? []) {
201
+ const list = (read[key] = read[key] ?? []);
202
+ const scoped = key === "deriveds" || key === "combineds";
203
+ const at = list.findIndex((x) => x.id === d.id && (!scoped || x.of === d.of));
204
+ if (at >= 0) list[at] = d;
205
+ else list.push(d);
206
+ }
207
+ }
208
+ }
209
+ return { read: JSON.stringify(read), identity: JSON.stringify(identity) };
210
+ }
211
+
212
+ // ── deterministic ports (clock + rng) ─────────────────────────────────────────
213
+ //
214
+ // A plan is a pure function of (payload, ports); the HOST stamps the captured
215
+ // clock (Date.now()) and rng (crypto.getRandomValues) onto the intent envelope.
216
+ // For reproducible tests we pin BOTH at the host boundary, scoped to the
217
+ // synchronous author call — the engine itself is untouched.
218
+
219
+ const toMs = (v) => (typeof v === "number" ? v : Date.parse(v));
220
+
221
+ function normalizeClock(c) {
222
+ if (c === undefined || c === null) return null;
223
+ if (typeof c === "function") return { fn: c };
224
+ if (typeof c === "object") return { cursor: toMs(c.start ?? "2026-01-01T00:00:00.000Z"), step: c.stepMs ?? 1000 };
225
+ return { cursor: toMs(c), step: 1000 };
226
+ }
227
+
228
+ const cloneClock = (c) => (c === null ? null : c.fn !== undefined ? { fn: c.fn } : { cursor: c.cursor, step: c.step });
229
+
230
+ function nextNow(clock) {
231
+ if (clock.fn !== undefined) return toMs(clock.fn());
232
+ const t = clock.cursor;
233
+ clock.cursor += clock.step;
234
+ return t;
235
+ }
236
+
237
+ // mulberry32 — a tiny, well-distributed seeded PRNG; state is a plain number so
238
+ // fork() clones it and a forked holon stays reproducible.
239
+ function nextFloat(rng) {
240
+ rng.state = (rng.state + 0x6d2b79f5) | 0;
241
+ let t = Math.imul(rng.state ^ (rng.state >>> 15), 1 | rng.state);
242
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
243
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
244
+ }
245
+
246
+ function withPorts({ nowMs, rng }, fn) {
247
+ if (nowMs === null && rng === null) return fn();
248
+ const realNow = Date.now;
249
+ const realCrypto = globalThis.crypto;
250
+ if (nowMs !== null) Date.now = () => nowMs;
251
+ if (rng !== null) {
252
+ const fake = {
253
+ getRandomValues(arr) {
254
+ const bytes = new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength);
255
+ for (let i = 0; i < bytes.length; i++) bytes[i] = (nextFloat(rng) * 256) | 0;
256
+ return arr;
257
+ },
258
+ subtle: realCrypto.subtle,
259
+ randomUUID: realCrypto.randomUUID?.bind(realCrypto),
260
+ };
261
+ Object.defineProperty(globalThis, "crypto", { value: fake, configurable: true, enumerable: true });
262
+ }
263
+ try {
264
+ return fn();
265
+ } finally {
266
+ Date.now = realNow;
267
+ Object.defineProperty(globalThis, "crypto", { value: realCrypto, configurable: true, enumerable: true });
268
+ }
269
+ }
270
+
271
+ const MASK63 = (1n << 63n) - 1n;
272
+ const randomReplica = () => {
273
+ const b = new Uint8Array(8);
274
+ globalThis.crypto.getRandomValues(b);
275
+ let v = 0n;
276
+ for (const x of b) v = (v << 8n) | BigInt(x);
277
+ return v & MASK63;
278
+ };
279
+
280
+ // ── the holon ─────────────────────────────────────────────────────────────────
281
+
282
+ class TestHolon {
283
+ /** @internal */
284
+ constructor(internal) {
285
+ this._eng = internal.eng;
286
+ this._bootCfg = internal.bootCfg; // {wasmModule, bootstrapPkg, nomosPkg, manifestStrings, installedBy}
287
+ this._chain = internal.chain;
288
+ this._clock = internal.clock;
289
+ this._rng = internal.rng;
290
+ this._forkSeq = 0;
291
+ /** This holon's replica id (rides every intent's captured clock). */
292
+ this.replica = internal.replica;
293
+ /** sha256 of the installed law package — the content-addressed law id. */
294
+ this.lawHash = internal.lawHash;
295
+ }
296
+
297
+ /** The raw engine handle (the vendored engine plane) — the escape hatch. */
298
+ get engine() {
299
+ return this._eng;
300
+ }
301
+
302
+ /** The in-engine workspace name reads/authors bind to. */
303
+ get workspace() {
304
+ return WS;
305
+ }
306
+
307
+ _author(domain, directiveId, payload, controllerHash, o = {}) {
308
+ const nowMs = o.at !== undefined ? toMs(o.at) : this._clock !== null ? nextNow(this._clock) : null;
309
+ const res = withPorts({ nowMs, rng: this._rng }, () =>
310
+ author(this._eng, WS, domain, directiveId, payload, controllerHash, o.rngDraws !== undefined ? { rngDraws: o.rngDraws } : {}),
311
+ );
312
+ if (res.ok !== true) throw new Refusal(res.error ?? JSON.stringify(res), { domain, directiveId });
313
+ const bytes = dec.decode(this._eng.preopen.dir.contents.get("ws").contents.get(WS).contents.get(res.intentOut).data);
314
+ this._chain.push(bytes);
315
+ warmEngine(this._eng);
316
+ const intent = JSON.parse(bytes);
317
+ const created = (intent.events ?? []).filter((e) => e.marker === "Create").map((e) => e.aggregate);
318
+ return { head: res.head, id: created[0] ?? null, created, intent };
319
+ }
320
+
321
+ /**
322
+ * Author ONE intent through the real gate (payload zod → plan re-run →
323
+ * invariant oracle → kernel fold → sealed onto the chain). Resolves with
324
+ * `{ head, id, created, intent }` (`id` = the first minted aggregate id —
325
+ * callers never supply ids) or REJECTS with a typed {@link Refusal}.
326
+ *
327
+ * opts: `{ at }` pins this intent's captured clock (ISO or ms);
328
+ * `{ rngDraws }` raises the captured-entropy budget for BATCH-creating plans
329
+ * (19 draws per minted id; the default 64 covers 3 mints).
330
+ */
331
+ async dispatch(domain, directiveId, payload, opts = {}) {
332
+ return this._author(domain, directiveId, payload, this.lawHash, opts);
333
+ }
334
+
335
+ /** All projected rows for one aggregate id (`[{ id, data }]`; partial folds are normal). */
336
+ byId(aggregateId) {
337
+ return qById(this._eng, WS, aggregateId);
338
+ }
339
+
340
+ /** A declared query (`query("…").key(…)`) — an indexed probe, never a scan. */
341
+ query(queryId, params = {}) {
342
+ return engineQuery(this._eng, WS, queryId, JSON.stringify(params));
343
+ }
344
+
345
+ /** A maintained count tally (O(1)); returns the number. */
346
+ count(countId, group = "") {
347
+ const r = engineCount(this._eng, WS, countId, group);
348
+ return r.count ?? r;
349
+ }
350
+
351
+ /** A maintained sum tally (O(1)); returns the number. */
352
+ sum(sumId, group = "") {
353
+ const r = engineSum(this._eng, WS, sumId, group);
354
+ return r.sum ?? r;
355
+ }
356
+
357
+ /** A maintained min/max read; `kind` = "min" | "max". Absent group ⇒ null. */
358
+ extremum(extremumId, kind, group = "") {
359
+ const r = engineExtremum(this._eng, WS, extremumId, kind, group);
360
+ return r.value ?? r;
361
+ }
362
+
363
+ /** The chain so far, parsed — genesis installs first, then every dispatch. */
364
+ intents() {
365
+ return this._chain.map((s) => JSON.parse(s));
366
+ }
367
+
368
+ /**
369
+ * The verdict of the SAME wasm `verify_chain` gate the cloud runs on an
370
+ * upload birth: contiguity → every intent re-admitted against the state
371
+ * folded from the chain prefix, every resolvable plan re-run and
372
+ * byte-compared. `{ valid: true, … }` or the refusing check + index.
373
+ */
374
+ verify() {
375
+ return verifyChainLocal(this._eng, WS);
376
+ }
377
+
378
+ /**
379
+ * A cheap what-if branch: a FRESH engine cold-mounting this holon's exact
380
+ * tree bytes (the same cold-fold path a restoring peer runs). Dispatches on
381
+ * the fork never touch the parent. The fork inherits law hash, chain, and a
382
+ * CLONE of the deterministic clock/rng state; its replica is derived
383
+ * (parent + fork ordinal) unless `opts.replica` pins it.
384
+ */
385
+ async fork(opts = {}) {
386
+ const bytes = snapshot(this._eng, WS);
387
+ const replica = (opts.replica !== undefined ? BigInt(opts.replica) : this.replica + BigInt(++this._forkSeq)) & MASK63;
388
+ const eng = await createEngine({ ...this._bootCfg, replica });
389
+ await mountWorkspace(eng, WS, UNBORN);
390
+ const wsRoot = eng.preopen.dir.contents.get("ws");
391
+ const template = wsRoot.contents.get(WS);
392
+ const Directory = template.constructor;
393
+ const File = template.contents.get("domain_manifests.json").constructor;
394
+ wsRoot.contents.set(WS, new Directory(deserializeTree(bytes, { File, Directory })));
395
+ return new TestHolon({
396
+ eng,
397
+ bootCfg: this._bootCfg,
398
+ replica,
399
+ lawHash: this.lawHash,
400
+ chain: [...this._chain],
401
+ clock: cloneClock(this._clock),
402
+ rng: this._rng === null ? null : { state: this._rng.state },
403
+ });
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Boot a law-under-test holon: the real engine plane, your compiled law
409
+ * installed at genesis, fully offline (see the runtime resolution order above).
410
+ *
411
+ * @param {object} opts
412
+ * deploy path/URL/object of the nomos-compile `<name>.deploy.json`
413
+ * law,manifests alternative: raw package USDA text (+ optional manifests)
414
+ * deterministic true ⇒ pinned clock (2026-01-01, +1s/intent), seed 42, replica 7
415
+ * clock ISO/ms (start, +1s per intent) | {start, stepMs} | () => now
416
+ * seed number ⇒ seeded captured rng (minted ids reproduce run-to-run)
417
+ * replica pin the 63-bit replica id (defaults: seed-derived, else random)
418
+ * installedBy the principal stamped on the genesis installs (default "law-test")
419
+ * runtimeDir / cloud / offline runtime acquisition (see module docs)
420
+ */
421
+ export async function testHolon(opts = {}) {
422
+ const body = loadBody(opts);
423
+ const runtime = await loadRuntime(opts);
424
+ const det = opts.deterministic === true;
425
+ const clock = normalizeClock(opts.clock ?? (det ? { start: "2026-01-01T00:00:00.000Z", stepMs: 1000 } : null));
426
+ const seed = opts.seed ?? (det ? 42 : undefined);
427
+ const rng = seed === undefined ? null : { state: seed >>> 0 };
428
+ const replica =
429
+ opts.replica !== undefined ? BigInt(opts.replica) & MASK63
430
+ : det ? 7n
431
+ : seed !== undefined ? (1000n + BigInt(seed >>> 0)) & MASK63
432
+ : randomReplica();
433
+ const installedBy = opts.installedBy ?? "law-test";
434
+ const bootCfg = {
435
+ wasmModule: runtime.wasmModule,
436
+ bootstrapPkg: runtime.bootstrapPkg,
437
+ nomosPkg: runtime.nomosPkg,
438
+ manifestStrings: mergeManifests(runtime.baked, body),
439
+ installedBy,
440
+ };
441
+ const eng = await createEngine({ ...bootCfg, replica });
442
+ await mountWorkspace(eng, WS, UNBORN);
443
+ const h = new TestHolon({ eng, bootCfg, replica, lawHash: null, chain: [], clock, rng });
444
+ // Genesis: bootstrap installs the nomos controller; then YOUR law under it.
445
+ h._author("bootstrap", "installDomain", installPayload(eng.hashes.nomos, runtime.nomosPkg, installedBy), "");
446
+ h.lawHash = await sha256hex(body.packageUsda);
447
+ h._author("nomos", "installDomain", installPayload(h.lawHash, body.packageUsda, installedBy), eng.hashes.nomos);
448
+ return h;
449
+ }