testNotesExample

tcmts.cli.test.e2e.examples.testNotesExample · #ys4g7xy4 · term

the notes example: React server components with no directives, in a browser

import { CHROME, hydrated, withPage } from "#codebase/tcmts/cli/test/browser";
import { env, exampleDir, hashOf, startApp } from "#codebase/tcmts/cli/test/e2e/examples";
import { tcmBin, tempDir, withChildren } from "#codebase/tcmts/cli/test/e2e/harness";
import { NOTES } from "#codebase/tcmts/fixtures";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { stripVTControlCharacters } from "node:util";

export const testNotesExample = async () => {
  if (!CHROME) return console.log("skipped: no Chrome");
  const notesDir = path.join(tempDir("examples"), "notes");
  const setup = spawnSync(path.join(exampleDir(NOTES), "setup.sh"), [notesDir], {
    env: env(),
    encoding: "utf8",
  });
  const out = stripVTControlCharacters(setup.stdout + setup.stderr);
  assert.equal(setup.status, 0, out);
  // (its own 8, and the react library's 3, mounted at notes.lib.react)
  assert.match(out, /16 passed, 0 failed/);
  assert.doesNotMatch(out, /warning/, "the example's docs link only to what exists");
  const tcm = (...args: string[]) =>
    spawnSync(process.execPath, [tcmBin(), ...args], {
      cwd: notesDir,
      env: env(),
      encoding: "utf8",
    }).stdout;
  assert.match(
    tcm("view", "notes.NoteList"),
    /renders on the server: it is an async component; the browser fetches its rendered tree by hash/,
  );
  assert.match(tcm("view", "notes.NoteEditor"), /renders in the browser: it uses useState/);
  assert.match(tcm("view", "notes.Markdown"), /renders anywhere/);

  await withChildren(async (keep) => {
    const url = await startApp(keep, notesDir, "notes.App");
    const text = (sel: string) =>
      `document.querySelector(${JSON.stringify(sel)})?.textContent ?? ""`;
    const click = (sel: string, label: string) =>
      `[...document.querySelectorAll(${JSON.stringify(sel)})].find((b) => b.textContent.trim() === ${JSON.stringify(label)}).click()`;
    const type = (sel: string, v: string) =>
      `(() => { const i = document.querySelector(${JSON.stringify(sel)}); const proto = i instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; ` +
      `Object.getOwnPropertyDescriptor(proto, "value").set.call(i, ${JSON.stringify(v)}); i.dispatchEvent(new Event("input", { bubbles: true })); })()`;
    // the first paint is rendered on the server: the sidebar's server component included
    const html = await (await fetch(url, { headers: { accept: "text/html" } })).text();
    // its look is notes.styles, a stylesheet the app imports as a file of the codebase: linked to (React's
    // `precedence`, so the page waits for it), as the file the build serves it as, kept for good
    const sheet =
      /^<div id="app"><link rel="stylesheet" href="(\/_app\/assets\/styles-[a-z2-7]{8}\.css)" data-precedence="default"\/><div class="notes">.*No notes yet/s.exec(
        html.slice(html.indexOf('<div id="app">')),
      )?.[1];
    assert.ok(sheet, html);
    assert.doesNotMatch(html, /\.notes \{ display: flex;/, "not written into the page as well");
    const css = await fetch(new URL(sheet, url));
    assert.equal(css.headers.get("content-type"), "text/css; charset=utf-8");
    assert.match(css.headers.get("cache-control") ?? "", /immutable/);
    assert.match(await css.text(), /^\/\* The notes page's look[^]*\.notes \{ display: flex;/);
    // the page names its file for the code, which knows it by name (as the import map names chunks)
    assert.ok(
      html.includes(
        `<script>var __TCM_STYLESHEET_FILES__ = {"styles.css":"${path.basename(sheet)}"};</script>`,
      ),
      "the page names the stylesheet's file",
    );
    assert.match(html, /var __TCM_TREES__ = /, "with the trees it was made from, for hydration");
    await withPage(url, async (p) => {
      // the sidebar is a server component; the button in it is a browser component it sent by hash
      assert.match(
        await p.waitFor<string>(text("aside"), (t) => /add sample notes/.test(t ?? "")),
        /No notes yet/,
      );
      assert.ok(await hydrated(p), "the browser takes over the server-rendered page");
      await p.evaluate(click("aside button", "add sample notes"));
      // the server function call makes the server components fetch their trees again
      assert.match(
        await p.waitFor<string>(text("aside"), (t) => /Groceries/.test(t ?? "")),
        /How this app works/,
      );
      await p.evaluate(click(".entry strong", "Groceries"));
      assert.match(
        await p.waitFor<string>(text("main"), (t) => /last changed/.test(t ?? "")),
        /eggs \(a dozen\)/,
      );
      await p.evaluate(click("main button", "edit"));
      await p.waitFor<string>(text("main"), (t) => /save/.test(t ?? ""));
      await p.evaluate(type("textarea", "- **bread**\n- milk"));
      // the editor's preview is the same Markdown component, rendered in the browser as you type
      assert.match(
        await p.waitFor<string>(`document.querySelector(".preview")?.innerHTML ?? ""`, (t) =>
          /<strong>bread/.test(t ?? ""),
        ),
        /<li><strong>bread<\/strong><\/li>/,
      );
      await p.evaluate(click("main button", "save"));
      assert.match(
        await p.waitFor<string>(text("aside"), (t) => /bread milk/.test(t ?? "")),
        /Groceries/,
      );
      await p.evaluate(type(".search", "bread"));
      assert.doesNotMatch(
        await p.waitFor<string>(text(".list"), (t) => !/Markdown/.test(t ?? "")),
        /Markdown/,
      );
      assert.deepEqual(p.errors, [], "no errors, hydration mismatches included");
    });
  });
  // what the browser received: no server code
  // (the build's own assets, in the directory builds share)
  const apps = path.join(notesDir, ".tcm", "apps");
  const manifest = JSON.parse(
    fs.readFileSync(path.join(apps, hashOf("notes.App", notesDir), "tcm-app.json"), "utf8"),
  ) as { assets: string[] };
  assert.ok(manifest.assets.length > 1, "a few chunks");
  const shipped = manifest.assets
    .map((f) => fs.readFileSync(path.join(apps, "assets", f), "utf8"))
    .join("\n");
  for (const secret of ["orderBy", "store.sqlite", "that note was deleted"])
    assert.ok(!shipped.includes(secret), `${secret} must not reach the browser`);
  // and the stylesheet reaches it as a file alone: the code only links to it, so its text isn't in the code, nor
  // its file's name (a change to it changes no chunk)
  const code = manifest.assets.filter((f) => f.endsWith(".js"));
  const sheets = manifest.assets.filter((f) => /^styles-[a-z2-7]{8}\.css$/.test(f));
  assert.equal(sheets.length, 1, manifest.assets.join(", "));
  for (const f of code) {
    const js = fs.readFileSync(path.join(apps, "assets", f), "utf8");
    assert.ok(!js.includes("The notes page's look"), `${f} doesn't hold the stylesheet`);
    assert.ok(!js.includes(sheets[0]!), `${f} doesn't name its file`);
  }
}

uses