testServeSite

tcmts.cli.test.e2e.sync.testServeSite · #slhafv6i · term

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/);
  })

uses