diff --git a/package.json b/package.json index 3424ca4..e103df0 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "preview": "astro preview", "astro": "astro", "build:icon-data": "node scripts/build-icon-data.mjs", - "check": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-catalog.mjs && node --disable-warning=ExperimentalWarning scripts/check-business-hours.mjs && node scripts/check-news-ticker.mjs && node --disable-warning=ExperimentalWarning scripts/check-flip-box.mjs && node scripts/check-social-grid.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-four.mjs && node --disable-warning=ExperimentalWarning scripts/check-video-player.mjs && node scripts/check-cookie-consent.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-icon.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-animated-background.mjs && node --disable-warning=ExperimentalWarning scripts/check-modal-and-bar.mjs && node --disable-warning=ExperimentalWarning scripts/check-reveal-cards.mjs && node --disable-warning=ExperimentalWarning scripts/check-time-and-toggles.mjs && node --disable-warning=ExperimentalWarning scripts/check-media-and-links.mjs && node --disable-warning=ExperimentalWarning scripts/check-structure.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-feedback.mjs && node --disable-warning=ExperimentalWarning scripts/check-controls.mjs && node scripts/check-small-pieces.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-categories.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-mega-menu.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-styles.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-type.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-palettes.mjs" + "check": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-catalog.mjs && node --disable-warning=ExperimentalWarning scripts/check-business-hours.mjs && node scripts/check-news-ticker.mjs && node --disable-warning=ExperimentalWarning scripts/check-flip-box.mjs && node scripts/check-social-grid.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-four.mjs && node --disable-warning=ExperimentalWarning scripts/check-video-player.mjs && node scripts/check-cookie-consent.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-icon.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-animated-background.mjs && node --disable-warning=ExperimentalWarning scripts/check-modal-and-bar.mjs && node --disable-warning=ExperimentalWarning scripts/check-reveal-cards.mjs && node --disable-warning=ExperimentalWarning scripts/check-time-and-toggles.mjs && node --disable-warning=ExperimentalWarning scripts/check-media-and-links.mjs && node --disable-warning=ExperimentalWarning scripts/check-structure.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-feedback.mjs && node --disable-warning=ExperimentalWarning scripts/check-controls.mjs && node scripts/check-small-pieces.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-categories.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-mega-menu.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-styles.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-type.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-palettes.mjs && node --disable-warning=ExperimentalWarning scripts/check-shop.mjs" }, "dependencies": { "astro": "^7.2.1" diff --git a/public/demo/shop-candle.svg b/public/demo/shop-candle.svg new file mode 100644 index 0000000..3b3989f --- /dev/null +++ b/public/demo/shop-candle.svg @@ -0,0 +1 @@ + diff --git a/public/demo/shop-catalog.json b/public/demo/shop-catalog.json new file mode 100644 index 0000000..724de8b --- /dev/null +++ b/public/demo/shop-catalog.json @@ -0,0 +1,50 @@ +{ + "site": "demo-shop", + "open": true, + "currency": "USD", + "products": [ + { + "id": "ITEMMUG", + "slug": "enamel-mug", + "name": "Enamel mug", + "description": "A camp mug in steel with a glass enamel coat. Holds 12 oz (< 350 ml).\n\nHand wash only. The rim is rolled, so it stays cool to the lip.", + "images": ["shop-mug.svg", "shop-mug-side.svg"], + "variations": [ + { "id": "VARMUGCREAM", "name": "Cream", "price_cents": 1800, "tracked": true }, + { "id": "VARMUGSLATE", "name": "Slate", "price_cents": 2000, "tracked": true } + ] + }, + { + "id": "ITEMNOTEBOOK", + "slug": "field-notebook", + "name": "Field notebook", + "description": "Forty-eight pages of dot grid, stitched, in a card cover that fits a back pocket.", + "images": ["shop-notebook.svg"], + "variations": [ + { "id": "VARNOTEBOOK", "name": "Regular", "price_cents": 900, "tracked": false } + ] + }, + { + "id": "ITEMCANDLE", + "slug": "beeswax-candle", + "name": "Beeswax candle", + "description": "Poured by hand from local beeswax, with a cotton wick.\nSmall burns for about 20 hours, large for about 45.", + "images": ["shop-candle.svg"], + "variations": [ + { "id": "VARCANDLESM", "name": "Small", "price_cents": 1400, "tracked": true }, + { "id": "VARCANDLELG", "name": "Large", "price_cents": 2600, "tracked": true } + ] + }, + { + "id": "ITEMTOTE", + "slug": "canvas-tote", + "name": "Canvas tote", + "description": "Heavy cotton canvas with long handles and an inside pocket.", + "images": ["shop-tote.svg"], + "variations": [ + { "id": "VARTOTENAT", "name": "Natural", "price_cents": 2400, "tracked": true }, + { "id": "VARTOTEBLK", "name": "Black", "price_cents": 2400, "tracked": true } + ] + } + ] +} diff --git a/public/demo/shop-mug-side.svg b/public/demo/shop-mug-side.svg new file mode 100644 index 0000000..4558149 --- /dev/null +++ b/public/demo/shop-mug-side.svg @@ -0,0 +1 @@ + diff --git a/public/demo/shop-mug.svg b/public/demo/shop-mug.svg new file mode 100644 index 0000000..7a2f454 --- /dev/null +++ b/public/demo/shop-mug.svg @@ -0,0 +1 @@ + diff --git a/public/demo/shop-notebook.svg b/public/demo/shop-notebook.svg new file mode 100644 index 0000000..02b2e60 --- /dev/null +++ b/public/demo/shop-notebook.svg @@ -0,0 +1 @@ + diff --git a/public/demo/shop-stock.json b/public/demo/shop-stock.json new file mode 100644 index 0000000..f0d899c --- /dev/null +++ b/public/demo/shop-stock.json @@ -0,0 +1,9 @@ +{ + "VARMUGCREAM": 6, + "VARMUGSLATE": 2, + "VARNOTEBOOK": null, + "VARCANDLESM": 12, + "VARCANDLELG": 1, + "VARTOTENAT": 0, + "VARTOTEBLK": 0 +} diff --git a/public/demo/shop-tote.svg b/public/demo/shop-tote.svg new file mode 100644 index 0000000..ac0697b --- /dev/null +++ b/public/demo/shop-tote.svg @@ -0,0 +1 @@ + diff --git a/scripts/check-catalog.mjs b/scripts/check-catalog.mjs index 95cbfda..d9cf5d3 100644 --- a/scripts/check-catalog.mjs +++ b/scripts/check-catalog.mjs @@ -153,7 +153,7 @@ const NEEDS_INPUT = [ 'accordion', 'announcement-bar', 'before-after', 'business-hours', 'content-toggle', 'countdown', 'flip-box', 'hotspot', 'info-circle', 'info-list', 'map', 'modal', 'news-ticker', 'responsive-table', 'slide-box', 'social-grid', 'tabcordion', 'tabs', 'testimonial-carousel', - 'video-background', 'video-gallery', 'video-player', + 'video-background', 'video-gallery', 'video-player', 'cart', 'product-list', 'product-page', ]; const plainText = (v) => typeof v === 'string' && v.trim() === v && v.length > 0; for (const e of catalog) { diff --git a/scripts/check-categories.mjs b/scripts/check-categories.mjs index 4011279..04858ff 100644 --- a/scripts/check-categories.mjs +++ b/scripts/check-categories.mjs @@ -76,6 +76,9 @@ const GOLDEN = { 'content-toggle': 'conversion', 'news-ticker': 'conversion', sticker: 'conversion', + cart: 'conversion', + 'product-list': 'conversion', + 'product-page': 'conversion', notice: 'feedback', toast: 'feedback', diff --git a/scripts/check-shop.mjs b/scripts/check-shop.mjs new file mode 100644 index 0000000..ca1a881 --- /dev/null +++ b/scripts/check-shop.mjs @@ -0,0 +1,596 @@ +#!/usr/bin/env node +/** + * npm run check (after check-catalog.mjs, which builds dist/) — pins the three shop elements: + * cart, product-list and product-page, built to the Superhero Shop Worker's contract + * (ecropolis/SuperheroShop, README "The catalogue contract" and "The checkout contract"). + * + * cart, its rules: the `` block of src/library/cart/Cart.astro, types stripped, run + * against golden cases. The caps (20 lines, 1 to 20 of each), cleaning stored data, the display + * subtotal and shipping estimate, money in the currency's minor unit; then the checkout against + * a recording fake fetch: stock re-checked before the POST (and a short line stops it), the + * POST body exactly { items: [{ variationId, quantity }], fulfillment, pickupPoint?, + * idempotencyKey } with NO PRICE in any request, a fresh key per attempt, the 409 `short` + * clamp, 503 "This shop is closed for the moment.", a network failure as one sentence, only + * an https URL redirected to, and the other refusals named. + * cart, the built page: the trigger (hidden until the script, aria-haspopup="dialog", + * aria-controls on the drawer, aria-expanded), the drawer (a closed native named by + * its heading, opened with showModal(), focus back to the trigger on close), a named close + * button, exactly one aria-live per cart and none in the drawer, the mount script straight + * after each cart and the runtime once per page, every add button hidden and aimed at a cart + * on the page. The built runtime holds the same caps as the source. + * product-list: one link per card, images lazy and sized with alt="", the from-price, Sold out + * exactly where every tracked variation is at 0 (in the badge and in the link's name). + * product-page: the Product JSON-LD's shape (one Offer per variation, sku, price as a decimal + * string, currency, availability from stock, "<" escaped), the picker only with more than one + * variation, quantity 1..20, the add button hidden until the cart runs, the description as + * text, and on superherotech.ai's demo the structured data shown, never emitted. + * fetchCatalog: a good catalogue parses; a malformed one, a 503 and a dead network throw a + * sentence. The two demo stubs are one copy. + * + * Mutation test: every rule above runs again on deliberately broken copies (a price in the body, + * a reused key, the clamp undone, a cap moved, aria-live in the drawer, an Offer's price as a + * number…). Every mutant must be caught, or the check itself is broken. + * + * Exits 1 with one line per failure. + */ +import { existsSync, readFileSync } from 'node:fs'; +import { stripTypeScriptTypes } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const failures = []; +const finish = (stage) => { + if (!failures.length) return; + console.error(`check-shop failed (${stage}):`); + for (const f of failures) console.error(` - ${f}`); + process.exit(1); +}; +const read = (rel) => { + const p = join(root, rel); + if (!existsSync(p)) { + failures.push(`${rel} is missing; run astro build (check-catalog.mjs does) first.`); + return ''; + } + return readFileSync(p, 'utf8'); +}; +const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b); + +// ------------------------------------------------------------------ html helpers +const attr = (tag, name) => { + const m = tag.match(new RegExp(`\\s${name}(?:="([^"]*)"|(?=[\\s>/]))`)); + return m ? (m[1] ?? '') : undefined; +}; +const decode = (s = '') => + s + .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16))) + .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d))) + .replace(/"/g, '"') + .replace(/'|'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +const text = (s) => decode(s.replace(//g, '').replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ').trim(); +const block = (html, index, tag) => { + const re = new RegExp(`<\\/?${tag}\\b[^>]*>`, 'g'); + re.lastIndex = index; + let depth = 0; + for (let t; (t = re.exec(html)); ) { + depth += t[0].startsWith(' + [...html.matchAll(new RegExp(`<${tag}\\b[^>]*\\s${marker}(?=[\\s>=])[^>]*>`, 'g'))].map((m) => ({ open: m[0], index: m.index, html: block(html, m.index, tag) })); +const tags = (html, re) => [...html.matchAll(re)].map((m) => ({ open: m[0], index: m.index })); +const textOfId = (html, id) => { + const m = html.match(new RegExp(`<([a-z0-9]+)\\b[^>]*\\sid="${id}"[^>]*>`)); + return m ? text(block(html, m.index, m[1])) : null; +}; + +// ------------------------------------------------------------------ fixtures +const fixture = JSON.parse(read('public/demo/shop-catalog.json')); +const stock = JSON.parse(read('public/demo/shop-stock.json')); +finish('fixtures'); +for (const p of fixture.products) for (const img of p.images) if (!existsSync(join(root, 'public/demo', img))) failures.push(`public/demo/shop-catalog.json names image ${img}, which is not in public/demo/.`); +const known = new Set(fixture.products.flatMap((p) => p.variations.map((v) => v.id))); +const priceOf = Object.fromEntries(fixture.products.flatMap((p) => p.variations.map((v) => [v.id, v.price_cents]))); + +// =================================================================== cart: the rules +const CORE_NAMES = 'MAX_LINES, MAX_QTY, KEY_RE, sanitize, addLine, setQty, removeLine, count, subtotal, shipping, money, newKey, checkoutBody, applyStock, applyShort, checkout, MESSAGES'; +const cartSource = read('src/library/cart/Cart.astro'); +const coreOf = (source) => source.match(/\/\/ [^\n]*\n([\s\S]*?)\/\/ <\/cart-core>/)?.[1]; +const loadCore = async (core) => import(`data:text/javascript;base64,${Buffer.from(`${stripTypeScriptTypes(core, { mode: 'strip' })}\nexport { ${CORE_NAMES} };`).toString('base64')}`); + +/** A fake fetch that records every request and answers from `routes`. */ +function fakeFetch(routes) { + const calls = []; + const f = async (url, init = {}) => { + const call = { url, method: init.method ?? 'GET', body: init.body === undefined ? undefined : JSON.parse(init.body), headers: init.headers ?? {} }; + calls.push(call); + const kind = /\/stock\?/.test(url) ? 'stock' : /\/checkout$/.test(url) ? 'checkout' : 'other'; + const r = routes[kind]; + if (!r) throw new Error(`no route for ${url}`); + const answer = typeof r === 'function' ? r(call, calls) : r; + if (answer instanceof Error) throw answer; + return { ok: answer.status >= 200 && answer.status < 300, status: answer.status, json: async () => (answer.raw !== undefined ? JSON.parse(answer.raw) : answer.body) }; + }; + return { f, calls }; +} +/** Any key anywhere in a request body that could be a price. */ +const PRICEY = /price|cents|amount|total|cost|money/i; +const priceKeys = (v, path = '') => { + if (!v || typeof v !== 'object') return []; + return Object.entries(v).flatMap(([k, x]) => [...(PRICEY.test(k) ? [`${path}${k}`] : []), ...priceKeys(x, `${path}${k}.`)]); +}; +const okStock = () => ({ status: 200, body: stock }); +const oneSentence = (s) => typeof s === 'string' && /^[A-Z][^.!?]*[.!?]$/.test(s); + +async function coreFailures(core) { + const out = []; + if (!core) return ['src/library/cart/Cart.astro has no `// ` … `// ` block.']; + let c; + try { + c = await loadCore(core); + } catch (e) { + return [`the cart's block does not load: ${e.message}`]; + } + const expect = (cond, msg) => cond || out.push(`cart: ${msg}`); + const run = async (label, fn) => { + try { + await fn(); + } catch (e) { + out.push(`cart: ${label}: threw ${e.message}`); + } + }; + const isKnown = (id) => known.has(id); + const L = (...pairs) => pairs.map(([id, q]) => ({ id, q })); + + await run('caps', () => { + expect(c.MAX_LINES === 20 && c.MAX_QTY === 20, `the caps are ${c.MAX_LINES} lines and ${c.MAX_QTY} of each; the Worker's are 20 and 20.`); + const many = Array.from({ length: 20 }, (_, i) => ({ id: `VAR${i}`, q: 1 })); + const r = c.addLine(many, 'VARNEW', 1); + expect(r.note === 'line_cap' && r.lines.length === 20, 'a 21st line is not refused with line_cap.'); + const r2 = c.addLine(L(['VARMUGCREAM', 19]), 'VARMUGCREAM', 5); + expect(r2.note === 'qty_cap' && r2.lines[0].q === 20, '19 + 5 of one variation does not clamp to 20 with qty_cap.'); + const r3 = c.addLine(L(['VARMUGCREAM', 20]), 'VARMUGCREAM', 1); + expect(r3.note === 'qty_cap' && r3.lines[0].q === 20, 'an add at 20 is not refused with qty_cap.'); + const r4 = c.addLine([], 'VARMUGCREAM', 25); + expect(r4.lines[0]?.q === 20 && r4.note === 'qty_cap', 'a first add of 25 is not clamped to 20 with qty_cap.'); + const r5 = c.addLine(L(['VARMUGCREAM', 2]), 'VARMUGSLATE', 3); + expect(eq(r5.lines, L(['VARMUGCREAM', 2], ['VARMUGSLATE', 3])) && r5.note === null, 'a plain add does not append the line.'); + expect(eq(c.setQty(L(['A', 3]), 'A', '0'), L(['A', 1])), 'setQty 0 does not clamp to 1.'); + expect(eq(c.setQty(L(['A', 3]), 'A', '25'), L(['A', 20])), 'setQty 25 does not clamp to 20.'); + expect(eq(c.setQty(L(['A', 3]), 'A', 'abc'), L(['A', 3])) && eq(c.setQty(L(['A', 3]), 'A', ''), L(['A', 3])), 'setQty with something that is not a number changes the line.'); + expect(eq(c.setQty(L(['A', 3]), 'A', '4.7'), L(['A', 4])), 'setQty 4.7 is not 4.'); + expect(eq(c.removeLine(L(['A', 1], ['B', 2]), 'A'), L(['B', 2])), 'removeLine does not remove exactly the line.'); + expect(c.count(L(['A', 2], ['B', 3])) === 5, 'count is not the sum of quantities.'); + }); + await run('sanitize', () => { + expect(eq(c.sanitize(null, isKnown).lines, []) && eq(c.sanitize('junk', isKnown).lines, []) && eq(c.sanitize({ lines: 'x' }, isKnown).lines, []), 'stored garbage does not give an empty cart.'); + const s = c.sanitize({ lines: [{ id: 'VARMUGCREAM', q: 12 }, { id: 'VARMUGCREAM', q: 12 }, { id: 'VARGONE', q: 1 }, { id: 'bad id!', q: 1 }, { id: 'VARNOTEBOOK', q: 0 }, { id: 'VARCANDLESM', q: 25 }, { id: 'VARTOTENAT', q: '2' }] }, isKnown); + expect(eq(s.lines, L(['VARMUGCREAM', 20], ['VARCANDLESM', 20], ['VARTOTENAT', 2])), `cleaning gave ${JSON.stringify(s.lines)}; duplicates merge (capped at 20), 25 clamps, 0 and bad ids go.`); + expect(eq(s.dropped, ['VARGONE']), 'an id the catalogue does not know is not reported as dropped.'); + const lots = { lines: Array.from({ length: 25 }, (_, i) => ({ id: `V${i}`, q: 1 })) }; + expect(c.sanitize(lots, () => true).lines.length === 20, 'a stored cart of 25 lines is not cut to 20.'); + }); + await run('money', () => { + const sub = c.subtotal(L(['VARMUGSLATE', 3], ['VARNOTEBOOK', 2], ['VARGONE', 9]), (id) => priceOf[id]); + expect(sub === 7800, `the subtotal of 3 × $20.00 and 2 × $9.00 is ${sub} cents, not 7800.`); + expect(eq(c.shipping(4999, 'ship', 800, 5000), { cents: 800, free: false }), 'shipping under the threshold is not the flat rate.'); + expect(eq(c.shipping(5000, 'ship', 800, 5000), { cents: 0, free: true }), 'shipping at the threshold is not free (the Worker waives it at subtotal >= free_shipping_over_cents).'); + expect(c.shipping(9000, 'pickup', 800, 5000).cents === null && c.shipping(10, 'ship', null, null).cents === null, 'pickup, or a shop that does not ship, shows a shipping amount.'); + expect(c.shipping(10, 'ship', 0, null).free === true, 'a flat rate of 0 is not shown as free.'); + expect(c.money(1800, 'USD') === '$18.00' && c.money(1800, 'JPY') === '¥1,800', `money is not in the currency's minor unit: ${c.money(1800, 'USD')}, ${c.money(1800, 'JPY')}.`); + }); + await run('keys and body', () => { + const k1 = c.newKey(globalThis.crypto); + const k2 = c.newKey(globalThis.crypto); + const k3 = c.newKey({ getRandomValues: (a) => globalThis.crypto.getRandomValues(a) }); + expect(k1 !== k2 && c.KEY_RE.test(k1) && c.KEY_RE.test(k3), 'newKey does not give a fresh key the Worker accepts (8 to 128 of A-Z a-z 0-9 - _).'); + const ship = c.checkoutBody(L(['VARMUGCREAM', 2]), 'ship', 'market', 'k-12345678'); + expect(eq(Object.keys(ship).sort(), ['fulfillment', 'idempotencyKey', 'items']), `a ship body has keys ${Object.keys(ship)}; exactly items, fulfillment, idempotencyKey.`); + expect(eq(ship.items, [{ variationId: 'VARMUGCREAM', quantity: 2 }]), 'an item is not exactly { variationId, quantity }.'); + const pick = c.checkoutBody(L(['VARMUGCREAM', 2]), 'pickup', 'market', 'k-12345678'); + expect(pick.pickupPoint === 'market' && eq(Object.keys(pick).sort(), ['fulfillment', 'idempotencyKey', 'items', 'pickupPoint']), 'a pickup body does not carry exactly the four fields.'); + }); + await run('stock', () => { + const r = c.applyStock(L(['VARMUGSLATE', 5], ['VARTOTENAT', 1], ['VARNOTEBOOK', 20], ['VARMUGCREAM', 2], ['VARGONE', 1]), { ...stock }); + expect(eq(r.lines, L(['VARMUGSLATE', 2], ['VARNOTEBOOK', 20], ['VARMUGCREAM', 2])), `stock gave ${JSON.stringify(r.lines)}; short clamps, 0 goes, untracked (null) stays.`); + expect(eq(r.changes.map((x) => [x.id, x.kind, x.available]), [['VARMUGSLATE', 'short', 2], ['VARTOTENAT', 'sold_out', 0], ['VARGONE', 'gone', 0]]), 'the stock changes are not short, sold_out and gone, with the counts.'); + }); + + // ---- the checkout, against the fake Worker + const base = { api: 'https://shop.example.test', shop: 'demo-shop', fulfillment: 'ship', point: null }; + const keys = () => c.newKey(globalThis.crypto); + const allBodies = []; + const go = async (lines, routes, extra = {}) => { + const { f, calls } = fakeFetch(routes); + const res = await c.checkout({ ...base, lines, fetch: f, key: keys, ...extra }); + for (const call of calls) allBodies.push(call); + return { res, calls }; + }; + await run('checkout ok', async () => { + const { res, calls } = await go(L(['VARMUGCREAM', 2], ['VARNOTEBOOK', 1]), { stock: okStock, checkout: { status: 200, body: { url: 'https://square.link/u/abc' } } }); + expect(res.kind === 'redirect' && res.url === 'https://square.link/u/abc', 'a 200 { url } is not a redirect to that url.'); + expect(eq(calls.map((x) => x.method), ['GET', 'POST']), `the calls were ${calls.map((x) => x.method)}; the stock re-check (GET) comes before the POST.`); + expect(calls[0].url === 'https://shop.example.test/v1/shops/demo-shop/stock?ids=VARMUGCREAM,VARNOTEBOOK' && calls[0].body === undefined, `the stock call is ${calls[0].url}.`); + expect(calls[1].url === 'https://shop.example.test/v1/shops/demo-shop/checkout' && calls[1].headers['Content-Type'] === 'application/json', 'the POST is not JSON to /v1/shops//checkout.'); + const b = calls[1].body; + expect(eq(Object.keys(b).sort(), ['fulfillment', 'idempotencyKey', 'items']) && b.fulfillment === 'ship' && c.KEY_RE.test(b.idempotencyKey), `the POST body is ${JSON.stringify(b)}.`); + expect(eq(b.items, [{ variationId: 'VARMUGCREAM', quantity: 2 }, { variationId: 'VARNOTEBOOK', quantity: 1 }]), 'the POST items are not the lines.'); + }); + await run('fresh key per attempt', async () => { + const a = await go(L(['VARMUGCREAM', 1]), { stock: okStock, checkout: { status: 503, body: { error: 'shop_closed' } } }); + const b = await go(L(['VARMUGCREAM', 1]), { stock: okStock, checkout: { status: 503, body: { error: 'shop_closed' } } }); + expect(a.calls[1]?.body.idempotencyKey !== b.calls[1]?.body.idempotencyKey, 'two checkout attempts sent the same idempotencyKey; the README requires a new one each time.'); + }); + await run('pickup', async () => { + const { calls } = await go(L(['VARMUGCREAM', 1]), { stock: okStock, checkout: { status: 200, body: { url: 'https://square.link/u/p' } } }, { fulfillment: 'pickup', point: 'market' }); + expect(calls[1]?.body.pickupPoint === 'market' && calls[1]?.body.fulfillment === 'pickup', 'a pickup checkout does not send the point.'); + }); + await run('short before the POST', async () => { + const { res, calls } = await go(L(['VARMUGSLATE', 5]), { stock: okStock, checkout: { status: 200, body: { url: 'https://square.link/u/x' } } }); + expect(res.kind === 'changed' && eq(res.lines, L(['VARMUGSLATE', 2])) && calls.length === 1, 'a line short on the re-check still reached the POST, or was not clamped.'); + }); + await run('stock unreachable', async () => { + const { res, calls } = await go(L(['VARMUGSLATE', 1]), { stock: () => new TypeError('Failed to fetch'), checkout: { status: 200, body: { url: 'https://square.link/u/y' } } }); + expect(res.kind === 'redirect' && calls.length === 2, 'an unreachable /stock stops the checkout; the Worker re-checks, so it must go on.'); + }); + await run('409 short', async () => { + const { res } = await go(L(['VARMUGCREAM', 5], ['VARCANDLELG', 3], ['VARNOTEBOOK', 2]), { + stock: () => ({ status: 503, body: {} }), + checkout: { status: 409, body: { ok: false, error: 'short', short: [{ variationId: 'VARMUGCREAM', available: 2 }, { variationId: 'VARCANDLELG', available: 0 }] } }, + }); + expect(res.kind === 'changed', '409 short is not a change to the cart.'); + expect(eq(res.lines, L(['VARMUGCREAM', 2], ['VARNOTEBOOK', 2])), `409 short gave ${JSON.stringify(res.lines)}; clamp to available, take out 0, leave the rest.`); + expect(eq(res.changes.map((x) => [x.id, x.kind, x.available]), [['VARMUGCREAM', 'short', 2], ['VARCANDLELG', 'sold_out', 0]]), 'the 409 changes are not named per line with the available count.'); + }); + await run('503', async () => { + const { res } = await go(L(['VARMUGCREAM', 1]), { stock: okStock, checkout: { status: 503, body: { ok: false, error: 'shop_closed' } } }); + expect(res.kind === 'error' && res.message === 'This shop is closed for the moment.', `a 503 reads "${res.message}".`); + }); + await run('network', async () => { + const { res } = await go(L(['VARMUGCREAM', 1]), { stock: okStock, checkout: () => new TypeError('Failed to fetch') }); + expect(res.kind === 'error' && res.code === 'network' && oneSentence(res.message), `a network failure reads "${res.message}"; it is one sentence.`); + }); + await run('redirect only to https', async () => { + for (const url of ['javascript:alert(1)', 'http://square.link/u/a', '', 'not a url']) { + const { res } = await go(L(['VARMUGCREAM', 1]), { stock: okStock, checkout: { status: 200, body: { url } } }); + expect(res.kind === 'error', `a 200 with url "${url}" is redirected to.`); + } + }); + await run('other refusals', async () => { + const unk = await go(L(['VARMUGCREAM', 1], ['VARNOTEBOOK', 1]), { stock: okStock, checkout: { status: 400, body: { error: 'unknown_variation', ids: ['VARNOTEBOOK'] } } }); + expect(unk.res.kind === 'changed' && eq(unk.res.lines, L(['VARMUGCREAM', 1])) && unk.res.changes[0]?.kind === 'gone', '400 unknown_variation does not take out exactly the named lines.'); + const said = {}; + for (const [status, error] of [[429, 'rate_limited'], [403, 'origin_not_allowed'], [400, 'bad_pickup_point'], [400, 'shipping_unavailable'], [502, 'checkout_failed'], [409, 'checkout_used']]) { + const { res } = await go(L(['VARMUGCREAM', 1]), { stock: okStock, checkout: { status, body: { error } } }); + said[error] = res.code; + expect(res.kind === 'error' && oneSentence(res.message), `${status} ${error} is not one sentence for the buyer.`); + } + expect(eq(said, { rate_limited: 'rate', origin_not_allowed: 'origin', bad_pickup_point: 'pickup', shipping_unavailable: 'noship', checkout_failed: 'failed', checkout_used: 'failed' }), `the refusals map to ${JSON.stringify(said)}.`); + const bad = await go(L(['VARMUGCREAM', 1]), { stock: okStock, checkout: { status: 502, raw: '' } }); + expect(bad.res.kind === 'error', 'a body that is not JSON is not a failure.'); + const empty = await go([], { stock: okStock, checkout: { status: 200, body: { url: 'https://square.link/u/e' } } }); + expect(empty.res.kind === 'error' && empty.calls.length === 0, 'an empty cart reaches the Worker.'); + }); + // No price, anywhere, in any request the cart made above. + for (const call of allBodies) { + const p = priceKeys(call.body); + if (p.length) out.push(`cart: a request to ${call.url} carried ${p.join(', ')}; the cart never sends a price.`); + if (call.method === 'GET' && /price|cents/i.test(call.url)) out.push(`cart: ${call.url} carries a price.`); + } + if (!allBodies.some((x) => x.method === 'POST')) out.push('cart: no POST was made in the checkout cases.'); + return out; +} + +// =================================================================== cart: the built page +function cartPageFailures(html, where, { demoAdds = 0 } = {}) { + const out = []; + const found = roots(html, 'div', 'data-crt'); + if (!found.length) return [`${where}: no cart rendered.`]; + const shops = new Set(); + for (const r of found) { + const id = attr(r.open, 'id'); + let cfg = {}; + try { + cfg = JSON.parse(decode(attr(r.open, 'data-crt') ?? '')); + } catch { + out.push(`${where} #${id}: data-crt is not JSON.`); + } + shops.add(cfg.shop); + if (priceKeys({ ...cfg, v: undefined }).length) out.push(`${where} #${id}: the config carries a price field outside the display map.`); + const trig = tags(r.html, /]*\sdata-crt-trigger[^>]*>/g)[0]?.open; + const dlg = tags(r.html, /]*>/g)[0]; + if (!trig) out.push(`${where} #${id}: no cart button.`); + if (!dlg) { + out.push(`${where} #${id}: the drawer is not a .`); + continue; + } + const dlgId = attr(dlg.open, 'id'); + const dlgHtml = block(r.html, dlg.index, 'dialog'); + if (trig) { + if (attr(trig, 'hidden') === undefined) out.push(`${where} #${id}: the cart button is not hidden before the script (it cannot work without it).`); + if (attr(trig, 'aria-haspopup') !== 'dialog') out.push(`${where} #${id}: the cart button has no aria-haspopup="dialog".`); + if (attr(trig, 'aria-controls') !== dlgId) out.push(`${where} #${id}: the cart button's aria-controls is not the drawer.`); + if (attr(trig, 'aria-expanded') !== 'false') out.push(`${where} #${id}: the cart button is not aria-expanded="false".`); + if (attr(trig, 'type') !== 'button') out.push(`${where} #${id}: the cart button is not type="button".`); + if (!/^\S.*, 0 items$/.test(attr(trig, 'aria-label') ?? '')) out.push(`${where} #${id}: the cart button is not named with its count ("Cart, 0 items").`); + } + if (attr(dlg.open, 'open') !== undefined) out.push(`${where} #${id}: the drawer is rendered open.`); + const lab = attr(dlg.open, 'aria-labelledby'); + if (!lab || !textOfId(dlgHtml, lab)) out.push(`${where} #${id}: the drawer is not named by a heading with text (aria-labelledby).`); + const close = tags(dlgHtml, /]*\sdata-crt-close[^>]*>/g)[0]?.open; + if (!close || !attr(close, 'aria-label') || attr(close, 'hidden') === undefined || attr(close, 'type') !== 'button') out.push(`${where} #${id}: the drawer's close button is missing, unnamed, or shown before the script.`); + const lives = tags(r.html, /<[a-z]+\b[^>]*\saria-live=[^>]*>/g); + if (lives.length !== 1 || attr(lives[0].open, 'data-crt-live') === undefined) out.push(`${where} #${id}: ${lives.length} aria-live regions; exactly one, the count.`); + if (/\saria-live=|\srole="(status|alert|log)"/.test(dlgHtml)) out.push(`${where} #${id}: a live region inside the drawer; only the count is live.`); + if (!/