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``; + 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"'