~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs

0
0
main
258 lines · 14.0 KB Raw History
// node --test docs/docs.test.mjs   (after `cargo wasm` and `cargo host`)
// Every fenced block in docs/ is checked:
//   a block under a line that is only `path/in/repo`   must appear in that file verbatim (lines of `…`
//                                                       elide: each piece must appear, in order)
//   ```bash run                                         runs (per page, one shell, in order) in a scratch
//                                                       directory that links the repo's top level, with
//                                                       atlibs on PATH and no ATPROTO_* in the environment
//   ```output                                           right after a run block: its lines must appear in
//                                                       that block's output, in order (`…` matches anything)
//   ```js run                                           the same, as a module file run with node
//   ```js run browser                                   the same, as a page module in headless Chrome (docs/browser.mjs:
//                                                       imports atlibs/...); not run when there is no Chrome
//   ```html run browser [name.html]                     a page (written to name.html, default a scratch name), opened in
//                                                       headless Chrome; its output is the text of its <output> element
//   ```bash run=network                                 runs only with ATLIBS_DOCS_NETWORK=1
// Each page's scratch directory is removed after it, unless ATLIBS_DOCS_KEEP=1 (debugging: the path is printed).
//   ```bash run=account | run=gate | run=container       not run here: needs an account, is a gate command
//   | run=install                                       (cargo …, node --test …) the full test suite runs,
//                                                       needs podman/docker and the network (atlibs verify),
//                                                       or installs (cargo install: into ~/.cargo/bin)
//   ```text help                                        each line must appear in `atlibs help`
//   ```<lang> norun                                     config for another program (nginx, Caddyfile)
//   ```mermaid                                          a diagram
// Anything else fails, so no snippet goes unchecked. Also: relative links resolve, every page is in
// docs/README.md, env.md lists every ATLIBS_* variable the code reads, lexicons.md every lexicon,
// limits.md the run budget, and no code outside docs/ points into docs/.
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, linkSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, relative } from "node:path";
import { test } from "node:test";
import { setTimeout as sleep } from "node:timers/promises";

import { BUDGET } from "../javascript/src/run-module/index.js";
import { hostBin } from "../rust/host/conformance.mjs";
import { CHROME } from "./browser.mjs";

const ROOT = new URL("../", import.meta.url).pathname;
const DOCS = join(ROOT, "docs");
const NETWORK = process.env.ATLIBS_DOCS_NETWORK === "1";
const KEEP = process.env.ATLIBS_DOCS_KEEP === "1";

const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(join(dir, e.name)) : [join(dir, e.name)]));
const pages = walk(DOCS).filter((f) => f.endsWith(".md")).sort();
const read = (file) => readFileSync(file, "utf8");

/** Fenced blocks: { info, lang, tags, body, line, path (the `path` line above it, if any) }. */
function blocks(text) {
	const lines = text.split("\n");
	const out = [];
	for (let i = 0; i < lines.length; i++) {
		const open = /^```(\S*)\s*(.*)$/.exec(lines[i]);
		if (!open) continue;
		const start = i;
		const body = [];
		while (++i < lines.length && lines[i] !== "```") body.push(lines[i]);
		let j = start - 1;
		while (j >= 0 && lines[j].trim() === "") j--;
		const above = /^`([^`\s]+)`$/.exec(lines[j] ?? "");
		out.push({ lang: open[1], tags: open[2].split(/\s+/).filter(Boolean), body: body.join("\n"), line: start + 1, path: above?.[1] });
	}
	return out;
}

const runTag = (b) => b.tags.find((t) => t === "run" || t.startsWith("run="));
const elided = (body) => body.split(/^\s*(?:\/\/ |# )?…\s*$/m).map((s) => s.replace(/^\n+|\n+$/g, "")).filter(Boolean);

/** Does `expected` (lines, `…` wildcards) appear in `actual`, line by line in order? */
function matches(expected, actual) {
	const have = actual.split("\n").map((l) => l.trimEnd());
	let at = 0;
	for (const want of expected.split("\n").map((l) => l.trimEnd())) {
		if (want.trim() === "…" || want.trim() === "") continue;
		const re = new RegExp(`^${want.split("…").map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`);
		while (at < have.length && !re.test(have[at])) at++;
		if (at === have.length) return `missing: ${want}`;
		at++;
	}
	return null;
}

/** A scratch working directory: the repo's top level linked in, so paths in the docs work and writes stay out. */
function scratch() {
	const dir = mkdtempSync(join(tmpdir(), "atlibs-docs-"));
	// tracked top-level entries and the build output only, so nothing a page writes lands in the repo
	const tracked = execFileSync("git", ["ls-tree", "--name-only", "HEAD"], { cwd: ROOT, encoding: "utf8" }).split("\n").filter(Boolean);
	for (const name of new Set([...tracked, "target"])) if (name !== "docs" && existsSync(join(ROOT, name))) symlinkSync(join(ROOT, name), join(dir, name));
	return dir;
}

/** The loopback ports a script names: `--port N`, `http.server N`, `localhost:N`, `127.0.0.1:N`. */
const portsIn = (text) => [...new Set([...text.matchAll(/(?:--port[ =]|http\.server |localhost:|127\.0\.0\.1:)(\d{2,5})\b/g)].map((m) => Number(m[1])))].sort((a, b) => a - b);

/**
 * Hold `port` until the returned release: a lock file in the temp dir, so docs runs in other checkouts on this
 * machine take turns on the fixed ports the pages serve on instead of answering each other's curls. A lock whose
 * process is gone is taken over.
 */
async function lockPort(port) {
	const lock = join(tmpdir(), `atlibs-docs-port-${port}.lock`);
	const mine = `${lock}.${process.pid}`;
	writeFileSync(mine, String(process.pid));
	try {
		for (;;) {
			try {
				linkSync(mine, lock);
				return () => rmSync(lock, { force: true });
			} catch (e) {
				if (e.code !== "EEXIST") throw e;
			}
			try {
				process.kill(Number(readFileSync(lock, "utf8")), 0);
			} catch {
				// ponytail: two waiters on one dead holder can both take it; flock if that ever bites
				rmSync(lock, { force: true });
				continue;
			}
			await sleep(200);
		}
	} finally {
		rmSync(mine, { force: true });
	}
}

/** A bash loop: fail if any of `ports` already answers (another program has it), or wait (30 s at most) until all do. */
const probe = (ports, free) =>
	`for p in ${ports.join(" ")}; do ${free ? `if (exec 3<>/dev/tcp/127.0.0.1/$p) 2>/dev/null; then echo "port $p is in use by another program"; exit 1; fi` : "for _ in $(seq 300); do (exec 3<>/dev/tcp/127.0.0.1/$p) 2>/dev/null && break; sleep 0.1; done"}; done`;

function env(dir) {
	const clean = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^(ATPROTO|ATP)_/.test(k) && !k.startsWith("ATLIBS_") && k !== "NODE_TEST_CONTEXT"));
	return { ...clean, PATH: `${dirname(hostBin())}:${process.env.PATH}`, ATLIBS_SERVER_STATE: join(dir, ".state"), XDG_CACHE_HOME: join(dir, ".cache"), ATLIBS_REPO: ROOT };
}

for (const page of pages) {
	const name = relative(DOCS, page);
	const text = read(page);
	test(name, async (t) => {
		const all = blocks(text);
		const script = [];
		const skipped = [];
		for (const [i, b] of all.entries()) {
			const where = `${name}:${b.line}`;
			if (b.path && existsSync(join(ROOT, b.path))) {
				const file = read(join(ROOT, b.path));
				let at = 0;
				for (const piece of elided(b.body)) {
					const found = file.indexOf(piece, at);
					assert.ok(found >= 0, `${where}: not in ${b.path}:\n${piece.split("\n").find((l) => !file.includes(l)) ?? piece.slice(0, 200)}`);
					at = found + piece.length;
				}
				continue;
			}
			assert.ok(!b.path, `${where}: \`${b.path}\` above a block is not a file in the repo`);
			const run = runTag(b);
			if (b.lang === "output") {
				assert.ok(all[i - 1] && runTag(all[i - 1]), `${where}: an output block must follow a run block`);
				continue;
			}
			if (b.lang === "mermaid" || b.tags.includes("norun")) continue;
			if (b.lang === "text" && b.tags.includes("help")) {
				const help = execFileSync(hostBin(), ["help"], { encoding: "utf8" });
				for (const line of b.body.split("\n").filter((l) => l.trim())) assert.ok(help.includes(line.trim()), `${where}: not in atlibs help: ${line.trim()}`);
				continue;
			}
			assert.ok(run, `${where}: a \`\`\`${b.lang} block that is not a file excerpt must be tagged run, run=network, run=account, run=gate, run=container, run=install, help or norun`);
			const browser = b.tags.includes("browser");
			assert.ok(!browser || b.lang === "js" || b.lang === "html", `${where}: only a js or html block runs in the browser`);
			if (browser && !CHROME) skipped.push(`${where} ${run} browser (no Chrome)`);
			else if (run === "run" || (run === "run=network" && NETWORK)) {
				const next = all[i + 1]?.lang === "output" ? all[i + 1].body : null;
				// a js block runs as a module file in the scratch directory: with node (imports: ./javascript/src/...),
				// or as a page module in Chrome (imports: atlibs/...)
				const runner = browser ? `node ${join(DOCS, "browser.mjs")}` : "node";
				const file = b.lang === "html" ? (b.tags.find((t) => t.endsWith(".html")) ?? `.snippet-${i}.html`) : `.snippet-${i}.mjs`;
				const body = b.lang === "js" || b.lang === "html" ? `cat > ${file} <<'__SNIPPET__'\n${b.body}\n__SNIPPET__\n${runner} ${file}` : b.body;
				script.push({ where, body, output: next });
			} else skipped.push(`${where} ${run}`);
		}
		if (skipped.length) t.diagnostic(`not run: ${skipped.join(", ")}`);
		if (!script.length) return;
		const dir = scratch();
		// a page's own builds (atlibs new my-module && cargo wasm: ~460 MB) go with it, failed or not
		t.after(() => KEEP ? t.diagnostic(`kept ${dir}`) : rmSync(dir, { recursive: true, force: true }));
		// a server started in the background (`… &`): its port (named in it, or else in the next block) must be free,
		// and the next block waits until it answers
		const serve = (s, n) => {
			const ports = portsIn(s.body).length ? portsIn(s.body) : portsIn(script[n + 1]?.body ?? "");
			return ports.length ? [probe(ports, true), s.body, probe(ports, false)] : [s.body, "sleep 1"];
		};
		const source = ["set -e", "exec 2>&1", 'trap \'kill $(jobs -p) 2>/dev/null || true\' EXIT']
			.concat(script.flatMap((s, n) => [`printf '\n@@%s\n' ${n}`, ...(s.body.trimEnd().endsWith("&") ? serve(s, n) : [s.body])]))
			.join("\n");
		const releases = [];
		let ran;
		try {
			for (const port of portsIn(script.map((s) => s.body).join("\n"))) releases.push(await lockPort(port));
			ran = spawnSync("bash", ["-c", source], { cwd: dir, env: env(dir), encoding: "utf8", timeout: 180_000 });
		} finally {
			for (const release of releases) release();
		}
		const parts = ran.stdout.split(/^@@(\d+)$/m);
		const outputs = new Map();
		for (let k = 1; k < parts.length; k += 2) outputs.set(Number(parts[k]), parts[k + 1] ?? "");
		const last = Math.max(-1, ...outputs.keys());
		assert.equal(ran.status, 0, `${script[last]?.where ?? name}: exited ${ran.status}${ran.error ? ` (${ran.error.message})` : ""}\n${script[last]?.body}\n--- output\n${(outputs.get(last) ?? ran.stdout).slice(-3000)}`);
		for (const [n, s] of script.entries()) {
			if (s.output === null) continue;
			const missing = matches(s.output, outputs.get(n) ?? "");
			assert.equal(missing, null, `${s.where}: ${missing}\n--- output\n${(outputs.get(n) ?? "").slice(-3000)}`);
		}
	});
}

test("links resolve", () => {
	for (const page of [...pages, join(ROOT, "README.md")]) {
		for (const [, target] of read(page).matchAll(/\]\(([^)\s]+)\)/g)) {
			if (/^[a-z]+:/.test(target) || target.startsWith("#")) continue;
			const path = join(dirname(page), target.split("#")[0]);
			assert.ok(existsSync(path), `${relative(ROOT, page)}: broken link ${target}`);
		}
	}
});

test("every page is in docs/README.md", () => {
	const index = read(join(DOCS, "README.md"));
	for (const page of pages) if (!page.endsWith("/docs/README.md")) assert.ok(index.includes(`(${relative(DOCS, page)})`), `docs/README.md does not link ${relative(DOCS, page)}`);
});

test("no code outside docs/ points into docs/", () => {
	const found = spawnSync("git", ["grep", "-nE", "docs/[A-Za-z0-9_/.-]+\\.md", "--", ".", ":!docs", ":!README.md"], { cwd: ROOT, encoding: "utf8" });
	assert.equal(found.stdout, "");
});

test("reference/env.md lists every ATLIBS_* variable the code reads, and no other", () => {
	const doc = read(join(DOCS, "reference/env.md"));
	const sources = ["javascript/src", "rust/host/src", "rust/cli/src", "examples/node-hono/server.mjs"];
	const code = spawnSync("git", ["grep", "-hoE", "ATLIBS_[A-Z_]+[A-Z]", "--", ...sources, ":!*.test.mjs", ":!**/fake.rs"], { cwd: ROOT, encoding: "utf8" }).stdout;
	const used = new Set(code.split("\n").filter(Boolean));
	for (const v of used) assert.ok(doc.includes(`\`${v}\``), `env.md misses ${v}`);
	for (const [, v] of doc.matchAll(/`(ATLIBS_[A-Z_]+)`/g)) assert.ok(used.has(v), `env.md lists ${v}, which no code reads`);
});

test("reference/lexicons.md lists every lexicon", () => {
	const doc = read(join(DOCS, "reference/lexicons.md"));
	for (const file of walk(join(ROOT, "lexicons")).filter((f) => f.endsWith(".json"))) {
		const { id } = JSON.parse(read(file));
		assert.ok(doc.includes(`\`${id}\``), `lexicons.md misses ${id}`);
	}
});

test("reference/limits.md states the run budget", () => {
	const doc = read(join(DOCS, "reference/limits.md"));
	for (const [key, value] of Object.entries(BUDGET)) {
		const shown = value === Infinity ? "unlimited" : value;
		assert.match(doc, new RegExp(`\`${key}\`[^\\n]*\\b${shown}\\b`), `limits.md: ${key} ${shown}`);
	}
});