@githolon/testing 0.15.0 → 0.16.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/README.md +3 -3
- package/build.mjs +14 -6
- package/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/index.mjs +4 -4
- package/vendor/engine/custody-transport.mjs +167 -0
- package/vendor/engine/engine.mjs +751 -291
- package/vendor/engine/git-fs.mjs +5 -1
- package/vendor/engine/tree.mjs +15 -8
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @githolon/testing — law TDD for Nomos domains
|
|
2
2
|
|
|
3
3
|
Test your law the way the cloud judges it. `testHolon()` boots **the real engine
|
|
4
|
-
plane** in-process — the vendored `cloud/
|
|
4
|
+
plane** in-process — the vendored `cloud/cloudflare-shell` engine over the one
|
|
5
5
|
wasm32-wasip1 artifact, the exact machinery every cloud DO, heavy container and
|
|
6
6
|
web client runs — installs your compiled law at genesis, and hands you a holon
|
|
7
7
|
you drive from vitest. Nothing is simulated: every `dispatch` runs the real
|
|
@@ -136,14 +136,14 @@ fixture) and the guestbook example's law tests
|
|
|
136
136
|
## How the runtime arrives (the wasm story, honestly)
|
|
137
137
|
|
|
138
138
|
The engine JS is **vendored into this package at build** (synced verbatim from
|
|
139
|
-
`cloud/
|
|
139
|
+
`cloud/cloudflare-shell/src` — the cli/deploy.sh pattern, so it can't fork). The wasm
|
|
140
140
|
artifact + the baked bootstrap/nomos packages and manifests are **not** in the
|
|
141
141
|
npm package (the wasm alone is tens of MB); they resolve, first hit wins:
|
|
142
142
|
|
|
143
143
|
1. **`runtimeDir`** option / `GITHOLON_RUNTIME_DIR` — a directory you control
|
|
144
144
|
holding `{holon.wasm, manifests.json, packages.json}` (the cache shape).
|
|
145
145
|
Pin this in CI for fully hermetic runs.
|
|
146
|
-
2. **The monorepo** — inside the nomos2 repo, `cloud/
|
|
146
|
+
2. **The monorepo** — inside the nomos2 repo, `cloud/cloudflare-shell`'s vendored
|
|
147
147
|
wasm + domain artifacts are used directly. Never any network.
|
|
148
148
|
3. **The shared cache** — `~/.holon/runtime` (`HOLON_CONFIG_DIR` overrides),
|
|
149
149
|
the SAME cache the `githolon` cli populates: any prior `githolon ledger`
|
package/build.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Vendor THE ENGINE PLANE (cloud/
|
|
1
|
+
// Vendor THE ENGINE PLANE (cloud/cloudflare-shell/src/{engine,git-fs,tree}.mjs — the
|
|
2
2
|
// exact machinery the edge DO and the heavy container run) into this package so
|
|
3
3
|
// `testHolon()` boots holons in-process with the same code path the cloud uses.
|
|
4
4
|
// Sync-at-build keeps it un-forked (the cli/build.mjs / deploy.sh pattern); the
|
|
@@ -9,15 +9,23 @@ import { dirname, join } from "node:path";
|
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
|
|
11
11
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
12
|
-
const ENGINE_SRC = join(HERE, "..", "cloud", "
|
|
12
|
+
const ENGINE_SRC = join(HERE, "..", "cloud", "cloudflare-shell", "src");
|
|
13
|
+
const ADAPTER_SRC = join(HERE, "..", "adapter"); // git-fs.mjs + tree.mjs are the SHARED adapter now
|
|
13
14
|
const VENDOR = join(HERE, "vendor", "engine");
|
|
14
15
|
|
|
15
16
|
if (existsSync(ENGINE_SRC)) {
|
|
16
17
|
mkdirSync(VENDOR, { recursive: true });
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
// The shell's engine plane (engine.mjs + its custody-transport sibling). Rewrite the repo-relative adapter
|
|
19
|
+
// import (../../../adapter/) to local so the vendored copies resolve side-by-side (the deploy.sh pattern).
|
|
20
|
+
for (const f of ["engine.mjs", "custody-transport.mjs"]) {
|
|
21
|
+
const body = readFileSync(join(ENGINE_SRC, f), "utf8").replace(/\.\.\/\.\.\/\.\.\/adapter\//g, "./");
|
|
22
|
+
writeFileSync(join(VENDOR, f), `// AUTO-SYNCED from cloud/cloudflare-shell/src/${f} by testing/build.mjs — DO NOT EDIT HERE.\n${body}`, "utf8");
|
|
23
|
+
}
|
|
24
|
+
// The shared adapter primitives the engine imports.
|
|
25
|
+
for (const f of ["git-fs.mjs", "tree.mjs"]) {
|
|
26
|
+
const body = readFileSync(join(ADAPTER_SRC, f), "utf8");
|
|
27
|
+
writeFileSync(join(VENDOR, f), `// AUTO-SYNCED from adapter/${f} by testing/build.mjs — DO NOT EDIT HERE.\n${body}`, "utf8");
|
|
20
28
|
}
|
|
21
29
|
} else if (!existsSync(join(VENDOR, "engine.mjs"))) {
|
|
22
|
-
throw new Error("engine plane not found: neither ../cloud/
|
|
30
|
+
throw new Error("engine plane not found: neither ../cloud/cloudflare-shell/src nor vendor/engine exists");
|
|
23
31
|
}
|
package/index.d.ts
CHANGED
|
@@ -136,7 +136,7 @@ export interface TestHolon {
|
|
|
136
136
|
|
|
137
137
|
/**
|
|
138
138
|
* Boot a law-under-test holon: the REAL engine plane (the vendored
|
|
139
|
-
* cloud/
|
|
139
|
+
* cloud/cloudflare-shell engine over the one wasm artifact), your compiled law
|
|
140
140
|
* installed at genesis, fully offline after the one-time runtime acquisition.
|
|
141
141
|
*/
|
|
142
142
|
export declare function testHolon(opts?: TestHolonOptions): Promise<TestHolon>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"type": "module",
|
|
5
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
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
package/src/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @githolon/testing — LAW TDD for Nomos domains.
|
|
2
2
|
//
|
|
3
3
|
// `testHolon({ deploy })` boots THE ENGINE PLANE in-process (the vendored
|
|
4
|
-
// cloud/
|
|
4
|
+
// cloud/cloudflare-shell/src/engine.mjs — the exact machinery every cloud DO, heavy
|
|
5
5
|
// container and web client runs) over the one wasm32-wasip1 artifact, installs
|
|
6
6
|
// your compiled law at genesis, and hands back a holon you drive from a test:
|
|
7
7
|
// dispatch directives, read rows/queries/counts/sums, assert TYPED REFUSALS
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
import { deserializeTree } from "../vendor/engine/tree.mjs";
|
|
34
34
|
|
|
35
35
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
36
|
-
const HOLON_HOST = join(HERE, "..", "..", "cloud", "
|
|
36
|
+
const HOLON_HOST = join(HERE, "..", "..", "cloud", "cloudflare-shell"); // the monorepo sibling
|
|
37
37
|
const DEFAULT_CLOUD = "https://nomos.captainapp.co.uk";
|
|
38
38
|
const WS = "test"; // the in-engine mount name — a chain binds to no workspace name
|
|
39
39
|
const UNBORN = { remote: "https://unborn.invalid/", headers: {} }; // degrades to a fresh in-memory tree
|
|
@@ -63,7 +63,7 @@ export class Refusal extends Error {
|
|
|
63
63
|
// Resolution order (first hit wins):
|
|
64
64
|
// 1. opts.runtimeDir / GITHOLON_RUNTIME_DIR — a cache-shaped dir you control
|
|
65
65
|
// ({holon.wasm, manifests.json, packages.json});
|
|
66
|
-
// 2. the monorepo sibling cloud/
|
|
66
|
+
// 2. the monorepo sibling cloud/cloudflare-shell (vendored wasm + baked domains) —
|
|
67
67
|
// fully offline for anyone working inside the nomos2 repo;
|
|
68
68
|
// 3. the shared on-disk cache ~/.holon/runtime (HOLON_CONFIG_DIR overrides) —
|
|
69
69
|
// the SAME cache the githolon cli populates, so any prior `githolon ledger`
|
|
@@ -148,7 +148,7 @@ async function resolveRuntime(opts) {
|
|
|
148
148
|
if (offline) {
|
|
149
149
|
throw new Error(
|
|
150
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/
|
|
151
|
+
`(b) run inside the nomos2 monorepo (cloud/cloudflare-shell vendors the wasm); ` +
|
|
152
152
|
`(c) warm the shared cache once while online (any testHolon() run, or \`githolon ledger verify\`, populates ${cacheRoot()}).`,
|
|
153
153
|
);
|
|
154
154
|
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// AUTO-SYNCED from cloud/cloudflare-shell/src/custody-transport.mjs by testing/build.mjs — DO NOT EDIT HERE.
|
|
2
|
+
// THE CLOUD SHELL'S DUMB-PIPE CUSTODY TRANSPORT — raw git smart-HTTP over fetch(), ZERO isomorphic-git.
|
|
3
|
+
//
|
|
4
|
+
// Custody (Cloudflare Artifacts) is a git smart-HTTP remote. The KERNEL owns all git semantics (gix builds
|
|
5
|
+
// + ingests conformant packs); this module is the platform-specific BYTE PIPE that shuttles those packs to
|
|
6
|
+
// and from the remote. It frames pkt-lines, POSTs/GETs bytes, and parses the ref advertisement — nothing
|
|
7
|
+
// more. It NEVER touches the object store, never decides admission, never manufactures a verdict: bytes +
|
|
8
|
+
// shell IO only (the offer-kernel doctrine, OFFER_KERNEL_ARCHITECTURE.dot).
|
|
9
|
+
//
|
|
10
|
+
// lsRefs ← replaces git.getRemoteInfo / git.listServerRefs (GET info/refs, parse advertisement)
|
|
11
|
+
// fetchPack ← replaces git.fetch (POST git-upload-pack → raw pack bytes)
|
|
12
|
+
// pushPack ← replaces git.push (generalises the proven dumbPush: many updates, deletes, empty pack)
|
|
13
|
+
//
|
|
14
|
+
// No sha1 anywhere: kernel-built packs carry their own trailer; the empty pack (delete-only push) is a
|
|
15
|
+
// well-known constant. So this file is identical-behaviour on workerd (edge DO) and node (heavy container).
|
|
16
|
+
|
|
17
|
+
const enc = new TextEncoder();
|
|
18
|
+
const dec = new TextDecoder();
|
|
19
|
+
const ZERO_OID = "0".repeat(40);
|
|
20
|
+
|
|
21
|
+
// The canonical EMPTY packfile: `PACK` + version 2 + 0 objects, then its sha1 trailer (a constant).
|
|
22
|
+
// Used for a delete-only receive-pack (a ref update that introduces no objects).
|
|
23
|
+
const EMPTY_PACK = new Uint8Array([
|
|
24
|
+
0x50, 0x41, 0x43, 0x4b, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, // "PACK", ver 2, 0 objects
|
|
25
|
+
0x02, 0x9d, 0x08, 0x82, 0x3b, 0xd8, 0xa8, 0xea, 0xb5, 0x10, 0xad, 0x6a, // sha1(the 12 header bytes)
|
|
26
|
+
0xc7, 0x5c, 0x82, 0x3c, 0xfd, 0x3e, 0xd3, 0x1e,
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
function concatBytes(chunks) {
|
|
30
|
+
let n = 0;
|
|
31
|
+
for (const c of chunks) n += c.length;
|
|
32
|
+
const out = new Uint8Array(n);
|
|
33
|
+
let o = 0;
|
|
34
|
+
for (const c of chunks) { out.set(c, o); o += c.length; }
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// pkt-line: 4 hex length (incl. the 4 bytes) + payload. 0000 = flush.
|
|
39
|
+
function pkt(payload) {
|
|
40
|
+
const body = typeof payload === "string" ? enc.encode(payload) : payload;
|
|
41
|
+
const len = (body.length + 4).toString(16).padStart(4, "0");
|
|
42
|
+
return concatBytes([enc.encode(len), body]);
|
|
43
|
+
}
|
|
44
|
+
const FLUSH = enc.encode("0000");
|
|
45
|
+
|
|
46
|
+
// Walk pkt-lines from `buf` starting at `off`. Returns { lines:[Uint8Array payloads], rest:offset }.
|
|
47
|
+
// Stops at a flush (0000) OR at the first non-pkt-line bytes (a raw packfile begins with "PACK", whose
|
|
48
|
+
// first 4 bytes are not valid length-hex) — `rest` then points at the raw remainder (the pack).
|
|
49
|
+
function readPktLines(buf, off = 0) {
|
|
50
|
+
const lines = [];
|
|
51
|
+
while (off + 4 <= buf.length) {
|
|
52
|
+
const hex = dec.decode(buf.subarray(off, off + 4));
|
|
53
|
+
if (!/^[0-9a-f]{4}$/.test(hex)) break; // raw data (pack) starts here
|
|
54
|
+
const len = parseInt(hex, 16);
|
|
55
|
+
if (len === 0) { off += 4; break; } // flush-pkt
|
|
56
|
+
if (len < 4 || off + len > buf.length) break;
|
|
57
|
+
lines.push(buf.subarray(off + 4, off + len));
|
|
58
|
+
off += len;
|
|
59
|
+
}
|
|
60
|
+
return { lines, rest: off };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── lsRefs: the remote's ref advertisement (oid → name), optionally filtered by prefix. ──
|
|
64
|
+
// GET info/refs?service=git-upload-pack returns: a `# service=...` pkt + flush, then `<oid> <name>\0caps`
|
|
65
|
+
// for HEAD and `<oid> <name>` for the rest, then a flush. Pure v0 parse (the proxy speaks it; the prior
|
|
66
|
+
// dumbPush already relied on this advertisement shape).
|
|
67
|
+
export async function lsRefs(remote, headers = {}, { service = "git-upload-pack", prefix = null } = {}) {
|
|
68
|
+
const r = await fetch(`${remote}/info/refs?service=${service}`, { headers });
|
|
69
|
+
if (!r.ok) {
|
|
70
|
+
if (r.status === 404) return {}; // unborn remote — no refs
|
|
71
|
+
throw new Error(`info/refs ${r.status}: ${(await r.text()).slice(0, 180)}`);
|
|
72
|
+
}
|
|
73
|
+
const buf = new Uint8Array(await r.arrayBuffer());
|
|
74
|
+
// The advertisement is TWO pkt sections: `# service=...\n` + flush, THEN the ref lines + flush.
|
|
75
|
+
// Read the banner (stops at its flush), then the refs from where it left off.
|
|
76
|
+
const banner = readPktLines(buf, 0);
|
|
77
|
+
const { lines } = readPktLines(buf, banner.rest);
|
|
78
|
+
const refs = {};
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
let s = dec.decode(line).replace(/\n$/, "");
|
|
81
|
+
if (s.startsWith("#")) continue; // `# service=...`
|
|
82
|
+
const nul = s.indexOf("\0");
|
|
83
|
+
if (nul >= 0) s = s.slice(0, nul); // strip caps off the first ref
|
|
84
|
+
const sp = s.indexOf(" ");
|
|
85
|
+
if (sp !== 40) continue; // `<40-hex-oid> <name>`
|
|
86
|
+
const oid = s.slice(0, 40), name = s.slice(41);
|
|
87
|
+
if (!/^[0-9a-f]{40}$/.test(oid)) continue;
|
|
88
|
+
if (prefix && !name.startsWith(prefix)) continue;
|
|
89
|
+
refs[name] = oid;
|
|
90
|
+
}
|
|
91
|
+
return refs;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── fetchPack: pull a packfile for `wants` (minus anything reachable from `haves`). ──
|
|
95
|
+
// POST git-upload-pack with a v0 want/have/done request, NO side-band (so the response is the ack
|
|
96
|
+
// section as pkt-lines followed by the RAW packfile — simplest conformant parse). Returns the pack bytes
|
|
97
|
+
// (the caller hands them to the kernel's apply_pack). `wants` empty ⇒ null (nothing to fetch).
|
|
98
|
+
export async function fetchPack(remote, headers = {}, { wants = [], haves = [] } = {}) {
|
|
99
|
+
wants = wants.filter((w) => /^[0-9a-f]{40}$/.test(w) && w !== ZERO_OID);
|
|
100
|
+
if (!wants.length) return null;
|
|
101
|
+
const caps = "thin-pack ofs-delta agent=nomos-cloud";
|
|
102
|
+
const parts = [];
|
|
103
|
+
wants.forEach((w, i) => parts.push(pkt(`want ${w}${i === 0 ? " " + caps : ""}\n`)));
|
|
104
|
+
parts.push(FLUSH);
|
|
105
|
+
for (const h of haves) if (/^[0-9a-f]{40}$/.test(h) && h !== ZERO_OID) parts.push(pkt(`have ${h}\n`));
|
|
106
|
+
parts.push(pkt("done\n"));
|
|
107
|
+
const r = await fetch(`${remote}/git-upload-pack`, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: { "content-type": "application/x-git-upload-pack-request", "accept": "application/x-git-upload-pack-result", ...headers },
|
|
110
|
+
body: concatBytes(parts),
|
|
111
|
+
});
|
|
112
|
+
if (!r.ok) throw new Error(`upload-pack ${r.status}: ${(await r.text()).slice(0, 180)}`);
|
|
113
|
+
const buf = new Uint8Array(await r.arrayBuffer());
|
|
114
|
+
// Response = an ack section (NAK / ACK ... pkt-lines) then the pack. The pack is EITHER raw (if the
|
|
115
|
+
// server honoured no-side-band) OR side-band-64k multiplexed (gitty defaults to it): pkt-lines whose
|
|
116
|
+
// first payload byte is the band (1 = pack data, 2 = progress, 3 = fatal). De-mux band 1; fall back to
|
|
117
|
+
// the raw remainder when there's no side-band.
|
|
118
|
+
const { lines, rest } = readPktLines(buf, 0);
|
|
119
|
+
const packChunks = [];
|
|
120
|
+
let sideband = false;
|
|
121
|
+
for (const line of lines) {
|
|
122
|
+
if (line.length === 0) continue;
|
|
123
|
+
const head = dec.decode(line.subarray(0, Math.min(line.length, 3)));
|
|
124
|
+
if (head === "NAK" || head === "ACK" || head === "ERR" || head.startsWith("sha")) continue; // control
|
|
125
|
+
const band = line[0];
|
|
126
|
+
if (band === 1) { packChunks.push(line.subarray(1)); sideband = true; }
|
|
127
|
+
else if (band === 2) { /* progress — ignore */ }
|
|
128
|
+
else if (band === 3) { throw new Error("upload-pack fatal: " + dec.decode(line.subarray(1)).slice(0, 180)); }
|
|
129
|
+
}
|
|
130
|
+
const pack = sideband ? concatBytes(packChunks) : buf.subarray(rest);
|
|
131
|
+
if (pack.length < 12 || dec.decode(pack.subarray(0, 4)) !== "PACK") {
|
|
132
|
+
throw new Error(`upload-pack: no pack in response (${pack.length}B, sideband=${sideband})`);
|
|
133
|
+
}
|
|
134
|
+
return pack;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── pushPack: advance/delete refs on the remote with a kernel-built pack. ──
|
|
138
|
+
// Generalises the proven dumbPush: many ref updates in one receive-pack, deletes (newOid = zero), and an
|
|
139
|
+
// empty pack when the push introduces no objects (delete-only). `updates`: [{ ref, oldOid, newOid }].
|
|
140
|
+
// `pack`: kernel-built bytes (self-trailered) or null/empty for delete-only. Throws unless EVERY ref
|
|
141
|
+
// update reports `ok` AND the pack unpacked — a silent `ng` (objects accepted, ref refused) is non-durable.
|
|
142
|
+
export async function pushPack(remote, headers = {}, { updates = [], pack = null } = {}) {
|
|
143
|
+
if (!updates.length) return { ok: true, noop: true };
|
|
144
|
+
const cmds = [];
|
|
145
|
+
updates.forEach((u, i) => {
|
|
146
|
+
const old = u.oldOid || ZERO_OID;
|
|
147
|
+
const neu = u.newOid || ZERO_OID;
|
|
148
|
+
const caps = i === 0 ? "\0report-status" : "";
|
|
149
|
+
cmds.push(pkt(`${old} ${neu} ${u.ref}${caps}\n`));
|
|
150
|
+
});
|
|
151
|
+
cmds.push(FLUSH);
|
|
152
|
+
const body = concatBytes([...cmds, pack && pack.length ? pack : EMPTY_PACK]);
|
|
153
|
+
const r = await fetch(`${remote}/git-receive-pack`, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: { "content-type": "application/x-git-receive-pack-request", ...headers },
|
|
156
|
+
body,
|
|
157
|
+
});
|
|
158
|
+
const txt = dec.decode(new Uint8Array(await r.arrayBuffer()));
|
|
159
|
+
const unpacked = /unpack ok/.test(txt);
|
|
160
|
+
const everyRefOk = updates.every((u) => new RegExp(`ok ${u.ref.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`).test(txt));
|
|
161
|
+
if (!(r.ok && unpacked && everyRefOk && !/\bng /.test(txt))) {
|
|
162
|
+
throw new Error(`receive-pack ${r.status}: ${txt.replace(/[^\x20-\x7e]/g, " ").trim().slice(0, 200)}`);
|
|
163
|
+
}
|
|
164
|
+
return { ok: true };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export { ZERO_OID };
|