From eb101c3e05e6ac92c41fd5e89bb88b57a9d2d378 Mon Sep 17 00:00:00 2001
From: jzzliu
Date: Sat, 7 Nov 2026 21:18:38 -0700
Subject: [PATCH] Add dependency-free browser test suite and README
49 tests run in headless Chrome via tests/run.sh: unit tests for the pure
modules plus integration tests that drive the real page in an iframe
(filtering, URL state, bag, limits, cross-tab sync, checkout, dialogs,
accessibility and overflow checks).
---
README.md | 61 +++++++++++
tests/app.test.js | 237 ++++++++++++++++++++++++++++++++++++++++++
tests/cart.test.js | 102 ++++++++++++++++++
tests/catalog.test.js | 75 +++++++++++++
tests/dom.test.js | 47 +++++++++
tests/format.test.js | 26 +++++
tests/harness.js | 52 +++++++++
tests/index.html | 21 ++++
tests/run.js | 8 ++
tests/run.sh | 23 ++++
10 files changed, 652 insertions(+)
create mode 100644 README.md
create mode 100644 tests/app.test.js
create mode 100644 tests/cart.test.js
create mode 100644 tests/catalog.test.js
create mode 100644 tests/dom.test.js
create mode 100644 tests/format.test.js
create mode 100644 tests/harness.js
create mode 100644 tests/index.html
create mode 100644 tests/run.js
create mode 100755 tests/run.sh
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b86e99e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,61 @@
+# Fieldwork Supply
+
+A fictional outdoor-gear storefront, built as a portfolio demo. Static site, no build step, no
+dependencies: plain HTML, CSS and ES modules, served by Caddy in Docker.
+
+## Layout
+
+```
+dist/ everything that ships (this folder is copied into the image)
+ index.html semantic markup, skip link, landmarks
+ style.css design tokens + cascade layers (see the header comment)
+ js/
+ catalog.js product data; pure filter / sort / URL-state functions
+ cart.js pure bag logic (cents, limits, sanitising) + storage load/save
+ format.js money and plural helpers (integer cents, cached Intl formatters)
+ dom.js html`` template tag that escapes every interpolation
+ main.js wires the modules to the page; one delegated click handler
+tests/ browser test suite (not shipped)
+Dockerfile, Caddyfile, compose.yaml
+```
+
+## Run
+
+```sh
+docker compose up -d --build # http://127.0.0.1:8088, see DOCKER.md
+```
+
+or, for quick edits, serve `dist/` with anything static: `python3 -m http.server -d dist`.
+
+## Test
+
+```sh
+tests/run.sh # headless Chrome; needs python3 and Google Chrome
+```
+
+Or open `tests/` through any static server to see the results in a browser. There is nothing to
+install. The suite has unit tests for the pure modules and integration tests that load the real
+page in an iframe and use it like a visitor would: filtering, sorting, shareable URLs, the bag,
+quantity limits, cross-tab sync, checkout, dialogs, and accessibility and overflow checks.
+
+## Design notes
+
+- **State and URL.** The category and sort live in the URL (`?category=Camp&sort=low`), so views
+ are shareable and survive a reload. Invalid values fall back to defaults.
+- **Money is integer cents** everywhere, formatted once at the edge.
+- **The bag** is `{ productId: quantity }` in `localStorage` (key unchanged from v1, so existing
+ bags carry over). Loading sanitises it, quantities are capped at 9, and it syncs across tabs.
+- **No injection.** Every interpolated value in a template is escaped; `mount()` refuses plain
+ strings. Combined with the Content-Security-Policy in the Caddyfile (no inline script or style),
+ injected markup can neither render nor run.
+- **Accessibility.** Skip link, landmarks, labelled dialogs, focus kept on the control you used,
+ announcements for bag changes, focus rings with at least 3:1 contrast on every surface, control
+ borders at 3:1, and no touch target under 24px.
+- **Styling.** One token set (colour, type scale, space, shape, motion) in cascade layers. Pills
+ are for navigation and toggles, square corners for actions and inputs.
+
+## Known limits
+
+- Products are rendered by JavaScript; without it the page shows a notice instead of the shop.
+- Assets are not fingerprinted, so they are cached for only five minutes.
+- Mouse-sized controls are 40px; they grow to 44px on touch screens.
diff --git a/tests/app.test.js b/tests/app.test.js
new file mode 100644
index 0000000..3d9fca8
--- /dev/null
+++ b/tests/app.test.js
@@ -0,0 +1,237 @@
+/**
+ * Integration tests: load the real page in a same-origin iframe and drive it like a user.
+ * Each test gets a fresh page and an empty bag.
+ */
+import { STORAGE_KEY } from "../dist/js/cart.js";
+import { assert, equal, sleep, test } from "./harness.js";
+
+async function withApp(fn, { search = "", stored, width = 1200 } = {}) {
+ localStorage.removeItem(STORAGE_KEY);
+ if (stored !== undefined) {
+ localStorage.setItem(STORAGE_KEY, typeof stored === "string" ? stored : JSON.stringify(stored));
+ }
+ const frame = document.createElement("iframe");
+ frame.style.width = `${width}px`;
+ const loaded = new Promise((resolve) => frame.addEventListener("load", resolve, { once: true }));
+ frame.src = `../dist/index.html${search}`;
+ document.body.append(frame);
+ await loaded;
+
+ const { contentWindow: win, contentDocument: doc } = frame;
+ const $ = (selector) => doc.querySelector(selector);
+ const app = {
+ win,
+ doc,
+ $,
+ $$: (selector) => [...doc.querySelectorAll(selector)],
+ click(target) {
+ const el = typeof target === "string" ? $(target) : target;
+ assert(el, `nothing matches ${target}`);
+ el.click();
+ },
+ names: () => [...doc.querySelectorAll(".product h3")].map((h) => h.textContent),
+ prices: () => [...doc.querySelectorAll(".product .price")].map((p) => p.textContent),
+ count: () => $("#count").textContent,
+ stored: () => JSON.parse(localStorage.getItem(STORAGE_KEY)),
+ addButton: (index = 0) => doc.querySelectorAll(".product [data-action=add]")[index],
+ };
+ try {
+ await fn(app);
+ } finally {
+ frame.remove();
+ localStorage.removeItem(STORAGE_KEY);
+ }
+}
+const scenario = (name, fn, options) => test(`app: ${name}`, () => withApp(fn, options));
+
+/* ------------------------------------------------------------------ browsing */
+
+scenario("renders the whole catalogue with sensible defaults", async (a) => {
+ equal(a.names(), [
+ "Ridgeline Pack",
+ "Camp Enamel Mug",
+ "Switchback Cap",
+ "Alpine Flask",
+ "Afterglow Lantern",
+ "Meadow Blanket",
+ ]);
+ equal(a.prices(), ["$86", "$24", "$32", "$38", "$54", "$68"]);
+ equal(a.$("#results").textContent, "6 products shown");
+ equal(a.count(), "0");
+ equal(a.$("#filters [aria-pressed=true]").textContent, "All");
+});
+
+scenario("filtering updates the grid, pressed state, announcement and URL", async (a) => {
+ a.click('#filters [data-value="Camp"]');
+ equal(a.names(), ["Camp Enamel Mug", "Afterglow Lantern", "Meadow Blanket"]);
+ equal(a.$("#results").textContent, "3 products shown");
+ equal(a.$$("#filters [aria-pressed=true]").map((b) => b.textContent), ["Camp"]);
+ equal(a.win.location.search, "?category=Camp");
+ a.click('#filters [data-value="All"]');
+ equal(a.win.location.search, "", "default view leaves the URL clean");
+});
+
+scenario("sorting by price reorders the grid and updates the URL", async (a) => {
+ a.$("#sort").value = "low";
+ a.$("#sort").dispatchEvent(new Event("change", { bubbles: true }));
+ equal(a.prices(), ["$24", "$32", "$38", "$54", "$68", "$86"]);
+ equal(a.win.location.search, "?sort=low");
+ a.$("#sort").value = "high";
+ a.$("#sort").dispatchEvent(new Event("change", { bubbles: true }));
+ equal(a.prices(), ["$86", "$68", "$54", "$38", "$32", "$24"]);
+});
+
+scenario("the view is restored from the URL (shareable links)", async (a) => {
+ equal(a.names(), ["Switchback Cap"]);
+ equal(a.$("#sort").value, "high");
+ equal(a.$("#filters [aria-pressed=true]").textContent, "Wear");
+}, { search: "?category=Wear&sort=high" });
+
+scenario("garbage in the URL is ignored", async (a) => {
+ equal(a.names().length, 6);
+ equal(a.$("#sort").value, "featured");
+}, { search: "?category="}
`),
+ "<script>alert(1)</script>
",
+ );
+});
+
+test("dom: escapes quotes so values cannot break out of attributes", () => {
+ const out = String(html`x`);
+ assert(!out.includes('" onmouseover'), `attribute escaped: ${out}`);
+ equal(escapeHtml(`&<>"'`), "&<>"'");
+});
+
+test("dom: nested templates are not double-escaped", () => {
+ const list = html`${[1, 2].map((n) => html`- ${n}
`)}
`;
+ equal(String(list), "");
+});
+
+test("dom: plain strings inside arrays are still escaped", () => {
+ equal(String(html`${["", html``]}`), "<b>");
+});
+
+test("dom: raw() passes trusted markup through", () => {
+ equal(String(html``), "");
+});
+
+test("dom: null/undefined render nothing, false and 0 render as text", () => {
+ equal(String(html`[${null}][${undefined}][${false}][${0}]`), "[][][false][0]");
+});
+
+test("dom: mount refuses plain strings", async () => {
+ const el = document.createElement("div");
+ await throws(() => mount(el, "
"), /html`/);
+ equal(el.innerHTML, "");
+});
+
+test("dom: injected markup never becomes live elements", () => {
+ const el = document.createElement("div");
+ window.__pwned = false;
+ mount(el, html`${"
"}
`);
+ assert(el.querySelector("img") === null, "no
element was created");
+ assert(window.__pwned === false, "no handler ran");
+ equal(el.textContent, "
");
+});
diff --git a/tests/format.test.js b/tests/format.test.js
new file mode 100644
index 0000000..0bc0f58
--- /dev/null
+++ b/tests/format.test.js
@@ -0,0 +1,26 @@
+import { money, plural } from "../dist/js/format.js";
+import { equal, test } from "./harness.js";
+
+test("format: whole-dollar amounts drop the cents", () => {
+ equal(money(8600), "$86");
+ equal(money(0), "$0");
+});
+
+test("format: other amounts show exact cents", () => {
+ equal(money(8650), "$86.50");
+ equal(money(99), "$0.99");
+ equal(money(123456), "$1,234.56");
+});
+
+test("format: integer cents sum exactly (no float drift)", () => {
+ // 0.1 + 0.2 style errors cannot happen with integer cents
+ equal(money(10 + 20), "$0.30");
+ equal(money(3 * 2400 + 8600), "$158");
+});
+
+test("format: plural", () => {
+ equal(plural(1, "item"), "1 item");
+ equal(plural(0, "item"), "0 items");
+ equal(plural(3, "item"), "3 items");
+ equal(plural(2, "box", "boxes"), "2 boxes");
+});
diff --git a/tests/harness.js b/tests/harness.js
new file mode 100644
index 0000000..542b178
--- /dev/null
+++ b/tests/harness.js
@@ -0,0 +1,52 @@
+/** A minimal test harness: no dependencies, runs in any browser. */
+
+const tests = [];
+
+export const test = (name, fn) => tests.push({ name, fn });
+
+export function assert(condition, message = "assertion failed") {
+ if (!condition) throw new Error(message);
+}
+
+export function equal(actual, expected, message = "") {
+ const a = JSON.stringify(actual);
+ const e = JSON.stringify(expected);
+ if (a !== e) throw new Error(`${message ? `${message}: ` : ""}expected ${e}, got ${a}`);
+}
+
+/** Assert that `fn` throws (sync or async); optionally that its message matches `pattern`. */
+export async function throws(fn, pattern) {
+ try {
+ await fn();
+ } catch (error) {
+ if (pattern && !pattern.test(String(error && error.message))) {
+ throw new Error(`threw "${error.message}", expected to match ${pattern}`);
+ }
+ return;
+ }
+ throw new Error("expected a throw, but nothing was thrown");
+}
+
+export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+export async function runAll() {
+ const log = document.getElementById("log");
+ let failed = 0;
+ for (const { name, fn } of tests) {
+ const item = document.createElement("li");
+ try {
+ await fn();
+ item.className = "pass";
+ item.textContent = name;
+ } catch (error) {
+ failed++;
+ item.className = "fail";
+ item.textContent = `${name} -- ${error && error.message}`;
+ console.error(name, error);
+ }
+ log.append(item);
+ }
+ const summary = document.getElementById("summary");
+ summary.textContent = `${failed ? "FAIL" : "PASS"} ${tests.length - failed}/${tests.length} tests`;
+ document.documentElement.dataset.result = failed ? "fail" : "pass";
+}
diff --git a/tests/index.html b/tests/index.html
new file mode 100644
index 0000000..af52030
--- /dev/null
+++ b/tests/index.html
@@ -0,0 +1,21 @@
+
+
+
+
+
+Fieldwork tests
+
+
+
+Fieldwork tests
+Running…
+
+
+
+
diff --git a/tests/run.js b/tests/run.js
new file mode 100644
index 0000000..29835aa
--- /dev/null
+++ b/tests/run.js
@@ -0,0 +1,8 @@
+import { runAll } from "./harness.js";
+import "./dom.test.js";
+import "./format.test.js";
+import "./catalog.test.js";
+import "./cart.test.js";
+import "./app.test.js";
+
+await runAll();
diff --git a/tests/run.sh b/tests/run.sh
new file mode 100755
index 0000000..fea4ef6
--- /dev/null
+++ b/tests/run.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+# Run the browser test suite headlessly and exit non-zero on any failure.
+# Needs python3 (static server) and Google Chrome; override with CHROME=/path/to/chrome.
+# To see the results in a browser instead: python3 -m http.server, then open /tests/
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+CHROME="${CHROME:-/Applications/Google Chrome.app/Contents/MacOS/Google Chrome}"
+PORT="${PORT:-8765}"
+PROFILE="$(mktemp -d)"
+
+python3 -m http.server "$PORT" --bind 127.0.0.1 >/dev/null 2>&1 &
+SERVER=$!
+trap 'kill "$SERVER" 2>/dev/null || true; rm -rf "$PROFILE"' EXIT
+sleep 1
+
+DOM="$("$CHROME" --headless=new --no-sandbox --disable-gpu --user-data-dir="$PROFILE" \
+ --virtual-time-budget=30000 --dump-dom "http://127.0.0.1:$PORT/tests/" 2>/dev/null)"
+
+printf '%s\n' "$DOM" | grep -o '[^<]*' \
+ | sed -e 's// ok /' -e 's// FAIL /'
+printf '%s\n' "$DOM" | grep -o 'id="summary">[^<]*' | sed 's/.*>//'
+printf '%s\n' "$DOM" | grep -q 'data-result="pass"'