openvibers/openvibe.extensions Commits

OpenVibe Companion 0.2.0: keep a listing on the Rent board, watch a page (#3)

OP OpenVibers committed 29 days ago · 8e33f4af6a · 18 files, +385 −73

The Coupons Helper becomes the OpenVibe Companion: one button in the browser
for the OpenVibe sites that work with the page you are on.

- Keep on my Rent board: opens openvibe.rent/board with the page's address
  and title (the main button on rental sites: Craigslist, Facebook
  Marketplace, Zillow, Apartments.com, Airbnb and the rest)
- Watch this page: opens a new OpenVibe.Watch page watch for it
- Coupon codes for the shop, as before (no link rewriting, no referral tags)

Permissions stay activeTab + storage with the one Coupons host. The API still
receives only the hostname. A hand-off is a new tab the person sees, opened
only when they press the button, with credentials, fragment and tracking
parameters removed (lib/handoff.js); platform.tabs.open refuses any origin
outside config HANDOFF_ORIGINS, and the audit allows only https://openvibe.*
hand-off origins. test/handoff.test.js covers it; Chromium loads the unpacked
build with no console or CSP errors.

Changes

ddiff --git a/README.md b/README.md
iindex dadc257..5c08037 100644
---- a/README.md
++++ b/README.md
@@@ -1,11 +1,12 @@
# OpenVibe.Extensions
--> OpenVibe's first-party client surfaces: today, the **OpenVibe Coupons Helper** 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 headless
--Chrome 150 loads the packed build. A development build of it looked up codes and sent a report
--against a local OpenVibe.Coupons. It is **not published** to any store, and it hasn't been verified
--in Firefox yet. OpenVibe.Coupons runs on the production host with an empty database, and
++**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
++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
++Firefox yet. OpenVibe.Coupons runs on the production host with an empty database, and
`https://openvibe.coupons` is public since 2026-10-09, so the release build talks to the live
Coupons API.
**Decision record:** [ADR-023](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-023-client-surfaces.md) (first-party client surfaces and their home). Roadmap §4.2 D.
@@@ -18,15 +19,24 @@ per-install permissions. ADR-023 gives clients their own repository.
| Surface | Directory | State |
|---|---|---|
--| OpenVibe Coupons Helper (Chromium and Firefox, Manifest V3) | `browser/` | alpha, described below |
++| OpenVibe Companion (Chromium and Firefox, Manifest V3) | `browser/` | alpha, described below (it was the OpenVibe Coupons Helper until 0.2.0) |
| Kiosk companion (Firefox, new-tab and history bridge for the /kiosk page) | `kiosk-companion/` | Moved from OpenVibe.Live `browser-extension/openvibe-kiosk-companion/` (ADR-023, Live 9c5aa64); Live keeps a redirect README |
| Hardware / Raspberry Pi companions (streamer and controller scripts) | `hardware/` | Moved from OpenVibe.Live `hardware/` (ADR-023, Live 9c5aa64); Live keeps a redirect README |
| Desktop client | — | Planned by ADR-023 for the long term. Nothing exists. |
--## The OpenVibe Coupons Helper (`browser/`)
++## The OpenVibe Companion (`browser/`)
--Open it on a shop's website and it shows the codes
--[OpenVibe.Coupons](https://github.com/OpenVibers/OpenVibe.Coupons) knows for that shop:
++One button in the browser for the OpenVibe sites that work with the page you are on:
++
++- **Keep on my Rent board.** On a rental listing (Craigslist, Facebook Marketplace, Zillow,
++ Apartments.com, Zumper, Airbnb and the rest) or any page, it opens
++ [openvibe.rent/board](https://openvibe.rent/board) with the listing's address and title filled in,
++ so every listing from every site sits in one list with your notes and how far you got. On a rental
++ site 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.
++- **Coupon codes.** On a shop's website it shows the codes
++ [OpenVibe.Coupons](https://github.com/OpenVibers/OpenVibe.Coupons) knows for that shop:
- the code, with a Copy button
- its status (validity unknown, reported working, reported not working)
@@@ -34,7 +44,8 @@ Open it on a shop's website and it shows the codes
- its restrictions, or "No restrictions stated"
- how to apply it
--If you connected the extension, you can report whether a code worked.
++If you connected the extension, you can report whether a code worked. The codes are shown to copy;
++the Companion never rewrites links or adds referral tags.
### What it can see, and what it sends
@@@ -42,8 +53,9 @@ If you connected the extension, you can report whether a code worked.
|---|---|
| **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 **hostname** of the active tab's URL, when you open the popup (that's what `activeTab` grants). `lib/host.js` drops the path, query, fragment, port and credentials before anything else sees the URL. Browser pages, files, IP addresses and local names are "not a shop", and nothing is sent for them. |
--| **Sent** | To `https://openvibe.coupons` only: the hostname (`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'`. |
++| **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. |
++| **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. |
| **CSP** | `default-src 'none'; script-src 'self'; connect-src https://openvibe.coupons; object-src 'none'`. There is no inline script, no remote code, no `eval`, and no `innerHTML`: the popup builds DOM nodes and sets text. |
@@@ -75,11 +87,12 @@ browser/
manifest.json MV3; Chromium (minimum_chrome_version 111) and Firefox (gecko id, strict_min_version 128)
popup.html / popup.js DOM wiring only
options.html / .js connect (save a checked token), disconnect, grant the API host permission
-- lib/config.js API_ORIGIN — the one origin the extension talks to
++ 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/handoff.js the Rent board and Watch addresses, built from a cleaned URL; which sites are rentals
lib/api.js the only code that makes requests
lib/controller.js popup logic without the DOM (tested in Node)
-- lib/platform.js chrome.* / browser.*: tabs.query, storage.local, permissions
++ lib/platform.js chrome.* / browser.*: tabs.query, tabs.create (hand-off pages only), storage.local, permissions
lib/format.js wording ("Expiry unknown", "No restrictions stated", …)
icons/ generated by scripts/icons.js
scripts/
@@@ -99,6 +112,7 @@ npm test
| The manifest permission audit fails if anything widens: extra permissions, `<all_urls>` or broader hosts, content scripts, a background worker, web-accessible resources, `externally_connectable`, optional permissions, `unsafe-eval`, remote script or connect sources, MV2. The API origin agrees across config, host permission and CSP. Pages have no inline or remote script. The code never uses cookies, scripting, dynamic code, HTML injection or `storage.sync`, and only `lib/api.js` fetches. | `test/manifest.test.js` |
| API client against a stub: routes; no `Authorization` without a token; never cookies, referrers or redirects; errors as codes (revoked, scope, 429, 503); timeouts; refused input. | `test/api-client.test.js` |
| The extension never sends page content or URLs beyond the hostname. The popup controller runs on checkout URLs full of personal data, and every request the stub receives is inspected. Non-shop pages send nothing. Only `tabs.query` and `storage.local` are touched. Only `cpx_` tokens are stored. | `test/privacy.test.js` |
++| The hand-offs: nothing opens when the popup opens; a button opens one tab on openvibe.rent or openvibe.watch with the cleaned address (no credentials, fragment or tracking parameters); the Coupons API still gets the hostname only; `tabs.open` refuses other origins; rental sites lead with the board. | `test/handoff.test.js` |
| `scripts/pack.js`: the zip is exactly `browser/`, audited and reproducible. A development build rewrites the origin in all three places. Remote `http` origins are refused. | `test/pack.test.js` |
Beyond the automated tests, this was checked by hand on 2026-09-22:
@@@ -114,10 +128,11 @@ That check is not in CI. Firefox hasn't been tried.
## Packing (never published from here)
```bash
--npm run pack # dist/openvibe-coupons-helper-<version>.zip (API https://openvibe.coupons)
++npm run pack # dist/openvibe-companion-<version>.zip (API https://openvibe.coupons)
node scripts/pack.js --api http://localhost:4850 # dist/…-dev.zip for a local Coupons (localhost/127.0.0.1 or https only)
```
++Each version's zip is attached to its [GitHub release](https://github.com/OpenVibers/OpenVibe.Extensions/releases/latest).
To load it:
- **Chromium:** `chrome://extensions`, turn on Developer mode, choose "Load unpacked" and pick the
@@@ -134,7 +149,7 @@ shows "Allow access to openvibe.coupons".
2026-10-09). The release build only talks to `https://openvibe.coupons`.
2. **Store listings:** submit the zip to the Chrome Web Store and addons.mozilla.org. The privacy
disclosure is the table above: the hostname of the active tab, only on open, sent to
-- openvibe.coupons. `browser_specific_settings.gecko.data_collection_permissions` declares
++ openvibe.coupons; the address and title only in a hand-off tab the person opens. `browser_specific_settings.gecko.data_collection_permissions` declares
`browsingActivity`. Check it against AMO's requirements at submission time.
3. **CORS (optional):** set `COUPONS_EXTENSION_ORIGINS` on Coupons to the Chrome Web Store id
(`chrome-extension://<id>`) and, if wanted, `moz-extension://*`. The extension doesn't need it,
@@@ -145,6 +160,9 @@ shows "Allow access to openvibe.coupons".
## Depends on
++- [OpenVibe.Rent](https://github.com/OpenVibers/OpenVibe.Rent) `/board?url=&title=` and
++ [OpenVibe.Watch](https://github.com/OpenVibers/OpenVibe.Watch) `/watches/new?template=page&url=&name=`
++ (pages, not APIs: the person signs in there as usual).
- [OpenVibe.Coupons](https://github.com/OpenVibers/OpenVibe.Coupons) public API:
- `GET /api/v1/merchants/resolve?host=`
- `GET /api/v1/merchants/:id/coupons`
ddiff --git a/STATUS.json b/STATUS.json
iindex 88327f1..3e0ec0b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,17 +7,29 @@
"adr": "OpenVibe.Contracts ADR-023 (first-party client surfaces)",
"surfaces": {
"browser": {
-- "name": "OpenVibe Coupons Helper",
-- "version": "0.1.0",
++ "name": "OpenVibe Companion",
++ "version": "0.2.0",
"manifest": 3,
-- "targets": ["chromium >= 111", "firefox >= 128"],
++ "targets": [
++ "chromium >= 111",
++ "firefox >= 128"
++ ],
"api": "https://openvibe.coupons (OpenVibe.Coupons public API)",
-- "permissions": ["activeTab", "storage"],
-- "hostPermissions": ["https://openvibe.coupons/*"],
++ "permissions": [
++ "activeTab",
++ "storage"
++ ],
++ "hostPermissions": [
++ "https://openvibe.coupons/*"
++ ],
"credential": "optional cpx_ install token (coupons.lookup, coupons.report); no Network session",
"publishedToStores": false,
-- "loadedInChrome": "yes: headless Chrome 150 loads the packed build; a development build looked up and reported against a local OpenVibe.Coupons",
-- "loadedInFirefox": "not yet verified"
++ "loadedInChrome": "yes: headless Chrome loads the packed 0.2.0 build (popup and options render, no console errors)",
++ "loadedInFirefox": "not yet verified",
++ "handoffs": [
++ "https://openvibe.rent/board (keep a listing)",
++ "https://openvibe.watch/watches/new (watch a page)"
++ ]
}
},
"pending": [
ddiff --git a/browser/lib/config.js b/browser/lib/config.js
iindex 93d2861..1bbf9be 100644
---- a/browser/lib/config.js
++++ b/browser/lib/config.js
@@@ -1,9 +1,13 @@
/*
-- * OpenVibe Coupons Helper — configuration.
++ * OpenVibe Companion — configuration.
*
-- * API_ORIGIN is the ONLY origin this extension ever talks to. It must match the manifest's
++ * API_ORIGIN is the ONLY origin this extension ever sends a request to. It must match the manifest's
* host_permissions and the connect-src of its CSP; scripts/pack.js rewrites all three together for
* 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.
*/
(function (root, factory) {
const api = factory();
@@@ -13,6 +17,9 @@
'use strict';
return Object.freeze({
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']),
CONNECT_PATH: '/connect-extension',
TOKEN_KEY: 'installToken',
TOKEN_RE: /^cpx_[A-Za-z0-9_-]{43}$/,
ddiff --git a/browser/lib/controller.js b/browser/lib/controller.js
iindex 3498141..e2320c7 100644
---- a/browser/lib/controller.js
++++ b/browser/lib/controller.js
@@@ -1,13 +1,17 @@
/*
* The popup's logic, without the DOM (so Node tests can drive it):
*
-- * const c = createController({ api, platform, host });
-- * const vm = await c.open(); // what to show for the active tab
++ * const c = createController({ api, platform, host, handoff });
++ * const vm = await c.open(); // the codes for the active tab's shop
++ * 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.report(couponId, 'worked') // → { ok, message }
*
-- * open() reads the active tab's URL once, keeps only its hostname (host.hostnameOf), and asks the
-- * API which shop that is and which codes it has. That hostname is the only thing about the page
-- * that ever leaves the browser.
++ * open() reads the active tab's URL and title once. Only the hostname (host.hostnameOf) is sent
++ * anywhere: to the Coupons API, to say which shop that is. The URL and title stay in this closure
++ * for the two hand-offs, which open a tab on openvibe.rent or openvibe.watch only when the person
++ * presses their button (lib/handoff.js cleans the URL first).
*
* View models:
* { state: 'not_a_site' } browser pages, files, IPs, local names
@@@ -40,10 +44,20 @@
const api = deps.api;
const platform = deps.platform;
const host = deps.host;
++ const handoff = deps.handoff || null;
++ let tab = { url: null, title: '' };
++
++ async function handOff(build) {
++ const target = handoff && build(tab.url, tab.title);
++ if (!target) return { ok: false, message: 'This page cannot be sent there.' };
++ await platform.tabs.open(target);
++ return { ok: true };
++ }
return {
async open() {
-- const url = await platform.tabs.activeUrl();
++ tab = await platform.tabs.active();
++ const url = tab.url;
const hostname = host.hostnameOf(url);
if (!hostname) return { state: 'not_a_site' };
const connected = Boolean(await platform.storage.getToken());
@@@ -57,6 +71,15 @@
}
},
++ /** 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. */
++ 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 };
++ },
++
++ keepOnBoard() { return handOff(function (u, t) { return handoff.boardUrl(u, t); }); },
++ watchPage() { return handOff(function (u, t) { return handoff.watchUrl(u, t); }); },
++
async report(couponId, outcome, reason) {
try {
const out = await api.report(couponId, outcome, reason);
ddiff --git a/browser/lib/handoff.js b/browser/lib/handoff.js
nnew file mode 100644
iindex 0000000..826c679
---- /dev/null
++++ b/browser/lib/handoff.js
@@@ -0,0 +1,93 @@
++/*
++ * Hand-offs: the pages the Companion opens on OpenVibe sites with the page you are on, when you
++ * press a button for it — never on its own.
++ *
++ * 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
++ *
++ * 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
++ * credentials, the fragment and the tracking parameters (utm_*, fbclid, gclid, …) first; anything
++ * that is not an http(s) page gives null and nothing opens. platform.tabs.open() refuses any
++ * address outside lib/config.js HANDOFF_ORIGINS.
++ */
++(function (root, factory) {
++ const api = factory();
++ if (typeof module === 'object' && module.exports) module.exports = api;
++ else root.OVHandoff = api;
++})(typeof self !== 'undefined' ? self : this, function () {
++ 'use strict';
++
++ const TRACKING = /^(utm_[a-z_]+|fbclid|gclid|gbraid|wbraid|dclid|msclkid|mc_cid|mc_eid|igshid|yclid|_hsenc|_hsmi|ref_src|spm)$/i;
++
++ /** Sites whose pages are rental listings: there, keeping it on the board is the main action. */
++ const RENTAL_HOSTS = ['craigslist.org', 'zillow.com', 'apartments.com', 'zumper.com', 'rent.com', 'rentcafe.com', 'apartmentlist.com',
++ 'padmapper.com', 'hotpads.com', 'trulia.com', 'realtor.com', 'roomies.com', 'furnishedfinder.com', 'airbnb.com', 'vrbo.com',
++ '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'];
++
++ function cleanUrl(url) {
++ if (typeof url !== 'string' || !url || url.length > 4000) return null;
++ let u;
++ try { u = new URL(url); } catch (e) { return null; }
++ if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
++ u.username = '';
++ u.password = '';
++ u.hash = '';
++ const keep = [];
++ u.searchParams.forEach(function (v, k) { if (!TRACKING.test(k)) keep.push([k, v]); });
++ u.search = '';
++ keep.forEach(function (kv) { u.searchParams.append(kv[0], kv[1]); });
++ const out = u.href;
++ return out.length <= 2000 ? out : null;
++ }
++
++ function cleanTitle(title, max) {
++ return String(title == null ? '' : title).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
++ }
++
++ function isRental(url) {
++ let u;
++ try { u = new URL(String(url)); } catch (e) { return false; }
++ const h = u.hostname.toLowerCase().replace(/^www\./, '');
++ if (h === 'facebook.com' || h.endsWith('.facebook.com')) return /^\/marketplace\b/.test(u.pathname);
++ return RENTAL_HOSTS.some(function (r) { return h === r || h.endsWith('.' + r); });
++ }
++
++ /** The hand-offs for one configuration (lib/config.js RENT_ORIGIN, WATCH_ORIGIN, HANDOFF_ORIGINS). */
++ function createHandoff(config) {
++ const RENT = config.RENT_ORIGIN;
++ const WATCH = config.WATCH_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); },
++ isHandoff: function (url) { return isHandoff(origins, url); },
++ };
++ }
++
++ function boardUrl(RENT, url, title) {
++ const page = cleanUrl(url);
++ if (!page) return null;
++ const q = new URLSearchParams({ url: page });
++ const t = cleanTitle(title, 200);
++ if (t) q.set('title', t);
++ return RENT + '/board?' + q.toString();
++ }
++
++ function watchUrl(WATCH, url, title) {
++ const page = cleanUrl(url);
++ if (!page) return null;
++ const q = new URLSearchParams({ template: 'page', url: page });
++ const t = cleanTitle(title, 80);
++ if (t) q.set('name', t);
++ return WATCH + '/watches/new?' + q.toString();
++ }
++
++ /** True when `url` is a page on one of the hand-off sites (what platform.tabs.open() accepts). */
++ function isHandoff(origins, url) {
++ return typeof url === 'string' && origins.some(function (o) { return url.indexOf(o + '/') === 0; });
++ }
++
++ return { createHandoff, RENTAL_HOSTS, cleanUrl, isRental, isHandoff };
++});
ddiff --git a/browser/lib/platform.js b/browser/lib/platform.js
iindex 6aa1b8d..9ff58f6 100644
---- a/browser/lib/platform.js
++++ b/browser/lib/platform.js
@@@ -1,8 +1,11 @@
/*
-- * The browser APIs the helper uses, in one place (Chromium `chrome.*` and Firefox `browser.*`):
++ * The browser APIs the Companion uses, in one place (Chromium `chrome.*` and Firefox `browser.*`):
*
-- * tabs.activeUrl() the active tab's URL — available because the user opened the popup
-- * (activeTab); handed straight to host.hostnameOf and dropped
++ * tabs.active() the active tab's URL and title — available because the user opened the
++ * popup (activeTab); the controller sends only the hostname anywhere, and
++ * keeps the rest for a hand-off the person may press
++ * tabs.open(url) open a new tab on one of config.HANDOFF_ORIGINS (and nowhere else) —
++ * tabs.create needs no permission
* storage.getToken/setToken/clearToken the optional cpx_ install token, in storage.local
* (never storage.sync: it stays on this device)
* permissions.hasApi/requestApi the host permission for the API origin (Firefox
@@@ -25,9 +28,15 @@
};
return {
tabs: {
-- async activeUrl() {
++ async active() {
const tabs = await call(ext.tabs.query, ext.tabs, [{ active: true, currentWindow: true }]);
-- return tabs && tabs[0] && typeof tabs[0].url === 'string' ? tabs[0].url : null;
++ const t = tabs && tabs[0];
++ return { 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; });
++ if (!ok) throw new TypeError('not an OpenVibe hand-off page');
++ await call(ext.tabs.create, ext.tabs, [{ url: url }]);
},
},
storage: {
ddiff --git a/browser/manifest.json b/browser/manifest.json
iindex 9d06325..7b91401 100644
---- a/browser/manifest.json
++++ b/browser/manifest.json
@@@ -1,10 +1,10 @@
{
"manifest_version": 3,
-- "name": "OpenVibe Coupons Helper",
-- "short_name": "OV Coupons",
-- "version": "0.1.0",
-- "description": "Shows the coupon codes OpenVibe.Coupons knows for the shop you are on, with their status, expiry and restrictions. Reads only the hostname, only when you open it.",
-- "homepage_url": "https://openvibe.coupons/connect-extension",
++ "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.",
++ "homepage_url": "https://github.com/OpenVibers/OpenVibe.Extensions",
"author": "OpenVibers",
"icons": {
"16": "icons/icon-16.png",
@@@ -13,7 +13,7 @@
"128": "icons/icon-128.png"
},
"action": {
-- "default_title": "Coupon codes for this shop",
++ "default_title": "OpenVibe Companion",
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon-16.png",
@@@ -37,7 +37,7 @@
"minimum_chrome_version": "111",
"browser_specific_settings": {
"gecko": {
-- "id": "[email protected]",
++ "id": "[email protected]",
"strict_min_version": "128.0",
"data_collection_permissions": {
"required": ["browsingActivity"]
ddiff --git a/browser/options.html b/browser/options.html
iindex 4503308..d3c2124 100644
---- a/browser/options.html
++++ b/browser/options.html
@@@ -2,13 +2,13 @@
<html lang="en">
<head>
<meta charset="utf-8">
--<title>OpenVibe Coupons Helper — settings</title>
++<title>OpenVibe Companion — settings</title>
<link rel="stylesheet" href="popup.css">
</head>
<body class="options">
<main>
-- <h1>OpenVibe Coupons Helper</h1>
-- <p>The helper works without connecting. Connecting lets it report whether a code worked, as you.</p>
++ <h1>OpenVibe Companion</h1>
++ <p>Coupon codes, the Rent board and Watch all work without connecting. Connecting lets the Companion report whether a code worked, as you.</p>
<ol>
<li>On <a id="connect-link" target="_blank" rel="noopener noreferrer" href="#">openvibe.coupons/connect-extension</a>, sign in and create a token for this browser.</li>
<li>Paste it here. It can only look up codes and report on them; it is not your OpenVibe sign-in.</li>
@@@ -20,12 +20,13 @@
</form>
<p id="state" class="muted" aria-live="polite"></p>
<p><button id="disconnect" type="button">Disconnect</button> <span class="muted">Removes the token from this browser. Revoke it on openvibe.coupons as well.</span></p>
-- <p id="permission-row" hidden><button id="grant" type="button">Allow access to openvibe.coupons</button> <span class="muted">Needed to report; the helper never asks for access to other sites.</span></p>
++ <p id="permission-row" hidden><button id="grant" type="button">Allow access to openvibe.coupons</button> <span class="muted">Needed to report; the Companion never asks for access to other sites.</span></p>
<h2>What this extension can see</h2>
<ul>
-- <li>Only the hostname of the tab you are on, and only when you open the helper.</li>
-- <li>No page content, no full addresses, no cookies, no browsing history.</li>
-- <li>It talks to openvibe.coupons only.</li>
++ <li>The tab you are on, and only when you open the Companion: its address and title.</li>
++ <li>It sends openvibe.coupons only the hostname, to find that shop’s codes.</li>
++ <li>When you press “Keep on my Rent board” or “Watch this page”, it opens openvibe.rent or openvibe.watch in a new tab with the page’s address and title, without its tracking parameters. You see that tab, and nothing is saved until you save it there.</li>
++ <li>No page content, no cookies, no browsing history, nothing in the background.</li>
</ul>
</main>
<script src="lib/config.js"></script>
ddiff --git a/browser/popup.css b/browser/popup.css
iindex 04ab1e5..b6b7097 100644
---- a/browser/popup.css
++++ b/browser/popup.css
@@@ -1,5 +1,5 @@
--:root { color-scheme: light dark; --fg: #1d2433; --muted: #5d6b82; --bg: #ffffff; --card: #f4f6fa; --line: #d6dce6; --accent: #b45309; --ok: #15803d; --bad: #b91c1c; }
--@media (prefers-color-scheme: dark) { :root { --fg: #e6edf7; --muted: #9aa8bf; --bg: #0f1522; --card: #172033; --line: #2a3650; --accent: #f59e0b; --ok: #22c55e; --bad: #f87171; } }
++:root { color-scheme: light dark; --fg: #1d2433; --muted: #5d6b82; --bg: #ffffff; --card: #f4f6fa; --line: #d6dce6; --accent: #1d4ed8; --accent-strong: #2563eb; --on-accent-strong: #ffffff; --code: #b45309; --ok: #15803d; --bad: #b91c1c; }
++@media (prefers-color-scheme: dark) { :root { --fg: #e6edf7; --muted: #9aa8bf; --bg: #0f1522; --card: #172033; --line: #2a3650; --accent: #60a5fa; --accent-strong: #2563eb; --on-accent-strong: #ffffff; --code: #f59e0b; --ok: #22c55e; --bad: #f87171; } }
* { box-sizing: border-box; }
body { margin: 0; width: 360px; max-height: 560px; overflow-y: auto; background: var(--bg); color: var(--fg); font: 13px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
header, main, footer { padding: 10px 12px; }
@@@ -10,7 +10,7 @@ h2 { font-size: 14px; margin: 0 0 6px; }
.coupon { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; margin: 8px 0; }
.coupon h3 { font-size: 13px; margin: 0 0 4px; }
.row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
--.code { font: 600 14px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; border: 1px dashed var(--accent); border-radius: 5px; padding: 2px 8px; user-select: all; word-break: break-all; }
++.code { font: 600 14px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; border: 1px dashed var(--code); border-radius: 5px; padding: 2px 8px; user-select: all; word-break: break-all; }
.status { font-size: 11px; border: 1px solid var(--line); border-radius: 999px; padding: 0 7px; }
.status.reported_working { color: var(--ok); border-color: var(--ok); }
.status.reported_failed { color: var(--bad); border-color: var(--bad); }
@@@ -19,6 +19,12 @@ button { font: inherit; padding: 3px 9px; border-radius: 5px; border: 1px solid
button:hover, button:focus-visible { border-color: var(--accent); }
select { font: inherit; }
.note { font-size: 12px; margin-top: 4px; }
++.actions { padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--card); }
++.actions .lede { margin: 0 0 8px; }
++.btn { padding: 6px 12px; border-radius: 7px; font-weight: 600; }
++.btn.primary { background: var(--accent-strong); border-color: var(--accent-strong); color: var(--on-accent-strong); }
++.btn.primary:hover, .btn.primary:focus-visible { filter: brightness(1.08); border-color: var(--accent-strong); }
++button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
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; }
ddiff --git a/browser/popup.html b/browser/popup.html
iindex 23e2fa7..27aaa59 100644
---- a/browser/popup.html
++++ b/browser/popup.html
@@@ -2,21 +2,30 @@
<html lang="en">
<head>
<meta charset="utf-8">
--<title>Coupon codes for this shop</title>
++<title>OpenVibe Companion</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<header>
-- <h1>OpenVibe Coupons</h1>
++ <h1>OpenVibe Companion</h1>
<p id="site" class="muted"></p>
</header>
++<section id="actions" class="actions" hidden aria-label="Send this page to OpenVibe">
++ <p id="actions-lede" class="lede"></p>
++ <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>
++ </div>
++ <p id="actions-note" class="note muted">Opens openvibe.rent or openvibe.watch 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>
<a id="settings" href="options.html" target="_blank" rel="noopener">Settings</a>
-- <span class="muted"> · Reads only this tab's hostname.</span>
++ <span class="muted"> · Reads this tab only when you open it.</span>
</footer>
<script src="lib/config.js"></script>
<script src="lib/host.js"></script>
++<script src="lib/handoff.js"></script>
<script src="lib/api.js"></script>
<script src="lib/format.js"></script>
<script src="lib/platform.js"></script>
ddiff --git a/browser/popup.js b/browser/popup.js
iindex 5f04abc..b4742f2 100644
---- a/browser/popup.js
++++ b/browser/popup.js
@@@ -1,5 +1,5 @@
/*
-- * Popup wiring: DOM only (no innerHTML — every value is set as text), clipboard, and the calls to
++ * OpenVibe Companion popup wiring: DOM only (no innerHTML — every value is set as text), clipboard, and the calls to
* the controller. All logic lives in lib/controller.js.
*/
(function () {
@@@ -8,7 +8,8 @@
const config = self.OVConfig;
const platform = self.OVPlatform.createPlatform(ext, config);
const api = self.OVApi.createApiClient({ origin: config.API_ORIGIN, fetch: self.fetch.bind(self), getToken: platform.storage.getToken, timeoutMs: config.TIMEOUT_MS });
-- const controller = self.OVController.createController({ api: api, platform: platform, host: self.OVHost });
++ const handoff = self.OVHandoff.createHandoff(config);
++ const controller = self.OVController.createController({ api: api, platform: platform, host: self.OVHost, handoff: handoff, isRental: self.OVHandoff.isRental });
const fmt = self.OVFormat;
const content = document.getElementById('content');
const site = document.getElementById('site');
@@@ -83,13 +84,38 @@
note);
}
++ /** The two hand-off buttons: the board leads on a rental listing, Watch everywhere else. */
++ function renderActions() {
++ const p = controller.page();
++ const section = document.getElementById('actions');
++ if (!p.handoff) return;
++ const keep = document.getElementById('keep');
++ const watch = document.getElementById('watch');
++ 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');
++ const go = function (fn) {
++ return async function () {
++ const r = await fn();
++ if (r.ok) window.close();
++ else note.textContent = r.message;
++ };
++ };
++ keep.addEventListener('click', go(controller.keepOnBoard));
++ watch.addEventListener('click', go(controller.watchPage));
++ section.hidden = false;
++ }
++
async function render() {
const vm = await controller.open();
if (vm.host) site.textContent = vm.host;
-- if (vm.state === 'not_a_site') return show(el('p', { text: 'Open this on a shop’s website.' }));
-- if (vm.state === 'no_merchant') return show(el('p', { text: 'OpenVibe.Coupons has no codes for ' + vm.host + '.' }), el('p', null, link('Submit a code', '/submit')));
++ 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 === '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: 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))));
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))));
}
ddiff --git a/package.json b/package.json
iindex 56cf134..1b52c73 100644
---- a/package.json
++++ b/package.json
@@@ -1,15 +1,22 @@
{
"name": "openvibe-extensions",
-- "version": "0.1.0",
++ "version": "0.2.0",
"private": true,
-- "description": "OpenVibe's first-party client surfaces (ADR-023). Today: the OpenVibe Coupons Helper, a Manifest V3 browser extension for Chromium and Firefox built on OpenVibe.Coupons' public API.",
++ "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": {
"test": "node test/run.js",
"pack": "node scripts/pack.js",
"pack:dev": "node scripts/pack.js --api http://localhost:4850",
"icons": "node scripts/icons.js"
},
-- "keywords": ["openvibe", "browser-extension", "webextension", "coupons"],
++ "keywords": [
++ "openvibe",
++ "browser-extension",
++ "webextension",
++ "coupons",
++ "rent",
++ "watch"
++ ],
"author": "OpenVibers",
"license": "MIT",
"repository": "github:OpenVibers/OpenVibe.Extensions",
ddiff --git a/scripts/audit.js b/scripts/audit.js
iindex 8a51de3..a610ed6 100644
---- a/scripts/audit.js
++++ b/scripts/audit.js
@@@ -12,6 +12,8 @@
* CSP script-src 'self' only, connect-src only the API origin, no unsafe-*
* pages popup/options HTML load only local scripts, no inline script
* config lib/config.js API_ORIGIN equals the host permission's origin
++ * hand-offs lib/config.js HANDOFF_ORIGINS are https://openvibe.<tld> sites only (the
++ * tabs the Companion may open when you press a button; never requested)
*/
const fs = require('fs');
const path = require('path');
@@@ -63,8 +65,11 @@ function auditDir(dir) {
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8'));
const configSrc = fs.readFileSync(path.join(dir, 'lib', 'config.js'), 'utf8');
const origin = (configSrc.match(/API_ORIGIN:\s*'([^']+)'/) || [])[1];
++ const handoffs = ((configSrc.match(/HANDOFF_ORIGINS:\s*Object\.freeze\(\[([^\]]*)\]\)/) || [])[1] || '').split(',').map((x) => x.trim().replace(/^'|'$/g, '')).filter(Boolean);
const problems = [];
if (!origin) problems.push('lib/config.js has no API_ORIGIN');
++ if (!handoffs.length) problems.push('lib/config.js has no HANDOFF_ORIGINS');
++ for (const h of handoffs) if (!/^https:\/\/openvibe\.[a-z]+$/.test(h)) problems.push(`hand-off origin ${h} is not an https://openvibe.<tld> site`);
problems.push(...auditManifest(manifest, { apiOrigin: origin }));
for (const page of ['popup.html', 'options.html']) problems.push(...auditPage(fs.readFileSync(path.join(dir, page), 'utf8'), page));
return { manifest, origin, problems };
ddiff --git a/scripts/pack.js b/scripts/pack.js
iindex b3a4880..237880c 100644
---- a/scripts/pack.js
++++ b/scripts/pack.js
@@@ -3,9 +3,9 @@
/**
* Packs browser/ into a zip for loading or store review. It never uploads anything anywhere.
*
-- * node scripts/pack.js → dist/openvibe-coupons-helper-<version>.zip
++ * node scripts/pack.js → dist/openvibe-companion-<version>.zip
* node scripts/pack.js --api http://localhost:4850
-- * → dist/openvibe-coupons-helper-<version>-dev.zip,
++ * → dist/openvibe-companion-<version>-dev.zip,
* a development build whose API origin (lib/config.js), host permission and CSP connect-src
* are rewritten together. Only https origins, or http://localhost / http://127.0.0.1.
*
@@@ -93,7 +93,7 @@ function build({ api = DEFAULT_ORIGIN, outDir = path.join(ROOT, 'dist') } = {})
if (audit.problems.length) throw new Error(`permission audit failed:\n - ${audit.problems.join('\n - ')}`);
const version = audit.manifest.version;
fs.mkdirSync(outDir, { recursive: true });
-- const out = path.join(outDir, `openvibe-coupons-helper-${version}${api === DEFAULT_ORIGIN ? '' : '-dev'}.zip`);
++ const out = path.join(outDir, `openvibe-companion-${version}${api === DEFAULT_ORIGIN ? '' : '-dev'}.zip`);
fs.writeFileSync(out, zip([...files].map(([name, data]) => ({ name, data }))));
return { out, files: [...files.keys()], origin: api };
} finally {
ddiff --git a/test/handoff.test.js b/test/handoff.test.js
nnew file mode 100644
iindex 0000000..dbd706c
---- /dev/null
++++ b/test/handoff.test.js
@@@ -0,0 +1,91 @@
++'use strict';
++/**
++ * The hand-offs (browser/lib/handoff.js): the Companion opens the Rent board or a new Watch with the
++ * page you are on only when you press the button, only on openvibe.rent or openvibe.watch, and with
++ * the page's credentials, fragment and tracking parameters gone. Opening the popup sends the Coupons
++ * API the hostname and nothing more, as before.
++ */
++const assert = require('assert');
++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 LISTING = 'https://user:[email protected]/apa/d/sunny-2br/7790000001.html?lang=en&utm_source=share&fbclid=abc#photos';
++const CLEAN = 'https://saltlakecity.craigslist.org/apa/d/sunny-2br/7790000001.html?lang=en';
++
++(async () => {
++ const stub = await startStub();
++ const handoff = handoffLib.createHandoff(config);
++ const popup = ({ url, title = '' }) => {
++ const ext = fakeExt({ url, title });
++ 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('cleanUrl drops credentials, the fragment and tracking parameters, and keeps what identifies the page', () => {
++ assert.strictEqual(handoffLib.cleanUrl(LISTING), CLEAN);
++ assert.strictEqual(handoffLib.cleanUrl('https://www.facebook.com/marketplace/item/100000000000001/?ref=search&gclid=x'), 'https://www.facebook.com/marketplace/item/100000000000001/?ref=search');
++ ['chrome://settings', 'file:///home/a.html', 'javascript:alert(1)', 'about:blank', '', null, `https://a.com/${'x'.repeat(2100)}`]
++ .forEach((u) => assert.strictEqual(handoffLib.cleanUrl(u), null, String(u).slice(0, 40)));
++ });
++
++ await check('the board and Watch addresses carry the cleaned page and a tidy title', () => {
++ const b = new URL(handoff.boardUrl(LISTING, ' Sunny 2BR\n near the park '));
++ assert.strictEqual(b.origin + b.pathname, 'https://openvibe.rent/board');
++ assert.deepStrictEqual(Object.fromEntries(b.searchParams), { url: CLEAN, title: 'Sunny 2BR near the park' });
++ const w = new URL(handoff.watchUrl(LISTING, 'T'.repeat(300)));
++ assert.strictEqual(w.origin + w.pathname, 'https://openvibe.watch/watches/new');
++ assert.deepStrictEqual([w.searchParams.get('template'), w.searchParams.get('url'), w.searchParams.get('name').length], ['page', CLEAN, 80]);
++ assert.strictEqual(handoff.boardUrl('chrome://extensions', 'x'), null);
++ });
++
++ await check('rental sites lead with the board: the listing sites and Facebook Marketplace, not the rest of Facebook', () => {
++ ['https://saltlakecity.craigslist.org/apa/1.html', 'https://www.zillow.com/homedetails/x', 'https://www.apartments.com/x/', 'https://www.facebook.com/marketplace/item/1/', 'https://www.airbnb.com/rooms/1']
++ .forEach((u) => assert.ok(handoffLib.isRental(u), u));
++ ['https://www.facebook.com/someone', 'https://www.amazon.com/dp/1', 'https://notzillow.com/x']
++ .forEach((u) => assert.ok(!handoffLib.isRental(u), u));
++ });
++
++ await check('opening the popup opens nothing and sends the API the hostname only; pressing a button opens one tab', async () => {
++ stub.requests.length = 0;
++ const p = popup({ url: LISTING, title: 'Sunny 2BR - apts/housing for rent - craigslist' });
++ await 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(await p.controller.keepOnBoard(), { ok: true });
++ assert.strictEqual(p.ext.opened.length, 1);
++ assert.ok(p.ext.opened[0].startsWith('https://openvibe.rent/board?url='));
++ assert.ok(!/hunter2|utm_|fbclid|#photos/.test(p.ext.opened[0]), 'nothing private or tracking goes along');
++ await p.controller.watchPage();
++ assert.ok(p.ext.opened[1].startsWith('https://openvibe.watch/watches/new?template=page&url='));
++ assert.strictEqual(stub.requests.length, 1, 'a hand-off is a tab you see, never a request the extension makes');
++ });
++
++ 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' });
++ await p.controller.open();
++ assert.deepStrictEqual(p.controller.page(), { handoff: false, rental: false });
++ assert.strictEqual((await p.controller.keepOnBoard()).ok, false);
++ assert.deepStrictEqual(p.ext.opened, []);
++ const platform = createPlatform(fakeExt({}), config);
++ await Promise.all(['https://evil.example/board', 'https://openvibe.rent.evil.example/x', 'javascript:alert(1)', 'https://openvibe.rentx/']
++ .map((u) => assert.rejects(platform.tabs.open(u), TypeError, u)));
++ await platform.tabs.open('https://openvibe.rent/board?url=x');
++ });
++
++ 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' });
++ await p.controller.open();
++ assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: false });
++ });
++
++ await stub.close();
++ done();
++})();
ddiff --git a/test/helpers/harness.js b/test/helpers/harness.js
iindex 64364b3..d27ee23 100644
---- a/test/helpers/harness.js
++++ b/test/helpers/harness.js
@@@ -70,14 +70,19 @@ function recordingFetch() {
return f;
}
--/** Fakes of chrome.tabs / chrome.storage.local / chrome.permissions. */
--function fakeExt({ url = null, token = null, hasPermission = true } = {}) {
++/** 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 } = {}) {
const store = token ? { installToken: token } : {};
const log = [];
++ const opened = [];
return {
log,
store,
-- tabs: { async query(q) { log.push(['tabs.query', q]); return url === undefined ? [] : [{ id: 1, url }]; } },
++ opened,
++ tabs: {
++ async query(q) { log.push(['tabs.query', q]); return url === undefined ? [] : [{ id: 1, url, title }]; },
++ async create(o) { log.push(['tabs.create', o]); opened.push(o.url); return { id: 2, url: o.url }; },
++ },
storage: { local: {
async get(k) { log.push(['storage.get', k]); return { [k]: store[k] }; },
async set(o) { log.push(['storage.set', Object.keys(o)]); Object.assign(store, o); },
ddiff --git a/test/manifest.test.js b/test/manifest.test.js
iindex 760cdcc..b39c937 100644
---- a/test/manifest.test.js
++++ b/test/manifest.test.js
@@@ -80,8 +80,8 @@ const ORIGIN = 'https://openvibe.coupons';
const rel = path.relative(DIR, f);
if (rel !== path.join('lib', 'api.js')) assert.doesNotMatch(src.replace(/fetch: self\.fetch\.bind\(self\)/g, ''), /\bfetch\s*\(/, `${rel} makes a request itself`);
}
-- const tabUse = files.filter((f) => /\btabs\.query\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');
++ 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)');
});
done();
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex fa2ba3e..e8879f7 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -40,7 +40,7 @@ function unzip(buf) {
await check('the release zip holds exactly browser/, byte for byte, and its manifest passes the audit', async () => {
const r = build({ outDir: tmp });
-- assert.match(path.basename(r.out), /^openvibe-coupons-helper-\d+\.\d+\.\d+\.zip$/);
++ assert.match(path.basename(r.out), /^openvibe-companion-\d+\.\d+\.\d+\.zip$/);
const files = unzip(fs.readFileSync(r.out));
const src = path.join(__dirname, '..', 'browser');
for (const [name, data] of files) assert.ok(data.equals(fs.readFileSync(path.join(src, name))), name);