diff --git a/README.md b/README.md index d787188..63d504e 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,7 @@ Welcome to String's open source utilities repository! This is a collection of scripts, gists, and tools that we reference in our content and share with the community. This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Contents + +- [`clean-choice/`](clean-choice/) — a Chromium extension that spots checkout upsells where the way to decline is visually suppressed and, on request, shows both choices as equal buttons. diff --git a/clean-choice/.gitignore b/clean-choice/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/clean-choice/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/clean-choice/README.md b/clean-choice/README.md new file mode 100644 index 0000000..b83d5d2 --- /dev/null +++ b/clean-choice/README.md @@ -0,0 +1,56 @@ +# Clean Choice + +A Chromium extension that finds checkout upsells where the way to decline is visually suppressed — +a big "Upgrade" button next to small, grey, underlined "Continue" text — and, when you ask, shows +both choices as equal buttons. + +It only acts when you click it. It reads visible buttons, their names and their computed styles on +the current tab. It never reads what you typed into a form, never sends anything off the machine, +never chooses for you, and never invents a price. + +## Install + +1. Open `chrome://extensions` and switch on **Developer mode**. +2. Click **Load unpacked** and pick `clean-choice/extension`. +3. Pin **Clean Choice** to the toolbar. + +On a checkout page, click the toolbar icon, then **Scan page**. Each upsell/decline pair is listed +with the evidence behind its verdict. **Show on page** outlines the two original controls; **Show +both equally** replaces them with two same-size buttons; **Undo** puts the page back. **Off for +this site** stops the extension on that origin and undoes anything it changed there. + +## How it decides + +A pair is an offer control (upgrade, add, protect, premium, a price…) and a decline control +(continue, no thanks, skip, without…) within four levels of a shared container or the same form or +dialog. Back, Cancel, Close, "Add to cart" and "Continue shopping" are never paired, so ordinary +primary/secondary buttons are ignored. + +A pair is flagged as lopsided when at least two of these hold: the offer is filled and the decline +is not; the decline is underlined like a link; the offer is at least 3× larger; the offer's text is +at least 1.25× larger or 200 weight units bolder; the decline's text contrast is below 4.5:1 or +half the offer's; the decline cannot be reached with Tab. Fewer than two is reported as balanced +and left alone. + +The replacement buttons call the original elements' own `click()`, so the site's handlers, form +state and navigation stay in charge. The originals are hidden, not removed. If the site re-renders +the controls, the panel follows the new ones; if they disappear, the panel removes itself. + +A price is shown only when exactly one amount appears on the offer or in the container around it. + +## Limitations + +- English wording only. Controls inside iframes or shadow DOM are not scanned. +- Built from an airline checkout layout, reproduced in `fixtures/asymmetric.html`. It has not been + checked against a live checkout, which needs a real booking. +- The tests load the real extension and exercise the page logic in Chromium, but the popup → tab + step needs a real toolbar click (`activeTab`), so that wiring is checked by hand. + +## Tests + +```bash +bun install +bun test tests/ +``` + +The tests launch Playwright's Chromium (`bunx playwright-core install chromium` if it is missing). diff --git a/clean-choice/bun.lock b/clean-choice/bun.lock new file mode 100644 index 0000000..26a3bad --- /dev/null +++ b/clean-choice/bun.lock @@ -0,0 +1,27 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "clean-choice", + "devDependencies": { + "@types/bun": "1.3.14", + "playwright-core": "1.58.2", + "typescript": "5.9.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + } +} diff --git a/clean-choice/extension/content.js b/clean-choice/extension/content.js new file mode 100644 index 0000000..2cdd20d --- /dev/null +++ b/clean-choice/extension/content.js @@ -0,0 +1,396 @@ +(() => { + if (globalThis.__cleanChoice) return; + + const CONTROL_SELECTOR = 'button, a[href], [role="button"], input[type="submit"], input[type="button"]'; + const OFFER = /\b(upgrade|add|bundle|protect|protection|insur\w*|premium|get|boost|include)\b/i; + const DECLINE = /\b(continue|no,? thanks|skip|decline|not now|without|keep (?:basic|standard)|i'?ll take the risk)\b/i; + const EXPLICIT_DECLINE = /\b(without|no,? thanks|decline|skip|not now)\b/i; + const NEVER_PAIRED = /^(back|cancel|close|previous|next page|go back)$|\b(to (?:cart|bag|basket|wishlist)|continue shopping)\b/i; + const PRICE = /(?:[$€£]\s?\d[\d,]*(?:\.\d{1,2})?|\d[\d,]*(?:\.\d{1,2})?\s?(?:USD|EUR|GBP))/; + const MAX_ANCESTOR_DEPTH = 4; + const STYLE_ID = "clean-choice-style"; + const PANEL_ATTR = "data-clean-choice-panel"; + const MARK_ATTR = "data-clean-choice"; + + const scanned = new Map(); + const applied = new Map(); + + const collapse = (text) => (text || "").replace(/\s+/g, " ").trim(); + + function accessibleName(el) { + const label = el.getAttribute("aria-label"); + if (label) return collapse(label); + const labelledBy = el.getAttribute("aria-labelledby"); + if (labelledBy) { + const text = labelledBy + .split(/\s+/) + .map((id) => document.getElementById(id)?.textContent || "") + .join(" "); + if (collapse(text)) return collapse(text); + } + if (el instanceof HTMLInputElement) return collapse(el.value); + return collapse(el.innerText || el.textContent); + } + + function isVisible(el) { + if (el.closest(`[${PANEL_ATTR}]`)) return false; + if (el.closest('[aria-hidden="true"]')) return false; + const style = getComputedStyle(el); + if (style.visibility === "hidden" || style.display === "none") return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } + + function isDisabled(el) { + return el.disabled === true || el.getAttribute("aria-disabled") === "true"; + } + + function classify(name) { + if (!name || NEVER_PAIRED.test(name)) return null; + const offer = OFFER.test(name) || PRICE.test(name); + const decline = DECLINE.test(name); + if (decline && (!offer || EXPLICIT_DECLINE.test(name))) return "decline"; + if (offer && !decline) return "offer"; + return null; + } + + function ancestorDepth(el, ancestor) { + let depth = 0; + for (let node = el; node; node = node.parentElement, depth++) { + if (node === ancestor) return depth; + } + return Infinity; + } + + function commonAncestor(a, b) { + for (let node = a.parentElement; node; node = node.parentElement) { + if (node.contains(b)) return node; + } + return null; + } + + function pairDistance(a, b) { + const ancestor = commonAncestor(a, b); + if (!ancestor) return Infinity; + const depth = Math.max(ancestorDepth(a, ancestor), ancestorDepth(b, ancestor)); + if (depth <= MAX_ANCESTOR_DEPTH) return depth; + const scope = a.closest("form, dialog, [role=dialog]"); + return scope && scope.contains(b) ? MAX_ANCESTOR_DEPTH + 1 : Infinity; + } + + function findPrice(offer, decline) { + const own = accessibleName(offer).match(PRICE); + if (own) return collapse(own[0]); + const shared = commonAncestor(offer, decline); + let node = offer.parentElement; + for (let i = 0; node && i < MAX_ANCESTOR_DEPTH; i++, node = node.parentElement) { + const text = collapse(node.textContent).replace(accessibleName(decline), ""); + if (text.length > 400) break; + const amounts = new Set((text.match(new RegExp(PRICE.source, "g")) || []).map((amount) => collapse(amount))); + if (amounts.size > 1) return null; + if (amounts.size === 1) return [...amounts][0]; + if (node === shared) break; + } + return null; + } + + function parseColor(value) { + const match = value.match(/rgba?\(([^)]+)\)/); + if (!match) return { r: 0, g: 0, b: 0, a: 0 }; + const [r, g, b, a = "1"] = match[1].split(/[\s,/]+/).filter(Boolean); + return { r: +r, g: +g, b: +b, a: +a }; + } + + function effectiveBackground(el) { + for (let node = el; node; node = node.parentElement) { + const color = parseColor(getComputedStyle(node).backgroundColor); + if (color.a > 0.5) return color; + } + return { r: 255, g: 255, b: 255, a: 1 }; + } + + function luminance({ r, g, b }) { + const channel = (v) => { + const c = v / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); + } + + function contrast(el) { + const fg = parseColor(getComputedStyle(el).color); + const bg = effectiveBackground(el); + const [hi, lo] = [luminance(fg), luminance(bg)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); + } + + function hasFill(el) { + const own = parseColor(getComputedStyle(el).backgroundColor); + if (own.a <= 0.1) return false; + const parent = el.parentElement ? effectiveBackground(el.parentElement) : null; + return !parent || own.r !== parent.r || own.g !== parent.g || own.b !== parent.b; + } + + function isUnderlined(el) { + return getComputedStyle(el).textDecorationLine.includes("underline"); + } + + function isFocusable(el) { + return el.tabIndex >= 0; + } + + function asymmetryReasons(offer, decline) { + const reasons = []; + const os = getComputedStyle(offer); + const ds = getComputedStyle(decline); + if (hasFill(offer) && !hasFill(decline)) { + reasons.push("The upsell has a filled background; the way to decline has none."); + } + if (isUnderlined(decline) && !isUnderlined(offer)) { + reasons.push("The way to decline is styled as underlined link text."); + } + const oRect = offer.getBoundingClientRect(); + const dRect = decline.getBoundingClientRect(); + const areaRatio = (oRect.width * oRect.height) / Math.max(1, dRect.width * dRect.height); + if (areaRatio >= 3) { + reasons.push(`The upsell is ${areaRatio.toFixed(1)}× larger than the way to decline.`); + } + const sizeRatio = parseFloat(os.fontSize) / Math.max(1, parseFloat(ds.fontSize)); + const weightGap = Number(os.fontWeight) - Number(ds.fontWeight); + if (sizeRatio >= 1.25 || weightGap >= 200) { + reasons.push("The upsell uses noticeably larger or bolder text."); + } + const oContrast = contrast(offer); + const dContrast = contrast(decline); + if (dContrast < 4.5 || oContrast / dContrast >= 2) { + reasons.push(`The way to decline has low text contrast (${dContrast.toFixed(1)}:1 vs ${oContrast.toFixed(1)}:1).`); + } + if (!isFocusable(decline) && isFocusable(offer)) { + reasons.push("The way to decline cannot be reached with the keyboard."); + } + return reasons; + } + + function describe(id, pair) { + return { + id, + verdict: pair.reasons.length >= 2 ? "flag" : "balanced", + offer: { name: pair.offerName, price: pair.price, disabled: isDisabled(pair.offer) }, + decline: { name: pair.declineName, disabled: isDisabled(pair.decline) }, + reasons: pair.reasons, + applied: applied.has(id) + }; + } + + function findPairs() { + const controls = [...document.querySelectorAll(CONTROL_SELECTOR)].filter(isVisible); + const offers = []; + const declines = []; + for (const el of controls) { + const kind = classify(accessibleName(el)); + if (kind === "offer") offers.push(el); + if (kind === "decline") declines.push(el); + } + const pairs = []; + const usedDeclines = new Set(); + for (const offer of offers) { + let best = null; + let bestDistance = Infinity; + for (const decline of declines) { + if (usedDeclines.has(decline) || offer.contains(decline) || decline.contains(offer)) continue; + const distance = pairDistance(offer, decline); + if (distance < bestDistance) { + best = decline; + bestDistance = distance; + } + } + if (!best) continue; + usedDeclines.add(best); + pairs.push({ + offer, + decline: best, + offerName: accessibleName(offer), + declineName: accessibleName(best), + price: findPrice(offer, best), + reasons: asymmetryReasons(offer, best) + }); + } + return pairs; + } + + function scan() { + clearPreview(); + const live = new Map([...applied].map(([id, entry]) => [id, entry.pair])); + scanned.clear(); + let next = 0; + for (const [id, pair] of live) { + scanned.set(id, pair); + next = Math.max(next, id + 1); + } + for (const pair of findPairs()) { + scanned.set(next++, pair); + } + return { pairs: [...scanned].map(([id, pair]) => describe(id, pair)) }; + } + + function ensureStyle() { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` + [${MARK_ATTR}][data-clean-choice-hidden] { display: none !important; } + [data-clean-choice-preview] { outline: 3px dashed #b45309 !important; outline-offset: 3px !important; } + [${PANEL_ATTR}] { all: initial; display: flex; flex-wrap: wrap; gap: 12px; margin: 12px 0; + font: 16px/1.3 system-ui, sans-serif; } + [${PANEL_ATTR}] button { all: initial; box-sizing: border-box; flex: 1 1 200px; min-height: 44px; + padding: 10px 16px; border: 2px solid #1f2937; border-radius: 6px; background: #ffffff; + color: #111827; font: 600 16px/1.3 system-ui, sans-serif; text-align: center; cursor: pointer; } + [${PANEL_ATTR}] button:focus-visible { outline: 3px solid #2563eb; outline-offset: 2px; } + [${PANEL_ATTR}] button:disabled { opacity: 0.5; cursor: not-allowed; } + `; + document.documentElement.appendChild(style); + } + + function preview(id) { + clearPreview(); + const pair = scanned.get(id); + if (!pair) return false; + pair.offer.setAttribute("data-clean-choice-preview", ""); + pair.decline.setAttribute("data-clean-choice-preview", ""); + ensureStyle(); + return true; + } + + function clearPreview() { + for (const el of document.querySelectorAll("[data-clean-choice-preview]")) { + el.removeAttribute("data-clean-choice-preview"); + } + } + + function offerLabel(pair) { + if (!pair.price || pair.offerName.includes(pair.price)) return pair.offerName; + return `${pair.offerName} (+${pair.price})`; + } + + function declineLabel(pair) { + return /\bupgrade\b/i.test(pair.offerName) ? "Continue without upgrade" : "Continue without this add-on"; + } + + function mark(pair, id) { + for (const [el, role] of [ + [pair.offer, "offer"], + [pair.decline, "decline"] + ]) { + el.setAttribute(MARK_ATTR, `${role}-${id}`); + el.setAttribute("data-clean-choice-hidden", ""); + } + } + + function unmark(pair) { + for (const el of [pair.offer, pair.decline]) { + el.removeAttribute(MARK_ATTR); + el.removeAttribute("data-clean-choice-hidden"); + } + } + + function syncDisabled(entry) { + entry.offerButton.disabled = isDisabled(entry.pair.offer); + entry.declineButton.disabled = isDisabled(entry.pair.decline); + } + + function placePanel(entry) { + const { offer, decline } = entry.pair; + const first = offer.compareDocumentPosition(decline) & Node.DOCUMENT_POSITION_FOLLOWING ? offer : decline; + first.before(entry.panel); + } + + function apply(id) { + const pair = scanned.get(id); + if (!pair || applied.has(id)) return false; + clearPreview(); + ensureStyle(); + + const panel = document.createElement("div"); + panel.setAttribute(PANEL_ATTR, String(id)); + panel.setAttribute("role", "group"); + panel.setAttribute("aria-label", "Clean Choice: both options shown equally"); + const offerButton = document.createElement("button"); + const declineButton = document.createElement("button"); + offerButton.type = "button"; + declineButton.type = "button"; + offerButton.textContent = offerLabel(pair); + declineButton.textContent = declineLabel(pair); + declineButton.title = `Original: ${pair.declineName}`; + panel.append(offerButton, declineButton); + + const entry = { pair, panel, offerButton, declineButton, observer: null }; + offerButton.addEventListener("click", () => entry.pair.offer.click()); + declineButton.addEventListener("click", () => entry.pair.decline.click()); + + mark(pair, id); + placePanel(entry); + syncDisabled(entry); + entry.observer = new MutationObserver(() => reconcile(id)); + entry.observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["disabled", "aria-disabled"] + }); + applied.set(id, entry); + return true; + } + + function reconcile(id) { + const entry = applied.get(id); + if (!entry) return; + const { pair } = entry; + if (pair.offer.isConnected && pair.decline.isConnected) { + syncDisabled(entry); + if (!entry.panel.isConnected) placePanel(entry); + return; + } + entry.observer.disconnect(); + const replacement = findPairs().find((p) => p.offerName === pair.offerName && p.declineName === pair.declineName); + if (!replacement) { + undo(id); + return; + } + unmark(pair); + entry.pair = replacement; + scanned.set(id, replacement); + mark(replacement, id); + entry.panel.remove(); + placePanel(entry); + syncDisabled(entry); + entry.observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["disabled", "aria-disabled"] + }); + } + + function undo(id) { + const ids = id === undefined ? [...applied.keys()] : [id]; + for (const key of ids) { + const entry = applied.get(key); + if (!entry) continue; + entry.observer?.disconnect(); + entry.panel.remove(); + unmark(entry.pair); + applied.delete(key); + } + if (applied.size === 0) { + clearPreview(); + document.getElementById(STYLE_ID)?.remove(); + } + return true; + } + + function state() { + return { pairs: [...scanned].map(([id, pair]) => describe(id, pair)) }; + } + + globalThis.__cleanChoice = { scan, preview, clearPreview, apply, undo, state }; +})(); diff --git a/clean-choice/extension/manifest.json b/clean-choice/extension/manifest.json new file mode 100644 index 0000000..74fb711 --- /dev/null +++ b/clean-choice/extension/manifest.json @@ -0,0 +1,11 @@ +{ + "manifest_version": 3, + "name": "Clean Choice", + "version": "0.1.0", + "description": "Finds checkout upsells that hide the way to decline, and shows both choices equally.", + "permissions": ["activeTab", "scripting", "storage"], + "action": { + "default_title": "Clean Choice", + "default_popup": "popup.html" + } +} diff --git a/clean-choice/extension/popup.css b/clean-choice/extension/popup.css new file mode 100644 index 0000000..05bd72a --- /dev/null +++ b/clean-choice/extension/popup.css @@ -0,0 +1,62 @@ +body { + width: 340px; + margin: 0; + padding: 12px; + font: + 14px/1.4 system-ui, + sans-serif; + color: #111827; +} +header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} +h1 { + font-size: 16px; + margin: 0; +} +button { + min-height: 36px; + padding: 6px 12px; + border: 1px solid #1f2937; + border-radius: 6px; + background: #fff; + font: inherit; + cursor: pointer; +} +button:focus-visible, +input:focus-visible { + outline: 3px solid #2563eb; + outline-offset: 2px; +} +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} +ul { + list-style: none; + padding: 0; + margin: 12px 0; + display: grid; + gap: 10px; +} +li { + border: 1px solid #d1d5db; + border-radius: 6px; + padding: 8px; +} +li ul { + list-style: disc; + padding-left: 18px; + margin: 6px 0; + display: block; +} +.actions { + display: flex; + gap: 6px; +} +.verdict { + font-weight: 600; +} diff --git a/clean-choice/extension/popup.html b/clean-choice/extension/popup.html new file mode 100644 index 0000000..b8a01d9 --- /dev/null +++ b/clean-choice/extension/popup.html @@ -0,0 +1,19 @@ + + + + + Clean Choice + + + +
+

Clean Choice

+ +
+

Scan this page for upsells that hide the way to decline.

+ + + + + + diff --git a/clean-choice/extension/popup.js b/clean-choice/extension/popup.js new file mode 100644 index 0000000..504b95c --- /dev/null +++ b/clean-choice/extension/popup.js @@ -0,0 +1,111 @@ +import { STORAGE_KEY, isDisabled, originOf, setDisabled } from "./site-settings.js"; + +const statusEl = document.getElementById("status"); +const scanButton = document.getElementById("scan"); +const results = document.getElementById("results"); +const undoAll = document.getElementById("undo-all"); +const siteToggle = document.getElementById("site-disabled"); + +const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); +const origin = originOf(tab?.url ?? ""); + +async function disabledOrigins() { + const stored = await chrome.storage.local.get(STORAGE_KEY); + return stored[STORAGE_KEY] ?? {}; +} + +async function call(method, ...args) { + await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["content.js"] }); + const [injection] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: (name, params) => globalThis.__cleanChoice[name](...params), + args: [method, args] + }); + return injection?.result; +} + +function render(report) { + results.replaceChildren(); + const pairs = report?.pairs ?? []; + const flagged = pairs.filter((p) => p.verdict === "flag"); + statusEl.textContent = + pairs.length === 0 + ? "No upsell with a separate way to decline was found." + : `${flagged.length} of ${pairs.length} upsell choice${pairs.length === 1 ? "" : "s"} look lopsided.`; + for (const pair of pairs) { + const item = document.createElement("li"); + const verdict = document.createElement("div"); + verdict.className = "verdict"; + verdict.textContent = pair.verdict === "flag" ? "Lopsided choice" : "Balanced choice (no change needed)"; + const names = document.createElement("div"); + const price = pair.offer.price ? ` (${pair.offer.price})` : ""; + names.textContent = `“${pair.offer.name}”${price} vs “${pair.decline.name}”`; + item.append(verdict, names); + if (pair.reasons.length) { + const reasons = document.createElement("ul"); + for (const reason of pair.reasons) { + const li = document.createElement("li"); + li.textContent = reason; + reasons.append(li); + } + item.append(reasons); + } + const actions = document.createElement("div"); + actions.className = "actions"; + const show = document.createElement("button"); + show.type = "button"; + show.textContent = "Show on page"; + show.addEventListener("click", () => call("preview", pair.id)); + actions.append(show); + if (pair.verdict === "flag") { + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.textContent = pair.applied ? "Undo" : "Show both equally"; + toggle.addEventListener("click", async () => { + await call(pair.applied ? "undo" : "apply", pair.id); + render(await call("state")); + }); + actions.append(toggle); + } + item.append(actions); + results.append(item); + } + undoAll.hidden = !pairs.some((p) => p.applied); +} + +async function refreshSiteState() { + const disabled = isDisabled(await disabledOrigins(), origin); + siteToggle.checked = disabled; + siteToggle.disabled = !origin; + scanButton.disabled = disabled || !origin; + if (!origin) statusEl.textContent = "Clean Choice cannot run on this page."; + else if (disabled) statusEl.textContent = "Clean Choice is off for this site."; +} + +scanButton.addEventListener("click", async () => { + try { + render(await call("scan")); + } catch (error) { + statusEl.textContent = `Could not scan this page: ${error.message}`; + } +}); + +undoAll.addEventListener("click", async () => { + await call("undo"); + render(await call("state")); +}); + +siteToggle.addEventListener("change", async () => { + const disabled = siteToggle.checked; + await chrome.storage.local.set({ + [STORAGE_KEY]: setDisabled(await disabledOrigins(), origin, disabled) + }); + if (disabled) { + await call("undo").catch(() => {}); + results.replaceChildren(); + undoAll.hidden = true; + } + await refreshSiteState(); +}); + +await refreshSiteState(); diff --git a/clean-choice/extension/site-settings.js b/clean-choice/extension/site-settings.js new file mode 100644 index 0000000..a731f47 --- /dev/null +++ b/clean-choice/extension/site-settings.js @@ -0,0 +1,21 @@ +export const STORAGE_KEY = "disabledOrigins"; + +export function originOf(url) { + try { + const { protocol, origin } = new URL(url); + return protocol === "http:" || protocol === "https:" || protocol === "file:" ? origin : null; + } catch { + return null; + } +} + +export function isDisabled(disabledOrigins, origin) { + return Boolean(origin && disabledOrigins?.[origin]); +} + +export function setDisabled(disabledOrigins, origin, disabled) { + const next = { ...disabledOrigins }; + if (disabled) next[origin] = true; + else delete next[origin]; + return next; +} diff --git a/clean-choice/fixtures/asymmetric.html b/clean-choice/fixtures/asymmetric.html new file mode 100644 index 0000000..1eabb46 --- /dev/null +++ b/clean-choice/fixtures/asymmetric.html @@ -0,0 +1,64 @@ + + + + + Asymmetric upgrade + + + +
+

Upgrade to Economy bundle

+

Carry-on bag, seat selection, and flexible changes for $89.00 per traveler.

+ +

+

Continue

+
+ + + diff --git a/clean-choice/fixtures/balanced.html b/clean-choice/fixtures/balanced.html new file mode 100644 index 0000000..9b94fe1 --- /dev/null +++ b/clean-choice/fixtures/balanced.html @@ -0,0 +1,65 @@ + + + + + Balanced choice + + + +
+

Add travel protection?

+

Covers trip cancellation for $29.

+ + +
+ + + diff --git a/clean-choice/fixtures/disabled.html b/clean-choice/fixtures/disabled.html new file mode 100644 index 0000000..27421b8 --- /dev/null +++ b/clean-choice/fixtures/disabled.html @@ -0,0 +1,64 @@ + + + + + Disabled continue + + + +
+

Upgrade your fare

+

Only $45.50 more.

+ +
+ +
+ + + diff --git a/clean-choice/fixtures/no-price.html b/clean-choice/fixtures/no-price.html new file mode 100644 index 0000000..74d3418 --- /dev/null +++ b/clean-choice/fixtures/no-price.html @@ -0,0 +1,64 @@ + + + + + Asymmetric without price + + + +
+

Get Premium seating

+

More legroom and priority boarding.

+ +
+ No thanks +
+ + + diff --git a/clean-choice/fixtures/replaced.html b/clean-choice/fixtures/replaced.html new file mode 100644 index 0000000..140a417 --- /dev/null +++ b/clean-choice/fixtures/replaced.html @@ -0,0 +1,78 @@ + + + + + Replaced controls + + + +
+

Upgrade to Economy bundle

+

Only $89.00.

+
+
+ + + + diff --git a/clean-choice/fixtures/unrelated.html b/clean-choice/fixtures/unrelated.html new file mode 100644 index 0000000..f28e0c1 --- /dev/null +++ b/clean-choice/fixtures/unrelated.html @@ -0,0 +1,62 @@ + + + + + Ordinary primary and secondary + + + +
+

Edit traveler

+ + Cancel +
+ + + diff --git a/clean-choice/package.json b/clean-choice/package.json new file mode 100644 index 0000000..5ed4cfc --- /dev/null +++ b/clean-choice/package.json @@ -0,0 +1,15 @@ +{ + "name": "clean-choice", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "bun test tests/", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/bun": "1.3.14", + "playwright-core": "1.58.2", + "typescript": "5.9.3" + } +} diff --git a/clean-choice/tests/content.test.ts b/clean-choice/tests/content.test.ts new file mode 100644 index 0000000..76d3389 --- /dev/null +++ b/clean-choice/tests/content.test.ts @@ -0,0 +1,197 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { chromium, type Browser, type Page } from "playwright-core"; +import type { Server } from "bun"; +import { CONTENT_SCRIPT, serveFixtures } from "./harness"; + +type Pair = { + id: number; + verdict: "flag" | "balanced"; + offer: { name: string; price: string | null; disabled: boolean }; + decline: { name: string; disabled: boolean }; + reasons: string[]; + applied: boolean; +}; + +let browser: Browser; +let server: Server; +let page: Page; + +beforeAll(async () => { + server = serveFixtures(); + browser = await chromium.launch({ channel: "chromium" }); +}); + +afterAll(async () => { + await browser?.close(); + server?.stop(true); +}); + +beforeEach(async () => { + page = await browser.newPage(); +}); + +async function open(fixture: string): Promise { + await page.goto(new URL(`/${fixture}.html`, server.url).href); + await page.addScriptTag({ path: CONTENT_SCRIPT }); +} + +function cc(method: string, ...args: unknown[]): Promise { + return page.evaluate(([name, params]) => (globalThis as any).__cleanChoice[name as string](...(params as unknown[])), [ + method, + args + ] as const) as Promise; +} + +const scan = async () => (await cc<{ pairs: Pair[] }>("scan")).pairs; +const clicks = () => page.evaluate(() => (window as any).__clicks as string[]); +const panelButtons = () => page.locator("[data-clean-choice-panel] button"); + +async function eventually(read: () => Promise, expected: T): Promise { + const deadline = Date.now() + 2000; + let actual = await read(); + while (actual !== expected && Date.now() < deadline) { + await Bun.sleep(25); + actual = await read(); + } + expect(actual).toBe(expected); +} + +describe("detection", () => { + test("flags an upgrade button paired with underlined continue text", async () => { + await open("asymmetric"); + const pairs = await scan(); + expect(pairs).toHaveLength(1); + const [pair] = pairs; + expect(pair!.verdict).toBe("flag"); + expect(pair!.offer.name).toBe("Upgrade"); + expect(pair!.offer.price).toBe("$89.00"); + expect(pair!.decline.name).toBe("Continue"); + expect(pair!.reasons.length).toBeGreaterThanOrEqual(2); + expect(pair!.reasons.join(" ")).toContain("underlined"); + }); + + test("reports an equally styled offer and decline as balanced", async () => { + await open("balanced"); + const pairs = await scan(); + expect(pairs).toHaveLength(1); + expect(pairs[0]!.verdict).toBe("balanced"); + expect(pairs[0]!.reasons.length).toBeLessThan(2); + }); + + test("ignores ordinary primary and secondary actions", async () => { + await open("unrelated"); + expect(await scan()).toEqual([]); + }); + + test("never invents a price when the page shows none", async () => { + await open("no-price"); + const [pair] = await scan(); + expect(pair!.verdict).toBe("flag"); + expect(pair!.offer.price).toBeNull(); + await cc("apply", pair!.id); + expect(await panelButtons().allTextContents()).toEqual(["Get Premium", "Continue without this add-on"]); + }); +}); + +describe("replacement", () => { + test("shows both choices equally and fires each original action once", async () => { + await open("asymmetric"); + const [pair] = await scan(); + expect(await cc("apply", pair!.id)).toBe(true); + + expect(await panelButtons().allTextContents()).toEqual(["Upgrade (+$89.00)", "Continue without upgrade"]); + const [offerBox, declineBox] = await Promise.all([panelButtons().nth(0).boundingBox(), panelButtons().nth(1).boundingBox()]); + expect(offerBox!.width).toBe(declineBox!.width); + expect(offerBox!.height).toBe(declineBox!.height); + expect(offerBox!.height).toBeGreaterThanOrEqual(44); + expect(await page.locator('[data-action="upgrade"]').isVisible()).toBe(false); + expect(await page.locator('[data-action="continue"]').isVisible()).toBe(false); + expect(await clicks()).toEqual([]); + + await panelButtons().nth(1).click(); + expect(await clicks()).toEqual(["continue"]); + await panelButtons().nth(0).click(); + expect(await clicks()).toEqual(["continue", "upgrade"]); + }); + + test("does not preselect or steal focus, and both choices are keyboard reachable", async () => { + await open("asymmetric"); + await page.locator("#traveler").focus(); + const [pair] = await scan(); + await cc("apply", pair!.id); + expect(await page.evaluate(() => document.activeElement?.id)).toBe("traveler"); + + await page.keyboard.press("Tab"); + expect(await page.evaluate(() => document.activeElement?.textContent)).toBe("Upgrade (+$89.00)"); + await page.keyboard.press("Tab"); + expect(await page.evaluate(() => document.activeElement?.textContent)).toBe("Continue without upgrade"); + await page.keyboard.press("Enter"); + expect(await clicks()).toEqual(["continue"]); + }); + + test("preserves form state and undo restores the original presentation", async () => { + await open("asymmetric"); + await page.locator("#traveler").fill("Ada Lovelace"); + const presentation = () => + page.evaluate(() => + ["upgrade", "continue"].map((action) => { + const el = document.querySelector(`[data-action="${action}"]`)!; + const rect = el.getBoundingClientRect(); + const style = getComputedStyle(el); + return [rect.x, rect.y, rect.width, rect.height, style.display, style.textDecorationLine]; + }) + ); + const before = await presentation(); + + const [pair] = await scan(); + await cc("apply", pair!.id); + expect(await page.locator("#traveler").inputValue()).toBe("Ada Lovelace"); + + await cc("undo", pair!.id); + expect(await presentation()).toEqual(before); + expect(await page.locator("[data-clean-choice-panel]").count()).toBe(0); + expect(await page.locator("#clean-choice-style").count()).toBe(0); + expect(await page.locator("[data-clean-choice]").count()).toBe(0); + expect(await page.locator("#traveler").inputValue()).toBe("Ada Lovelace"); + }); + + test("mirrors a disabled original", async () => { + await open("disabled"); + const [pair] = await scan(); + expect(pair!.verdict).toBe("flag"); + expect(pair!.decline.disabled).toBe(true); + await cc("apply", pair!.id); + expect(await panelButtons().nth(1).isDisabled()).toBe(true); + expect(await panelButtons().nth(0).isDisabled()).toBe(false); + + await page.evaluate(() => document.querySelector('[data-action="continue"]')!.removeAttribute("disabled")); + await eventually(() => panelButtons().nth(1).isDisabled(), false); + await panelButtons().nth(1).click(); + expect(await clicks()).toEqual(["continue"]); + }); + + test("follows controls the page re-renders, and fails open when they disappear", async () => { + await open("replaced"); + const [pair] = await scan(); + await cc("apply", pair!.id); + + await page.evaluate(() => (window as any).rerender(2)); + await eventually(() => page.locator('[data-action="continue-2"]').isVisible(), false); + expect(await page.locator("[data-clean-choice-panel]").count()).toBe(1); + await panelButtons().nth(1).click(); + expect(await clicks()).toEqual(["continue-2"]); + + await page.evaluate(() => (window as any).replaceWithUnrelated()); + await eventually(() => page.locator("[data-clean-choice-panel]").count(), 0); + expect(await page.locator("#clean-choice-style").count()).toBe(0); + }); + + test("the script is idempotent when injected twice", async () => { + await open("asymmetric"); + await page.addScriptTag({ path: CONTENT_SCRIPT }); + const [pair] = await scan(); + await cc("apply", pair!.id); + expect(await cc("apply", pair!.id)).toBe(false); + expect(await page.locator("[data-clean-choice-panel]").count()).toBe(1); + }); +}); diff --git a/clean-choice/tests/extension.test.ts b/clean-choice/tests/extension.test.ts new file mode 100644 index 0000000..567c028 --- /dev/null +++ b/clean-choice/tests/extension.test.ts @@ -0,0 +1,40 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { chromium, type BrowserContext } from "playwright-core"; +import { EXTENSION_DIR, unpackedExtensionId } from "./harness"; + +let context: BrowserContext; +let profile: string; + +beforeAll(async () => { + profile = mkdtempSync(join(tmpdir(), "clean-choice-profile-")); + context = await chromium.launchPersistentContext(profile, { + channel: "chromium", + args: [`--disable-extensions-except=${EXTENSION_DIR}`, `--load-extension=${EXTENSION_DIR}`] + }); +}); + +afterAll(async () => { + await context?.close(); + rmSync(profile, { recursive: true, force: true }); +}); + +test("the unpacked extension loads and its popup persists the per-site switch", async () => { + const errors: string[] = []; + const popup = await context.newPage(); + popup.on("pageerror", (error) => errors.push(error.message)); + await popup.goto(`chrome-extension://${unpackedExtensionId(EXTENSION_DIR)}/popup.html`); + + await expect(popup.locator("h1").textContent()).resolves.toBe("Clean Choice"); + // The popup's own tab is not a web page, so it must refuse to scan. + await popup.waitForFunction(() => document.getElementById("status")?.textContent === "Clean Choice cannot run on this page."); + expect(await popup.locator("#scan").isDisabled()).toBe(true); + + await popup.evaluate(() => (globalThis as any).chrome.storage.local.set({ disabledOrigins: { "https://a.example": true } })); + await popup.reload(); + const stored = await popup.evaluate(() => (globalThis as any).chrome.storage.local.get("disabledOrigins")); + expect(stored).toEqual({ disabledOrigins: { "https://a.example": true } }); + expect(errors).toEqual([]); +}); diff --git a/clean-choice/tests/harness.ts b/clean-choice/tests/harness.ts new file mode 100644 index 0000000..99fda7d --- /dev/null +++ b/clean-choice/tests/harness.ts @@ -0,0 +1,28 @@ +import { createHash } from "node:crypto"; +import { join, resolve } from "node:path"; +import type { Server } from "bun"; + +export const ROOT = resolve(import.meta.dir, ".."); +export const EXTENSION_DIR = join(ROOT, "extension"); +export const CONTENT_SCRIPT = join(EXTENSION_DIR, "content.js"); + +export function serveFixtures(): Server { + return Bun.serve({ + port: 0, + fetch(request) { + const { pathname } = new URL(request.url); + if (!/^\/[\w-]+\.html$/.test(pathname)) return new Response("not found", { status: 404 }); + return new Response(Bun.file(join(ROOT, "fixtures", pathname)), { + headers: { "content-type": "text/html; charset=utf-8" } + }); + } + }); +} + +// Chromium derives an unpacked extension's ID from the SHA-256 of its absolute path, +// mapping each hex digit of the first 16 bytes onto the letters a-p. +export function unpackedExtensionId(path: string): string { + return [...createHash("sha256").update(path).digest("hex").slice(0, 32)] + .map((digit) => String.fromCharCode(97 + parseInt(digit, 16))) + .join(""); +} diff --git a/clean-choice/tests/site-settings.test.ts b/clean-choice/tests/site-settings.test.ts new file mode 100644 index 0000000..a7fca44 --- /dev/null +++ b/clean-choice/tests/site-settings.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; +// @ts-expect-error plain-JS extension module without type declarations +import { isDisabled, originOf, setDisabled } from "../extension/site-settings.js"; + +describe("site settings", () => { + test("keys settings by origin and refuses non-web pages", () => { + expect(originOf("https://booking.example.com/checkout?step=3")).toBe("https://booking.example.com"); + expect(originOf("chrome://extensions")).toBeNull(); + expect(originOf("not a url")).toBeNull(); + }); + + test("disabling one site leaves others enabled, and re-enabling removes the entry", () => { + const off = setDisabled({}, "https://a.example", true); + expect(isDisabled(off, "https://a.example")).toBe(true); + expect(isDisabled(off, "https://b.example")).toBe(false); + expect(setDisabled(off, "https://a.example", false)).toEqual({}); + expect(isDisabled(off, null)).toBe(false); + }); +}); diff --git a/clean-choice/tsconfig.json b/clean-choice/tsconfig.json new file mode 100644 index 0000000..68b1811 --- /dev/null +++ b/clean-choice/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext", "DOM"], + "types": ["bun"], + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true + }, + "include": ["tests/**/*.ts"] +}