Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions tests/docs-commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";

// The commands the docs tell people to run must exist: every `npm run <x>`
// is a package.json script, and every documented `--flag` is one the engine
// still knows. Renaming or removing a script or a flag fails here until the
// docs follow.

const REPO = fileURLToPath(new URL("..", import.meta.url));

// improvements.md is a historical log: its entries describe commands and
// flags as they were when each problem was found, and are never rewritten.
const HISTORICAL_DOCS = new Set(["improvements.md"]);

// Flags in the docs that belong to other tools (git, npm, node).
const EXTERNAL_FLAGS = new Set(["--allow-unrelated-histories"]);

function docFiles(): string[] {
const files = readdirSync(REPO).filter(
(name) => name.endsWith(".md") && !HISTORICAL_DOCS.has(name),
);
const walk = (dir: string) => {
for (const entry of readdirSync(join(REPO, dir), { withFileTypes: true })) {
const path = `${dir}/${entry.name}`;
if (entry.isDirectory()) walk(path);
else if (entry.name.endsWith(".md")) files.push(path);
}
};
walk("docs");
walk("examples");
return files.sort();
}

interface Mention {
file: string;
value: string;
}

function mentions(): { scripts: Mention[]; flags: Mention[] } {
const scripts: Mention[] = [];
const flags: Mention[] = [];
for (const file of docFiles()) {
const text = readFileSync(join(REPO, file), "utf8");
for (const line of text.split("\n")) {
for (const match of line.matchAll(/npm run ([a-z][a-z:-]*)/g))
scripts.push({ file, value: match[1]! });
// Flags written after `npm run …` on the same line.
const start = line.indexOf("npm run ");
if (start >= 0)
for (const match of line
.slice(start)
.matchAll(/(?<![\w-])(--[a-z][a-z-]*[a-z])/g))
flags.push({ file, value: match[1]! });
}
// Flags mentioned on their own in prose: `--overwrite`, `--resolve=ours`.
for (const match of text.matchAll(/`(--[a-z][a-z-]*[a-z])(?:[= ][^`]*)?`/g))
flags.push({ file, value: match[1]! });
}
return { scripts, flags };
}

function sourceText(): string {
return readdirSync(join(REPO, "src"))
.filter((name) => name.endsWith(".ts"))
.map((name) => readFileSync(join(REPO, "src", name), "utf8"))
.join("\n");
}

const unique = (list: Mention[]) =>
[...new Map(list.map((m) => [`${m.value} (${m.file})`, m])).keys()].sort();

test("every `npm run <script>` in the docs is a package.json script", () => {
const scripts = Object.keys(
JSON.parse(readFileSync(join(REPO, "package.json"), "utf8")).scripts,
);
const { scripts: used } = mentions();
assert.ok(
used.length > 50,
`expected many npm run mentions, found ${used.length}`,
);
assert.deepEqual(unique(used.filter((m) => !scripts.includes(m.value))), []);
});

test("every documented --flag is one the engine knows", () => {
const source = sourceText();
const { flags } = mentions();
assert.ok(
flags.length > 20,
`expected many flag mentions, found ${flags.length}`,
);
assert.deepEqual(
unique(
flags.filter(
(m) => !EXTERNAL_FLAGS.has(m.value) && !source.includes(m.value),
),
),
[],
);
});
75 changes: 75 additions & 0 deletions tests/docs-links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import assert from "node:assert/strict";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, join, normalize, relative } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";

// Every relative link and #anchor in the docs must resolve. The README links
// into a set of guides that link to each other, so renaming a file or a
// heading would otherwise break links silently.

const REPO = fileURLToPath(new URL("..", import.meta.url));

function markdownFiles(): string[] {
const files = readdirSync(REPO).filter((name) => name.endsWith(".md"));
const walk = (dir: string) => {
for (const entry of readdirSync(join(REPO, dir), { withFileTypes: true })) {
const path = `${dir}/${entry.name}`;
if (entry.isDirectory()) walk(path);
else if (entry.name.endsWith(".md")) files.push(path);
}
};
walk("docs");
walk("examples");
return files.sort();
}

const withoutCode = (text: string) => text.replace(/```[\s\S]*?```/g, "");

// GitHub's heading anchor: lowercase, punctuation dropped, spaces to dashes.
function anchorsOf(file: string): Set<string> {
const text = withoutCode(readFileSync(file, "utf8"));
return new Set(
[...text.matchAll(/^#{1,6} (.*)$/gm)].map((match) =>
match[1]!
.toLowerCase()
.trim()
.replace(/[^\w\- ]+/g, "")
.replace(/ /g, "-"),
),
);
}

test("every relative link and anchor in the docs resolves", () => {
const files = markdownFiles();
const broken: string[] = [];
for (const file of files) {
const text = withoutCode(readFileSync(join(REPO, file), "utf8"));
for (const match of text.matchAll(/\]\(([^)\s]+)\)/g)) {
const link = match[1]!;
if (/^(https?:|mailto:)/.test(link)) continue;
const [path, anchor] = link.split("#");
const target = path
? normalize(join(REPO, dirname(file), decodeURIComponent(path)))
: join(REPO, file);
if (!existsSync(target)) {
broken.push(`${file}: ${link} (no such file)`);
continue;
}
if (
anchor &&
statSync(target).isFile() &&
target.endsWith(".md") &&
!anchorsOf(target).has(anchor)
)
broken.push(
`${file}: ${link} (no heading #${anchor} in ${relative(REPO, target)})`,
);
}
}
assert.ok(
files.length > 20,
`expected the docs tree, found ${files.length} files`,
);
assert.deepEqual(broken, []);
});
Loading