aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/call-wake-test.js4
-rw-r--r--tools/notif-spam-score.js82
-rw-r--r--tools/sw-push-quota.test.js2
3 files changed, 85 insertions, 3 deletions
diff --git a/tools/call-wake-test.js b/tools/call-wake-test.js
index 54c42ee..b0f64cc 100644
--- a/tools/call-wake-test.js
+++ b/tools/call-wake-test.js
@@ -1,7 +1,7 @@
// A call wakes a closed app: with Bob's coinos closed, Alice calls; the push
// the notifier would send (delivered here to Bob's service worker through
// Chrome's DevTools protocol) makes the worker fetch the offer's wrap, open
-// it with Bob's key and ring — "Alice Caller is calling" — and the tap that
+// it with Bob's key and ring — "Calling | Alice Caller" — and the tap that
// opens the app finds the offer kept for it: it rings there, Bob answers,
// they talk. A push for a stranger's call, or for something that isn't an
// offer, rings nothing. Plus the worker's check on its own (classifyCall).
@@ -152,7 +152,7 @@ try {
// the tap: the worker opens the app at ?open=call
await B.page.goto(`http://localhost:${PORT}/?open=call`, { waitUntil: 'domcontentloaded' });
const ns = await notifications(B.page);
- check('the worker rang: "Alice Caller is calling"', ns.some((n) => /^Alice Caller is calling \| Voice call/.test(n) && / \| call$/.test(n)), JSON.stringify(ns));
+ check('the worker rang: "Calling | Alice Caller"', ns.some((n) => /^Calling \| Alice Caller/.test(n) && / \| call$/.test(n)), JSON.stringify(ns));
check('the app the tap opened rings with the offer the worker kept', await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept') && /Alice Caller/.test(document.querySelector('.call-overlay').innerText), 25000));
// Alice's candidates to Bob (his own 'answered here' copy, ~770 chars, isn't one)
const toBob = () => [...callWraps.values()].filter((w) => !wrapsBefore.has(w.id) && w.tags.some((x) => x[0] === 'p' && x[1] === B.pk) && w.content.length > 1000 && w.content.length < 1500).length;
diff --git a/tools/notif-spam-score.js b/tools/notif-spam-score.js
new file mode 100644
index 0000000..c9e48ba
--- /dev/null
+++ b/tools/notif-spam-score.js
@@ -0,0 +1,82 @@
+// Scores every notification the wallet can show against Chrome's on-device
+// "unwanted notification" model — the one that replaces a web notification
+// with "Possible spam" on Android when its title + body score above 90.
+//
+// Not part of the test suite: it launches a throwaway headful-less Chrome
+// (/usr/bin/google-chrome) that fetches the model from Google's optimization
+// guide on first use (~10 s, network), shows each line from src/notify-text.js
+// SAMPLES as a persistent notification on a local page, and reads the score
+// off chrome://histograms (SafeBrowsing.NotificationContentDetection
+// .SuspiciousScore, one new sample per notification). Desktop Chrome runs the
+// model too, flag or no flag; SystemNotifications is off so nothing reaches
+// the desktop's notification tray. Exit 1 if any line scores 90 or more.
+//
+// bun tools/notif-spam-score.js # SAMPLES from src/notify-text.js
+// bun tools/notif-spam-score.js "Title" "Body" ["Title" "Body" ...]
+import { spawn } from 'node:child_process';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import puppeteer from 'puppeteer-core';
+import { SAMPLES } from '../src/notify-text.js';
+
+const CHROME = process.env.CHROME || '/usr/bin/google-chrome';
+const PORT = 8766, DEBUG = 9445, THRESHOLD = 90;
+const argv = process.argv.slice(2);
+const items = argv.length ? argv.reduce((a, v, i) => (i % 2 ? a[a.length - 1].push(v) : a.push([v]), a), []) : SAMPLES;
+
+const server = Bun.serve({ port: PORT, hostname: '127.0.0.1', fetch(req) {
+ const p = new URL(req.url).pathname;
+ if (p === '/sw.js') return new Response("self.addEventListener('install', () => self.skipWaiting()); self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));", { headers: { 'content-type': 'text/javascript' } });
+ return new Response(`<!doctype html><title>score</title><script>navigator.serviceWorker.register('./sw.js');
+ window.show = async (t, b) => { const r = await navigator.serviceWorker.ready; await r.showNotification(t, { body: b, tag: 's' + Date.now() + Math.random() }); };</script>score`,
+ { headers: { 'content-type': 'text/html' } });
+} });
+const profile = mkdtempSync(join(tmpdir(), 'notif-score-'));
+const chrome = spawn(CHROME, ['--headless=new', `--user-data-dir=${profile}`, `--remote-debugging-port=${DEBUG}`, '--no-first-run', '--password-store=basic',
+ '--enable-features=ShowWarningsForSuspiciousNotifications', '--disable-features=SystemNotifications', 'about:blank'], { stdio: 'ignore' });
+const cleanup = () => { try { chrome.kill(); } catch {} try { server.stop(true); } catch {} try { rmSync(profile, { recursive: true, force: true }); } catch {} };
+process.on('exit', cleanup);
+let browser;
+for (let i = 0; i < 40 && !browser; i++) {
+ await Bun.sleep(500);
+ browser = await puppeteer.connect({ browserURL: `http://localhost:${DEBUG}`, defaultViewport: null }).catch(() => null);
+}
+if (!browser) { console.error('could not reach the scoring Chrome'); process.exit(2); }
+await browser.defaultBrowserContext().overridePermissions(`http://127.0.0.1:${PORT}`, ['notifications']);
+const page = await browser.newPage();
+await page.goto(`http://127.0.0.1:${PORT}/`, { waitUntil: 'load' });
+await page.evaluate(() => navigator.serviceWorker.ready);
+const hist = await browser.newPage();
+async function counts() {
+ await hist.goto('chrome://histograms/SafeBrowsing.NotificationContentDetection.SuspiciousScore', { waitUntil: 'load' });
+ await Bun.sleep(250);
+ const txt = await hist.evaluate(() => document.body.innerText);
+ const m = {};
+ for (const line of txt.split('\n')) { const r = /^(\d+)\s+.*\((\d+) = /.exec(line); if (r) m[r[1]] = +r[2]; }
+ const t = /recorded (\d+) samples/.exec(txt);
+ return { m, total: t ? +t[1] : 0 };
+}
+// the model arrives on first use: warm up until one line scores
+let before = await counts();
+for (let i = 0; i < 12 && before.total === 0; i++) {
+ await page.evaluate(() => window.show('warm up', 'the model is still downloading'));
+ await Bun.sleep(5000);
+ before = await counts();
+}
+if (before.total === 0) { console.error('the model never scored anything (offline? no optimization-guide access?)'); process.exit(2); }
+let worst = 0;
+for (const [title, body] of items) {
+ await page.evaluate((t, b) => window.show(t, b), title, body);
+ let after, tries = 0;
+ do { await Bun.sleep(600); after = await counts(); } while (after.total === before.total && ++tries < 25);
+ const d = after.total - before.total;
+ const bucket = Object.keys(after.m).filter((k) => (after.m[k] || 0) > (before.m[k] || 0)).join('+');
+ const score = d === 1 ? Number(bucket) : NaN;
+ worst = Math.max(worst, score || 0);
+ console.log(`${(d === 1 ? String(score) : d === 0 ? '??' : 'ambiguous').padStart(9)} | ${title} | ${body}`);
+ before = after;
+}
+await page.close(); await hist.close(); browser.disconnect();
+console.log(worst >= THRESHOLD ? `\nFAIL: a line scored ${worst} (warning at >${THRESHOLD - 1})` : `\nok: every line under ${THRESHOLD}, worst ${worst}`);
+process.exit(worst >= THRESHOLD ? 1 : 0);
diff --git a/tools/sw-push-quota.test.js b/tools/sw-push-quota.test.js
index cf4a7bf..544c72e 100644
--- a/tools/sw-push-quota.test.js
+++ b/tools/sw-push-quota.test.js
@@ -85,5 +85,5 @@ test('elsewhere: quiet stays quiet', async () => {
expect(asked).toBe(0); // the open (if hidden) window answers on Android
windows = [];
respond = async () => false;
- expect(await push(nwc)).toEqual(['Payment request']);
+ expect(await push(nwc)).toEqual(['Spend request']);
});