The dev server
vp dev on a copy of the blog, with Sitez in its config: how it serves pages, and what the
browser is told when a file changes.
import { cpSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, test } from "vite-plus/test";
import { createServer, type ViteDevServer } from "vite-plus";
// A copy of the blog, since the tests change its files as an author would. Its config reaches the
// plugin in `src/`, from where the copy sits.
const site = join(mkdtempSync(join(tmpdir(), "sitez-dev-")), "blog");
cpSync(join(import.meta.dirname, "../tests/sites/blog"), site, { recursive: true });
writeFileSync(
join(site, "vite.config.js"),
`import sitez from ${JSON.stringify(join(import.meta.dirname, "../tests/sitez.ts"))};\n\nexport default { plugins: [sitez()] };\n`,
);
let vite: ViteDevServer;
let url: string;
beforeAll(async () => {
vite = await createServer({
root: site,
configLoader: "native",
logLevel: "silent",
server: { port: 0, host: "localhost" },
});
await vite.listen();
url = vite.resolvedUrls!.local[0]!;
});
afterAll(() => vite.close());
const get = (path: string) => fetch(new URL(path, url), { redirect: "manual" });
describe("pages", () => {
test("a page is the frame around it, with its stylesheet linked from code/ and its own script", async () => {
const response = await get("/blog/");
expect(response.status).toBe(200);
const html = await response.text();
expect(html).toContain("<tag-filter>");
expect(html).toContain('<link rel="stylesheet" href="/code/style.css" />');
expect(html).toContain('<script type="module" src="/@vite/client"></script>');
expect(html).toContain('<script type="module" src="/@sitez/page.js?url=%2Fblog%2F"></script>');
expect(html).toContain("<title>Field Notes</title>");
expect(html).toContain('<link rel="canonical" href="https://notes.example.com/blog/">');
});
test("the stylesheet is served as CSS, where Vite can replace it", async () => {
const response = await fetch(new URL("/code/style.css", url), {
headers: { accept: "text/css" },
});
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/css");
expect(await response.text()).toContain("Blog Serif");
});
test("an element's CSS is linked from its file, and served as CSS in @layer elements", async () => {
const html = await (await get("/blog/hello/")).text();
const href = "/code/@call-out.html?sitez=style&lang.css";
expect(html).toContain(`<link rel="stylesheet" href="${href}" />`);
const response = await fetch(new URL(href, url), { headers: { accept: "text/css" } });
expect(response.headers.get("content-type")).toContain("text/css");
expect(await response.text()).toMatch(/@layer elements\s*\{\s*call-out/);
});
test("the page's own script defines the elements with behavior it uses, and a page with none defines none", async () => {
const page = await (await get("/@sitez/page.js?url=%2Fblog%2F")).text();
expect(page).toMatch(/import setup0 from "\/code\/blog\/@tag-filter\.html\?.*sitez=setup/);
expect(page).toContain('define("tag-filter", setup0)');
expect(await (await get("/@sitez/page.js?url=%2Fabout%2F")).text()).not.toContain("define(");
// What the browser loads, by the URL the page's script imports it from.
const imported = /import setup0 from "([^"]+)"/.exec(page)![1]!;
const setup = await (await get(imported)).text();
expect(setup).toContain('addEventListener("click"');
});
test("a draft is served", async () => {
expect((await get("/blog/draft/")).status).toBe(200);
});
test("/blog redirects to /blog/, as a host would", async () => {
const response = await get("/blog");
expect(response.status).toBe(301);
expect(response.headers.get("location")).toBe("/blog/");
});
test("a page's old URL redirects to it, a draft's too", async () => {
for (const [path, location] of [
["/hello/", "/blog/hello/"],
["/hello", "/blog/hello/"],
["/hello.html", "/blog/hello/"],
["/not-yet/", "/blog/draft/"],
]) {
const response = await get(path!);
expect(response.status).toBe(301);
expect(response.headers.get("location")).toBe(location);
}
});
test("a URL that isn't valid percent-encoding is answered, not fatal", async () => {
expect((await get("/%E0")).status).toBe(404);
expect((await get("/blog/")).status).toBe(200);
});
test("a URL with no page gets the 404 page", async () => {
const response = await get("/nothing/");
expect(response.status).toBe(404);
expect(await response.text()).toContain("<title>");
});
test("public/ is served, and Sitez writes no sitemap or feed", async () => {
expect((await get("/favicon.svg")).status).toBe(200);
for (const path of ["/feed.xml", "/sitemap.xml"]) expect((await get(path)).status).toBe(404);
});
});
describe("changes", () => {What Vite tells the browser after a file changes, read from its hot-update socket as the
page's @vite/client would read it.
async function afterChange(change: () => void): Promise<{ type: string; kinds: string[] }> {
const socket = new WebSocket(url.replace(/^http/, "ws"), "vite-hmr");
const messages: { type: string; updates?: { type: string }[] }[] = [];
await new Promise<void>((resolve) => {
socket.addEventListener("message", (event) => {
const message = JSON.parse(String(event.data));
if (message.type === "connected") resolve();
else messages.push(message);
});
});
// The browser has loaded the page, so its modules are in Vite's graph.
await (await get("/@sitez/page.js?url=%2Fblog%2F")).text();
await fetch(new URL("/code/style.css", url), { headers: { accept: "text/css" } });
// Only what this change causes: a reload from an earlier change may still be arriving.
await new Promise((resolve) => setTimeout(resolve, 100));
messages.length = 0;
change();
for (let i = 0; i < 100 && messages.length === 0; i++) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
socket.close();
const [first] = messages;
return { type: first?.type ?? "none", kinds: first?.updates?.map((u) => u.type) ?? [] };
}
const edit = (file: string, from: string, to: string) => () => {
const path = join(site, file);
writeFileSync(path, readFileSync(path, "utf8").replace(from, to));
};
test("CSS is replaced in place, as it is linked", async () => {
const message = await afterChange(edit("code/style.css", "40rem", "42rem"));
expect(message.type).toBe("update");
});
test("code reloads the page, which shows the change", async () => {
const message = await afterChange(edit("code/blog/@tag-filter.html", ">All<", ">Every<"));
expect(message.type).toBe("full-reload");
expect(await (await get("/blog/")).text()).toContain(">Every</button>");
});
test("the frame reloads the page, which shows the change", async () => {
const message = await afterChange(
edit("code/index.html", '<a href="/">Field Notes</a>', '<a href="/">Notes, Field</a>'),
);
expect(message.type).toBe("full-reload");
expect(await (await get("/about/")).text()).toContain(">Notes, Field</a>");
});
test("a frame that breaks the rules shows as build's message, and fixing it reloads", async () => {
edit("code/index.html", "<title>Field Notes</title>", "")();
const broken = await get("/about/");
expect(broken.status).toBe(500);
expect(await broken.text()).toContain("code/index.html: there's no <title>.");
const message = await afterChange(
edit("code/index.html", "<head>", "<head><title>Field Notes</title>"),
);
expect(message.type).toBe("full-reload");
expect((await get("/about/")).status).toBe(200);
});
test("text reloads the page, which shows the change", async () => {
const message = await afterChange(edit("text/about.md", "# About", "# About us"));
expect(message.type).toBe("full-reload");
expect(await (await get("/about/")).text()).toContain(">About us</h1>");
});
test("a new file is a page at once", async () => {
await afterChange(() => writeFileSync(join(site, "text/new.md"), "# New\n\nJust added.\n"));
expect((await get("/new/")).status).toBe(200);
});
test("a folder with a non-Latin name redirects with its URL encoded", async () => {
await afterChange(() => writeFileSync(join(site, "text/博客.md"), "# 博客\n\nIn Chinese.\n"));
const response = await get("/博客");
expect(response.status).toBe(301);
expect(response.headers.get("location")).toBe("/%E5%8D%9A%E5%AE%A2/");
});
test("a url() in the CSS that isn't there shows, and fixing it reloads", async () => {
edit("code/style.css", "./fonts/Serif.woff2", "./fonts/Serify.woff2")();
const broken = await get("/about/");
expect(broken.status).toBe(500);
expect(await broken.text()).toContain(
"code/style.css: url(./fonts/Serify.woff2) isn't there. Fix the path: relative to this file, or /… for a file in public/.",
);
const message = await afterChange(
edit("code/style.css", "./fonts/Serify.woff2", "./fonts/Serif.woff2"),
);
expect(message.type).toBe("full-reload");
expect((await get("/about/")).status).toBe(200);
});
test("a mistake shows as build's message, in the page", async () => {
await afterChange(edit("text/about.md", "the blog", "the [gone](gone.md) blog"));
const response = await get("/about/");
expect(response.status).toBe(500);
const html = await response.text();
expect(html).toContain("text/about.md: line 9: gone.md isn't there: no file at text/gone.md.");
expect(html).toContain("/@vite/client");
});
});