lavinmq needs building now, so the step that assigns it builds it first. And the ref is per repository rather than one value for all of them: a change under test lives in one repository, and building the others from that branch would prove it against itself. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
432 lines
22 KiB
TypeScript
432 lines
22 KiB
TypeScript
/**
|
|
* FOUR FRESH MACHINES, AND NOTHING HANDED TO THEM.
|
|
*
|
|
* The four-machine bed (`whole-mesh-full`) proves the mesh converges. It does so by loading
|
|
* thirty-four of the mesh's own images onto its machines from the workstation, because it does not
|
|
* build them — something beside the bed built them and copied them in. No real installation looks
|
|
* like that, and the lab has been burned by exactly this shape before: it used to raise a registry
|
|
* inside the scenario, and a bootstrap that only worked against that registry went green here and
|
|
* would have failed on any bare machine.
|
|
*
|
|
* This bed hands over nothing. The scenario has no `images:` list at all. What the machines get is
|
|
* a container runtime and the host binary — prerequisites of a machine, not parts of a mesh — and
|
|
* from there:
|
|
*
|
|
* 1. novox is raised into a mesh of one by the installer, which BUILDS the control plane.
|
|
* 2. ace, shanks and g14 JOIN it, across a household NAT, with a token and nothing else.
|
|
* 3. The mesh builds the shared base from source, with its own builder.
|
|
* 4. The mesh builds a real module standing on that base.
|
|
* 5. The anchor runs it, pinned to a digest the mesh's own registry assigned.
|
|
* 6. A JOINED machine runs it — which means pulling from a registry that asks who it is.
|
|
*
|
|
* Steps 1 and 2 are proven elsewhere and are here because the later ones need them. **Steps 3
|
|
* through 6 are what this bed exists for**, and 6 is the one nothing has ever checked: genesis
|
|
* puts the builder, the registry and everything they produce on ONE machine, so every earlier
|
|
* proof of a mesh-built module running is a proof about the machine that built it. A second
|
|
* machine has to fetch, and fetching needs an account nothing yet grants (novox/hq issue 042).
|
|
*
|
|
* Each step is recorded separately rather than allowed to throw, so a gap at 6 reports as a gap at
|
|
* 6 instead of erasing the evidence for 3, 4 and 5.
|
|
*
|
|
* MESH_LAB_INCUS='sudo -n incus'
|
|
* MESH_LAB_HOST_BINARY=.../mesh-host/mesh-host
|
|
* MESH_LAB_BOOTSTRAP_BINARY=.../mesh-host/mesh-bootstrap
|
|
* MESH_LAB_BUNDLE=.../mesh-host/examples/substrate-first-node.lock
|
|
* MESH_LAB_CATALOG=.../mesh-catalog/modules
|
|
* MESH_LAB_SOURCE=<forge>/mesh-control.git MESH_LAB_SOURCE_REF=<commit>
|
|
* MESH_LAB_KEEP=1 to leave it standing afterwards
|
|
*/
|
|
import { test, before, after } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { existsSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import { loadScenario } from "../../src/declaration/parse.ts";
|
|
import { raise } from "../../src/lifecycle/raise.ts";
|
|
import { destroy, exec, push } from "../../src/lifecycle/operate.ts";
|
|
import { bootstrapBinaryPath, hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts";
|
|
import { labIsUsable, destroyAll, substrateBundle } from "./harness.ts";
|
|
import { genesis, type GenesisResult } from "./genesis.ts";
|
|
|
|
const SCENARIO = "fresh-mesh";
|
|
const CONTROL = "novox";
|
|
const HOME_NODES = ["ace", "shanks", "g14"];
|
|
/** The joined machine asked to run a mesh-built module. Any of the three would do. */
|
|
const SECOND = "ace";
|
|
|
|
/** The anchor's public address — what every other machine dials, and what its own token must name. */
|
|
const ANCHOR = "192.0.2.20";
|
|
/** Where this mesh's registry answers, on the anchor's public address so a joined node can reach it. */
|
|
const REGISTRY = `${ANCHOR}:5000`;
|
|
|
|
/**
|
|
* The module built on top of the base, and the base it stands on.
|
|
*
|
|
* `amqp-ping` is deliberately small and deliberately REAL: its own TypeScript, compiled by the
|
|
* shared toolchain, running on the shared runtime, talking to the broker. A module whose artifact
|
|
* is a mirrored public image would pass every assertion below while skipping the whole of what is
|
|
* under test (novox/hq SELF-UPGRADE-PLAN, rule 1).
|
|
*/
|
|
const BASE = { module: "mesh-tools", repo: "mesh-tools", path: "" };
|
|
/**
|
|
* What `amqp-ping` requires, and what the substrate does not supply.
|
|
*
|
|
* The installer raises a broker, but as a bundle resource — plumbing, not a module the mesh has a
|
|
* record of, so it provides nothing to anything. A module asking for `amqp` is asking for a
|
|
* provider in the graph, and this is it. No build: its image is upstream.
|
|
*/
|
|
const PROVIDER = { module: "lavinmq", repo: "mesh-catalog", path: "modules/lavinmq" };
|
|
/** Named once, because the step title is also how later steps say what they waited on. */
|
|
const NEEDS = "the mesh runs a broker for that module to talk to";
|
|
const MODULE = { module: "amqp-ping", repo: "mesh-catalog", path: "modules/amqp-ping" };
|
|
|
|
const capability = await labIsUsable();
|
|
const binary = hostBinaryPath();
|
|
const installer = bootstrapBinaryPath();
|
|
const bundle = process.env["MESH_LAB_BUNDLE"] ?? "";
|
|
const catalogDir = process.env["MESH_LAB_CATALOG"] ?? "";
|
|
const source = process.env["MESH_LAB_SOURCE"] ?? "";
|
|
const sourceRef = process.env["MESH_LAB_SOURCE_REF"] ?? "";
|
|
const KEEP = !!process.env["MESH_LAB_KEEP"];
|
|
const FIXED_ID = process.env["MESH_LAB_INSTANCE_ID"] ?? (KEEP ? "fresh-mesh-live" : undefined);
|
|
|
|
/**
|
|
* Where a repository other than the control plane's lives.
|
|
*
|
|
* Derived from `MESH_LAB_SOURCE` by swapping the last path segment, because every one of these
|
|
* repositories sits beside the others under the same owner on the same forge. Overridable, so a
|
|
* forge that is arranged differently does not need this bed edited.
|
|
*/
|
|
function forgeUrl(repo: string): string {
|
|
const override = process.env[`MESH_LAB_SOURCE_${repo.toUpperCase().replaceAll("-", "_")}`];
|
|
if (override) return override;
|
|
return source.replace(/[^/]+\.git$/, `${repo}.git`);
|
|
}
|
|
/**
|
|
* What to build, per repository.
|
|
*
|
|
* A branch is acceptable for an ordinary build; only genesis insists on a commit (ADR 0071). Per
|
|
* repository rather than one value for all of them, because a change under test usually lives in
|
|
* one repository and the rest should be built from what everyone else has — building them all from
|
|
* a feature branch would prove that branch against itself.
|
|
*
|
|
* MESH_LAB_BUILD_REF the default for every repository
|
|
* MESH_LAB_BUILD_REF_MESH_CATALOG ...overridden for one
|
|
*/
|
|
function refFor(repo: string): string {
|
|
const override = process.env[`MESH_LAB_BUILD_REF_${repo.toUpperCase().replaceAll("-", "_")}`];
|
|
return override ?? process.env["MESH_LAB_BUILD_REF"] ?? "main";
|
|
}
|
|
|
|
/**
|
|
* The shared base's manifest, on this workstation.
|
|
*
|
|
* The base is a repository with a manifest at its root (novox/hq ADR 0069), so unlike the
|
|
* catalogue's modules it is not under `MESH_LAB_CATALOG`. Derived from that path on the convention
|
|
* that the checkouts sit beside each other, and overridable for a layout where they do not.
|
|
*/
|
|
const baseManifest = process.env["MESH_LAB_BASE_MANIFEST"] ??
|
|
resolve(catalogDir, "..", "..", BASE.repo, "module.json");
|
|
|
|
const skip =
|
|
!capability.usable ? capability.why :
|
|
!binary ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" :
|
|
!installer ? "MESH_LAB_BOOTSTRAP_BINARY is not set to a built mesh-bootstrap" :
|
|
!source ? "MESH_LAB_SOURCE is not set to the repository the control plane is built from" :
|
|
!sourceRef ? "MESH_LAB_SOURCE_REF is not set to the commit to build" :
|
|
!bundle || !existsSync(bundle) ? "MESH_LAB_BUNDLE is not set to a substrate template" :
|
|
!catalogDir || !existsSync(catalogDir) ? "MESH_LAB_CATALOG is not set to mesh-catalog/modules" :
|
|
false;
|
|
|
|
let instanceId = "";
|
|
let raised: GenesisResult;
|
|
|
|
// ---- talking to the machines ------------------------------------------------------------------
|
|
|
|
function quote(s: string): string {
|
|
return `'${s.replaceAll("'", `'\\''`)}'`;
|
|
}
|
|
async function on(machine: string, command: string, timeoutMs?: number): Promise<{ out: string; ok: boolean }> {
|
|
const { stdout } = await exec(instanceId, machine, [
|
|
"sh", "-c", `exec 2>&1\n${command}\necho "__exit=$?"`,
|
|
], timeoutMs);
|
|
const marker = stdout.lastIndexOf("__exit=");
|
|
if (marker < 0) return { out: stdout, ok: false };
|
|
return { out: stdout.slice(0, marker), ok: stdout.slice(marker + 7).trim() === "0" };
|
|
}
|
|
async function must(machine: string, command: string, timeoutMs?: number): Promise<string> {
|
|
const { out, ok } = await on(machine, command, timeoutMs);
|
|
if (!ok) throw new Error(`${machine}: ${command}\n${out}`);
|
|
return out;
|
|
}
|
|
async function mesh(command: string, timeoutMs?: number): Promise<string> {
|
|
return must(CONTROL, `docker exec mesh-control /mesh-control ${command}`, timeoutMs);
|
|
}
|
|
|
|
/**
|
|
* Register a module from a manifest on this workstation.
|
|
*
|
|
* **The control plane runs in a container, so a file on the machine is not a file it can open.**
|
|
* Pushing the manifest to the machine and naming that path got `no such file or directory` from
|
|
* inside mesh-control, which is correct and was briefly mistaken for a missing manifest. It is
|
|
* copied the last step of the way with `docker cp`.
|
|
*
|
|
* **Into the root, not into /tmp.** The control plane's image is a minimal one and has no `/tmp`
|
|
* to copy into — `docker cp` says so in those words. `/` is the one directory every image has.
|
|
*/
|
|
async function registerModule(module: string, manifest: string): Promise<string> {
|
|
assert.ok(existsSync(manifest), `no manifest for ${module} at ${manifest}`);
|
|
const onMachine = `/tmp/${module}.json`;
|
|
const inContainer = `/${module}.json`;
|
|
await push(instanceId, CONTROL, manifest, onMachine);
|
|
await must(CONTROL, `docker cp ${onMachine} mesh-control:${inContainer}`);
|
|
return mesh(`module add ${inContainer}`);
|
|
}
|
|
function tokenFrom(said: string): string {
|
|
const found = said.split("\n").map((l) => l.trim()).find((l) => l.length > 100 && !l.includes(" "));
|
|
assert.ok(found, `no token in:\n${said}`);
|
|
return found;
|
|
}
|
|
|
|
// ---- steps, recorded rather than thrown --------------------------------------------------------
|
|
|
|
interface Step { ok: boolean; why: string; said: string }
|
|
const steps = new Map<string, Step>();
|
|
const order: string[] = [];
|
|
|
|
/** Run a step, remember what it said, and never throw. A step whose predecessor failed is skipped. */
|
|
async function step(name: string, after_: string | null, fn: () => Promise<string>): Promise<void> {
|
|
order.push(name);
|
|
if (after_ && !steps.get(after_)?.ok) {
|
|
steps.set(name, { ok: false, why: `not attempted — "${after_}" did not succeed`, said: "" });
|
|
console.log(`SKIPPED ${name}`);
|
|
return;
|
|
}
|
|
console.log(`\n======== ${name} ========`);
|
|
try {
|
|
const said = await fn();
|
|
steps.set(name, { ok: true, why: "", said });
|
|
console.log(`OK ${name}`);
|
|
} catch (err) {
|
|
const why = (err as Error).message;
|
|
steps.set(name, { ok: false, why, said: "" });
|
|
console.log(`FAILED ${name}\n${why.split("\n").slice(0, 25).join("\n")}`);
|
|
}
|
|
}
|
|
|
|
function report(name: string): string {
|
|
const s = steps.get(name);
|
|
if (!s) return `${name}: never ran`;
|
|
const lines = order.map((n) => {
|
|
const it = steps.get(n);
|
|
return ` ${it?.ok ? "PASS" : "FAIL"} ${n}`;
|
|
});
|
|
return `${s.why}\n\nWhere this bed got to:\n${lines.join("\n")}`;
|
|
}
|
|
|
|
before(async () => {
|
|
if (skip) return;
|
|
|
|
const bed = await raise(loadScenario(`scenarios/${SCENARIO}.yml`), {
|
|
onProgress: (m) => console.log(`raise: ${m}`),
|
|
...(FIXED_ID ? { instanceId: FIXED_ID } : {}),
|
|
});
|
|
instanceId = bed.instanceId;
|
|
console.log(`INSTANCE ${instanceId}${KEEP ? " (KEEP — will be left standing)" : ""}`);
|
|
console.log(`NOTHING WAS LOADED: this scenario names no images. Every image on every machine ` +
|
|
`below was pulled from the internet or built by the mesh.`);
|
|
|
|
// ---- 1. GENESIS -----------------------------------------------------------------------------
|
|
//
|
|
// The shared description, the same one `genesis-single` calls. The bundle is the TEMPLATE with
|
|
// nothing held: no image is pre-resolved, because none is here to resolve to.
|
|
await step("novox becomes a mesh of one, raised by the installer", null, async () => {
|
|
try {
|
|
raised = await genesis({
|
|
instanceId,
|
|
node: CONTROL,
|
|
installer: installer as string,
|
|
catalogDir,
|
|
// The broker's advertised address, corrected.
|
|
//
|
|
// The template hardcodes 192.0.2.10:5671 — the address of the anchor in the single-machine
|
|
// bed. A token carries this verbatim as the endpoint an enrolling node dials, so on a mesh
|
|
// whose anchor is somewhere else every node, including this one, would enrol against an
|
|
// address nothing answers on. The installer refuses to guess it and says so, which is
|
|
// right: it does not know what this machine is called from outside.
|
|
bundleTemplate: substrateBundle(bundle, []).replaceAll("192.0.2.10:5671", `${ANCHOR}:5671`),
|
|
registry: REGISTRY,
|
|
source,
|
|
sourceRef,
|
|
log: (m) => console.log(m),
|
|
});
|
|
} catch (err) {
|
|
throw new Error(`the installer never ran: ${(err as Error).message}`);
|
|
}
|
|
if (!raised.ok) throw new Error(`${raised.step || "no step named"}: ${raised.why}\n\n${raised.report.join("\n")}`);
|
|
return raised.report.join("\n");
|
|
});
|
|
|
|
// ---- 2. JOINING -----------------------------------------------------------------------------
|
|
//
|
|
// Host binary and a token. novox is NOT in this loop — the installer enrolled it, and enrolling
|
|
// it again would offer the mesh a second identity for a node it already knows.
|
|
await step("three machines join it across the household gateway",
|
|
"novox becomes a mesh of one, raised by the installer", async () => {
|
|
const said: string[] = [];
|
|
for (const machine of HOME_NODES) {
|
|
await mesh(`node add ${machine}`);
|
|
const token = tokenFrom(await mesh(`token issue --node ${machine}`));
|
|
const out = await must(machine, `${HOST_PATH} enrol --token ${quote(token)}`, 180_000);
|
|
assert.match(out, new RegExp(`enrolled as ${machine}`), out);
|
|
await must(machine, `nohup ${HOST_PATH} run > /var/log/mesh-host.log 2>&1 & sleep 3`);
|
|
said.push(` ${machine} enrolled and running`);
|
|
}
|
|
const nodes = await mesh("node list");
|
|
said.push(nodes.trim());
|
|
return said.join("\n");
|
|
});
|
|
|
|
// ---- 3. THE MESH BUILDS THE SHARED BASE -----------------------------------------------------
|
|
//
|
|
// The toolchain and runtime every module with code of its own stands on. It is a module, and it
|
|
// is built like one — cloned from the forge by the builder installing put here, compiled on the
|
|
// machine, published into the mesh's own registry.
|
|
await step("the mesh builds the shared base from source",
|
|
"three machines join it across the household gateway", async () => {
|
|
// Registered from the manifest the builder will also read, so what the mesh holds and what it
|
|
// builds are the same description of the same module.
|
|
//
|
|
// **Not swallowed.** This call used to end in `.catch(() => {})`, on the reasoning that the
|
|
// base might already be known. It hid a real failure — the manifest was being named at a path
|
|
// inside a container that had never seen it — and the step passed anyway, because a base with
|
|
// nothing to stand on builds whether or not the mesh has a record of it. The next step, which
|
|
// needs that record, is where it surfaced.
|
|
await registerModule(BASE.module, baseManifest);
|
|
const built = await mesh(
|
|
`build ${forgeUrl(BASE.repo)} --ref ${refFor(BASE.repo)} --wait 1200s`, 1_500_000);
|
|
assert.doesNotMatch(built, /failed/i, built);
|
|
return built;
|
|
});
|
|
|
|
// ---- 4. AND A MODULE STANDING ON IT ---------------------------------------------------------
|
|
await step("the mesh builds a module standing on that base",
|
|
"the mesh builds the shared base from source", async () => {
|
|
await registerModule(MODULE.module, resolve(catalogDir, MODULE.module, "module.json"));
|
|
const built = await mesh(
|
|
`build ${forgeUrl(MODULE.repo)} --path ${MODULE.path} --ref ${refFor(MODULE.repo)} --wait 1200s`,
|
|
1_500_000);
|
|
assert.doesNotMatch(built, /failed/i, built);
|
|
// The point of the whole step: what came out is named by a digest this mesh's registry
|
|
// assigned, not by a placeholder and not by a tag.
|
|
const builds = await mesh(`builds ${MODULE.module}`);
|
|
assert.match(builds, /sha256:[0-9a-f]{12}/,
|
|
`the build recorded no digest — the module is not pinned to anything this registry serves:\n${builds}`);
|
|
return `${built}\n${builds}`;
|
|
});
|
|
|
|
// ---- 4b. WHAT THE MODULE NEEDS --------------------------------------------------------------
|
|
//
|
|
// `amqp-ping` requires the `amqp` provision, and the mesh refused to place it: "nothing provides
|
|
// amqp, wanted by amqp-ping". That refusal is correct and is the reason this step exists rather
|
|
// than the reason to pick an easier module. **The substrate's broker is not a provider.** It is
|
|
// raised by the installer as part of the bundle, so it is a running container and not a module
|
|
// with something to offer — the mesh's own plumbing, not an entry in its graph. A module that
|
|
// wants a broker wants one the mesh knows about.
|
|
//
|
|
// `lavinmq` is that module. It was first added here on the belief that it needed no building —
|
|
// its broker is an upstream image — and the mesh refused it: two of its three containers named a
|
|
// placeholder digest, "which is never a real image". That was right. The broker is upstream, but
|
|
// the module is not only the broker: it carries a run-once bootstrap that writes the broker's
|
|
// configuration, a provisioner that grants each consumer its own vhost and user, tools and an
|
|
// event consumer. All of that is its own code and has to be built like anything else.
|
|
await step(NEEDS, "the mesh builds a module standing on that base", async () => {
|
|
await registerModule(PROVIDER.module, resolve(catalogDir, PROVIDER.module, "module.json"));
|
|
const built = await mesh(
|
|
`build ${forgeUrl(PROVIDER.repo)} --path ${PROVIDER.path} --ref ${refFor(PROVIDER.repo)} --wait 1200s`,
|
|
1_500_000);
|
|
assert.doesNotMatch(built, /failed/i, built);
|
|
await mesh(`assign ${CONTROL} ${PROVIDER.module}`);
|
|
await mesh(`push ${CONTROL}`, 600_000);
|
|
for (let i = 0; i < 60; i++) {
|
|
const ps = (await on(CONTROL, `docker ps --format '{{.Names}}\t{{.Status}}'`)).out;
|
|
const line = ps.split("\n").find((l) => l.includes(PROVIDER.module));
|
|
if (line && /Up /.test(line)) return ps;
|
|
await new Promise((r) => setTimeout(r, 5_000));
|
|
}
|
|
throw new Error(`${PROVIDER.module} never came up on ${CONTROL}:\n` +
|
|
(await on(CONTROL, `docker ps -a --format '{{.Names}}\t{{.Status}}'`)).out);
|
|
});
|
|
|
|
// ---- 5. THE ANCHOR RUNS IT ------------------------------------------------------------------
|
|
//
|
|
// The machine that built it. This is the case every earlier proof covered, and it is here as the
|
|
// control for step 6: if this fails, step 6's failure says nothing about fetching.
|
|
await step("the anchor runs the module the mesh built", NEEDS, async () => {
|
|
await mesh(`module issue ${MODULE.module} --node ${CONTROL}`).catch(() => {});
|
|
await mesh(`assign ${CONTROL} ${MODULE.module}`);
|
|
await mesh(`push ${CONTROL}`, 600_000);
|
|
for (let i = 0; i < 40; i++) {
|
|
const ps = (await on(CONTROL, `docker ps --format '{{.Names}}\t{{.Status}}'`)).out;
|
|
if (/amqp-ping/.test(ps) && /Up /.test(ps.split("\n").find((l) => l.includes("amqp-ping")) ?? "")) {
|
|
return ps;
|
|
}
|
|
await new Promise((r) => setTimeout(r, 5_000));
|
|
}
|
|
throw new Error(`amqp-ping never came up on ${CONTROL}:\n` +
|
|
(await on(CONTROL, `docker ps -a --format '{{.Names}}\t{{.Status}}'`)).out);
|
|
});
|
|
|
|
// ---- 6. AND A MACHINE THAT DID NOT BUILD IT -------------------------------------------------
|
|
//
|
|
// **The thing nothing has ever checked.** ace did not build this image and has never seen it. To
|
|
// run it, it must fetch it from the mesh's registry — and a joined node has no account there.
|
|
// The mesh grants a consumer a credential for a database; it does not yet do so for the store
|
|
// its own images live in (novox/hq issue 042).
|
|
//
|
|
// Asked anyway, and asked LAST, so that when it fails the five steps above still stand as
|
|
// evidence of what does work.
|
|
await step(`a joined machine runs the module the mesh built`,
|
|
"the anchor runs the module the mesh built", async () => {
|
|
await mesh(`module issue ${MODULE.module} --node ${SECOND}`).catch(() => {});
|
|
await mesh(`assign ${SECOND} ${MODULE.module}`);
|
|
await mesh(`push ${SECOND}`, 600_000);
|
|
for (let i = 0; i < 40; i++) {
|
|
const ps = (await on(SECOND, `docker ps --format '{{.Names}}\t{{.Status}}'`)).out;
|
|
const line = ps.split("\n").find((l) => l.includes("amqp-ping"));
|
|
if (line && /Up /.test(line)) return ps;
|
|
await new Promise((r) => setTimeout(r, 5_000));
|
|
}
|
|
const state = (await on(SECOND, `docker ps -a --format '{{.Names}}\t{{.Status}}'`)).out;
|
|
const log = (await on(SECOND, `tail -40 /var/log/mesh-host.log`)).out;
|
|
throw new Error(
|
|
`amqp-ping never came up on ${SECOND} — the machine that did NOT build it.\n\n` +
|
|
`containers:\n${state}\n\nwhat the host said:\n${log}`);
|
|
});
|
|
|
|
console.log(`\n================ WHAT THIS MESH DID FOR ITSELF ================`);
|
|
for (const n of order) console.log(` ${steps.get(n)?.ok ? "PASS" : "FAIL"} ${n}`);
|
|
}, { timeout: 7_200_000 });
|
|
|
|
after(async () => {
|
|
if (KEEP) {
|
|
console.log(`\nLEFT STANDING: ${instanceId} — not destroyed (MESH_LAB_KEEP).`);
|
|
return;
|
|
}
|
|
if (instanceId) await destroy(instanceId);
|
|
await destroyAll(`${SCENARIO}-`);
|
|
}, { timeout: 900_000 });
|
|
|
|
for (const name of [
|
|
"novox becomes a mesh of one, raised by the installer",
|
|
"three machines join it across the household gateway",
|
|
"the mesh builds the shared base from source",
|
|
"the mesh builds a module standing on that base",
|
|
NEEDS,
|
|
"the anchor runs the module the mesh built",
|
|
"a joined machine runs the module the mesh built",
|
|
]) {
|
|
test(name, { skip, timeout: 60_000 }, () => {
|
|
assert.ok(steps.get(name)?.ok, report(name));
|
|
});
|
|
}
|