sitez

Elements with behavior in a browser

Built pages loaded into happy-dom with their own scripts. Each element with behavior sets itself up once on the HTML the build wrote, keeps those nodes, and responds to a click. The snapshots show what a page ships, and this shows that it works.

import { mkdtempSync, readdirSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { Window } from "happy-dom";
import { expect, test } from "vite-plus/test";
import { buildSite } from "./build.ts";

const blogOutDir = await built("blog");
const elementsOutDir = await built("elements");

test("the blog index's tag-filter filters on a click", async () => {
  expect(readdirSync(blogOutDir).filter((file) => file.endsWith(".js"))).toHaveLength(1);
  await inBrowser(blogOutDir, "blog/index.html", async (document) => {
    const element = document.querySelector("tag-filter")!;
    const visible = () =>
      [...element.querySelectorAll("li")]
        .filter((li) => !li.hidden)
        .map((li) => li.textContent!.trim().split(" ")[0]);
    expect(visible()).toEqual(["Again", "Hello"]);

    const start = [...element.querySelectorAll("button")].find((b) => b.textContent === "start")!;
    start.click();
    await Promise.resolve();
    expect(visible()).toEqual(["Hello"]);
    expect(start.getAttribute("aria-pressed")).toBe("true");
  });
});

test("a fold-out shows its text as the build wrote it", async () => {
  // Two pages use fold-out, and both load the site's one script.
  const src = (page: string) =>
    /<script type="module" src="([^"]+)">/.exec(
      readFileSync(join(elementsOutDir, page), "utf8"),
    )?.[1];
  expect(src("docs/index.html")).toBe(src("index.html"));
  await inBrowser(elementsOutDir, "index.html", async (document) => {
    const element = document.querySelector("fold-out")!;
    const button = element.querySelector("button")!;
    const text = element.querySelector("div")!;
    const term = text.querySelector("dfn")!;
    expect(text.hidden).toBe(true);
    button.click();
    await Promise.resolve();
    expect(text.hidden).toBe(false);
    expect(button.getAttribute("aria-expanded")).toBe("true");
    // The text is the build's nodes, not a copy the script made.
    expect(element.querySelector("div")).toBe(text);
    expect(text.querySelector("dfn")).toBe(term);
  });
});

test("a page with no element that has behavior loads no script", () => {
  expect(readFileSync(join(blogOutDir, "about/index.html"), "utf8")).not.toContain("<script");
});

async function built(site: string): Promise<string> {
  const outDir = join(mkdtempSync(join(tmpdir(), "sitez-")), "dist");
  await buildSite(join(import.meta.dirname, "sites", site), outDir);
  return outDir;
}

Loads a built page into happy-dom and runs its script from dist/, as a browser would, with the DOM as globals for the custom elements. Setting up must keep the build's nodes, and a second define of a tag must not run setup again.

async function inBrowser(
  outDir: string,
  page: string,
  check: (document: Document) => Promise<void>,
): Promise<void> {
  const html = readFileSync(join(outDir, page), "utf8");
  const script = /<script type="module" src="\/([^"]+)"><\/script>/.exec(html)?.[1];
  expect(script).toMatch(/\.[\w-]+\.js$/);

  const window = new Window({ url: `https://example.com/${page.replace(/index\.html$/, "")}` });
  const added = Object.getOwnPropertyNames(window).filter((key) => !(key in globalThis));
  const globals = globalThis as Record<string, unknown>;
  for (const key of added) globals[key] = (window as unknown as Record<string, unknown>)[key];
  try {
    window.document.write(html);
    const document = window.document as unknown as Document;
    const element = [...document.querySelectorAll("*")].find((el) => el.localName.includes("-"))!;
    const first = element.firstElementChild;
    await import(pathToFileURL(join(outDir, script!)).href);
    expect(element.firstElementChild).toBe(first);
    await check(document);
  } finally {
    for (const key of added) delete globals[key];
    await window.happyDOM.close();
  }
}