testServeSite
tcm serve --site: a static site to anyone, beside the codebase's routes (which still need the token); a token that was given isn't printed; a directory asked without its slash goes to it with one, and with --moved, a page that moved under a path is redirected there
import { plainEnv, tcmBin, tempDir, withChildren } from "#codebase/tcmts/cli/test/e2e/harness"; import { codebase } from "#codebase/tcmts/cli/test/e2e/sync"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; export const testServeSite = () => withChildren(async (keep) => { const root = tempDir("serve site"); const server = codebase(root, "server"); const site = path.join(root, "site"); fs.mkdirSync(path.join(site, "guide"), { recursive: true }); fs.writeFileSync(path.join(site, "index.html"), "<h1>docs</h1>"); fs.writeFileSync(path.join(site, "cli.html"), "<h1>cli</h1>"); fs.writeFileSync(path.join(site, "guide", "index.html"), "<h1>guide</h1>"); fs.writeFileSync(path.join(site, "guide", "old.html"), "<h1>old</h1>"); fs.writeFileSync(path.join(root, "secret.txt"), "not served"); const big = `<h1>big</h1>${"<p>words, words</p>\n".repeat(500)}`; fs.writeFileSync(path.join(site, "big.html"), big); const css = "body { color: red }"; const named = `style.${createHash("sha256").update(css).digest("hex").slice(0, 12)}.css`; fs.writeFileSync(path.join(site, named), css); fs.writeFileSync(path.join(site, "style.0123456789ab.css"), css); const child = keep( spawn( process.execPath, [ tcmBin(), "serve", "--port", "0", "--token", "sitetok", "--site", site, "--moved", "/guide", "--proxy", "/guide/app=http://127.0.0.1:9", ], { cwd: server.dir, env: plainEnv() }, ), ); let out = ""; const url = await new Promise<string>((resolve, reject) => { child.stdout.setEncoding("utf8").on("data", (d: string) => { out += d; const m = /url\s+(https?:\/\/[\d.]+:\d+)/.exec(out); if (m && /on another machine/.test(out)) resolve(m[1]!); }); child.on("exit", (code) => reject(new Error(`server exited ${code}: ${out}`))); }); assert.doesNotMatch(out, /sitetok/, "a given token isn't printed"); const get = async (p: string) => { const r = await fetch(url + p); return { status: r.status, type: r.headers.get("content-type"), body: await r.text() }; }; assert.deepEqual(await get("/"), { status: 200, type: "text/html; charset=utf-8", body: "<h1>docs</h1>", }); assert.equal((await get("/cli.html")).body, "<h1>cli</h1>"); assert.equal((await get("/cli")).body, "<h1>cli</h1>"); assert.equal((await get("/guide/")).body, "<h1>guide</h1>"); assert.equal((await get("/nope.html")).status, 404); const goes = async (p: string) => { const r = await fetch(url + p, { redirect: "manual" }); return `${r.status} ${r.headers.get("location")}`; }; assert.equal(await goes("/guide"), "301 /guide/", "a directory, with its slash"); assert.equal(await goes("/guide?x=1"), "301 /guide/?x=1"); // (moved under /guide: what the site has there, from its old address; what it has at the root stays) assert.equal(await goes("/old.html"), "301 /guide/old.html"); assert.equal(await goes("/old?x=1"), "301 /guide/old?x=1"); assert.equal( await goes("/app/notes?x=1"), "301 /guide/app/notes?x=1", "and an app's, moved with it", ); assert.equal((await get("/old")).body, "<h1>old</h1>"); assert.equal((await get("/cli")).status, 200); assert.equal((await get("/guide/nope.html")).status, 404); assert.equal((await get("/%2e%2e/secret.txt")).status, 404, "nothing outside the site"); // sent once: an unchanged file is a 304, text is compressed, a file named by its content is kept for good const first = await fetch(`${url}/big.html`, { headers: { "accept-encoding": "br, gzip" } }); assert.equal(first.headers.get("content-encoding"), "br"); assert.ok(Number(first.headers.get("content-length")) < big.length / 10, "compressed"); assert.equal(await first.text(), big); assert.equal(first.headers.get("cache-control"), "no-cache"); const again = await fetch(`${url}/big.html`, { headers: { "if-none-match": first.headers.get("etag")! }, }); assert.equal(again.status, 304); assert.equal( (await fetch(`${url}/big.html`, { headers: { "accept-encoding": "gzip" } })).headers.get( "content-encoding", ), "gzip", ); assert.equal( (await fetch(`${url}/${named}`)).headers.get("cache-control"), "public, max-age=31536000, immutable", ); assert.equal( (await fetch(`${url}/style.0123456789ab.css`)).headers.get("cache-control"), "no-cache", "a name that isn't its content isn't trusted", ); // the codebase's routes still need the token assert.equal((await fetch(`${url}/missing`, { method: "POST", body: "{}" })).status, 401); server.scratch(`// tcm: namespace lib\nexport const one = 1;\n`); server.tcm("add"); const client = codebase(root, "client"); fs.rmSync(path.join(client.dir, "scratch.ts")); client.tcm("remote", "add", "origin", url, "--token", "sitetok"); assert.match(client.tcm("pull"), /lib\.one|fast-forward|received/); assert.match(client.tcm("ls", "lib"), /lib\.one/); })