Server tests
The pages prose . serves (reading), mostly for
tests/fixtures/simple. Also the links between comments, the table of contents and live
reload. Requests go out raw, so a path like /../x arrives as written and must be refused.
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { type IncomingHttpHeaders, request } from "node:http";
import { tmpdir } from "node:os";
import { basename, join, resolve } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test";
import { LIVE, SCRIPT, STYLE } from "../src/assets.js";
import { segments, sourceBody, tableOfContents } from "../src/render.js";
import { serve, type Served, touches } from "../src/server.js";
import { parseFile } from "../src/parser.js";
/** A raw GET, so a path like `/../x` reaches the server as written instead of being normalized. */
function get(
url: string,
path: string,
): Promise<{ status: number; body: string; location?: string; headers: IncomingHttpHeaders }> {
return new Promise((done, fail) => {
const req = request(new URL(url), { path, method: "GET" }, (res) => {
let body = "";
res.setEncoding("utf-8");
res.on("data", (chunk: string) => (body += chunk));
res.on("end", () =>
done({
status: res.statusCode ?? 0,
body,
location: res.headers.location,
headers: res.headers,
}),
);
});
req.on("error", fail);
req.end();
});
}
describe("segments", () => {
it("lays a file out as code runs around its comments, dropping the comment text", () => {
const source = "/** @prose A. */\nimport x;\n\n/** @prose B. */\n\nconst b = 1;\n";
const comment = (start: number) => ({ body: "", start, end: start + 16 });
const a = source.indexOf("/** @prose A. */");
const b = source.indexOf("/** @prose B. */");
const parts = segments(source, [comment(b), comment(a)]);
expect(parts.map((p) => (p.kind === "code" ? [p.text, p.line] : "comment"))).toEqual([
"comment",
["import x;", 2],
"comment",
["const b = 1;", 6],
]);
});
});
describe("sourceBody", () => {
it("links each later comment by its first heading, unique on the page, and one with none not at all", async () => {
const source = [
"/** @prose\n * # Store\n */",
"/** @prose\n * # Adding\n */\nconst a = 1;",
"/** @prose\n * # Adding\n */\nconst b = 1;",
"/** @prose\n * No heading.\n */\nconst c = 1;",
].join("\n");
const comments = parseFile(source, "ts");
const body = sourceBody({
name: "s.ts",
kind: "file",
path: "s.ts",
summary: "",
source,
comments,
children: [],
});
expect(body).toContain('<h1 id="store">Store</h1>');
expect(body).toContain('<h2 id="adding"><a class="anchor" href="#adding"');
expect(body).toContain('<h2 id="adding-1"><a class="anchor" href="#adding-1"');
expect(body.match(/class="anchor"/g)).toHaveLength(2);
});
});
describe("tableOfContents", () => {
it("lists a doc's second- and third-level headings by their ids, folded and beside the page", () => {
const toc = tableOfContents(
'<h1 id="t">T</h1><h2 id="a">A</h2><h3 id="b">B <code>x</code></h3><h4 id="c">C</h4><h2 id="d">D</h2>',
);
expect(toc).toContain('<details class="toc toc-top"><summary>On this page</summary>');
expect(toc).toContain('<nav class="toc toc-side" aria-label="On this page">');
expect(toc).toContain('<li class="toc-3"><a href="#b">B x</a></li>');
expect(toc).not.toContain('href="#c"');
expect(toc).not.toContain('href="#t"');
});
it("lists a comment's heading by its id, without its # link", () => {
const block = (id: string) =>
`<section class="block"><div class="prose"><h2 id="${id}"><a class="anchor" href="#${id}" aria-label="Link to this section">#</a>${id}</h2></div></section>`;
const toc = tableOfContents(block("one") + block("two") + block("three"));
expect(toc).toContain('<li class="toc-2"><a href="#two">two</a></li>');
});
it("has none for a page with fewer than three", () => {
expect(tableOfContents('<h2 id="a">A</h2><h2 id="b">B</h2>')).toBe("");
});
});
describe("serve: tests/fixtures/simple", () => {
let served: Served;
beforeAll(async () => {
served = await serve(resolve("tests/fixtures/simple"), { port: 0, watch: false });
});
afterAll(() => served.close());
it("renders the project page: the README, then each file with its first paragraph", async () => {
const { status, body } = await get(served.url, "/");
expect(status).toBe(200);
expect(body).toContain("<h1");
expect(body).toContain('href="/main.js"');
expect(body).toContain("holds the count, applies a step");
});
it("renders a source file as one document: comments linked by heading, code numbered from its file line", async () => {
const { status, body } = await get(served.url, "/main.js");
expect(status).toBe(200);
// A later comment's heading keeps markz's id, one level down, with its `#` inside it.
expect(body).toContain(
'<h2 id="state"><a class="anchor" href="#state" aria-label="Link to this section">#</a>State</h2>',
);
expect(body).toContain('<h2 id="input">');
expect(body).toContain('<h2 id="persistence">');
// The file's own comment has no heading here, so no link.
expect(body.match(/class="anchor"/g)).toHaveLength(3);
// The State comment's code starts on line 12 of main.js, so the gutter counts on from 11.
expect(body).toMatch(/<div class="code" style="counter-reset: line 11;/);
expect(body).toContain('data-mode="prose"');
expect(body).toContain('class="code-head"');
expect(body).not.toContain("@prose");
});
it("renders CSS and HTML files the same way", async () => {
expect((await get(served.url, "/style.css")).body).toContain('class="block');
expect((await get(served.url, "/index.html")).body).toContain('class="block');
});
// The fixture keeps another project's style, tabs and single quotes, so the formatter skips it.
it("shows code as its file has it, tabs and quotes untouched", async () => {
const js = (await get(served.url, "/main.js")).body;
expect(js).toContain('<span class="line">\t<span class="constant">output</span>.value');
expect(js).toContain(`<span class="string">'#count'</span>`);
expect((await get(served.url, "/style.css")).body).toContain('<span class="line">\t--bg');
});
it("sends a folder's README.md to the folder's page, which the breadcrumb ends in", async () => {
const res = await get(served.url, "/README.md");
expect(res.status).toBe(301);
expect(res.location).toBe("/");
expect((await get(served.url, "/")).body).toMatch(
/<span class="sep">\/<\/span><a href="\/" aria-current="page">README\.md<\/a><\/nav>/,
);
});
it("links the shared files and serves them, cached for good", async () => {
const { body } = await get(served.url, "/");
for (const asset of [STYLE, SCRIPT, LIVE]) {
expect(body).toContain(asset.url);
const res = await get(served.url, asset.url);
expect(res.body).toBe(asset.body);
expect(res.headers["content-type"]).toBe(asset.type);
expect(res.headers["cache-control"]).toContain("immutable");
}
expect(body).not.toContain("<style>");
});
it("refuses anything the walk doesn't list, a path outside the root included", async () => {
expect((await get(served.url, "/nope.js")).status).toBe(404);
// `/../x` and `/%2e%2e/x` normalize to `/x`, inside the root; an encoded slash doesn't.
expect((await get(served.url, "/..%2F..%2Fpackage.json")).status).toBe(404);
expect((await get(served.url, "/..%2Fsrc%2Fparser.ts")).status).toBe(404);
expect((await get(served.url, "/node_modules/")).status).toBe(404);
});
});
describe("serve: folders", () => {
let root: string;
let served: Served;
beforeAll(async () => {
root = mkdtempSync(join(tmpdir(), "prose-serve-test-"));
const files: Record<string, string> = {
"README.md": "# Demo\n\nThe project.\n",
"app.ts": "export {};\n",
"src/README.md": "# src\n\nThe source.\n",
"src/a.ts": "/** @prose\n * Does a.\n */\nexport const a = 1;\n",
"src/b.ts": "export const b = 2;\n",
"docs/plan.md": "# Plan\n\nWhat's next.\n",
"docs/grammar.md": "# Grammar\n\n```ebnf\ndigit ::= [0-9] | 'x'\n```\n",
};
for (const [path, text] of Object.entries(files)) {
mkdirSync(join(root, path, ".."), { recursive: true });
writeFileSync(join(root, path), text);
}
execFileSync("git", ["init", "-q"], { cwd: root });
served = await serve(root, { port: 0, watch: false });
});
afterAll(async () => {
await served.close();
rmSync(root, { recursive: true, force: true });
});
it("lists a folder's files in groups with their summaries, and names the undocumented on one line", async () => {
const { body } = await get(served.url, "/src/");
expect(body).toContain("The source.");
expect(body).toMatch(
/<p class="group-label">Code<\/p><ul class="listing"><li class="file"><a href="\/src\/a\.ts">a\.ts<\/a><p>Does a\.<\/p>/,
);
expect(body).toContain(
'<p class="group-label">No prose yet</p><p class="names"><a href="/src/b.ts">b.ts</a></p>',
);
const root = (await get(served.url, "/")).body;
expect(root.indexOf(">Folders<")).toBeLessThan(root.indexOf(">No prose yet<"));
});
it("shows a file with no prose as one run with the same header, and says so", async () => {
const { body } = await get(served.url, "/src/b.ts");
expect(body).toContain('<p class="no-prose">No prose in this file.</p><div class="code"');
expect(body).toContain('class="code-head"');
expect(body).not.toContain('data-mode="prose" aria-pressed="false" disabled');
});
it("shows docs/ as an ordinary folder on the project page", async () => {
const { body } = await get(served.url, "/");
expect(body).toContain('href="/docs/"');
expect(body).toContain('href="/src/"');
});
it("shows the file tree on every page: folders first, the current page's folders open", async () => {
const { body } = await get(served.url, "/src/a.ts");
const start = body.indexOf('<nav class="rail"');
const rail = body.slice(start, body.indexOf("</nav>", start) + 6);
// `app.ts` sorts before both folders, and still comes after them.
expect(rail.indexOf('data-folder="src"')).toBeLessThan(rail.indexOf('href="/app.ts"'));
expect(rail).toContain('<details data-folder="src" open>');
expect(rail).toContain('<details data-folder="docs">');
// A folder's row opens and closes it, and holds no link of its own.
expect(rail).toContain('<summary style="--depth: 0">src</summary>');
expect(rail).toContain(
'<a class="file" style="--depth: 1" href="/src/a.ts" aria-current="page">a.ts</a>',
);
// A folder's README.md is listed first in it, and goes to the folder's page.
expect(rail).toMatch(
/<ul style="--depth: 0"><li><a class="file" style="--depth: 1" href="\/src\/">README.md<\/a>/,
);
expect(rail).not.toContain('href="/src/README.md"');
});
it("renders a Markdown file with markz, the switch there but disabled", async () => {
const { status, body } = await get(served.url, "/docs/plan.md");
expect(status).toBe(200);
expect(body).toContain('<div class="prose"><h1');
expect(body).toContain('data-mode="prose" aria-pressed="false" disabled');
});
it("starts every breadcrumb at the project, and ends a folder's page with its README", async () => {
const crumbs = (body: string) =>
body.slice(
body.indexOf('<nav class="crumbs">'),
body.indexOf("</nav>", body.indexOf('<nav class="crumbs">')),
);
const sep = '<span class="sep">/</span>';
const name = basename(root);
expect(crumbs((await get(served.url, "/")).body)).toBe(
`<nav class="crumbs"><a href="/">${name}</a>${sep}<a href="/" aria-current="page">README.md</a>`,
);
expect(crumbs((await get(served.url, "/src/")).body)).toBe(
`<nav class="crumbs"><a href="/">${name}</a>${sep}<a href="/src/">src</a>${sep}<a href="/src/" aria-current="page">README.md</a>`,
);
expect(crumbs((await get(served.url, "/src/a.ts")).body)).toBe(
`<nav class="crumbs"><a href="/">${name}</a>${sep}<a href="/src/">src</a>${sep}<a href="/src/a.ts" aria-current="page">a.ts</a>`,
);
});
it("titles a tab by a doc's heading, a file's name or a folder's, then the project", async () => {
const title = async (path: string) =>
/<title>(.*)<\/title>/.exec((await get(served.url, path)).body)?.[1];
const name = basename(root);
expect(await title("/")).toBe(name);
expect(await title("/src/")).toBe(`src/ | ${name}`);
expect(await title("/src/a.ts")).toBe(`a.ts | ${name}`);
expect(await title("/docs/plan.md")).toBe(`Plan | ${name}`);
});
it("gives every page one h1, its name when its text has none, and the 404 page its own", async () => {
const h1s = async (path: string) =>
[...(await get(served.url, path)).body.matchAll(/<h1\b[^>]*>(.*?)<\/h1>/g)].map((m) => m[1]);
expect(await h1s("/src/b.ts")).toEqual(["b.ts"]);
expect(await h1s("/src/a.ts")).toEqual(["a.ts"]);
expect(await h1s("/docs/")).toEqual(["docs/"]);
expect(await h1s("/docs/plan.md")).toEqual(["Plan"]);
expect(await h1s("/nope.ts")).toEqual(["Page not found"]);
});
it("starts with a link past the bar to the text, and names each code run's header as code", async () => {
const { body } = await get(served.url, "/src/b.ts");
expect(body.indexOf('<a class="skip" href="#Text">')).toBeLessThan(body.indexOf('class="bar"'));
expect(body).toContain('<main id="Text" class="has-code">');
expect(body).toMatch(
/class="code-head"[^>]*>.*?<span><span class="unseen">Code, <\/span>\d+ lines? · /,
);
});
it("highlights an EBNF fence: the rule's name, its terminals and character classes", async () => {
const { body } = await get(served.url, "/docs/grammar.md");
expect(body).toContain('<span class="function">digit</span>');
expect(body).toContain('<span class="constant">[0-9]</span>');
expect(body).toContain(`<span class="string">'x'</span>`);
});
it("redirects a folder asked for without its slash", async () => {
const res = await get(served.url, "/src");
expect(res.status).toBe(301);
expect(res.location).toBe("/src/");
});
it("refuses to write", async () => {
const status = await new Promise<number>((done) => {
const req = request(new URL(served.url), { method: "POST", path: "/src/a.ts" }, (res) =>
done(res.statusCode ?? 0),
);
req.end();
});
expect(status).toBe(405);
});
});
describe("serve: live reload", () => {
/** Opens the event stream for a page and collects what arrives within `ms`. */
function listen(url: string, page: string, since: number, ms: number): Promise<string> {
return new Promise((done) => {
let text = "";
const path = `/.prose/events?path=${encodeURIComponent(page)}&since=${since}`;
const req = request(new URL(url), { path }, (res) => {
res.setEncoding("utf-8");
res.on("data", (chunk: string) => (text += chunk));
});
req.end();
setTimeout(() => {
req.destroy();
done(text);
}, ms);
});
}
it("tells a page when something it shows changes, and not otherwise", () => {
expect(touches("src/a.ts", "src/a.ts")).toBe(true);
expect(touches("src/a.ts", "src/")).toBe(true);
expect(touches("src/a.ts", "")).toBe(true);
expect(touches("src/b.ts", "src/a.ts")).toBe(false);
expect(touches("docs/plan.md", "src/")).toBe(false);
});
it("catches a page up on a change it missed while it wasn't listening", async () => {
const root = mkdtempSync(join(tmpdir(), "prose-reload-test-"));
writeFileSync(join(root, "a.ts"), "export const a = 1;\n");
writeFileSync(join(root, "b.ts"), "export const b = 1;\n");
const served = await serve(root, { port: 0 });
try {
// macOS can report the two files' creation late; let that settle before the page renders.
await new Promise((r) => setTimeout(r, 500));
const renderedAt = Date.now();
await new Promise((r) => setTimeout(r, 50));
writeFileSync(join(root, "a.ts"), "export const a = 2;\n");
await new Promise((r) => setTimeout(r, 300));
expect(await listen(served.url, "a.ts", renderedAt, 200)).toContain("data: reload");
expect(await listen(served.url, "b.ts", renderedAt, 200)).not.toContain("data: reload");
expect(await listen(served.url, "a.ts", Date.now(), 200)).not.toContain("data: reload");
} finally {
await served.close();
rmSync(root, { recursive: true, force: true });
}
});
});