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:
2026-11-07 21:18:38 -07:00
parent c4c07cbc16
commit a15d0d37b1
10 changed files with 652 additions and 0 deletions
+237
View File
@@ -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`);
});
+102
View File
@@ -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 });
});
+75
View File
@@ -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}`);
}
}
});
+47
View File
@@ -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>&lt;script&gt;alert(1)&lt;/script&gt;</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(`&<>"'`), "&amp;&lt;&gt;&quot;&#39;");
});
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>`]}`), "&lt;b&gt;<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>");
});
+26
View File
@@ -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");
});
+52
View File
@@ -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";
}
+21
View File
@@ -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>
+8
View File
@@ -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
View File
@@ -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"'