openvibers/openvibe.extensions Commits
OpenVibe Companion 0.3.0: try coupon codes on the page, compare delivery apps (#4)
Changes
@@@ -3,7 +3,8 @@> OpenVibe's first-party client surfaces: today, the **OpenVibe Companion** browser extension.**Status:** alpha (roadmap Wave 18, client part). The extension builds, its tests pass, and Chromium--loads the unpacked 0.2.0 build with no console or CSP errors (2026-10-15). A development build of++loads the unpacked 0.3.0 build with no console or CSP errors (2026-10-15, Playwright's Chromium: branded Chrome no++longer loads unpacked extensions from the command line). A development build ofthe coupon part looked up codes and sent a report against a local OpenVibe.Coupons. It is **notpublished** to any store (install it from a release zip, below), and it hasn't been verified inFirefox yet. OpenVibe.Coupons runs on the production host with an empty database, and@@@ -35,6 +36,15 @@ One button in the browser for the OpenVibe sites that work with the page you aresite this is the main button.- **Watch this page.** It opens a new watch on [OpenVibe.Watch](https://openvibe.watch) for the page,which tells you (or runs your agent) when it changes.++- **Compare delivery apps.** On DoorDash, Uber Eats, Grubhub, Seamless, Caviar or Instacart it opens++ [openvibe.food/delivery](https://openvibe.food/delivery) with the restaurant or store's name (read from the page++ title, never the address or the cart), and every app's own search for it opens from there.++- **Try these codes on this page.** At a shop's cart or checkout, one button types the codes OpenVibe.Coupons knows++ into the page's own coupon box, presses that box's Apply button, and reads the order total before and after. It++ stops at the first code that lowers the total and leaves it applied. Codes reported working go first, codes++ reported not working are never tried, and at most ten are tried. It never presses checkout, pay, order, cart or++ remove buttons, never submits a form and never presses Enter. With no Apply button next to the box it presses++ nothing. If you connected the extension, you can send what happened to OpenVibe.Coupons in one click.- **Coupon codes.** On a shop's website it shows the codes[OpenVibe.Coupons](https://github.com/OpenVibers/OpenVibe.Coupons) knows for that shop:@@@ -51,9 +61,9 @@ the Companion never rewrites links or adds referral tags.| | ||---|---|--| **Permissions** | `activeTab` and `storage`. The host permission is `https://openvibe.coupons/*` only. |--| **Not requested** | `<all_urls>`, `tabs`, `cookies`, `scripting`, `webRequest`, `history`, optional permissions, content scripts, a background worker, web-accessible resources, `externally_connectable`. |--| **Read from the page** | The active tab's **URL and title**, when you open the popup (that's what `activeTab` grants). Never the page's content. Browser pages, files, IP addresses and local names are "not a site", and nothing is sent or offered for them. |++| **Permissions** | `activeTab`, `scripting` and `storage`. The host permission is `https://openvibe.coupons/*` only. `scripting` is used for one thing: "Try these codes on this page" injects the packaged `lib/autoapply.js` into the active tab (the access `activeTab` gives after you press the button), in its top frame and the extension's isolated world. |++| **Not requested** | `<all_urls>`, `tabs`, `cookies`, `webRequest`, `history`, optional permissions, content scripts, a background worker, web-accessible resources, `externally_connectable`. |++| **Read from the page** | The active tab's **URL and title**, when you open the popup (that's what `activeTab` grants). The page's content only when you press "Try these codes on this page": `lib/autoapply.js` finds the coupon box, its Apply button and the order total, types the codes in, and returns to the popup which codes changed the total. It makes no request, reads no cookies or storage, and sends nothing anywhere; `test/manifest.test.js` scans it for that. Browser pages, files, IP addresses and local names are "not a site", and nothing is sent or offered for them. || **Sent by the extension** | To `https://openvibe.coupons` only, and only the **hostname** (`lib/host.js` drops the path, query, fragment, port and credentials first): `GET /api/v1/merchants/resolve?host=`, the merchant id (`GET /api/v1/merchants/:id/coupons`), and, when you report, the code id plus `worked` or `failed` with an optional reason. Every request goes with `credentials: 'omit'` (no cookies), `referrerPolicy: 'no-referrer'` and `redirect: 'error'`. || **Hand-offs (only when you press the button)** | "Keep on my Rent board" and "Watch this page" open **a new tab you see** on `https://openvibe.rent/board` or `https://openvibe.watch/watches/new`, with the page's address and title in that tab's address. `lib/handoff.js` removes the credentials, the fragment and tracking parameters (`utm_*`, `fbclid`, `gclid`, …) first. The extension makes no request for it, nothing opens on its own, and nothing is saved until you save it on that site. `platform.tabs.open()` refuses any other origin, and the permission audit allows only `https://openvibe.<tld>` hand-off origins. || **Stored** | Only the optional install token, in `storage.local`. It is never synced to other devices. |@@@ -89,7 +99,8 @@ browser/options.html / .js connect (save a checked token), disconnect, grant the API host permissionlib/config.js API_ORIGIN (the one origin it sends requests to); HANDOFF_ORIGINS (Rent, Watch)lib/host.js URL → hostname (everything else dropped)-- lib/handoff.js the Rent board and Watch addresses, built from a cleaned URL; which sites are rentals++ lib/handoff.js the Rent board, Watch and Food delivery addresses, built from a cleaned URL; which sites are rentals++ lib/autoapply.js "Try these codes on this page": injected into the active tab only when you press itlib/api.js the only code that makes requestslib/controller.js popup logic without the DOM (tested in Node)lib/platform.js chrome.* / browser.*: tabs.query, tabs.create (hand-off pages only), storage.local, permissions@@@ -98,6 +109,7 @@ browser/scripts/audit.js the permission audit (manifest, CSP, pages)pack.js zip for loading or store review (runs the audit first)++ check-autoapply.js lib/autoapply.js on test/fixtures/checkout-*.html in headless Chrome (CDP, no dependencies)icons.js draws the icons (no dependencies)```@@@ -8,7 +8,7 @@"surfaces": {"browser": {"name": "OpenVibe Companion",-- "version": "0.2.0",++ "version": "0.3.0","manifest": 3,"targets": ["chromium >= 111",@@@ -24,7 +24,7 @@],"credential": "optional cpx_ install token (coupons.lookup, coupons.report); no Network session","publishedToStores": false,-- "loadedInChrome": "yes: headless Chrome loads the packed 0.2.0 build (popup and options render, no console errors)",++ "loadedInChrome": "yes: headless Chromium loads the 0.3.0 build (popup and options render, no console errors); lib/autoapply.js passes three fixture checkout pages in headless Chrome","loadedInFirefox": "not yet verified","handoffs": ["https://openvibe.rent/board (keep a listing)",nnew file mode 100644@@@ -0,0 +1,201 @@++/*++ * Try coupon codes in the shop page's own coupon box: the OpenVibe Companion's "Try these codes on this page".++ *++ * Injected into the active tab only when the person presses that button in the popup (activeTab + scripting:++ * no host permission for any shop, no content script that runs by itself). It runs in the extension's isolated++ * world: it reads the page's DOM, types each code into the coupon field and presses the field's own Apply button,++ * and reads the order total before and after. It makes no network request of its own, reads no cookies or++ * storage, and returns only what it saw (which code changed the total) to the popup.++ *++ * run(codes, opts) → { ok, reason?, before, after, results: [{ id, code, outcome, before, after }] }++ * outcome 'worked' the total went down after the code was applied++ * 'failed' the page showed a "not valid / expired / not eligible…" message near the field++ * 'unknown' neither within the wait++ * reason 'no_field' no coupon box on this page (not even behind a "Have a promo code?" link)++ * 'no_apply' a coupon box but no Apply button next to it: nothing is pressed rather than risk++ * submitting the whole checkout form++ *++ * Safety: it never presses a button that reads like ordering or paying (DANGER_RE), never submits a form, and++ * never presses Enter. It stops at the first code that lowers the total and leaves it applied; at most ten codes++ * are tried.++ */++(function (root, factory) {++ const api = factory();++ if (typeof module === 'object' && module.exports) module.exports = api;++ else root.OVAutoApply = api;++})(typeof self !== 'undefined' ? self : this, function () {++ 'use strict';++++ const FIELD_RE = /(coupon|promo|discount|voucher|offer|redeem|reward|gift)[\s_-]*(code|cod|key)?|promocode|couponcode|code\s*promo/i;++ const NOT_FIELD_RE = /(e-?mail|zip|postal|phone|tel\b|card[\s_-]*(number|num|no)|cvv|cvc|security[\s_-]*code|expir|search|address|first[\s_-]*name|last[\s_-]*name|full[\s_-]*name|city|state|country|password|quantity|qty|gift[\s_-]*card[\s_-]*pin|pin\b)/i;++ const OPEN_RE = /(have|add|enter|apply|use|got)\b[^.]{0,25}\b(promo|coupon|discount|voucher|offer|gift)|^(\+\s*)?(promo|coupon|discount|voucher)\s*(code)?s?\s*\??$/i;++ const APPLY_RE = /^\s*(apply|redeem|use|add|submit|validate|ok|go)\b/i;++ const DANGER_RE = /(place|submit|complete|confirm|review)\s*(my\s*)?order|\bpay\b|pay\s*now|checkout|check\s*out|purchase|\bbuy\b|continue|next\s*step|proceed|subscribe|sign\s*up|log\s*in|remove|delete|cancel|\b(cart|bag|basket|wish\s*list)\b/i;++ const ERROR_RE = /(invalid|not valid|isn.t valid|is not a valid|expired|has ended|no longer|not applicable|cannot be applied|can.t be applied|could not be applied|couldn.t be applied|doesn.t apply|does not apply|not eligible|ineligible|minimum|not recognized|unable to apply|not found|does not exist|doesn.t exist|try another|not available|already been used|limit reached)/i;++ const TOTAL_RE = /\b(order\s*total|grand\s*total|estimated\s*total|total\s*due|amount\s*due|you\s*pay|total\s*to\s*pay|total)\b/i;++ const NOT_TOTAL_RE = /sub-?\s*total|total\s*savings|you\s*saved|items?\s*total|total\s*items|total\s*discount|tax|shipping/i;++ const AMOUNT_RE = /(?:[$€£¥₹]|\b(?:USD|EUR|GBP|CAD|AUD)\b)\s?-?\d{1,3}(?:[,. ]\d{3})*(?:[.,]\d{1,2})?|-?\d{1,3}(?:[,. ]\d{3})*(?:[.,]\d{1,2})?\s?(?:[$€£¥₹]|\b(?:USD|EUR|GBP|CAD|AUD)\b)/g;++++ /** "$1,234.56" → 1234.56, "1.234,56 €" → 1234.56, "£12" → 12; null when no amount. */++ function parseAmount(text) {++ const m = String(text || '').match(AMOUNT_RE);++ if (!m) return null;++ let s = m[m.length - 1].replace(/[^\d.,-]/g, '');++ const lastDot = s.lastIndexOf('.');++ const lastComma = s.lastIndexOf(',');++ const dec = Math.max(lastDot, lastComma);++ if (dec > -1 && s.length - dec - 1 <= 2 && s.length - dec - 1 > 0) {++ s = s.slice(0, dec).replace(/[.,]/g, '') + '.' + s.slice(dec + 1);++ } else {++ s = s.replace(/[.,]/g, '');++ }++ const n = Number(s);++ return Number.isFinite(n) ? n : null;++ }++++ function shown(el) {++ if (!el || !el.isConnected) return false;++ if (el.disabled || el.readOnly || el.type === 'hidden') return false;++ const view = el.ownerDocument && el.ownerDocument.defaultView;++ const st = view && view.getComputedStyle ? view.getComputedStyle(el) : null;++ if (st && (st.visibility === 'hidden' || st.display === 'none')) return false;++ return Boolean(el.getClientRects && el.getClientRects().length);++ }++++ /** Everything that names a field: name, id, placeholder, aria-label, autocomplete and its <label>s. */++ function nameOf(el) {++ const bits = [el.name, el.id, el.getAttribute && el.getAttribute('placeholder'), el.getAttribute && el.getAttribute('aria-label'),++ el.getAttribute && el.getAttribute('autocomplete'), el.getAttribute && el.getAttribute('data-testid')];++ if (el.labels) for (let i = 0; i < el.labels.length; i++) bits.push(el.labels[i].textContent);++ return bits.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();++ }++++ function isCouponField(el) {++ if (!el || el.tagName !== 'INPUT') return false;++ const type = String(el.type || 'text').toLowerCase();++ if (type !== 'text' && type !== 'search' && type !== '') return false;++ const n = nameOf(el);++ return FIELD_RE.test(n) && !NOT_FIELD_RE.test(n.replace(FIELD_RE, ''));++ }++++ function findField(doc) {++ const inputs = doc.querySelectorAll('input');++ for (let i = 0; i < inputs.length; i++) if (isCouponField(inputs[i]) && shown(inputs[i])) return inputs[i];++ return null;++ }++++ const textOf = (el) => String((el && (el.innerText || el.textContent || el.value)) || '').replace(/\s+/g, ' ').trim();++++ /** A "Have a promo code?" link or button that reveals the box. */++ function findOpener(doc) {++ const els = doc.querySelectorAll('a, button, summary, [role="button"], label');++ for (let i = 0; i < els.length; i++) {++ const t = textOf(els[i]);++ if (t && t.length <= 60 && OPEN_RE.test(t) && !DANGER_RE.test(t) && shown(els[i])) return els[i];++ }++ return null;++ }++++ /** The field's own Apply button: a short "Apply/Redeem/Use…" label within three levels around the field, never an order or pay button. */++ function findApply(field) {++ let box = field.parentElement;++ for (let depth = 0; box && depth < 4; depth++, box = box.parentElement) {++ const els = box.querySelectorAll('button, input[type="submit"], input[type="button"], [role="button"], a');++ for (let i = 0; i < els.length; i++) {++ const el = els[i];++ if (el === field) continue;++ const t = textOf(el) || String(el.getAttribute('aria-label') || '');++ if (t && t.length <= 24 && APPLY_RE.test(t) && !DANGER_RE.test(t) && shown(el)) return el;++ }++ }++ return null;++ }++++ /**++ * The order total: walking the page's text once, the last visible text that names a total (not a subtotal, tax or++ * saving) with an amount in it or just after it.++ */++ function readTotal(doc) {++ if (!doc.body || !doc.createTreeWalker) return null;++ const walker = doc.createTreeWalker(doc.body, 4 /* NodeFilter.SHOW_TEXT */);++ let found = null;++ for (let n = walker.nextNode(); n; n = walker.nextNode()) {++ const t = n.nodeValue;++ if (!t || t.length > 80 || !TOTAL_RE.test(t) || NOT_TOTAL_RE.test(t)) continue;++ const el = n.parentElement;++ if (!el || !el.getClientRects || !el.getClientRects().length) continue;++ let amount = parseAmount(textOf(el));++ for (let up = el, hop = 0; amount == null && up && hop < 3; up = up.parentElement, hop++) {++ const sib = up.nextElementSibling;++ if (sib) amount = parseAmount(textOf(sib));++ if (amount == null && up.parentElement) {++ const row = textOf(up.parentElement);++ if (row.length <= 120 && !NOT_TOTAL_RE.test(row)) amount = parseAmount(row);++ }++ }++ if (amount != null && amount >= 0) found = amount;++ }++ return found;++ }++++ /** Set an input's value so React, Vue and plain pages all see it. */++ function setValue(input, value) {++ const proto = Object.getPrototypeOf(input);++ const desc = proto && Object.getOwnPropertyDescriptor(proto, 'value');++ if (desc && desc.set) desc.set.call(input, value); else input.value = value;++ input.dispatchEvent(new Event('input', { bubbles: true }));++ input.dispatchEvent(new Event('change', { bubbles: true }));++ }++++ /** The text around the field (where a shop writes "Code not valid"). */++ function around(field) {++ let box = field.parentElement;++ for (let depth = 0; box && box.parentElement && depth < 3 && textOf(box).length < 400; depth++) box = box.parentElement;++ return textOf(box);++ }++++ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));++++ async function run(codes, opts) {++ const o = opts || {};++ const doc = o.document || document;++ const waitMs = o.waitMs || 4500;++ const list = (Array.isArray(codes) ? codes : []).filter((c) => c && typeof c.code === 'string' && c.code.trim()).slice(0, 10);++ let field = findField(doc);++ if (!field) {++ const opener = findOpener(doc);++ if (opener) { opener.click(); await sleep(o.openMs || 800); field = findField(doc); }++ }++ const before = readTotal(doc);++ if (!field) return { ok: false, reason: 'no_field', before, after: before, results: [] };++ if (!findApply(field)) return { ok: false, reason: 'no_apply', before, after: before, results: [] };++ const results = [];++ for (const c of list) {++ if (!field.isConnected) field = findField(doc);++ const button = field && findApply(field);++ if (!field || !button) break;++ const t0 = readTotal(doc);++ const said = around(field);++ setValue(field, c.code.trim());++ button.click();++ let outcome = 'unknown';++ let total = t0;++ const end = Date.now() + waitMs;++ while (Date.now() < end) {++ await sleep(o.pollMs || 250);++ total = readTotal(doc);++ if (t0 != null && total != null && total < t0 - 0.004) { outcome = 'worked'; break; }++ const f = field.isConnected ? field : findField(doc);++ const now = f ? around(f) : textOf(doc.body);++ if (now !== said && ERROR_RE.test(now)) { outcome = 'failed'; break; }++ }++ results.push({ id: c.id || null, code: c.code.trim(), outcome, before: t0, after: total });++ if (outcome === 'worked') break;++ // Clear the box for the next code.++ if (field.isConnected) setValue(field, '');++ }++ return { ok: true, before, after: readTotal(doc), results };++ }++++ return { run, parseAmount, isCouponField, findField, findOpener, findApply, readTotal, nameOf, DANGER_RE, APPLY_RE };++});@@@ -6,8 +6,11 @@* a development build (--api http://localhost:4850) and test/manifest.test.js checks they agree.** HANDOFF_ORIGINS are the sites the Companion may open a tab on when you press one of its buttons-- * (lib/handoff.js): the Rent board and a new Watch. It sends them nothing itself; the address of the-- * tab it opens carries the page you were on, and you see it.++ * (lib/handoff.js): the Rent board, a new Watch, and Food's delivery comparison. It sends them nothing++ * itself; the address of the tab it opens carries the page you were on, and you see it.++ *++ * MAX_TRIES is how many codes "Try these codes on this page" types into a shop's coupon box at most++ * (lib/autoapply.js stops at the first that lowers the total).*/(function (root, factory) {const api = factory();@@@ -19,7 +22,9 @@API_ORIGIN: 'https://openvibe.coupons',RENT_ORIGIN: 'https://openvibe.rent',WATCH_ORIGIN: 'https://openvibe.watch',-- HANDOFF_ORIGINS: Object.freeze(['https://openvibe.rent', 'https://openvibe.watch']),++ FOOD_ORIGIN: 'https://openvibe.food',++ HANDOFF_ORIGINS: Object.freeze(['https://openvibe.rent', 'https://openvibe.watch', 'https://openvibe.food']),++ MAX_TRIES: 10,CONNECT_PATH: '/connect-extension',TOKEN_KEY: 'installToken',TOKEN_RE: /^cpx_[A-Za-z0-9_-]{43}$/,@@@ -6,6 +6,8 @@* c.page() // { handoff, rental }: which buttons to offer* await c.keepOnBoard() // open the Rent board with this page (a new tab)* await c.watchPage() // open a new Watch for this page (a new tab)++ * await c.compareDelivery() // the restaurant on every delivery app, on openvibe.food (a new tab)++ * await c.tryCodes(coupons) // type the codes into this page's coupon box (lib/autoapply.js) → a view of it* await c.report(couponId, 'worked') // → { ok, message }** open() reads the active tab's URL and title once. Only the hostname (host.hostnameOf) is sent@@@ -45,7 +47,7 @@const platform = deps.platform;const host = deps.host;const handoff = deps.handoff || null;-- let tab = { url: null, title: '' };++ let tab = { id: null, url: null, title: '' };async function handOff(build) {const target = handoff && build(tab.url, tab.title);@@@ -71,14 +73,52 @@}},-- /** Which hand-offs make sense for this page: any web page can go to the board or a watch; a rental site leads with the board. */++ /** Which hand-offs make sense for this page: any web page can go to the board or a watch; a rental site leads with the board; a delivery app offers the comparison. */page() {const ok = Boolean(handoff && host.hostnameOf(tab.url) && handoff.boardUrl(tab.url, tab.title));-- return { handoff: ok, rental: ok && deps.isRental ? deps.isRental(tab.url) : false };++ return {++ handoff: ok,++ rental: ok && deps.isRental ? deps.isRental(tab.url) : false,++ delivery: ok && Boolean(handoff.deliveryUrl && handoff.deliveryUrl(tab.url, tab.title)),++ };},keepOnBoard() { return handOff(function (u, t) { return handoff.boardUrl(u, t); }); },watchPage() { return handOff(function (u, t) { return handoff.watchUrl(u, t); }); },++ compareDelivery() { return handOff(function (u, t) { return handoff.deliveryUrl(u, t); }); },++++ /**++ * Try the codes in this page's own coupon box. → { ok, message, results: [{ id, code, outcome, saved }] }.++ * Codes reported working come first; at most config.MAX_TRIES.++ */++ async tryCodes(coupons) {++ const rank = { reported_working: 0, unknown: 1 };++ const list = (coupons || []).filter(function (c) { return c && c.code && c.status !== 'reported_not_working'; })++ .slice().sort(function (a, b) { return (rank[a.status] == null ? 2 : rank[a.status]) - (rank[b.status] == null ? 2 : rank[b.status]); });++ if (!list.length) return { ok: false, message: 'There are no codes to try here.', results: [] };++ let r;++ try {++ r = await platform.scripting.tryCodes(tab.id, list.map(function (c) { return { id: c.id, code: c.code }; }));++ } catch (err) {++ return { ok: false, message: 'The Companion could not reach this page. Reload it and try again.', results: [] };++ }++ if (!r) return { ok: false, message: 'The page did not answer. Reload it and try again.', results: [] };++ if (!r.ok) {++ return { ok: false, results: [], message: r.reason === 'no_apply'++ ? 'This page has a coupon box but no Apply button next to it, so nothing was pressed. Copy a code below instead.'++ : 'No coupon box on this page. Open your cart or the checkout page, then try again.' };++ }++ const results = r.results.map(function (x) {++ const saved = x.outcome === 'worked' && x.before != null && x.after != null ? Math.round((x.before - x.after) * 100) / 100 : null;++ return { id: x.id, code: x.code, outcome: x.outcome, saved: saved };++ });++ const win = results.find(function (x) { return x.outcome === 'worked'; });++ return {++ ok: true, results: results,++ message: win ? 'Applied ' + win.code + (win.saved ? ': the total went down by ' + win.saved.toFixed(2) : '') + '. Check the total before you pay.'++ : 'None of the codes lowered the total on this page.',++ };++ },async report(couponId, outcome, reason) {try {@@@ -4,6 +4,9 @@** boardUrl(url, title) https://openvibe.rent/board?url=…&title=… keep a listing on your Rent board* watchUrl(url, title) https://openvibe.watch/watches/new?template=page… watch the page for changes++ * deliveryUrl(url, title) https://openvibe.food/delivery?q=… the same restaurant on every delivery app++ * (on a DoorDash, Uber Eats, Grubhub, Seamless, Caviar or Instacart page: the restaurant or++ * store name read from the page title, never the address or the cart)** This is the only code that uses more of the page than its hostname, and what it builds is a page* address opened in a new tab you see, never a request the extension makes. cleanUrl() drops the@@@ -26,6 +29,37 @@'loopnet.com', 'spothero.com', 'sparefoot.com', 'turo.com', 'spareroom.com', 'spareroom.co.uk', 'rightmove.co.uk', 'zoopla.co.uk','kijiji.ca', 'rentals.ca', 'idealista.com', 'domain.com.au', 'realestate.com.au', 'openvibe.rent'];++ /** The delivery apps Food compares, and how each one's page title names the restaurant or store. */++ const DELIVERY = [++ { host: 'doordash.com', name: /^(?:Order\s+)?(.+?)\s+(?:Delivery|Menu|Near Me|-|\||\u2013)/i },++ { host: 'ubereats.com', name: /^(?:Order\s+)?(.+?)\s*(?:\(|Menu|Delivery|-|\||\u2013)/i },++ { host: 'grubhub.com', name: /^(?:Order\s+)?(.+?)\s+(?:Delivery|Menu|-|\||\u2013)/i },++ { host: 'seamless.com', name: /^(?:Order\s+)?(.+?)\s+(?:Delivery|Menu|-|\||\u2013)/i },++ { host: 'trycaviar.com', name: /^(?:Order\s+)?(.+?)\s+(?:Delivery|Menu|-|\||\u2013)/i },++ { host: 'instacart.com', name: /^(?:Order\s+)?(.+?)\s+(?:Delivery|Same-Day|-|\||\u2013)/i },++ ];++ // A title about the app itself (its home or a search page) names no restaurant.++ const GENERIC_TITLE = /^(doordash|uber\s*eats|grubhub|seamless|caviar|instacart)\b|^(food|restaurant|grocery)( delivery)?$|^home$|^search\b/i;++++ function deliveryApp(url) {++ let u;++ try { u = new URL(String(url)); } catch (e) { return null; }++ const h = u.hostname.toLowerCase().replace(/^www\./, '');++ return DELIVERY.find(function (d) { return h === d.host || h.endsWith('.' + d.host); }) || null;++ }++++ function isDelivery(url) { return Boolean(deliveryApp(url)); }++++ /** The restaurant or store a delivery app's page is about, from its title; '' when the title names none. */++ function deliveryQuery(url, title) {++ const app = deliveryApp(url);++ const t = cleanTitle(title, 200);++ if (!app || !t) return '';++ const m = app.name.exec(t);++ const name = cleanTitle(m ? m[1] : t.split(/\s[|\-\u2013]\s/)[0], 80).replace(/\s+(Delivery|Menu)$/i, '');++ return name && !GENERIC_TITLE.test(name) ? name : '';++ }++function cleanUrl(url) {if (typeof url !== 'string' || !url || url.length > 4000) return null;let u;@@@ -58,10 +92,16 @@function createHandoff(config) {const RENT = config.RENT_ORIGIN;const WATCH = config.WATCH_ORIGIN;++ const FOOD = config.FOOD_ORIGIN;const origins = config.HANDOFF_ORIGINS;return {boardUrl: function (url, title) { return boardUrl(RENT, url, title); },watchUrl: function (url, title) { return watchUrl(WATCH, url, title); },++ deliveryUrl: function (url, title) {++ if (!FOOD || !isDelivery(url)) return null;++ const q = deliveryQuery(url, title);++ return FOOD + '/delivery' + (q ? '?' + new URLSearchParams({ q: q }).toString() : '');++ },isHandoff: function (url) { return isHandoff(origins, url); },};}@@@ -89,5 +129,5 @@return typeof url === 'string' && origins.some(function (o) { return url.indexOf(o + '/') === 0; });}-- return { createHandoff, RENTAL_HOSTS, cleanUrl, isRental, isHandoff };++ return { createHandoff, RENTAL_HOSTS, cleanUrl, isRental, isHandoff, isDelivery, deliveryQuery };});@@@ -10,8 +10,11 @@* (never storage.sync: it stays on this device)* permissions.hasApi/requestApi the host permission for the API origin (Firefox* lets people withhold it; reporting needs it)++ * scripting.tryCodes(tabId, codes) when the person presses "Try these codes on this page": inject++ * lib/autoapply.js into that tab (activeTab grants it for this one tab, after++ * that click) and run it with the codes; returns what it saw*-- * Nothing else: no cookies, no scripting, no history, no page access.++ * Nothing else: no cookies, no history, and the page is touched only by that one button.*/(function (root, factory) {const api = factory();@@@ -31,7 +34,7 @@async active() {const tabs = await call(ext.tabs.query, ext.tabs, [{ active: true, currentWindow: true }]);const t = tabs && tabs[0];-- return { url: t && typeof t.url === 'string' ? t.url : null, title: t && typeof t.title === 'string' ? t.title : '' };++ return { id: t && Number.isInteger(t.id) ? t.id : null, url: t && typeof t.url === 'string' ? t.url : null, title: t && typeof t.title === 'string' ? t.title : '' };},async open(url) {const ok = typeof url === 'string' && config.HANDOFF_ORIGINS.some(function (o) { return url.indexOf(o + '/') === 0; });@@@ -39,6 +42,19 @@await call(ext.tabs.create, ext.tabs, [{ url: url }]);},},++ scripting: {++ async tryCodes(tabId, codes) {++ if (!ext.scripting || !Number.isInteger(tabId)) throw new TypeError('this browser cannot run the code tester');++ const list = (Array.isArray(codes) ? codes : []).slice(0, config.MAX_TRIES).map(function (c) { return { id: String(c.id || ''), code: String(c.code || '') }; });++ await call(ext.scripting.executeScript, ext.scripting, [{ target: { tabId: tabId }, files: ['lib/autoapply.js'] }]);++ const out = await call(ext.scripting.executeScript, ext.scripting, [{++ target: { tabId: tabId },++ func: function (c) { return self.OVAutoApply.run(c); },++ args: [list],++ }]);++ return out && out[0] ? out[0].result : null;++ },++ },storage: {async getToken() {const out = await call(ext.storage.local.get, ext.storage.local, [config.TOKEN_KEY]);@@@ -2,8 +2,8 @@"manifest_version": 3,"name": "OpenVibe Companion","short_name": "OpenVibe",-- "version": "0.2.0",-- "description": "Coupon codes for the shop you are on, and one click to keep a rental listing on your OpenVibe.Rent board or watch a page with OpenVibe.Watch. Reads the tab only when you open it.",++ "version": "0.3.0",++ "description": "Coupon codes for the shop you are on, tried in its coupon box when you ask; the same restaurant on every delivery app; and one click to keep a rental listing on your OpenVibe.Rent board or watch a page. Reads the tab only when you open it.","homepage_url": "https://github.com/OpenVibers/OpenVibe.Extensions","author": "OpenVibers","icons": {@@@ -26,6 +26,7 @@},"permissions": ["activeTab",++ "scripting","storage"],"host_permissions": [@@@ -40,7 +41,9 @@"id": "[email protected]","strict_min_version": "128.0","data_collection_permissions": {-- "required": ["browsingActivity"]++ "required": [++ "browsingActivity"++ ]}}}@@@ -29,3 +29,7 @@ footer { border-top: 1px solid var(--line); font-size: 12px; }a { color: var(--accent); }body.options { width: auto; max-width: 640px; max-height: none; margin: 0 auto; padding: 12px 16px; font-size: 14px; }body.options input { font: inherit; padding: 4px 6px; max-width: 100%; }++.try { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; margin: 8px 0; }++.try ol { margin: 6px 0 2px; padding-left: 18px; }++.try li.worked { color: var(--ok); }++.try li.failed { color: var(--bad); }@@@ -15,8 +15,9 @@<div class="row"><button type="button" id="keep" class="btn">Keep on my Rent board</button><button type="button" id="watch" class="btn">Watch this page</button>++ <button type="button" id="delivery" class="btn" hidden>Compare delivery apps</button></div>-- <p id="actions-note" class="note muted">Opens openvibe.rent or openvibe.watch in a new tab with this page’s address.</p>++ <p id="actions-note" class="note muted">Opens openvibe.rent, openvibe.watch or openvibe.food in a new tab with this page’s address.</p></section><main id="content" aria-live="polite"><p class="muted">Looking up this shop…</p></main><footer>@@@ -91,11 +91,15 @@if (!p.handoff) return;const keep = document.getElementById('keep');const watch = document.getElementById('watch');++ const delivery = document.getElementById('delivery');const note = document.getElementById('actions-note');-- document.getElementById('actions-lede').textContent = p.rental-- ? 'A rental listing? Keep it on your board with your notes, next to the ones from every other site.'-- : 'Keep this page on your Rent board, or have OpenVibe.Watch tell you when it changes.';-- (p.rental ? keep : watch).classList.add('primary');++ document.getElementById('actions-lede').textContent = p.delivery++ ? 'Ordering delivery? See the same restaurant on every delivery app, with each app’s own prices and fees.'++ : p.rental++ ? 'A rental listing? Keep it on your board with your notes, next to the ones from every other site.'++ : 'Keep this page on your Rent board, or have OpenVibe.Watch tell you when it changes.';++ delivery.hidden = !p.delivery;++ (p.delivery ? delivery : p.rental ? keep : watch).classList.add('primary');const go = function (fn) {return async function () {const r = await fn();@@@ -105,19 +109,57 @@};keep.addEventListener('click', go(controller.keepOnBoard));watch.addEventListener('click', go(controller.watchPage));++ delivery.addEventListener('click', go(controller.compareDelivery));section.hidden = false;}++ const OUTCOME = { worked: 'lowered the total', failed: 'not accepted', unknown: 'no change seen' };++++ /** "Try these codes on this page": the button, what it does, and what happened to each code. */++ function tester(coupons, connected) {++ const list = el('ol');++ const note = el('p', { className: 'note muted', text: 'Types each code into this page’s coupon box and presses its Apply button: never checkout or pay. It stops at the first code that lowers the total and leaves it applied.' });++ const status = el('p', { className: 'note', 'aria-live': 'polite' });++ const send = el('button', { type: 'button', hidden: 'hidden', text: 'Send these results to OpenVibe.Coupons' });++ const run = el('button', { type: 'button', className: 'btn primary', text: 'Try these codes on this page' });++ let done = [];++ run.addEventListener('click', async function () {++ run.disabled = true;++ status.textContent = 'Trying codes…';++ while (list.firstChild) list.removeChild(list.firstChild);++ const r = await controller.tryCodes(coupons);++ status.textContent = r.message;++ done = r.results.filter(function (x) { return x.outcome !== 'unknown' && x.id; });++ r.results.forEach(function (x) {++ list.appendChild(el('li', { className: x.outcome, text: x.code + ': ' + OUTCOME[x.outcome] + (x.saved ? ' (−' + x.saved.toFixed(2) + ')' : '') }));++ });++ send.hidden = !(connected && done.length);++ run.disabled = false;++ run.textContent = 'Try again';++ });++ send.addEventListener('click', async function () {++ send.disabled = true;++ let ok = 0;++ for (const x of done) {++ const r = await controller.report(x.id, x.outcome === 'worked' ? 'worked' : 'failed', x.outcome === 'failed' ? 'invalid' : null);++ if (r.ok) ok++;++ }++ status.textContent = ok ? 'Thanks: ' + ok + ' result' + (ok === 1 ? '' : 's') + ' sent.' : 'The results could not be sent.';++ send.hidden = true;++ });++ return el('section', { className: 'try', 'aria-label': 'Try the codes on this page' }, run, note, status, list, send);++ }++async function render() {const vm = await controller.open();if (vm.host) site.textContent = vm.host;renderActions();-- if (vm.state === 'not_a_site') return show(el('p', { text: 'Open this on a website: a shop for its coupon codes, a rental listing for your board, any page to watch.' }));++ if (vm.state === 'not_a_site') return show(el('p', { text: 'Open this on a website: a shop for its coupon codes, a delivery app to compare with the others, a rental listing for your board, any page to watch.' }));if (vm.state === 'no_merchant') return show(el('p', { className: 'muted', text: 'No coupon codes for ' + vm.host + ' on OpenVibe.Coupons. ' }, link('Submit one', '/submit')));if (vm.state === 'error') return show(el('p', { text: vm.message }));const header = el('h2', { text: 'Coupon codes for ' + vm.merchant.name });if (!vm.coupons.length) return show(header, el('p', { text: 'No active codes right now.' }), el('p', null, link('See the shop page', '/m/' + encodeURIComponent(vm.merchant.slug))));-- return show(header, ...vm.coupons.map(function (c) { return couponNode(c, vm.connected); }), el('p', { className: 'muted' }, link('All codes and details', '/m/' + encodeURIComponent(vm.merchant.slug))));++ return show(header, tester(vm.coupons, vm.connected), ...vm.coupons.map(function (c) { return couponNode(c, vm.connected); }), el('p', { className: 'muted' }, link('All codes and details', '/m/' + encodeURIComponent(vm.merchant.slug))));}document.getElementById('settings').addEventListener('click', function (ev) {@@@ -1,6 +1,6 @@{"name": "openvibe-extensions",-- "version": "0.2.0",++ "version": "0.3.0","private": true,"description": "OpenVibe's first-party client surfaces (ADR-023). Today: the OpenVibe Companion, a Manifest V3 browser extension for Chromium and Firefox: coupon codes from OpenVibe.Coupons, and one click to keep a listing on the OpenVibe.Rent board or watch a page with OpenVibe.Watch.","scripts": {@@@ -3,7 +3,8 @@* The permission audit for browser/manifest.json. test/manifest.test.js runs it, and so does* scripts/pack.js before it writes a zip. It fails when anything widens:*-- * permissions exactly activeTab + storage++ * permissions exactly activeTab + scripting + storage (scripting runs lib/autoapply.js in the active tab only,++ * when the person presses "Try these codes on this page": activeTab is the only host access)* host_permissions exactly <API origin>/** forbidden keys content_scripts, background, web_accessible_resources,* externally_connectable, optional_permissions, optional_host_permissions,@@@ -20,7 +21,7 @@ const path = require('path');const ALLOWED_KEYS = new Set(['manifest_version', 'name', 'short_name', 'version', 'description', 'homepage_url', 'author', 'icons','action', 'options_ui', 'permissions', 'host_permissions', 'content_security_policy', 'minimum_chrome_version', 'browser_specific_settings']);--const PERMISSIONS = ['activeTab', 'storage'];++const PERMISSIONS = ['activeTab', 'scripting', 'storage'];function auditManifest(m, { apiOrigin }) {const problems = [];nnew file mode 100644@@@ -0,0 +1,130 @@++#!/usr/bin/env node++'use strict';++/**++ * browser/lib/autoapply.js in a real browser: headless Chrome (CDP over Node's own WebSocket, no dependencies) opens++ * each fixture checkout page in test/fixtures/, runs the module there, and checks what it did:++ *++ * checkout-simple.html a promo box with an Apply button: the bad code fails on the page's message, SAVE10 lowers++ * the total and is kept; the Place order button is never pressed++ * checkout-hidden.html the box sits behind "Have a promo code?" and the page reads its value from input events (a++ * framework-style field); € amounts with a decimal comma++ * checkout-noapply.html a discount field with no Apply button, in a form whose only button is "Pay now": nothing++ * is typed or pressed (reason no_apply)++ *++ * node scripts/check-autoapply.js (CHROME=/path/to/chrome to choose the browser)++ *++ * test/autoapply.test.js runs it when a Chrome is installed and says so when it is not.++ */++const fs = require('fs');++const os = require('os');++const path = require('path');++const { spawn } = require('child_process');++++const CHROME = process.env.CHROME || ['/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser'].find((p) => fs.existsSync(p));++const ROOT = path.join(__dirname, '..');++const SOURCE = fs.readFileSync(path.join(ROOT, 'browser', 'lib', 'autoapply.js'), 'utf8');++++const CASES = [++ {++ file: 'checkout-simple.html',++ codes: [{ id: 'cpn_bad', code: 'NOPE5' }, { id: 'cpn_good', code: 'SAVE10' }, { id: 'cpn_never', code: 'LATER' }],++ expect: (r, page) => {++ const out = r.results.map((x) => `${x.code}:${x.outcome}`).join(' ');++ if (out !== 'NOPE5:failed SAVE10:worked') return `results ${out}`;++ if (r.before !== 54 || r.after !== 44) return `totals ${r.before} → ${r.after}`;++ if (page.submits) return 'the checkout form was submitted';++ if (page.tried.join(',') !== 'NOPE5,SAVE10') return `tried ${page.tried}`;++ return null;++ },++ },++ {++ file: 'checkout-hidden.html',++ codes: [{ id: 'a', code: 'OLD' }, { id: 'b', code: 'WELCOME' }],++ expect: (r, page) => {++ const out = r.results.map((x) => `${x.code}:${x.outcome}`).join(' ');++ if (out !== 'OLD:failed WELCOME:worked') return `results ${out}`;++ if (r.before !== 1234.56 || r.after !== 1111.1) return `totals ${r.before} → ${r.after}`;++ if (page.tried.join(',') !== 'OLD,WELCOME') return `the page saw ${page.tried} (framework value)`;++ return null;++ },++ },++ {++ file: 'checkout-noapply.html',++ codes: [{ id: 'a', code: 'SAVE10' }],++ expect: (r, page) => {++ if (r.ok || r.reason !== 'no_apply') return `ok ${r.ok} reason ${r.reason}`;++ if (page.submits) return 'the form was submitted';++ if (page.value) return `the field was filled (${page.value})`;++ return null;++ },++ },++];++++function cdp(url) {++ const ws = new WebSocket(url);++ let id = 0;++ const waiting = new Map();++ ws.addEventListener('message', (ev) => {++ const m = JSON.parse(ev.data);++ if (m.id && waiting.has(m.id)) { const w = waiting.get(m.id); waiting.delete(m.id); if (m.error) w.reject(new Error(m.error.message)); else w.resolve(m.result); }++ });++ const ready = new Promise((resolve, reject) => { ws.addEventListener('open', resolve); ws.addEventListener('error', reject); });++ return {++ ready,++ send(method, params = {}) { return new Promise((resolve, reject) => { const n = ++id; waiting.set(n, { resolve, reject }); ws.send(JSON.stringify({ id: n, method, params })); }); },++ close() { ws.close(); },++ };++}++++async function evaluate(c, expression) {++ const r = await c.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });++ if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception ? r.exceptionDetails.exception.description : r.exceptionDetails.text);++ return r.result.value;++}++++const pause = (ms) => new Promise((r) => setTimeout(r, ms));++++async function main() {++ if (!CHROME) return { skipped: 'no Chrome or Chromium installed (set CHROME)' };++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-autoapply-'));++ const proc = spawn(CHROME, ['--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--allow-file-access-from-files',++ `--user-data-dir=${dir}`, '--remote-debugging-port=0', 'about:blank'], { stdio: 'ignore' });++ try {++ let port = null;++ for (let i = 0; i < 100 && !port; i++) {++ await pause(100);++ try { port = fs.readFileSync(path.join(dir, 'DevToolsActivePort'), 'utf8').split('\n')[0]; } catch { /* not yet */ }++ }++ if (!port) throw new Error('Chrome did not start');++ const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();++ const page = targets.find((t) => t.type === 'page');++ const c = cdp(page.webSocketDebuggerUrl);++ await c.ready;++ const failures = [];++ for (const k of CASES) {++ await c.send('Page.navigate', { url: `file://${path.join(ROOT, 'test', 'fixtures', k.file)}` });++ for (let i = 0; i < 50 && (await evaluate(c, 'document.readyState')) !== 'complete'; i++) await pause(50);++ await evaluate(c, SOURCE);++ const r = await evaluate(c, `OVAutoApply.run(${JSON.stringify(k.codes)}, { waitMs: 2500, openMs: 300 })`);++ const seen = await evaluate(c, '({ tried: window.tried || [], submits: window.submits || 0, value: (document.querySelector("input[name=discount_code]") || {}).value || "" })');++ const problem = k.expect(r, seen);++ if (problem) failures.push(`${k.file}: ${problem} — ${JSON.stringify(r)}`);++ }++ c.close();++ return { failures, cases: CASES.length };++ } finally {++ proc.kill();++ await pause(200);++ fs.rmSync(dir, { recursive: true, force: true });++ }++}++++module.exports = { main };++++if (require.main === module) {++ main().then((r) => {++ if (r.skipped) { console.log(`autoapply in Chrome: skipped (${r.skipped})`); return; }++ if (r.failures.length) { console.error(r.failures.join('\n')); process.exit(1); }++ console.log(`autoapply in Chrome: ${r.cases}/${r.cases} fixture pages behave`);++ }).catch((err) => { console.error(err); process.exit(1); });++}nnew file mode 100644@@@ -0,0 +1,128 @@++'use strict';++/**++ * "Try these codes on this page" (browser/lib/autoapply.js, platform.scripting.tryCodes, controller.tryCodes) and the++ * delivery comparison hand-off:++ * - amounts in the forms shops print them; which fields are coupon boxes; which buttons are never pressed;++ * - the popup injects the one packaged file into the active tab only, codes reported working first, at most ten,++ * never one reported not working, and says what happened;++ * - a delivery app's page opens openvibe.food/delivery with the restaurant's name and nothing else;++ * - and, when a Chrome is installed, the module itself on three fixture checkout pages (scripts/check-autoapply.js).++ */++const assert = require('assert');++const auto = require('../browser/lib/autoapply');++const host = require('../browser/lib/host');++const handoffLib = require('../browser/lib/handoff');++const { createApiClient } = require('../browser/lib/api');++const { createPlatform } = require('../browser/lib/platform');++const { createController } = require('../browser/lib/controller');++const config = require('../browser/lib/config');++const { check, done, startStub, fakeExt } = require('./helpers/harness');++++const field = (attrs, labels = []) => ({++ tagName: 'INPUT', type: attrs.type || 'text', name: attrs.name || '', id: attrs.id || '',++ getAttribute: (k) => (attrs[k] == null ? null : attrs[k]), labels: labels.map((t) => ({ textContent: t })),++});++++(async () => {++ const stub = await startStub();++ const handoff = handoffLib.createHandoff(config);++ const popup = ({ url, title = '', scriptResult = null }) => {++ const ext = fakeExt({ url, title, scriptResult });++ const platform = createPlatform(ext, { ...config, API_ORIGIN: stub.origin });++ const api = createApiClient({ origin: stub.origin, fetch, getToken: platform.storage.getToken });++ return { ext, controller: createController({ api, platform, host, handoff, isRental: handoffLib.isRental }) };++ };++++ await check('amounts as shops print them', () => {++ const cases = [['$54.00', 54], ['Order total: $1,234.56', 1234.56], ['€1.234,56', 1234.56], ['1 234,56 €', 1234.56], ['£12', 12], ['USD 9.99', 9.99], ['Total $0.00', 0], ['no amount here', null], ['Save $5 — total $45.10', 45.1]];++ for (const [text, want] of cases) assert.strictEqual(auto.parseAmount(text), want, text);++ });++++ await check('coupon boxes are told apart from the other checkout fields', () => {++ assert.ok(auto.isCouponField(field({ name: 'promo_code' })));++ assert.ok(auto.isCouponField(field({ placeholder: 'Enter coupon code' })));++ assert.ok(auto.isCouponField(field({ id: 'x' }, ['Discount code'])));++ assert.ok(auto.isCouponField(field({ 'aria-label': 'Gift card or discount code' })));++ assert.ok(!auto.isCouponField(field({ name: 'email', placeholder: 'Email for offers' })), 'an email field');++ assert.ok(!auto.isCouponField(field({ name: 'card_number', placeholder: 'Card number' })));++ assert.ok(!auto.isCouponField(field({ name: 'zip' })));++ assert.ok(!auto.isCouponField(field({ name: 'promo', type: 'password' })), 'not a text box');++ assert.ok(!auto.isCouponField({ ...field({ name: 'promo' }), tagName: 'SELECT' }));++ });++++ await check('an Apply button is pressed; ordering, paying and leaving never are', () => {++ for (const t of ['Apply', 'Apply code', 'Redeem', 'Use code', 'Add']) assert.ok(auto.APPLY_RE.test(t) && !auto.DANGER_RE.test(t), t);++ for (const t of ['Place order', 'Pay now', 'Continue to checkout', 'Checkout', 'Buy now', 'Complete order', 'Submit order', 'Remove', 'Proceed to payment', 'Add to cart', 'Add to bag']) {++ assert.ok(auto.DANGER_RE.test(t), t);++ }++ });++++ await check('the popup injects only lib/autoapply.js, in the active tab, with the codes worth trying first', async () => {++ const p = popup({ url: 'https://shop.acme-outdoor.com/cart', scriptResult: { ok: true, before: 54, after: 44, results: [++ { id: 'w', code: 'GOOD', outcome: 'failed', before: 54, after: 54 }, { id: 'u', code: 'SAVE10', outcome: 'worked', before: 54, after: 44 }] } });++ await p.controller.open();++ const coupons = [++ { id: 'u', code: 'SAVE10', status: 'unknown' }, { id: 'n', code: 'DEAD', status: 'reported_not_working' },++ { id: 'w', code: 'GOOD', status: 'reported_working' }, ...Array.from({ length: 12 }, (_, i) => ({ id: `x${i}`, code: `X${i}`, status: 'unknown' })),++ ];++ const r = await p.controller.tryCodes(coupons);++ const calls = p.ext.log.filter((l) => l[0] === 'scripting.executeScript').map((l) => l[1]);++ assert.strictEqual(calls.length, 2);++ assert.deepStrictEqual(calls[0], { target: { tabId: 1 }, files: ['lib/autoapply.js'] });++ assert.deepStrictEqual(calls[1].target, { tabId: 1 });++ const sent = calls[1].args[0];++ assert.strictEqual(sent.length, config.MAX_TRIES, 'at most ten codes');++ assert.deepStrictEqual(sent.slice(0, 2).map((c) => c.code), ['GOOD', 'SAVE10'], 'reported working first');++ assert.ok(!sent.some((c) => c.code === 'DEAD'), 'never a code reported not working');++ assert.deepStrictEqual(Object.keys(sent[0]).sort(), ['code', 'id'], 'only the code and its id reach the page');++ assert.strictEqual(r.ok, true);++ assert.match(r.message, /Applied SAVE10: the total went down by 10\.00\. Check the total before you pay\./);++ assert.deepStrictEqual(r.results.map((x) => [x.code, x.outcome, x.saved]), [['GOOD', 'failed', null], ['SAVE10', 'worked', 10]]);++ });++++ await check('no coupon box, or no Apply button: it says so and presses nothing', async () => {++ for (const [reason, re] of [['no_field', /No coupon box on this page/], ['no_apply', /no Apply button next to it, so nothing was pressed/]]) {++ const p = popup({ url: 'https://shop.example.com/checkout', scriptResult: { ok: false, reason, before: 20, after: 20, results: [] } });++ await p.controller.open();++ const r = await p.controller.tryCodes([{ id: 'a', code: 'A', status: 'unknown' }]);++ assert.strictEqual(r.ok, false);++ assert.match(r.message, re);++ }++ const none = popup({ url: 'https://shop.example.com/' });++ await none.controller.open();++ assert.strictEqual((await none.controller.tryCodes([{ id: 'n', code: 'X', status: 'reported_not_working' }])).ok, false);++ assert.strictEqual(none.ext.log.filter((l) => l[0] === 'scripting.executeScript').length, 0, 'nothing injected without a code to try');++ });++++ await check('a delivery app\'s page opens the comparison on openvibe.food with the restaurant\'s name only', async () => {++ const cases = [++ ['https://www.doordash.com/store/chipotle-123/?cart=abc#menu', 'Chipotle Mexican Grill Delivery Menu | 123 Main St San Francisco | DoorDash', 'Chipotle Mexican Grill'],++ ['https://www.ubereats.com/store/chipotle/xyz', 'Order Chipotle (Market St) Menu Delivery Online | San Francisco | Uber Eats', 'Chipotle'],++ ['https://www.grubhub.com/restaurant/joes-pizza/1', 'Joe\'s Pizza - Delivery & Pickup | Grubhub', 'Joe\'s Pizza'],++ ['https://www.instacart.com/store/costco/storefront', 'Costco Delivery Near Me | Instacart', 'Costco'],++ ['https://www.doordash.com/', 'DoorDash Food Delivery', null],++ ];++ for (const [url, title, q] of cases) {++ const u = new URL(handoff.deliveryUrl(url, title));++ assert.strictEqual(u.origin + u.pathname, 'https://openvibe.food/delivery');++ assert.strictEqual(u.searchParams.get('q'), q, title);++ assert.ok(!u.search.includes('123') && !u.search.includes('cart'), 'no address or cart');++ }++ assert.strictEqual(handoff.deliveryUrl('https://acme.com/pizza', 'Pizza'), null, 'not a delivery app');++ const p = popup({ url: cases[0][0], title: cases[0][1] });++ await p.controller.open();++ assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: false, delivery: true });++ assert.deepStrictEqual(p.ext.opened, []);++ assert.strictEqual((await p.controller.compareDelivery()).ok, true);++ assert.deepStrictEqual(p.ext.opened, ['https://openvibe.food/delivery?q=Chipotle+Mexican+Grill']);++ });++++ await check('in a real browser: three fixture checkout pages (skipped without Chrome)', async () => {++ const r = await require('../scripts/check-autoapply').main();++ if (r.skipped) { console.log(` skipped: ${r.skipped}`); return; }++ assert.deepStrictEqual(r.failures, []);++ });++++ await stub.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,24 @@++<!doctype html>++<html lang="en"><head><meta charset="utf-8"><title>Checkout</title></head>++<body>++<p><a href="#" id="open">Have a promo code?</a></p>++<div id="box" style="display:none">++ <input id="code" placeholder="Enter coupon code" type="text">++ <button id="redeem">Redeem</button>++ <span id="err"></span>++</div>++<table><tr><th>Total</th><td id="sum">€1.234,56</td></tr></table>++<script>++// A framework-style field: the page keeps its own copy of the value from input events, so a bare .value write is not seen.++var state = '';++document.getElementById('code').addEventListener('input', function (e) { state = e.target.value; });++document.getElementById('open').addEventListener('click', function (e) { e.preventDefault(); document.getElementById('box').style.display = 'block'; });++document.getElementById('redeem').addEventListener('click', function () {++ window.tried = (window.tried || []).concat(state);++ setTimeout(function () {++ if (state === 'WELCOME') document.getElementById('sum').textContent = '€1.111,10';++ else document.getElementById('err').textContent = 'Sorry, this coupon has expired.';++ }, 200);++});++</script>++</body></html>nnew file mode 100644@@@ -0,0 +1,10 @@++<!doctype html>++<html lang="en"><head><meta charset="utf-8"><title>Checkout</title></head>++<body>++<form id="f" onsubmit="window.submits = (window.submits || 0) + 1; return false;">++ <input name="discount_code" placeholder="Discount code" type="text">++ <input name="card_number" placeholder="Card number" type="text">++ <button type="submit">Pay now</button>++</form>++<p>Total <strong>$20.00</strong></p>++</body></html>nnew file mode 100644@@@ -0,0 +1,30 @@++<!doctype html>++<html lang="en"><head><meta charset="utf-8"><title>Cart</title></head>++<body>++<h1>Your cart</h1>++<div class="summary">++ <div class="row"><span>Subtotal</span><span>$50.00</span></div>++ <div class="row"><span>Shipping</span><span>$4.00</span></div>++ <div class="row total"><span>Order total</span><span id="total">$54.00</span></div>++</div>++<form id="checkout" onsubmit="window.submits = (window.submits || 0) + 1; return false;">++ <div class="promo">++ <label for="promo">Promo code</label>++ <input id="promo" name="promo_code" type="text">++ <button type="button" id="apply">Apply</button>++ <p id="msg"></p>++ </div>++ <input name="email" type="text" placeholder="Email">++ <button type="submit" id="place">Place order</button>++</form>++<script>++document.getElementById('apply').addEventListener('click', function () {++ var code = document.getElementById('promo').value;++ window.tried = (window.tried || []).concat(code);++ setTimeout(function () {++ if (code === 'SAVE10') { document.getElementById('total').textContent = '$44.00'; document.getElementById('msg').textContent = 'SAVE10 applied'; }++ else document.getElementById('msg').textContent = 'The code ' + code + ' is not valid.';++ }, 300);++});++</script>++</body></html>@@@ -57,7 +57,7 @@ const CLEAN = 'https://saltlakecity.craigslist.org/apa/d/sunny-2br/7790000001.htawait p.controller.open();assert.deepStrictEqual(p.ext.opened, [], 'nothing opens on its own');assert.deepStrictEqual(stub.requests.map((r) => r.url), ['/api/v1/merchants/resolve?host=saltlakecity.craigslist.org']);-- assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: true });++ assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: true, delivery: false });assert.deepStrictEqual(await p.controller.keepOnBoard(), { ok: true });assert.strictEqual(p.ext.opened.length, 1);@@@ -71,7 +71,7 @@ const CLEAN = 'https://saltlakecity.craigslist.org/apa/d/sunny-2br/7790000001.htawait check('pages that are not websites offer no hand-off, and platform.tabs.open refuses anywhere else', async () => {const p = popup({ url: 'chrome://extensions' });await p.controller.open();-- assert.deepStrictEqual(p.controller.page(), { handoff: false, rental: false });++ assert.deepStrictEqual(p.controller.page(), { handoff: false, rental: false, delivery: false });assert.strictEqual((await p.controller.keepOnBoard()).ok, false);assert.deepStrictEqual(p.ext.opened, []);const platform = createPlatform(fakeExt({}), config);@@@ -83,7 +83,7 @@ const CLEAN = 'https://saltlakecity.craigslist.org/apa/d/sunny-2br/7790000001.htawait check('a shop page that is not a rental leads with Watch', async () => {const p = popup({ url: 'https://acme-outdoor.com/tents/trail-2' });await p.controller.open();-- assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: false });++ assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: false, delivery: false });});await stub.close();@@@ -71,7 +71,7 @@ function recordingFetch() {}/** Fakes of chrome.tabs (query, create) / chrome.storage.local / chrome.permissions; `opened` lists the tabs created. */--function fakeExt({ url = null, title = '', token = null, hasPermission = true } = {}) {++function fakeExt({ url = null, title = '', token = null, hasPermission = true, scriptResult = null } = {}) {const store = token ? { installToken: token } : {};const log = [];const opened = [];@@@ -88,6 +88,10 @@ function fakeExt({ url = null, title = '', token = null, hasPermission = true }async set(o) { log.push(['storage.set', Object.keys(o)]); Object.assign(store, o); },async remove(k) { log.push(['storage.remove', k]); delete store[k]; },} },++ // chrome.scripting.executeScript: the file injection answers [{}], the call answers scriptResult.++ scripting: {++ async executeScript(o) { log.push(['scripting.executeScript', o]); return o.files ? [{}] : [{ result: scriptResult }]; },++ },permissions: {async contains(p) { log.push(['permissions.contains', p]); return hasPermission; },async request(p) { log.push(['permissions.request', p]); return true; },@@@ -15,10 +15,10 @@ const ORIGIN = 'https://openvibe.coupons';(async () => {const { manifest, origin, problems } = auditDir(DIR);-- await check('the shipped manifest passes the audit: activeTab + storage, one host permission (the API), MV3', async () => {++ await check('the shipped manifest passes the audit: activeTab + scripting + storage, one host permission (the API), MV3', async () => {assert.deepStrictEqual(problems, []);assert.strictEqual(origin, ORIGIN);-- assert.deepStrictEqual(manifest.permissions.slice().sort(), ['activeTab', 'storage']);++ assert.deepStrictEqual(manifest.permissions.slice().sort(), ['activeTab', 'scripting', 'storage']);assert.deepStrictEqual(manifest.host_permissions, ['https://openvibe.coupons/*']);for (const k of ['content_scripts', 'background', 'web_accessible_resources', 'externally_connectable', 'optional_permissions', 'optional_host_permissions']) {assert.ok(!(k in manifest), `${k} must not be present`);@@@ -29,7 +29,8 @@ const ORIGIN = 'https://openvibe.coupons';const widen = [(m) => m.permissions.push('tabs'),(m) => m.permissions.push('cookies'),-- (m) => m.permissions.push('scripting'),++ (m) => m.permissions.push('declarativeNetRequest'),++ (m) => { m.permissions = m.permissions.filter((p) => p !== 'scripting').concat('tabs'); },(m) => m.permissions.push('<all_urls>'),(m) => m.permissions.push('webRequest'),(m) => m.permissions.push('history'),@@@ -68,11 +69,11 @@ const ORIGIN = 'https://openvibe.coupons';assert.ok(auditPage('<button onclick="x()">', 'x').length);});-- await check('the code never uses page access, cookies, dynamic code or HTML injection; only api.js fetches', async () => {++ await check('the code never uses cookies, dynamic code or HTML injection; only api.js fetches; only platform.js runs a script in a page', async () => {const files = [];(function walk(d) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else if (p.endsWith('.js')) files.push(p); } })(DIR);const forbidden = [/\binnerHTML\b/, /\bouterHTML\b/, /insertAdjacentHTML/, /document\.write/, /\beval\s*\(/, /new Function\s*\(/, /\bcookies\b/, /document\.cookie/,-- /executeScript/, /\.scripting\b/, /\bhistory\.(search|getVisits)/, /webRequest/, /storage\.sync/, /sendMessage|onMessageExternal|runtime\.connect/, /XMLHttpRequest/, /importScripts/];++ /\bhistory\.(search|getVisits)/, /webRequest/, /storage\.sync/, /sendMessage|onMessageExternal|runtime\.connect/, /XMLHttpRequest/, /importScripts/];for (const f of files) {// Comments may name what the code must not do; only code is scanned.const src = fs.readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"])\/\/.*$/gm, '$1');@@@ -82,6 +83,17 @@ const ORIGIN = 'https://openvibe.coupons';}const tabUse = files.filter((f) => /\btabs\.(query|create|update)\b/.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(DIR, f));assert.deepStrictEqual(tabUse, [path.join('lib', 'platform.js')], 'only the platform wrapper touches tabs (it opens only hand-off pages)');++ // The page is touched only by "Try these codes on this page": platform.js injects the one packaged file and calls it.++ const injects = files.filter((f) => /executeScript|ext\.scripting/.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(DIR, f));++ assert.deepStrictEqual(injects, [path.join('lib', 'platform.js')], 'only the platform wrapper runs a script in a page');++ const plat = fs.readFileSync(path.join(DIR, 'lib', 'platform.js'), 'utf8');++ assert.deepStrictEqual([...plat.matchAll(/files:\s*\[([^\]]*)\]/g)].map((m) => m[1].trim()), ["'lib/autoapply.js'"], 'the one file it injects');++ assert.doesNotMatch(plat, /allFrames|world:\s*['"]MAIN|tabId:(?!\s*tabId\b)/, 'the active tab only, its top frame, the isolated world');++ // What runs in the shop's page makes no request and reads no stored data.++ const auto = fs.readFileSync(path.join(DIR, 'lib', 'autoapply.js'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');++ for (const re of [/\bfetch\s*\(/, /XMLHttpRequest/, /WebSocket/, /sendBeacon/, /localStorage|sessionStorage|indexedDB/, /document\.cookie/, /\.submit\s*\(|requestSubmit/, /KeyboardEvent|keydown|keypress/, /chrome\.|browser\./]) {++ assert.doesNotMatch(auto, re, `lib/autoapply.js uses ${re}`);++ }});done();
@@ -3,7 +3,8 @@
@@ -3,7 +3,8 @@
> OpenVibe's first-party client surfaces: today, the **OpenVibe Companion** browser extension.
> OpenVibe's first-party client surfaces: today, the **OpenVibe Companion** browser extension.
**Status:** alpha (roadmap Wave 18, client part). The extension builds, its tests pass, and Chromium
**Status:** alpha (roadmap Wave 18, client part). The extension builds, its tests pass, and Chromium
-loads the unpacked 0.2.0 build with no console or CSP errors (2026-10-15). A development build of
+loads the unpacked 0.3.0 build with no console or CSP errors (2026-10-15, Playwright's Chromium: branded Chrome no
+longer loads unpacked extensions from the command line). A development build of
the coupon part looked up codes and sent a report against a local OpenVibe.Coupons. It is **not
the coupon part looked up codes and sent a report against a local OpenVibe.Coupons. It is **not
published** to any store (install it from a release zip, below), and it hasn't been verified in
published** to any store (install it from a release zip, below), and it hasn't been verified in
Firefox yet. OpenVibe.Coupons runs on the production host with an empty database, and
Firefox yet. OpenVibe.Coupons runs on the production host with an empty database, and
@@ -35,6 +36,15 @@ One button in the browser for the OpenVibe sites that work with the page you are
@@ -35,6 +36,15 @@ One button in the browser for the OpenVibe sites that work with the page you are
site this is the main button.
site this is the main button.
- **Watch this page.** It opens a new watch on [OpenVibe.Watch](https://openvibe.watch) for the page,
- **Watch this page.** It opens a new watch on [OpenVibe.Watch](https://openvibe.watch) for the page,
which tells you (or runs your agent) when it changes.
which tells you (or runs your agent) when it changes.
+- **Compare delivery apps.** On DoorDash, Uber Eats, Grubhub, Seamless, Caviar or Instacart it opens
+ [openvibe.food/delivery](https://openvibe.food/delivery) with the restaurant or store's name (read from the page
+ title, never the address or the cart), and every app's own search for it opens from there.
+- **Try these codes on this page.** At a shop's cart or checkout, one button types the codes OpenVibe.Coupons knows
+ into the page's own coupon box, presses that box's Apply button, and reads the order total before and after. It
+ stops at the first code that lowers the total and leaves it applied. Codes reported working go first, codes
+ reported not working are never tried, and at most ten are tried. It never presses checkout, pay, order, cart or
+ remove buttons, never submits a form and never presses Enter. With no Apply button next to the box it presses
+ nothing. If you connected the extension, you can send what happened to OpenVibe.Coupons in one click.
- **Coupon codes.** On a shop's website it shows the codes
- **Coupon codes.** On a shop's website it shows the codes
[OpenVibe.Coupons](https://github.com/OpenVibers/OpenVibe.Coupons) knows for that shop:
[OpenVibe.Coupons](https://github.com/OpenVibers/OpenVibe.Coupons) knows for that shop:
@@ -51,9 +61,9 @@ the Companion never rewrites links or adds referral tags.
@@ -51,9 +61,9 @@ the Companion never rewrites links or adds referral tags.
| | |
| | |
|---|---|
|---|---|
-| **Permissions** | `activeTab` and `storage`. The host permission is `https://openvibe.coupons/*` only. |
-| **Not requested** | `<all_urls>`, `tabs`, `cookies`, `scripting`, `webRequest`, `history`, optional permissions, content scripts, a background worker, web-accessible resources, `externally_connectable`. |
-| **Read from the page** | The active tab's **URL and title**, when you open the popup (that's what `activeTab` grants). Never the page's content. Browser pages, files, IP addresses and local names are "not a site", and nothing is sent or offered for them. |
+| **Permissions** | `activeTab`, `scripting` and `storage`. The host permission is `https://openvibe.coupons/*` only. `scripting` is used for one thing: "Try these codes on this page" injects the packaged `lib/autoapply.js` into the active tab (the access `activeTab` gives after you press the button), in its top frame and the extension's isolated world. |
+| **Not requested** | `<all_urls>`, `tabs`, `cookies`, `webRequest`, `history`, optional permissions, content scripts, a background worker, web-accessible resources, `externally_connectable`. |
+| **Read from the page** | The active tab's **URL and title**, when you open the popup (that's what `activeTab` grants). The page's content only when you press "Try these codes on this page": `lib/autoapply.js` finds the coupon box, its Apply button and the order total, types the codes in, and returns to the popup which codes changed the total. It makes no request, reads no cookies or storage, and sends nothing anywhere; `test/manifest.test.js` scans it for that. Browser pages, files, IP addresses and local names are "not a site", and nothing is sent or offered for them. |
| **Sent by the extension** | To `https://openvibe.coupons` only, and only the **hostname** (`lib/host.js` drops the path, query, fragment, port and credentials first): `GET /api/v1/merchants/resolve?host=`, the merchant id (`GET /api/v1/merchants/:id/coupons`), and, when you report, the code id plus `worked` or `failed` with an optional reason. Every request goes with `credentials: 'omit'` (no cookies), `referrerPolicy: 'no-referrer'` and `redirect: 'error'`. |
| **Sent by the extension** | To `https://openvibe.coupons` only, and only the **hostname** (`lib/host.js` drops the path, query, fragment, port and credentials first): `GET /api/v1/merchants/resolve?host=`, the merchant id (`GET /api/v1/merchants/:id/coupons`), and, when you report, the code id plus `worked` or `failed` with an optional reason. Every request goes with `credentials: 'omit'` (no cookies), `referrerPolicy: 'no-referrer'` and `redirect: 'error'`. |
| **Hand-offs (only when you press the button)** | "Keep on my Rent board" and "Watch this page" open **a new tab you see** on `https://openvibe.rent/board` or `https://openvibe.watch/watches/new`, with the page's address and title in that tab's address. `lib/handoff.js` removes the credentials, the fragment and tracking parameters (`utm_*`, `fbclid`, `gclid`, …) first. The extension makes no request for it, nothing opens on its own, and nothing is saved until you save it on that site. `platform.tabs.open()` refuses any other origin, and the permission audit allows only `https://openvibe.<tld>` hand-off origins. |
| **Hand-offs (only when you press the button)** | "Keep on my Rent board" and "Watch this page" open **a new tab you see** on `https://openvibe.rent/board` or `https://openvibe.watch/watches/new`, with the page's address and title in that tab's address. `lib/handoff.js` removes the credentials, the fragment and tracking parameters (`utm_*`, `fbclid`, `gclid`, …) first. The extension makes no request for it, nothing opens on its own, and nothing is saved until you save it on that site. `platform.tabs.open()` refuses any other origin, and the permission audit allows only `https://openvibe.<tld>` hand-off origins. |
| **Stored** | Only the optional install token, in `storage.local`. It is never synced to other devices. |
| **Stored** | Only the optional install token, in `storage.local`. It is never synced to other devices. |
@@ -89,7 +99,8 @@ browser/
@@ -89,7 +99,8 @@ browser/
options.html / .js connect (save a checked token), disconnect, grant the API host permission
options.html / .js connect (save a checked token), disconnect, grant the API host permission
lib/config.js API_ORIGIN (the one origin it sends requests to); HANDOFF_ORIGINS (Rent, Watch)
lib/config.js API_ORIGIN (the one origin it sends requests to); HANDOFF_ORIGINS (Rent, Watch)
lib/host.js URL → hostname (everything else dropped)
lib/host.js URL → hostname (everything else dropped)
- lib/handoff.js the Rent board and Watch addresses, built from a cleaned URL; which sites are rentals
+ lib/handoff.js the Rent board, Watch and Food delivery addresses, built from a cleaned URL; which sites are rentals
+ lib/autoapply.js "Try these codes on this page": injected into the active tab only when you press it
lib/api.js the only code that makes requests
lib/api.js the only code that makes requests
lib/controller.js popup logic without the DOM (tested in Node)
lib/controller.js popup logic without the DOM (tested in Node)
lib/platform.js chrome.* / browser.*: tabs.query, tabs.create (hand-off pages only), storage.local, permissions
lib/platform.js chrome.* / browser.*: tabs.query, tabs.create (hand-off pages only), storage.local, permissions
@@ -98,6 +109,7 @@ browser/
@@ -98,6 +109,7 @@ browser/
scripts/
scripts/
audit.js the permission audit (manifest, CSP, pages)
audit.js the permission audit (manifest, CSP, pages)
pack.js zip for loading or store review (runs the audit first)
pack.js zip for loading or store review (runs the audit first)
+ check-autoapply.js lib/autoapply.js on test/fixtures/checkout-*.html in headless Chrome (CDP, no dependencies)
icons.js draws the icons (no dependencies)
icons.js draws the icons (no dependencies)
```
```
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
"surfaces": {
"surfaces": {
"browser": {
"browser": {
"name": "OpenVibe Companion",
"name": "OpenVibe Companion",
- "version": "0.2.0",
+ "version": "0.3.0",
"manifest": 3,
"manifest": 3,
"targets": [
"targets": [
"chromium >= 111",
"chromium >= 111",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
],
],
"credential": "optional cpx_ install token (coupons.lookup, coupons.report); no Network session",
"credential": "optional cpx_ install token (coupons.lookup, coupons.report); no Network session",
"publishedToStores": false,
"publishedToStores": false,
- "loadedInChrome": "yes: headless Chrome loads the packed 0.2.0 build (popup and options render, no console errors)",
+ "loadedInChrome": "yes: headless Chromium loads the 0.3.0 build (popup and options render, no console errors); lib/autoapply.js passes three fixture checkout pages in headless Chrome",
"loadedInFirefox": "not yet verified",
"loadedInFirefox": "not yet verified",
"handoffs": [
"handoffs": [
"https://openvibe.rent/board (keep a listing)",
"https://openvibe.rent/board (keep a listing)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,201 @@
@@ -0,0 +1,201 @@
+/*
+ * Try coupon codes in the shop page's own coupon box: the OpenVibe Companion's "Try these codes on this page".
+ *
+ * Injected into the active tab only when the person presses that button in the popup (activeTab + scripting:
+ * no host permission for any shop, no content script that runs by itself). It runs in the extension's isolated
+ * world: it reads the page's DOM, types each code into the coupon field and presses the field's own Apply button,
+ * and reads the order total before and after. It makes no network request of its own, reads no cookies or
+ * storage, and returns only what it saw (which code changed the total) to the popup.
+ *
+ * run(codes, opts) → { ok, reason?, before, after, results: [{ id, code, outcome, before, after }] }
+ * outcome 'worked' the total went down after the code was applied
+ * 'failed' the page showed a "not valid / expired / not eligible…" message near the field
+ * 'unknown' neither within the wait
+ * reason 'no_field' no coupon box on this page (not even behind a "Have a promo code?" link)
+ * 'no_apply' a coupon box but no Apply button next to it: nothing is pressed rather than risk
+ * submitting the whole checkout form
+ *
+ * Safety: it never presses a button that reads like ordering or paying (DANGER_RE), never submits a form, and
+ * never presses Enter. It stops at the first code that lowers the total and leaves it applied; at most ten codes
+ * are tried.
+ */
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api;
+ else root.OVAutoApply = api;
+})(typeof self !== 'undefined' ? self : this, function () {
+ 'use strict';
+
+ const FIELD_RE = /(coupon|promo|discount|voucher|offer|redeem|reward|gift)[\s_-]*(code|cod|key)?|promocode|couponcode|code\s*promo/i;
+ const NOT_FIELD_RE = /(e-?mail|zip|postal|phone|tel\b|card[\s_-]*(number|num|no)|cvv|cvc|security[\s_-]*code|expir|search|address|first[\s_-]*name|last[\s_-]*name|full[\s_-]*name|city|state|country|password|quantity|qty|gift[\s_-]*card[\s_-]*pin|pin\b)/i;
+ const OPEN_RE = /(have|add|enter|apply|use|got)\b[^.]{0,25}\b(promo|coupon|discount|voucher|offer|gift)|^(\+\s*)?(promo|coupon|discount|voucher)\s*(code)?s?\s*\??$/i;
+ const APPLY_RE = /^\s*(apply|redeem|use|add|submit|validate|ok|go)\b/i;
+ const DANGER_RE = /(place|submit|complete|confirm|review)\s*(my\s*)?order|\bpay\b|pay\s*now|checkout|check\s*out|purchase|\bbuy\b|continue|next\s*step|proceed|subscribe|sign\s*up|log\s*in|remove|delete|cancel|\b(cart|bag|basket|wish\s*list)\b/i;
+ const ERROR_RE = /(invalid|not valid|isn.t valid|is not a valid|expired|has ended|no longer|not applicable|cannot be applied|can.t be applied|could not be applied|couldn.t be applied|doesn.t apply|does not apply|not eligible|ineligible|minimum|not recognized|unable to apply|not found|does not exist|doesn.t exist|try another|not available|already been used|limit reached)/i;
+ const TOTAL_RE = /\b(order\s*total|grand\s*total|estimated\s*total|total\s*due|amount\s*due|you\s*pay|total\s*to\s*pay|total)\b/i;
+ const NOT_TOTAL_RE = /sub-?\s*total|total\s*savings|you\s*saved|items?\s*total|total\s*items|total\s*discount|tax|shipping/i;
+ const AMOUNT_RE = /(?:[$€£¥₹]|\b(?:USD|EUR|GBP|CAD|AUD)\b)\s?-?\d{1,3}(?:[,. ]\d{3})*(?:[.,]\d{1,2})?|-?\d{1,3}(?:[,. ]\d{3})*(?:[.,]\d{1,2})?\s?(?:[$€£¥₹]|\b(?:USD|EUR|GBP|CAD|AUD)\b)/g;
+
+ /** "$1,234.56" → 1234.56, "1.234,56 €" → 1234.56, "£12" → 12; null when no amount. */
+ function parseAmount(text) {
+ const m = String(text || '').match(AMOUNT_RE);
+ if (!m) return null;
+ let s = m[m.length - 1].replace(/[^\d.,-]/g, '');
+ const lastDot = s.lastIndexOf('.');
+ const lastComma = s.lastIndexOf(',');
+ const dec = Math.max(lastDot, lastComma);
+ if (dec > -1 && s.length - dec - 1 <= 2 && s.length - dec - 1 > 0) {
+ s = s.slice(0, dec).replace(/[.,]/g, '') + '.' + s.slice(dec + 1);
+ } else {
+ s = s.replace(/[.,]/g, '');
+ }
+ const n = Number(s);
+ return Number.isFinite(n) ? n : null;
+ }
+
+ function shown(el) {
+ if (!el || !el.isConnected) return false;
+ if (el.disabled || el.readOnly || el.type === 'hidden') return false;
+ const view = el.ownerDocument && el.ownerDocument.defaultView;
+ const st = view && view.getComputedStyle ? view.getComputedStyle(el) : null;
+ if (st && (st.visibility === 'hidden' || st.display === 'none')) return false;
+ return Boolean(el.getClientRects && el.getClientRects().length);
+ }
+
+ /** Everything that names a field: name, id, placeholder, aria-label, autocomplete and its <label>s. */
+ function nameOf(el) {
+ const bits = [el.name, el.id, el.getAttribute && el.getAttribute('placeholder'), el.getAttribute && el.getAttribute('aria-label'),
+ el.getAttribute && el.getAttribute('autocomplete'), el.getAttribute && el.getAttribute('data-testid')];
+ if (el.labels) for (let i = 0; i < el.labels.length; i++) bits.push(el.labels[i].textContent);
+ return bits.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
+ }
+
+ function isCouponField(el) {
+ if (!el || el.tagName !== 'INPUT') return false;
+ const type = String(el.type || 'text').toLowerCase();
+ if (type !== 'text' && type !== 'search' && type !== '') return false;
+ const n = nameOf(el);
+ return FIELD_RE.test(n) && !NOT_FIELD_RE.test(n.replace(FIELD_RE, ''));
+ }
+
+ function findField(doc) {
+ const inputs = doc.querySelectorAll('input');
+ for (let i = 0; i < inputs.length; i++) if (isCouponField(inputs[i]) && shown(inputs[i])) return inputs[i];
+ return null;
+ }
+
+ const textOf = (el) => String((el && (el.innerText || el.textContent || el.value)) || '').replace(/\s+/g, ' ').trim();
+
+ /** A "Have a promo code?" link or button that reveals the box. */
+ function findOpener(doc) {
+ const els = doc.querySelectorAll('a, button, summary, [role="button"], label');
+ for (let i = 0; i < els.length; i++) {
+ const t = textOf(els[i]);
+ if (t && t.length <= 60 && OPEN_RE.test(t) && !DANGER_RE.test(t) && shown(els[i])) return els[i];
+ }
+ return null;
+ }
+
+ /** The field's own Apply button: a short "Apply/Redeem/Use…" label within three levels around the field, never an order or pay button. */
+ function findApply(field) {
+ let box = field.parentElement;
+ for (let depth = 0; box && depth < 4; depth++, box = box.parentElement) {
+ const els = box.querySelectorAll('button, input[type="submit"], input[type="button"], [role="button"], a');
+ for (let i = 0; i < els.length; i++) {
+ const el = els[i];
+ if (el === field) continue;
+ const t = textOf(el) || String(el.getAttribute('aria-label') || '');
+ if (t && t.length <= 24 && APPLY_RE.test(t) && !DANGER_RE.test(t) && shown(el)) return el;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * The order total: walking the page's text once, the last visible text that names a total (not a subtotal, tax or
+ * saving) with an amount in it or just after it.
+ */
+ function readTotal(doc) {
+ if (!doc.body || !doc.createTreeWalker) return null;
+ const walker = doc.createTreeWalker(doc.body, 4 /* NodeFilter.SHOW_TEXT */);
+ let found = null;
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
+ const t = n.nodeValue;
+ if (!t || t.length > 80 || !TOTAL_RE.test(t) || NOT_TOTAL_RE.test(t)) continue;
+ const el = n.parentElement;
+ if (!el || !el.getClientRects || !el.getClientRects().length) continue;
+ let amount = parseAmount(textOf(el));
+ for (let up = el, hop = 0; amount == null && up && hop < 3; up = up.parentElement, hop++) {
+ const sib = up.nextElementSibling;
+ if (sib) amount = parseAmount(textOf(sib));
+ if (amount == null && up.parentElement) {
+ const row = textOf(up.parentElement);
+ if (row.length <= 120 && !NOT_TOTAL_RE.test(row)) amount = parseAmount(row);
+ }
+ }
+ if (amount != null && amount >= 0) found = amount;
+ }
+ return found;
+ }
+
+ /** Set an input's value so React, Vue and plain pages all see it. */
+ function setValue(input, value) {
+ const proto = Object.getPrototypeOf(input);
+ const desc = proto && Object.getOwnPropertyDescriptor(proto, 'value');
+ if (desc && desc.set) desc.set.call(input, value); else input.value = value;
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+
+ /** The text around the field (where a shop writes "Code not valid"). */
+ function around(field) {
+ let box = field.parentElement;
+ for (let depth = 0; box && box.parentElement && depth < 3 && textOf(box).length < 400; depth++) box = box.parentElement;
+ return textOf(box);
+ }
+
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+ async function run(codes, opts) {
+ const o = opts || {};
+ const doc = o.document || document;
+ const waitMs = o.waitMs || 4500;
+ const list = (Array.isArray(codes) ? codes : []).filter((c) => c && typeof c.code === 'string' && c.code.trim()).slice(0, 10);
+ let field = findField(doc);
+ if (!field) {
+ const opener = findOpener(doc);
+ if (opener) { opener.click(); await sleep(o.openMs || 800); field = findField(doc); }
+ }
+ const before = readTotal(doc);
+ if (!field) return { ok: false, reason: 'no_field', before, after: before, results: [] };
+ if (!findApply(field)) return { ok: false, reason: 'no_apply', before, after: before, results: [] };
+ const results = [];
+ for (const c of list) {
+ if (!field.isConnected) field = findField(doc);
+ const button = field && findApply(field);
+ if (!field || !button) break;
+ const t0 = readTotal(doc);
+ const said = around(field);
+ setValue(field, c.code.trim());
+ button.click();
+ let outcome = 'unknown';
+ let total = t0;
+ const end = Date.now() + waitMs;
+ while (Date.now() < end) {
+ await sleep(o.pollMs || 250);
+ total = readTotal(doc);
+ if (t0 != null && total != null && total < t0 - 0.004) { outcome = 'worked'; break; }
+ const f = field.isConnected ? field : findField(doc);
+ const now = f ? around(f) : textOf(doc.body);
+ if (now !== said && ERROR_RE.test(now)) { outcome = 'failed'; break; }
+ }
+ results.push({ id: c.id || null, code: c.code.trim(), outcome, before: t0, after: total });
+ if (outcome === 'worked') break;
+ // Clear the box for the next code.
+ if (field.isConnected) setValue(field, '');
+ }
+ return { ok: true, before, after: readTotal(doc), results };
+ }
+
+ return { run, parseAmount, isCouponField, findField, findOpener, findApply, readTotal, nameOf, DANGER_RE, APPLY_RE };
+});
@@ -6,8 +6,11 @@
@@ -6,8 +6,11 @@
* a development build (--api http://localhost:4850) and test/manifest.test.js checks they agree.
* a development build (--api http://localhost:4850) and test/manifest.test.js checks they agree.
*
*
* HANDOFF_ORIGINS are the sites the Companion may open a tab on when you press one of its buttons
* HANDOFF_ORIGINS are the sites the Companion may open a tab on when you press one of its buttons
- * (lib/handoff.js): the Rent board and a new Watch. It sends them nothing itself; the address of the
- * tab it opens carries the page you were on, and you see it.
+ * (lib/handoff.js): the Rent board, a new Watch, and Food's delivery comparison. It sends them nothing
+ * itself; the address of the tab it opens carries the page you were on, and you see it.
+ *
+ * MAX_TRIES is how many codes "Try these codes on this page" types into a shop's coupon box at most
+ * (lib/autoapply.js stops at the first that lowers the total).
*/
*/
(function (root, factory) {
(function (root, factory) {
const api = factory();
const api = factory();
@@ -19,7 +22,9 @@
@@ -19,7 +22,9 @@
API_ORIGIN: 'https://openvibe.coupons',
API_ORIGIN: 'https://openvibe.coupons',
RENT_ORIGIN: 'https://openvibe.rent',
RENT_ORIGIN: 'https://openvibe.rent',
WATCH_ORIGIN: 'https://openvibe.watch',
WATCH_ORIGIN: 'https://openvibe.watch',
- HANDOFF_ORIGINS: Object.freeze(['https://openvibe.rent', 'https://openvibe.watch']),
+ FOOD_ORIGIN: 'https://openvibe.food',
+ HANDOFF_ORIGINS: Object.freeze(['https://openvibe.rent', 'https://openvibe.watch', 'https://openvibe.food']),
+ MAX_TRIES: 10,
CONNECT_PATH: '/connect-extension',
CONNECT_PATH: '/connect-extension',
TOKEN_KEY: 'installToken',
TOKEN_KEY: 'installToken',
TOKEN_RE: /^cpx_[A-Za-z0-9_-]{43}$/,
TOKEN_RE: /^cpx_[A-Za-z0-9_-]{43}$/,
@@ -6,6 +6,8 @@
@@ -6,6 +6,8 @@
* c.page() // { handoff, rental }: which buttons to offer
* c.page() // { handoff, rental }: which buttons to offer
* await c.keepOnBoard() // open the Rent board with this page (a new tab)
* await c.keepOnBoard() // open the Rent board with this page (a new tab)
* await c.watchPage() // open a new Watch for this page (a new tab)
* await c.watchPage() // open a new Watch for this page (a new tab)
+ * await c.compareDelivery() // the restaurant on every delivery app, on openvibe.food (a new tab)
+ * await c.tryCodes(coupons) // type the codes into this page's coupon box (lib/autoapply.js) → a view of it
* await c.report(couponId, 'worked') // → { ok, message }
* await c.report(couponId, 'worked') // → { ok, message }
*
*
* open() reads the active tab's URL and title once. Only the hostname (host.hostnameOf) is sent
* open() reads the active tab's URL and title once. Only the hostname (host.hostnameOf) is sent
@@ -45,7 +47,7 @@
@@ -45,7 +47,7 @@
const platform = deps.platform;
const platform = deps.platform;
const host = deps.host;
const host = deps.host;
const handoff = deps.handoff || null;
const handoff = deps.handoff || null;
- let tab = { url: null, title: '' };
+ let tab = { id: null, url: null, title: '' };
async function handOff(build) {
async function handOff(build) {
const target = handoff && build(tab.url, tab.title);
const target = handoff && build(tab.url, tab.title);
@@ -71,14 +73,52 @@
@@ -71,14 +73,52 @@
}
}
},
},
- /** Which hand-offs make sense for this page: any web page can go to the board or a watch; a rental site leads with the board. */
+ /** Which hand-offs make sense for this page: any web page can go to the board or a watch; a rental site leads with the board; a delivery app offers the comparison. */
page() {
page() {
const ok = Boolean(handoff && host.hostnameOf(tab.url) && handoff.boardUrl(tab.url, tab.title));
const ok = Boolean(handoff && host.hostnameOf(tab.url) && handoff.boardUrl(tab.url, tab.title));
- return { handoff: ok, rental: ok && deps.isRental ? deps.isRental(tab.url) : false };
+ return {
+ handoff: ok,
+ rental: ok && deps.isRental ? deps.isRental(tab.url) : false,
+ delivery: ok && Boolean(handoff.deliveryUrl && handoff.deliveryUrl(tab.url, tab.title)),
+ };
},
},
keepOnBoard() { return handOff(function (u, t) { return handoff.boardUrl(u, t); }); },
keepOnBoard() { return handOff(function (u, t) { return handoff.boardUrl(u, t); }); },
watchPage() { return handOff(function (u, t) { return handoff.watchUrl(u, t); }); },
watchPage() { return handOff(function (u, t) { return handoff.watchUrl(u, t); }); },
+ compareDelivery() { return handOff(function (u, t) { return handoff.deliveryUrl(u, t); }); },
+
+ /**
+ * Try the codes in this page's own coupon box. → { ok, message, results: [{ id, code, outcome, saved }] }.
+ * Codes reported working come first; at most config.MAX_TRIES.
+ */
+ async tryCodes(coupons) {
+ const rank = { reported_working: 0, unknown: 1 };
+ const list = (coupons || []).filter(function (c) { return c && c.code && c.status !== 'reported_not_working'; })
+ .slice().sort(function (a, b) { return (rank[a.status] == null ? 2 : rank[a.status]) - (rank[b.status] == null ? 2 : rank[b.status]); });
+ if (!list.length) return { ok: false, message: 'There are no codes to try here.', results: [] };
+ let r;
+ try {
+ r = await platform.scripting.tryCodes(tab.id, list.map(function (c) { return { id: c.id, code: c.code }; }));
+ } catch (err) {
+ return { ok: false, message: 'The Companion could not reach this page. Reload it and try again.', results: [] };
+ }
+ if (!r) return { ok: false, message: 'The page did not answer. Reload it and try again.', results: [] };
+ if (!r.ok) {
+ return { ok: false, results: [], message: r.reason === 'no_apply'
+ ? 'This page has a coupon box but no Apply button next to it, so nothing was pressed. Copy a code below instead.'
+ : 'No coupon box on this page. Open your cart or the checkout page, then try again.' };
+ }
+ const results = r.results.map(function (x) {
+ const saved = x.outcome === 'worked' && x.before != null && x.after != null ? Math.round((x.before - x.after) * 100) / 100 : null;
+ return { id: x.id, code: x.code, outcome: x.outcome, saved: saved };
+ });
+ const win = results.find(function (x) { return x.outcome === 'worked'; });
+ return {
+ ok: true, results: results,
+ message: win ? 'Applied ' + win.code + (win.saved ? ': the total went down by ' + win.saved.toFixed(2) : '') + '. Check the total before you pay.'
+ : 'None of the codes lowered the total on this page.',
+ };
+ },
async report(couponId, outcome, reason) {
async report(couponId, outcome, reason) {
try {
try {
@@ -4,6 +4,9 @@
@@ -4,6 +4,9 @@
*
*
* boardUrl(url, title) https://openvibe.rent/board?url=…&title=… keep a listing on your Rent board
* boardUrl(url, title) https://openvibe.rent/board?url=…&title=… keep a listing on your Rent board
* watchUrl(url, title) https://openvibe.watch/watches/new?template=page… watch the page for changes
* watchUrl(url, title) https://openvibe.watch/watches/new?template=page… watch the page for changes
+ * deliveryUrl(url, title) https://openvibe.food/delivery?q=… the same restaurant on every delivery app
+ * (on a DoorDash, Uber Eats, Grubhub, Seamless, Caviar or Instacart page: the restaurant or
+ * store name read from the page title, never the address or the cart)
*
*
* This is the only code that uses more of the page than its hostname, and what it builds is a page
* This is the only code that uses more of the page than its hostname, and what it builds is a page
* address opened in a new tab you see, never a request the extension makes. cleanUrl() drops the
* address opened in a new tab you see, never a request the extension makes. cleanUrl() drops the
@@ -26,6 +29,37 @@
@@ -26,6 +29,37 @@
'loopnet.com', 'spothero.com', 'sparefoot.com', 'turo.com', 'spareroom.com', 'spareroom.co.uk', 'rightmove.co.uk', 'zoopla.co.uk',
'loopnet.com', 'spothero.com', 'sparefoot.com', 'turo.com', 'spareroom.com', 'spareroom.co.uk', 'rightmove.co.uk', 'zoopla.co.uk',
'kijiji.ca', 'rentals.ca', 'idealista.com', 'domain.com.au', 'realestate.com.au', 'openvibe.rent'];
'kijiji.ca', 'rentals.ca', 'idealista.com', 'domain.com.au', 'realestate.com.au', 'openvibe.rent'];
+ /** The delivery apps Food compares, and how each one's page title names the restaurant or store. */
+ const DELIVERY = [
+ { host: 'doordash.com', name: /^(?:Order\s+)?(.+?)\s+(?:Delivery|Menu|Near Me|-|\||\u2013)/i },
+ { host: 'ubereats.com', name: /^(?:Order\s+)?(.+?)\s*(?:\(|Menu|Delivery|-|\||\u2013)/i },
+ { host: 'grubhub.com', name: /^(?:Order\s+)?(.+?)\s+(?:Delivery|Menu|-|\||\u2013)/i },
+ { host: 'seamless.com', name: /^(?:Order\s+)?(.+?)\s+(?:Delivery|Menu|-|\||\u2013)/i },
+ { host: 'trycaviar.com', name: /^(?:Order\s+)?(.+?)\s+(?:Delivery|Menu|-|\||\u2013)/i },
+ { host: 'instacart.com', name: /^(?:Order\s+)?(.+?)\s+(?:Delivery|Same-Day|-|\||\u2013)/i },
+ ];
+ // A title about the app itself (its home or a search page) names no restaurant.
+ const GENERIC_TITLE = /^(doordash|uber\s*eats|grubhub|seamless|caviar|instacart)\b|^(food|restaurant|grocery)( delivery)?$|^home$|^search\b/i;
+
+ function deliveryApp(url) {
+ let u;
+ try { u = new URL(String(url)); } catch (e) { return null; }
+ const h = u.hostname.toLowerCase().replace(/^www\./, '');
+ return DELIVERY.find(function (d) { return h === d.host || h.endsWith('.' + d.host); }) || null;
+ }
+
+ function isDelivery(url) { return Boolean(deliveryApp(url)); }
+
+ /** The restaurant or store a delivery app's page is about, from its title; '' when the title names none. */
+ function deliveryQuery(url, title) {
+ const app = deliveryApp(url);
+ const t = cleanTitle(title, 200);
+ if (!app || !t) return '';
+ const m = app.name.exec(t);
+ const name = cleanTitle(m ? m[1] : t.split(/\s[|\-\u2013]\s/)[0], 80).replace(/\s+(Delivery|Menu)$/i, '');
+ return name && !GENERIC_TITLE.test(name) ? name : '';
+ }
+
function cleanUrl(url) {
function cleanUrl(url) {
if (typeof url !== 'string' || !url || url.length > 4000) return null;
if (typeof url !== 'string' || !url || url.length > 4000) return null;
let u;
let u;
@@ -58,10 +92,16 @@
@@ -58,10 +92,16 @@
function createHandoff(config) {
function createHandoff(config) {
const RENT = config.RENT_ORIGIN;
const RENT = config.RENT_ORIGIN;
const WATCH = config.WATCH_ORIGIN;
const WATCH = config.WATCH_ORIGIN;
+ const FOOD = config.FOOD_ORIGIN;
const origins = config.HANDOFF_ORIGINS;
const origins = config.HANDOFF_ORIGINS;
return {
return {
boardUrl: function (url, title) { return boardUrl(RENT, url, title); },
boardUrl: function (url, title) { return boardUrl(RENT, url, title); },
watchUrl: function (url, title) { return watchUrl(WATCH, url, title); },
watchUrl: function (url, title) { return watchUrl(WATCH, url, title); },
+ deliveryUrl: function (url, title) {
+ if (!FOOD || !isDelivery(url)) return null;
+ const q = deliveryQuery(url, title);
+ return FOOD + '/delivery' + (q ? '?' + new URLSearchParams({ q: q }).toString() : '');
+ },
isHandoff: function (url) { return isHandoff(origins, url); },
isHandoff: function (url) { return isHandoff(origins, url); },
};
};
}
}
@@ -89,5 +129,5 @@
@@ -89,5 +129,5 @@
return typeof url === 'string' && origins.some(function (o) { return url.indexOf(o + '/') === 0; });
return typeof url === 'string' && origins.some(function (o) { return url.indexOf(o + '/') === 0; });
}
}
- return { createHandoff, RENTAL_HOSTS, cleanUrl, isRental, isHandoff };
+ return { createHandoff, RENTAL_HOSTS, cleanUrl, isRental, isHandoff, isDelivery, deliveryQuery };
});
});
@@ -10,8 +10,11 @@
@@ -10,8 +10,11 @@
* (never storage.sync: it stays on this device)
* (never storage.sync: it stays on this device)
* permissions.hasApi/requestApi the host permission for the API origin (Firefox
* permissions.hasApi/requestApi the host permission for the API origin (Firefox
* lets people withhold it; reporting needs it)
* lets people withhold it; reporting needs it)
+ * scripting.tryCodes(tabId, codes) when the person presses "Try these codes on this page": inject
+ * lib/autoapply.js into that tab (activeTab grants it for this one tab, after
+ * that click) and run it with the codes; returns what it saw
*
*
- * Nothing else: no cookies, no scripting, no history, no page access.
+ * Nothing else: no cookies, no history, and the page is touched only by that one button.
*/
*/
(function (root, factory) {
(function (root, factory) {
const api = factory();
const api = factory();
@@ -31,7 +34,7 @@
@@ -31,7 +34,7 @@
async active() {
async active() {
const tabs = await call(ext.tabs.query, ext.tabs, [{ active: true, currentWindow: true }]);
const tabs = await call(ext.tabs.query, ext.tabs, [{ active: true, currentWindow: true }]);
const t = tabs && tabs[0];
const t = tabs && tabs[0];
- return { url: t && typeof t.url === 'string' ? t.url : null, title: t && typeof t.title === 'string' ? t.title : '' };
+ return { id: t && Number.isInteger(t.id) ? t.id : null, url: t && typeof t.url === 'string' ? t.url : null, title: t && typeof t.title === 'string' ? t.title : '' };
},
},
async open(url) {
async open(url) {
const ok = typeof url === 'string' && config.HANDOFF_ORIGINS.some(function (o) { return url.indexOf(o + '/') === 0; });
const ok = typeof url === 'string' && config.HANDOFF_ORIGINS.some(function (o) { return url.indexOf(o + '/') === 0; });
@@ -39,6 +42,19 @@
@@ -39,6 +42,19 @@
await call(ext.tabs.create, ext.tabs, [{ url: url }]);
await call(ext.tabs.create, ext.tabs, [{ url: url }]);
},
},
},
},
+ scripting: {
+ async tryCodes(tabId, codes) {
+ if (!ext.scripting || !Number.isInteger(tabId)) throw new TypeError('this browser cannot run the code tester');
+ const list = (Array.isArray(codes) ? codes : []).slice(0, config.MAX_TRIES).map(function (c) { return { id: String(c.id || ''), code: String(c.code || '') }; });
+ await call(ext.scripting.executeScript, ext.scripting, [{ target: { tabId: tabId }, files: ['lib/autoapply.js'] }]);
+ const out = await call(ext.scripting.executeScript, ext.scripting, [{
+ target: { tabId: tabId },
+ func: function (c) { return self.OVAutoApply.run(c); },
+ args: [list],
+ }]);
+ return out && out[0] ? out[0].result : null;
+ },
+ },
storage: {
storage: {
async getToken() {
async getToken() {
const out = await call(ext.storage.local.get, ext.storage.local, [config.TOKEN_KEY]);
const out = await call(ext.storage.local.get, ext.storage.local, [config.TOKEN_KEY]);
@@ -2,8 +2,8 @@
@@ -2,8 +2,8 @@
"manifest_version": 3,
"manifest_version": 3,
"name": "OpenVibe Companion",
"name": "OpenVibe Companion",
"short_name": "OpenVibe",
"short_name": "OpenVibe",
- "version": "0.2.0",
- "description": "Coupon codes for the shop you are on, and one click to keep a rental listing on your OpenVibe.Rent board or watch a page with OpenVibe.Watch. Reads the tab only when you open it.",
+ "version": "0.3.0",
+ "description": "Coupon codes for the shop you are on, tried in its coupon box when you ask; the same restaurant on every delivery app; and one click to keep a rental listing on your OpenVibe.Rent board or watch a page. Reads the tab only when you open it.",
"homepage_url": "https://github.com/OpenVibers/OpenVibe.Extensions",
"homepage_url": "https://github.com/OpenVibers/OpenVibe.Extensions",
"author": "OpenVibers",
"author": "OpenVibers",
"icons": {
"icons": {
@@ -26,6 +26,7 @@
@@ -26,6 +26,7 @@
},
},
"permissions": [
"permissions": [
"activeTab",
"activeTab",
+ "scripting",
"storage"
"storage"
],
],
"host_permissions": [
"host_permissions": [
@@ -40,7 +41,9 @@
@@ -40,7 +41,9 @@
"id": "[email protected]",
"id": "[email protected]",
"strict_min_version": "128.0",
"strict_min_version": "128.0",
"data_collection_permissions": {
"data_collection_permissions": {
- "required": ["browsingActivity"]
+ "required": [
+ "browsingActivity"
+ ]
}
}
}
}
}
}
@@ -29,3 +29,7 @@ footer { border-top: 1px solid var(--line); font-size: 12px; }
@@ -29,3 +29,7 @@ footer { border-top: 1px solid var(--line); font-size: 12px; }
a { color: var(--accent); }
a { color: var(--accent); }
body.options { width: auto; max-width: 640px; max-height: none; margin: 0 auto; padding: 12px 16px; font-size: 14px; }
body.options { width: auto; max-width: 640px; max-height: none; margin: 0 auto; padding: 12px 16px; font-size: 14px; }
body.options input { font: inherit; padding: 4px 6px; max-width: 100%; }
body.options input { font: inherit; padding: 4px 6px; max-width: 100%; }
+.try { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; margin: 8px 0; }
+.try ol { margin: 6px 0 2px; padding-left: 18px; }
+.try li.worked { color: var(--ok); }
+.try li.failed { color: var(--bad); }
@@ -15,8 +15,9 @@
@@ -15,8 +15,9 @@
<div class="row">
<div class="row">
<button type="button" id="keep" class="btn">Keep on my Rent board</button>
<button type="button" id="keep" class="btn">Keep on my Rent board</button>
<button type="button" id="watch" class="btn">Watch this page</button>
<button type="button" id="watch" class="btn">Watch this page</button>
+ <button type="button" id="delivery" class="btn" hidden>Compare delivery apps</button>
</div>
</div>
- <p id="actions-note" class="note muted">Opens openvibe.rent or openvibe.watch in a new tab with this page’s address.</p>
+ <p id="actions-note" class="note muted">Opens openvibe.rent, openvibe.watch or openvibe.food in a new tab with this page’s address.</p>
</section>
</section>
<main id="content" aria-live="polite"><p class="muted">Looking up this shop…</p></main>
<main id="content" aria-live="polite"><p class="muted">Looking up this shop…</p></main>
<footer>
<footer>
@@ -91,11 +91,15 @@
@@ -91,11 +91,15 @@
if (!p.handoff) return;
if (!p.handoff) return;
const keep = document.getElementById('keep');
const keep = document.getElementById('keep');
const watch = document.getElementById('watch');
const watch = document.getElementById('watch');
+ const delivery = document.getElementById('delivery');
const note = document.getElementById('actions-note');
const note = document.getElementById('actions-note');
- document.getElementById('actions-lede').textContent = p.rental
- ? 'A rental listing? Keep it on your board with your notes, next to the ones from every other site.'
- : 'Keep this page on your Rent board, or have OpenVibe.Watch tell you when it changes.';
- (p.rental ? keep : watch).classList.add('primary');
+ document.getElementById('actions-lede').textContent = p.delivery
+ ? 'Ordering delivery? See the same restaurant on every delivery app, with each app’s own prices and fees.'
+ : p.rental
+ ? 'A rental listing? Keep it on your board with your notes, next to the ones from every other site.'
+ : 'Keep this page on your Rent board, or have OpenVibe.Watch tell you when it changes.';
+ delivery.hidden = !p.delivery;
+ (p.delivery ? delivery : p.rental ? keep : watch).classList.add('primary');
const go = function (fn) {
const go = function (fn) {
return async function () {
return async function () {
const r = await fn();
const r = await fn();
@@ -105,19 +109,57 @@
@@ -105,19 +109,57 @@
};
};
keep.addEventListener('click', go(controller.keepOnBoard));
keep.addEventListener('click', go(controller.keepOnBoard));
watch.addEventListener('click', go(controller.watchPage));
watch.addEventListener('click', go(controller.watchPage));
+ delivery.addEventListener('click', go(controller.compareDelivery));
section.hidden = false;
section.hidden = false;
}
}
+ const OUTCOME = { worked: 'lowered the total', failed: 'not accepted', unknown: 'no change seen' };
+
+ /** "Try these codes on this page": the button, what it does, and what happened to each code. */
+ function tester(coupons, connected) {
+ const list = el('ol');
+ const note = el('p', { className: 'note muted', text: 'Types each code into this page’s coupon box and presses its Apply button: never checkout or pay. It stops at the first code that lowers the total and leaves it applied.' });
+ const status = el('p', { className: 'note', 'aria-live': 'polite' });
+ const send = el('button', { type: 'button', hidden: 'hidden', text: 'Send these results to OpenVibe.Coupons' });
+ const run = el('button', { type: 'button', className: 'btn primary', text: 'Try these codes on this page' });
+ let done = [];
+ run.addEventListener('click', async function () {
+ run.disabled = true;
+ status.textContent = 'Trying codes…';
+ while (list.firstChild) list.removeChild(list.firstChild);
+ const r = await controller.tryCodes(coupons);
+ status.textContent = r.message;
+ done = r.results.filter(function (x) { return x.outcome !== 'unknown' && x.id; });
+ r.results.forEach(function (x) {
+ list.appendChild(el('li', { className: x.outcome, text: x.code + ': ' + OUTCOME[x.outcome] + (x.saved ? ' (−' + x.saved.toFixed(2) + ')' : '') }));
+ });
+ send.hidden = !(connected && done.length);
+ run.disabled = false;
+ run.textContent = 'Try again';
+ });
+ send.addEventListener('click', async function () {
+ send.disabled = true;
+ let ok = 0;
+ for (const x of done) {
+ const r = await controller.report(x.id, x.outcome === 'worked' ? 'worked' : 'failed', x.outcome === 'failed' ? 'invalid' : null);
+ if (r.ok) ok++;
+ }
+ status.textContent = ok ? 'Thanks: ' + ok + ' result' + (ok === 1 ? '' : 's') + ' sent.' : 'The results could not be sent.';
+ send.hidden = true;
+ });
+ return el('section', { className: 'try', 'aria-label': 'Try the codes on this page' }, run, note, status, list, send);
+ }
+
async function render() {
async function render() {
const vm = await controller.open();
const vm = await controller.open();
if (vm.host) site.textContent = vm.host;
if (vm.host) site.textContent = vm.host;
renderActions();
renderActions();
- if (vm.state === 'not_a_site') return show(el('p', { text: 'Open this on a website: a shop for its coupon codes, a rental listing for your board, any page to watch.' }));
+ if (vm.state === 'not_a_site') return show(el('p', { text: 'Open this on a website: a shop for its coupon codes, a delivery app to compare with the others, a rental listing for your board, any page to watch.' }));
if (vm.state === 'no_merchant') return show(el('p', { className: 'muted', text: 'No coupon codes for ' + vm.host + ' on OpenVibe.Coupons. ' }, link('Submit one', '/submit')));
if (vm.state === 'no_merchant') return show(el('p', { className: 'muted', text: 'No coupon codes for ' + vm.host + ' on OpenVibe.Coupons. ' }, link('Submit one', '/submit')));
if (vm.state === 'error') return show(el('p', { text: vm.message }));
if (vm.state === 'error') return show(el('p', { text: vm.message }));
const header = el('h2', { text: 'Coupon codes for ' + vm.merchant.name });
const header = el('h2', { text: 'Coupon codes for ' + vm.merchant.name });
if (!vm.coupons.length) return show(header, el('p', { text: 'No active codes right now.' }), el('p', null, link('See the shop page', '/m/' + encodeURIComponent(vm.merchant.slug))));
if (!vm.coupons.length) return show(header, el('p', { text: 'No active codes right now.' }), el('p', null, link('See the shop page', '/m/' + encodeURIComponent(vm.merchant.slug))));
- return show(header, ...vm.coupons.map(function (c) { return couponNode(c, vm.connected); }), el('p', { className: 'muted' }, link('All codes and details', '/m/' + encodeURIComponent(vm.merchant.slug))));
+ return show(header, tester(vm.coupons, vm.connected), ...vm.coupons.map(function (c) { return couponNode(c, vm.connected); }), el('p', { className: 'muted' }, link('All codes and details', '/m/' + encodeURIComponent(vm.merchant.slug))));
}
}
document.getElementById('settings').addEventListener('click', function (ev) {
document.getElementById('settings').addEventListener('click', function (ev) {
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-extensions",
"name": "openvibe-extensions",
- "version": "0.2.0",
+ "version": "0.3.0",
"private": true,
"private": true,
"description": "OpenVibe's first-party client surfaces (ADR-023). Today: the OpenVibe Companion, a Manifest V3 browser extension for Chromium and Firefox: coupon codes from OpenVibe.Coupons, and one click to keep a listing on the OpenVibe.Rent board or watch a page with OpenVibe.Watch.",
"description": "OpenVibe's first-party client surfaces (ADR-023). Today: the OpenVibe Companion, a Manifest V3 browser extension for Chromium and Firefox: coupon codes from OpenVibe.Coupons, and one click to keep a listing on the OpenVibe.Rent board or watch a page with OpenVibe.Watch.",
"scripts": {
"scripts": {
@@ -3,7 +3,8 @@
@@ -3,7 +3,8 @@
* The permission audit for browser/manifest.json. test/manifest.test.js runs it, and so does
* The permission audit for browser/manifest.json. test/manifest.test.js runs it, and so does
* scripts/pack.js before it writes a zip. It fails when anything widens:
* scripts/pack.js before it writes a zip. It fails when anything widens:
*
*
- * permissions exactly activeTab + storage
+ * permissions exactly activeTab + scripting + storage (scripting runs lib/autoapply.js in the active tab only,
+ * when the person presses "Try these codes on this page": activeTab is the only host access)
* host_permissions exactly <API origin>/*
* host_permissions exactly <API origin>/*
* forbidden keys content_scripts, background, web_accessible_resources,
* forbidden keys content_scripts, background, web_accessible_resources,
* externally_connectable, optional_permissions, optional_host_permissions,
* externally_connectable, optional_permissions, optional_host_permissions,
@@ -20,7 +21,7 @@ const path = require('path');
@@ -20,7 +21,7 @@ const path = require('path');
const ALLOWED_KEYS = new Set(['manifest_version', 'name', 'short_name', 'version', 'description', 'homepage_url', 'author', 'icons',
const ALLOWED_KEYS = new Set(['manifest_version', 'name', 'short_name', 'version', 'description', 'homepage_url', 'author', 'icons',
'action', 'options_ui', 'permissions', 'host_permissions', 'content_security_policy', 'minimum_chrome_version', 'browser_specific_settings']);
'action', 'options_ui', 'permissions', 'host_permissions', 'content_security_policy', 'minimum_chrome_version', 'browser_specific_settings']);
-const PERMISSIONS = ['activeTab', 'storage'];
+const PERMISSIONS = ['activeTab', 'scripting', 'storage'];
function auditManifest(m, { apiOrigin }) {
function auditManifest(m, { apiOrigin }) {
const problems = [];
const problems = [];
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * browser/lib/autoapply.js in a real browser: headless Chrome (CDP over Node's own WebSocket, no dependencies) opens
+ * each fixture checkout page in test/fixtures/, runs the module there, and checks what it did:
+ *
+ * checkout-simple.html a promo box with an Apply button: the bad code fails on the page's message, SAVE10 lowers
+ * the total and is kept; the Place order button is never pressed
+ * checkout-hidden.html the box sits behind "Have a promo code?" and the page reads its value from input events (a
+ * framework-style field); € amounts with a decimal comma
+ * checkout-noapply.html a discount field with no Apply button, in a form whose only button is "Pay now": nothing
+ * is typed or pressed (reason no_apply)
+ *
+ * node scripts/check-autoapply.js (CHROME=/path/to/chrome to choose the browser)
+ *
+ * test/autoapply.test.js runs it when a Chrome is installed and says so when it is not.
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { spawn } = require('child_process');
+
+const CHROME = process.env.CHROME || ['/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser'].find((p) => fs.existsSync(p));
+const ROOT = path.join(__dirname, '..');
+const SOURCE = fs.readFileSync(path.join(ROOT, 'browser', 'lib', 'autoapply.js'), 'utf8');
+
+const CASES = [
+ {
+ file: 'checkout-simple.html',
+ codes: [{ id: 'cpn_bad', code: 'NOPE5' }, { id: 'cpn_good', code: 'SAVE10' }, { id: 'cpn_never', code: 'LATER' }],
+ expect: (r, page) => {
+ const out = r.results.map((x) => `${x.code}:${x.outcome}`).join(' ');
+ if (out !== 'NOPE5:failed SAVE10:worked') return `results ${out}`;
+ if (r.before !== 54 || r.after !== 44) return `totals ${r.before} → ${r.after}`;
+ if (page.submits) return 'the checkout form was submitted';
+ if (page.tried.join(',') !== 'NOPE5,SAVE10') return `tried ${page.tried}`;
+ return null;
+ },
+ },
+ {
+ file: 'checkout-hidden.html',
+ codes: [{ id: 'a', code: 'OLD' }, { id: 'b', code: 'WELCOME' }],
+ expect: (r, page) => {
+ const out = r.results.map((x) => `${x.code}:${x.outcome}`).join(' ');
+ if (out !== 'OLD:failed WELCOME:worked') return `results ${out}`;
+ if (r.before !== 1234.56 || r.after !== 1111.1) return `totals ${r.before} → ${r.after}`;
+ if (page.tried.join(',') !== 'OLD,WELCOME') return `the page saw ${page.tried} (framework value)`;
+ return null;
+ },
+ },
+ {
+ file: 'checkout-noapply.html',
+ codes: [{ id: 'a', code: 'SAVE10' }],
+ expect: (r, page) => {
+ if (r.ok || r.reason !== 'no_apply') return `ok ${r.ok} reason ${r.reason}`;
+ if (page.submits) return 'the form was submitted';
+ if (page.value) return `the field was filled (${page.value})`;
+ return null;
+ },
+ },
+];
+
+function cdp(url) {
+ const ws = new WebSocket(url);
+ let id = 0;
+ const waiting = new Map();
+ ws.addEventListener('message', (ev) => {
+ const m = JSON.parse(ev.data);
+ if (m.id && waiting.has(m.id)) { const w = waiting.get(m.id); waiting.delete(m.id); if (m.error) w.reject(new Error(m.error.message)); else w.resolve(m.result); }
+ });
+ const ready = new Promise((resolve, reject) => { ws.addEventListener('open', resolve); ws.addEventListener('error', reject); });
+ return {
+ ready,
+ send(method, params = {}) { return new Promise((resolve, reject) => { const n = ++id; waiting.set(n, { resolve, reject }); ws.send(JSON.stringify({ id: n, method, params })); }); },
+ close() { ws.close(); },
+ };
+}
+
+async function evaluate(c, expression) {
+ const r = await c.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
+ if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception ? r.exceptionDetails.exception.description : r.exceptionDetails.text);
+ return r.result.value;
+}
+
+const pause = (ms) => new Promise((r) => setTimeout(r, ms));
+
+async function main() {
+ if (!CHROME) return { skipped: 'no Chrome or Chromium installed (set CHROME)' };
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-autoapply-'));
+ const proc = spawn(CHROME, ['--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--allow-file-access-from-files',
+ `--user-data-dir=${dir}`, '--remote-debugging-port=0', 'about:blank'], { stdio: 'ignore' });
+ try {
+ let port = null;
+ for (let i = 0; i < 100 && !port; i++) {
+ await pause(100);
+ try { port = fs.readFileSync(path.join(dir, 'DevToolsActivePort'), 'utf8').split('\n')[0]; } catch { /* not yet */ }
+ }
+ if (!port) throw new Error('Chrome did not start');
+ const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
+ const page = targets.find((t) => t.type === 'page');
+ const c = cdp(page.webSocketDebuggerUrl);
+ await c.ready;
+ const failures = [];
+ for (const k of CASES) {
+ await c.send('Page.navigate', { url: `file://${path.join(ROOT, 'test', 'fixtures', k.file)}` });
+ for (let i = 0; i < 50 && (await evaluate(c, 'document.readyState')) !== 'complete'; i++) await pause(50);
+ await evaluate(c, SOURCE);
+ const r = await evaluate(c, `OVAutoApply.run(${JSON.stringify(k.codes)}, { waitMs: 2500, openMs: 300 })`);
+ const seen = await evaluate(c, '({ tried: window.tried || [], submits: window.submits || 0, value: (document.querySelector("input[name=discount_code]") || {}).value || "" })');
+ const problem = k.expect(r, seen);
+ if (problem) failures.push(`${k.file}: ${problem} — ${JSON.stringify(r)}`);
+ }
+ c.close();
+ return { failures, cases: CASES.length };
+ } finally {
+ proc.kill();
+ await pause(200);
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+module.exports = { main };
+
+if (require.main === module) {
+ main().then((r) => {
+ if (r.skipped) { console.log(`autoapply in Chrome: skipped (${r.skipped})`); return; }
+ if (r.failures.length) { console.error(r.failures.join('\n')); process.exit(1); }
+ console.log(`autoapply in Chrome: ${r.cases}/${r.cases} fixture pages behave`);
+ }).catch((err) => { console.error(err); process.exit(1); });
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,128 @@
@@ -0,0 +1,128 @@
+'use strict';
+/**
+ * "Try these codes on this page" (browser/lib/autoapply.js, platform.scripting.tryCodes, controller.tryCodes) and the
+ * delivery comparison hand-off:
+ * - amounts in the forms shops print them; which fields are coupon boxes; which buttons are never pressed;
+ * - the popup injects the one packaged file into the active tab only, codes reported working first, at most ten,
+ * never one reported not working, and says what happened;
+ * - a delivery app's page opens openvibe.food/delivery with the restaurant's name and nothing else;
+ * - and, when a Chrome is installed, the module itself on three fixture checkout pages (scripts/check-autoapply.js).
+ */
+const assert = require('assert');
+const auto = require('../browser/lib/autoapply');
+const host = require('../browser/lib/host');
+const handoffLib = require('../browser/lib/handoff');
+const { createApiClient } = require('../browser/lib/api');
+const { createPlatform } = require('../browser/lib/platform');
+const { createController } = require('../browser/lib/controller');
+const config = require('../browser/lib/config');
+const { check, done, startStub, fakeExt } = require('./helpers/harness');
+
+const field = (attrs, labels = []) => ({
+ tagName: 'INPUT', type: attrs.type || 'text', name: attrs.name || '', id: attrs.id || '',
+ getAttribute: (k) => (attrs[k] == null ? null : attrs[k]), labels: labels.map((t) => ({ textContent: t })),
+});
+
+(async () => {
+ const stub = await startStub();
+ const handoff = handoffLib.createHandoff(config);
+ const popup = ({ url, title = '', scriptResult = null }) => {
+ const ext = fakeExt({ url, title, scriptResult });
+ const platform = createPlatform(ext, { ...config, API_ORIGIN: stub.origin });
+ const api = createApiClient({ origin: stub.origin, fetch, getToken: platform.storage.getToken });
+ return { ext, controller: createController({ api, platform, host, handoff, isRental: handoffLib.isRental }) };
+ };
+
+ await check('amounts as shops print them', () => {
+ const cases = [['$54.00', 54], ['Order total: $1,234.56', 1234.56], ['€1.234,56', 1234.56], ['1 234,56 €', 1234.56], ['£12', 12], ['USD 9.99', 9.99], ['Total $0.00', 0], ['no amount here', null], ['Save $5 — total $45.10', 45.1]];
+ for (const [text, want] of cases) assert.strictEqual(auto.parseAmount(text), want, text);
+ });
+
+ await check('coupon boxes are told apart from the other checkout fields', () => {
+ assert.ok(auto.isCouponField(field({ name: 'promo_code' })));
+ assert.ok(auto.isCouponField(field({ placeholder: 'Enter coupon code' })));
+ assert.ok(auto.isCouponField(field({ id: 'x' }, ['Discount code'])));
+ assert.ok(auto.isCouponField(field({ 'aria-label': 'Gift card or discount code' })));
+ assert.ok(!auto.isCouponField(field({ name: 'email', placeholder: 'Email for offers' })), 'an email field');
+ assert.ok(!auto.isCouponField(field({ name: 'card_number', placeholder: 'Card number' })));
+ assert.ok(!auto.isCouponField(field({ name: 'zip' })));
+ assert.ok(!auto.isCouponField(field({ name: 'promo', type: 'password' })), 'not a text box');
+ assert.ok(!auto.isCouponField({ ...field({ name: 'promo' }), tagName: 'SELECT' }));
+ });
+
+ await check('an Apply button is pressed; ordering, paying and leaving never are', () => {
+ for (const t of ['Apply', 'Apply code', 'Redeem', 'Use code', 'Add']) assert.ok(auto.APPLY_RE.test(t) && !auto.DANGER_RE.test(t), t);
+ for (const t of ['Place order', 'Pay now', 'Continue to checkout', 'Checkout', 'Buy now', 'Complete order', 'Submit order', 'Remove', 'Proceed to payment', 'Add to cart', 'Add to bag']) {
+ assert.ok(auto.DANGER_RE.test(t), t);
+ }
+ });
+
+ await check('the popup injects only lib/autoapply.js, in the active tab, with the codes worth trying first', async () => {
+ const p = popup({ url: 'https://shop.acme-outdoor.com/cart', scriptResult: { ok: true, before: 54, after: 44, results: [
+ { id: 'w', code: 'GOOD', outcome: 'failed', before: 54, after: 54 }, { id: 'u', code: 'SAVE10', outcome: 'worked', before: 54, after: 44 }] } });
+ await p.controller.open();
+ const coupons = [
+ { id: 'u', code: 'SAVE10', status: 'unknown' }, { id: 'n', code: 'DEAD', status: 'reported_not_working' },
+ { id: 'w', code: 'GOOD', status: 'reported_working' }, ...Array.from({ length: 12 }, (_, i) => ({ id: `x${i}`, code: `X${i}`, status: 'unknown' })),
+ ];
+ const r = await p.controller.tryCodes(coupons);
+ const calls = p.ext.log.filter((l) => l[0] === 'scripting.executeScript').map((l) => l[1]);
+ assert.strictEqual(calls.length, 2);
+ assert.deepStrictEqual(calls[0], { target: { tabId: 1 }, files: ['lib/autoapply.js'] });
+ assert.deepStrictEqual(calls[1].target, { tabId: 1 });
+ const sent = calls[1].args[0];
+ assert.strictEqual(sent.length, config.MAX_TRIES, 'at most ten codes');
+ assert.deepStrictEqual(sent.slice(0, 2).map((c) => c.code), ['GOOD', 'SAVE10'], 'reported working first');
+ assert.ok(!sent.some((c) => c.code === 'DEAD'), 'never a code reported not working');
+ assert.deepStrictEqual(Object.keys(sent[0]).sort(), ['code', 'id'], 'only the code and its id reach the page');
+ assert.strictEqual(r.ok, true);
+ assert.match(r.message, /Applied SAVE10: the total went down by 10\.00\. Check the total before you pay\./);
+ assert.deepStrictEqual(r.results.map((x) => [x.code, x.outcome, x.saved]), [['GOOD', 'failed', null], ['SAVE10', 'worked', 10]]);
+ });
+
+ await check('no coupon box, or no Apply button: it says so and presses nothing', async () => {
+ for (const [reason, re] of [['no_field', /No coupon box on this page/], ['no_apply', /no Apply button next to it, so nothing was pressed/]]) {
+ const p = popup({ url: 'https://shop.example.com/checkout', scriptResult: { ok: false, reason, before: 20, after: 20, results: [] } });
+ await p.controller.open();
+ const r = await p.controller.tryCodes([{ id: 'a', code: 'A', status: 'unknown' }]);
+ assert.strictEqual(r.ok, false);
+ assert.match(r.message, re);
+ }
+ const none = popup({ url: 'https://shop.example.com/' });
+ await none.controller.open();
+ assert.strictEqual((await none.controller.tryCodes([{ id: 'n', code: 'X', status: 'reported_not_working' }])).ok, false);
+ assert.strictEqual(none.ext.log.filter((l) => l[0] === 'scripting.executeScript').length, 0, 'nothing injected without a code to try');
+ });
+
+ await check('a delivery app\'s page opens the comparison on openvibe.food with the restaurant\'s name only', async () => {
+ const cases = [
+ ['https://www.doordash.com/store/chipotle-123/?cart=abc#menu', 'Chipotle Mexican Grill Delivery Menu | 123 Main St San Francisco | DoorDash', 'Chipotle Mexican Grill'],
+ ['https://www.ubereats.com/store/chipotle/xyz', 'Order Chipotle (Market St) Menu Delivery Online | San Francisco | Uber Eats', 'Chipotle'],
+ ['https://www.grubhub.com/restaurant/joes-pizza/1', 'Joe\'s Pizza - Delivery & Pickup | Grubhub', 'Joe\'s Pizza'],
+ ['https://www.instacart.com/store/costco/storefront', 'Costco Delivery Near Me | Instacart', 'Costco'],
+ ['https://www.doordash.com/', 'DoorDash Food Delivery', null],
+ ];
+ for (const [url, title, q] of cases) {
+ const u = new URL(handoff.deliveryUrl(url, title));
+ assert.strictEqual(u.origin + u.pathname, 'https://openvibe.food/delivery');
+ assert.strictEqual(u.searchParams.get('q'), q, title);
+ assert.ok(!u.search.includes('123') && !u.search.includes('cart'), 'no address or cart');
+ }
+ assert.strictEqual(handoff.deliveryUrl('https://acme.com/pizza', 'Pizza'), null, 'not a delivery app');
+ const p = popup({ url: cases[0][0], title: cases[0][1] });
+ await p.controller.open();
+ assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: false, delivery: true });
+ assert.deepStrictEqual(p.ext.opened, []);
+ assert.strictEqual((await p.controller.compareDelivery()).ok, true);
+ assert.deepStrictEqual(p.ext.opened, ['https://openvibe.food/delivery?q=Chipotle+Mexican+Grill']);
+ });
+
+ await check('in a real browser: three fixture checkout pages (skipped without Chrome)', async () => {
+ const r = await require('../scripts/check-autoapply').main();
+ if (r.skipped) { console.log(` skipped: ${r.skipped}`); return; }
+ assert.deepStrictEqual(r.failures, []);
+ });
+
+ await stub.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+<!doctype html>
+<html lang="en"><head><meta charset="utf-8"><title>Checkout</title></head>
+<body>
+<p><a href="#" id="open">Have a promo code?</a></p>
+<div id="box" style="display:none">
+ <input id="code" placeholder="Enter coupon code" type="text">
+ <button id="redeem">Redeem</button>
+ <span id="err"></span>
+</div>
+<table><tr><th>Total</th><td id="sum">€1.234,56</td></tr></table>
+<script>
+// A framework-style field: the page keeps its own copy of the value from input events, so a bare .value write is not seen.
+var state = '';
+document.getElementById('code').addEventListener('input', function (e) { state = e.target.value; });
+document.getElementById('open').addEventListener('click', function (e) { e.preventDefault(); document.getElementById('box').style.display = 'block'; });
+document.getElementById('redeem').addEventListener('click', function () {
+ window.tried = (window.tried || []).concat(state);
+ setTimeout(function () {
+ if (state === 'WELCOME') document.getElementById('sum').textContent = '€1.111,10';
+ else document.getElementById('err').textContent = 'Sorry, this coupon has expired.';
+ }, 200);
+});
+</script>
+</body></html>
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+<!doctype html>
+<html lang="en"><head><meta charset="utf-8"><title>Checkout</title></head>
+<body>
+<form id="f" onsubmit="window.submits = (window.submits || 0) + 1; return false;">
+ <input name="discount_code" placeholder="Discount code" type="text">
+ <input name="card_number" placeholder="Card number" type="text">
+ <button type="submit">Pay now</button>
+</form>
+<p>Total <strong>$20.00</strong></p>
+</body></html>
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+<!doctype html>
+<html lang="en"><head><meta charset="utf-8"><title>Cart</title></head>
+<body>
+<h1>Your cart</h1>
+<div class="summary">
+ <div class="row"><span>Subtotal</span><span>$50.00</span></div>
+ <div class="row"><span>Shipping</span><span>$4.00</span></div>
+ <div class="row total"><span>Order total</span><span id="total">$54.00</span></div>
+</div>
+<form id="checkout" onsubmit="window.submits = (window.submits || 0) + 1; return false;">
+ <div class="promo">
+ <label for="promo">Promo code</label>
+ <input id="promo" name="promo_code" type="text">
+ <button type="button" id="apply">Apply</button>
+ <p id="msg"></p>
+ </div>
+ <input name="email" type="text" placeholder="Email">
+ <button type="submit" id="place">Place order</button>
+</form>
+<script>
+document.getElementById('apply').addEventListener('click', function () {
+ var code = document.getElementById('promo').value;
+ window.tried = (window.tried || []).concat(code);
+ setTimeout(function () {
+ if (code === 'SAVE10') { document.getElementById('total').textContent = '$44.00'; document.getElementById('msg').textContent = 'SAVE10 applied'; }
+ else document.getElementById('msg').textContent = 'The code ' + code + ' is not valid.';
+ }, 300);
+});
+</script>
+</body></html>
@@ -57,7 +57,7 @@ const CLEAN = 'https://saltlakecity.craigslist.org/apa/d/sunny-2br/7790000001.ht
@@ -57,7 +57,7 @@ const CLEAN = 'https://saltlakecity.craigslist.org/apa/d/sunny-2br/7790000001.ht
await p.controller.open();
await p.controller.open();
assert.deepStrictEqual(p.ext.opened, [], 'nothing opens on its own');
assert.deepStrictEqual(p.ext.opened, [], 'nothing opens on its own');
assert.deepStrictEqual(stub.requests.map((r) => r.url), ['/api/v1/merchants/resolve?host=saltlakecity.craigslist.org']);
assert.deepStrictEqual(stub.requests.map((r) => r.url), ['/api/v1/merchants/resolve?host=saltlakecity.craigslist.org']);
- assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: true });
+ assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: true, delivery: false });
assert.deepStrictEqual(await p.controller.keepOnBoard(), { ok: true });
assert.deepStrictEqual(await p.controller.keepOnBoard(), { ok: true });
assert.strictEqual(p.ext.opened.length, 1);
assert.strictEqual(p.ext.opened.length, 1);
@@ -71,7 +71,7 @@ const CLEAN = 'https://saltlakecity.craigslist.org/apa/d/sunny-2br/7790000001.ht
@@ -71,7 +71,7 @@ const CLEAN = 'https://saltlakecity.craigslist.org/apa/d/sunny-2br/7790000001.ht
await check('pages that are not websites offer no hand-off, and platform.tabs.open refuses anywhere else', async () => {
await check('pages that are not websites offer no hand-off, and platform.tabs.open refuses anywhere else', async () => {
const p = popup({ url: 'chrome://extensions' });
const p = popup({ url: 'chrome://extensions' });
await p.controller.open();
await p.controller.open();
- assert.deepStrictEqual(p.controller.page(), { handoff: false, rental: false });
+ assert.deepStrictEqual(p.controller.page(), { handoff: false, rental: false, delivery: false });
assert.strictEqual((await p.controller.keepOnBoard()).ok, false);
assert.strictEqual((await p.controller.keepOnBoard()).ok, false);
assert.deepStrictEqual(p.ext.opened, []);
assert.deepStrictEqual(p.ext.opened, []);
const platform = createPlatform(fakeExt({}), config);
const platform = createPlatform(fakeExt({}), config);
@@ -83,7 +83,7 @@ const CLEAN = 'https://saltlakecity.craigslist.org/apa/d/sunny-2br/7790000001.ht
@@ -83,7 +83,7 @@ const CLEAN = 'https://saltlakecity.craigslist.org/apa/d/sunny-2br/7790000001.ht
await check('a shop page that is not a rental leads with Watch', async () => {
await check('a shop page that is not a rental leads with Watch', async () => {
const p = popup({ url: 'https://acme-outdoor.com/tents/trail-2' });
const p = popup({ url: 'https://acme-outdoor.com/tents/trail-2' });
await p.controller.open();
await p.controller.open();
- assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: false });
+ assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: false, delivery: false });
});
});
await stub.close();
await stub.close();
@@ -71,7 +71,7 @@ function recordingFetch() {
@@ -71,7 +71,7 @@ function recordingFetch() {
}
}
/** Fakes of chrome.tabs (query, create) / chrome.storage.local / chrome.permissions; `opened` lists the tabs created. */
/** Fakes of chrome.tabs (query, create) / chrome.storage.local / chrome.permissions; `opened` lists the tabs created. */
-function fakeExt({ url = null, title = '', token = null, hasPermission = true } = {}) {
+function fakeExt({ url = null, title = '', token = null, hasPermission = true, scriptResult = null } = {}) {
const store = token ? { installToken: token } : {};
const store = token ? { installToken: token } : {};
const log = [];
const log = [];
const opened = [];
const opened = [];
@@ -88,6 +88,10 @@ function fakeExt({ url = null, title = '', token = null, hasPermission = true }
@@ -88,6 +88,10 @@ function fakeExt({ url = null, title = '', token = null, hasPermission = true }
async set(o) { log.push(['storage.set', Object.keys(o)]); Object.assign(store, o); },
async set(o) { log.push(['storage.set', Object.keys(o)]); Object.assign(store, o); },
async remove(k) { log.push(['storage.remove', k]); delete store[k]; },
async remove(k) { log.push(['storage.remove', k]); delete store[k]; },
} },
} },
+ // chrome.scripting.executeScript: the file injection answers [{}], the call answers scriptResult.
+ scripting: {
+ async executeScript(o) { log.push(['scripting.executeScript', o]); return o.files ? [{}] : [{ result: scriptResult }]; },
+ },
permissions: {
permissions: {
async contains(p) { log.push(['permissions.contains', p]); return hasPermission; },
async contains(p) { log.push(['permissions.contains', p]); return hasPermission; },
async request(p) { log.push(['permissions.request', p]); return true; },
async request(p) { log.push(['permissions.request', p]); return true; },
@@ -15,10 +15,10 @@ const ORIGIN = 'https://openvibe.coupons';
@@ -15,10 +15,10 @@ const ORIGIN = 'https://openvibe.coupons';
(async () => {
(async () => {
const { manifest, origin, problems } = auditDir(DIR);
const { manifest, origin, problems } = auditDir(DIR);
- await check('the shipped manifest passes the audit: activeTab + storage, one host permission (the API), MV3', async () => {
+ await check('the shipped manifest passes the audit: activeTab + scripting + storage, one host permission (the API), MV3', async () => {
assert.deepStrictEqual(problems, []);
assert.deepStrictEqual(problems, []);
assert.strictEqual(origin, ORIGIN);
assert.strictEqual(origin, ORIGIN);
- assert.deepStrictEqual(manifest.permissions.slice().sort(), ['activeTab', 'storage']);
+ assert.deepStrictEqual(manifest.permissions.slice().sort(), ['activeTab', 'scripting', 'storage']);
assert.deepStrictEqual(manifest.host_permissions, ['https://openvibe.coupons/*']);
assert.deepStrictEqual(manifest.host_permissions, ['https://openvibe.coupons/*']);
for (const k of ['content_scripts', 'background', 'web_accessible_resources', 'externally_connectable', 'optional_permissions', 'optional_host_permissions']) {
for (const k of ['content_scripts', 'background', 'web_accessible_resources', 'externally_connectable', 'optional_permissions', 'optional_host_permissions']) {
assert.ok(!(k in manifest), `${k} must not be present`);
assert.ok(!(k in manifest), `${k} must not be present`);
@@ -29,7 +29,8 @@ const ORIGIN = 'https://openvibe.coupons';
@@ -29,7 +29,8 @@ const ORIGIN = 'https://openvibe.coupons';
const widen = [
const widen = [
(m) => m.permissions.push('tabs'),
(m) => m.permissions.push('tabs'),
(m) => m.permissions.push('cookies'),
(m) => m.permissions.push('cookies'),
- (m) => m.permissions.push('scripting'),
+ (m) => m.permissions.push('declarativeNetRequest'),
+ (m) => { m.permissions = m.permissions.filter((p) => p !== 'scripting').concat('tabs'); },
(m) => m.permissions.push('<all_urls>'),
(m) => m.permissions.push('<all_urls>'),
(m) => m.permissions.push('webRequest'),
(m) => m.permissions.push('webRequest'),
(m) => m.permissions.push('history'),
(m) => m.permissions.push('history'),
@@ -68,11 +69,11 @@ const ORIGIN = 'https://openvibe.coupons';
@@ -68,11 +69,11 @@ const ORIGIN = 'https://openvibe.coupons';
assert.ok(auditPage('<button onclick="x()">', 'x').length);
assert.ok(auditPage('<button onclick="x()">', 'x').length);
});
});
- await check('the code never uses page access, cookies, dynamic code or HTML injection; only api.js fetches', async () => {
+ await check('the code never uses cookies, dynamic code or HTML injection; only api.js fetches; only platform.js runs a script in a page', async () => {
const files = [];
const files = [];
(function walk(d) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else if (p.endsWith('.js')) files.push(p); } })(DIR);
(function walk(d) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else if (p.endsWith('.js')) files.push(p); } })(DIR);
const forbidden = [/\binnerHTML\b/, /\bouterHTML\b/, /insertAdjacentHTML/, /document\.write/, /\beval\s*\(/, /new Function\s*\(/, /\bcookies\b/, /document\.cookie/,
const forbidden = [/\binnerHTML\b/, /\bouterHTML\b/, /insertAdjacentHTML/, /document\.write/, /\beval\s*\(/, /new Function\s*\(/, /\bcookies\b/, /document\.cookie/,
- /executeScript/, /\.scripting\b/, /\bhistory\.(search|getVisits)/, /webRequest/, /storage\.sync/, /sendMessage|onMessageExternal|runtime\.connect/, /XMLHttpRequest/, /importScripts/];
+ /\bhistory\.(search|getVisits)/, /webRequest/, /storage\.sync/, /sendMessage|onMessageExternal|runtime\.connect/, /XMLHttpRequest/, /importScripts/];
for (const f of files) {
for (const f of files) {
// Comments may name what the code must not do; only code is scanned.
// Comments may name what the code must not do; only code is scanned.
const src = fs.readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"])\/\/.*$/gm, '$1');
const src = fs.readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"])\/\/.*$/gm, '$1');
@@ -82,6 +83,17 @@ const ORIGIN = 'https://openvibe.coupons';
@@ -82,6 +83,17 @@ const ORIGIN = 'https://openvibe.coupons';
}
}
const tabUse = files.filter((f) => /\btabs\.(query|create|update)\b/.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(DIR, f));
const tabUse = files.filter((f) => /\btabs\.(query|create|update)\b/.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(DIR, f));
assert.deepStrictEqual(tabUse, [path.join('lib', 'platform.js')], 'only the platform wrapper touches tabs (it opens only hand-off pages)');
assert.deepStrictEqual(tabUse, [path.join('lib', 'platform.js')], 'only the platform wrapper touches tabs (it opens only hand-off pages)');
+ // The page is touched only by "Try these codes on this page": platform.js injects the one packaged file and calls it.
+ const injects = files.filter((f) => /executeScript|ext\.scripting/.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(DIR, f));
+ assert.deepStrictEqual(injects, [path.join('lib', 'platform.js')], 'only the platform wrapper runs a script in a page');
+ const plat = fs.readFileSync(path.join(DIR, 'lib', 'platform.js'), 'utf8');
+ assert.deepStrictEqual([...plat.matchAll(/files:\s*\[([^\]]*)\]/g)].map((m) => m[1].trim()), ["'lib/autoapply.js'"], 'the one file it injects');
+ assert.doesNotMatch(plat, /allFrames|world:\s*['"]MAIN|tabId:(?!\s*tabId\b)/, 'the active tab only, its top frame, the isolated world');
+ // What runs in the shop's page makes no request and reads no stored data.
+ const auto = fs.readFileSync(path.join(DIR, 'lib', 'autoapply.js'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
+ for (const re of [/\bfetch\s*\(/, /XMLHttpRequest/, /WebSocket/, /sendBeacon/, /localStorage|sessionStorage|indexedDB/, /document\.cookie/, /\.submit\s*\(|requestSubmit/, /KeyboardEvent|keydown|keypress/, /chrome\.|browser\./]) {
+ assert.doesNotMatch(auto, re, `lib/autoapply.js uses ${re}`);
+ }
});
});
done();
done();