testRemindersExample

tcmts.cli.test.e2e.examples.testRemindersExample · #zibyjabj · term

the reminders example: a local-first PWA built as a static site, in a browser: its data on the device, opened with no server at all, and a new version found and taken by itself, the data kept

import { CHROME, withPage } from "#codebase/tcmts/cli/test/browser";
import { env, exampleDir, staticHost } from "#codebase/tcmts/cli/test/e2e/examples";
import { strip, tcmBin, tempDir } from "#codebase/tcmts/cli/test/e2e/harness";
import { REMINDERS } 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";

export const testRemindersExample = async () => {
  if (!CHROME) return console.log("skipped: no Chrome");
  const dir = path.join(tempDir("examples"), "reminders");
  const setup = spawnSync(path.join(exampleDir(REMINDERS), "setup.sh"), [dir], {
    env: env(),
    encoding: "utf8",
  });
  const out = strip(setup.stdout + setup.stderr);
  assert.equal(setup.status, 0, out);
  // (its own 3, and the react library's 3, mounted at reminders.lib.react)
  assert.match(out, /6 passed, 0 failed/);
  assert.doesNotMatch(out, /warning/, "the example's docs link only to what exists");
  const tcm = (...args: string[]) => {
    const r = spawnSync(process.execPath, [tcmBin(), ...args], {
      cwd: dir,
      env: env(),
      encoding: "utf8",
    });
    const said = strip(r.stdout + r.stderr);
    assert.equal(r.status, 0, said);
    return said;
  };
  assert.match(tcm("view", "reminders.App"), /renders in the browser/);
  assert.match(tcm("view", "reminders.saveReminder"), /runs in the browser/);

  // a static site, under a path of the site it's on
  const site = path.join(dir, "site");
  const built = tcm(
    "build",
    "reminders.App",
    "--static",
    "site/apps/reminders",
    "--base",
    "/apps/reminders",
  );
  assert.match(built, /server\s+nothing \(the app runs entirely in the browser\)/);
  assert.match(
    built,
    /pwa\s+installable; its service worker keeps \d+ file\(s\) and opens it from them first/,
  );
  assert.doesNotMatch(built, /warning/, "its icons are all an installed app needs");
  // a page for each list it links to, rendered as it's built
  assert.match(
    built,
    /wrote reminders\.App as a static site into site\/apps\/reminders \(6 page\(s\)/,
  );
  const root = path.join(site, "apps", "reminders");
  for (const f of [
    "index.html",
    "today.html",
    "flagged.html",
    "completed.html",
    "404.html",
    "sw.js",
    ".nojekyll",
  ])
    assert.ok(fs.existsSync(path.join(root, f)), `${f} is written`);
  const page = fs.readFileSync(path.join(root, "index.html"), "utf8");
  // (its look linked to, under the base: a file of the site)
  const sheet =
    /<div id="app"><link rel="stylesheet" href="\/apps\/reminders\/(_app\/assets\/styles-[a-z2-7]{8}\.css)" data-precedence="default"\/><div class="app" data-open="home">.*Scheduled/s.exec(
      page,
    )?.[1];
  assert.ok(sheet, "its first paint");
  assert.ok(fs.existsSync(path.join(root, sheet)), "its stylesheet is written");
  assert.match(
    fs.readFileSync(path.join(root, "flagged.html"), "utf8"),
    /<h1 class="title">Flagged<\/h1>/,
  );
  assert.doesNotMatch(
    fs.readFileSync(path.join(root, "404.html"), "utf8"),
    /<div id="app"><div/,
    "any other path renders in the browser",
  );
  assert.match(page, /<title>Reminders<\/title>/);
  assert.match(page, /var __TCM_BASE__ = "\/apps\/reminders"/);
  assert.match(
    page,
    /<link rel="apple-touch-icon" href="\/apps\/reminders\/_app\/assets\/icon-[0-9a-f]{12}\.png">/,
  );
  const linked = /<link rel="manifest" href="([^"]+)">/.exec(page)?.[1] ?? "";
  assert.match(linked, /^\/apps\/reminders\/_app\/assets\/manifest-[0-9a-f]{12}\.webmanifest$/);
  const manifest = JSON.parse(fs.readFileSync(path.join(site, linked), "utf8")) as Record<
    string,
    any
  >;
  assert.equal(manifest.name, "Reminders");
  assert.equal(manifest.start_url, "../../");
  assert.equal(manifest.shortcuts[0].url, "../../today");
  assert.equal(manifest.display, "standalone");
  assert.deepEqual(
    manifest.icons.map((i: Record<string, string>) => `${i.type} ${i.sizes} ${i.purpose ?? "any"}`),
    ["image/svg+xml any any", "image/png 512x512 any", "image/png 512x512 maskable"],
  );

  let host = await staticHost(site);
  const url = `${host.url}/apps/reminders/`;
  // (what's shown, with what the fields hold: a reminder's title is one)
  const text = (sel: string) =>
    `(() => { const e = document.querySelector(${JSON.stringify(sel)}); return e ? [e.innerText, ...[...e.querySelectorAll("input")].map((i) => i.value)].join("\\n") : ""; })()`;
  try {
    await withPage(url, async (p) => {
      // what the device starts with, from its own database
      assert.match(
        await p.waitFor<string>(text(".list"), (t) => /Add a reminder/.test(t ?? "")),
        /Today/,
      );
      assert.equal(
        await p.waitFor<string>(
          `navigator.serviceWorker.controller ? "kept" : ""`,
          (v) => v === "kept",
        ),
        "kept",
        "the service worker keeps the app, and serves the page",
      );
      // a reminder in one line: "tomorrow" and "!" are read
      await p.evaluate(
        `(() => { const i = document.querySelector(".new input"); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(i, "Buy milk tomorrow !"); i.dispatchEvent(new Event("input", { bubbles: true })); })()`,
      );
      await p.waitFor<string>(text(".new"), (t) => /Tomorrow/.test(t ?? ""));
      await p.evaluate(`document.querySelector(".new").requestSubmit()`);
      await p.evaluate(
        `[...document.querySelectorAll(".tile")].find((t) => t.innerText.includes("Flagged")).click()`,
      );
      assert.match(
        await p.waitFor<string>(text(".list"), (t) => /Buy milk/.test(t ?? "")),
        /Tomorrow/,
        "flagged, due tomorrow, and the URL says the list",
      );
      assert.equal(await p.evaluate("location.pathname"), "/apps/reminders/flagged");

      // no network at all: the app opens anyway, at the list it was on, with what's on the device
      await host.close();
      await p.evaluate("location.reload()");
      assert.match(
        await p.waitFor<string>(text(".list"), (t) => /Buy milk/.test(t ?? "")),
        /Flagged/,
      );
      // and a list the build never saw, only on the device: the page that renders any path, from the device
      await p.evaluate(`location.href = "/apps/reminders/lists/reminders"`);
      assert.match(
        await p.waitFor<string>(text(".list"), (t) => /Open it in a second window/.test(t ?? "")),
        /Reminders/,
      );
      await p.evaluate(`location.href = "/apps/reminders/flagged"`);
      await p.waitFor<string>(text(".list"), (t) => /Buy milk/.test(t ?? ""));

      // a new version, built into the same directory and back where it was: the app finds it by itself the next
      // time it's opened, and says so; reloading takes it, the data kept
      assert.match(
        tcm("patch", "reminders.SMART", "--old", `name: "All"`, "--new", `name: "Everything"`),
        /update/,
      );
      tcm("build", "reminders.App", "--static", "site/apps/reminders", "--base", "/apps/reminders");
      host = await staticHost(site, host.port);
      await p.evaluate("location.reload()");
      assert.match(
        await p.waitFor<string>(
          text(".status"),
          (t) => /new version is ready/.test(t ?? ""),
          30000,
        ),
        /Reload/,
      );
      assert.doesNotMatch(
        await p.evaluate<string>(text(".home")),
        /Everything/,
        "until it's reloaded, the page is the one it was",
      );
      await p.evaluate(`document.querySelector(".status button").click()`);
      assert.match(
        await p.waitFor<string>(text(".home"), (t) => /Everything/.test(t ?? "")),
        /Flagged/,
      );
      assert.match(
        await p.waitFor<string>(text(".list"), (t) => /Buy milk/.test(t ?? "")),
        /Buy milk/,
      );
      assert.deepEqual(p.errors, []);
    });
  } finally {
    await host.close();
  }
}

uses