No prose in this file.
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { request } from "node:http";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test";
import { segments } from "./render.js";
import { serve, type Served, touches } from "./server.js";
import type { TreeNode } from "./tree.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 }> {
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 }),
);
});
req.on("error", fail);
req.end();
});
}
describe("segments (spec §4.1)", () => {
it("lays a file out as code runs around its blocks, dropping the comment text", () => {
const source = "/** @prose A. */\nimport x;\n\n/** @prose B. */\n\nconst b = 1;\n";
const block = (start: number, end: number): TreeNode => ({
name: "",
kind: "chunk",
path: "f.ts#x",
summary: "",
span: [start, end],
children: [],
});
const a = source.indexOf("/** @prose A. */");
const b = source.indexOf("/** @prose B. */");
const parts = segments(source, [block(b, b + 16), block(a, a + 16)]);
expect(parts.map((p) => (p.kind === "code" ? [p.text, p.line] : "block"))).toEqual([
"block",
["import x;", 2],
"block",
["const b = 1;", 6],
]);
});
});
describe("serve: examples/single", () => {
let served: Served;
beforeAll(async () => {
served = await serve(resolve("examples/single"), { 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: blocks by anchor, code numbered from its file line, pending marked", async () => {
const { status, body } = await get(served.url, "/main.js");
expect(status).toBe(200);
expect(body).toContain('id="file"');
// A block that opens with a heading is named by it, and its `#` sits inside that heading.
expect(body).toContain(
'id="state"><div class="prose"><h1><a class="anchor" href="#state" aria-label="Link to this block">#</a>',
);
expect(body).not.toContain('href="#file"');
expect(body).toContain('id="input"');
expect(body).toMatch(/class="block pending" id="persistence"/);
// The State chunk'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("#file L");
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');
});
it("renders a Markdown file with markz, the switch there but disabled", async () => {
const { status, body } = await get(served.url, "/README.md");
expect(status).toBe(200);
expect(body).toContain('<div class="prose"><h1');
expect(body).toContain('data-mode="prose" aria-pressed="false" disabled');
});
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",
};
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 with their summaries, and marks the undocumented", async () => {
const { body } = await get(served.url, "/src/");
expect(body).toContain("The source.");
expect(body).toContain("Does a.");
expect(body).toMatch(/b\.ts<\/a><p><span class="undocumented">undocumented/);
});
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 rail = body.slice(body.indexOf('<nav class="rail"'), body.indexOf("</nav>") + 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">');
expect(rail).toContain(
'<a class="file" style="--depth: 1" href="/src/a.ts" aria-current="page">a.ts</a>',
);
expect(rail).not.toContain('href="/src/README.md"');
});
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 });
}
});
});