testNotesExample
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
- tcmts.cli.test.browser.CHROME
- tcmts.cli.test.browser.hydrated
- tcmts.cli.test.browser.withPage
- tcmts.cli.test.e2e.examples.env
- tcmts.cli.test.e2e.examples.exampleDir
- tcmts.cli.test.e2e.examples.hashOf
- tcmts.cli.test.e2e.examples.startApp
- tcmts.cli.test.e2e.harness.tcmBin
- tcmts.cli.test.e2e.harness.tempDir
- tcmts.cli.test.e2e.harness.withChildren
- tcmts.fixtures.NOTES