Presets and the Generic Fallback Are Both Necessary
SITE_PRESETS in src/checkout-rules.js has 24 keys: Amazon, eBay, Etsy, Walmart, Target, Best Buy, Home Depot, Lowe's and sixteen more. Each carries a host regex, selectors, URL patterns, an item pattern and cartTotal selectors:
target: {
host: /(^|\.)target\.com$/,
checkout: ['[data-testid="checkout-button"]', 'button[data-testid*="checkout"]', 'a[href*="/checkout"]'],
checkoutUrl: /\/(checkout|cart\/checkout)/i,
cartUrl: '/cart',
itemFromUrl: /\/A-(\d{6,})/,
cartTotal: ['[data-testid="cart-summary-total"]', '[data-testid="order-summary-total"]']
}
Generic alone fails both ways. Amazon's primary control, input[name="proceedToRetailCheckout"], contains no token the generic list knows; icon-only buttons labeled only by aria-label are invisible to it. It also over-matches: a help-center link ending in /checkout matches the generic list.
Presets only fail on the long tail: new storefronts, regional domains and one-off Shopify installs never have one. The generic list covers day one; the preset makes it accurate later.
matchCheckoutElement() resolves the tension with a fixed order: presets first, then generic selectors, then the anchor's resolved href, then the form's action, and finally the button text. First match wins, so the order is a confidence ranking, not a union:
const presetKeys = matchSiteKeys(host);
for (const k of presetKeys) {
if (matchesAny(node, SITE_PRESETS[k].checkout)) {
return { node: node, reason: 'preset:' + k, text: textOf(node) };
}
}
if (matchesAny(node, GENERIC_CHECKOUT_SELECTORS)) {
return { node: node, reason: 'generic', text: textOf(node) };
}
Most presets include an a[href*="/checkout"] entry that duplicates the generic list, so a preset hit proves site-specific knowledge, not uniqueness. The reason string, preset:target versus generic, surfaces in the mis-block report.
Button Text Is a Localization Problem Disguised as a Selector
Text is last because it is the least stable signal. CHECKOUT_TEXT_RE covers English, Chinese, Japanese, German, French and Spanish — checkout, place order, buy now, zur kasse, paiement, finalizar compra. CHECKOUT_EXCLUDE_RE then removes navigation labels: continue shopping, view cart, add to cart, 返回購物車, カートを見る, voir le panier. A button reading "Checkout · View cart" matches both and is rejected; plain "Checkout" passes.
textOf() reads innerText, falls back to textContent, then an input's value, then aria-label or title, and collapses whitespace. Matching is case-insensitive, capped at 120 characters for checkout and 80 for add-to-cart, which handles "CHECKOUT" and extra whitespace.
It does not handle a label split across spans without a space, an A/B test renaming "Buy now", or a locale the regex does not know: there is no Dutch afrekenen, Polish do kasy or zapłać, Italian paga ora, or Turkish ödeme. Allegro and bol.com therefore depend on presets and URL patterns, not text. Locale coverage is a list someone maintains.
Strict mode is the escape hatch: hosts in strictSites use STRICT_TEXT_RE, which adds bare pay, buy, submit and purchase. It catches more real buttons and also "Pay bill", hence opt-in per host.
URL and DOM: Two Signals with Different Failure Modes
The URL layer is a path test. pathOf() lowercases pathname + search + hash, and isCheckoutUrl() matches /checkout, /cart/checkout, /kasse, /paiement, /payment, /finalizar and /go-to-checkout. isSuccessUrl() runs first, so /checkout/success is never a checkout page.
Including the query string has a known cost: /login?return=/checkout matches. A path-only test would miss query-routed checkouts; a full parse still would not know which parameter is a redirect. The code picks the broader match and relies on the DOM layer for the consequences.
isCheckoutPage() returns true if the URL matches or the document contains card fields (input[autocomplete="cc-number"], a card-number input, a CVC input) or a [data-testid*="payment-form"] container. Payment hosts and success URLs return false before any of that. The OR is intentional: a client-side checkout can keep the cart URL while rendering card fields, and a server-rendered one is recognized before hydration.
hasCheckoutSignal() is a different question. It does not look at the URL; it asks whether any preset selector, any generic selector or any add-to-cart selector currently exists in the document:
for (const sel of GENERIC_CHECKOUT_SELECTORS) {
try { if (doc.querySelector(sel)) { result.genericHit = true; break; } } catch (e) { /* ignore */ }
}
result.anyHit = result.presetHit || result.genericHit || result.addToCartHit;
That flag blocks nothing. It feeds the health check below, which is why it can be a presence test without visibility or clickability.
Payment Hosts Are a Different Boundary
PAYMENT_HOSTS is 21 regular expressions: Stripe, PayPal and its object CDN, Adyen, Checkout.com, Braintree, Square, Klarna, Affirm, Afterpay, Authorize.net, 2Checkout, Worldpay, Mollie, Razorpay, Amazon Pay, Google Pay and Apple's payment gateway. isPaymentHost() is checked in three places: guard.js returns before attaching any listener, handleStartLock() rejects with bad_host, and isCheckoutPage() returns false.
Fail-open is deliberate: on a listed processor there are no listeners, overlay or storage reads; a missing host or parse failure also blocks nothing. Interrupting a payment is worse than missing one block; card values are never read.
The boundary is as wide as the list. PayU, Mercado Pago, YooKassa and iyzico are absent. A payment step on the merchant's own domain is not excluded: isCheckoutUrl() includes /payment and isCheckoutPage() accepts card fields, so an active timer takes over that page. A cross-origin payment iframe on an unlisted host gets its own guard; on a listed host, nothing. "Never touches payment pages" holds for the listed processors, not the category.
A Rule Set That Notices Its Own Decay
A preset that silently stops matching is worse than none: the generic fallback may still find the button and hide the regression. 1.8 seconds after state loads, reportSelfCheck() runs, but only when the host has a preset key and the URL is a cart or checkout URL. It sends presetHit, genericHit and the URL.
The background validates that the claimed presetKey resolves to the sending host, then stores one entry per preset with okAt, misses and genericAt. A preset hit clears the misses; a miss is retained 14 days, capped at 20. The warning is narrow:
if (misses.length >= SELFCHECK_MISS_LIMIT && now - (entry.okAt || 0) > SELFCHECK_MISS_WINDOW) {
return { host: host, presetKey: presetKey, misses: misses.length, lastUrl: entry.lastUrl || '' };
}
Three misses inside seven days and no preset hit for seven days. That suppresses noise from one-off DOM states at the cost of latency: a preset that breaks the day after a good visit will not warn until day seven. Entries with no success for 90 days are dropped.
Two blind spots: hasCheckoutSignal() uses querySelector without a visibility check, so a hidden legacy button still counts as a preset hit and resets okAt: the preset looks healthy while the visible control changed. And the check only runs on cart and checkout URLs; addToCartHit is computed but never sent, so a preset broken on a product page produces no signal.
The mis-block report is the other half. On a blocked click, rememberBlock() records a cssPath() selector and the element text; VKT_PAGE_INFO returns both to the popup. The path stops at a shadow root and classes are often hashed. More importantly, exclusions are consulted only in matchCheckoutElement() and matchAddToCartElement(). checkPage() does not read them, so an L4 false positive cannot be fixed with a selector exclusion — only by allowlisting the host, which still does not clear an active timer.
Scan Less: Budgets Instead of Site-Wide Sweeps
Running on every URL, the matching layer treats DOM reads as expensive. checkPage() is scheduled, not continuous: 80 ms after state load, on a 1.2-second mutation throttle, a 1.2-second URL poll, and on popstate, hashchange, pageshow and visibility changes. Hidden tabs return immediately; a closed overlay sets suppressUntil for 2.5 seconds.
Amount reading is ordered and capped. cartTotalSelectors(host) puts preset selectors first, then generic, deduplicating in order:
const out = [];
for (const k of matchSiteKeys(host)) {
for (const sel of SITE_PRESETS[k].cartTotal || []) if (out.indexOf(sel) === -1) out.push(sel);
}
for (const sel of GENERIC_CART_TOTAL_SELECTORS) if (out.indexOf(sel) === -1) out.push(sel);
return out;
readCartInfo() tries six nodes per selector, stops once it has eight or more, and otherwise tries the broad fallback ([aria-label*="total" i], [class*="total" i], [id*="total" i]), capped at 80 nodes. BAD_AMOUNT_RE rejects savings, discounts, shipping, tax, points and translations; the largest remaining amount wins. readProductPrice() caps each selector at eight nodes. The click path sweeps nothing: it uses closest(), then at most three class-based ancestors, and reads one element's text.
This is not just hygiene. The parsed amount feeds the exemption rule: a total below the configured minimum passes without a lock, so a low misread silently exempts a purchase and a high one blocks it. The item-scoped timer key has the same dependency: itemIdFromUrl() tries preset patterns, then query keys such as variant, sku and product_id, then a generic /product/ path; a miss changes which timer a checkout matches.
The Matrix You Have to Run Before Adding Preset 25
Selector drift is a family, not one bug. The matrix has seven dimensions:
- Matching regime: preset selector, generic test id, anchor with only a checkout
href, form action, text-only label, add-to-cart start. - URL class: cart, checkout, success, payment host, product page, and a non-checkout page carrying
?return=/checkout. - Timer state: none, active, expired inside the grace window, expired past grace, confirmed after expiry.
- Frame: top document, same-origin iframe, cross-origin iframe, payment iframe.
- Locale: English, Chinese, Japanese, German, French, Spanish, plus an unsupported language.
- DOM timing: server-rendered, hydrated late, hidden desktop/mobile duplicates, button inside a shadow root, label split across spans.
- Injected drift: rename the test id, remove the href, change the label to an unsupported locale, move the button into a shadow root.
For each row, four assertions: no block where none is expected; the overlay appears where one is expected; the self-check reports presetHit or genericHit accordingly; and lastBlocked contains a selector a human can act on. The last is easy to skip and most useful in production.
The seven-day window cannot be exercised in normal QA: seed vkt_cl_selfcheck with old timestamps rather than waiting, and do not ship altered constants — SELFCHECK_MISS_WINDOW and SELFCHECK_MISS_LIMIT are product behavior, not test knobs. Run two profiles with sync enabled: the later expiresAt wins, so a stale lock must never shorten the other device's.
Adding preset 25 is then a checklist: walk a real cart and checkout, record only selectors that resolve to a visible element, add the URLs, total selector and item pattern, run the matrix, and watch the self-check for a week. The guard that consumes these rules — capture-phase clicks, the Shadow DOM overlay, the stored deadline, no skip button — is covered in Locking the Buy Button: Building a Checkout Guard in MV3. The extension is VKT Cart Lock.