@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/LICENSE.md ADDED
@@ -0,0 +1,45 @@
1
+ # Nomos Pre-Release License (v1)
2
+
3
+ Copyright © 2026 Captain App Ltd. All rights reserved.
4
+
5
+ This is a pre-release of Nomos. This license gives you what you need to BUILD
6
+ with it; we keep the rest for now.
7
+
8
+ ## You may
9
+
10
+ - install and use these packages to author Nomos domains, compile them, and
11
+ deploy them to Nomos Cloud or any Nomos instance Captain App operates or
12
+ authorizes;
13
+ - build, run, and ship applications on top of them — including commercial ones;
14
+ - keep everything that's yours: code you write, and everything these tools
15
+ generate FOR you (scaffolds from `create-githolon` / `githolon generate`, generated
16
+ clients, compiled domain packages) carries NO restriction from us — it is
17
+ yours outright.
18
+
19
+ ## You may not
20
+
21
+ - redistribute these packages or their source, in whole or in part, outside
22
+ your team;
23
+ - modify them or build derivative tools, SDKs, or runtimes from their source;
24
+ - offer the Nomos runtime, or anything materially similar, as a hosted service;
25
+ - reverse-engineer the holon wasm runtime.
26
+
27
+ ## Data retention
28
+
29
+ This is a pre-release we are actively evaluating. Workspaces you retire stop
30
+ counting toward your quota but are NOT deleted: **we retain all workspace data
31
+ (ledgers, law, intents) and may examine it to evaluate how the product is
32
+ used.** Don't put anything in a pre-release workspace you wouldn't want the
33
+ builders to read. If you need something truly expunged, ask:
34
+ jack@captainapp.co.uk.
35
+
36
+ ## The rest
37
+
38
+ Provided **as is**, with no warranty of any kind; to the maximum extent
39
+ permitted by law, Captain App Ltd accepts no liability arising from your use.
40
+ This license terminates automatically if you breach it.
41
+
42
+ Want the kernel source, broader rights, or to do something this doesn't cover?
43
+ **Ask: jack@captainapp.co.uk.** The plan is to open Nomos up gradually, with
44
+ the people actually using it — telling us what you're building is how that
45
+ happens faster.
package/README.md ADDED
@@ -0,0 +1,159 @@
1
+ # @githolon/testing — law TDD for Nomos domains
2
+
3
+ Test your law the way the cloud judges it. `testHolon()` boots **the real engine
4
+ plane** in-process — the vendored `cloud/holon-host` engine over the one
5
+ wasm32-wasip1 artifact, the exact machinery every cloud DO, heavy container and
6
+ web client runs — installs your compiled law at genesis, and hands you a holon
7
+ you drive from vitest. Nothing is simulated: every `dispatch` runs the real
8
+ author gate (payload zod → plan re-run → invariant oracle → kernel fold →
9
+ sealed onto the chain), and `verify()` is the same wasm `verify_chain` the
10
+ cloud runs on an upload birth.
11
+
12
+ ```bash
13
+ npm i -D @githolon/testing vitest
14
+ npx nomos-compile # your law → build/<name>.deploy.json
15
+ ```
16
+
17
+ ```ts
18
+ import { describe, expect, it } from "vitest";
19
+ import { testHolon } from "@githolon/testing";
20
+
21
+ it("the law refuses an over-capacity booking", async () => {
22
+ const h = await testHolon({ deploy: "./build/seats.deploy.json", deterministic: true });
23
+ const b = await h.dispatch("seats", "openBooking", { label: "launch", capacity: 2 });
24
+ await h.dispatch("seats", "takeSeat", { bookingId: b.id, attendee: "ada", takenAt: T });
25
+ await h.dispatch("seats", "takeSeat", { bookingId: b.id, attendee: "bob", takenAt: T });
26
+
27
+ // THE HEADLINE — invariants are real law; the refusal carries the law's own reject code:
28
+ await expect(
29
+ h.dispatch("seats", "takeSeat", { bookingId: b.id, attendee: "eve", takenAt: T }),
30
+ ).rejects.toThrow(/seats-exceed-capacity/);
31
+ });
32
+ ```
33
+
34
+ ## The surface
35
+
36
+ ```ts
37
+ const h = await testHolon({
38
+ deploy: "./build/<name>.deploy.json", // the nomos-compile output (path, URL or object)
39
+ // or: law: usdaText, manifests: { readManifest, identityManifests }
40
+ deterministic: true, // pinned clock + seeded rng + fixed replica (below)
41
+ });
42
+
43
+ await h.dispatch(domain, directiveId, payload, { at?, rngDraws? });
44
+ // → { head, id, created, intent } (id = the first minted aggregate id)
45
+ // → throws Refusal — message carries the gate's verdict VERBATIM, so
46
+ // expect(...).rejects.toThrow(/your-reject-code/) asserts the law
47
+
48
+ h.byId(id); // [{ id, data }] — projected rows (partial folds are normal)
49
+ h.query(queryId, params); // a declared query — indexed probe, never a scan
50
+ h.count(countId, group); // maintained O(1) tally → number
51
+ h.sum(sumId, group); // maintained O(1) sum → number
52
+ h.extremum(id, "min"|"max", group);
53
+
54
+ h.intents(); // the chain, parsed — genesis installs first, then every dispatch
55
+ h.verify(); // the wasm verify_chain verdict (the cloud's upload-birth gate)
56
+ await h.fork(); // a cheap what-if branch — a fresh engine cold-mounting the
57
+ // exact tree bytes; fork dispatches never touch the parent
58
+ h.lawHash; // sha256(package bytes) — the content-addressed law id
59
+ ```
60
+
61
+ `Refusal` is typed: `{ name: "Refusal", domain, directiveId, refusal }` with the
62
+ verdict verbatim in `message` — payload-schema refusals, missing instances and
63
+ declared invariants all arrive through the same lane, exactly as on the edge.
64
+
65
+ ## Deterministic clock & rng
66
+
67
+ A plan is a pure function of `(payload, ports)`; the HOST stamps the captured
68
+ clock and rng onto each intent's envelope. The harness pins both at that host
69
+ boundary (scoped to the synchronous author call — the engine is untouched):
70
+
71
+ ```ts
72
+ // the shorthand: clock 2026-01-01 advancing +1s/intent, seed 42, replica 7
73
+ const h = await testHolon({ deploy, deterministic: true });
74
+
75
+ // or granular:
76
+ const h = await testHolon({
77
+ deploy,
78
+ clock: { start: "2026-01-01T00:00:00.000Z", stepMs: 1000 }, // or an ISO/ms start, or () => now
79
+ seed: 7, // minted ids reproduce run-to-run
80
+ replica: 99, // the 63-bit replica id on every captured clock
81
+ });
82
+
83
+ await h.dispatch(d, id, p, { at: "2026-03-01T09:00:00.000Z" }); // pin ONE intent's clock
84
+ ```
85
+
86
+ Same seed + clock + replica ⇒ **byte-identical history**: the same minted ids,
87
+ the same chain head, run after run. (Remember: domain timestamps still ride IN
88
+ your payloads as ISO strings — the captured clock is the envelope's, not your
89
+ law's.)
90
+
91
+ ## Recipes
92
+
93
+ **Test an invariant** (real since the invariant gate landed — declared
94
+ invariants execute at author, admission AND verify):
95
+
96
+ ```ts
97
+ await expect(h.dispatch("seats", "takeSeat", overCapacityPayload))
98
+ .rejects.toThrow(/seats-exceed-capacity/);
99
+ expect(h.intents().length).toBe(before); // a refusal commits NOTHING
100
+ expect(h.verify().valid).toBe(true); // the chain stays green
101
+ ```
102
+
103
+ **Test a merge driver** — the merge IS the law:
104
+
105
+ ```ts
106
+ await h.dispatch("guestbook", "tagEntry", { entryId, tags: ["kind"] });
107
+ await h.dispatch("guestbook", "tagEntry", { entryId, tags: ["warm", "kind"] });
108
+ expect(h.byId(entryId)[0].data.tags).toEqual(["kind", "warm"]); // AddWins unions
109
+
110
+ await h.dispatch("guestbook", "reactToEntry", { entryId, reactor: "bob", reaction: "👏", reactedAt });
111
+ await h.dispatch("guestbook", "reactToEntry", { entryId, reactor: "bob", reaction: "💚", reactedAt: later });
112
+ expect(h.byId(entryId)[0].data.reactions).toEqual({ bob: "💚" }); // MapOf(Lww): per-key last write
113
+ ```
114
+
115
+ **Test a derived field** — engine-projected, never in the ledger:
116
+
117
+ ```ts
118
+ const { id } = await h.dispatch("guestbook", "signGuestbook", { mood: "grumpy", ... });
119
+ expect(h.byId(id)[0].data.moodEmoji).toBe("😠"); // projected
120
+ expect(JSON.stringify(h.intents().at(-1))).not.toContain("moodEmoji"); // never sealed
121
+ ```
122
+
123
+ **What-if on a fork** — consequence-free branches:
124
+
125
+ ```ts
126
+ const f = await h.fork();
127
+ await expect(f.dispatch(...breachingWrite)).rejects.toThrow(/your-code/);
128
+ await f.dispatch(...somethingElse); // lands on the fork
129
+ expect(h.byId(thatId)).toHaveLength(0); // the parent never saw it
130
+ ```
131
+
132
+ Worked examples: this package's own suite (`test/harness.test.mjs`, the seats
133
+ fixture) and the guestbook example's law tests
134
+ (`examples/guestbook/test/law.test.mts`).
135
+
136
+ ## How the runtime arrives (the wasm story, honestly)
137
+
138
+ The engine JS is **vendored into this package at build** (synced verbatim from
139
+ `cloud/holon-host/src` — the cli/deploy.sh pattern, so it can't fork). The wasm
140
+ artifact + the baked bootstrap/nomos packages and manifests are **not** in the
141
+ npm package (the wasm alone is tens of MB); they resolve, first hit wins:
142
+
143
+ 1. **`runtimeDir`** option / `GITHOLON_RUNTIME_DIR` — a directory you control
144
+ holding `{holon.wasm, manifests.json, packages.json}` (the cache shape).
145
+ Pin this in CI for fully hermetic runs.
146
+ 2. **The monorepo** — inside the nomos2 repo, `cloud/holon-host`'s vendored
147
+ wasm + domain artifacts are used directly. Never any network.
148
+ 3. **The shared cache** — `~/.holon/runtime` (`HOLON_CONFIG_DIR` overrides),
149
+ the SAME cache the `githolon` cli populates: any prior `githolon ledger`
150
+ run, or any prior `testHolon()` run, already warmed it.
151
+ 4. **The cloud** — one fetch of `/v1/runtime/{holon.wasm,manifests,packages}`
152
+ from `https://nomos.captainapp.co.uk` (`cloud` option / `NOMOS_CLOUD`
153
+ override), written into the cache. Every run after is offline.
154
+
155
+ So: the FIRST run on a fresh machine outside the monorepo needs the network
156
+ once. `offline: true` (or `GITHOLON_TESTING_OFFLINE=1`) forbids lane 4 — you
157
+ get a clear error naming your options instead of a surprise fetch. The wasm in
158
+ the cache is the cloud's own deployed artifact: byte-identical machinery,
159
+ which is the point.
package/build.mjs ADDED
@@ -0,0 +1,23 @@
1
+ // Vendor THE ENGINE PLANE (cloud/holon-host/src/{engine,git-fs,tree}.mjs — the
2
+ // exact machinery the edge DO and the heavy container run) into this package so
3
+ // `testHolon()` boots holons in-process with the same code path the cloud uses.
4
+ // Sync-at-build keeps it un-forked (the cli/build.mjs / deploy.sh pattern); the
5
+ // published package ships the vendored copies and skips the sync when the
6
+ // monorepo source is absent.
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const HERE = dirname(fileURLToPath(import.meta.url));
12
+ const ENGINE_SRC = join(HERE, "..", "cloud", "holon-host", "src");
13
+ const VENDOR = join(HERE, "vendor", "engine");
14
+
15
+ if (existsSync(ENGINE_SRC)) {
16
+ mkdirSync(VENDOR, { recursive: true });
17
+ for (const f of ["engine.mjs", "git-fs.mjs", "tree.mjs"]) {
18
+ const body = readFileSync(join(ENGINE_SRC, f), "utf8");
19
+ writeFileSync(join(VENDOR, f), `// AUTO-SYNCED from cloud/holon-host/src/${f} by testing/build.mjs — DO NOT EDIT HERE.\n${body}`, "utf8");
20
+ }
21
+ } else if (!existsSync(join(VENDOR, "engine.mjs"))) {
22
+ throw new Error("engine plane not found: neither ../cloud/holon-host/src nor vendor/engine exists");
23
+ }
package/index.d.ts ADDED
@@ -0,0 +1,142 @@
1
+ // @githolon/testing — the typed surface (see README.md for the vitest recipes).
2
+
3
+ /** A projected read-model row. Every field is partial — partial folds are normal. */
4
+ export interface Row<T = Record<string, unknown>> {
5
+ type?: string;
6
+ id: string;
7
+ data: Partial<T>;
8
+ }
9
+
10
+ /** What a successful dispatch resolves with. */
11
+ export interface DispatchResult {
12
+ /** The new chain head (a git commit oid). */
13
+ head: string;
14
+ /** The FIRST aggregate id this intent minted, or null (callers never supply ids). */
15
+ id: string | null;
16
+ /** Every aggregate id this intent minted (batch-creating plans mint many). */
17
+ created: string[];
18
+ /** The sealed chain entry, parsed. */
19
+ intent: Record<string, unknown>;
20
+ }
21
+
22
+ export interface DispatchOptions {
23
+ /** Pin THIS intent's captured clock (ISO string or epoch ms). */
24
+ at?: string | number;
25
+ /**
26
+ * The captured-entropy budget the plan may consume (19 draws per minted id;
27
+ * default 64 covers 3 mints). Raise it for batch-creating directives —
28
+ * commit-only-consumed: only the consumed prefix is sealed.
29
+ */
30
+ rngDraws?: number;
31
+ }
32
+
33
+ /**
34
+ * A dispatch the gate REFUSED — payload validation, a missing instance, or a
35
+ * declared invariant. The law's own reject code rides `message` verbatim, so
36
+ * `expect(...).rejects.toThrow(/seats-exceed-capacity/)` asserts the law.
37
+ */
38
+ export declare class Refusal extends Error {
39
+ name: "Refusal";
40
+ domain: string;
41
+ directiveId: string;
42
+ /** The gate's verdict, verbatim. */
43
+ refusal: string;
44
+ }
45
+
46
+ /** The wasm verify_chain verdict — the same gate the cloud runs on an upload birth. */
47
+ export interface Verdict {
48
+ valid: boolean;
49
+ head?: string;
50
+ intents?: number;
51
+ plansRerun?: number;
52
+ controllerHash?: string;
53
+ installedDomains?: string[];
54
+ /** On an invalid chain: the refusing check + index + error. */
55
+ check?: string;
56
+ atIndex?: number;
57
+ error?: string;
58
+ [k: string]: unknown;
59
+ }
60
+
61
+ export interface DeployBody {
62
+ packageUsda: string;
63
+ readManifest?: Record<string, unknown>;
64
+ identityManifests?: Record<string, string>;
65
+ }
66
+
67
+ export type ClockOption =
68
+ | string
69
+ | number
70
+ | { start?: string | number; stepMs?: number }
71
+ | (() => string | number);
72
+
73
+ export interface TestHolonOptions {
74
+ /** Path / URL / parsed object of the nomos-compile `<name>.deploy.json`. */
75
+ deploy?: string | URL | DeployBody;
76
+ /** Alternative to `deploy`: the raw package USDA text… */
77
+ law?: string;
78
+ /** …plus its manifests (omit and declared queries/derived fields won't route). */
79
+ manifests?: { readManifest?: Record<string, unknown>; identityManifests?: Record<string, string> };
80
+ /** true ⇒ pinned clock (2026-01-01, +1s per intent), seed 42, replica 7 — full reproducibility. */
81
+ deterministic?: boolean;
82
+ /** Pin the captured clock: a start (ISO/ms, advancing +1s per intent), {start, stepMs}, or a function. */
83
+ clock?: ClockOption;
84
+ /** Seed the captured rng — minted ids reproduce run-to-run. */
85
+ seed?: number;
86
+ /** Pin the 63-bit replica id (defaults: 7 under `deterministic`, seed-derived under `seed`, else random). */
87
+ replica?: number | bigint;
88
+ /** The principal stamped on the genesis installs (default "law-test"). */
89
+ installedBy?: string;
90
+ /** Runtime acquisition (see README): a dir holding {holon.wasm, manifests.json, packages.json}. */
91
+ runtimeDir?: string;
92
+ /** The cloud to fetch runtime artifacts from on first use (default https://nomos.captainapp.co.uk). */
93
+ cloud?: string;
94
+ /** Forbid the network fallback — fail with the offline story instead of fetching. */
95
+ offline?: boolean;
96
+ }
97
+
98
+ export interface TestHolon {
99
+ /** sha256(package bytes) of the installed law — the content-addressed law id. */
100
+ readonly lawHash: string;
101
+ /** This holon's 63-bit replica id (rides every intent's captured clock). */
102
+ readonly replica: bigint;
103
+ /** The in-engine workspace name reads/authors bind to. */
104
+ readonly workspace: string;
105
+ /** The raw engine handle (the vendored engine plane) — the escape hatch. */
106
+ readonly engine: unknown;
107
+
108
+ /**
109
+ * Author ONE intent through the real gate (payload zod → plan re-run →
110
+ * invariant oracle → kernel fold → sealed onto the chain). Resolves with the
111
+ * result or REJECTS with a typed {@link Refusal}.
112
+ */
113
+ dispatch(domain: string, directiveId: string, payload: unknown, opts?: DispatchOptions): Promise<DispatchResult>;
114
+
115
+ /** All projected rows for one aggregate id. */
116
+ byId<T = Record<string, unknown>>(aggregateId: string): Row<T>[];
117
+ /** A declared query — an indexed probe, never a scan. */
118
+ query<T = Record<string, unknown>>(queryId: string, params?: Record<string, unknown>): Row<T>[];
119
+ /** A maintained count tally (O(1)). */
120
+ count(countId: string, group?: string): number;
121
+ /** A maintained sum tally (O(1)). */
122
+ sum(sumId: string, group?: string): number;
123
+ /** A maintained min/max read; absent group ⇒ null. */
124
+ extremum(extremumId: string, kind: "min" | "max", group?: string): number | null;
125
+
126
+ /** The chain so far, parsed — genesis installs first, then every dispatch. */
127
+ intents(): Record<string, unknown>[];
128
+ /** The wasm verify_chain verdict over this holon's chain (the cloud's gate). */
129
+ verify(): Verdict;
130
+ /**
131
+ * A cheap what-if branch: a fresh engine cold-mounting this holon's exact
132
+ * tree bytes. Dispatches on the fork never touch the parent.
133
+ */
134
+ fork(opts?: { replica?: number | bigint }): Promise<TestHolon>;
135
+ }
136
+
137
+ /**
138
+ * Boot a law-under-test holon: the REAL engine plane (the vendored
139
+ * cloud/holon-host engine over the one wasm artifact), your compiled law
140
+ * installed at genesis, fully offline after the one-time runtime acquisition.
141
+ */
142
+ export declare function testHolon(opts?: TestHolonOptions): Promise<TestHolon>;
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@githolon/testing",
3
+ "version": "0.3.0",
4
+ "type": "module",
5
+ "description": "@githolon/testing — law TDD for Nomos domains: boot the REAL engine plane in-process (the exact machinery every cloud DO, heavy container and web client runs), dispatch directives, assert rows, assert TYPED REFUSALS, fork for what-ifs — fully offline inside vitest.",
6
+ "license": "SEE LICENSE IN LICENSE.md",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Captain-App/nomos2.git",
10
+ "directory": "testing"
11
+ },
12
+ "main": "./src/index.mjs",
13
+ "types": "./index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./index.d.ts",
17
+ "default": "./src/index.mjs"
18
+ }
19
+ },
20
+ "files": [
21
+ "src",
22
+ "vendor",
23
+ "index.d.ts",
24
+ "README.md",
25
+ "LICENSE.md",
26
+ "build.mjs"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "scripts": {
32
+ "build": "node build.mjs",
33
+ "prepare": "npm run build",
34
+ "test": "vitest run"
35
+ },
36
+ "dependencies": {
37
+ "@bjorn3/browser_wasi_shim": "0.4.2",
38
+ "isomorphic-git": "^1.38.4"
39
+ },
40
+ "devDependencies": {
41
+ "vitest": "^2.1.8"
42
+ }
43
+ }