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).
This commit is contained in:
@@ -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>&sort=evil" });
|
||||
|
||||
/* ------------------------------------------------------------------ the bag */
|
||||
|
||||
scenario("adding to the bag updates count, label, storage and the toast", async (a) => {
|
||||
a.addButton(0).click();
|
||||
equal(a.count(), "1");
|
||||
equal(a.$("#bag").getAttribute("aria-label"), "Open bag, 1 item");
|
||||
equal(a.stored(), { pack: 1 });
|
||||
assert(/Ridgeline Pack added/.test(a.$("#toast").textContent), a.$("#toast").textContent);
|
||||
a.addButton(0).click();
|
||||
equal(a.$("#bag").getAttribute("aria-label"), "Open bag, 2 items");
|
||||
});
|
||||
|
||||
scenario("bag: quantities, subtotal, focus handling and the empty state", async (a) => {
|
||||
a.addButton(0).click();
|
||||
a.click("#bag");
|
||||
assert(a.$("#cart").open, "the drawer opens");
|
||||
equal(a.$("#subtotal").textContent, "$86");
|
||||
|
||||
a.click('[data-action=adjust][data-step="1"]');
|
||||
equal(a.$(".qty span").textContent, "2");
|
||||
equal(a.$("#subtotal").textContent, "$172");
|
||||
assert(a.doc.activeElement.matches('[data-step="1"]'), "focus stays on the + control");
|
||||
assert(/Ridgeline Pack: 2 in bag/.test(a.$("#cart-status").textContent), "change is announced");
|
||||
|
||||
a.click('[data-action=adjust][data-step="-1"]');
|
||||
a.click('[data-action=adjust][data-step="-1"]');
|
||||
assert(a.$(".empty"), "empty message appears");
|
||||
assert(a.$("#cart-summary").hidden, "checkout is hidden when empty");
|
||||
assert(a.doc.activeElement.matches(".close"), "focus moves to a safe control when the row is gone");
|
||||
equal(localStorage.getItem(STORAGE_KEY), null, "empty bag clears storage");
|
||||
});
|
||||
|
||||
scenario("bag: quantity is capped at 9 and the limit is explained", async (a) => {
|
||||
for (let i = 0; i < 9; i++) a.addButton(0).click();
|
||||
equal(a.count(), "9");
|
||||
a.addButton(0).click();
|
||||
equal(a.count(), "9");
|
||||
assert(/Limit of 9/.test(a.$("#toast").textContent), a.$("#toast").textContent);
|
||||
equal(a.stored(), { pack: 9 });
|
||||
a.click("#bag");
|
||||
assert(a.$('[data-step="1"]').disabled, "+ is disabled at the limit");
|
||||
});
|
||||
|
||||
scenario("bag persists across reloads", async (a) => {
|
||||
equal(a.count(), "2");
|
||||
equal(a.$("#bag").getAttribute("aria-label"), "Open bag, 2 items");
|
||||
}, { stored: { mug: 2 } });
|
||||
|
||||
scenario("corrupt or hostile stored data is ignored", async (a) => {
|
||||
equal(a.count(), "0");
|
||||
}, { stored: '{"pack":-3,"ghost":4,"mug":"x"}' });
|
||||
|
||||
scenario("unparseable stored data is ignored", async (a) => {
|
||||
equal(a.count(), "0");
|
||||
}, { stored: "not json{" });
|
||||
|
||||
scenario("the bag stays in sync with other tabs", async (a) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ lantern: 2 })); // as another tab would
|
||||
await sleep(100);
|
||||
equal(a.count(), "2");
|
||||
});
|
||||
|
||||
scenario("checkout places a demo order, clears the bag, and resets on reopen", async (a) => {
|
||||
a.addButton(0).click(); // pack $86
|
||||
a.addButton(1).click(); // mug $24 x2
|
||||
a.addButton(1).click();
|
||||
a.click("#bag");
|
||||
a.click("#checkout");
|
||||
const order = a.$("#order");
|
||||
assert(!order.hidden, "confirmation is visible");
|
||||
assert(/^Demo order FW-\d{5} placed: 3 items, \$134\./.test(order.textContent), order.textContent);
|
||||
equal(a.count(), "0");
|
||||
equal(localStorage.getItem(STORAGE_KEY), null);
|
||||
assert(a.$("#cart-items").hidden, "line items are hidden");
|
||||
equal(a.doc.activeElement.id, "order", "focus moves to the confirmation");
|
||||
|
||||
a.$("#cart").close();
|
||||
a.click("#bag");
|
||||
assert(a.$("#order").hidden && !a.$("#cart-items").hidden, "reopening starts fresh");
|
||||
assert(a.$(".empty"));
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ dialogs */
|
||||
|
||||
scenario("product dialog: details, descriptive alt text, add-to-bag closes it", async (a) => {
|
||||
a.click(".product-image");
|
||||
const dialog = a.$("#product-dialog");
|
||||
assert(dialog.open, "dialog opens");
|
||||
equal(a.$("#product-title").textContent, "Ridgeline Pack");
|
||||
assert(a.$("#product-detail img").alt.length > 15, "detail image has real alt text");
|
||||
assert(a.$$(".product img").every((img) => img.alt === ""), "grid images are decorative");
|
||||
a.click("#product-detail [data-action=add]");
|
||||
assert(!dialog.open, "dialog closes after adding");
|
||||
equal(a.count(), "1");
|
||||
});
|
||||
|
||||
scenario("clicking the backdrop closes a dialog, clicking inside does not", async (a) => {
|
||||
a.click(".product-image");
|
||||
const dialog = a.$("#product-dialog");
|
||||
const box = dialog.getBoundingClientRect();
|
||||
const click = (x, y) =>
|
||||
dialog.dispatchEvent(new a.win.MouseEvent("click", { bubbles: true, clientX: x, clientY: y }));
|
||||
click(box.left + 10, box.top + 10);
|
||||
assert(dialog.open, "a click inside keeps it open");
|
||||
click(2, 2);
|
||||
assert(!dialog.open, "a click on the backdrop closes it");
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ quality gates */
|
||||
|
||||
scenario("accessibility basics: names, alt text, landmarks, headings, dialogs", async (a) => {
|
||||
for (const el of a.$$("button, a[href], select")) {
|
||||
const name =
|
||||
el.getAttribute("aria-label") || el.textContent.trim() || el.labels?.[0]?.textContent.trim();
|
||||
assert(name, `unnamed control: ${el.outerHTML.slice(0, 70)}`);
|
||||
}
|
||||
assert(a.$$("img").every((img) => img.hasAttribute("alt")), "every image has an alt attribute");
|
||||
equal(a.$$("h1").length, 1, "exactly one h1");
|
||||
for (const tag of ["header", "nav", "main", "footer"]) assert(a.$(tag), `<${tag}> landmark`);
|
||||
equal(a.doc.documentElement.lang, "en");
|
||||
});
|
||||
|
||||
scenario("every dialog is labelled by a real element while it is open", async (a) => {
|
||||
const labelled = (dialog) => {
|
||||
const label = a.doc.getElementById(dialog.getAttribute("aria-labelledby"));
|
||||
return label && label.textContent.trim();
|
||||
};
|
||||
a.click(".product-image");
|
||||
assert(labelled(a.$("#product-dialog")), "product dialog has an accessible name");
|
||||
a.$("#product-dialog").close();
|
||||
a.click("#bag");
|
||||
equal(labelled(a.$("#cart")), "Your bag");
|
||||
});
|
||||
|
||||
scenario("no horizontal overflow on a 320px phone", async (a) => {
|
||||
const { scrollWidth } = a.doc.documentElement;
|
||||
assert(scrollWidth <= 320, `page is ${scrollWidth}px wide`);
|
||||
}, { width: 320 });
|
||||
|
||||
scenario("no horizontal overflow on a desktop", async (a) => {
|
||||
const { scrollWidth } = a.doc.documentElement;
|
||||
assert(scrollWidth <= 1200, `page is ${scrollWidth}px wide`);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { PRODUCTS } from "../dist/js/catalog.js";
|
||||
import {
|
||||
MAX_QTY,
|
||||
STORAGE_KEY,
|
||||
cartCount,
|
||||
cartLines,
|
||||
cartSubtotal,
|
||||
changeQty,
|
||||
loadCart,
|
||||
sanitizeCart,
|
||||
saveCart,
|
||||
} from "../dist/js/cart.js";
|
||||
import { assert, equal, test } from "./harness.js";
|
||||
|
||||
const ids = PRODUCTS.map((p) => p.id);
|
||||
|
||||
function fakeStorage(initial = {}) {
|
||||
const data = { ...initial };
|
||||
return {
|
||||
data,
|
||||
getItem: (k) => (k in data ? data[k] : null),
|
||||
setItem: (k, v) => void (data[k] = String(v)),
|
||||
removeItem: (k) => void delete data[k],
|
||||
};
|
||||
}
|
||||
const broken = {
|
||||
getItem() { throw new Error("blocked"); },
|
||||
setItem() { throw new Error("quota"); },
|
||||
removeItem() { throw new Error("blocked"); },
|
||||
};
|
||||
|
||||
test("cart: the storage key is unchanged, so existing bags carry over", () => {
|
||||
equal(STORAGE_KEY, "fieldwork-bag-v1");
|
||||
});
|
||||
|
||||
test("cart: sanitizeCart keeps valid lines", () => {
|
||||
equal(sanitizeCart({ pack: 2, mug: 1 }, ids), { pack: 2, mug: 1 });
|
||||
});
|
||||
|
||||
test("cart: sanitizeCart drops invalid quantities and unknown products", () => {
|
||||
const dirty = { pack: -3, mug: 0, cap: 1.5, bottle: "2", lantern: NaN, blanket: null, ghost: 4 };
|
||||
equal(sanitizeCart(dirty, ids), {});
|
||||
equal(sanitizeCart({ pack: Infinity }, ids), {});
|
||||
});
|
||||
|
||||
test("cart: sanitizeCart clamps oversized quantities", () => {
|
||||
equal(sanitizeCart({ pack: 500 }, ids), { pack: MAX_QTY });
|
||||
});
|
||||
|
||||
test("cart: sanitizeCart rejects non-objects and cannot be prototype-polluted", () => {
|
||||
for (const bad of [null, undefined, [], "x", 5, true]) equal(sanitizeCart(bad, ids), {});
|
||||
const polluted = JSON.parse('{"__proto__":{"pack":3},"constructor":{"mug":2}}');
|
||||
equal(sanitizeCart(polluted, ids), {});
|
||||
assert(({}).pack === undefined, "Object.prototype untouched");
|
||||
});
|
||||
|
||||
test("cart: changeQty adds, removes at zero, and clamps to the limit", () => {
|
||||
let cart = changeQty({}, "pack", 1);
|
||||
equal(cart, { pack: 1 });
|
||||
cart = changeQty(cart, "pack", 1);
|
||||
equal(cart, { pack: 2 });
|
||||
cart = changeQty(cart, "pack", -2);
|
||||
equal(cart, {}, "line removed at zero");
|
||||
equal(changeQty({}, "pack", -1), {}, "cannot go negative");
|
||||
equal(changeQty({ pack: MAX_QTY }, "pack", 1), { pack: MAX_QTY }, "limit holds");
|
||||
});
|
||||
|
||||
test("cart: changeQty is immutable", () => {
|
||||
const original = Object.freeze({ pack: 1 });
|
||||
const next = changeQty(original, "mug", 1);
|
||||
equal(original, { pack: 1 });
|
||||
assert(next !== original);
|
||||
});
|
||||
|
||||
test("cart: counts, lines and subtotal (exact, in cents, in catalogue order)", () => {
|
||||
const cart = { mug: 2, pack: 1 }; // inserted out of catalogue order
|
||||
equal(cartCount(cart), 3);
|
||||
equal(cartLines(cart, PRODUCTS).map((l) => [l.product.id, l.qty, l.total]), [
|
||||
["pack", 1, 8600],
|
||||
["mug", 2, 4800],
|
||||
]);
|
||||
equal(cartSubtotal(cart, PRODUCTS), 13400);
|
||||
equal(cartSubtotal({}, PRODUCTS), 0);
|
||||
});
|
||||
|
||||
test("cart: loadCart reads, sanitises, and survives bad storage", () => {
|
||||
equal(loadCart(fakeStorage({ [STORAGE_KEY]: '{"pack":2}' }), ids), { pack: 2 });
|
||||
equal(loadCart(fakeStorage({ [STORAGE_KEY]: "not json{" }), ids), {}, "corrupt JSON");
|
||||
equal(loadCart(fakeStorage(), ids), {}, "nothing saved");
|
||||
equal(loadCart(broken, ids), {}, "storage throws");
|
||||
equal(loadCart(null, ids), {}, "storage unavailable");
|
||||
});
|
||||
|
||||
test("cart: saveCart writes, clears when empty, and never throws", () => {
|
||||
const storage = fakeStorage();
|
||||
saveCart(storage, { pack: 1 });
|
||||
equal(JSON.parse(storage.data[STORAGE_KEY]), { pack: 1 });
|
||||
saveCart(storage, {});
|
||||
assert(!(STORAGE_KEY in storage.data), "empty cart removes the key");
|
||||
saveCart(broken, { pack: 1 });
|
||||
saveCart(null, { pack: 1 });
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
CATEGORIES,
|
||||
PRODUCTS,
|
||||
SORTS,
|
||||
queryProducts,
|
||||
readView,
|
||||
viewToSearch,
|
||||
} from "../dist/js/catalog.js";
|
||||
import { assert, equal, test } from "./harness.js";
|
||||
|
||||
test("catalog: data is frozen and well formed", () => {
|
||||
assert(Object.isFrozen(PRODUCTS) && PRODUCTS.every(Object.isFrozen), "frozen");
|
||||
equal(new Set(PRODUCTS.map((p) => p.id)).size, PRODUCTS.length, "unique ids");
|
||||
for (const p of PRODUCTS) {
|
||||
assert(Number.isInteger(p.price) && p.price > 0, `${p.id}: price is positive integer cents`);
|
||||
assert(p.name && p.detail && p.alt, `${p.id}: has name, detail and alt text`);
|
||||
assert(CATEGORIES.includes(p.category), `${p.id}: category is listed`);
|
||||
}
|
||||
});
|
||||
|
||||
test("catalog: every product has its illustration on the server", async () => {
|
||||
for (const p of PRODUCTS) {
|
||||
const response = await fetch(`../dist/${p.id}.svg`);
|
||||
assert(response.ok, `${p.id}.svg returned ${response.status}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("catalog: categories keep first-appearance order, All first", () => {
|
||||
equal(CATEGORIES, ["All", "Carry", "Camp", "Wear"]);
|
||||
});
|
||||
|
||||
test("catalog: queryProducts filters by category", () => {
|
||||
const camp = queryProducts(PRODUCTS, { category: "Camp" });
|
||||
equal(camp.map((p) => p.id), ["mug", "lantern", "blanket"]);
|
||||
equal(queryProducts(PRODUCTS, { category: "Nope" }), []);
|
||||
});
|
||||
|
||||
test("catalog: queryProducts sorts by price, low and high", () => {
|
||||
const low = queryProducts(PRODUCTS, { sort: "low" }).map((p) => p.price);
|
||||
const high = queryProducts(PRODUCTS, { sort: "high" }).map((p) => p.price);
|
||||
equal(low, [...low].sort((a, b) => a - b));
|
||||
equal(high, [...high].sort((a, b) => b - a));
|
||||
equal(queryProducts(PRODUCTS).map((p) => p.id), PRODUCTS.map((p) => p.id), "featured order");
|
||||
});
|
||||
|
||||
test("catalog: queryProducts never mutates its input and keeps ties in order", () => {
|
||||
const input = [
|
||||
{ id: "a", price: 100, category: "X" },
|
||||
{ id: "b", price: 100, category: "X" },
|
||||
{ id: "c", price: 50, category: "X" },
|
||||
];
|
||||
const before = JSON.stringify(input);
|
||||
const out = queryProducts(input, { sort: "low" });
|
||||
equal(out.map((p) => p.id), ["c", "a", "b"], "stable sort");
|
||||
assert(out !== input && JSON.stringify(input) === before, "input untouched");
|
||||
});
|
||||
|
||||
test("catalog: readView falls back to defaults for bad input", () => {
|
||||
const defaults = { category: "All", sort: "featured" };
|
||||
equal(readView(""), defaults);
|
||||
equal(readView("?category=Nope&sort=evil"), defaults);
|
||||
equal(readView("?sort=toString"), defaults, "prototype keys are not valid sorts");
|
||||
equal(readView("?category=Camp&sort=low"), { category: "Camp", sort: "low" });
|
||||
});
|
||||
|
||||
test("catalog: viewToSearch omits defaults and round-trips every combination", () => {
|
||||
equal(viewToSearch({ category: "All", sort: "featured" }), "");
|
||||
equal(viewToSearch({ category: "Camp", sort: "featured" }), "?category=Camp");
|
||||
for (const category of CATEGORIES) {
|
||||
for (const sort of Object.keys(SORTS)) {
|
||||
const view = { category, sort };
|
||||
equal(readView(viewToSearch(view)), view, `${category}/${sort}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { escapeHtml, html, mount, raw } from "../dist/js/dom.js";
|
||||
import { assert, equal, test, throws } from "./harness.js";
|
||||
|
||||
test("dom: escapes markup in interpolated text", () => {
|
||||
equal(
|
||||
String(html`<p>${"<script>alert(1)</script>"}</p>`),
|
||||
"<p><script>alert(1)</script></p>",
|
||||
);
|
||||
});
|
||||
|
||||
test("dom: escapes quotes so values cannot break out of attributes", () => {
|
||||
const out = String(html`<a title="${'" onmouseover="alert(1)'}">x</a>`);
|
||||
assert(!out.includes('" onmouseover'), `attribute escaped: ${out}`);
|
||||
equal(escapeHtml(`&<>"'`), "&<>"'");
|
||||
});
|
||||
|
||||
test("dom: nested templates are not double-escaped", () => {
|
||||
const list = html`<ul>${[1, 2].map((n) => html`<li>${n}</li>`)}</ul>`;
|
||||
equal(String(list), "<ul><li>1</li><li>2</li></ul>");
|
||||
});
|
||||
|
||||
test("dom: plain strings inside arrays are still escaped", () => {
|
||||
equal(String(html`${["<b>", html`<i></i>`]}`), "<b><i></i>");
|
||||
});
|
||||
|
||||
test("dom: raw() passes trusted markup through", () => {
|
||||
equal(String(html`<p ${raw("hidden")}></p>`), "<p hidden></p>");
|
||||
});
|
||||
|
||||
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, "<img src=x onerror=1>"), /html`/);
|
||||
equal(el.innerHTML, "");
|
||||
});
|
||||
|
||||
test("dom: injected markup never becomes live elements", () => {
|
||||
const el = document.createElement("div");
|
||||
window.__pwned = false;
|
||||
mount(el, html`<p>${"<img src=x onerror=window.__pwned=true>"}</p>`);
|
||||
assert(el.querySelector("img") === null, "no <img> element was created");
|
||||
assert(window.__pwned === false, "no handler ran");
|
||||
equal(el.textContent, "<img src=x onerror=window.__pwned=true>");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Fieldwork tests</title>
|
||||
<style>
|
||||
body { font: 14px/1.6 ui-monospace, Menlo, monospace; margin: 2rem; color: #1d2924; background: #f7f5ed; }
|
||||
#summary { font-size: 1.25rem; font-weight: 700; margin: 1rem 0; }
|
||||
li { list-style: none; } li.pass::before { content: "ok "; color: #1a7f37; }
|
||||
li.fail { color: #cf222e; font-weight: 700; } li.fail::before { content: "FAIL "; }
|
||||
iframe { position: fixed; right: 0; bottom: 0; width: 1200px; height: 900px; border: 0; opacity: 0; pointer-events: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Fieldwork tests</h1>
|
||||
<p id="summary">Running…</p>
|
||||
<ul id="log"></ul>
|
||||
<script type="module" src="run.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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();
|
||||
Executable
+23
@@ -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 '<li class="[a-z]*">[^<]*' \
|
||||
| sed -e 's/<li class="pass">/ ok /' -e 's/<li class="fail">/ FAIL /'
|
||||
printf '%s\n' "$DOM" | grep -o 'id="summary">[^<]*' | sed 's/.*>//'
|
||||
printf '%s\n' "$DOM" | grep -q 'data-result="pass"'
|
||||
Reference in New Issue
Block a user