aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/account-access-test.js15
-rw-r--r--tools/account-delete-test.js115
-rw-r--r--tools/account-switch-leak-test.js6
-rw-r--r--tools/amber-dm-upgrade-test.js10
-rw-r--r--tools/amber-login-test.js5
-rw-r--r--tools/ark-ln-savings-small-test.js112
-rw-r--r--tools/ark-store-test.js21
-rw-r--r--tools/ark/custody-test.js21
-rw-r--r--tools/ark/refresh-test.js9
-rw-r--r--tools/avatar-profile-test.js6
-rw-r--r--tools/balance-settings-row-test.js65
-rw-r--r--tools/blob-alt-test.js92
-rw-r--r--tools/call-test.js6
-rw-r--r--tools/call-wake-test.js10
-rw-r--r--tools/chat-remove-member-test.js224
-rw-r--r--tools/comments-test.js6
-rw-r--r--tools/composer-media-test.js6
-rw-r--r--tools/dm-nip42-test.js6
-rw-r--r--tools/dm-row-resort-test.js6
-rw-r--r--tools/feed-appetite-test.js6
-rw-r--r--tools/feed-boot-chrome-test.js113
-rw-r--r--tools/feed-bottom-nav-test.js2
-rw-r--r--tools/feed-conversations-test.js6
-rw-r--r--tools/feed-custom-test.js6
-rw-r--r--tools/feed-fab-anim-test.js58
-rw-r--r--tools/feed-list-test.js6
-rw-r--r--tools/feed-media-cache-test.js6
-rw-r--r--tools/feed-media-views-test.js6
-rw-r--r--tools/feed-mention-tap-test.js6
-rw-r--r--tools/feed-new-posts-test.js2
-rw-r--r--tools/feed-pager-ahead-test.js6
-rw-r--r--tools/feed-picture-pager-test.js6
-rw-r--r--tools/feed-reboost-test.js6
-rw-r--r--tools/feed-repost-test.js6
-rw-r--r--tools/feed-streaming-test.js2
-rw-r--r--tools/feed-switch-seed-test.js6
-rw-r--r--tools/feed-video-aspect-test.js6
-rw-r--r--tools/feed-video-pager-test.js8
-rw-r--r--tools/feed-window-dom-test.js6
-rw-r--r--tools/fullscreen-notice-test.js8
-rw-r--r--tools/gif-picker-test.js6
-rw-r--r--tools/gift-from-test.js71
-rw-r--r--tools/gift-profile-setup-test.js95
-rw-r--r--tools/go-live-test.js6
-rw-r--r--tools/grid-early-player-test.js6
-rw-r--r--tools/history-export-test.js6
-rw-r--r--tools/image-tap-test.js6
-rw-r--r--tools/kind0-overwrite-test.js141
-rw-r--r--tools/lightbox-scroll-test.js6
-rw-r--r--tools/link-preview-test.js6
-rw-r--r--tools/live-stream-test.js6
-rw-r--r--tools/ln-pay-double-wake.test.js111
-rw-r--r--tools/lnurl-withdraw-links-test.js166
-rw-r--r--tools/lnurl-withdraw-test.js9
-rw-r--r--tools/media-grid-dups-test.js6
-rw-r--r--tools/media-grid-order-test.js6
-rw-r--r--tools/media-moments-feed-test.js18
-rw-r--r--tools/mention-ac-test.js6
-rw-r--r--tools/moderation-test.js6
-rw-r--r--tools/names-listening-test.js70
-rw-r--r--tools/negentropy-sync-test.js6
-rw-r--r--tools/nip29-group-test.js102
-rw-r--r--tools/notif-app-zap-test.js109
-rw-r--r--tools/notif-categories-test.js4
-rw-r--r--tools/notif-custom-emoji-test.js6
-rw-r--r--tools/notif-faces-test.js6
-rw-r--r--tools/notif-note-cache-test.js6
-rw-r--r--tools/notif-report-fold-test.js6
-rw-r--r--tools/notif-spam-score.js82
-rw-r--r--tools/nwc-settings-ui-test.js6
-rw-r--r--tools/nwc-sw-test.js162
-rw-r--r--tools/onboard-backup-test.js80
-rw-r--r--tools/onboard-cover-test.js4
-rw-r--r--tools/onboard-migrate-door-test.js5
-rw-r--r--tools/own-post-feeds-test.js6
-rw-r--r--tools/padlock-flow-test.js4
-rw-r--r--tools/pager-caption-test.js10
-rw-r--r--tools/pager-close-test.js8
-rw-r--r--tools/pager-controls-test.js6
-rw-r--r--tools/pager-eager-test.js8
-rw-r--r--tools/pager-first-frame-test.js8
-rw-r--r--tools/pager-gone-picture-test.js6
-rw-r--r--tools/pager-no-poster-test.js8
-rw-r--r--tools/pager-prime-test.js8
-rw-r--r--tools/pager-ready-gate-test.js6
-rw-r--r--tools/pager-resize-test.js6
-rw-r--r--tools/pager-rotate-test.js8
-rw-r--r--tools/pager-seek-hide-test.js8
-rw-r--r--tools/pager-sharpen-test.js6
-rw-r--r--tools/pager-swipe-fly-test.js6
-rw-r--r--tools/pager-tail-test.js8
-rw-r--r--tools/pager-tap-slop-test.js6
-rw-r--r--tools/pager-wheel-test.js6
-rw-r--r--tools/pic-proxy-test.js6
-rw-r--r--tools/picture-pager-ready-test.js6
-rw-r--r--tools/poll-test.js6
-rw-r--r--tools/post-edit-test.js7
-rw-r--r--tools/post-search-test.js83
-rw-r--r--tools/profile-pager-test.js6
-rw-r--r--tools/profile-publish-race-test.js18
-rw-r--r--tools/profile-repost-test.js6
-rw-r--r--tools/public-chat-join-test.js75
-rw-r--r--tools/puppeteer.js17
-rw-r--r--tools/quote-pager-test.js6
-rw-r--r--tools/quoted-clip-fullscreen-test.js8
-rw-r--r--tools/receive-batch-fold-test.js20
-rw-r--r--tools/relay-budget-test.js167
-rw-r--r--tools/relays-settings-test.js6
-rw-r--r--tools/resume-wake-test.js111
-rw-r--r--tools/room-faces-test.js6
-rw-r--r--tools/scheduled-post-test.js99
-rw-r--r--tools/seed-cache-test.js60
-rw-r--r--tools/sensitive-feed-test.js6
-rw-r--r--tools/signer-resume-test.js140
-rw-r--r--tools/slow-image-test.js6
-rw-r--r--tools/standalone-file-test.js4
-rw-r--r--tools/sw-push-quota.test.js45
-rw-r--r--tools/sw-reload-test.js111
-rw-r--r--tools/thread-instant-test.js6
-rw-r--r--tools/thread-like-count-test.js91
-rw-r--r--tools/thread-reply-test.js21
-rw-r--r--tools/translate-phone-lang-test.js6
-rw-r--r--tools/translate-test.js6
-rw-r--r--tools/video-autoplay-test.js6
-rw-r--r--tools/video-compress-test.js6
-rw-r--r--tools/video-events-test.js8
-rw-r--r--tools/video-fallback-test.js6
-rw-r--r--tools/video-gone-test.js129
-rw-r--r--tools/video-hls-play-test.js6
-rw-r--r--tools/video-tap-test.js46
-rw-r--r--tools/video-tile-poster-test.js6
-rw-r--r--tools/video-touch-tap-test.js6
-rw-r--r--tools/web-comment-test.js123
-rw-r--r--tools/wn-only-device-test.js6
-rw-r--r--tools/youtube-tap-test.js92
135 files changed, 3704 insertions, 378 deletions
diff --git a/tools/account-access-test.js b/tools/account-access-test.js
index c9af32e..91fbe05 100644
--- a/tools/account-access-test.js
+++ b/tools/account-access-test.js
@@ -36,19 +36,24 @@ try {
check('avatar opens the wallet settings with sign-in and logout', await page.evaluate(() => ['Security', 'Sign into another account', 'Log out'].every((s) => document.body.innerText.includes(s))));
const saved = await page.evaluate(() => localStorage.getItem('btc-wallet-watch'));
await click('Sign into another account');
- check('sign-in opens on Create new, not the seed import box', await page.evaluate(() =>
- document.querySelector('.tabs .active')?.textContent.includes('Create new') && !document.querySelector('.card textarea')));
+ check('sign-in opens on the front door\'s two choices', await page.evaluate(() =>
+ !!document.getElementById('add-create') && !!document.getElementById('add-signin') && !document.querySelector('.tabs') && !document.querySelector('.card textarea')));
+ await click('Sign in to an existing account');
+ check('...then the sign-in list, seed phrase first', await page.evaluate(() => {
+ const b = [...document.querySelectorAll('.onb button')].map((x) => x.textContent.trim());
+ return b[0] === 'Seed phrase' && b.includes('Nostr');
+ }));
// Expand the Nostr login card without supplying a private key.
- await click('Sign in with Nostr');
+ await click('Nostr');
await page.waitForSelector('input[placeholder="nsec, private key, or bunker://…"]');
check('Nostr key sign-in is reachable without deleting the old wallet', await page.evaluate((saved) => localStorage.getItem('btc-wallet-watch') === saved, saved));
- await click('← Back');
+ for (let i = 0; i < 3; i++) { await click('← Back'); await new Promise((r) => setTimeout(r, 150)); }
await page.waitForSelector('.header-avatar');
await page.click('.header-avatar');
await click('Log out');
check('logout asks before ending the session', await page.evaluate(() => document.body.innerText.includes('Your accounts stay saved on this device.')));
await click('Log out');
- check('logout reaches the sign-in screen and retains the saved wallet', await page.evaluate((saved) => !document.querySelector('.header-avatar') && document.body.innerText.includes('Sign in with Nostr') && localStorage.getItem('btc-wallet-watch') === saved, saved));
+ check('logout reaches the sign-in screen and retains the saved wallet', await page.evaluate((saved) => !document.querySelector('.header-avatar') && document.body.innerText.includes('Sign in to an existing account') && localStorage.getItem('btc-wallet-watch') === saved, saved));
// Remember a public login identity on the watch-only account so every
// header destination is available, without supplying a private key.
await page.evaluate(({ key }) => {
diff --git a/tools/account-delete-test.js b/tools/account-delete-test.js
new file mode 100644
index 0000000..d06bcb8
--- /dev/null
+++ b/tools/account-delete-test.js
@@ -0,0 +1,115 @@
+// Settings → Wallet → Delete account: asks the registrar what stands in the
+// way, says what goes and what stays (and how many sats are still here),
+// and on a ticked box + tap has the registrar forget the account (a NIP-98
+// request signed by the wallet's key) and removes the wallet from the device.
+// The registrar here is a stand-in inside the test; relays are the stand-ins.
+//
+// Run: bun tools/account-delete-test.js
+import puppeteer from './puppeteer.js';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { verifyEvent } from 'nostr-tools/pure';
+import { createHash } from 'node:crypto';
+import { buildHtml } from '../build.js';
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5271, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.setViewport({ width: 420, height: 900 });
+page.on('pageerror', (e) => { console.log(' page error:', e.message); ok = false; });
+
+// the registrar: records what it's asked, answers as the real one would
+const calls = [];
+let owed = { pendingSat: 0, withdrawSat: 0 };
+const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type,authorization', 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS' };
+await page.setRequestInterception(true);
+page.on('request', (req) => {
+ const u = new URL(req.url());
+ if (u.hostname !== 'names.coinos.io') return req.continue();
+ if (req.method() === 'OPTIONS') return req.respond({ status: 200, headers: cors, body: '{}' });
+ const reply = (status, j) => req.respond({ status, headers: { ...cors, 'content-type': 'application/json' }, body: JSON.stringify(j) });
+ if (u.pathname !== '/account/delete') return reply(404, { error: 'not found' });
+ const body = req.postData() || '';
+ let ev = null;
+ try { ev = JSON.parse(Buffer.from((req.headers().authorization || '').replace(/^Nostr /, ''), 'base64').toString()); } catch {}
+ const tag = (k) => ev?.tags.find((t) => t[0] === k)?.[1];
+ const signed = !!ev && ev.kind === 27235 && verifyEvent(ev) && tag('method') === 'POST'
+ && new URL(tag('u')).pathname === '/account/delete' && tag('payload') === createHash('sha256').update(body).digest('hex');
+ const j = JSON.parse(body || '{}');
+ calls.push({ check: !!j.check, signed, pubkey: ev?.pubkey });
+ const blocked = owed.pendingSat + owed.withdrawSat > 0;
+ if (j.check) return reply(200, { names: ['[email protected]'], ...owed, paying: 0, blocked });
+ if (blocked) return reply(409, { error: 'funds', ...owed, paying: 0 });
+ return reply(200, { ok: true, names: ['[email protected]'] });
+});
+
+const click = (sel, t) => page.evaluate((s, x) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, t);
+const until = async (fn, ms = 15000) => { const end = Date.now() + ms; for (;;) { const v = await fn(); if (v) return v; if (Date.now() > end) return null; await sleep(250); } };
+const text = () => page.evaluate(() => document.body.innerText);
+const goBtn = () => page.evaluate(() => { const b = [...document.querySelectorAll('button.btn-danger')].find((x) => x.textContent.trim() === 'Delete account' && x.closest('.card')?.querySelector('input[type=checkbox]')); return b ? { disabled: b.disabled } : null; });
+const openCard = async () => {
+ await page.evaluate(() => document.querySelector('[data-key="delete-account"] button')?.click());
+ await until(async () => (await text()).includes('[email protected] is released'));
+};
+
+try {
+ await page.goto('http://localhost:5271/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('button', 'Create a new wallet');
+ await sleep(300);
+ await click('button', 'Have an existing seed');
+ await sleep(300);
+ await page.waitForSelector('textarea');
+ await page.type('textarea', generateMnemonic(wordlist));
+ await click('button', 'Open wallet');
+ await until(async () => /receive/i.test(await text()));
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings')?.click());
+ await until(async () => (await text()).includes('Advanced'));
+ await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find((e) => [...e.querySelectorAll('span')].some((x) => x.textContent.trim() === 'Wallet'))?.click());
+ await sleep(500);
+ if (process.env.DEBUG) console.log((await text()).slice(0, 600));
+ check('Settings → Wallet has a Delete account card', !!(await until(() => page.evaluate(() => !!document.querySelector('[data-key="delete-account"]')))));
+
+ console.log('\n[sats still owed]');
+ owed = { pendingSat: 21, withdrawSat: 500 };
+ await openCard();
+ let tx = await text();
+ check('says the name is released, records deleted, and what stays', /is released/.test(tx) && /Our servers delete/.test(tx) && /Not deleted: your coins/.test(tx));
+ check('names the sats still owed and won\'t go on', /521 sats are still on their way/.test(tx) && (await goBtn())?.disabled === true, tx.match(/⚠[^\n]*/)?.[0]?.slice(0, 80));
+ check('the question was signed by the wallet\'s key', calls.length === 1 && calls[0].check && calls[0].signed);
+ await click('button', 'Back');
+ await sleep(300);
+ check('Back returns to the settings page, account intact', !!(await page.evaluate(() => !!document.querySelector('[data-key="delete-account"]'))) && calls.every((c) => c.check));
+
+ console.log('\n[nothing in the way]');
+ owed = { pendingSat: 0, withdrawSat: 0 };
+ await openCard();
+ check('the button waits for the box to be ticked', (await goBtn())?.disabled === true);
+ await page.evaluate(() => document.querySelector('.card input[type=checkbox]')?.click());
+ await sleep(300);
+ check('ticked: the button is live', (await goBtn())?.disabled === false);
+ const before = calls.length;
+ await page.evaluate(() => [...document.querySelectorAll('button.btn-danger')].find((x) => x.textContent.trim() === 'Delete account' && !x.disabled)?.click());
+ const gone = await until(async () => /create a new wallet/i.test(await text()), 20000);
+ if (process.env.DEBUG) console.log((await text()).slice(0, 900));
+ check('the registrar was told to delete, signed', calls.slice(before).some((c) => !c.check && c.signed), JSON.stringify(calls.slice(before).map((c) => ({ check: c.check, signed: c.signed }))));
+ check('the wallet is gone from the device: back at the front door', !!gone);
+ const left = await page.evaluate(() => ({ session: sessionStorage.getItem('btc-wallet-accounts'), vault: localStorage.getItem('btc-wallet-vault') }));
+ check('no account left in the session', !left.session || !JSON.parse(left.session).accounts?.length, String(left.session).slice(0, 60));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ check('and a reload doesn\'t bring it back', !!(await until(async () => /create a new wallet/i.test(await text()), 10000)));
+} catch (e) {
+ console.log(' error:', e.message); ok = false;
+} finally {
+ await browser.close();
+ server.stop(true);
+}
+console.log(ok ? '\nPASS' : '\nFAIL');
+process.exit(ok ? 0 : 1);
diff --git a/tools/account-switch-leak-test.js b/tools/account-switch-leak-test.js
index ea3b4ee..aa06579 100644
--- a/tools/account-switch-leak-test.js
+++ b/tools/account-switch-leak-test.js
@@ -3,7 +3,7 @@
// under its key, so a throwaway account showed adam's zaps and reactions for
// good. Also: a DM cache already polluted that way is swept on load.
// Run: bun tools/account-switch-leak-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
@@ -61,9 +61,9 @@ const nav = (label) => page.evaluate((l) => document.querySelector(`.app-nav-but
async function newWallet() {
await click('create a new wallet'); await sleep(600);
if (!(await page.$('.words .w .t'))) { await click('generate'); await sleep(600); }
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
return mn;
}
diff --git a/tools/amber-dm-upgrade-test.js b/tools/amber-dm-upgrade-test.js
index 9a5070a..3a1917a 100644
--- a/tools/amber-dm-upgrade-test.js
+++ b/tools/amber-dm-upgrade-test.js
@@ -7,7 +7,7 @@
// relays hold is opened again — including ones the old code wrote off.
// The remote signer is played by this script over a shimmed relay.
// Run: bun tools/amber-dm-upgrade-test.js (and ALL_LOGGED=1 bun tools/amber-dm-upgrade-test.js)
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import * as nip44 from 'nostr-tools/nip44';
@@ -102,9 +102,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
// an Amber login, and a wrap log in which the old code had already
@@ -120,14 +120,14 @@ try {
await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Messages"]')?.click());
console.log('\n[signed in with Amber by intent]');
- check('Messages says messages sent to you can\'t be opened, and offers Connect Amber', await waitText('can’t be opened here', 10000) && await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Connect Amber')));
+ check('Messages says messages sent to you can\'t be opened, and offers Connect Amber', await waitText('can’t be opened here', 10000) && await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Connect signer')));
await sleep(4000);
check('no decrypt intent went to Amber for an arriving wrap', !(await page.evaluate(() => window.__amberUrls.some((u) => /nip44_decrypt/.test(u)))));
check('neither message shows', !(await text()).includes('a message from before the upgrade') && !(await text()).includes('wrote off'));
if (!ALL) check('the arriving wrap is not logged as opened', await page.evaluate(([k, id]) => !(JSON.parse(localStorage.getItem(k + ':messages') || '{}').wraps || []).includes(id.slice(0, 16)), [base, NEW.id]));
console.log('\n[Connect Amber: the same account over NIP-46]');
- await click('connect amber'); await sleep(300);
+ await click('connect signer'); await sleep(300);
check('a screen explains it and offers the signer-app link', await waitText('read your messages', 5000));
let uri = null;
for (let i = 0; i < 40 && !uri; i++) { uri = await page.evaluate(() => document.querySelector('a[href^="nostrconnect://"]')?.getAttribute('href')); if (!uri) await sleep(250); }
diff --git a/tools/amber-login-test.js b/tools/amber-login-test.js
index 9da49a8..46a16f6 100644
--- a/tools/amber-login-test.js
+++ b/tools/amber-login-test.js
@@ -39,10 +39,11 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(500);
console.log('\n[the Amber door]');
+ await click('sign in to an existing account'); await sleep(300);
check('the front door offers Nostr sign-in', await click('sign in with nostr') || await click('nostr'));
await sleep(500);
- check('on Android, the login offers Amber', await waitText('sign in with amber', 5000));
- await click('sign in with amber'); await sleep(400);
+ check('on Android, the login offers Amber', await waitText('sign in with a signer app', 5000));
+ await click('sign in with a signer app'); await sleep(400);
check('tapping it opens a get_public_key intent', (await lastType()) === 'get_public_key', JSON.stringify(await urls()));
check('...and shows the waiting card with a paste box', await waitText('waiting for your signer', 2000) && !!(await page.$('.amber-paste')));
await deliver(npubEncode(PK));
diff --git a/tools/ark-ln-savings-small-test.js b/tools/ark-ln-savings-small-test.js
new file mode 100644
index 0000000..a674b42
--- /dev/null
+++ b/tools/ark-ln-savings-small-test.js
@@ -0,0 +1,112 @@
+// A Lightning invoice opened while Savings is selected, with Spending empty
+// and Savings under the server's board minimum (546 sats against coinos's
+// 1,000: "can you help me", 2026-11-03), used to offer "Move Savings into
+// Spending first" — a button whose board panel then said "Needs at least
+// 1,000 sats in Savings". The ask now says what Savings holds and what a move
+// needs, and offers the door that works: a Lightning receive into Spending.
+// With enough in Savings the move is offered as before, straight to the
+// board panel; the zap card asks the same question through a hook.
+// Run: bun tools/ark-ln-savings-small-test.js
+import { buildFeatures } from '../src/features/index.js';
+import { getArkConfig } from '../src/api.js';
+
+let fails = 0;
+const check = (name, ok, detail = '') => {
+ console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + String(detail).slice(0, 200) : ''}`);
+ if (!ok) fails++;
+};
+
+globalThis.fetch = async () => { throw new Error('offline (test)'); };
+globalThis.window = undefined;
+globalThis.localStorage = {
+ _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); },
+ removeItem(k) { delete this._d[k]; }, key(i) { return Object.keys(this._d)[i] ?? null; },
+ get length() { return Object.keys(this._d).length; },
+};
+// the server's info as the app remembers it between sessions
+const cfg = getArkConfig();
+localStorage.setItem('btc-wallet-arkinfo:' + cfg.ark, JSON.stringify({ info: { minBoardAmountSat: 1000, boardFees: {} }, tip: 1, at: Date.now() }));
+
+const CHAIN_FEE = 150;
+const store = {};
+const wallet = {
+ watchOnly: false, offline: false, spendable: 546, feeRates: { halfHourFee: 1 },
+ utxos: [], txs: [], receive: [], change: [],
+ account: () => null,
+ loadFeatureState: (n, fb) => JSON.parse(store[n] || 'null') || fb,
+ saveFeatureState: (n, st) => { store[n] = JSON.stringify(st); },
+ _cacheKey: () => 'test', saveCache: () => {},
+ nostrPubkey: () => 'ab'.repeat(32),
+ registerCacheExtension: () => {}, registerRealtimeHook: () => {}, registerLoadHook: () => {},
+ registerScanHook: () => {}, registerCacheSavedHook: () => {}, registerCoinLock: () => {},
+ loadArkState: () => null, saveArkState: () => {}, _arkNs: () => 'ns', _arkKey: () => 'k',
+ _arkHoldKey: () => 'h', _arkVtxoOwner: () => null, adoptArkState: () => null,
+ freshReceive: () => ({ address: 'bc1qtest' }), freshChange: () => ({ address: 'bc1qchange' }),
+ balance: () => ({ spendableSat: 0 }), emit: () => {}, on: () => {},
+ buildTx({ recipients }) {
+ const amount = recipients[0].amount;
+ if (amount + CHAIN_FEE > wallet.spendable) throw new Error('Insufficient funds for amount + fee.');
+ return { outputs: [{ amount }], fee: CHAIN_FEE };
+ },
+};
+const calls = [];
+const ui = { account: 'savings' };
+let features = [];
+const ctx = {
+ h: (tag, attrs, ...kids) => ({ tag, attrs: attrs || {}, kids: kids.flat().filter(Boolean) }),
+ ui, render: () => {}, wallet,
+ hook: (name, ...args) => { for (const f of features) if (f[name]) { const v = f[name](...args); if (v) return v; } return null; },
+ getAccount: () => ui.account, setAccount: (a, dir) => { calls.push(['setAccount', a]); ui.account = a; },
+ goHome: () => calls.push(['goHome']),
+ openMnemonic: async () => {},
+ fmtAmount: (n) => Number(n).toLocaleString('en-US'), unitLabel: () => 'sats', unitTag: () => 'sats', getUnit: () => 'sats',
+ parseAmount: (v) => parseInt(v, 10) || 0,
+ copyBtn: () => null, pasteBtn: () => null, toast: () => {}, blankSend: () => ({ recipients: [] }),
+ openExternal: () => {},
+};
+features = buildFeatures(ctx);
+const ark = features.find((f) => f.id === 'ark');
+
+const text = (n) => typeof n === 'string' ? n : n && n.kids ? n.kids.map(text).join(' ') : '';
+const buttons = (n, out = []) => { if (n && n.tag === 'button') out.push(n); if (n && n.kids) n.kids.forEach((k) => buttons(k, out)); return out; };
+const button = (tree, label) => buttons(tree).find((b) => text(b) === label);
+
+console.log('[546 sats in Savings, Spending empty, 175-sat invoice]');
+{
+ ui.arkLnFromSavings = { invoice: 'lnbc1750n1test', meta: null, amountSat: 175 };
+ const tree = ark.sendView();
+ const body = text(tree);
+ check('says what Savings holds and what a move needs', /Savings holds 546 sats, and a move into Spending needs at least 1,000 sats/.test(body), body);
+ check('no move button', !button(tree, 'Move Savings into Spending first'), buttons(tree).map(text).join(' | '));
+ check('hook answers: Savings cannot board', !ark.arkSavingsCanBoard());
+ const recv = button(tree, 'Receive over Lightning instead');
+ check('offers a Lightning receive', !!recv);
+ recv.attrs.onClick();
+ check('the receive door opens Spending (its Receive page) and closes the ask',
+ calls.some((c) => c[0] === 'setAccount' && c[1] === 'spending') && !ui.arkLnFromSavings && ui.account === 'spending', JSON.stringify(calls));
+}
+
+console.log('[1,000 sats in Savings: the minimum, but not the fee on top]');
+{
+ calls.length = 0; ui.account = 'savings'; wallet.spendable = 1000;
+ ui.arkLnFromSavings = { invoice: 'lnbc1750n1test', meta: null, amountSat: 175 };
+ const tree = ark.sendView();
+ check('still no move offered', !button(tree, 'Move Savings into Spending first') && !!button(tree, 'Receive over Lightning instead'), text(tree));
+}
+
+console.log('[5,000 sats in Savings]');
+{
+ calls.length = 0; ui.account = 'savings'; wallet.spendable = 5000;
+ ui.arkLnFromSavings = { invoice: 'lnbc1750n1test', meta: null, amountSat: 175 };
+ const tree = ark.sendView();
+ const body = text(tree);
+ check('the move is offered, with the confirmation note', /Move funds over from Savings first/.test(body) && !!button(tree, 'Move Savings into Spending first'), body);
+ check('no receive door', !button(tree, 'Receive over Lightning instead'));
+ check('hook answers: Savings can board', ark.arkSavingsCanBoard() === true);
+ button(tree, 'Move Savings into Spending first').attrs.onClick();
+ check('the move button opens the board panel toward Spending, prefilled at the minimum or more',
+ !ui.arkLnFromSavings && ui.arkMoveOpen && ui.arkMoveDir === 'toSpending' && parseInt(ui.arkBoardAmt, 10) >= 1000, `amt=${ui.arkBoardAmt} ${JSON.stringify(calls)}`);
+}
+
+console.log(fails ? `\n${fails} FAILED` : '\nall passed');
+process.exit(fails ? 1 : 0);
diff --git a/tools/ark-store-test.js b/tools/ark-store-test.js
index 4e02fa0..a72ad81 100644
--- a/tools/ark-store-test.js
+++ b/tools/ark-store-test.js
@@ -87,4 +87,25 @@ assert.equal(broken.lsMode, true, 'a refusing IndexedDB drops to localStorage');
assert.equal(broken.get(K)._rev, 1);
console.log('✓ localStorage fallback');
+// ---- peek: the stored object, no copy; a write is seen at once ----
+const ls4 = new Storage();
+ls4.setItem(K, JSON.stringify({ _rev: 3, vtxos: [] }));
+const pk = new ArkStore({ backend: memoryBackend(), ls: () => ls4 });
+const before = pk.peek(K);
+assert.equal(before._rev, 3, 'before opening, peek reads localStorage');
+assert.equal(pk.peek(K), before, 'and keeps the parse');
+await pk.open();
+assert.equal(pk.peek(K), pk.peek(K), 'opened: the same object every time');
+assert.notEqual(pk.get(K), pk.peek(K), 'get still copies');
+pk.set(K, { _rev: 4, vtxos: [] });
+assert.equal(pk.peek(K)._rev, 4, 'a write shows in the next peek');
+pk.remove(K);
+assert.equal(pk.peek(K), null);
+const pl = new ArkStore({ backend: null, ls: () => ls4 });
+pl.set(K, { _rev: 5, vtxos: [] });
+assert.equal(pl.peek(K)._rev, 5);
+pl.set(K, { _rev: 6, vtxos: [] });
+assert.equal(pl.peek(K)._rev, 6, 'localStorage mode: a write drops the kept parse');
+console.log('✓ peek reads without copying');
+
console.log('\n✅ ark store behaves');
diff --git a/tools/ark/custody-test.js b/tools/ark/custody-test.js
index 214ab45..68e7191 100644
--- a/tools/ark/custody-test.js
+++ b/tools/ark/custody-test.js
@@ -127,4 +127,25 @@ const coin = (id, amountSat, expiryHeight, extra = {}) => ({ id, amountSat, expi
console.log('✓ deep coins: no base fee for a refresh of deep coins alone, a capped coin is never spent, the worker orders them flattened');
}
+// Lightning sends: a wallet that renews its own coins spends the nearest
+// expiry first; the custodial default keeps the smallest coin that covers it
+{
+ const m = mk(true);
+ m.info.lnSendFees = { baseFeeSat: 0, minFeeSat: 0, ppmExpiryTable: [] };
+ m.state.vtxos = [coin('young', 3000, 2000), coin('old', 9000, 1100), coin('older', 2000, 1050), coin('past', 8000, 900)];
+ const ids = (amt) => m._selectLnInputs(m.state.vtxos, 1000, amt, 0).inputs.map((v) => v.id);
+ assert.deepEqual(ids(2500), ['young'], 'custodial: the smallest coin that covers it');
+ assert.deepEqual(ids(12000), ['old', 'young'], 'custodial: largest first');
+ m.lnOldestFirst = () => true;
+ assert.deepEqual(ids(1500), ['older'], 'self: the nearest expiry that covers it');
+ assert.deepEqual(ids(2500), ['old'], '…skipping one too small');
+ assert.deepEqual(ids(10000), ['older', 'old'], 'self: gathers nearest expiry first');
+ assert.deepEqual(ids(13000), ['older', 'old', 'young'], 'coins inside their timelock carry it alone when they can');
+ // a pile of old crumbs that would breach the input cap: largest-first instead
+ m.state.vtxos = [...Array.from({ length: MAX_SEND_INPUTS + 2 }, (_, i) => coin('crumb' + i, 100, 1010)), coin('big', 6000, 3000)];
+ assert.deepEqual(ids(6050).slice(0, 1), ['big'], 'too many old crumbs for one payment: largest first');
+ assert.equal(ids(6050).length, 2);
+ console.log('✓ Lightning sends: nearest expiry first when renewing your own coins, smallest-that-covers otherwise');
+}
+
console.log('\n✅ custody tiers behave');
diff --git a/tools/ark/refresh-test.js b/tools/ark/refresh-test.js
index 0557659..6938daf 100644
--- a/tools/ark/refresh-test.js
+++ b/tools/ark/refresh-test.js
@@ -29,6 +29,7 @@ import {
import { ALICE, aliceReady } from './alice.js';
const ARK = process.env.ARK_URL || 'http://127.0.0.1:3535';
+const ESPLORA = process.env.ESPLORA_URL || 'http://127.0.0.1:30002';
const BCLI = `docker exec bc bitcoin-cli -rpcwallet=coinos`;
const FUND_SAT = 12345;
const SPEND_SAT = 3000;
@@ -86,7 +87,13 @@ if (!status?.fundingTx) { console.error('round never executed'); process.exit(1)
const fundingTx = parseTx(status.fundingTx);
console.log(`round executed: funding tx ${fundingTx.txid}, ${status.outputVtxos.length} output vtxo(s)`);
execSync(`${BCLI} generatetoaddress 2 $(${BCLI} getnewaddress) >/dev/null`, { shell: '/bin/bash' });
-await sleep(2000);
+// alice's bark checks the round anchor against esplora, which indexes a block
+// ~5 s after it's mined; hand her the coin before then and she drops it
+for (let i = 0; i < 30; i++) {
+ const st = await fetch(`${ESPLORA}/tx/${fundingTx.txid}/status`).then((r) => r.ok && r.json()).catch(() => null);
+ if (st?.confirmed) break;
+ await sleep(1000);
+}
const newVtxo = decodeVtxo(status.outputVtxos[0]);
console.log(`new vtxo: ${newVtxo.id} (${newVtxo.amountSat} sat, expiry ${newVtxo.expiryHeight}, transitions: ${newVtxo.genesis.map((g) => g.transition.type).join(' -> ')})`);
diff --git a/tools/avatar-profile-test.js b/tools/avatar-profile-test.js
index 5c5293d..6d6c944 100644
--- a/tools/avatar-profile-test.js
+++ b/tools/avatar-profile-test.js
@@ -3,7 +3,7 @@
// wherever the reader was in the feed. Back returns to the same place.
//
// Run: bun tools/avatar-profile-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { generateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import { cacheKeyFor } from '../src/wallet.js';
@@ -25,9 +25,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
const A = 'a'.repeat(63) + '9', B = 'b'.repeat(63) + '7';
diff --git a/tools/balance-settings-row-test.js b/tools/balance-settings-row-test.js
new file mode 100644
index 0000000..89e68d9
--- /dev/null
+++ b/tools/balance-settings-row-test.js
@@ -0,0 +1,65 @@
+// Settings → Wallet lists Spending and Savings; each row opens the page the
+// balance card's gear opens. "I simply cannot find this option" (Nils,
+// 2026-11-12) — the Spending gear was the only way in.
+// Run: bun tools/balance-settings-row-test.js (NO_BUILD=1 to reuse dist/)
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
+const server = Bun.serve({ port: 0, fetch: async (req) => {
+ const path = new URL(req.url).pathname;
+ const f = Bun.file('dist' + path);
+ return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errs = [];
+// mainnet so Spending turns on (the Ark server's info is read); every other
+// outside request is refused, so nothing is claimed or published
+await page.setRequestInterception(true);
+page.on('request', (r) => { const u = new URL(r.url()); (u.origin === server.url.origin || u.hostname === 'ark.coinos.io') ? r.continue() : r.abort(); });
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (x) => page.evaluate((s) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(s)); if (e) e.click(); return !!e; }, x);
+const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const openWalletPage = async () => {
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings').click()); await sleep(500);
+ await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find((e) => /^Wallet/.test(e.textContent.trim())).click()); await sleep(500);
+};
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'mainnet'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await seedWords(page);
+ await skipSeedCheck(page);
+ if (await waitText('claim a username', 8000)) { await click('not now'); await sleep(800); }
+ await waitText('receive');
+ await sleep(1500);
+ check('savings-only wallet lists Savings alone', await (async () => { await openWalletPage(); const v = await page.$$eval('.balance-settings-row', (l) => l.map((e) => e.dataset.view)); await page.goBack(); await page.goBack(); await sleep(500); return v.join() === 'savings'; })());
+ await click('set up a spending account'); await sleep(800);
+ if (await waitText('claim a username', 5000)) { await click('not now'); await sleep(800); }
+ await waitText('receive'); await sleep(800);
+ if (process.env.DEBUG) console.log((await page.evaluate(() => document.body.innerText)).slice(0, 600));
+ const gears = await page.$$eval('.balance-gear', (l) => l.length);
+ await openWalletPage();
+ if (process.env.SHOT) { await page.evaluate(() => document.querySelector('.balance-settings-row').closest('.card').scrollIntoView()); await page.screenshot({ path: process.env.SHOT }); }
+ const views = await page.$$eval('.balance-settings-row', (l) => l.map((e) => e.dataset.view));
+ check('Settings → Wallet lists the balances', views.length >= 1, views.join(','));
+ check('Spending is listed when the card shows it', views.includes('spending'), `gears on the wallet: ${gears}`);
+ await page.evaluate(() => document.querySelector('.balance-settings-row[data-view="spending"]')?.click()); await sleep(600);
+ const st = await page.evaluate(() => ({ screen: history.state.nav.screen, view: history.state.nav.settingsView, h3: [...document.querySelectorAll('h3')].map((e) => e.textContent) }));
+ check('the Spending row opens the Spending settings', st.screen === 'accountSettings' && st.view === 'spending' && st.h3.includes('Spending'), JSON.stringify(st.h3.slice(0, 4)));
+ await page.goBack(); await sleep(600);
+ check('Back returns to Settings → Wallet', !!(await page.$('.balance-settings-row')));
+ await page.evaluate(() => document.querySelector('.balance-settings-row[data-view="savings"]')?.click()); await sleep(600);
+ const sv = await page.evaluate(() => ({ screen: history.state.nav.screen, view: history.state.nav.settingsView }));
+ check('the Savings row opens the Savings settings', sv.screen === 'accountSettings' && sv.view === 'savings');
+ check('no page errors', errs.length === 0, errs.join(' | '));
+} finally {
+ await browser.close(); server.stop(true);
+}
+console.log(ok ? 'PASS' : 'FAIL');
+process.exit(ok ? 0 : 1);
diff --git a/tools/blob-alt-test.js b/tools/blob-alt-test.js
new file mode 100644
index 0000000..4d958f2
--- /dev/null
+++ b/tools/blob-alt-test.js
@@ -0,0 +1,92 @@
+// A face or banner whose host has lost the file is found on another server
+// that holds the same Blossom blob (same sha256 name): the one the person
+// publishes in their kind 10063 first. The small avatar's thumbnail is made
+// from the copy, the profile page's big face and banner paint from it, and
+// the find is remembered on the device.
+// Run: bun tools/blob-alt-test.js
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { nip19 } from 'nostr-tools';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const sk = generateSecretKey(), PK = getPublicKey(sk), now = Math.floor(Date.now() / 1000);
+const H1 = 'c11a29785c1af623a45ebfdb23860170a9d0c0ead31c1bcb209085cd662f7a17';
+const H2 = '479711427c577f040a5bf8cb05c47f75abfef37580893dd63b66f777c34dc327';
+const png = (color) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', `color=c=${color}:size=320x320`, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
+const FACE = png('orange'), COVER = png('teal');
+const html = await buildHtml({ minify: true, pwa: false });
+const hits = { dead: 0, alt: 0 };
+let origin = '';
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const u = new URL(req.url);
+ if (u.pathname.startsWith('/dead/')) { hits.dead++; return new Response('{"error":"The requested blob was not found."}', { status: 404, headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*' } }); }
+ if (u.pathname === `/alt/${H1}.png`) { hits.alt++; return new Response(FACE, { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*' } }); }
+ if (u.pathname === `/alt/${H2}.png`) { hits.alt++; return new Response(COVER, { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*' } }); }
+ if (u.pathname === '/api/feed') return new Response(snapshot, { headers: { 'content-type': 'application/json' } });
+ return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } });
+} });
+origin = server.url.origin;
+const notes = Array.from({ length: 12 }, (_, i) => finalizeEvent({ kind: 1, created_at: now - 60 - i * 60, tags: [], content: `Post ${i}: the harbour at dusk, boats coming in.` }, sk));
+const profile = finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Karnage', picture: `${origin}/dead/${H1}.png`, banner: `${origin}/dead/${H2}.png`, about: 'Saying the quiet part out loud.' }) }, sk);
+const servers = finalizeEvent({ kind: 10063, created_at: now, tags: [['server', `${origin}/alt/`]], content: '' }, sk);
+const snapshot = JSON.stringify({ version: 1, generatedAt: Date.now(), notes, profiles: [profile], popUntil: now - 3600 });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(String(e)));
+ await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
+ await page.setRequestInterception(true);
+ page.on('request', (r) => r.url().startsWith(origin) ? r.continue() : r.abort()); // the public hosts: unreachable here
+ await page.evaluateOnNewDocument(([notes, profile, servers]) => {
+ class FakeSocket extends EventTarget {
+ constructor(url) { super(); this.url = url; this.readyState = 0; this.subs = new Set();
+ setTimeout(() => { this.readyState = 1; this.emit('open', new Event('open')); }, 0); }
+ emit(type, ev) { this.dispatchEvent(ev); this['on' + type]?.(ev); }
+ message(d) { this.emit('message', new MessageEvent('message', { data: JSON.stringify(d) })); }
+ send(raw) {
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ if (m[0] === 'CLOSE') { this.subs.delete(m[1]); return; }
+ if (m[0] !== 'REQ') return;
+ const [, id, f] = m; this.subs.add(id);
+ let evs = [];
+ if (f.kinds?.includes(1) && !f.since) evs = notes.filter((e) => (!f.until || e.created_at <= f.until)
+ && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id))).slice(0, f.limit || 100);
+ if (f.kinds?.includes(0) && f.authors?.includes(profile.pubkey)) evs = [profile];
+ if (f.kinds?.includes(10063) && f.authors?.includes(servers.pubkey)) evs = [servers];
+ if (f.ids) evs = notes.filter((e) => f.ids.includes(e.id));
+ setTimeout(() => { if (!this.subs.has(id)) return; for (const e of evs) this.message(['EVENT', id, e]); this.message(['EOSE', id]); }, 30);
+ }
+ close() { this.readyState = 3; this.emit('close', new Event('close')); }
+ }
+ Object.assign(FakeSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+ window.WebSocket = FakeSocket;
+ }, [notes, profile, servers]);
+ const bg = (sel) => page.evaluate((s) => document.querySelector(s)?.style.backgroundImage || '', sel);
+ // the feed: the small face's thumbnail is made from the copy
+ await page.goto(origin + '/feed', { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => document.querySelectorAll('.notes-feed > [data-zap-post]').length >= 5, { timeout: 20000 });
+ // the copy is found and the thumbnail made from it (the probe and the
+ // thumbnail's own fetch: two hits); the row keeps the look it was admitted
+ // with, the next paint wears the face
+ for (let i = 0; i < 80 && hits.alt < 2; i++) await sleep(250);
+ assert.ok(hits.dead >= 1 && hits.alt >= 2, `the dead host was asked (${hits.dead}) and the copy fetched for the thumbnail (${hits.alt})`);
+ console.log('✓ the thumbnail is made from the copy on the published server');
+ const remembered = await page.evaluate(() => localStorage.getItem('btc-wallet-blob-alts') || '');
+ assert.ok(remembered.includes(`/alt/${H1}.png`), 'the find is remembered on the device');
+ // the profile page (the screen that showed an empty circle and no cover):
+ // big face and banner from the copies, the post rows' small faces from
+ // the thumbnail
+ await page.goto(origin + '/' + nip19.npubEncode(PK), { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction((h) => (document.querySelector('.profile-banner')?.style.backgroundImage || '').includes('/alt/' + h), { timeout: 20000 }, H2)
+ .catch(async () => { throw new Error('banner not from the copy: ' + await bg('.profile-banner')); });
+ const face = await bg('.profile-avatar');
+ assert.ok(face.includes(`/alt/${H1}.png`), 'the big face paints from the copy: ' + face);
+ console.log('✓ the profile page paints the big face and the banner from the copies');
+ const rowSel = `.note-post[data-zap-post="${notes[0].id}"] .ava-img`;
+ await page.waitForFunction((s, h) => /^url\("data:/.test(document.querySelector(s)?.style.backgroundImage || '') || (document.querySelector(s)?.style.backgroundImage || '').includes('/alt/' + h), { timeout: 20000 }, rowSel, H1)
+ .catch(async () => { throw new Error('post row face: ' + await page.evaluate((id) => [...document.querySelector('.note-post[data-zap-post="' + id + '"]')?.querySelectorAll('[class*=avatar], .ava-img') || []].map((e) => e.outerHTML.slice(0, 220)).join(' || '), notes[0].id)); });
+ assert.deepEqual(errors, []);
+ console.log('✓ the post rows wear the face, and no page errors');
+} finally { await browser.close(); server.stop(true); }
diff --git a/tools/call-test.js b/tools/call-test.js
index d5c3909..44f9b46 100644
--- a/tools/call-test.js
+++ b/tools/call-test.js
@@ -5,7 +5,7 @@
// both. A call can be declined, and a stranger's (neither followed nor
// written to) doesn't ring, but is in the conversation as a missed call. Everything on the wire is an ephemeral gift wrap.
// Run: bun tools/call-test.js (NO_BUILD=1 to reuse dist/)
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
import { getPublicKey } from 'nostr-tools/pure';
@@ -74,9 +74,9 @@ async function wallet(name) {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive');
return { page, mn, pk: getPublicKey(privateKeyFromSeedWords(mn)), base: cacheKeyFor(mn + '\n'), click, waitText, name };
}
diff --git a/tools/call-wake-test.js b/tools/call-wake-test.js
index 54c42ee..3f81db1 100644
--- a/tools/call-wake-test.js
+++ b/tools/call-wake-test.js
@@ -1,12 +1,12 @@
// 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).
// Run: bun tools/call-wake-test.js (NO_BUILD=1 to reuse dist/)
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
import { getPublicKey, finalizeEvent, generateSecretKey } from 'nostr-tools/pure';
@@ -91,9 +91,9 @@ async function wallet(name) {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive');
const sk = privateKeyFromSeedWords(mn);
return { context, page, mn, sk, pk: getPublicKey(sk), base: cacheKeyFor(mn + '\n'), click, waitText, name };
@@ -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/chat-remove-member-test.js b/tools/chat-remove-member-test.js
new file mode 100644
index 0000000..1007973
--- /dev/null
+++ b/tools/chat-remove-member-test.js
@@ -0,0 +1,224 @@
+// A community's admin taps a message and removes its author, with everything
+// they said: one owner-signed ban-list edition (CORD-04 vsk 4) that every
+// client folds. "can i have some control as a chat group admin to click on a
+// message and see an option to remove the user and all their messages from
+// the group?" (Adam, 2026-11-10).
+//
+// Everything runs against a relay inside this process: the page's sockets all
+// go there, so nothing reaches a live room.
+//
+// Run: bun tools/chat-remove-member-test.js
+import puppeteer from './puppeteer.js';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import {
+ parseInviteLink, openInviteBundle, channelStream, channelEpoch, controlKey, wrapRumor, openWrap, msTags,
+ foldControl, banlistHead, makeBanlist,
+} from '../src/concord.js';
+import { hexToBytes } from '@noble/hashes/utils';
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+
+// ---- a relay in a few lines: stores what it's given, serves it by filter ----
+const events = new Map();
+const socks = new Map(); // ws -> Map(subId -> filters)
+const matches = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey))
+ && (!f.ids || f.ids.includes(e.id)) && (!f.since || e.created_at >= f.since) && (!f.until || e.created_at <= f.until)
+ && Object.entries(f).every(([k, v]) => k[0] !== '#' || e.tags.some((t) => t[0] === k.slice(1) && v.includes(t[1])));
+const store = (e) => {
+ if (events.has(e.id)) return;
+ events.set(e.id, e);
+ for (const [ws, subs] of socks) for (const [id, fs] of subs) if (fs.some((f) => matches(f, e))) ws.send(JSON.stringify(['EVENT', id, e]));
+};
+const RELAY_PORT = 27811, RELAY = `ws://127.0.0.1:${RELAY_PORT}`;
+const relay = Bun.serve({
+ port: RELAY_PORT, hostname: '127.0.0.1',
+ fetch(req, srv) { return srv.upgrade(req) ? undefined : new Response('relay'); },
+ websocket: {
+ open(ws) { socks.set(ws, new Map()); },
+ close(ws) { socks.delete(ws); },
+ message(ws, raw) {
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ if (m[0] === 'EVENT') { store(m[1]); ws.send(JSON.stringify(['OK', m[1].id, true, ''])); }
+ else if (m[0] === 'REQ') {
+ const fs = m.slice(2);
+ socks.get(ws).set(m[1], fs);
+ for (const f of fs) {
+ const hit = [...events.values()].filter((e) => matches(f, e)).sort((a, b) => b.created_at - a.created_at);
+ for (const e of f.limit ? hit.slice(0, f.limit) : hit) ws.send(JSON.stringify(['EVENT', m[1], e]));
+ }
+ ws.send(JSON.stringify(['EOSE', m[1]]));
+ } else if (m[0] === 'CLOSE') socks.get(ws)?.delete(m[1]);
+ else if (m[0] === 'COUNT') ws.send(JSON.stringify(['COUNT', m[1], { count: 0 }]));
+ else if (/^NEG-/.test(m[0])) ws.send(JSON.stringify(['NEG-ERR', m[1], 'blocked: unsupported']));
+ },
+ },
+});
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5263, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.setViewport({ width: 1100, height: 850 });
+page.on('pageerror', (e) => { console.log(' page error:', e.message); ok = false; });
+await page.evaluateOnNewDocument((relayUrl) => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (url, protocols) { return new Real(/^wss?:/.test(url) ? relayUrl : url, protocols); };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ // the invite link is "copied": keep it where the test can read it
+ try { Object.defineProperty(navigator, 'clipboard', { value: { writeText: async (v) => { window.__clip = v; }, readText: async () => window.__clip || '' }, configurable: true }); } catch {}
+}, RELAY);
+
+const click = (sel, t) => page.evaluate((s, x) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, t);
+const setInput = (sel, v) => page.evaluate((s, val) => { const e = document.querySelector(s); if (!e) return false; e.value = val; e.dispatchEvent(new Event('input', { bubbles: true })); return true; }, sel, v);
+const until = async (fn, ms = 20000) => { const end = Date.now() + ms; for (;;) { const v = await fn(); if (v) return v; if (Date.now() > end) return null; await sleep(300); } };
+const bodyText = () => page.evaluate(() => document.body.innerText);
+const waitText = (x, ms = 20000) => until(async () => (await bodyText()).toLowerCase().includes(x.toLowerCase()), ms);
+const logText = () => page.evaluate(() => document.querySelector('.chat-log')?.innerText || '');
+const say = async (text) => {
+ await page.waitForSelector('#msg-draft', { timeout: 15000 });
+ await page.evaluate((v) => {
+ const e = document.querySelector('#msg-draft');
+ const ta = e.shadowRoot ? e.shadowRoot.querySelector('textarea') : (e.querySelector && e.querySelector('textarea')) || e;
+ ta.focus(); ta.value = v;
+ ta.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
+ }, text);
+ await sleep(150);
+ await page.keyboard.press('Enter');
+};
+const tapBubble = (text) => page.evaluate((x) => {
+ const b = [...document.querySelectorAll('.chat-log .chat-bubble')].find((n) => n.innerText.includes(x));
+ if (b) b.click();
+ return !!b;
+}, text);
+const sheet = () => page.evaluate(() => [...document.querySelectorAll('.msg-sheet .msg-sheet-item')].map((b) => b.innerText.trim()));
+
+try {
+ await page.goto('http://localhost:5263/', { waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('button', 'Create a new wallet');
+ await sleep(300);
+ await click('button', 'Have an existing seed');
+ await sleep(300);
+ await page.waitForSelector('textarea');
+ await page.type('textarea', generateMnemonic(wordlist));
+ await click('button', 'Open wallet');
+ await waitText('receive', 15000);
+ await page.evaluate(() => [...document.querySelectorAll('.app-nav-button')].find((b) => /message/i.test(b.getAttribute('aria-label') || ''))?.click());
+ await sleep(800);
+ await click('button', 'Create');
+ await sleep(300);
+ await setInput('input[placeholder*="name" i]', 'removal test');
+ await sleep(200);
+ await page.evaluate(() => { const i = document.querySelector('input[placeholder*="name" i]'); const b = i && i.parentElement.querySelector('button'); if (b) b.click(); });
+ await waitText('removal test', 25000);
+ await click('.item', 'removal test');
+ await page.waitForSelector('#msg-draft', { timeout: 15000 });
+ await say('the admin says hello');
+ check('the admin posts in their community', await until(async () => (await logText()).includes('the admin says hello')));
+
+ // the join material, the way anyone gets it: the invite link
+ await page.evaluate(() => document.querySelector('[aria-label="Add person"]')?.click());
+ await sleep(300);
+ await click('button', 'Copy invite link');
+ const link = await until(() => page.evaluate(() => window.__clip || null), 10000);
+ const parsed = parseInviteLink(link || '');
+ const bundleEv = parsed && [...events.values()].find((e) => e.kind === 33301 && e.pubkey === parsed.signerPk);
+ const jm = bundleEv && openInviteBundle(bundleEv, parsed.token);
+ check('invite link opens the community', !!(jm && jm.community_root), link ? link.slice(0, 50) + '…' : 'no link');
+ if (!jm) throw new Error('no join material');
+ await page.evaluate(() => document.querySelector('[aria-label="Add person"]')?.click());
+ const ch = jm.channels[0];
+ const stream = channelStream(jm, ch);
+ const post = async (sk, text) => {
+ const { created_at, ms } = msTags(Date.now());
+ const rumor = { kind: 9, pubkey: getPublicKey(sk), content: text, tags: [['channel', ch.id], ['epoch', String(channelEpoch(jm, ch))], ms], created_at };
+ store(await wrapRumor(rumor, sk, stream));
+ };
+ const spammer = generateSecretKey(), friend = generateSecretKey();
+ await post(spammer, 'Can I have some free sats');
+ await sleep(30);
+ await post(friend, 'good evening all');
+ await sleep(30);
+ await post(spammer, 'Any donation can help');
+ check('members\' messages arrive', await until(async () => { const x = await logText(); return x.includes('free sats') && x.includes('good evening') && x.includes('donation'); }));
+
+ console.log('\n[the sheet]');
+ await tapBubble('the admin says hello');
+ await sleep(300);
+ let items = await sheet();
+ check('own message: no remove option', items.length > 0 && !items.some((x) => /remove/i.test(x)), items.join(' / '));
+ await page.keyboard.press('Escape');
+ await page.evaluate(() => document.querySelector('.confirm-pop-backdrop')?.click());
+ await sleep(300);
+ await tapBubble('free sats');
+ await sleep(300);
+ items = await sheet();
+ check('someone else\'s message: "Remove … and their messages"', items.some((x) => /Remove .+ and their messages$/.test(x)), items.join(' / '));
+ await click('.msg-sheet-item', 'and their messages');
+ await sleep(300);
+ items = await sheet();
+ check('first tap only asks', items.some((x) => /tap again/i.test(x)) && (await logText()).includes('free sats'), items.join(' / '));
+ await click('.msg-sheet-item', 'tap again');
+ const gone = await until(async () => { const x = await logText(); return !x.includes('free sats') && !x.includes('donation'); }, 15000);
+ check('second tap: every message of theirs is gone', !!gone);
+ const after = await logText();
+ check('everyone else\'s stay', after.includes('good evening') && after.includes('the admin says hello'));
+
+ console.log('\n[what other clients see]');
+ const control = controlKey(hexToBytes(jm.community_root), jm.community_id, jm.root_epoch || 0);
+ const entries = () => [...events.values()].filter((e) => e.kind === 1059 && e.pubkey === control.pk).map((w) => openWrap(w, control)).filter(Boolean);
+ let folded = foldControl(entries(), { ownerHex: jm.owner, cid: jm.community_id });
+ check('the relay holds an owner-signed ban list naming them', folded.banned.has(getPublicKey(spammer)) && folded.banned.size === 1);
+ // a member (not the owner) signing a higher version can't undo it
+ const head = banlistHead(entries(), { ownerHex: jm.owner, cid: jm.community_id });
+ const forged = { ...makeBanlist({ ...head, version: head.version + 5 }, [], Date.now()), pubkey: getPublicKey(spammer) };
+ store(await wrapRumor(forged, spammer, control, { plaintext: true }));
+ folded = foldControl(entries(), { ownerHex: jm.owner, cid: jm.community_id });
+ check('a member\'s own "empty list" edition changes nothing', folded.banned.has(getPublicKey(spammer)));
+ await post(spammer, 'me again, free sats?');
+ await post(friend, 'still here');
+ await until(async () => (await logText()).includes('still here'));
+ check('their new messages never show', !(await logText()).includes('me again'));
+
+ console.log('\n[after a reload, and a second removal]');
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await until(async () => (await logText()).includes('good evening'), 25000);
+ await sleep(1500);
+ check('still gone after a reload', !/free sats|donation|me again/.test(await logText()) && (await logText()).includes('good evening'));
+ const second = generateSecretKey();
+ await post(second, 'buy my course');
+ await until(async () => (await logText()).includes('buy my course'));
+ await tapBubble('buy my course');
+ await sleep(300);
+ await click('.msg-sheet-item', 'and their messages');
+ await sleep(300);
+ await click('.msg-sheet-item', 'tap again');
+ await until(async () => !(await logText()).includes('buy my course'), 15000);
+ folded = foldControl(entries(), { ownerHex: jm.owner, cid: jm.community_id });
+ check('the second removal keeps the first', folded.banned.has(getPublicKey(spammer)) && folded.banned.has(getPublicKey(second)) && folded.banned.size === 2, `${folded.banned.size} on the list`);
+
+ console.log('\n[putting someone back]');
+ await page.evaluate(() => document.querySelector('.chat-head .col.clickable')?.click());
+ await sleep(500);
+ const hasList = await until(async () => (await bodyText()).includes('Removed from this community'), 5000);
+ check('the member list shows who was removed (to the admin)', !!hasList);
+ await page.evaluate(() => { const rows = [...document.querySelectorAll('.chat-members .item')].filter((r) => [...r.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Restore')); rows.at(-1)?.querySelector('button')?.click(); });
+ const back = await until(async () => (await logText()).includes('buy my course'), 15000);
+ check('Restore brings their messages back', !!back);
+ folded = foldControl(entries(), { ownerHex: jm.owner, cid: jm.community_id });
+ check('and the list keeps the other', folded.banned.has(getPublicKey(spammer)) && folded.banned.size === 1);
+} catch (e) {
+ console.log(' error:', e.message); ok = false;
+} finally {
+ await browser.close();
+ server.stop(true); relay.stop(true);
+}
+console.log(ok ? '\nPASS' : '\nFAIL');
+process.exit(ok ? 0 : 1);
diff --git a/tools/comments-test.js b/tools/comments-test.js
index 5a73e6f..21d028d 100644
--- a/tools/comments-test.js
+++ b/tools/comments-test.js
@@ -5,7 +5,7 @@
// are caught at the socket; nothing is published.
//
// Run: bun tools/comments-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { SimplePool } from 'nostr-tools/pool';
import { buildHtml } from '../build.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -86,8 +86,8 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
- await click('skip verification');
+ await seedWords(page);
+ await skipSeedCheck(page);
await waitText('receive', 20000);
await page.goto('http://localhost:5283/' + NEVENT, { waitUntil: 'domcontentloaded' });
const first = comments.sort((a, b) => a.created_at - b.created_at)[0];
diff --git a/tools/composer-media-test.js b/tools/composer-media-test.js
index 94b343a..db1648a 100644
--- a/tools/composer-media-test.js
+++ b/tools/composer-media-test.js
@@ -5,7 +5,7 @@
// paperclip, its URL landing in the draft.
//
// Run: bun tools/composer-media-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { buildHtml } from '../build.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -42,9 +42,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
await page.evaluate(() => document.querySelector('button[aria-label="Settings"]').click());
await sleep(300);
diff --git a/tools/dm-nip42-test.js b/tools/dm-nip42-test.js
index 51fa3a9..debe910 100644
--- a/tools/dm-nip42-test.js
+++ b/tools/dm-nip42-test.js
@@ -6,7 +6,7 @@
// copy of what we send in our own inbox so other clients see it too.
// Both inboxes are played by this script's WebSocket shim.
// Run: bun tools/dm-nip42-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent, verifyEvent } from 'nostr-tools/pure';
import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
@@ -87,9 +87,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const ME_SK = privateKeyFromSeedWords(mn); const ME = getPublicKey(ME_SK);
const base = cacheKeyFor(mn + '\n');
diff --git a/tools/dm-row-resort-test.js b/tools/dm-row-resort-test.js
index 9e400bc..0ecdd44 100644
--- a/tools/dm-row-resort-test.js
+++ b/tools/dm-row-resort-test.js
@@ -3,7 +3,7 @@
// it was first drawn for — a tap on Bill's face opened someone else's
// profile. After the re-sort, each row's avatar and row open their own.
// Run: bun tools/dm-row-resort-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
@@ -67,9 +67,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('Receive', 20000);
const ME = getPublicKey(privateKeyFromSeedWords(mn));
const base = cacheKeyFor(mn + '\n');
diff --git a/tools/feed-appetite-test.js b/tools/feed-appetite-test.js
index b79a2a9..9f469cb 100644
--- a/tools/feed-appetite-test.js
+++ b/tools/feed-appetite-test.js
@@ -6,7 +6,7 @@
// still a while brings the ordinary page back. A page held still under an
// insert above is not travel.
// Run: bun tools/feed-appetite-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -79,9 +79,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/feed-boot-chrome-test.js b/tools/feed-boot-chrome-test.js
new file mode 100644
index 0000000..91e5188
--- /dev/null
+++ b/tools/feed-boot-chrome-test.js
@@ -0,0 +1,113 @@
+// The feed opened by URL (/feed) paints as the restoring wallet's from its
+// first frame. It painted as a visitor's — no post button, chips or nav —
+// until the restore landed a beat later (the vault's scrypt, the wallet's
+// state, every feature's init: ~0.5 s on a laptop, more on a phone), and
+// the button "took a while to appear" (Adam, 2026-10-31). Boot now tells
+// a wallet is about to open (a session list in the tab, a vault the last
+// unlock opened without a password, watch-only accounts) before the work,
+// and the page paints for it. A visitor with no wallet still gets none.
+// Run: bun tools/feed-boot-chrome-test.js
+import puppeteer from './puppeteer.js';
+import { buildHtml } from '../build.js';
+import { encryptVault } from '../src/wallet.js';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+
+let fails = 0;
+const check = (name, ok, detail = '') => { console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + String(detail).slice(0, 200) : ''}`); if (!ok) fails++; };
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5331, fetch: (req) => {
+ const p = new URL(req.url).pathname;
+ if (p === '/verify-worker.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const mnemonic = generateMnemonic(wordlist);
+const account = { id: 'boot-test', label: 'Boot', type: 'full', mnemonic, passphrase: '', network: 'regtest', persisted: true };
+const vault = encryptVault([{ label: account.label, mnemonic, passphrase: '', network: 'regtest' }], '');
+
+// Every mutation batch gets a number; the first batch each piece of the page
+// showed up in says whether it came with the page or after it.
+const watch = () => {
+ window.__seen = {}; window.__batch = 0;
+ const SEL = { page: '.feed-page', fab: '.feed-post-fab', chips: '.feed-chips', nav: '.app-bottom-nav', side: '.wallet-side' };
+ new MutationObserver(() => {
+ window.__batch++;
+ for (const [k, s] of Object.entries(SEL)) if (!(k in window.__seen) && document.querySelector(s)) { window.__seen[k] = { batch: window.__batch, at: Math.round(performance.now()) }; if (k === 'fab') window.__fab0 = document.querySelector(s); }
+ }).observe(document, { childList: true, subtree: true });
+};
+async function load(name, seed) {
+ const ctx = await browser.createBrowserContext(); // its own storage: each scenario starts clean
+ const page = await ctx.newPage();
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.setViewport({ width: 390, height: 844 });
+ await page.evaluateOnNewDocument(watch);
+ await page.evaluateOnNewDocument(seed, { account, vault });
+ await page.goto('http://localhost:5331/feed', { waitUntil: 'domcontentloaded' });
+ await sleep(3500);
+ const r = await page.evaluate(() => ({
+ seen: window.__seen, errors: [],
+ fabSame: !!window.__fab0 && document.querySelector('.feed-post-fab') === window.__fab0,
+ chips: [...document.querySelectorAll('.feed-chip')].map((c) => c.textContent.trim() + (c.classList.contains('on') ? '*' : '')),
+ fab: !!document.querySelector('.feed-post-fab'), nav: !!document.querySelector('.app-bottom-nav'),
+ walletOpen: !!document.querySelector('.app-nav-profile, .chat-avatar'),
+ blankNote: localStorage.getItem('btc-wallet-vault-blank'),
+ text: document.body.innerText.replace(/\s+/g, ' ').slice(0, 120),
+ }));
+ r.errors = errors;
+ console.log(`[${name}] ${JSON.stringify(r.seen)} chips=${r.chips.join('|')}`);
+ await ctx.close();
+ return r;
+}
+// with the page, or (the nav rides the boot shell too) before it
+const withPage = (r, k) => r.seen[k] && r.seen.page && r.seen[k].batch <= r.seen.page.batch;
+
+try {
+ // 1. A reload: the tab's session list says which wallet is coming.
+ let r = await load('session', ({ account }) => {
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ sessionStorage.setItem('btc-wallet-accounts', JSON.stringify({ accounts: [account], activeId: account.id }));
+ });
+ check('session: the post button paints with the feed page', withPage(r, 'fab'), JSON.stringify(r.seen));
+ check('session: the chips paint with the page', withPage(r, 'chips'));
+ check('session: the nav paints with the page', withPage(r, 'nav'));
+ check('session: the wallet then opens behind it', r.fab && r.nav && r.walletOpen, r.text);
+ check('session: the button is the same node through the restore (no blink)', r.fabSame);
+ check('session: Popular stood for the visitor copy, then the wallet\'s own chips', r.chips.length >= 2 && r.chips.filter((c) => /Popular/.test(c)).length === 1, r.chips.join('|'));
+ check('session: no page errors', r.errors.length === 0, r.errors.join(' | '));
+
+ // 2. A cold start: a vault the last unlock opened without a password.
+ r = await load('vault', ({ vault }) => {
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ localStorage.setItem('btc-wallet-vault', JSON.stringify(vault));
+ localStorage.setItem('btc-wallet-vault-blank', '1');
+ });
+ check('vault: the post button paints with the feed page', withPage(r, 'fab'), JSON.stringify(r.seen));
+ check('vault: the nav paints with the page', withPage(r, 'nav'));
+ check('vault: the wallet opens behind it', r.fab && r.walletOpen, r.text);
+ check('vault: the same node through the scrypt restore', r.fabSame);
+ check('vault: no page errors', r.errors.length === 0, r.errors.join(' | '));
+
+ // 3. The same vault before any unlock left its note: boot can't tell, so
+ // the page paints as a visitor's and grows the button when the wallet
+ // opens — and the unlock leaves the note for next time.
+ r = await load('vault, no note', ({ vault }) => {
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ localStorage.setItem('btc-wallet-vault', JSON.stringify(vault));
+ });
+ check('unnoted vault: the button still comes, after the page', r.fab && r.seen.fab && r.seen.page && r.seen.fab.batch > r.seen.page.batch, JSON.stringify(r.seen));
+ check('unnoted vault: the unlock leaves the note', r.blankNote === '1');
+
+ // 4. A visitor: no wallet on this device, nothing to paint for.
+ r = await load('visitor', () => { localStorage.setItem('btc-wallet-network', 'regtest'); });
+ check('visitor: the feed paints, without a post button', r.seen.page && !r.fab && !r.seen.fab, JSON.stringify(r.seen));
+ check('visitor: no page errors', r.errors.length === 0, r.errors.join(' | '));
+} finally {
+ await browser.close();
+ server.stop(true);
+}
+console.log(fails ? `\n❌ ${fails} failing` : '\n✅ the feed paints for the wallet that is coming');
+process.exit(fails ? 1 : 0);
diff --git a/tools/feed-bottom-nav-test.js b/tools/feed-bottom-nav-test.js
index ea466fd..ccba776 100644
--- a/tools/feed-bottom-nav-test.js
+++ b/tools/feed-bottom-nav-test.js
@@ -13,7 +13,7 @@ const wallet = { xpub: 'test-wallet', loaded: true, nostrRelays: () => [],
loadFeatureState: (k, d) => k === 'follows' ? { tags: [['p', author]], at: Date.now()/1000 } : d,
saveFeatureState() {}, registerCacheExtension() {} };
let feature;
-function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet'), feature.bottomNav()]); }
+function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet'), ...(feature.pageFloaters ? feature.pageFloaters() : []), feature.bottomNav()]); }
feature = messagesFeature({ h, ui, wallet, render, toast() {}, hook: name => name === 'unreadMessages' ? 1 : null, brandHeader: () => h('div', { style: 'height:60px' }, 'Coinos') });
window.test = { ui, wallet, render, get feature() { return feature; }, async feed() {
ui.chatOpen = true; ui.msgView = 'feed'; ui.noteThread = null;
diff --git a/tools/feed-conversations-test.js b/tools/feed-conversations-test.js
index f16fc35..7394986 100644
--- a/tools/feed-conversations-test.js
+++ b/tools/feed-conversations-test.js
@@ -4,7 +4,7 @@
// profile's Replies tab shows the same context; the view persists across
// feeds and reloads like the other views.
// Run: bun tools/feed-conversations-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -48,9 +48,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, alice, bob, roots, replies]) => {
diff --git a/tools/feed-custom-test.js b/tools/feed-custom-test.js
index 122cb0a..c5ed41f 100644
--- a/tools/feed-custom-test.js
+++ b/tools/feed-custom-test.js
@@ -4,7 +4,7 @@
// feed, and deleting a feed. No relays are contacted for real — the
// WebSocket shim answers the subscriptions.
// Run: bun tools/feed-custom-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { generateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import { cacheKeyFor } from '../src/wallet.js';
@@ -90,9 +90,9 @@ try {
await sleep(400);
// through the real first run: the wizard's own words, read off the screen
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
// the starter follows land once the current list has been asked for
diff --git a/tools/feed-fab-anim-test.js b/tools/feed-fab-anim-test.js
new file mode 100644
index 0000000..bd9379c
--- /dev/null
+++ b/tools/feed-fab-anim-test.js
@@ -0,0 +1,58 @@
+// The post button is on screen the moment the feed is, while the page is
+// still sliding in. It is position: fixed, and it used to be rendered inside
+// the page — whose entrance animation puts a transform on it, which makes
+// fixed descendants position within the page: the button rode at the foot
+// of the feed card, below the screen, until the animation ended (measured
+// on a phone: 4109 px down at 87 ms, in place at 562 ms). Now the shell
+// paints it beside the page.
+// Run: bun tools/feed-fab-anim-test.js
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.js';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const p = new URL(req.url).pathname;
+ if (p === '/verify-worker.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const account = { id: 'fab-test', label: 'Fab', type: 'full', mnemonic: generateMnemonic(wordlist), passphrase: '', network: 'regtest', persisted: true };
+try {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
+ await page.evaluateOnNewDocument((account) => {
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ sessionStorage.setItem('btc-wallet-accounts', JSON.stringify({ accounts: [account], activeId: account.id }));
+ }, account);
+ await page.goto(server.url.origin + '/', { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('.app-bottom-nav', { timeout: 20000 });
+ await sleep(1500);
+ const tap = (which) => page.evaluate((which) => new Promise((resolve) => {
+ const b = [...document.querySelectorAll('.app-bottom-nav button')].find((x) => new RegExp(which, 'i').test(x.textContent));
+ const t0 = performance.now(); const samples = [];
+ const tick = () => {
+ const f = document.querySelector('.feed-post-fab'); const ms = Math.round(performance.now() - t0);
+ if (f) { const r = f.getBoundingClientRect(); samples.push({ ms, top: Math.round(r.top), right: Math.round(innerWidth - r.right), anim: !!document.querySelector('.anim-page'), inPage: !!f.closest('.anim-page, .feed-page') }); }
+ if (ms < 320) requestAnimationFrame(tick); else resolve(samples);
+ };
+ b.click(); requestAnimationFrame(tick);
+ }), which);
+ for (const round of [1, 2]) {
+ await tap('wallet'); await sleep(900);
+ const s = await tap('feed');
+ assert.ok(s.length >= 5, 'the button was sampled while the page slid in: ' + s.length);
+ const first = s[0];
+ assert.ok(first.anim, 'the page was still animating at the first sample (' + first.ms + ' ms)');
+ assert.ok(!first.inPage, 'the button is painted beside the page, not inside it');
+ const off = s.filter((x) => x.top > 844 - 40 || x.top < 0);
+ assert.equal(off.length, 0, `the button sat off screen in ${off.length} of ${s.length} frames: ` + JSON.stringify(s.slice(0, 4)));
+ console.log(`✓ round ${round}: the post button is on screen from the first frame (top ${first.top}px at ${first.ms} ms, ${s.length} frames sampled)`);
+ }
+ assert.deepEqual(errors, []);
+ console.log('✓ no page errors');
+} finally { await browser.close(); server.stop(true); }
diff --git a/tools/feed-list-test.js b/tools/feed-list-test.js
index 6d60240..00942b8 100644
--- a/tools/feed-list-test.js
+++ b/tools/feed-list-test.js
@@ -5,7 +5,7 @@
// unticking "Keep as a nostr list" sends the NIP-09 deletion. No relay is
// written for real — the WebSocket shim swallows EVENT and answers OK.
// Run: bun tools/feed-list-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
@@ -83,9 +83,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
const ME_SK = privateKeyFromSeedWords(mn);
diff --git a/tools/feed-media-cache-test.js b/tools/feed-media-cache-test.js
index 5a26ca2..8ca8185 100644
--- a/tools/feed-media-cache-test.js
+++ b/tools/feed-media-cache-test.js
@@ -3,7 +3,7 @@
// slow picture), a reload paints the grid from that cache at once and
// resumes the crawl where it stopped, and tile frames are kept as pictures.
// Run: bun tools/feed-media-cache-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -115,9 +115,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const key = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk]) => {
diff --git a/tools/feed-media-views-test.js b/tools/feed-media-views-test.js
index dac9804..74de24a 100644
--- a/tools/feed-media-views-test.js
+++ b/tools/feed-media-views-test.js
@@ -5,7 +5,7 @@
// until tapped: then the author, the words as a caption, likes and sats.
// Back and Escape close it.
// Run: bun tools/feed-media-views-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -112,9 +112,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const key = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/feed-mention-tap-test.js b/tools/feed-mention-tap-test.js
index e4d3f7a..d381288 100644
--- a/tools/feed-mention-tap-test.js
+++ b/tools/feed-mention-tap-test.js
@@ -2,7 +2,7 @@
// thread on top of them (the mention's tap reached the row, which opened
// the thread over the profile it had just shown). Links in a post likewise.
// Run: bun tools/feed-mention-tap-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { npubEncode } from 'nostr-tools/nip19';
@@ -33,9 +33,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('Receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, a, f, ns]) => {
diff --git a/tools/feed-new-posts-test.js b/tools/feed-new-posts-test.js
index 59d6f7c..d66ab59 100644
--- a/tools/feed-new-posts-test.js
+++ b/tools/feed-new-posts-test.js
@@ -14,7 +14,7 @@ const wallet = { xpub: 'test-wallet', loaded: true, nostrRelays: () => [],
loadFeatureState: (k, d) => k === 'follows' ? { tags: [['p', author]], at: Date.now()/1000 } : d,
saveFeatureState() {}, registerCacheExtension() {} };
let feature;
-function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet'), feature.bottomNav()]); }
+function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet'), ...(feature.pageFloaters ? feature.pageFloaters() : []), feature.bottomNav()]); }
feature = messagesFeature({ h, ui, wallet, render, toast() {}, hook: name => name === 'unreadMessages' ? 1 : null, brandHeader: () => h('div', { style: 'height:60px' }, 'Coinos') });
window.test = { ui, wallet, render, get feature() { return feature; }, async feed() {
ui.chatOpen = true; ui.msgView = 'feed'; ui.noteThread = null;
diff --git a/tools/feed-pager-ahead-test.js b/tools/feed-pager-ahead-test.js
index 057ac3e..0906034 100644
--- a/tools/feed-pager-ahead-test.js
+++ b/tools/feed-pager-ahead-test.js
@@ -3,7 +3,7 @@
// feed pages on, and the pager grows — past a run of posts with nothing to
// show, too — so the reader never meets the end while there is more.
// Run: bun tools/feed-pager-ahead-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -96,9 +96,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/feed-picture-pager-test.js b/tools/feed-picture-pager-test.js
index 4cbe68c..f74c981 100644
--- a/tools/feed-picture-pager-test.js
+++ b/tools/feed-picture-pager-test.js
@@ -4,7 +4,7 @@
// closes it, as before; once the reader has moved on, a tap shows the post's
// details and the next tap (or Escape) closes the pager onto that post.
// Run: bun tools/feed-picture-pager-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -67,9 +67,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/feed-reboost-test.js b/tools/feed-reboost-test.js
index e22df91..f43e7b5 100644
--- a/tools/feed-reboost-test.js
+++ b/tools/feed-reboost-test.js
@@ -4,7 +4,7 @@
// reload — stays out. A repost of a post you haven't read still lands, and
// the row you read keeps its place.
// Run: bun tools/feed-reboost-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -95,9 +95,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pks]) => {
diff --git a/tools/feed-repost-test.js b/tools/feed-repost-test.js
index 4936dd6..1bf3d7a 100644
--- a/tools/feed-repost-test.js
+++ b/tools/feed-repost-test.js
@@ -4,7 +4,7 @@
// copy doesn't check out is left out, and a note already in the feed isn't
// shown twice.
// Run: bun tools/feed-repost-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
@@ -48,9 +48,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, z, l, o]) => {
diff --git a/tools/feed-streaming-test.js b/tools/feed-streaming-test.js
index c69b1eb..4fa00cf 100644
--- a/tools/feed-streaming-test.js
+++ b/tools/feed-streaming-test.js
@@ -26,7 +26,7 @@ const wallet = { xpub: 'test-wallet', loaded: true, nostrRelays: () => ['wss://k
: k === 'feedNotes' ? seeded : k === 'profiles' ? { [author]: { name: 'Stream Author', t: Date.now() } } : d,
saveFeatureState() {}, registerCacheExtension() {} };
let feature;
-function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView(), feature.bottomNav()]); }
+function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView(), ...(feature.pageFloaters ? feature.pageFloaters() : []), feature.bottomNav()]); }
feature = messagesFeature({ h, ui, wallet, render, toast() {}, hook: () => null,
brandHeader: () => h('div', { style: 'height:60px' }, 'Coinos') });
window.test = { feature, render };
diff --git a/tools/feed-switch-seed-test.js b/tools/feed-switch-seed-test.js
index bf2b830..6d9d4f0 100644
--- a/tools/feed-switch-seed-test.js
+++ b/tools/feed-switch-seed-test.js
@@ -3,7 +3,7 @@
// other open feeds already hold, and once the feed on screen settles the
// other feeds' first posts are fetched ahead into the disk cache.
// Run: bun tools/feed-switch-seed-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -70,9 +70,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, a, b, ns]) => {
diff --git a/tools/feed-video-aspect-test.js b/tools/feed-video-aspect-test.js
index 84c0568..4196fe1 100644
--- a/tools/feed-video-aspect-test.js
+++ b/tools/feed-video-aspect-test.js
@@ -4,7 +4,7 @@
// shape still gets the 16:9 box.
// Photos too: a big one paints from a column-sized copy (see makeMediaThumb).
// Run: bun tools/feed-video-aspect-test.js (CHROME=<path> to pick a browser)
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -96,9 +96,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const key = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/feed-video-pager-test.js b/tools/feed-video-pager-test.js
index cccde41..5bba7d8 100644
--- a/tools/feed-video-pager-test.js
+++ b/tools/feed-video-pager-test.js
@@ -4,7 +4,7 @@
// Each clip has a seek bar to drag; a tap pauses (and shows the details),
// another plays on. Leaving full screen leaves the pager.
// Run: bun tools/feed-video-pager-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -67,6 +67,8 @@ const openClip = async (word) => {
await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }, word);
await sleep(400);
const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
await page.mouse.click(at.x, at.y);
await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000);
};
@@ -80,9 +82,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/feed-window-dom-test.js b/tools/feed-window-dom-test.js
index c86e98e..1f6ff93 100644
--- a/tools/feed-window-dom-test.js
+++ b/tools/feed-window-dom-test.js
@@ -3,7 +3,7 @@
// page keeps its length, the row under your eyes never moves when the window
// shifts, and scrolling back up brings the top posts back.
// Run: bun tools/feed-window-dom-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -78,9 +78,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/fullscreen-notice-test.js b/tools/fullscreen-notice-test.js
index cf15f8c..a7c041a 100644
--- a/tools/fullscreen-notice-test.js
+++ b/tools/fullscreen-notice-test.js
@@ -5,7 +5,7 @@
// (feed-video-pager-test). A feed clip's own bar has no full-screen button:
// a tap on the clip is the way, into the pager.
// Run: bun tools/fullscreen-notice-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -68,6 +68,8 @@ async function run(lockOk) {
await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }, word);
await sleep(400);
const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
await page.mouse.click(at.x, at.y);
await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000);
};
@@ -77,9 +79,9 @@ async function run(lockOk) {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/gif-picker-test.js b/tools/gif-picker-test.js
index f72cde3..9a48de9 100644
--- a/tools/gif-picker-test.js
+++ b/tools/gif-picker-test.js
@@ -4,7 +4,7 @@
// In a DM the picked GIF is sent at once. Uses the live API — localhost is
// admitted as nostr.build's shared development client.
// Run: bun tools/gif-picker-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -35,9 +35,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk]) => {
diff --git a/tools/gift-from-test.js b/tools/gift-from-test.js
new file mode 100644
index 0000000..a0ffaf8
--- /dev/null
+++ b/tools/gift-from-test.js
@@ -0,0 +1,71 @@
+// A gift link can name its sender: #f=<base32 pubkey|created_at|sig>, a
+// nostr signature over the gift's code. The claim screen shows who it's
+// from (and that claiming follows them); a link whose signature doesn't
+// match its code — passed on as someone else's gift — shows no sender.
+// Run: bun run build && bun tools/gift-from-test.js
+import puppeteer from './puppeteer.js';
+import { base32nopad } from '@scure/base';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { sha256 } from '@noble/hashes/sha256';
+import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const server = Bun.serve({ port: 0, async fetch(req) {
+ const p = new URL(req.url).pathname;
+ const f = Bun.file('dist' + (p === '/' ? '/index.html' : p));
+ return (await f.exists()) ? new Response(f) : new Response(Bun.file('dist/index.html'), { headers: { 'content-type': 'text/html' } });
+} });
+const giftCode = () => {
+ const b = new Uint8Array(42);
+ b[0] = 0x11; b[1] = 3; // ark gift, mutinynet
+ new DataView(b.buffer).setBigUint64(2, 1000n, true);
+ b.set(crypto.getRandomValues(new Uint8Array(32)), 10);
+ return base32nopad.encode(b);
+};
+const sk = generateSecretKey();
+const fromFrag = (code) => {
+ const ev = finalizeEvent({ kind: 27121, created_at: Math.floor(Date.now() / 1000), content: '',
+ tags: [['x', bytesToHex(sha256(new TextEncoder().encode(code.toUpperCase())))]] }, sk);
+ const b = new Uint8Array(100);
+ b.set(hexToBytes(ev.pubkey), 0);
+ new DataView(b.buffer).setUint32(32, ev.created_at);
+ b.set(hexToBytes(ev.sig), 36);
+ return base32nopad.encode(b);
+};
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+const text = (page) => page.evaluate(() => document.body.innerText);
+const waitText = async (page, re, ms = 30000) => {
+ for (let t = 0; t < ms; t += 250) { if (re.test(await text(page))) return true; await sleep(250); }
+ return false;
+};
+async function open(path) {
+ const page = await browser.newPage();
+ await page.goto(server.url.href);
+ await page.evaluate(() => { localStorage.clear(); localStorage.setItem('btc-wallet-network', 'mutinynet'); });
+ await page.goto(server.url.href + path);
+ await waitText(page, /aren.t visible/i);
+ await sleep(500);
+ return page;
+}
+try {
+ const code = giftCode();
+ let page = await open(`ag/${code}#m=hi&f=${fromFrag(code)}`);
+ let tx = await text(page);
+ check('a signed link names its sender', /\bFrom\b/.test(tx) && /Claiming it follows them/.test(tx), tx.slice(0, 300).replace(/\n/g, ' | '));
+ check('the note still shows', /\bhi\b/.test(tx));
+ await page.close();
+ // an upper-case key, as a scanner may hand it over
+ page = await open(`ag/${code}#F=${fromFrag(code)}`);
+ { const tx = await text(page); check("an upper-cased link still names its sender", /Claiming it follows them/.test(tx), tx.slice(0, 200).replace(/\n/g, " | ")); }
+ await page.close();
+ // the same signature on a different gift: no sender
+ page = await open(`ag/${giftCode()}#f=${fromFrag(code)}`);
+ check('a signature over another code names nobody', !/Claiming it follows them/.test(await text(page)));
+ await page.close();
+} finally {
+ await browser.close();
+ server.stop();
+}
+console.log(ok ? 'PASS' : 'FAIL');
+process.exit(ok ? 0 : 1);
diff --git a/tools/gift-profile-setup-test.js b/tools/gift-profile-setup-test.js
new file mode 100644
index 0000000..04e58c5
--- /dev/null
+++ b/tools/gift-profile-setup-test.js
@@ -0,0 +1,95 @@
+// After a gift is opened into a fresh wallet and its seed written down, the
+// first-run wizard asks for a username, then a picture and a cover — the gift's
+// recipient should arrive as someone people can pay by name. "Not now" lands in
+// the account the gift went to.
+// Drives an ark gift on mutinynet whose bearer key holds nothing (a random
+// secret: the staging ASP sees no coin, so the sweep finds it taken), then
+// "Create wallet anyway" → the seed → on to the wizard.
+// Run: bun run build && bun tools/gift-profile-setup-test.js [screenshot.png]
+import puppeteer from './puppeteer.js';
+import { base32nopad } from '@scure/base';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const server = Bun.serve({ port: 0, async fetch(req) {
+ const p = new URL(req.url).pathname;
+ const f = Bun.file('dist' + (p === '/' ? '/index.html' : p));
+ return (await f.exists()) ? new Response(f) : new Response(Bun.file('dist/index.html'), { headers: { 'content-type': 'text/html' } });
+} });
+const giftCode = () => {
+ const b = new Uint8Array(42);
+ b[0] = 0x11; b[1] = 3; // ark gift, mutinynet
+ new DataView(b.buffer).setBigUint64(2, 1000n, true);
+ b.set(crypto.getRandomValues(new Uint8Array(32)), 10);
+ return base32nopad.encode(b);
+};
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+const text = (page) => page.evaluate(() => document.body.innerText);
+const click = (page, x) => page.evaluate((x) => {
+ const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x));
+ e?.click(); return !!e;
+}, x);
+const waitText = async (page, re, ms = 30000) => {
+ for (let t = 0; t < ms; t += 250) { if (re.test(await text(page))) return true; await sleep(250); }
+ return false;
+};
+async function toSeed(page, note) {
+ await page.goto(server.url.href);
+ await page.evaluate(() => { localStorage.clear(); localStorage.setItem('btc-wallet-network', 'mutinynet'); });
+ await page.goto(server.url.href + 'ag/' + giftCode() + (note ? '#m=' + encodeURIComponent(note) : ''));
+ check('the gift opens: the server sees no coin behind it', await waitText(page, /aren.t visible/i));
+ if (note) {
+ const q = await page.evaluate(() => document.querySelector('.gift-note')?.textContent || null);
+ check('the sender\'s note shows on the gift', q === note, String(q));
+ check('...and leaves the address bar with the code', await page.evaluate(() => location.pathname === '/' && !location.hash));
+ }
+ await click(page, 'claim');
+ check('...so the claim finds it taken', await waitText(page, /create a wallet anyway/i));
+ check('...create a wallet anyway shows the seed', await click(page, 'create a wallet anyway') && await waitText(page, /recovery phrase/i, 10000));
+}
+try {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(String(e)));
+ await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 2 });
+ await toSeed(page, 'Happy birthday! 🎂 <b>not bold</b>');
+ check('a heads-up comes before the words', /no one is looking/i.test(await text(page)) && await page.evaluate(() => !document.querySelector('.words')));
+ await click(page, 'show seed phrase');
+ check('...then the words, with Next', await page.evaluate(() => document.querySelectorAll('.words .w').length === 12) && await click(page, 'next'));
+ check('...then one word to pick, or Skip', await waitText(page, /which one is word #5/i, 5000) && await page.evaluate(() => document.querySelectorAll('.quiz-opt').length === 4));
+ await click(page, 'skip');
+ check('the seed done: the wizard asks for a username', await waitText(page, /now pick a username/i, 10000), (await text(page)).slice(0, 200));
+ check('...with the claim form (or its wait) and Not now', await page.evaluate(() => !!document.querySelector('.onb')) && /not now/i.test(await text(page)));
+ if (process.argv[2]) await page.screenshot({ path: process.argv[2] });
+ // the look step follows the name, as in any first run
+ await page.evaluate(() => localStorage.setItem('btc-wallet-onb-step', 'avatar'));
+ await page.reload();
+ check('the next step is the picture and cover', !!await page.waitForSelector('.onb-cover', { timeout: 15000 }).catch(() => null));
+ // a second gift, verified this time, then Not now
+ const p2 = await browser.newPage();
+ p2.on('pageerror', (e) => errors.push(String(e)));
+ await p2.setViewport({ width: 390, height: 844, deviceScaleFactor: 2 });
+ await toSeed(p2);
+ await click(p2, 'show seed phrase');
+ const fifth = await p2.evaluate(() => document.querySelectorAll('.words .w .t')[4]?.textContent.trim());
+ check('Next opens the word check', await click(p2, 'next') && await waitText(p2, /which one is word #5/i, 5000));
+ // a wrong pick says so and stays
+ const wrongOk = await p2.evaluate((right) => {
+ const b = [...document.querySelectorAll('.quiz-opt')].find((x) => x.textContent.trim() !== right); b?.click(); return !!b;
+ }, fifth);
+ await sleep(300);
+ check('...a wrong word is marked, and the check stays', wrongOk && await p2.evaluate(() => !!document.querySelector('.quiz-opt.wrong')) && /not that one/i.test(await text(p2)));
+ const filled = await p2.evaluate((right) => {
+ const b = [...document.querySelectorAll('.quiz-opt')].find((x) => x.textContent.trim() === right); b?.click(); return !!b;
+ }, fifth);
+ if (filled) {
+ check('a verified seed leads to the username step too', await waitText(p2, /now pick a username/i, 10000));
+ await click(p2, 'not now');
+ let gone = false;
+ for (let i = 0; i < 20 && !gone; i++) { await sleep(250); gone = !(await p2.evaluate(() => !!document.querySelector('.onb'))); }
+ check('Not now: into the wallet, the wizard gone', gone, (await text(p2)).slice(0, 200));
+ } else check('the right word is among the choices', false);
+ check('no page errors', errors.length === 0, errors.join(' | '));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ a gift\'s new wallet is offered a name, a picture and a cover' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/go-live-test.js b/tools/go-live-test.js
index c3fc8e3..3990fa6 100644
--- a/tools/go-live-test.js
+++ b/tools/go-live-test.js
@@ -6,7 +6,7 @@
// recording, and the recording is complete.
// Run: bun tools/go-live-test.js (LIVE_SERVER=path to coinosv3-server/live/server.js; NO_BUILD=1 to reuse dist/;
// LIVE_CONFIG=a json with {sfuAppId, sfuSecret} to watch over WebRTC — the real Cloudflare SFU)
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { naddrEncode } from 'nostr-tools/nip19';
import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
@@ -129,9 +129,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive');
const base = cacheKeyFor(mn + '\n');
await page.evaluate((k) => {
diff --git a/tools/grid-early-player-test.js b/tools/grid-early-player-test.js
index 0ab1c1e..04e44fb 100644
--- a/tools/grid-early-player-test.js
+++ b/tools/grid-early-player-test.js
@@ -14,7 +14,7 @@
// that); on a link slower than the clip, more — to the 4 MB cap. With Data
// Saver on, the pager is careful again: such a clip waits for a tap and
// nothing loads ahead.
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -100,9 +100,9 @@ async function run() {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/history-export-test.js b/tools/history-export-test.js
index f7fff82..877b95e 100644
--- a/tools/history-export-test.js
+++ b/tools/history-export-test.js
@@ -2,7 +2,7 @@
// movements) as a line of a CSV file, oldest first, with what was paid, the
// fee beside it and the change to the balance; a failed payment is left out.
// Run: bun tools/history-export-test.js (NO_BUILD=1 to reuse dist/)
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
@@ -43,9 +43,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive');
// Spending's history, as the Ark manager stores it (moved into IndexedDB at the next boot)
const key = cacheKeyFor(mn + '\n') + ':ark:' + ns('http://localhost:3535');
diff --git a/tools/image-tap-test.js b/tools/image-tap-test.js
index d3bcb7e..ce40d53 100644
--- a/tools/image-tap-test.js
+++ b/tools/image-tap-test.js
@@ -3,7 +3,7 @@
// the picture opens the picture.
//
// Run: bun tools/image-tap-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { buildHtml } from '../build.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -27,9 +27,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
const A = 'a'.repeat(63) + '9';
diff --git a/tools/kind0-overwrite-test.js b/tools/kind0-overwrite-test.js
new file mode 100644
index 0000000..e791560
--- /dev/null
+++ b/tools/kind0-overwrite-test.js
@@ -0,0 +1,141 @@
+// A profile (kind 0) must never be written over one we failed to find.
+//
+// 2026-10-30: a long-time nostr user signed in to coinos with his key for
+// the first time. His kind 0 lived on relays that keep history (ditto,
+// snort, oxtr) and nowhere our quick lookup asked, so the payment-address
+// publish merged over an empty base and went out as {lud16, nip05} alone:
+// name, picture and banner gone from every relay. These scenarios run the
+// messages feature against a fake relay network where only an archive holds
+// the real profile, and record every event the wallet publishes.
+// Run: bun tools/kind0-overwrite-test.js
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+
+let fails = 0;
+const check = (name, ok, detail = '') => {
+ console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + String(detail).slice(0, 200) : ''}`);
+ if (!ok) fails++;
+};
+
+globalThis.localStorage = {
+ _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); },
+ removeItem(k) { delete this._d[k]; }, key(i) { return Object.keys(this._d)[i] ?? null; },
+ get length() { return Object.keys(this._d).length; },
+};
+
+// per-relay stores: REQ answered from the socket's own relay, EVENT stored there
+const relayStore = {};
+const published = []; // { url, ev }
+const reqs = []; // { url, filter }
+const norm = (u) => String(u).replace(/\/$/, '');
+class FakeSocket {
+ constructor(url) {
+ this.url = norm(url); this.readyState = 0;
+ setTimeout(() => { this.readyState = 1; this.onopen && this.onopen({}); }, 0);
+ }
+ reply(msg) { setTimeout(() => { if (this.readyState === 1 && this.onmessage) this.onmessage({ data: JSON.stringify(msg) }); }, 0); }
+ send(str) {
+ let m; try { m = JSON.parse(str); } catch { return; }
+ if (m[0] === 'EVENT') { (relayStore[this.url] ||= []).push(m[1]); published.push({ url: this.url, ev: m[1] }); this.reply(['OK', m[1].id, true, '']); }
+ if (m[0] === 'REQ') {
+ const [, sub, ...filters] = m;
+ for (const f of filters) reqs.push({ url: this.url, filter: f });
+ if (filters.some((f) => f.cache)) { this.reply(['EOSE', sub]); return; } // Primal's cache knows nothing here
+ const hit = (relayStore[this.url] || []).filter((e) => filters.some((f) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey))));
+ hit.sort((a, b) => b.created_at - a.created_at);
+ for (const e of hit) this.reply(['EVENT', sub, e]);
+ this.reply(['EOSE', sub]);
+ }
+ }
+ close() { this.readyState = 3; this.onclose && this.onclose({}); }
+}
+globalThis.WebSocket = FakeSocket;
+globalThis.Image = class { set src(v) { this._src = v; } };
+globalThis.fetch = async () => { throw new Error('offline'); };
+
+const { messagesFeature } = await import('../src/features/messages.js');
+
+const ARCHIVE = 'wss://relay.ditto.pub';
+const makeFeature = (sk, ui = {}) => {
+ const pk = getPublicKey(sk);
+ const signer = { pubkey: pk, signEvent: async (e) => finalizeEvent(e, sk) };
+ const walletSk = generateSecretKey();
+ const wallet = {
+ mnemonic: 'x '.repeat(11) + 'y', utxos: [], txs: [], nostrRelays: () => [],
+ nostr: { sk: walletSk, pk: getPublicKey(walletSk), ck: new Uint8Array(32) },
+ loadFeatureState: () => null, saveFeatureState: () => {},
+ _cacheKey: () => 'k0-test', saveCache: () => {}, registerCacheExtension: () => {}, registerLoadHook: () => {},
+ };
+ const toasts = [];
+ const feature = messagesFeature({
+ h: (tag, attrs, ...kids) => ({ tag, attrs, kids }), ui, render: () => {}, wallet, toast: (m) => toasts.push(m), brandHeader: () => null,
+ hook: (name) => (name === 'nostrLoginIdentity' ? { pubkey: pk, signer } : null),
+ });
+ return { feature, pk, toasts };
+};
+const newestOn = (url, pk) => (relayStore[url] || []).filter((e) => e.kind === 0 && e.pubkey === pk).sort((a, b) => b.created_at - a.created_at)[0];
+const kind0s = () => published.filter((p) => p.ev.kind === 0);
+
+console.log('[a first sign-in: the real profile lives only on an archive relay]');
+{
+ const sk = generateSecretKey();
+ const pk = getPublicKey(sk);
+ const old = { name: 'Nik kusanagi', display_name: 'Nik', lud16: '[email protected]', picture: 'https://coinos.io/api/public/face.webp', banner: 'https://coinos.io/api/public/cover.webp' };
+ relayStore[ARCHIVE] = [finalizeEvent({ kind: 0, created_at: 1760302726, tags: [], content: JSON.stringify(old) }, sk)];
+ const { feature } = makeFeature(sk);
+ // the payment-address publish that wiped him (lud16/nip05 empty or at the old address → repoint)
+ await feature.addressRenamed(null, '[email protected]');
+ const sent = [...new Set(kind0s().map((p) => p.ev.id))];
+ check('one kind 0 went out', sent.length === 1, sent.length);
+ const c = sent.length ? JSON.parse(kind0s()[kind0s().length - 1].ev.content) : {};
+ check('it fills the empty nip05 and leaves the lightning address he had', c.nip05 === '[email protected]' && c.lud16 === '[email protected]', JSON.stringify(c));
+ check('...and carries the name, face and cover the archive held', c.name === 'Nik kusanagi' && /face/.test(c.picture) && /cover/.test(c.banner), JSON.stringify(c));
+ check('the archive\'s copy was asked for', reqs.some((r) => r.url === ARCHIVE && r.filter.kinds && r.filter.kinds.includes(0) && r.filter.authors && r.filter.authors.includes(pk)));
+
+ // the onboarding wizard's suggestion must not rename him either
+ published.length = 0;
+ await feature.publishProfile({ name: 'satoshi1234', picture: 'https://punk/1.png' }, { fillOnly: ['name', 'picture'] });
+ check('a fill-only suggestion changes nothing on a profile that has a name', kind0s().length === 0, kind0s().length);
+}
+
+console.log('[a key with no profile anywhere: one deep look, then no waiting]');
+{
+ const sk = generateSecretKey();
+ const pk = getPublicKey(sk);
+ const { feature } = makeFeature(sk);
+ published.length = 0; reqs.length = 0;
+ const t0 = Date.now();
+ await feature.publishProfile({ name: 'newbie' });
+ const deep1 = reqs.filter((r) => r.url === ARCHIVE && r.filter.kinds && r.filter.kinds.includes(0) && r.filter.authors && r.filter.authors.includes(pk)).length;
+ await feature.publishProfile({ about: 'hello' });
+ const deep2 = reqs.filter((r) => r.url === ARCHIVE && r.filter.kinds && r.filter.kinds.includes(0) && r.filter.authors && r.filter.authors.includes(pk)).length;
+ const last = JSON.parse(kind0s().pop().ev.content);
+ check('the new key still gets its profile', last.name === 'newbie' && last.about === 'hello', JSON.stringify(last));
+ check('the archive was asked once, not again for the second publish', deep1 === 1 && deep2 === 1, `${deep1} ${deep2} in ${Date.now() - t0} ms`);
+}
+
+console.log('[already hit: the editor finds the older copy and fills what is empty]');
+{
+ const sk = generateSecretKey();
+ const pk = getPublicKey(sk);
+ const old = { name: 'Nik kusanagi', display_name: 'Nik', picture: 'https://coinos.io/api/public/face.webp', banner: 'https://coinos.io/api/public/cover.webp' };
+ relayStore[ARCHIVE] = [finalizeEvent({ kind: 0, created_at: 1760302726, tags: [], content: JSON.stringify(old) }, sk)];
+ // what the wipe left: the address and a picture he re-uploaded by hand
+ const wiped = { lud16: '[email protected]', nip05: '[email protected]', picture: 'https://image.nostr.build/new.png' };
+ for (const u of ['wss://relay.coinos.io', 'wss://relay.primal.net', 'wss://purplepag.es', ARCHIVE]) (relayStore[u] ||= []).push(finalizeEvent({ kind: 0, created_at: 1793399187, tags: [], content: JSON.stringify(wiped) }, sk));
+ const ui = { profEdit: { uname: 'nicstazzema', name: '', about: '', picture: wiped.picture, banner: '' } };
+ const { feature, toasts } = makeFeature(sk, ui);
+ const versions = await feature.profileHistory();
+ check('both copies found', versions.length === 2, versions.map((e) => e.created_at).join(' '));
+ const older = versions.find((e) => /Nik/.test(e.content));
+ const n = feature.fillProfileFrom(older);
+ check('name and banner filled, the picture he chose kept', n === 2 && ui.profEdit.name === 'Nik' && ui.profEdit.banner === old.banner && ui.profEdit.picture === wiped.picture, JSON.stringify(ui.profEdit));
+ check('says what it filled', toasts.some((m) => /Filled 2 fields/.test(m)), toasts.join(' | '));
+ published.length = 0;
+ await feature.saveProfileDraft(ui.profEdit);
+ const saved = JSON.parse(kind0s().pop().ev.content);
+ check('Save publishes the name back with the address intact', saved.name === 'Nik' && saved.display_name === 'Nik' && saved.lud16 === wiped.lud16 && /new\.png/.test(saved.picture) && /cover/.test(saved.banner), JSON.stringify(saved));
+ check('newer than the wiped copy everywhere', newestOn('wss://relay.coinos.io', pk).created_at > 1793399187);
+}
+
+console.log(fails ? `\n❌ ${fails} failing` : '\n✅ profiles are never written over unseen');
+process.exit(fails ? 1 : 0);
diff --git a/tools/lightbox-scroll-test.js b/tools/lightbox-scroll-test.js
index be03e9b..f507557 100644
--- a/tools/lightbox-scroll-test.js
+++ b/tools/lightbox-scroll-test.js
@@ -6,7 +6,7 @@
// thread had been opened from, or to the top. Back out of a thread still
// lands on the row that opened it.
// Run: bun tools/lightbox-scroll-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -56,9 +56,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/link-preview-test.js b/tools/link-preview-test.js
index 52ddca1..190b220 100644
--- a/tools/link-preview-test.js
+++ b/tools/link-preview-test.js
@@ -4,7 +4,7 @@
// nothing to say stays a bare link; pictures and YouTube keep their own
// boxes.
// Run: bun tools/link-preview-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
@@ -80,9 +80,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
// follow the author, so the feed asks for their posts
diff --git a/tools/live-stream-test.js b/tools/live-stream-test.js
index 2be6051..56dcb71 100644
--- a/tools/live-stream-test.js
+++ b/tools/live-stream-test.js
@@ -6,7 +6,7 @@
// profile says they're on, Back stops the player, the browser without HLS
// of its own plays it through hls.js, and a zap carries the stream's `a`.
// Run: bun tools/live-stream-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { naddrEncode } from 'nostr-tools/nip19';
@@ -150,9 +150,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive');
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/ln-pay-double-wake.test.js b/tools/ln-pay-double-wake.test.js
new file mode 100644
index 0000000..c29d625
--- /dev/null
+++ b/tools/ln-pay-double-wake.test.js
@@ -0,0 +1,111 @@
+// Two wakes of the phone's worker, seconds apart, each over its own copy of
+// the wallet, both drive one Lightning send (PullCap, 2026-10-24 23:21). The
+// second's InitiateLightningPayment is refused — the HTLC is already 'ln-spent'
+// to this very payment — and that once marked a PAID send 'revoking'. Now the
+// send follows the payment instead, and a send in 'revoking' asks the ASP
+// before revoking: paid → settled; still in flight → back to waiting.
+// Run: bun test tools/ln-pay-double-wake.test.js
+import { test, expect, mock } from 'bun:test';
+import { hex } from '@scure/base';
+import { sha256 } from '@noble/hashes/sha256';
+import * as realLn from '../src/ark/lightning.js';
+import * as realProto from '../src/ark/proto.js';
+import * as realSend from '../src/ark/send.js';
+
+const PRE = 'ab'.repeat(32);
+const HASH = hex.encode(sha256(hex.decode(PRE)));
+const asp = { initiate: null, check: null, revoked: 0, checks: 0 };
+mock.module('../src/ark/lightning.js', () => ({
+ ...realLn,
+ initiateLightningPayment: async () => { if (asp.initiate) throw asp.initiate; },
+ checkLightningPayment: async (_u, _h, wait) => { asp.checks++; (asp.waits = asp.waits || []).push(!!wait); if (asp.check instanceof Error) throw asp.check; return asp.check; },
+ requestLightningPayHtlcRevocation: async () => { asp.revoked++; throw new Error('revocation should not be asked for'); },
+}));
+mock.module('../src/ark/proto.js', () => ({ ...realProto, decodeVtxo: () => ({ id: 'htlc1', point: { raw: new Uint8Array(36) } }) }));
+mock.module('../src/ark/send.js', () => ({ ...realSend, registerVtxoTransactions: async () => {},
+ buildArkoorSend: () => ({}), genUserNonces: () => ({}), cosignPartBytes: () => new Uint8Array(1) }));
+const { ArkManager } = await import('../src/ark/manager.js');
+const { GrpcError } = realProto;
+
+function mgrWith(step) {
+ const action = { id: 'lnpay-1700000000000', type: 'ln-pay', step, paymentHash: HASH, amountSat: 1, htlcExpiry: 1000, htlcBytesList: ['00'], htlcVtxoIds: ['htlc1'], changeBytesList: [], invoice: 'lnbc1' };
+ const m = Object.create(ArkManager.prototype);
+ const own = {
+ arkUrl: 'http://ark.test', chain: { tipHeight: async () => 900 },
+ state: { actions: [action], vtxos: [{ id: 'htlc1', state: 'pending', amountSat: 1 }], movements: [] },
+ _save: () => {}, _lnDriving: new Set(), _spentRetried: new Set(),
+ _key: () => ({ pubkey: new Uint8Array(33) }), _mailboxKey: () => ({ pubkey: new Uint8Array(33) }),
+ };
+ for (const [k, v] of Object.entries(own)) Object.defineProperty(m, k, { value: v, writable: true, configurable: true });
+ return { m, action };
+}
+
+test("a second wake's initiate refused as 'ln-spent' follows the payment to done", async () => {
+ Object.assign(asp, { initiate: new GrpcError(3, 'bad user input: vtxo b47f…:0 is not spendable (state: ln-spent)', 'bark_server.ArkService/InitiateLightningPayment'), check: { status: 'success', preimage: PRE }, revoked: 0 });
+ const { m, action } = mgrWith('cosigned');
+ await m._driveLnPay(action);
+ expect(action.step).toBe('done');
+ expect(action.preimage).toBe(PRE);
+ expect(asp.revoked).toBe(0);
+ expect(m.state.movements.filter((x) => x.type === 'ln-send' && x.status === 'complete').length).toBe(1);
+});
+
+test("a send in 'revoking' that the ASP paid settles, nothing revoked", async () => {
+ Object.assign(asp, { initiate: null, check: { status: 'success', preimage: PRE }, revoked: 0 });
+ const { m, action } = mgrWith('revoking');
+ await m._driveLnPay(action);
+ expect(action.step).toBe('done');
+ expect(asp.revoked).toBe(0);
+});
+
+test("a send in 'revoking' still in flight before its expiry goes back to waiting", async () => {
+ Object.assign(asp, { initiate: null, check: { status: 'pending' }, revoked: 0 });
+ const { m, action } = mgrWith('revoking');
+ await m._driveLnPay(action);
+ expect(action.step).toBe('initiated');
+ expect(asp.revoked).toBe(0);
+});
+
+test("a send the ASP never saw (not found) is still revoked", async () => {
+ Object.assign(asp, { initiate: null, check: new GrpcError(5, 'not found: payment attempt not found', 'x'), revoked: 0 });
+ const { m, action } = mgrWith('revoking');
+ await m._driveLnPay(action).catch(() => {});
+ expect(asp.revoked).toBe(1);
+});
+
+// A zap's wait is made of round trips (398ja, 2026-11-10: NWC zaps "a lot more
+// slower"). A send just handed to the ASP used to ask its status at once
+// ("pending"), fetch the chain tip to go with that, hold a call until it
+// settled, then ask once more for the preimage it had just been given: four
+// round trips where the one held call does.
+test('a fresh send: initiate, then the one held call settles it', async () => {
+ Object.assign(asp, { initiate: null, check: { status: 'success', preimage: PRE }, revoked: 0, checks: 0, waits: [] });
+ const { m, action } = mgrWith('cosigned');
+ let tips = 0;
+ m.chain = { tipHeight: async () => { tips++; return 900; } };
+ await m._driveLnPay(action);
+ expect(action.step).toBe('initiated'); // handed over, nothing asked yet
+ expect(asp.checks).toBe(0);
+ expect(tips).toBe(0);
+ const a = await m.awaitLnPay(action.id, 5000);
+ expect(a.step).toBe('done');
+ expect(a.preimage).toBe(PRE);
+ expect(asp.waits).toEqual([true]); // the held call alone
+ expect(m.state.movements.filter((x) => x.type === 'ln-send' && x.status === 'complete').length).toBe(1);
+});
+
+test('a held call that comes back failed is checked the ordinary way (and revoked)', async () => {
+ Object.assign(asp, { initiate: null, check: { status: 'failed' }, revoked: 0, checks: 0, waits: [] });
+ const { m, action } = mgrWith('initiated');
+ m._revokeLnPay = async (x) => { x.step = 'failed'; };
+ const a = await m.awaitLnPay(action.id, 5000);
+ expect(asp.waits[0]).toBe(true);
+ expect(a.step).toBe('failed');
+});
+
+test('a held call with a preimage that does not match the hash settles nothing', async () => {
+ Object.assign(asp, { initiate: null, check: { status: 'success', preimage: 'cd'.repeat(32) }, revoked: 0, checks: 0, waits: [] });
+ const { m, action } = mgrWith('initiated');
+ const a = await m.awaitLnPay(action.id, 5000);
+ expect(a.step).not.toBe('done');
+});
diff --git a/tools/lnurl-withdraw-links-test.js b/tools/lnurl-withdraw-links-test.js
new file mode 100644
index 0000000..b349e17
--- /dev/null
+++ b/tools/lnurl-withdraw-links-test.js
@@ -0,0 +1,166 @@
+// Prepaid withdraw links (src/features/withdraw-links.js + coinosv3-server
+// names/withdraw.js): create one from Send (it pays the registrar up front),
+// get an LNURL QR, have it claimed — here by our own LNURL-withdraw redeem
+// flow (zaps.js), the same road any Lightning wallet takes — and take back
+// what's left. The registrar is the real withdraw service behind a stand-in
+// HTTP server (NIP-98 checked) with a fake Lightning node; the Ark side is
+// faked through the hooks. Run: bun tools/lnurl-withdraw-links-test.js
+import assert from 'node:assert/strict';
+import { verifyEvent } from 'nostr-tools/pure';
+import puppeteer from './puppeteer.js';
+import { withdrawService } from '../../coinosv3-server/names/withdraw.js';
+
+const app = await Bun.file('src/app.js').text();
+const domHelper = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing'));
+const entry = `
+import { withdrawLinks } from './src/features/withdraw-links.js';
+import { zapsFeature } from './src/features/zaps.js';
+import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
+${domHelper}
+const sk = generateSecretKey();
+const ui = { screen: 'wallet', tab: 'send', giftMode: false, send: { recipients: [{ address: '', amount: '' }] } };
+let lnInvoices = 0; const watched = [];
+const wallet = { watchOnly: false, nostrSign: async (e) => finalizeEvent(e, sk), loadFeatureState: (k, d) => d, saveFeatureState: () => {} };
+const api = async (p) => (await fetch(p)).json();
+const ctx = {
+ h, ui, wallet, render, toast: (m) => (window.toasts = (window.toasts || [])).push(m),
+ withdrawRegistrar: location.origin,
+ copyBtn: (v, label) => h('button', { class: 'btn-sm', onClick: () => { window.copied = v; } }, label),
+ goBack: (fn) => { fn(); render(); },
+ blankSend: () => ({ recipients: [{ address: '', amount: '' }] }),
+ fmtAmount: (n) => Number(n).toLocaleString('en-US'), unitLabel: () => 'sats', unitTag: () => h('span', { class: 'unit-tag' }, 'sats'),
+ parseAmount: (v) => { const n = Number(String(v).replace(/,/g, '')); return isFinite(n) && n > 0 ? Math.round(n) : null; }, getUnit: () => 'sats',
+ amountField: (sat) => String(sat),
+ hook(name, ...a) {
+ if (name === 'arkReady' || name === 'canLnPay') return true;
+ if (name === 'arkSpendableSat') return window.spendable ?? 100000;
+ if (name === 'arkPayInvoice') { window.paidInvoices = (window.paidInvoices || 0) + 1; return api('/__pay?bolt11=' + a[0]); }
+ // a coinos wallet's Lightning receive lives on the registrar's own node
+ if (name === 'arkLnInvoice') { lnInvoices++; return api('/__mkinv?own=1&sat=' + a[0]).then((r) => ({ id: 'ln' + lnInvoices, invoice: r.bolt11, amountSat: a[0] })); }
+ if (name === 'arkLnWatch') { watched.push(a[0]); return true; }
+ if (name === 'arkLnUnwatch' || name === 'arkLnCancel') return true;
+ return null;
+ },
+};
+const wl = withdrawLinks(ctx);
+const zaps = zapsFeature(ctx);
+function render() {
+ const v = (ui.giftMode === 'lnurl' && wl.view()) || zaps.sendView() || h('div', { id: 'closed' }, wl.entry());
+ document.querySelector('#app').replaceChildren(v);
+}
+window.test = { ui, render, pk: getPublicKey(sk), redeem: (s) => zaps.matchSendText(s, false), watched };
+render();
+`;
+const bundle = await Bun.build({
+ entrypoints: ['wl-test-entry'], target: 'browser',
+ plugins: [{ name: 'wl-test', setup(build) {
+ build.onResolve({ filter: /^wl-test-entry$/ }, () => ({ path: 'entry', namespace: 'wl-test' }));
+ build.onLoad({ filter: /.*/, namespace: 'wl-test' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() }));
+ } }],
+});
+assert(bundle.success, bundle.logs.join('\n'));
+const css = await Bun.file('src/style.css').text();
+const js = await bundle.outputs[0].text();
+const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>${css}</style><div id="app"></div><script type="module">${js}</script>`;
+
+// ---- the stand-in registrar: the real service, a fake node ----
+const OUR = '02' + 'aa'.repeat(32), THEIRS = '03' + 'bb'.repeat(32);
+const invoices = {}, decoded = {}, outbound = {};
+let seq = 0, circular = 0;
+const mk = (sat, payee) => { const hash = (++seq).toString(16).padStart(64, '0'); const b = 'lnbc' + sat + 'n1fake' + seq; decoded[b] = { valid: true, payment_hash: hash, amount_msat: sat * 1000, payee, created_at: Math.floor(Date.now() / 1000), expiry: 3600 }; return { b, hash }; };
+const ln = { async call(m, p) {
+ if (m === 'invoice') { const { b, hash } = mk(p.amount_msat / 1000, OUR); invoices[hash] = { status: 'unpaid', bolt11: b }; return { bolt11: b, payment_hash: hash }; }
+ if (m === 'listinvoices') return { invoices: invoices[p.payment_hash] ? [invoices[p.payment_hash]] : [] };
+ if (m === 'decode') { if (!decoded[p.string]) throw new Error('bad'); return decoded[p.string]; }
+ if (m === 'pay') { const d = decoded[p.bolt11]; outbound[d.payment_hash] = 'complete'; return { status: 'complete', amount_msat: d.amount_msat, amount_sent_msat: d.amount_msat }; }
+ if (m === 'waitsendpay') { outbound[p.payment_hash] = 'complete'; return { status: 'complete' }; }
+ if (m === 'listsendpays') return { payments: outbound[p.payment_hash] ? [{ status: outbound[p.payment_hash] }] : [] };
+ throw new Error('unexpected ' + m);
+} };
+const state = {};
+let svc; // made once the server has its port: the service's URLs are built on it
+const nip98 = async (req, url, body) => {
+ const evt = JSON.parse(atob((req.headers.get('authorization') || '').replace(/^Nostr /, '')));
+ assert(verifyEvent(evt) && evt.kind === 27235 && new URL(evt.tags.find((t) => t[0] === 'u')[1]).pathname === url.pathname);
+ return evt.pubkey;
+};
+const server = Bun.serve({ port: 0, async fetch(req) {
+ const u = new URL(req.url);
+ const j = (b, s = 200) => Response.json(b, { status: s });
+ if (u.pathname === '/__pay') { const d = decoded[u.searchParams.get('bolt11')]; invoices[d.payment_hash].status = 'paid'; return j({ ok: true }); }
+ if (u.pathname === '/__mkinv') { const { b } = mk(Number(u.searchParams.get('sat')), u.searchParams.get('own') ? OUR : THEIRS); return j({ bolt11: b }); }
+ const w = u.pathname.match(/^\/w\/([A-Za-z0-9_-]+)(\/cb)?$/);
+ if (w) return j(w[2] ? await svc.callback(w[1], u.searchParams.get('k1'), u.searchParams.get('pr')) : svc.params(w[1]));
+ if (u.pathname.startsWith('/withdraw/')) {
+ const body = req.method === 'POST' ? await req.text() : '';
+ const pk = await nip98(req, u, body);
+ const b = body ? JSON.parse(body) : {};
+ try {
+ if (u.pathname === '/withdraw/mine') return j({ links: svc.mine(pk) });
+ if (u.pathname === '/withdraw/new') return j(await svc.create({ pubkey: pk, ...b }));
+ if (u.pathname === '/withdraw/funded') return j(await svc.confirmFunded(b.id, pk));
+ if (u.pathname === '/withdraw/reclaim') return j(await svc.reclaim(b.id, pk, b.invoice));
+ } catch (e) { return j({ error: e.message }, e.status || 500); }
+ }
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+svc = withdrawService({ state, persist: () => {}, lnFor: () => ln, ourNodeId: async () => OUR,
+ circularPick: async () => ({ cap: 1e9 }), payCircular: async () => { circular++; return { feeMsat: 0 }; }, publicBase: server.url.origin.replace(/\/$/, '') });
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errors = [];
+page.on('pageerror', (e) => errors.push(e.message));
+const pause = (ms) => new Promise((r) => setTimeout(r, ms));
+const check = (name, value, detail = '') => { assert(value, name + (detail ? ' — ' + detail : '')); console.log(' ✓ ' + name); };
+const text = () => page.evaluate(() => document.body.innerText.replace(/\s+/g, ' '));
+const waitText = async (re, ms = 6000) => { for (let i = 0; i < ms / 100; i++) { if (re.test(await text())) return true; await pause(100); } return false; };
+const click = (label) => page.evaluate((x) => { const b = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().startsWith(x)); if (!b) return false; b.click(); return true; }, label);
+const type = (sel, v) => page.evaluate(([s, val]) => { const i = document.querySelector(s); i.value = val; i.dispatchEvent(new Event('input', { bubbles: true })); }, [sel, v]);
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href);
+ await page.waitForSelector('#wl-open');
+ console.log('[making a link]');
+ await page.click('#wl-open');
+ check('Send opens the withdraw-link page', await waitText(/A Lightning link or QR code that anyone can withdraw sats from/));
+ await page.click('#wl-create');
+ check('no amount: asked for one, nothing paid', await waitText(/Enter an amount/) && !(await page.evaluate(() => window.paidInvoices)));
+ await type('#wl-amount', '500'); await type('#wl-uses', '3');
+ check('the button and the line say what gets paid now', /Create link for 1,500 sats/.test(await text()) && /1,500 sats paid now from Spending: 3 withdrawals of up to 500 sats/.test(await text()), await text());
+ await page.evaluate(() => { window.spendable = 1000; });
+ await page.click('#wl-create');
+ check('more than Spending holds: refused before paying', await waitText(/Not enough in Spending for 1,500 sats/) && !(await page.evaluate(() => window.paidInvoices)));
+ await page.evaluate(() => { window.spendable = 100000; });
+ await type('textarea', 'Meetup door prize');
+ await page.click('#wl-create');
+ check('it pays the registrar once and shows the link', await waitText(/Your withdraw link is ready/) && (await page.evaluate(() => window.paidInvoices)) === 1, await text());
+ check('…with a QR, the terms and the note', !!(await page.$('svg')) && /Up to 500 sats each, 3 of 3 withdrawals left/.test(await text()) && /Meetup door prize/.test(await text()), await text());
+ const lnurl = await page.evaluate(() => [...document.querySelectorAll('.addr-box')].map((e) => e.textContent.trim())[0]);
+ check('the link is an LNURL', /^LNURL1[0-9A-Z]+$/.test(lnurl), lnurl);
+ const link = Object.values(state.withdraws)[0];
+ check('the registrar holds it funded', link.status === 'open' && link.totalSat === 1500 && link.pubkey === (await page.evaluate(() => test.pk)));
+
+ console.log('\n[claiming it with a Lightning wallet: ours]');
+ await click('Back');
+ await page.evaluate(() => { test.ui.giftMode = false; });
+ check('the redeem flow takes the LNURL', await page.evaluate((s) => test.redeem(s), lnurl));
+ check('…and reads the link\'s terms', await waitText(/will pay you between 1 and 500 sats/) && /Meetup door prize/.test(await text()), await text());
+ await click('Withdraw');
+ check('the claim is paid out of the link', await waitText(/has your invoice for 500 sats/) && (await (async () => { await pause(100); return link.claims[0]?.status === 'paid'; })()), JSON.stringify(link.claims));
+ check('…round the circular route (a coinos wallet\'s own invoice)', circular === 1);
+
+ console.log('\n[taking back the rest]');
+ await page.evaluate(() => { test.ui.lnWithdraw = null; test.ui.giftMode = false; test.render(); });
+ await page.evaluate(() => document.querySelector('#wl-open').click());
+ check('the list shows what is left', await waitText(/Your withdraw links/) && /1,000 sats left/.test(await text()), await text());
+ await page.evaluate(() => document.querySelector('.wl-row').click());
+ check('the link page shows 2 of 3 left', await waitText(/2 of 3 withdrawals left/));
+ await click('Take back 1,000 sats');
+ check('take back pays the remainder to the wallet and closes the link', await waitText(/This link is closed/) && link.status === 'closed' && link.spentSat === 1500, JSON.stringify(link));
+ check('…and the wallet watched for that payment', (await page.evaluate(() => test.watched.length)) >= 1);
+ check('…the toast says so', (await page.evaluate(() => window.toasts || [])).some((m) => /1,000 sats is back in Spending/.test(m)));
+ check('a closed link refuses claims', svc.params(link.id).status === 'ERROR');
+ check('no page errors', errors.length === 0, errors.join('; '));
+ console.log('\n✅ prepaid withdraw links');
+} catch (e) { console.error('\n❌', e.message); process.exitCode = 1; } finally { await browser.close(); server.stop(true); }
diff --git a/tools/lnurl-withdraw-test.js b/tools/lnurl-withdraw-test.js
index cc1b152..0241f31 100644
--- a/tools/lnurl-withdraw-test.js
+++ b/tools/lnurl-withdraw-test.js
@@ -22,7 +22,7 @@ const ctx = {
parseAmount: (v) => { const n = Number(String(v).replace(/,/g, '')); return isFinite(n) ? Math.round(n) : null; }, getUnit: () => 'sats',
amountField: (sat) => String(sat),
hook(name, ...a) {
- if (name === 'canLnPay') return true;
+ if (name === 'canLnPay') return !window.noPay;
if (name === 'arkLnInvoice') { invoices++; return Promise.resolve({ id: 'ln' + invoices, invoice: 'lnbc' + a[0] + 'n1testinvoice' + invoices, amountSat: a[0] }); }
if (name === 'arkLnWatch') { watch = { id: a[0], cb: a[1] }; return true; }
if (name === 'arkLnUnwatch') { if (watch && watch.id === a[0]) watch = null; return true; }
@@ -94,6 +94,13 @@ try {
await click('Back'); await pause(50);
check('Back before it is paid stops watching', (await page.evaluate(() => test.watch)) === null && !!(await page.$('#closed')));
+ console.log('[scanned while Spending is still connecting]');
+ await page.evaluate(() => { window.noPay = true; });
+ check('a withdraw lnurl is still taken (it pays us; nothing to spend)', await page.evaluate((s) => test.paste(s), lnurl('/withdraw')));
+ check('…and opens as a withdraw, not a zap', await waitText(/Withdraw over Lightning/) && !/Zap/.test(await text()), await text());
+ await click('Back'); await pause(50);
+ await page.evaluate(() => { window.noPay = false; });
+
console.log('[a site that refuses]');
await page.evaluate((s) => test.paste(s), lnurl('/withdraw-spent'));
await waitText(/will pay you 1 sats/); await click('Withdraw');
diff --git a/tools/media-grid-dups-test.js b/tools/media-grid-dups-test.js
index e7b67da..1a00222 100644
--- a/tools/media-grid-dups-test.js
+++ b/tools/media-grid-dups-test.js
@@ -5,7 +5,7 @@
// that won't load leaves the Images grid instead of a broken-picture glyph. And on a big screen with a mouse, a
// small clip opened in the pager is drawn at most 1.5× its own pixels, not
// blown up across the monitor. Run: bun tools/media-grid-dups-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -92,9 +92,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/media-grid-order-test.js b/tools/media-grid-order-test.js
index 9274d92..0e333f0 100644
--- a/tools/media-grid-order-test.js
+++ b/tools/media-grid-order-test.js
@@ -3,7 +3,7 @@
// jumped around): tiles found later go under the ones already showing, in
// their own date order, even when they are dated between them; only a post
// newer than the top tile goes above it. Run: bun tools/media-grid-order-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -97,9 +97,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const key = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk]) => {
diff --git a/tools/media-moments-feed-test.js b/tools/media-moments-feed-test.js
index a188c5d..5b4c8fb 100644
--- a/tools/media-moments-feed-test.js
+++ b/tools/media-moments-feed-test.js
@@ -1,6 +1,6 @@
// Full feed integration with local media and relay acknowledgements; no events leave the test.
// Run: bun tools/media-moments-feed-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -111,9 +111,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const key = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
@@ -175,6 +175,18 @@ try {
check('...and shows over the pager\'s seek bar', await page.waitForFunction(() => !!document.querySelector('.media-pager .mp-cur .mp-seek .mp-mark'), { timeout: 3000 }).then(() => true, () => false));
check('no timeline panel over the pager', await page.evaluate(() => !document.querySelector('.media-pager .media-timeline')));
await page.screenshot({ path: '/tmp/media-timeline-pager.png' });
+ // back in the feed: the clip has a moment now, so the moments bar is its
+ // only seek bar (no player controls), with the middle button to play it
+ await page.keyboard.press('Escape'); await sleep(500);
+ await page.evaluate(() => { const v = document.querySelector('video.note-video'); v.pause(); v.dispatchEvent(new Event('timeupdate')); });
+ await sleep(200);
+ const one = await page.evaluate(() => { const v = document.querySelector('video.note-video'); const b = v.closest('.note-video-box'); const tl = b.querySelector('.media-timeline');
+ return { controls: v.controls, own: b.classList.contains('own-bar'), bar: !!tl && getComputedStyle(tl).display !== 'none', mark: !!b.querySelector('.media-mark'), pp: getComputedStyle(b.querySelector('.vid-pp')).display }; });
+ check('in the feed, a clip with a moment has one seek bar: the moments bar, no player controls', !one.controls && one.own && one.bar && one.mark, JSON.stringify(one));
+ check('...and the middle button plays it, on a touch screen too', one.pp === 'flex', JSON.stringify(one));
+ const vid2 = await page.evaluate(() => { const b = document.querySelector('.note-video-box .vid-pp').getBoundingClientRect(); return { x: b.x + b.width / 2, y: b.y + b.height / 2 }; });
+ await page.mouse.click(vid2.x, vid2.y); await sleep(600);
+ check('...which plays it in place', await page.evaluate(() => !document.querySelector('video.note-video').paused && !document.querySelector('.media-pager')));
check('no browser exceptions', errs.length === 0, errs.join(' | '));
await page.screenshot({ path: '/tmp/media-timeline-feed.png' });
} finally { await browser.close(); server.stop(); }
diff --git a/tools/mention-ac-test.js b/tools/mention-ac-test.js
index 52ba1ef..e476e61 100644
--- a/tools/mention-ac-test.js
+++ b/tools/mention-ac-test.js
@@ -2,7 +2,7 @@
// people — the ones you follow first, then the search — and picking one puts
// nostr:npub… in the text; the post goes out with their p tag.
// Run: bun tools/mention-ac-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
import { npubEncode } from 'nostr-tools/nip19';
@@ -59,9 +59,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
await sleep(3000); // a new wallet's starter follows land first; ours go over them
const base = cacheKeyFor(mn + '\n');
diff --git a/tools/moderation-test.js b/tools/moderation-test.js
index 426115c..8856864 100644
--- a/tools/moderation-test.js
+++ b/tools/moderation-test.js
@@ -5,7 +5,7 @@
// are hidden; Settings → Nostr lays the mute list out and edits it.
// No relay is written for real — the WebSocket shim swallows EVENT.
// Run: bun tools/moderation-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
@@ -114,9 +114,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
const ME_SK = privateKeyFromSeedWords(mn);
diff --git a/tools/names-listening-test.js b/tools/names-listening-test.js
new file mode 100644
index 0000000..acb84e2
--- /dev/null
+++ b/tools/names-listening-test.js
@@ -0,0 +1,70 @@
+// A wallet on another ark server tells the registrar it is listening once
+// its offer listener is up, so Lightning receives queued for its name go
+// out now instead of on the hourly retry. A wallet on the coinos server
+// never does (it is paid while asleep). Run: bun tools/names-listening-test.js
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.js';
+
+const app = await Bun.file('src/app.js').text();
+const domHelper = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing'));
+const entry = `
+import { namesFeature } from './src/features/names.js';
+import { setArkProviderId, getArkProviderId, getNetwork } from './src/api.js';
+${domHelper}
+const state = { names: { byDomain: { 'coinos.io': { name: 'manna', domain: 'coinos.io' } } } };
+const ui = { screen: 'wallet' };
+const wallet = { loadFeatureState: (k, d) => (k in state ? state[k] : d), saveFeatureState: (k, v) => { state[k] = v; }, nostrSign: async (e) => ({ ...e, id: 'ab'.repeat(32), sig: 'cd'.repeat(64), pubkey: 'aa'.repeat(32) }), nostrPubkey: () => 'aa'.repeat(32), _cacheKey: () => 'w1' };
+const ctx = { h, ui, wallet, render: () => {}, toast: () => {}, copy: () => {}, copyBtn: () => null, brandHeader: () => null, goBack: () => {}, setAccount: () => {}, hook: (name) => (name === 'arkReady' ? true : null) };
+const feature = namesFeature(ctx);
+window.test = { listening: () => feature.namesListening(), server: (id) => setArkProviderId(id), dbg: () => ({ net: getNetwork(), prov: getArkProviderId(), avail: feature.namesAvailable() }) };
+`;
+const bundle = await Bun.build({
+ entrypoints: ['names-listen-entry'], target: 'browser',
+ plugins: [{ name: 'names-listen', setup(build) {
+ build.onResolve({ filter: /^names-listen-entry$/ }, () => ({ path: 'entry', namespace: 'names-listen' }));
+ build.onLoad({ filter: /.*/, namespace: 'names-listen' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() }));
+ } }],
+});
+assert(bundle.success, bundle.logs.join('\n'));
+const html = `<!doctype html><meta charset="utf-8"><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`;
+const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errors = [];
+page.on('pageerror', (e) => errors.push(e.message));
+// the registrar, faked: every call recorded, answered as told
+const calls = [];
+let answer = { status: 200, body: '{"waiting":1,"sat":52}' };
+await page.setRequestInterception(true);
+page.on('request', (r) => {
+ if (!r.url().startsWith('https://names.coinos.io/')) return r.continue();
+ const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*', 'access-control-allow-methods': '*' };
+ if (r.method() === 'OPTIONS') return r.respond({ status: 204, headers: cors });
+ calls.push({ method: r.method(), path: new URL(r.url()).pathname, auth: r.headers().authorization || '' });
+ r.respond({ ...answer, contentType: 'application/json', headers: cors });
+});
+const check = (name, value, detail = '') => { assert(value, name + (detail ? ' — ' + detail : '')); console.log(' ✓ ' + name); };
+const listening = () => page.evaluate(() => test.listening());
+try {
+ await page.goto(server.url.href);
+ await page.waitForFunction(() => !!window.test);
+ check('a wallet on the coinos server says nothing', (await listening()) === null && !calls.length);
+ await page.evaluate(() => test.server('second'));
+ const r = await listening();
+ const posts = () => calls.filter((c) => c.method === 'POST');
+ check('on Second it tells the registrar it is listening', posts().length === 1 && posts()[0].path === '/listening', JSON.stringify(calls));
+ check('...as itself (NIP-98)', /^Nostr /.test(posts()[0].auth));
+ check('...and hears what is waiting', r && r.waiting === 1 && r.sat === 52, JSON.stringify(r));
+ check('a second listen within ten minutes is not another call', (await listening()) === null && posts().length === 1);
+ await page.reload(); await page.waitForFunction(() => !!window.test);
+ answer = { status: 500, body: '{"error":"down"}' };
+ const down = await listening();
+ check('a registrar that is down is quietly left', down === null && posts().length === 2, JSON.stringify({ down, calls }));
+ answer = { status: 200, body: '{"waiting":0,"sat":0}' };
+ check('...and asked again next time, not in ten minutes', (await listening())?.waiting === 0 && posts().length === 3);
+ check('no page errors', !errors.length, errors.join(' | '));
+ console.log('\n✅ names: a wallet on another ark server says it is listening');
+} finally {
+ await browser.close();
+ server.stop(true);
+}
diff --git a/tools/negentropy-sync-test.js b/tools/negentropy-sync-test.js
index e0257ca..c7adb84 100644
--- a/tools/negentropy-sync-test.js
+++ b/tools/negentropy-sync-test.js
@@ -8,7 +8,7 @@
// backlog, and is remembered as such for a day: the next ask there goes
// straight out as a REQ.
// Run: bun tools/negentropy-sync-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
import { buildHtml } from '../build.js';
@@ -48,9 +48,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const ME = getPublicKey(privateKeyFromSeedWords(mn));
const MINE = finalizeEvent({ kind: 1, created_at: now - 600, tags: [], content: 'my post, replied to' }, FRIEND.sk);
diff --git a/tools/nip29-group-test.js b/tools/nip29-group-test.js
new file mode 100644
index 0000000..2ff1192
--- /dev/null
+++ b/tools/nip29-group-test.js
@@ -0,0 +1,102 @@
+// NIP-29 relay groups in Messages: New group → Relay group → paste an
+// address → Join. Reads the real "pyramid runners" group off
+// pyramid.fiatjaf.com (relays: 'read' — nothing published leaves the page:
+// the join request, the kind 10009 list and the test message are answered
+// OK in place and checked here).
+// Run: bun tools/nip29-group-test.js
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const ADDR = process.env.GROUP || "https://chachi.chat/pyramid.fiatjaf.com/Tnq7x2ZTgrPZWFrC";
+const GID = 'Tnq7x2ZTgrPZWFrC';
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const browser = await puppeteer.launch({ relays: 'read', executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errs = [];
+page.on('console', (m) => { if (/nip29/.test(m.text())) console.log(' [page]', m.text()); });
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase() === x); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const published = () => page.evaluate(() => window.__relayGuard.events);
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((n) => /create a new wallet/i.test(n.textContent))?.click()); await sleep(600);
+ await seedWords(page);
+ await skipSeedCheck(page);
+ await waitText('receive', 20000);
+ await page.click('[aria-label="Messages"]');
+ await waitText('new group', 10000);
+ await click('new group'); await sleep(300);
+ check('New group offers a relay group', await click('relay group'));
+ await sleep(200);
+ await page.type('.nip29-address', 'not an address');
+ await click('join'); await sleep(500);
+ check('a bad address is refused', await waitText("isn't a group address", 3000));
+ await page.evaluate(() => { const i = document.querySelector('.nip29-address'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); });
+ await page.type('.nip29-address', ADDR);
+ await click('join');
+ check('the group opens, named by its relay', await waitText('pyramid runners', 20000));
+ check('its messages arrive', await waitText('paff.club', 20000));
+ const evs = await published();
+ const join = evs.find((e) => e.kind === 9021);
+ check('a join request went to the group', !!join && join.tags.some((x) => x[0] === 'h' && x[1] === GID));
+ // the 10009 write reads the newest list first, everywhere (a first write)
+ let list = null;
+ for (let i = 0; i < 80 && !list; i++) { list = (await published()).filter((e) => e.kind === 10009).pop(); if (!list) await sleep(250); }
+ check('the group is added to the kind 10009 list', !!list && list.tags.some((x) => x[0] === 'group' && x[1] === GID && /pyramid\.fiatjaf\.com/.test(x[2])), JSON.stringify(list?.tags));
+
+ // say something (answered OK in place, never sent)
+ await page.click('#msg-draft');
+ await page.type('#msg-draft', 'hello from the nip29 test');
+ await page.keyboard.press('Enter');
+ await sleep(1500);
+ const msg = (await published()).find((e) => e.kind === 9 && e.content === 'hello from the nip29 test');
+ check('a message is a kind 9 with the group h tag', !!msg && msg.tags.some((x) => x[0] === 'h' && x[1] === GID), JSON.stringify(msg?.tags));
+ check('...and previous tags from the timeline', !!msg && msg.tags.some((x) => x[0] === 'previous' && /^[0-9a-f]{8}$/.test(x[1])));
+ check('it shows in the group', await waitText('hello from the nip29 test', 3000));
+
+ // members: the relay's list
+ await page.evaluate(() => document.querySelector('.chat-head .clickable')?.click()); await sleep(800);
+ check('the member panel says it is a relay group', await waitText('not encrypted', 3000));
+ const n = await page.evaluate(() => document.querySelectorAll('.chat-members .chat-thread-row').length);
+ check('members come from the relay', n > 10, n + ' members');
+
+ // back home: a row of its own; reload keeps it
+ await page.evaluate(() => document.querySelector('.chat-head button')?.click()); await sleep(600);
+ check('the group is listed in Messages', await page.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].some((r) => r.textContent.includes('pyramid runners'))));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.click('[aria-label="Messages"]');
+ let kept = false;
+ for (let i = 0; i < 40 && !kept; i++) { kept = await page.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].some((r) => r.textContent.includes('pyramid runners'))); if (!kept) await sleep(250); }
+ check('...and still is after a reload', kept);
+
+ // leave: two taps, a 9022 to the relay, and off the list
+ await page.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].find((r) => r.textContent.includes('pyramid runners'))?.click()); await sleep(800);
+ await page.click('[aria-label="Leave group"]'); await sleep(200);
+ await page.click('[aria-label="Leave group"]');
+ let left = null, relist = null;
+ for (let i = 0; i < 80 && !(left && relist); i++) {
+ const evs = await published();
+ left = evs.find((e) => e.kind === 9022);
+ relist = evs.filter((e) => e.kind === 10009).find((e) => !e.tags.some((x) => x[0] === 'group'));
+ if (!(left && relist)) await sleep(250);
+ }
+ check('leaving tells the relay (9022)', !!left && left.tags.some((x) => x[0] === 'h' && x[1] === GID));
+ check('...and takes it off the 10009 list', !!relist);
+ check('...and out of Messages', !(await page.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].some((r) => r.textContent.includes('pyramid runners')))));
+ check('no page errors', !errs.length, errs.join(' | '));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close(); server.stop();
+}
+console.log(ok ? '\nPASS' : '\nFAIL');
+process.exit(ok ? 0 : 1);
diff --git a/tools/notif-app-zap-test.js b/tools/notif-app-zap-test.js
new file mode 100644
index 0000000..3ee2030
--- /dev/null
+++ b/tools/notif-app-zap-test.js
@@ -0,0 +1,109 @@
+// Notifications: a zap on your Zapstore app listing (kind 32267) reads
+// "zapped your app PAFF" and opens the app's Zapstore page. It opened the
+// zapper's profile: Zapstore's receipt carries no a tag (only the zap request
+// inside it does), so the row looked like a zap on nothing (Adam, 2026-11-07:
+// "clicking this franzap zap notification keeps bringing me to his profile").
+// A row saved before reads the receipt once on tap and goes there too.
+// Run: bun tools/notif-app-zap-test.js
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const A = generateSecretKey(), B = generateSecretKey();
+const now = Math.floor(Date.now() / 1000);
+const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5317, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.endsWith('.png')) return new Response(PNG, { headers: { 'content-type': 'image/png' } });
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5317/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await seedWords(page);
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await skipSeedCheck(page);
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ const ME = getPublicKey(privateKeyFromSeedWords(mn));
+ const APP = '32267:' + 'c'.repeat(64) + ':club.paff.app';
+ const zap = (sk, sats, ts, content) => {
+ const req = finalizeEvent({ kind: 9734, created_at: ts, content, tags: [['p', ME], ['amount', String(sats * 1000)], ['a', APP]] }, sk);
+ // the receipt Zapstore's LNURL server publishes: p and P, no a tag
+ return finalizeEvent({ kind: 9735, created_at: ts, content: '', tags: [['p', ME], ['P', req.pubkey], ['amount', String(sats * 1000)], ['description', JSON.stringify(req)]] }, generateSecretKey());
+ };
+ const OLD = zap(A, 10000, now - 120, 'Love it'); // saved by the old code, without the app
+ const NEW = zap(B, 2100, now - 60, 'great app');
+ const LISTING = finalizeEvent({ kind: 32267, created_at: now - 3600, content: '', tags: [['d', 'club.paff.app'], ['name', 'PAFF']] }, generateSecretKey());
+ await page.evaluateOnNewDocument((evs, listing) => {
+ window.__opened = [];
+ window.open = (u) => { window.__opened.push(u); return null; };
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ') {
+ const fs = m.slice(2);
+ const hit = [
+ // the notifications sync only hands the NEW one; the old row is already held
+ ...evs.slice(1).filter(() => fs.some((f) => (f.kinds || []).includes(9735) && (f['#p'] || []).length)),
+ ...evs.filter((ev) => fs.some((f) => (f.ids || []).includes(ev.id))),
+ ...(fs.some((f) => (f.kinds || []).includes(32267)) ? [listing] : []),
+ ];
+ setTimeout(() => {
+ for (const e of hit) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], e]) }));
+ ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) }));
+ }, 0);
+ return;
+ }
+ if (m[0] === 'EVENT' || m[0] === 'NEG-OPEN') return;
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+ }, [OLD, NEW], LISTING);
+ await page.evaluate(([k, me, old]) => {
+ const st = JSON.parse(localStorage.getItem(k + ':messages') || '{}');
+ st.notifs = [{ id: old.id, what: 'zap', actor: JSON.parse(old.tags.find((t) => t[0] === 'description')[1]).pubkey, target: null, stream: null, hint: [], sats: 10000, text: 'Love it', ts: old.created_at }];
+ st.notifsPk = me; st.notifsV = 2;
+ localStorage.setItem(k + ':messages', JSON.stringify(st));
+ }, [base, ME, OLD]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Notifications"]')?.click());
+ console.log('\n[a zap on your app]');
+ check('a new one reads "zapped your app PAFF"', await waitText('zapped your app paff 2.1k sats', 15000),
+ await page.evaluate(() => [...document.querySelectorAll('.alert-row')].map((r) => r.innerText.replace(/\s+/g, ' ')).join(' | ')));
+ await page.evaluate(() => [...document.querySelectorAll('.alert-row')].find((r) => /great app/.test(r.innerText))?.click());
+ await sleep(300);
+ check('...and opens its Zapstore page', (await page.evaluate(() => window.__opened)).includes('https://zapstore.dev/apps/club.paff.app'), JSON.stringify(await page.evaluate(() => window.__opened)));
+ await page.evaluate(() => { window.__opened = []; [...document.querySelectorAll('.alert-row')].find((r) => /Love it/.test(r.innerText))?.click(); });
+ for (let i = 0; i < 40 && !(await page.evaluate(() => window.__opened.length)); i++) await sleep(250);
+ check('a row saved before reads its receipt on tap and opens the app too', (await page.evaluate(() => window.__opened)).includes('https://zapstore.dev/apps/club.paff.app'), JSON.stringify(await page.evaluate(() => window.__opened)));
+ check('...and is labelled as an app zap from then on', await waitText('zapped your app paff 10k sats', 5000));
+ check('still in Notifications, not a profile', await page.evaluate(() => !!document.querySelector('.alert-row')));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ zaps on your app open the app' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/notif-categories-test.js b/tools/notif-categories-test.js
index f38dd01..8bd0da1 100644
--- a/tools/notif-categories-test.js
+++ b/tools/notif-categories-test.js
@@ -1,7 +1,7 @@
// Settings → Notifications lists zaps and reactions as their own categories,
// each with its own switch. Run: bun tools/notif-categories-test.js
import assert from 'node:assert/strict';
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { buildHtml } from '../build.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const html = await buildHtml({ minify: true, pwa: false });
@@ -19,7 +19,7 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload(); await sleep(500);
await click('create a new wallet'); await sleep(600);
- await click('skip verification'); await sleep(300);
+ await skipSeedCheck(page); await sleep(300);
await page.waitForFunction(() => document.body.innerText.toLowerCase().includes('receive'), { timeout: 20000 });
await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings').click()); await sleep(600);
await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find((e) => /notification/i.test(e.textContent)).click()); await sleep(600);
diff --git a/tools/notif-custom-emoji-test.js b/tools/notif-custom-emoji-test.js
index 5847a0c..b1ff564 100644
--- a/tools/notif-custom-emoji-test.js
+++ b/tools/notif-custom-emoji-test.js
@@ -3,7 +3,7 @@
// :peepoClap: text, because the row kept the content and not the tag.
// A row saved before that is healed when the relay hands the reaction again.
// Run: bun tools/notif-custom-emoji-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
@@ -34,9 +34,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
const ME = getPublicKey(privateKeyFromSeedWords(mn));
diff --git a/tools/notif-faces-test.js b/tools/notif-faces-test.js
index a1c1c8f..0aa7a92 100644
--- a/tools/notif-faces-test.js
+++ b/tools/notif-faces-test.js
@@ -3,7 +3,7 @@
// opens with faces, not circles filling in. The stub relay answers the kind-0
// batch for the actors with pictures served by this test.
// Run: bun tools/notif-faces-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
@@ -65,9 +65,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
const ME = getPublicKey(privateKeyFromSeedWords(mn));
diff --git a/tools/notif-note-cache-test.js b/tools/notif-note-cache-test.js
index e24c2e2..2b8be67 100644
--- a/tools/notif-note-cache-test.js
+++ b/tools/notif-note-cache-test.js
@@ -1,7 +1,7 @@
// Referenced notification notes are fetched before the page opens, persisted,
// and available on the first paint after a reload.
// Run: bun tools/notif-note-cache-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
@@ -84,9 +84,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await clickText('create a new wallet');
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mnemonic = (await page.$$eval('.words .w .t', (nodes) => nodes.map((node) => node.textContent.trim()))).join(' ');
- await clickText('skip verification');
+ await skipSeedCheck(page);
await waitText('receive');
const base = cacheKeyFor(mnemonic + '\n');
const me = getPublicKey(privateKeyFromSeedWords(mnemonic));
diff --git a/tools/notif-report-fold-test.js b/tools/notif-report-fold-test.js
index 678aefd..6a906ec 100644
--- a/tools/notif-report-fold-test.js
+++ b/tools/notif-report-fold-test.js
@@ -4,7 +4,7 @@
// (Adam's phone, 2026-10-14: a reply Amethyst showed was missing here). A
// person one follow flagged, or a stranger nobody did, shows as ever.
// Run: bun tools/notif-report-fold-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
@@ -36,9 +36,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
const ME = getPublicKey(privateKeyFromSeedWords(mn));
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/nwc-settings-ui-test.js b/tools/nwc-settings-ui-test.js
index b88f9f7..ea52a6c 100644
--- a/tools/nwc-settings-ui-test.js
+++ b/tools/nwc-settings-ui-test.js
@@ -2,7 +2,7 @@
// budget that resets every day, week or month; the card says so, and
// Revoke keeps it (greyed out of the list) answering for a while.
// Run: bun tools/nwc-settings-ui-test.js (needs the regtest ark stack on :3535 for the card to show)
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { buildHtml } from '../build.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
@@ -47,8 +47,8 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
- await click('skip verification');
+ await seedWords(page);
+ await skipSeedCheck(page);
await waitText('receive', 20000);
await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings'); b.click(); }); await sleep(600);
await page.evaluate(() => { const b = [...document.querySelectorAll('.settings-tile')].find((e) => [...e.querySelectorAll('span')].some((x) => x.textContent.trim() === 'Nostr')); if (b) b.click(); }); await sleep(800);
diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js
index 1ce9944..18528a2 100644
--- a/tools/nwc-sw-test.js
+++ b/tools/nwc-sw-test.js
@@ -5,7 +5,8 @@ import { hex } from '@scure/base';
import * as nip04 from 'nostr-tools/nip04';
import * as nip44 from 'nostr-tools/nip44';
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
-import { respondFromBg } from '../src/nwc-respond.js';
+import { respondFromBg, tendLnPays } from '../src/nwc-respond.js';
+import { maybeBolt11 } from '../src/ark/lightning.js';
let fails = 0;
const check = (name, ok, detail = '') => {
@@ -46,13 +47,21 @@ function makeReq(method, params = {}, scheme = 'nip44_v2', ageSec = 0) {
function harness({ rec = baseRec(), recs = null, answered = false } = {}) {
const published = [];
+ const declined = []; // wait replies left with the notifier (sent only if nobody answers)
const fetchFn = async (url, opts) => {
if (url.includes('/publish')) {
published.push(JSON.parse(opts.body).event);
return { ok: true, json: async () => ({ ok: true }) };
}
+ if (url.includes('/decline')) {
+ declined.push(JSON.parse(opts.body).event);
+ return { ok: true, json: async () => ({ ok: true, scheduled: true }) };
+ }
if (url.includes('/claim')) {
- return { ok: true, json: async () => ({ ok: true }) };
+ // as the notifier does: a request already replied to can't be claimed
+ const was = typeof answered === 'function' ? answered() : answered;
+ if (JSON.parse(opts.body).release) return { ok: true, json: async () => ({ ok: true, released: true }) };
+ return { ok: true, json: async () => (was ? { ok: false, answered: true } : { ok: true }) };
}
if (url.includes('/answered')) {
return { ok: true, json: async () => ({ answered: typeof answered === 'function' ? answered() : answered }) };
@@ -72,7 +81,7 @@ function harness({ rec = baseRec(), recs = null, answered = false } = {}) {
? nip44.decrypt(ev.content, nip44.getConversationKey(clientSk, conn.servicePk))
: nip04.decrypt(clientSk, conn.servicePk, ev.content));
};
- return { deps, published, decryptReply };
+ return { deps, published, declined, decryptReply };
}
const ev = (e) => JSON.parse(JSON.stringify(e)); // strip verified symbol like a push payload would
@@ -118,15 +127,37 @@ console.log('\n[merchant methods (BTCPay)]');
check('list_transactions includes receives', txs.some((t) => t.type === 'incoming' && t.amount === 21000), JSON.stringify(txs));
}
{
- const { deps } = harness(); // a mirror without receive keys
- check('make_invoice on an old mirror → wake the user', (await respondFromBg({ type: 'nwc', event: ev(makeReq('make_invoice', { amount: 21000 })) }, deps)) === false);
+ // PullCap 2026-10-24: lookups of a send in flight came back "not found".
+ // Every copy of the connection is asked; an outgoing send answers as one.
+ const INV = 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0zpe3alqhp5ppg7g8qwpdv34hgpymhw446y37duzwcn388yp3pw05n7tlulyn2scqzysxqrrssrzjqv3dpepm8kfdxrk3sl6wzqdf49s9c0h9ljtjrek6c08r6aejlwcnur0dwyqqvusqqqqqqqlgqqqq86qqjqsp5dc0jrq94ke2f4dzx8c2dwqsc6a65eu56dt2j599l7kxp7q2hs6zq9qxpqysgqdjeft8gkl0uga24e502pvcp5vgsfap3dxuutcpgfaj33fffuqs9psmnrklshp3fg3py7vlnzsea90vj9ahqq5t9xuy67u3pk0sfnheqpn95f2g';
+ const H = '7e9d690de6b475ef4f9e964b8a5180afc5fb11af12f0056fece6fa1788398f7e';
+ const fresher = baseRec(); fresher.updated = 2; fresher.ark = { ...fresher.ark, network: 'signet' };
+ const live = baseRec(); live.updated = 1;
+ live.mgr.actions.push({ id: 'lnpay-1', type: 'ln-pay', step: 'done', paymentHash: H, preimage: 'cd'.repeat(32), amountSat: 21, invoice: INV });
+ const { deps, published, decryptReply } = harness({ recs: [{ walletKey: 'old', rec: fresher }, { walletKey: 'w1', rec: live }] });
+ await respondFromBg({ type: 'nwc', event: ev(makeReq('lookup_invoice', { payment_hash: H })) }, deps);
+ const r = decryptReply(published[0]).result || {};
+ check('lookup_invoice finds a send in the older copy of the connection → outgoing, settled, its preimage', r.type === 'outgoing' && r.state === 'settled' && r.preimage === 'cd'.repeat(32), JSON.stringify(r));
+ live.mgr.actions[0].step = 'initiated'; delete live.mgr.actions[0].preimage;
+ const t0 = Date.now();
+ await respondFromBg({ type: 'nwc', event: ev(makeReq('lookup_invoice', { payment_hash: H })) }, { ...deps, deadline: Date.now() + 2500 });
+ const r2 = decryptReply(published[1]).result || {};
+ check('…one still in flight (ASP unreachable here) → outgoing, pending, inside the push budget', r2.type === 'outgoing' && r2.state === 'pending' && Date.now() - t0 < 4000, JSON.stringify(r2) + ` ${Date.now() - t0} ms`);
+}
+{
+ const { deps, declined, decryptReply } = harness(); // a mirror without receive keys
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('make_invoice', { amount: 21000 })) }, deps);
+ const r = declined.length ? decryptReply(declined[0]) : null;
+ check('make_invoice on an old mirror → wake the user, wait reply left behind', handled === false && declined.length === 1 && r.result_type === 'make_invoice' && r.error?.code === 'OTHER', JSON.stringify(r));
}
console.log('\n[guards]');
{
- const { deps } = harness();
+ const { deps, published, declined } = harness();
const stale = ev(makeReq('get_balance', {}, 'nip44_v2', 300));
- check('stale request refused', (await respondFromBg({ type: 'nwc', event: stale }, deps)) === false);
+ // a push that sat at APNs past the request window: nothing to answer, and
+ // no "Payment request" for it either (true = handled quietly)
+ check('stale request ignored quietly', (await respondFromBg({ type: 'nwc', event: stale }, deps)) === true && !published.length && !declined.length);
}
{
const { deps } = harness();
@@ -166,16 +197,21 @@ const INV21 = 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0z
{
const rec = baseRec();
rec.mgr.vtxos = []; // nothing spendable in the mirror
- const { deps } = harness({ rec });
+ const { deps, published, declined, decryptReply } = harness({ rec });
const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
- check('empty mirror → wake the user', handled === false);
+ const r = declined.length ? decryptReply(declined[0]) : null;
+ // the wait reply is NOT published by the worker: the notifier holds it and
+ // sends it only if nobody answers — a sibling's payment can't be shouted over
+ check('empty mirror → wake the user, wait reply left with the notifier', handled === false && published.length === 0 && declined.length === 1
+ && r.result_type === 'pay_invoice' && r.error?.code === 'OTHER' && /open the app/.test(r.error.message), JSON.stringify(r));
+ check('…addressed to the request', declined[0].kind === 23195 && declined[0].tags.some((t) => t[0] === 'e'));
}
{
const rec = baseRec();
delete rec.keys['5']; // change index outside the mirrored window
- const { deps } = harness({ rec });
+ const { deps, declined } = harness({ rec });
const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
- check('exhausted key window → wake the user', handled === false);
+ check('exhausted key window → wake the user, wait reply left behind', handled === false && declined.length === 1);
}
console.log('\n[pay outcome]');
@@ -203,6 +239,110 @@ console.log('\n[pay outcome]');
}
}
+console.log('\n[sends begun on an earlier wake]');
+// The worker died mid-payment on an earlier wake: the action sits in the
+// mirror at 'created' with its coin reserved (PullCap, 2026-10-23).
+{
+ const { ArkManager } = await import('../src/ark/manager.js');
+ const HASH = maybeBolt11(INV21).paymentHash;
+ const PRE = 'cd'.repeat(32);
+ const names = ['init', 'payLnInvoice', 'awaitLnPay', 'lnAction', 'driveLn'];
+ const orig = Object.fromEntries(names.map((k) => [k, ArkManager.prototype[k]]));
+ ArkManager.prototype.init = async function () { this.state = this.storage.load(); return this; };
+ // a fresh attempt at an invoice with an action on the books is refused…
+ ArkManager.prototype.payLnInvoice = async function (invoice) {
+ const hash = maybeBolt11(invoice).paymentHash;
+ if (this.state.actions.some((a) => a.type === 'ln-pay' && a.paymentHash === hash && a.step !== 'failed')) throw new Error('a payment for this invoice is already in progress');
+ const a = { id: 'lnpay-new', type: 'ln-pay', step: 'done', invoice, paymentHash: hash, amountSat: 21, feeSat: 0, preimage: 'ab'.repeat(32) };
+ this.state.actions.push(a); this.storage.save(this.state);
+ return a.id;
+ };
+ // …and a drive takes the stuck one through to done, freeing its change
+ ArkManager.prototype.awaitLnPay = async function (id) { return this.driveLn(id); };
+ ArkManager.prototype.driveLn = async function (id) {
+ const a = this.state.actions.find((x) => x.id === id);
+ if (a && a.step !== 'done') {
+ a.step = 'done'; a.preimage = PRE;
+ for (const v of this.state.vtxos) if (v.state === 'pending') v.state = 'spendable';
+ this.storage.save(this.state);
+ }
+ return a;
+ };
+ ArkManager.prototype.lnAction = function (id) { return this.state.actions.find((x) => x.id === id); };
+ const stuckRec = (hash) => {
+ const rec = baseRec();
+ rec.mgr.vtxos[0].state = 'pending';
+ rec.mgr.actions.push({ id: 'lnpay-stuck', type: 'ln-pay', step: 'created', invoice: INV21, paymentHash: hash, amountSat: 21, feeSat: 0 });
+ rec.pendingPays = [{ hash, connId: 'c1', ts: 1 }];
+ return rec;
+ };
+ try {
+ {
+ // the SAME invoice asked again: finish that send, answer with ITS preimage
+ const rec = stuckRec(HASH);
+ const log = [];
+ const { deps, published, decryptReply } = harness({ rec });
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, { ...deps, log: (m) => log.push(m) });
+ check('same invoice → the stuck send is finished and answered with its preimage', published.length === 1 && decryptReply(published[0]).result?.preimage === PRE, JSON.stringify(handled));
+ check('…recorded once on the spend log, under its connection', rec.spends.length === 1 && rec.spends[0].connId === 'c1' && rec.spends[0].preimage === PRE && rec.pendingPays.length === 0, JSON.stringify(rec.spends));
+ check('…with a "Payment sent" heads-up', handled && handled.notify && handled.notify.title === 'Payment sent');
+ check('…and the diary names the step it reached', log.some((m) => /already created/.test(m)) && log.some((m) => /→ done/.test(m)), log.join(' | '));
+ }
+ {
+ // a send that settled earlier (in the app, say): its preimage again,
+ // nothing new on the spend log
+ const rec = baseRec();
+ rec.mgr.actions.push({ id: 'lnpay-old', type: 'ln-pay', step: 'done', invoice: INV21, paymentHash: HASH, amountSat: 21, feeSat: 0, preimage: PRE });
+ const { deps, published, decryptReply } = harness({ rec });
+ await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ check('an invoice paid earlier answers with the same preimage', published.length === 1 && decryptReply(published[0]).result?.preimage === PRE);
+ check('…without a second spend', (rec.spends || []).length === 0);
+ }
+ {
+ // a stuck send for ANOTHER invoice holds the only coin: finish it first,
+ // then pay the new request from the freed coin
+ const rec = stuckRec('ee'.repeat(32));
+ const { deps, published, decryptReply } = harness({ rec });
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ check('another invoice → the earlier send is finished, then this one paid', published.length === 1 && decryptReply(published[0]).result?.preimage === 'ab'.repeat(32), JSON.stringify(handled));
+ check('…both on the spend log', rec.spends.length === 2 && rec.spends[0].preimage === PRE && rec.spends[1].preimage === 'ab'.repeat(32));
+ check('…the earlier one gets its own "Payment sent" line', handled && handled.notices && handled.notices.length === 1 && handled.notify && handled.notify.title === 'Payment sent', JSON.stringify(handled));
+ }
+ {
+ // any wake at all finishes it (a lookup, a push for something else)
+ const rec = stuckRec(HASH);
+ const notices = await tendLnPays({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async () => {} });
+ check('tendLnPays finishes a stuck send and reports it', notices.length === 1 && notices[0].title === 'Payment sent' && rec.spends.length === 1 && rec.spends[0].connId === 'c1', JSON.stringify(notices));
+ check('…and a second wake has nothing left to do', (await tendLnPays({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async () => {} })).length === 0);
+ }
+ } finally {
+ Object.assign(ArkManager.prototype, orig);
+ }
+}
+
+console.log('\n[a send that fails]');
+// The ASP refused the cosign (a coin at the depth cap, PullCap 2026-10-29):
+// the error is left with the notifier at once, not waited out in a worker
+// iOS stops before it speaks.
+{
+ const { ArkManager } = await import('../src/ark/manager.js');
+ const names = ['init', 'payLnInvoice'];
+ const orig = Object.fromEntries(names.map((k) => [k, ArkManager.prototype[k]]));
+ ArkManager.prototype.init = async function () { this.state = this.storage.load(); return this; };
+ try {
+ for (const [why, code] of [
+ ['insufficient ark balance (259 sat have a history too long to spend and are too few to renew; receive more to keep paying)', 'INSUFFICIENT_BALANCE'],
+ ['ln-pay failed: route not found', 'PAYMENT_FAILED'],
+ ]) {
+ ArkManager.prototype.payLnInvoice = async function () { throw new Error(why); };
+ const { deps, published, declined, decryptReply } = harness();
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, { ...deps, errGraceMs: 60_000 });
+ const r = declined.length ? decryptReply(declined[0]) : null;
+ check(`${code}: the error is handed to the notifier, nothing waited out here`, handled === true && declined.length === 1 && !published.length && r.error?.code === code && r.error.message === why, JSON.stringify(r));
+ }
+ } finally { Object.assign(ArkManager.prototype, orig); }
+}
+
console.log('\n[network fit]');
{
// a mutinynet-era mirror must not answer a mainnet invoice with a nonsense
diff --git a/tools/onboard-backup-test.js b/tools/onboard-backup-test.js
index ddef8d7..8fc1071 100644
--- a/tools/onboard-backup-test.js
+++ b/tools/onboard-backup-test.js
@@ -43,11 +43,14 @@ async function signIn() {
await page.goto('http://localhost:5291/', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' });
- // wait for the front door to actually be painted before poking it
- await waitText(page, 'sign in with nostr');
+ // wait for the front door to actually be painted before poking it; the
+ // sign-in ways are one step in
+ await waitText(page, 'sign in to an existing account');
+ await page.evaluate(() => document.getElementById('onb-signin')?.click());
+ await waitText(page, 'migrate a coinos account');
const sk = generateSecretKey();
const nsec = bech32.encode('nsec', bech32.toWords(sk), 1000);
- await click(page, 'sign in with nostr');
+ await click(page, 'nostr');
await waitText(page, 'log in with nostr');
await page.waitForSelector('input[placeholder*="nsec" i], textarea[placeholder*="nsec" i]', { timeout: 15000 });
await page.type('input[placeholder*="nsec" i], textarea[placeholder*="nsec" i]', nsec);
@@ -62,41 +65,82 @@ try {
await page.setViewport({ width: 390, height: 844 });
await page.goto('http://localhost:5291/', { waitUntil: 'domcontentloaded' });
await sleep(1500);
+ // Two choices and nothing else (2026-11-06): a new wallet, or one you
+ // already have — the sign-in methods wait behind the second.
const front = await page.evaluate(() => {
const btns = [...document.querySelectorAll('button')].filter((b) => b.offsetParent);
const primary = document.getElementById('onb-create');
- const idx = btns.indexOf(primary);
- const signins = btns.filter((b) => /sign in with/i.test(b.textContent));
+ const signin = document.getElementById('onb-signin');
return {
label: primary ? primary.textContent.trim() : null,
- first: idx === 0 || (idx >= 0 && idx < btns.findIndex((b) => /sign in with/i.test(b.textContent))),
- primaryTop: primary ? primary.getBoundingClientRect().top : -1,
- signinTop: signins.length ? signins[0].getBoundingClientRect().top : -1,
- divider: !!document.querySelector('.onb-or'),
+ labels: btns.map((b) => b.textContent.trim()),
+ order: !!primary && !!signin && primary.getBoundingClientRect().top < signin.getBoundingClientRect().top,
+ footer: (() => { const f = document.querySelector('.footer'); return f ? { buttons: f.querySelectorAll('button, a').length, selects: f.querySelectorAll('select').length } : null; })(),
};
});
check('the way in says what it makes', /wallet/i.test(front.label || ''), front.label || 'missing');
- check('...and comes before the sign-in buttons', front.first && front.primaryTop < front.signinTop,
- `create at ${Math.round(front.primaryTop)}, first sign-in at ${Math.round(front.signinTop)}`);
- check('...with a line between them, so they read as the alternative', front.divider);
+ check('...and the only other choice is signing in to one you have', front.order && front.labels.length === 2,
+ front.labels.join(' | '));
+ check('...with only the language picker under them', !!front.footer && front.footer.buttons === 0 && front.footer.selects === 1, JSON.stringify(front.footer));
// The welcome card is just the title and the buttons now (42c8d4e): the
// way in itself says what the wallet is — tapping it puts the twelve
// words on screen at once, with what they are.
await page.evaluate(() => document.getElementById('onb-create')?.click());
await sleep(600);
- const made = await page.evaluate(() => ({
- words: document.querySelectorAll('.words .w .t').length,
- says: /12 words|twelve words/i.test(document.body.innerText),
- }));
- check('the way in shows what the wallet actually is: its twelve words', made.words === 12 && made.says,
- made.words + ' words' + (made.says ? ', explained' : ', unexplained'));
+ // a heads-up first, no warning icon (2026-11-07): sensitive words next
+ const intro = await page.evaluate(() => ({ words: document.querySelectorAll('.words .w').length, text: document.body.innerText, warn: !!document.querySelector('.warn-box') }));
+ check('the words wait behind a heads-up to write them down', intro.words === 0 && /write them down/i.test(intro.text) && !intro.warn && !/⚠/.test(intro.text));
+ await page.evaluate(() => document.getElementById('seed-intro-next')?.click());
+ await sleep(400);
+ const made = await page.evaluate(() => document.querySelectorAll('.words .w .t').length);
+ check('the way in shows what the wallet actually is: its twelve words', made === 12, made + ' words');
await page.close();
}
+ // Three: a wallet made elsewhere comes in by its twelve words, and that
+ // door has to be on the front page too — it used to be a line under the
+ // freshly minted words behind "Create a new wallet", found by chance.
+ console.log('\n[restoring from twelve words]');
+ {
+ const ctx = await browser.createBrowserContext();
+ const page = await ctx.newPage();
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5291/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText(page, 'sign in to an existing account');
+ await page.evaluate(() => document.getElementById('onb-signin')?.click());
+ await waitText(page, 'migrate a coinos account');
+ const door = await page.evaluate(() => {
+ const btns = [...document.querySelectorAll('button')].filter((b) => b.offsetParent && b.textContent.trim());
+ const b = document.getElementById('onb-restore');
+ return b ? { label: b.textContent.trim(), first: btns[0] === b, visible: !!b.offsetParent } : null;
+ });
+ check('signing in offers the seed phrase', !!door && door.visible && /seed phrase/i.test(door.label), door ? door.label : 'missing');
+ check('...first, above the other ways', !!door && door.first);
+ await page.evaluate(() => document.getElementById('onb-restore')?.click());
+ await sleep(600);
+ const pane = await page.evaluate(() => ({
+ title: /enter seed phrase/i.test(document.body.innerText),
+ field: !!document.querySelector('textarea[placeholder*="seed words" i]'),
+ minted: document.querySelectorAll('.words .w .t').length,
+ }));
+ check('it opens straight onto the phrase field, no minted words', pane.title && pane.field && pane.minted === 0,
+ `${pane.title ? 'titled' : 'untitled'}, ${pane.field ? 'field' : 'no field'}, ${pane.minted} minted words`);
+ const phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
+ await page.type('textarea[placeholder*="seed words" i]', phrase);
+ await click(page, 'open wallet');
+ check('the typed phrase opens the wallet', await waitText(page, 'receive'));
+ await ctx.close();
+ }
+
console.log('\n[signing in, then writing it down]');
{
const { page, ctx, seed } = await signIn();
check('a sign-in is asked to write the phrase down', await waitText(page, 'write down your recovery phrase'));
+ check('...with the words behind Show seed phrase', await page.evaluate(() => !document.querySelector('.words')));
+ await click(page, 'show seed phrase');
+ await sleep(300);
const shown = await page.evaluate(() => [...document.querySelectorAll('.words .w .t')].map((e) => e.textContent.trim()));
check('...and the real twelve words are on screen', shown.length === 12 && shown.join(' ') === seed,
shown.length + ' words' + (shown.join(' ') === seed ? ', matching the derived seed' : ', WRONG'));
diff --git a/tools/onboard-cover-test.js b/tools/onboard-cover-test.js
index 4262bc1..733a515 100644
--- a/tools/onboard-cover-test.js
+++ b/tools/onboard-cover-test.js
@@ -3,7 +3,7 @@
// twelve nature covers with a counter; the preview copies are what load.
// Run: bun run build && bun tools/onboard-cover-test.js [screenshot.png]
import assert from 'node:assert/strict';
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const server = Bun.serve({ port: 0, async fetch(req) {
const p = new URL(req.url).pathname;
@@ -21,7 +21,7 @@ try {
await page.reload(); await sleep(1200);
const click = (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); e?.click(); return !!e; }, x);
await click('create a new wallet'); await sleep(1500);
- await click('skip verification'); await sleep(1500);
+ await skipSeedCheck(page); await sleep(1500);
await page.evaluate(() => localStorage.setItem('btc-wallet-onb-step', 'avatar'));
await page.reload();
await page.waitForSelector('.onb-cover', { timeout: 10000 });
diff --git a/tools/onboard-migrate-door-test.js b/tools/onboard-migrate-door-test.js
index 3b16dd0..ac2252c 100644
--- a/tools/onboard-migrate-door-test.js
+++ b/tools/onboard-migrate-door-test.js
@@ -29,6 +29,9 @@ try {
await page.setViewport({ width: 390, height: 844 });
await page.goto('http://localhost:5293/', { waitUntil: 'domcontentloaded' });
check('the front door paints', await waitText(page, 'create a new wallet'));
+ // the sign-in ways (migrating included) wait behind "Sign in to an existing account"
+ await page.click('#onb-signin');
+ await waitText(page, 'migrate a coinos account', 5000);
const btns = await visible(page);
const iMigrate = btns.findIndex((b) => b.id === 'onb-migrate');
const iLastSignin = btns.map((b) => /sign in/i.test(b.text)).lastIndexOf(true);
@@ -44,7 +47,7 @@ try {
doors.map((b) => b.text).join(' | '));
await page.reload({ waitUntil: 'domcontentloaded' });
check('a refresh stays on the move page', await waitText(page, 'move your coinos.io account', 8000));
- check('Back returns to the front door', await clickText(page, 'back') && await waitText(page, 'create a new wallet', 5000));
+ check('Back returns to the sign-in choices', await clickText(page, 'back') && await waitText(page, 'migrate a coinos account', 5000));
const forgot = await page.evaluate(() => !localStorage.getItem('btc-wallet-migrate-link'));
check('…and forgets the intent', forgot);
await page.click('#onb-migrate');
diff --git a/tools/own-post-feeds-test.js b/tools/own-post-feeds-test.js
index a3e8fc3..a877749 100644
--- a/tools/own-post-feeds-test.js
+++ b/tools/own-post-feeds-test.js
@@ -6,7 +6,7 @@
// whose tag it wears, which is now a t tag on the event) — not to a topic
// feed that happens to be open and doesn't match.
// Run: bun tools/own-post-feeds-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
@@ -63,9 +63,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
const ME = getPublicKey(privateKeyFromSeedWords(mn));
diff --git a/tools/padlock-flow-test.js b/tools/padlock-flow-test.js
index dcc0a85..f56d557 100644
--- a/tools/padlock-flow-test.js
+++ b/tools/padlock-flow-test.js
@@ -110,7 +110,9 @@ try {
await p2.evaluate(() => document.querySelector('.header-avatar')?.click()); await sleep(800);
await click2('Sign into another account'); await sleep(500);
await click2('Sign into a new account'); await sleep(500);
- await click2('Import existing'); await sleep(300); // the sign-in screen's import tab
+ // the front door's layout (2026-11-07): sign in → Seed phrase
+ await click2('Sign in to an existing account'); await sleep(300);
+ await click2('Seed phrase'); await sleep(300);
return importWallet();
};
// the wallets on this device, as the identity switcher lists them (the
diff --git a/tools/pager-caption-test.js b/tools/pager-caption-test.js
index 5952d81..a3d7800 100644
--- a/tools/pager-caption-test.js
+++ b/tools/pager-caption-test.js
@@ -4,7 +4,7 @@
// caption leaves the pager and opens the post, where the whole caption
// reads. A link to the web opens in its own tab and the pager stays.
// Run: bun tools/pager-caption-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { npubEncode } from 'nostr-tools/nip19';
@@ -55,6 +55,8 @@ const openClip = async (word) => {
await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }, word);
await sleep(400);
const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
await page.mouse.click(at.x, at.y);
const opened = await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000);
// the caption and the counts show on a tap on the clip (details), as a reader sees them
@@ -72,6 +74,8 @@ const clickIn = async (sel, where) => {
// the caption's first word sits at its left edge; a link in the middle
return where === 'start' ? { x: r.left + 12, y: r.top + 9 } : { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}, ['(' + CAP + ')', sel, where]);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
await page.mouse.click(at.x, at.y);
};
const toFeed = async () => { await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); await waitFor(() => !!document.querySelector('.notes-feed video.note-video'), null, 8000); };
@@ -83,9 +87,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, valley, ns]) => {
diff --git a/tools/pager-close-test.js b/tools/pager-close-test.js
index 73aa15b..2f0bece 100644
--- a/tools/pager-close-test.js
+++ b/tools/pager-close-test.js
@@ -6,7 +6,7 @@
// the reader on that page, and that entry becomes it. And the ordinary case
// still steps Back, so the phone's Back doesn't reopen the pager.
// Run: bun tools/pager-close-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -84,6 +84,8 @@ const st = () => page.evaluate(() => ({ pager: !!document.querySelector('.media-
const openFirst = async () => {
const r = await page.evaluate(() => { const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip zero')); row.querySelector('video.note-video').scrollIntoView({ block: 'center' }); const b = row.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; });
await sleep(400);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [r.x, r.y]);
await page.mouse.click(r.x, r.y);
return waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000);
};
@@ -94,9 +96,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/pager-controls-test.js b/tools/pager-controls-test.js
index a02591d..79e9bca 100644
--- a/tools/pager-controls-test.js
+++ b/tools/pager-controls-test.js
@@ -4,7 +4,7 @@
// the reaction button offers the emoji sheet, over the pager, as the feed's
// does; the emoji picked shows on the button, and a tap again takes it back.
// Run: bun tools/pager-controls-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -54,9 +54,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/pager-eager-test.js b/tools/pager-eager-test.js
index 3b5c5c3..f1cd150 100644
--- a/tools/pager-eager-test.js
+++ b/tools/pager-eager-test.js
@@ -8,7 +8,7 @@
// Saver on, the pager is careful again: such a clip waits for a tap and
// nothing loads ahead.
// Run: bun tools/pager-eager-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -93,9 +93,9 @@ async function run(saveData, slow = false) {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
@@ -117,6 +117,8 @@ async function run(saveData, slow = false) {
await waitFor(() => { const v = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip c0 here')).querySelector('video.note-video'); return v && !v.paused && v.currentTime > 0; }, null, 10000);
const r = await feedRow('clip c0 here');
log = log.filter((e) => e.clip === 'c0');
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [r.x, r.y]);
await page.mouse.click(r.x, r.y);
check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000));
await sleep(slow ? 8000 : 3000);
diff --git a/tools/pager-first-frame-test.js b/tools/pager-first-frame-test.js
index 6507607..63de5ac 100644
--- a/tools/pager-first-frame-test.js
+++ b/tools/pager-first-frame-test.js
@@ -5,7 +5,7 @@
// opening, and a clip beyond gets no picture (and stays out of the
// carousel) until a first frame comes — never the post's still first. The slide left behind keeps its frame for the swipe
// back. Run: bun tools/pager-first-frame-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -81,9 +81,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
@@ -99,6 +99,8 @@ try {
await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); });
await sleep(600);
const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; });
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
await page.mouse.click(at.x, at.y);
check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000));
check('the clip on screen plays', await waitFor(() => [...document.querySelectorAll('.mp-slide.mp-cur video.mp-media')].some((v) => !v.paused), null, 8000));
diff --git a/tools/pager-gone-picture-test.js b/tools/pager-gone-picture-test.js
index 34c89b6..b7e3c8f 100644
--- a/tools/pager-gone-picture-test.js
+++ b/tools/pager-gone-picture-test.js
@@ -2,7 +2,7 @@
// nor the original can load) never joins the pager's carousel: a picture
// joins once decoded (2026-10-17), and a step passes over it to the next.
// (Its slide is marked gone, with the host's name, as before — out of sight.) Run: bun tools/pager-gone-picture-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -43,9 +43,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/pager-no-poster-test.js b/tools/pager-no-poster-test.js
index 0930834..c40f73e 100644
--- a/tools/pager-no-poster-test.js
+++ b/tools/pager-no-poster-test.js
@@ -7,7 +7,7 @@
// connection to be careful with (Data Saver), where a clip may never load,
// the thumbnail poster stays.
// Run: bun tools/pager-no-poster-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -64,6 +64,8 @@ const openClip = async (word) => {
await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }, word);
await sleep(400);
const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
await page.mouse.click(at.x, at.y);
await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000);
};
@@ -75,9 +77,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/pager-prime-test.js b/tools/pager-prime-test.js
index 7263ee1..df20cc7 100644
--- a/tools/pager-prime-test.js
+++ b/tools/pager-prime-test.js
@@ -15,7 +15,7 @@
// that); on a link slower than the clip, more — to the 4 MB cap. With Data
// Saver on, the pager is careful again: such a clip waits for a tap and
// nothing loads ahead.
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -97,9 +97,9 @@ async function run() {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
@@ -114,6 +114,8 @@ async function run() {
await page.waitForSelector('.notes-feed video.note-video', { timeout: 20000 });
const r = await page.evaluate(() => { const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip c0 here')); row.querySelector('.note-video-box').scrollIntoView({ block: 'center' }); const v = row.querySelector('video.note-video').getBoundingClientRect(); return { x: v.x + v.width * 0.2, y: v.y + v.height * 0.3 }; });
await sleep(800);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [r.x, r.y]);
await page.mouse.click(r.x, r.y);
check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000));
check('the next clip\'s warm player has played a moment, unheard, and waits paused a frame or two in', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/c1.webm')); const v = s && s.querySelector('video.mp-media'); return v && v.paused && v.currentTime >= 0.03 && v.currentTime < 0.6; }, null, 10000), JSON.stringify(await vid('c1')));
diff --git a/tools/pager-ready-gate-test.js b/tools/pager-ready-gate-test.js
index dd6982d..20cb337 100644
--- a/tools/pager-ready-gate-test.js
+++ b/tools/pager-ready-gate-test.js
@@ -5,7 +5,7 @@
// grid — shows the clips the screen's way round (tall ones on a phone held
// upright), a square either way, until the toggle asks for all shapes.
// Run: bun tools/pager-ready-gate-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -81,9 +81,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/pager-resize-test.js b/tools/pager-resize-test.js
index 07fcbba..d18745b 100644
--- a/tools/pager-resize-test.js
+++ b/tools/pager-resize-test.js
@@ -5,7 +5,7 @@
// button on a clip 23 posts down showed him another post's picture. The
// resize can come before the observer has even named the first slide.
// Run: bun tools/pager-resize-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -68,9 +68,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/pager-rotate-test.js b/tools/pager-rotate-test.js
index 9912b7a..4d453ad 100644
--- a/tools/pager-rotate-test.js
+++ b/tools/pager-rotate-test.js
@@ -4,7 +4,7 @@
// autoplay used to start it again, from the start, with sound if it had been
// unmuted there), and not the slide a turn's half-way layout passes over.
// Run: bun tools/pager-rotate-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -68,9 +68,9 @@ async function run(unmuteFirst) {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
@@ -91,6 +91,8 @@ async function run(unmuteFirst) {
await sleep(800);
}
const at = await rowOf(); await sleep(300);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
await page.mouse.click(at.x, at.y);
check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000));
await waitFor(() => { const v = document.querySelector('.media-pager video[src]'); return v && !v.paused && v.currentTime > 1.5; }, null, 8000);
diff --git a/tools/pager-seek-hide-test.js b/tools/pager-seek-hide-test.js
index f25ff49..59a0353 100644
--- a/tools/pager-seek-hide-test.js
+++ b/tools/pager-seek-hide-test.js
@@ -8,7 +8,7 @@
// position counter is the other way
// round: it shows while the pager moves and fades out once it has settled.
// Run: bun tools/pager-seek-hide-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -70,9 +70,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
@@ -88,6 +88,8 @@ try {
await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); });
await sleep(400);
const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; });
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
await page.mouse.click(at.x, at.y);
check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000));
diff --git a/tools/pager-sharpen-test.js b/tools/pager-sharpen-test.js
index ea5c878..e61b51d 100644
--- a/tools/pager-sharpen-test.js
+++ b/tools/pager-sharpen-test.js
@@ -3,7 +3,7 @@
// comes to rest; only the slide at rest carries the zoom layer. (Originals
// decoded and uploaded as they passed made a flight stutter — Adam,
// 2026-10-16.) Run: bun tools/pager-sharpen-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -42,9 +42,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/pager-swipe-fly-test.js b/tools/pager-swipe-fly-test.js
index 657ea75..1184e7b 100644
--- a/tools/pager-swipe-fly-test.js
+++ b/tools/pager-swipe-fly-test.js
@@ -5,7 +5,7 @@
// mid-flight stops it on the post most on screen, and a swipe back from
// there goes to the one before.
// Run: bun tools/pager-swipe-fly-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -85,9 +85,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/pager-tail-test.js b/tools/pager-tail-test.js
index b294b8f..96227f8 100644
--- a/tools/pager-tail-test.js
+++ b/tools/pager-tail-test.js
@@ -4,7 +4,7 @@
// moved onto and plays by itself. With nothing older to be had, the end says
// that's everything. Escape from there lands on the last clip's post.
// Run: bun tools/pager-tail-test.js (SHOT=path.png saves the loading screen)
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -84,9 +84,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
@@ -103,6 +103,8 @@ try {
const r = await page.evaluate(() => { const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip zero')); row.querySelector('video.note-video').scrollIntoView({ block: 'center' }); const b = row.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; });
await sleep(400);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [r.x, r.y]);
await page.mouse.click(r.x, r.y);
check('the pager opens on the first clip', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000) && await resting('c0'), await at());
check('a loading screen waits at the end, after the clips', (await tail())?.last, JSON.stringify(await tail()));
diff --git a/tools/pager-tap-slop-test.js b/tools/pager-tap-slop-test.js
index 4de87df..c184e3b 100644
--- a/tools/pager-tap-slop-test.js
+++ b/tools/pager-tap-slop-test.js
@@ -4,7 +4,7 @@
// 30px pauses (or plays) the clip and leaves the pager where it was; only a
// longer one pages.
// Run: bun tools/pager-tap-slop-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -66,9 +66,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/pager-wheel-test.js b/tools/pager-wheel-test.js
index 45a5115..7f59c34 100644
--- a/tools/pager-wheel-test.js
+++ b/tools/pager-wheel-test.js
@@ -5,7 +5,7 @@
// the wheel pans it while zoomed and pages again at 1×; and arrows at the
// edges for the pointer, the sideways pair only where the post has more.
// Run: bun tools/pager-wheel-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -68,9 +68,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/pic-proxy-test.js b/tools/pic-proxy-test.js
index 881867c..69a09c1 100644
--- a/tools/pic-proxy-test.js
+++ b/tools/pic-proxy-test.js
@@ -5,7 +5,7 @@
// paints the original as before. The server is stood in for: its answers are
// canned.
// Run: bun tools/pic-proxy-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -76,9 +76,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/picture-pager-ready-test.js b/tools/picture-pager-ready-test.js
index 7cfaa45..08a4e21 100644
--- a/tools/picture-pager-ready-test.js
+++ b/tools/picture-pager-ready-test.js
@@ -5,7 +5,7 @@
// the screen stay out, with the "Tall only" / "All shapes" toggle in the
// top bar; and with fewer than ten ready and more still warming, no
// counter shows and a flick goes one post. Run: bun tools/picture-pager-ready-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -50,9 +50,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/poll-test.js b/tools/poll-test.js
index edcb061..41aa02e 100644
--- a/tools/poll-test.js
+++ b/tools/poll-test.js
@@ -4,7 +4,7 @@
// caught at the socket so the test never votes on someone's real poll.
//
// Run: bun tools/poll-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { SimplePool } from 'nostr-tools/pool';
import { cacheKeyFor } from '../src/wallet.js';
import { neventOf } from '../src/nostr.js';
@@ -41,9 +41,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
const Q = 'a'.repeat(63) + '9';
diff --git a/tools/post-edit-test.js b/tools/post-edit-test.js
index 0b8bf6f..08d1190 100644
--- a/tools/post-edit-test.js
+++ b/tools/post-edit-test.js
@@ -5,7 +5,7 @@
// deletion is remembered across a reload.
// No relay is written for real — the WebSocket shim swallows EVENT.
// Run: bun tools/post-edit-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
@@ -95,9 +95,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
const ME_SK = privateKeyFromSeedWords(mn);
@@ -160,6 +160,7 @@ try {
await openFeed();
check('the feed is back', await waitText('a post a stranger tried to delete', 15000));
check('my deleted post stays gone', !(await has('my own post')));
+ check('...with no "was deleted" row in its place', !(await has('deleted by its author')));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ post edits and deletions' : '\n❌ failed');
diff --git a/tools/post-search-test.js b/tools/post-search-test.js
new file mode 100644
index 0000000..81e5b62
--- /dev/null
+++ b/tools/post-search-test.js
@@ -0,0 +1,83 @@
+// Full-text search: words typed into Search and submitted with Enter (no
+// suggestion picked) open a feed of the notes AND replies that say them,
+// asked of NIP-50 relays ({ kinds: [1], search }). The search relays are
+// answered in the page; nothing leaves the machine.
+// Run: bun tools/post-search-test.js
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
+import { generateSecretKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const SK = generateSecretKey();
+const now = Math.floor(Date.now() / 1000);
+const post = finalizeEvent({ kind: 1, created_at: now - 300, tags: [], content: 'talked to fiatjaf about relays today' }, SK);
+const parent = 'a'.repeat(64);
+// one newsletter, posted by bot keys with a word or emoji changed each time
+const NL = 'In this week\'s issue: The Quiet Coup: Fiatjaf Wipes Authorship From 57 NIPs. In a 12-hour window, the documentation layer was rewritten without consent. https://nostrmag.com/article/w45';
+const copies = ['📰 ' + NL, '🗞️ BREAKING ' + NL, NL + ' 👤 NM team'].map((content, i) => finalizeEvent({ kind: 1, created_at: now - 100 - i, tags: [], content }, generateSecretKey()));
+const reply = finalizeEvent({ kind: 1, created_at: now - 200, tags: [['e', parent, '', 'reply']], content: 'fiatjaf would say just use a relay' }, SK);
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument((hits) => {
+ window.__searches = [];
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ' && m[2] && m[2].search) {
+ window.__searches.push({ url: ws.url, f: m[2] });
+ const r = send(data);
+ setTimeout(() => { for (const ev of hits) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) })); }, 2);
+ return r;
+ }
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, Real);
+}, [post, reply, ...copies]);
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await seedWords(page);
+ await skipSeedCheck(page);
+ await waitText('receive', 20000);
+ await page.click('[aria-label="Search"]');
+ await page.waitForSelector('.user-search-input');
+ await page.type('.user-search-input', 'fiatjaf');
+ await sleep(300);
+ check('the search page offers the words themselves', await waitText('posts and replies that say this', 3000));
+ await page.keyboard.press('Enter');
+ check('Enter opens a feed of posts that say it', await waitText('talked to fiatjaf about relays', 10000));
+ check('...replies too', await waitText('fiatjaf would say just use a relay', 3000));
+ await waitText('quiet coup', 10000); await sleep(1500);
+ const nl = await page.evaluate(() => (document.body.innerText.match(/Quiet Coup/g) || []).length);
+ check('a newsletter posted by three bot keys shows once', nl === 1, nl + ' copies');
+ const s = await page.evaluate(() => window.__searches.filter((x) => x.f.search === 'fiatjaf' && !(x.f.kinds || []).includes(0)));
+ check('asked NIP-50 relays for kind 1 with the words', s.length > 0 && s.every((x) => JSON.stringify(x.f.kinds) === '[1]'), JSON.stringify(s.map((x) => x.url)));
+ check('the feed is named for the search', await page.evaluate(() => [...document.querySelectorAll('.feed-chip')].some((c) => c.classList.contains('on') && c.textContent.includes('fiatjaf'))),
+ JSON.stringify(await page.evaluate(() => [...document.querySelectorAll('.feed-chip')].map((c) => c.textContent.trim()))));
+ check('no page errors', !errs.length, errs.join(' | '));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close(); server.stop();
+}
+console.log(ok ? '\nPASS' : '\nFAIL');
+process.exit(ok ? 0 : 1);
diff --git a/tools/profile-pager-test.js b/tools/profile-pager-test.js
index 2a78598..5593192 100644
--- a/tools/profile-pager-test.js
+++ b/tools/profile-pager-test.js
@@ -3,7 +3,7 @@
// else's — and closing it after moving on lands the profile on that post.
// (It opened alone, in the lightbox, before: no pager on a profile.)
// Run: bun tools/profile-pager-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -67,9 +67,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns, other]) => {
diff --git a/tools/profile-publish-race-test.js b/tools/profile-publish-race-test.js
index 390c01b..becf375 100644
--- a/tools/profile-publish-race-test.js
+++ b/tools/profile-publish-race-test.js
@@ -67,9 +67,10 @@ const wallet = {
loadFeatureState: () => null, saveFeatureState: () => {},
_cacheKey: () => 'race-test', saveCache: () => {}, registerCacheExtension: () => {}, registerLoadHook: () => {},
};
+let namesAddr = null; // what the registrar says our address is
const feature = messagesFeature({
h: (tag, attrs, ...kids) => ({ tag, attrs, kids }), ui: {}, render: () => {}, wallet, toast: () => {}, brandHeader: () => null,
- hook: (name) => (name === 'nostrLoginIdentity' ? { pubkey: pk, signer } : null),
+ hook: (name) => (name === 'nostrLoginIdentity' ? { pubkey: pk, signer } : name === 'namesAddress' ? namesAddr : null),
});
console.log('[the migration: rename and avatar import fired together]');
@@ -91,5 +92,20 @@ await Promise.all([feature.addressRenamed('[email protected]', '[email protected]'),
const last = JSON.parse(store.filter((e) => e.kind === 0 && e.pubkey === pk).sort((a, b) => b.created_at - a.created_at)[0].content);
check('both changes survive', last.lud16 === '[email protected]' && last.about === 'hi' && /face/.test(last.picture), JSON.stringify(last));
+console.log('[Save in the editor repoints a dead same-domain address]');
+// The heal never ran (an Amber user: no background signatures), so the kind 0
+// still names the released placeholder. The domain says jar is dead, other
+// is alive.
+store.push(finalizeEvent({ kind: 0, created_at: now + 1000, tags: [], content: JSON.stringify({ name: 'Jarryd', lud16: '[email protected]', nip05: '[email protected]' }) }, sk));
+namesAddr = '[email protected]';
+globalThis.fetch = async (url) => /lnurlp\/jar$/.test(url)
+ ? { ok: false, status: 500, json: async () => null }
+ : { ok: true, status: 200, json: async () => ({ tag: 'payRequest', callback: 'https://x/cb' }) };
+published.length = 0;
+await feature.saveProfileDraft({ uname: 'jarryd', name: 'Jarryd' });
+const saved = JSON.parse(published.filter((e) => e.kind === 0).pop().content);
+check('the dead placeholder moves to the claimed address', saved.lud16 === '[email protected]', JSON.stringify(saved));
+check('a same-domain address that still answers is left alone', saved.nip05 === '[email protected]', saved.nip05);
+
console.log(fails ? `\n❌ ${fails} failing` : '\n✅ kind-0 publishes take turns');
process.exit(fails ? 1 : 0);
diff --git a/tools/profile-repost-test.js b/tools/profile-repost-test.js
index 7825efe..5ee97e7 100644
--- a/tools/profile-repost-test.js
+++ b/tools/profile-repost-test.js
@@ -4,7 +4,7 @@
// boost, a video event still has its clip — they were all saved as kind 1
// notes, so a boost came back as "reply · {"content":…}".
// Run: bun tools/profile-repost-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { npubEncode } from 'nostr-tools/nip19';
import { buildHtml } from '../build.js';
@@ -57,8 +57,8 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
- await click('skip verification');
+ await seedWords(page);
+ await skipSeedCheck(page);
await waitText('receive', 20000);
await page.goto(server.url.href + npubEncode(ME.pk), { waitUntil: 'domcontentloaded' });
await look('from the relays');
diff --git a/tools/public-chat-join-test.js b/tools/public-chat-join-test.js
new file mode 100644
index 0000000..3f0eee4
--- /dev/null
+++ b/tools/public-chat-join-test.js
@@ -0,0 +1,75 @@
+// The public chat page (/chat) on a device that already has a wallet: its
+// button opens the coinos community's room itself, not the wallet, and the
+// page doesn't tell a signed-in reader to sign in. And /chat's loading shell
+// is not a profile named "chat". (Andy, 2026-11-10: "I can see the feed etc
+// fine, so I am logged in".) Relays are the stand-ins: nothing is sent.
+//
+// Run: bun tools/public-chat-join-test.js
+import puppeteer from './puppeteer.js';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { buildHtml } from '../build.js';
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5247, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.setViewport({ width: 390, height: 844 });
+const click = (sel, t) => page.evaluate((s, x) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, t);
+const waitFor = async (fn, ms = 15000, ...a) => { for (let i = 0; i < ms / 200; i++) { try { if (await page.evaluate(fn, ...a)) return true; } catch {} await sleep(200); } return false; };
+// did the static profile shell ever show while the page loaded?
+await page.evaluateOnNewDocument(() => {
+ window.__shellShown = false;
+ const look = () => { const s = document.getElementById('boot-shell'); if (s && s.style.display !== 'none') window.__shellShown = true; };
+ new MutationObserver(look).observe(document, { childList: true, subtree: true, attributes: true });
+ document.addEventListener('DOMContentLoaded', look);
+});
+const head = () => page.evaluate(() => {
+ const c = document.querySelector('.chat-head');
+ return c ? { text: c.innerText, button: c.querySelector('button')?.textContent.trim() } : null;
+});
+
+try {
+ console.log('\n[a visitor]');
+ await page.goto('http://localhost:5247/chat', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitFor(() => !!document.querySelector('.chat-head'));
+ let hd = await head();
+ check('the public chat mounts', !!hd);
+ check('a visitor is asked to sign in', hd?.button === 'Sign in to chat' && /sign in to join/i.test(hd.text), hd?.button);
+ check("/chat's loading shell is not a profile", !(await page.evaluate(() => window.__shellShown)));
+ await page.goto('http://localhost:5247/someone', { waitUntil: 'domcontentloaded' });
+ check('a name still gets its profile shell', await page.evaluate(() => window.__shellShown));
+
+ console.log('\n[with a wallet on the device]');
+ await page.goto('http://localhost:5247/', { waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('button', 'Create a new wallet');
+ await sleep(300);
+ await click('button', 'Have an existing seed');
+ await sleep(300);
+ await page.waitForSelector('textarea');
+ await page.type('textarea', generateMnemonic(wordlist));
+ await click('button', 'Open wallet');
+ check('wallet opens', await waitFor(() => /receive/i.test(document.body.innerText)));
+ await page.goto('http://localhost:5247/chat', { waitUntil: 'domcontentloaded' });
+ await waitFor(() => !!document.querySelector('.chat-head'));
+ hd = await head();
+ check('no "sign in" for someone signed in', !!hd && !/sign in/i.test(hd.text), hd?.text.replace(/\n/g, ' | '));
+ check('the button offers the chat', hd?.button === 'Join the chat', hd?.button);
+ await Promise.all([page.waitForNavigation({ waitUntil: 'domcontentloaded' }), click('.chat-head button', 'Join the chat')]);
+ const inRoom = await waitFor(() => !!document.querySelector('.chat-compose textarea, .chat-compose input, .chat-compose [contenteditable]'), 20000);
+ check('it lands in the community room, composer ready', inRoom, await page.evaluate(() => document.body.innerText.slice(0, 120).replace(/\n/g, ' | ')));
+ check('the room is the coinos community', await page.evaluate(() => /coinos/i.test(document.querySelector('.chat-head, .chat-title')?.innerText || '')));
+ check('the marker is gone from the address', await page.evaluate(() => location.pathname === '/' && !location.search));
+} finally {
+ await browser.close();
+ server.stop(true);
+}
+console.log(ok ? '\nPASS' : '\nFAIL');
+process.exit(ok ? 0 : 1);
diff --git a/tools/puppeteer.js b/tools/puppeteer.js
index 2609981..8cefd6e 100644
--- a/tools/puppeteer.js
+++ b/tools/puppeteer.js
@@ -126,3 +126,20 @@ export default new Proxy(real, {
return typeof v === 'function' ? v.bind(t) : v;
},
});
+
+// The create flow (2026-11-07): a heads-up, then the words, then the
+// passphrase page, then a one-word check. seedWords() gets a test from
+// "Create a new wallet" to the words on screen; skipSeedCheck() from there
+// (or from the heads-up) into the wallet, no passphrase, check skipped.
+export async function seedWords(page) {
+ await page.waitForSelector('#seed-intro-next, .words .w .t', { timeout: 30000 });
+ if (await page.$('#seed-intro-next')) await page.evaluate(() => document.getElementById('seed-intro-next').click());
+ await page.waitForSelector('.words .w .t');
+}
+export async function skipSeedCheck(page) {
+ if (await page.$('#seed-intro-next')) await seedWords(page);
+ for (const id of ['seed-next', 'pass-next', 'seed-skip']) {
+ await page.waitForSelector('#' + id, { timeout: 10000 });
+ await page.evaluate((i) => document.getElementById(i).click(), id);
+ }
+}
diff --git a/tools/quote-pager-test.js b/tools/quote-pager-test.js
index 7f8b76c..d125edd 100644
--- a/tools/quote-pager-test.js
+++ b/tools/quote-pager-test.js
@@ -6,7 +6,7 @@
// quoted picture's details name its author, and closing lands on the post
// that quoted it.
// Run: bun tools/quote-pager-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { noteEncode } from 'nostr-tools/nip19';
@@ -80,9 +80,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pa, pq]) => {
diff --git a/tools/quoted-clip-fullscreen-test.js b/tools/quoted-clip-fullscreen-test.js
index bd8dd45..ce235f6 100644
--- a/tools/quoted-clip-fullscreen-test.js
+++ b/tools/quoted-clip-fullscreen-test.js
@@ -5,7 +5,7 @@
// handler took the clip back out of full screen for a pager that then
// wouldn't open.
// Run: bun tools/quoted-clip-fullscreen-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -47,9 +47,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, alice, bob, roots, replies]) => {
@@ -70,6 +70,8 @@ try {
console.log('\n[a tap on the picture]');
const box = await (await page.$(SEL)).boundingBox();
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [box.x + box.width / 2, box.y + box.height * 0.25]);
await page.mouse.click(box.x + box.width / 2, box.y + box.height * 0.25); // above the middle: not the play button
check('the clip goes full screen', await waitFor(() => document.fullscreenElement && document.fullscreenElement.matches('video'), 4000), JSON.stringify(await state()));
await sleep(600);
diff --git a/tools/receive-batch-fold-test.js b/tools/receive-batch-fold-test.js
new file mode 100644
index 0000000..8e38265
--- /dev/null
+++ b/tools/receive-batch-fold-test.js
@@ -0,0 +1,20 @@
+// One payment delivered as several coins — a gift whose 886 sats sat in two
+// (595 + 291, dust padding) swept in one claim — is one Received row.
+// sameDelivery decides which receive rows fold; nothing else does.
+// Run: bun tools/receive-batch-fold-test.js
+import { sameDelivery } from '../src/ark/manager.js';
+let ok = true;
+const check = (n, c) => { console.log(` ${c ? '✓' : '✗'} ${n}`); if (!c) ok = false; };
+const T = 1_790_000_000_000;
+const rcv = (id, sat, ts, extra = {}) => ({ id, type: 'receive', status: 'complete', amountSat: sat, ts, vtxoId: 'v-' + id, ...extra });
+check('two coins of one mailbox message fold', sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T, { batch: '7' })));
+check('...even when their instants differ (live, no checkpoint time)', sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T + 40, { batch: '7' })));
+check('two messages stay two rows, same instant or not', !sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T, { batch: '8' })));
+check('a stamped row never folds an unstamped one', !sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T)));
+check('older rows (no stamp) fold on the same delivery instant', sameDelivery(rcv('a', 595, T), rcv('b', 291, T)));
+check('...not a millisecond apart', !sameDelivery(rcv('a', 595, T), rcv('b', 291, T + 1)));
+check('a ⚡ receive is its own payment', !sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T, { batch: '7', viaLn: true })));
+check('a rejected coin is not part of the payment', !sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T, { batch: '7', status: 'rejected' })));
+check('only receives fold', !sameDelivery({ ...rcv('a', 595, T), type: 'send' }, rcv('b', 291, T)));
+console.log(ok ? '\n✅ one delivery, one row' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/relay-budget-test.js b/tools/relay-budget-test.js
new file mode 100644
index 0000000..d9798a3
--- /dev/null
+++ b/tools/relay-budget-test.js
@@ -0,0 +1,167 @@
+// What a Following feed asks of a relay, and how. Measured from the laptop
+// (2026-11-02) an open of Following sent some 560 REQs in twenty seconds:
+// a relay list asked per quoted note's author (one REQ per author per
+// relay), the live subscriptions closed and reopened at the end of every
+// refresh pass, the follows' reports fetched afresh at every reload, and
+// every one-shot REQ held open on our fast relay until the slowest relay
+// in the ask had answered. damus answered the home IP "banned: too many
+// rate-limit violations". Now: relay lists go out in one REQ per batch,
+// unchanged live subscriptions stay up, the reports sync honours the saved
+// clock, each relay's REQ closes on its own EOSE, and no relay carries more
+// than ten one-shot REQs at once. Run: bun tools/relay-budget-test.js
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { neventEncode } from 'nostr-tools/nip19';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+
+const html = await buildHtml({ minify: true, pwa: false });
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = () => Math.floor(Date.now() / 1000);
+const post = (sk, content, ago, tags = []) => finalizeEvent({ kind: 1, created_at: now() - ago, tags, content }, sk);
+// six people the follow quotes, each with one note on the relay
+const quoters = Array.from({ length: 6 }, () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; });
+const quotedNotes = quoters.map((q, i) => post(q.sk, `quoted thought ${i}`, 7200 + i));
+const stored = [...quotedNotes, post(SK, 'morning one', 3600), post(SK, 'morning two', 3000)];
+const quotePosts = quotedNotes.map((n, i) => post(SK, `look at this ${i} nostr:${neventEncode({ id: n.id, author: n.pubkey })}`, 1200 + i * 10, [['q', n.id, '', n.pubkey]]));
+stored.push(...quotePosts);
+// one quoted note only a far relay holds (the wide net's job)
+const farQ = generateSecretKey();
+const farNote = finalizeEvent({ kind: 1, created_at: now() - 9000, tags: [], content: 'kept only on the far relay' }, farQ);
+const farPost = post(SK, `and this nostr:${neventEncode({ id: farNote.id, author: farNote.pubkey })}`, 1100, [['q', farNote.id, '', farNote.pubkey]]);
+stored.push(farPost);
+const wideStore = [farNote];
+const cached = [...quotePosts, ...stored.filter((e) => e.pubkey === AUTHOR && !quotePosts.includes(e))].sort((a, b) => b.created_at - a.created_at);
+const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id)) && (!f.since || e.created_at >= f.since);
+
+// two relays on one server: /fast answers at once, /slow holds its EOSE
+const SLOW_MS = 3000;
+const log = []; // { t, relay, kind: 'REQ'|'CLOSE'|'EOSE', id, filters }
+const sockets = new Set();
+let maxOpen = { fast: 0, slow: 0 };
+const server = Bun.serve({ port: 0, fetch: (req, srv) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/fast' || path === '/slow' || path === '/wide') return srv.upgrade(req, { data: { relay: path.slice(1), subs: new Map() } }) ? undefined : new Response('ws', { status: 426 });
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+}, websocket: {
+ open(ws) { sockets.add(ws); },
+ message(ws, raw) {
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ const relay = ws.data.relay;
+ if (m[0] === 'EVENT') { ws.send(JSON.stringify(['OK', m[1].id, true, ''])); return; }
+ if (m[0] === 'REQ') {
+ const filters = m.slice(2);
+ ws.data.subs.set(m[1], filters);
+ maxOpen[relay] = Math.max(maxOpen[relay], ws.data.subs.size);
+ log.push({ t: Date.now(), relay, kind: 'REQ', id: m[1], filters });
+ const answer = () => {
+ if (!ws.data.subs.has(m[1])) return;
+ for (const e of (relay === 'wide' ? wideStore : stored)) if (filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e]));
+ ws.send(JSON.stringify(['EOSE', m[1]]));
+ log.push({ t: Date.now(), relay, kind: 'EOSE', id: m[1] });
+ };
+ if (relay === 'slow') setTimeout(answer, SLOW_MS); else answer();
+ } else if (m[0] === 'CLOSE') { ws.data.subs.delete(m[1]); log.push({ t: Date.now(), relay, kind: 'CLOSE', id: m[1] }); }
+ },
+ close(ws) { sockets.delete(ws); },
+} });
+const PORT = server.port;
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.setViewport({ width: 390, height: 844 });
+await page.evaluateOnNewDocument((port) => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (url, p) {
+ if (/relay\.coinos\.io/.test(String(url))) url = `ws://localhost:${port}/fast`;
+ else if (/relay\.damus\.io/.test(String(url))) url = `ws://localhost:${port}/slow`;
+ else if (/nostr\.wine/.test(String(url))) url = `ws://localhost:${port}/wide`;
+ return p ? new Real(url, p) : new Real(url);
+ };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+}, PORT);
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+const reqs = (relay, pred) => log.filter((x) => x.kind === 'REQ' && x.relay === relay && pred(x.filters));
+const isLive = (fs) => fs.some((f) => f.authors && f.since && !f.limit && f.kinds && f.kinds.includes(1));
+// a subscription meant to stay up: since with no limit, or limit 0 (the DM inbox)
+const isSub = (fs) => fs.some((f) => (f.since && !f.limit) || f.limit === 0);
+
+try {
+ await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await seedWords(page);
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await skipSeedCheck(page);
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Early Bird', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, cached]);
+ log.length = 0; maxOpen = { fast: 0, slow: 0 };
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ const t0 = Date.now();
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the quote posts are on screen', await waitFor(() => document.querySelectorAll('.notes-feed > .row[data-key]').length >= 6, null, 20000));
+ await sleep(9000); // the refresh pass, the quotes' lookups, the reports sync
+
+ console.log('\n[relay lists for the quoted notes\' authors]');
+ const qpks = new Set(quoters.map((q) => q.pk));
+ const listAsks = reqs('fast', (fs) => fs.some((f) => f.kinds && f.kinds.length === 1 && f.kinds[0] === 10002 && (f.authors || []).some((a) => qpks.has(a))));
+ const covered = new Set(listAsks.flatMap((x) => x.filters.flatMap((f) => f.authors || [])).filter((a) => qpks.has(a)));
+ check('asked for in one REQ, not one per author', listAsks.length === 1 && covered.size === 6, listAsks.length + ' REQ(s) covering ' + covered.size + ' of 6 authors');
+
+ console.log('\n[quoted notes]');
+ const wideIds = new Set(reqs('wide', (fs) => fs.some((f) => f.ids)).flatMap((x) => x.filters.flatMap((f) => f.ids || [])));
+ const nearHeld = quotedNotes.filter((n) => wideIds.has(n.id)).length;
+ check('the ones our relay holds were never asked of the wide net', nearHeld === 0, nearHeld + ' of 6 asked on the far relay');
+ check('the one only the far relay holds was found there', wideIds.has(farNote.id) && (await page.evaluate(() => document.body.innerText.includes('kept only on the far relay'))), wideIds.has(farNote.id) ? 'asked' : 'never asked');
+
+ console.log('\n[the live subscriptions]');
+ const live = reqs('fast', isLive);
+ const liveClosed = live.filter((x) => log.some((y) => y.kind === 'CLOSE' && y.relay === 'fast' && y.id === x.id));
+ check('opened once and kept across the refresh', live.length === 1 && liveClosed.length === 0, live.length + ' live REQs, ' + liveClosed.length + ' closed');
+
+ console.log('\n[one-shot REQs]');
+ const fastReqs = reqs('fast', (fs) => !isSub(fs));
+ const lag = [];
+ for (const r of fastReqs) {
+ const eose = log.find((y) => y.kind === 'EOSE' && y.relay === 'fast' && y.id === r.id);
+ const close = log.find((y) => y.kind === 'CLOSE' && y.relay === 'fast' && y.id === r.id && y.t >= r.t);
+ if (eose && close) { lag.push(close.t - eose.t); if (close.t - eose.t > 1500) console.log(' late close ' + (close.t - eose.t) + ' ms: ' + JSON.stringify(r.filters).slice(0, 160)); }
+ }
+ const slowLag = lag.filter((x) => x > 1500).length;
+ check('closed on the fast relay\'s own EOSE, not the slow relay\'s', lag.length >= 10 && slowLag === 0, lag.length + ' closed, ' + slowLag + ' later than 1.5 s (slowest ' + Math.max(0, ...lag) + ' ms; the slow relay holds EOSE ' + SLOW_MS + ' ms)');
+ const slowReqs = reqs('slow', () => true).length;
+ check('no more than ten one-shot REQs open on a relay at once', maxOpen.slow <= 10 + 5, slowReqs + ' REQs to the slow relay, at most ' + maxOpen.slow + ' open at once (fast: ' + maxOpen.fast + ')');
+ const totalFast = reqs('fast', () => true).length;
+ console.log(' ' + totalFast + ' REQs to the fast relay in ' + ((Date.now() - t0) / 1000).toFixed(0) + ' s');
+
+ console.log('\n[a reload within the half hour]');
+ const reportsBefore = reqs('fast', (fs) => fs.some((f) => f.kinds && f.kinds[0] === 1984)).length;
+ log.length = 0;
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ await sleep(6000);
+ const reportsAfter = reqs('fast', (fs) => fs.some((f) => f.kinds && f.kinds[0] === 1984)).length;
+ check('the follows\' reports are not fetched again', reportsBefore >= 1 && reportsAfter === 0, reportsBefore + ' REQ(s) on the first open, ' + reportsAfter + ' after the reload');
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ the feed asks the relays for less, and asks it in fewer REQs' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/relays-settings-test.js b/tools/relays-settings-test.js
index 9e18f59..771e3cd 100644
--- a/tools/relays-settings-test.js
+++ b/tools/relays-settings-test.js
@@ -3,7 +3,7 @@
// both only after a fresh lookup, newer than the copies it replaces.
// No relay is written for real — the WebSocket shim swallows EVENT.
// Run: bun tools/relays-settings-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
import { buildHtml } from '../build.js';
@@ -71,9 +71,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const ME_SK = privateKeyFromSeedWords(mn);
const ME = getPublicKey(ME_SK);
diff --git a/tools/resume-wake-test.js b/tools/resume-wake-test.js
new file mode 100644
index 0000000..c142dfb
--- /dev/null
+++ b/tools/resume-wake-test.js
@@ -0,0 +1,111 @@
+// Back from the background on a phone, the app's relay sockets read OPEN
+// and are dead (the tab was frozen; the network forgot them), and the
+// catch-up it sends on them finds nothing. The library noticed a dead one
+// only after its 29 s ping + 20 s timeout, then waited 10 s to redial — a
+// Following feed stuck at the morning's posts for a minute or more after
+// opening the app in the evening ("no new notes since 11:35?", Adam,
+// 2026-11-01). On resume every open socket is now asked one tiny REQ; a
+// silent one is closed and redialed at once, its subscriptions refired, and
+// the feed refresh runs on the live sockets. Run: bun tools/resume-wake-test.js
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+
+const html = await buildHtml({ minify: true, pwa: false });
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = () => Math.floor(Date.now() / 1000);
+const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now() - ago, tags: [], content }, SK);
+const stored = [post('morning one', 3600), post('morning two', 3000), post('morning three', 2400)];
+const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id)) && (!f.since || e.created_at >= f.since);
+// one relay; a socket marked dead keeps its connection and answers nothing,
+// the way a phone's frozen sockets do once the network has forgotten them
+const sockets = new Set(); let opened = 0, closed = 0;
+const server = Bun.serve({ port: 0, fetch: (req, srv) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/relay') return srv.upgrade(req) ? undefined : new Response('ws', { status: 426 });
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+}, websocket: {
+ open(ws) { ws.data = { subs: new Map(), dead: false }; sockets.add(ws); opened++; },
+ message(ws, raw) {
+ if (ws.data.dead) return;
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ if (m[0] === 'EVENT') { ws.send(JSON.stringify(['OK', m[1].id, true, ''])); return; }
+ if (m[0] === 'REQ') {
+ const s = { id: m[1], filters: m.slice(2) };
+ ws.data.subs.set(m[1], s);
+ for (const e of stored) if (s.filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e]));
+ ws.send(JSON.stringify(['EOSE', m[1]]));
+ } else if (m[0] === 'CLOSE') ws.data.subs.delete(m[1]);
+ },
+ close(ws) { sockets.delete(ws); closed++; },
+} });
+const PORT = server.port;
+const liveSubs = () => [...sockets].filter((s) => !s.data.dead).flatMap((s) => [...s.data.subs.values()]);
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.setViewport({ width: 390, height: 844 });
+await page.evaluateOnNewDocument((port) => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (url, p) { if (/relay\.coinos\.io/.test(String(url))) url = `ws://localhost:${port}/relay`; return p ? new Real(url, p) : new Real(url); };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+}, PORT);
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const warns = [];
+page.on('console', (m) => { if (/redialing|silent after resume/.test(m.text())) warns.push(m.text()); });
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { const t0 = Date.now(); for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return (Date.now() - t0) / 1000; await sleep(100); } return null; };
+const rowTexts = () => page.evaluate(() => [...document.querySelectorAll('.notes-feed > .row[data-key]')].map((r) => r.textContent.replace(/\s+/g, ' ').trim().slice(0, 40)));
+
+try {
+ await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await seedWords(page);
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await skipSeedCheck(page);
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Early Bird', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, stored]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the morning\'s posts are in the feed', (await waitFor(() => document.querySelectorAll('.notes-feed > .row[data-key]').length >= 3, null, 20000)) != null, (await rowTexts()).join(' | '));
+ check('the feed watches the relay live', (await waitFor(() => true, null, 100), await (async () => { for (let i = 0; i < 100; i++) { if (liveSubs().some((s) => s.filters.some((f) => f.authors && f.since))) return true; await sleep(100); } return false; })()), liveSubs().length + ' subs on ' + sockets.size + ' sockets');
+ const openedBefore = opened;
+
+ console.log('\n[the phone sleeps: its sockets die quietly, a follow posts]');
+ for (const s of sockets) s.data.dead = true;
+ const fresh = post('evening news', 0);
+ stored.push(fresh);
+ await page.evaluate((k) => { try { localStorage.setItem('btc-wallet-bg-at', String(Date.now() - 10 * 60_000)); } catch {} }, base);
+ await sleep(300);
+
+ console.log('\n[back on screen]');
+ const t0 = Date.now();
+ await page.evaluate(() => window.dispatchEvent(new Event('focus')));
+ const redial = await (async () => { for (let i = 0; i < 150; i++) { if (opened > openedBefore) return (Date.now() - t0) / 1000; await sleep(100); } return null; })();
+ check('the dead socket is found out and redialed within seconds', redial != null && redial < 6, redial != null ? redial.toFixed(1) + 's (' + warns[0] + ')' : 'no new socket in 15 s');
+ const shown = await waitFor(() => { const p = document.querySelector('.feed-new-pill'); if (p) p.click(); return [...document.querySelectorAll('.notes-feed > .row[data-key]')].some((r) => r.textContent.includes('evening news')); }, null, 15000);
+ check('the post from while it slept is on screen within seconds', shown != null && (Date.now() - t0) / 1000 < 12, shown != null ? ((Date.now() - t0) / 1000).toFixed(1) + 's after resume' : (await rowTexts()).join(' | '));
+ check('the dead sockets were let go', closed >= 1, closed + ' closed');
+ check('…and the live subscriptions are back on the new socket', liveSubs().some((s) => s.filters.some((f) => f.authors && f.since)), liveSubs().length + ' subs');
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ back from the background, dead sockets are redialed at once and the feed catches up' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/room-faces-test.js b/tools/room-faces-test.js
index cc4c6fe..ecbcc14 100644
--- a/tools/room-faces-test.js
+++ b/tools/room-faces-test.js
@@ -4,7 +4,7 @@
// profiles cached — the worst case — against the real coinos community.
//
// Run: bun tools/room-faces-test.js [--no-hover]
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { buildHtml } from '../build.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
@@ -22,8 +22,8 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
- await click('skip verification');
+ await seedWords(page);
+ await skipSeedCheck(page);
await waitText('receive', 20000);
await page.evaluate(() => [...document.querySelectorAll('.app-bottom-nav .app-nav-button')][1].click());
await waitText('members', 20000);
diff --git a/tools/scheduled-post-test.js b/tools/scheduled-post-test.js
new file mode 100644
index 0000000..e4a61f1
--- /dev/null
+++ b/tools/scheduled-post-test.js
@@ -0,0 +1,99 @@
+// Scheduled posts: the composer's clock button picks a time; Schedule signs
+// the note with that created_at and hands it to the notifier (POST
+// nwcpush /schedule), nothing goes to a relay, the composer closes and the
+// post waits in a "Scheduled" list, where Cancel sends a kind 5 for it to
+// /schedule/cancel. The notifier is stubbed.
+// Run: bun tools/scheduled-post-test.js
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
+import { verifyEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5316, fetch: (req) => { const p = new URL(req.url).pathname; if (p === '/verify-worker.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p)); return new Response(html, { headers: { 'content-type': 'text/html' } }); } });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument(() => {
+ window.__published = [];
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') { window.__published.push(m[1]); setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', m[1].id, true, '']) })), 0); return; } } catch {} return send(d); }; return ws; };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+});
+const held = new Map();
+const calls = [];
+await page.setRequestInterception(true);
+page.on('request', (r) => {
+ const u = new URL(r.url());
+ if (u.host !== 'nwcpush.coinos.io' || !u.pathname.startsWith('/schedule')) return r.continue();
+ const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type' };
+ if (r.method() === 'OPTIONS') return r.respond({ status: 200, headers: cors, body: '' });
+ const body = r.postData() ? JSON.parse(r.postData()) : null;
+ calls.push({ path: u.pathname, body });
+ let res = { ok: true };
+ if (u.pathname === '/schedule') held.set(body.event.id, body.event);
+ if (u.pathname === '/schedule/cancel') { let n = 0; for (const [k, id] of body.event.tags) if (k === 'e' && held.delete(id)) n++; res = { ok: true, cancelled: n }; }
+ if (u.pathname === '/schedule/status') res = Object.fromEntries(u.searchParams.get('ids').split(',').map((id) => [id, held.has(id) ? 'pending' : 'gone']));
+ r.respond({ status: 200, headers: { ...cors, 'content-type': 'application/json' }, body: JSON.stringify(res) });
+});
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, ms = 15000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; };
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5316/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await seedWords(page);
+ await skipSeedCheck(page);
+ await waitText('receive', 20000);
+
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
+ await waitFor(() => !!document.querySelector('.feed-post-fab'), 15000);
+ await page.evaluate(() => document.querySelector('.feed-post-fab').click());
+ await waitFor(() => !!document.querySelector('.post-input'), 8000);
+ await page.focus('.post-input');
+ await page.keyboard.type('later, #coinos', { delay: 10 });
+ await page.evaluate(() => document.querySelector('.post-actions button[aria-label="Schedule for later"]').click());
+ check('the clock button shows a time field, about an hour ahead', await waitFor(() => !!document.querySelector('.sched-pick input[type=datetime-local]'), 5000));
+ const shown = await page.$eval('.sched-pick input', (e) => e.value);
+ const shownAt = new Date(shown).getTime();
+ check('...on a quarter hour', shownAt - Date.now() > 3500_000 && shownAt - Date.now() < 4500_000 && new Date(shownAt).getMinutes() % 15 === 0, shown);
+ check('the main button reads Schedule', await page.evaluate(() => document.querySelector('.post-actions .btn-primary').textContent.trim() === 'Schedule'));
+ // two hours ahead, typed as the user would
+ const want = new Date(Date.now() + 2 * 3600_000); want.setSeconds(0, 0);
+ const local = new Date(want.getTime() - want.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
+ await page.evaluate((v) => { const i = document.querySelector('.sched-pick input'); i.value = v; i.dispatchEvent(new Event('input', { bubbles: true })); }, local);
+ await page.evaluate(() => { window.__published = []; document.querySelector('.post-actions .btn-primary').click(); });
+ await waitFor(() => !document.querySelector('.post-input'), 8000);
+ const sc = calls.find((c) => c.path === '/schedule');
+ const ev = sc && sc.body.event;
+ check('the notifier gets a signed kind 1 dated at the chosen time', !!ev && ev.kind === 1 && verifyEvent(ev) && ev.created_at === Math.floor(want.getTime() / 1000), ev && new Date(ev.created_at * 1000).toISOString());
+ check('...with its tags (t coinos, client) and the relays to send it to', !!ev && ev.tags.some((x) => x[0] === 't' && x[1] === 'coinos') && ev.tags.some((x) => x[0] === 'client') && sc.body.relays.length > 0, JSON.stringify(sc?.body.relays));
+ check('nothing went to a relay', !(await page.evaluate(() => window.__published.some((e) => e.kind === 1))));
+ check('the composer closed and the draft is cleared', await page.evaluate(() => !document.querySelector('.post-input')));
+ check('the post is not in the feed yet', await page.evaluate(() => !document.body.innerText.includes('later, #coinos') || !!document.querySelector('.sched-list')) && !(await page.evaluate(() => [...document.querySelectorAll('.note-text')].some((n) => n.textContent.includes('later, #coinos')))));
+
+ // the list shows with the composer open and on your profile
+ await page.evaluate(() => document.querySelector('.feed-post-fab').click());
+ check('the composer lists it under Scheduled (1)', await waitFor(() => /Scheduled \(1\)/.test(document.querySelector('.sched-list')?.innerText || ''), 5000));
+ check('...with its words', await page.evaluate(() => document.querySelector('.sched-item').innerText.includes('later, #coinos')));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
+ await waitFor(() => !!document.querySelector('.feed-post-fab'), 15000);
+ await page.evaluate(() => document.querySelector('.feed-post-fab').click());
+ check('it survives a reload (and the status check keeps it)', await waitFor(() => !!document.querySelector('.sched-item'), 8000) && calls.some((c) => c.path === '/schedule/status'));
+ await page.evaluate(() => [...document.querySelectorAll('.sched-item button')].find((b) => b.textContent.trim() === 'Cancel').click());
+ await waitFor(() => !document.querySelector('.sched-item'), 8000);
+ const cc = calls.find((c) => c.path === '/schedule/cancel');
+ check('Cancel sends a kind 5 naming it, signed by the author', !!cc && cc.body.event.kind === 5 && verifyEvent(cc.body.event) && cc.body.event.pubkey === ev.pubkey && cc.body.event.tags.some((x) => x[0] === 'e' && x[1] === ev.id));
+ check('...and it leaves the list', await page.evaluate(() => !document.querySelector('.sched-list')));
+ check('no page errors', !errs.length, errs.join(' | '));
+} catch (e) { check('ran', false, e.stack); }
+await browser.close(); server.stop();
+console.log(ok ? '\nALL PASS' : '\nFAILED');
+process.exit(ok ? 0 : 1);
diff --git a/tools/seed-cache-test.js b/tools/seed-cache-test.js
new file mode 100644
index 0000000..6250d65
--- /dev/null
+++ b/tools/seed-cache-test.js
@@ -0,0 +1,60 @@
+// The seed a phrase stretches to, and the vault under the blank password:
+// both now come from caches, and must come out exactly as before.
+// seed.js: WebCrypto's PBKDF2 (warmSeed) gives @scure/bip39's seed, byte
+// for byte, for plain, passphrased and non-ASCII (NFKD) input; seedSync
+// finds it waiting, and works cold. nostr.js's NIP-06 key matches
+// nostr-tools' privateKeyFromSeedWords.
+// wallet.js: a blank vault's key is kept and reused (no scrypt on the
+// second open); a vault sealed under a real password keeps nothing.
+// Run: bun tools/seed-cache-test.js
+import assert from 'node:assert/strict';
+import { mnemonicToSeedSync, generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english.js';
+import * as nip06 from 'nostr-tools/nip06';
+
+const store = new Map();
+globalThis.localStorage = { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k) };
+
+const { seedSync, warmSeed, forgetSeeds } = await import('../src/seed.js');
+const { nostrKeyFromSeed } = await import('../src/nostr.js');
+const { encryptVault, decryptVault } = await import('../src/wallet.js');
+const hex = (u) => Buffer.from(u).toString('hex');
+
+const cases = [
+ [generateMnemonic(wordlist), ''],
+ [generateMnemonic(wordlist, 256), 'correct horse'],
+ [generateMnemonic(wordlist), 'pässwörd Ⅸ'], // NFKD changes these
+];
+for (const [m, p] of cases) {
+ forgetSeeds();
+ await warmSeed(m, p);
+ const t = performance.now();
+ const warm = seedSync(m, p);
+ assert.ok(performance.now() - t < 5, 'a warmed seed is read, not stretched');
+ assert.equal(hex(warm), hex(mnemonicToSeedSync(m, p)), 'WebCrypto PBKDF2 = @scure/bip39');
+ forgetSeeds();
+ assert.equal(hex(seedSync(m, p)), hex(mnemonicToSeedSync(m, p)), 'cold seedSync = @scure/bip39');
+ for (const i of [0, 3]) assert.equal(hex(nostrKeyFromSeed(m, p, i)), hex(nip06.privateKeyFromSeedWords(m, p || undefined, i)), 'NIP-06 key = nostr-tools');
+}
+await warmSeed('not a phrase', '');
+assert.throws(() => seedSync('not a phrase', ''), 'a malformed phrase is still refused');
+console.log('✓ seeds match @scure/bip39 and nostr-tools, warmed or cold');
+
+const list = [{ label: 'a', mnemonic: cases[0][0], passphrase: '' }];
+const blob = encryptVault(list, '');
+assert.ok(store.get('btc-wallet-vault-k0'), 'the blank vault key is kept');
+let t = performance.now();
+assert.deepEqual(decryptVault(blob, ''), list);
+assert.ok(performance.now() - t < 30, `opening a blank vault needs no scrypt (${Math.round(performance.now() - t)} ms)`);
+const blob2 = encryptVault(list, '');
+assert.equal(blob2.salt, blob.salt, 'a new blank seal reuses the kept salt');
+assert.notEqual(blob2.nonce, blob.nonce, 'never the nonce');
+assert.deepEqual(decryptVault(blob2, ''), list);
+assert.throws(() => decryptVault(blob, 'guess'), 'a wrong password still fails');
+assert.ok(store.get('btc-wallet-vault-k0'), 'and a failed guess drops nothing');
+const sealed = encryptVault(list, 'hunter2');
+assert.equal(store.get('btc-wallet-vault-k0'), undefined, 'sealing under a real password keeps nothing');
+assert.deepEqual(decryptVault(sealed, 'hunter2'), list);
+assert.throws(() => decryptVault(sealed, ''));
+console.log('✓ blank vault key kept and reused; real passwords untouched');
+console.log('\n✅ seed + vault caches behave');
diff --git a/tools/sensitive-feed-test.js b/tools/sensitive-feed-test.js
index a050269..626da2c 100644
--- a/tools/sensitive-feed-test.js
+++ b/tools/sensitive-feed-test.js
@@ -4,7 +4,7 @@
// accounts whose own profile says they post adult work. Elsewhere a marked
// post is folded behind its warning with a Show.
// Run: bun tools/sensitive-feed-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
import { buildHtml, buildVerifyWorker } from '../build.js';
@@ -74,9 +74,9 @@ try {
await sleep(400);
const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await page.waitForFunction(() => /receive/i.test(document.body.innerText), { timeout: 20000 });
await sleep(3000); // the starter follows land first
const base = cacheKeyFor(mn + '\n');
diff --git a/tools/signer-resume-test.js b/tools/signer-resume-test.js
new file mode 100644
index 0000000..9ca3950
--- /dev/null
+++ b/tools/signer-resume-test.js
@@ -0,0 +1,140 @@
+// A remote signer (Amethyst) that wasn't heard the moment the app needed it
+// — the tab was frozen, a socket cut, the signer app asleep — put up
+// "Reconnect your signer" and then never tried the stored session again, so
+// every time meant a brand-new connection (Adam's phone, 2026-10-29: the
+// stored session answered in 0.7 s the moment anyone asked). Now the screen
+// keeps trying the stored session and closes itself once the signer answers.
+// The remote signer is played by this script over a shimmed relay.
+// Run: bun tools/signer-resume-test.js
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import * as nip44 from 'nostr-tools/nip44';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const SK = generateSecretKey(); const PK = getPublicKey(SK); // the account, held by the remote signer
+const CLIENT_SK = generateSecretKey(); const CLIENT = getPublicKey(CLIENT_SK); // the client key it authorised long ago
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5319, fetch: (req) => { const p = new URL(req.url).pathname; if (p === '/verify-worker.js' || p === '/nip46.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p)); return new Response(html, { headers: { 'content-type': 'text/html' } }); } });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.setUserAgent('Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Mobile Safari/537.36');
+await page.evaluateOnNewDocument((fx) => {
+ window.__amberUrls = [];
+ window.__coinosAmberLaunch = (u) => { window.__amberUrls.push(u); };
+ window.__published = [];
+ const subs = [];
+ const fits = (ev, f) => (!f.kinds || f.kinds.includes(ev.kind)) && (!f['#p'] || ev.tags.some((t) => t[0] === 'p' && f['#p'].includes(t[1])))
+ && (!f.authors || f.authors.includes(ev.pubkey)) && (!f.since || ev.created_at >= f.since);
+ // the test pushes the signer's answers through here, to every live sub that wants them
+ window.__inject = (ev) => { for (const s of subs) if (s.fs.some((f) => fits(ev, f))) s.ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', s.id, ev]) })); };
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const send = ws.send.bind(ws);
+ ws.send = (d) => {
+ try {
+ const m = JSON.parse(d);
+ if (m[0] === 'REQ') {
+ const fs = m.slice(2);
+ for (let i = subs.length - 1; i >= 0; i--) if (subs[i].ws === ws && subs[i].id === m[1]) subs.splice(i, 1);
+ subs.push({ ws, id: m[1], fs });
+ const evs = fx.filter((ev) => fs.some((f) => fits(ev, f)));
+ setTimeout(() => {
+ for (const ev of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) }));
+ ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) }));
+ }, 0);
+ return;
+ }
+ if (m[0] === 'CLOSE') { for (let i = subs.length - 1; i >= 0; i--) if (subs[i].ws === ws && subs[i].id === m[1]) subs.splice(i, 1); return; }
+ if (m[0] === 'EVENT') {
+ window.__published.push(m[1]);
+ setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', m[1].id, true, '']) })), 0);
+ return;
+ }
+ } catch {}
+ return send(d);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+}, []);
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const text = () => page.evaluate(() => document.body.innerText);
+const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await text()).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+
+// The remote signer: answers the connect, then get_public_key / nip44_decrypt.
+let client = CLIENT, awake = false, handled = new Set(), decrypts = 0;
+const signerLoop = async () => {
+ if (!awake) return;
+ const pub = await page.evaluate(() => window.__published.filter((e) => e.kind === 24133));
+ for (const ev of pub) {
+ if (handled.has(ev.id) || !client || ev.pubkey !== client) continue;
+ handled.add(ev.id);
+ const ck = nip44.getConversationKey(SK, ev.pubkey);
+ const req = JSON.parse(nip44.decrypt(ev.content, ck));
+ let result;
+ if (req.method === 'get_public_key') result = PK;
+ else if (req.method === 'nip44_decrypt') { decrypts++; result = nip44.decrypt(req.params[1], nip44.getConversationKey(SK, req.params[0])); }
+ else if (req.method === 'nip44_encrypt') result = nip44.encrypt(req.params[1], nip44.getConversationKey(SK, req.params[0]));
+ else if (req.method === 'sign_event') { const u = JSON.parse(req.params[0]); result = JSON.stringify(finalizeEvent(u, SK)); }
+ else if (req.method === 'ping') result = 'pong';
+ else if (req.method === 'connect') result = 'ack';
+ const resp = finalizeEvent({ kind: 24133, created_at: Math.floor(Date.now() / 1000), tags: [['p', ev.pubkey]], content: nip44.encrypt(JSON.stringify({ id: req.id, result }), ck) }, SK);
+ await page.evaluate((e) => window.__inject(e), resp);
+ }
+};
+
+
+const waitFor = async (fn, ms = 15000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; };
+const pump = async (ms) => { for (let i = 0; i < ms / 250; i++) { await signerLoop(); await sleep(250); } };
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5319/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await seedWords(page);
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await skipSeedCheck(page);
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ const local = Array.from(CLIENT_SK, (b) => b.toString(16).padStart(2, '0')).join('');
+ await page.evaluate(([k, pk, local]) => {
+ localStorage.setItem(k + ':nostrlogin', JSON.stringify({ pubkey: pk, linked: Date.now(), via: 'bunker',
+ session: { local, bp: { pubkey: pk, relays: ['wss://relay.coinos.io'], secret: null } } }));
+ }, [base, PK, local]);
+
+ console.log('\n[the signer is asleep when the app needs it]');
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
+ await waitFor(() => !!document.querySelector('.feed-post-fab'));
+ await page.evaluate(() => document.querySelector('.feed-post-fab').click());
+ await waitFor(() => !!document.querySelector('.post-input'), 8000);
+ await page.focus('.post-input');
+ await page.keyboard.type('hello', { delay: 10 });
+ await page.evaluate(() => document.querySelector('.post-actions .btn-primary').click());
+ check('the reconnect screen comes up', await waitText('reconnect your signer', 45000));
+ await sleep(16000); // let the screen's first try run out unanswered
+ check('...and stays while the signer is silent', (await text()).toLowerCase().includes('reconnect your signer'));
+
+ console.log('\n[the signer wakes up]');
+ awake = true;
+ const t0 = Date.now();
+ let gone = false;
+ for (let i = 0; i < 160 && !gone; i++) { await signerLoop(); gone = !(await text()).toLowerCase().includes('reconnect your signer'); if (!gone) await sleep(250); }
+ check('the screen closes by itself, no new connection', gone, gone ? `${Math.round((Date.now() - t0) / 1000)} s after the signer answered` : 'still up after 40 s');
+ const st = await page.evaluate((k) => JSON.parse(localStorage.getItem(k + ':nostrlogin') || '{}'), base);
+ check('the stored session is the same one', st.session && st.session.local === local);
+ check('no nostrconnect link was needed', !(await page.evaluate((pk) => window.__published.some((e) => e.kind === 24133 && e.pubkey !== pk), CLIENT)));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ a stored remote-signer session comes back by itself' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/slow-image-test.js b/tools/slow-image-test.js
index 103f6da..43eff97 100644
--- a/tools/slow-image-test.js
+++ b/tools/slow-image-test.js
@@ -4,7 +4,7 @@
// inside a fixed box (nothing moves when it lands); a picture that refused to
// load is still a link.
// Run: bun tools/slow-image-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -46,9 +46,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/standalone-file-test.js b/tools/standalone-file-test.js
index 743bc7d..13445f6 100644
--- a/tools/standalone-file-test.js
+++ b/tools/standalone-file-test.js
@@ -3,7 +3,7 @@
// Run: bun run build && bun tools/standalone-file-test.js
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
try {
@@ -25,7 +25,7 @@ try {
await page.evaluate(() => { const s = [...document.querySelectorAll('select')].find((x) => [...x.options].some((o) => o.value === 'en')); s.value = 'en'; s.dispatchEvent(new Event('change', { bubbles: true })); });
await sleep(500);
await click('create a new wallet'); await sleep(1500);
- await click('skip verification'); await sleep(1500);
+ await skipSeedCheck(page); await sleep(1500);
// the avatar step comes after a name claim; resume the wizard there
await page.evaluate(() => localStorage.setItem('btc-wallet-onb-step', 'avatar'));
await page.reload(); await sleep(2500);
diff --git a/tools/sw-push-quota.test.js b/tools/sw-push-quota.test.js
index e7d00f2..798b913 100644
--- a/tools/sw-push-quota.test.js
+++ b/tools/sw-push-quota.test.js
@@ -4,8 +4,9 @@
import { test, expect, mock } from 'bun:test';
let respond = async () => true;
-mock.module('../src/nwc-respond.js', () => ({ respondFromBg: (...a) => respond(...a), tendRenewals: async () => {} }));
-mock.module('../src/nwc-lock.js', () => ({ withRequestLock: async (_id, fn) => fn() }));
+mock.module('../src/nwc-respond.js', () => ({ respondFromBg: (...a) => respond(...a), tendRenewals: async () => {}, tendLnPays: async () => [] }));
+mock.module('../src/nwc-lock.js', () => ({ withRequestLock: async (_id, fn) => fn(), withWorkLock: async (fn) => fn() }));
+mock.module('../src/nwc-bg.js', () => ({ appendBgLog: async () => {}, takeLease: async () => ({ waited: 0, release: async () => {} }) }));
mock.module('../src/bg-autowithdraw.js', () => ({ bgAutoWithdraw: async () => {} }));
mock.module('../src/dm-inbox.js', () => ({
allInboxes: async () => [{ muted: ['m'.repeat(64)] }],
@@ -50,6 +51,13 @@ test('apple: every quiet path shows the stand-in', async () => {
expect(closed.length).toBe(0);
});
+test('apple: a send finished from an earlier wake shows its own line and drops the stand-in', async () => {
+ endpoint = 'https://web.push.apple.com/abc'; windows = [];
+ respond = async () => ({ notices: [{ title: 'Payment sent', body: '-21 sats via a connected app', tag: 'nwc-sent' }] });
+ expect(await push(nwc)).toEqual(['Coinos', 'Payment sent']);
+ expect(closed.length).toBe(1);
+});
+
test('apple: an app on screen gets the stand-in closed at once', async () => {
windows = [visible];
expect(await push({ type: 'notify', reason: 'payment', amountSat: 5 })).toEqual(['Coinos']);
@@ -60,7 +68,9 @@ test('apple: a suspended background app does not swallow the request', async ()
windows = [hidden];
let asked = 0;
respond = async () => { asked++; return { notify: { title: 'Payment sent', body: '' } }; };
- expect(await push(nwc)).toEqual(['Payment sent']);
+ // the stand-in goes up first and the outcome replaces it (91dbee4)
+ expect(await push(nwc)).toEqual(['Coinos', 'Payment sent']);
+ expect(closed.length).toBe(1);
expect(asked).toBe(1);
});
@@ -72,8 +82,33 @@ test('elsewhere: quiet stays quiet', async () => {
let asked = 0;
respond = async () => { asked++; return true; };
expect(await push(nwc)).toEqual([]);
- expect(asked).toBe(0); // the open (if hidden) window answers on Android
+ expect(asked).toBe(0); // on a computer the open (if hidden) window answers
windows = [];
respond = async () => false;
- expect(await push(nwc)).toEqual(['Payment request']);
+ expect(await push(nwc)).toEqual(['Spend request']);
+});
+
+// An Android phone freezes an app behind another one, minutes in: its relay
+// sockets are dead and the worker's nudge sits unread until it is opened, so
+// a zap sent from another app spun until then (wake to payment: 15 s at the
+// median on Android, 2026-11-10). On a phone only a window on screen is
+// handed the request; behind another app, the worker answers (and nudges the
+// window anyway — the request's lock decides if it is still awake).
+test('an Android phone: the app behind another one does not swallow the request', async () => {
+ endpoint = 'https://fcm.googleapis.com/fcm/send/abc';
+ self.navigator = { userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Mobile Safari/537.36' };
+ try {
+ let asked = 0, nudged = 0;
+ respond = async () => { asked++; return true; };
+ windows = [{ visibilityState: 'hidden', postMessage: () => { nudged++; } }];
+ expect(await push(nwc)).toEqual([]);
+ expect(asked).toBe(1);
+ expect(nudged).toBe(1);
+ // on screen, it answers itself with everything it knows
+ asked = 0; nudged = 0;
+ windows = [{ visibilityState: 'visible', postMessage: () => { nudged++; } }];
+ expect(await push(nwc)).toEqual([]);
+ expect(asked).toBe(0);
+ expect(nudged).toBe(1);
+ } finally { delete self.navigator; }
});
diff --git a/tools/sw-reload-test.js b/tools/sw-reload-test.js
index 6ad4fed..d626514 100644
--- a/tools/sw-reload-test.js
+++ b/tools/sw-reload-test.js
@@ -1,11 +1,12 @@
-// A deploy should not refresh the page out from under you.
+// Opening the app is instant, and a deploy never refreshes the page out from
+// under you.
//
-// The app is inlined in the page, so when a new worker takes over, a page
-// still running the old build has to reload. But navigations are
-// network-first: the refresh that triggered the update has usually already
-// fetched the new page, and reloading then is a second, unasked-for refresh a
-// beat after your own. Both halves are asserted here — the quiet case and the
-// case where a reload is genuinely owed.
+// Navigations to / are cache-first: a launch paints the installed build's
+// shell without waiting on the network, and a deploy that lands meanwhile
+// installs in the background and opens on the next launch. A refresh goes to
+// the network and gets the new build at once, without a second reload. A page
+// that installs a newer build while open reloads only if nothing was touched,
+// and only as it is put away.
//
// Run: bun tools/sw-reload-test.js (needs a built dist/)
import puppeteer from './puppeteer.js';
@@ -21,15 +22,25 @@ if (!token) { console.log('no build token in dist/index.html — run bun run bui
// A "new deploy" is the same app under a different build token.
const restamp = (tok) => ({ html: html1.replaceAll(token, tok), sw: sw1.replaceAll(token, tok) });
-const builds = { v1: { html: html1, sw: sw1 }, v2: restamp('deadbeef00001'), v3: restamp('deadbeef00002') };
+const builds = { v1: { html: html1, sw: sw1 }, v2: restamp('deadbeef00001'), v3: restamp('deadbeef00002'), v4: restamp('deadbeef00003') };
let current = builds.v1;
+let pageHits = 0; // network requests for the page itself
+let preloads = 0;
const server = Bun.serve({
port: 5223,
fetch(req) {
const p = new URL(req.url).pathname;
if (p === '/sw.js') return new Response(current.sw, { headers: { 'content-type': 'text/javascript', 'cache-control': 'no-store' } });
- if (p === '/' || p === '/index.html') return new Response(current.html, { headers: { 'content-type': 'text/html', 'cache-control': 'no-store' } });
+ if (p === '/' || p === '/index.html') {
+ pageHits++;
+ // as cs nginx does: a launch's preload (no Cache-Control) gets no body
+ if (req.headers.get('service-worker-navigation-preload')) {
+ preloads++;
+ if (!req.headers.get('cache-control')) return new Response(null, { status: 204 });
+ }
+ return new Response(current.html, { headers: { 'content-type': 'text/html', 'cache-control': 'no-store' } });
+ }
// The worker precaches the shell during install and addAll rejects on any
// miss — a 404 here means it never activates and the test proves nothing.
// The split build names its entry after the token (app-<token>.js), so
@@ -43,9 +54,9 @@ const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome
const page = await browser.newPage();
let loads = 0;
page.on('load', () => { loads++; });
-page.on('console', (m) => { if (m.type() === 'error') console.log(' [page]', m.text().slice(0, 160)); });
page.on('pageerror', (e) => console.log(' [pageerror]', e.message.slice(0, 160)));
+const running = () => page.evaluate(() => (document.documentElement.outerHTML.match(/_v='([a-z0-9]+)'/) || [])[1]);
const controlled = async (ms = 15000) => {
for (let i = 0; i < ms / 250; i++) {
if (await page.evaluate(() => !!navigator.serviceWorker.controller)) return true;
@@ -53,45 +64,81 @@ const controlled = async (ms = 15000) => {
}
return false;
};
+// the worker's own build, once the newest one has taken over
+const workerBuild = () => page.evaluate(() => new Promise((res) => {
+ const c = navigator.serviceWorker.controller;
+ if (!c) return res(null);
+ const on = (e) => { if (e.data && e.data.type === 'sw-version') { navigator.serviceWorker.removeEventListener('message', on); res(e.data.version); } };
+ navigator.serviceWorker.addEventListener('message', on);
+ c.postMessage({ type: 'sw-version' });
+ setTimeout(() => res(null), 2000);
+}));
+const waitWorker = async (tok) => { for (let i = 0; i < 60; i++) { if ((await workerBuild()) === tok) return true; await sleep(250); } return false; };
+const putAway = () => page.evaluate(() => {
+ Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' });
+ document.dispatchEvent(new Event('visibilitychange'));
+});
try {
console.log('[first visit]');
await page.goto('http://localhost:5223/', { waitUntil: 'load' });
- const reg = await page.evaluate(async () => {
- try {
- const r = await navigator.serviceWorker.register('sw.js', { updateViaCache: 'none' });
- return { ok: true, scope: r.scope, active: !!r.active, installing: !!r.installing, waiting: !!r.waiting };
- } catch (e) { return { ok: false, err: String(e).slice(0, 200) }; }
- });
- console.log(' register:', JSON.stringify(reg));
+ await page.evaluate(() => navigator.serviceWorker.register('sw.js', { updateViaCache: 'none' }));
check('worker takes control', await controlled());
await sleep(3000);
check('a first visit does not reload itself', loads === 1, `${loads} load(s)`);
- console.log('\n[refreshing after a deploy]');
+ console.log('\n[opening the app after a deploy]');
current = builds.v2;
- const before = loads;
+ let hits = pageHits, pre = preloads, before = loads;
+ await page.goto('http://localhost:5223/?u=x', { waitUntil: 'load' });
+ check('the launch paints the installed build', (await running()) === token, `running ${await running()}`);
+ check('without fetching the page (only an empty launch preload)', pageHits - hits === preloads - pre, `${pageHits - hits} page request(s), ${preloads - pre} of them preloads`);
+ check('the new build installs meanwhile', await waitWorker('deadbeef00001'));
+ await sleep(1000);
+ check('and does not reload a page on screen', loads === before + 1, `${loads - before} load(s)`);
+ before = loads;
+ await page.goto('http://localhost:5223/', { waitUntil: 'load' });
+ check('the next launch opens the new build', (await running()) === 'deadbeef00001', `running ${await running()}`);
+
+ console.log('\n[refreshing after a deploy]');
+ current = builds.v3;
+ before = loads;
await page.reload({ waitUntil: 'load' });
- await sleep(6000); // well past install + activate + claim
+ check('a refresh gets the new build at once', (await running()) === 'deadbeef00002', `running ${await running()}`);
+ await waitWorker('deadbeef00002');
+ await sleep(2000);
check('your refresh is the only refresh', loads === before + 1, `${loads - before} load(s) — one is yours`);
- check('and the new build is what is running', await page.evaluate(() => document.documentElement.outerHTML.includes("_v='deadbeef00001'")));
- console.log('\n[a deploy while the page sits open]');
- current = builds.v3;
- const before2 = loads;
- // no navigation: just let the worker notice, the way the periodic update
- // check would
+ console.log('\n[a deploy while the page sits open, untouched]');
+ current = builds.v4;
+ before = loads;
await page.evaluate(() => navigator.serviceWorker.getRegistration().then((r) => r && r.update()));
- for (let i = 0; i < 40 && loads === before2; i++) await sleep(250);
- check('a stale page still gets reloaded', loads === before2 + 1, `${loads - before2} reload(s)`);
- await sleep(3000);
- check('and only once', loads === before2 + 1, `${loads - before2} reload(s)`);
- check('now running the newest build', await page.evaluate(() => document.documentElement.outerHTML.includes("_v='deadbeef00002'")));
+ check('the new build installs', await waitWorker('deadbeef00003'));
+ await sleep(1000);
+ check('no reload while on screen', loads === before, `${loads - before} reload(s)`);
+ await putAway();
+ for (let i = 0; i < 40 && loads === before; i++) await sleep(250);
+ check('it reloads as it is put away', loads === before + 1, `${loads - before} reload(s)`);
+ check('now running the newest build', (await running()) === 'deadbeef00003');
+
+ console.log('\n[a deploy while someone is using the page]');
+ current = builds.v1;
+ before = loads;
+ await page.mouse.click(5, 5);
+ await page.evaluate(() => navigator.serviceWorker.getRegistration().then((r) => r && r.update()));
+ check('the new build installs', await waitWorker(token));
+ await putAway();
+ await sleep(2000);
+ check('a page in use is never reloaded', loads === before, `${loads - before} reload(s)`);
+ check('an older build\'s chunks are still cached', await page.evaluate(async () => {
+ const tok = (document.documentElement.outerHTML.match(/_v='([a-z0-9]+)'/) || [])[1];
+ return !!(await caches.match('app-' + tok + '.js'));
+ }));
} catch (e) {
check('run completed', false, e.message);
} finally {
await browser.close();
server.stop(true);
}
-console.log(ok ? '\n✅ sw reload behaviour is right' : '\n❌ failures above');
+console.log(ok ? '\n✅ sw launch + reload behaviour is right' : '\n❌ failures above');
process.exit(ok ? 0 : 1);
diff --git a/tools/thread-instant-test.js b/tools/thread-instant-test.js
index bd80408..e9a9138 100644
--- a/tools/thread-instant-test.js
+++ b/tools/thread-instant-test.js
@@ -3,7 +3,7 @@
// is seeded from them — the relays only add to it. A thread with nothing in
// hand paints its replies as they arrive, not after the slowest relay.
// Run: bun tools/thread-instant-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -95,9 +95,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, alice, bob, carol, notes]) => {
diff --git a/tools/thread-like-count-test.js b/tools/thread-like-count-test.js
new file mode 100644
index 0000000..6d81b3f
--- /dev/null
+++ b/tools/thread-like-count-test.js
@@ -0,0 +1,91 @@
+// The heart's number in a thread matches the people the who-panel lists.
+// A thread remembers its counts between visits; a reaction that arrived
+// through the feed's own count fetch before the thread was opened again
+// must raise the remembered number, not be outvoted by it.
+// Run: bun tools/thread-like-count-test.js
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.js';
+import { generateSecretKey, finalizeEvent } from 'nostr-tools/pure';
+import { nip19 } from 'nostr-tools';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const sk = generateSecretKey(), now = Math.floor(Date.now() / 1000);
+const notes = Array.from({ length: 12 }, (_, i) => finalizeEvent({ kind: 1, created_at: now - 60 - i * 60, tags: [],
+ content: `Post number ${i}. The tide was out and the gulls had the whole beach to themselves.` }, sk));
+const target = notes[0];
+const react = (n) => finalizeEvent({ kind: 7, created_at: now - 30 + n, content: '+', tags: [['e', target.id], ['p', target.pubkey]] }, generateSecretKey());
+const reactions = [react(1), react(2)];
+const profile = finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Poster' }) }, sk);
+const html = await buildHtml({ minify: true, pwa: false });
+const snapshot = JSON.stringify({ version: 1, generatedAt: Date.now(), notes, profiles: [profile], popUntil: now - 3600 });
+const server = Bun.serve({ port: 0, fetch: (req) => new URL(req.url).pathname === '/api/feed'
+ ? new Response(snapshot, { headers: { 'content-type': 'application/json' } })
+ : new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(String(e)));
+ await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
+ await page.setRequestInterception(true);
+ page.on('request', (r) => r.url().startsWith(server.url.origin) ? r.continue() : r.abort());
+ // The relay serves as many of the two reactions as localStorage says
+ // (one on the first visit, both on the second).
+ await page.evaluateOnNewDocument(([notes, profile, reactions]) => {
+ class FakeSocket extends EventTarget {
+ constructor(url) { super(); this.url = url; this.readyState = 0; this.subs = new Set();
+ setTimeout(() => { this.readyState = 1; this.emit('open', new Event('open')); }, 0); }
+ emit(type, ev) { this.dispatchEvent(ev); this['on' + type]?.(ev); }
+ message(d) { this.emit('message', new MessageEvent('message', { data: JSON.stringify(d) })); }
+ send(raw) {
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ if (m[0] === 'CLOSE') { this.subs.delete(m[1]); return; }
+ if (m[0] !== 'REQ') return;
+ const [, id, f] = m; this.subs.add(id);
+ const n = Number(localStorage.getItem('test-reactions') || 1);
+ let evs = [];
+ if (f.kinds?.includes(1) && !f.since) evs = notes.filter((e) => (!f.until || e.created_at <= f.until)
+ && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id))).slice(0, f.limit || 100);
+ if (f.kinds?.includes(0) && f.authors?.includes(profile.pubkey)) evs = [profile];
+ if (f['#e']) evs = f.kinds?.includes(7) ? reactions.slice(0, n).filter((r) => f['#e'].includes(r.tags[0][1])) : [];
+ if (f.ids) evs = notes.filter((e) => f.ids.includes(e.id));
+ setTimeout(() => { if (!this.subs.has(id)) return; for (const e of evs) this.message(['EVENT', id, e]); this.message(['EOSE', id]); }, 30);
+ }
+ close() { this.readyState = 3; this.emit('close', new Event('close')); }
+ }
+ Object.assign(FakeSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+ window.WebSocket = FakeSocket;
+ }, [notes, profile, reactions]);
+ const heartSel = (scope, id) => `${scope} .note-post[data-zap-post="${id}"] .note-acts > .note-act:nth-child(4) .note-act-n`;
+ const heart = (scope, id) => page.evaluate((s) => document.querySelector(s)?.textContent || '', heartSel(scope, id));
+ const openRow = (id) => page.evaluate((id) => document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"] > .note-content > .note-text').click(), id);
+ const visit = async (n, path = '/feed') => {
+ await page.goto(server.url.origin + '/feed', { waitUntil: 'domcontentloaded' });
+ await page.evaluate((n) => localStorage.setItem('test-reactions', String(n)), n);
+ await page.goto(server.url.origin + path, { waitUntil: 'domcontentloaded' });
+ };
+ // first visit: the thread itself, from a link (a notification, say), with
+ // one heart on the relay — the count answer lands while the thread is
+ // open, so the thread remembers it
+ await visit(1, '/' + nip19.neventEncode({ id: target.id }));
+ await page.waitForFunction((s) => document.querySelector(s)?.textContent === '1', { timeout: 15000 }, heartSel('.thread-page', target.id));
+ await sleep(1500); // the thread is persisted
+ // second visit: the feed, where the count fetch brings both hearts; the
+ // thread is then opened from the row
+ await visit(2);
+ await page.waitForFunction(() => document.querySelectorAll('.notes-feed > [data-zap-post]').length >= 5, { timeout: 20000 });
+ await page.waitForFunction((s) => document.querySelector(s)?.textContent === '2', { timeout: 10000 }, heartSel('.notes-feed', target.id));
+ await openRow(target.id);
+ await page.waitForFunction((s) => document.querySelector(s), { timeout: 10000 }, heartSel('.thread-page', target.id));
+ await sleep(800);
+ const n = await heart('.thread-page', target.id);
+ const who = await page.evaluate((id) => {
+ const row = document.querySelector('.thread-page .note-post[data-zap-post="' + id + '"]');
+ row.querySelector('.note-acts > .note-act:last-child').click();
+ return new Promise((r) => setTimeout(() => r(row.querySelectorAll('.note-who-person').length), 300));
+ }, target.id);
+ assert.equal(who, 2, 'the panel lists both people');
+ assert.equal(n, '2', `the heart in the thread says 2, not the remembered 1 (got "${n}")`);
+ assert.deepEqual(errors, []);
+ console.log('✓ the thread\'s heart count catches up with the people who reacted');
+} finally { await browser.close(); server.stop(true); }
diff --git a/tools/thread-reply-test.js b/tools/thread-reply-test.js
index 738a31c..f3bda91 100644
--- a/tools/thread-reply-test.js
+++ b/tools/thread-reply-test.js
@@ -78,6 +78,27 @@ try {
await page.click('#app .note-act');
check('Reply immediately shows its composer beneath the tapped post', await inline());
check('the input receives focus before relay queries finish', await page.evaluate(() => document.activeElement.matches('.thread-reply-input')));
+ // Cancel with text typed: the box folds and the draft is gone (it used to
+ // stay open — the box shows while a draft has text, and Cancel only
+ // cleared the replying flag)
+ await page.type('.thread-reply-input', 'Half a thought');
+ await page.evaluate(() => document.querySelector('.thread-reply-cancel').click());
+ check('Cancel with text typed hides the box and discards the draft', await page.evaluate(() =>
+ !document.querySelector('.thread-reply-input') && !(test.ui.noteThread.draft || '').length));
+ await page.evaluate(() => document.querySelector('#app .note-act').click()); // one atomic tap, as below
+ await page.waitForSelector('.thread-reply-input');
+ check('Reply again opens an empty field', await page.evaluate(() => document.querySelector('.thread-reply-input')?.value === ''));
+ // a phone: Return is a new line, not a send
+ await page.evaluate(() => { const mm = window.matchMedia; window._mm = mm; window.matchMedia = (q) => q === '(pointer: coarse)' ? { matches: true } : mm(q); });
+ await page.type('.thread-reply-input', 'Line one');
+ await page.keyboard.press('Enter');
+ await page.type('.thread-reply-input', 'two');
+ check("on a phone, Return adds a line instead of sending", await page.evaluate(() =>
+ document.querySelector('.thread-reply-input')?.value === 'Line one\ntwo' && sent.length === 0), await page.evaluate(() => document.querySelector('.thread-reply-input')?.value));
+ await page.evaluate(() => { window.matchMedia = window._mm; });
+ await page.focus('.thread-reply-input');
+ await page.keyboard.down('Control'); await page.keyboard.press('a'); await page.keyboard.up('Control');
+ await page.keyboard.press('Backspace');
await page.type('.thread-reply-input', 'A reply before the root loads');
// (one atomic tap: a repaint landing between finding the button and clicking it replaces it)
await page.waitForFunction(() => !document.querySelector('.thread-reply-send')?.disabled);
diff --git a/tools/translate-phone-lang-test.js b/tools/translate-phone-lang-test.js
index 9e1eefc..28676eb 100644
--- a/tools/translate-phone-lang-test.js
+++ b/tools/translate-phone-lang-test.js
@@ -6,7 +6,7 @@
// the target and takes a DeepL key, which then rides with each request.
// The registrar's /translate is stubbed (window.fetch shim).
// Run: bun tools/translate-phone-lang-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -57,9 +57,9 @@ try {
await page.evaluate(() => { localStorage.setItem('btc-wallet-network', 'regtest'); localStorage.setItem('btc-wallet-lang', 'en'); });
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/translate-test.js b/tools/translate-test.js
index d3fe095..f00ba22 100644
--- a/tools/translate-test.js
+++ b/tools/translate-test.js
@@ -3,7 +3,7 @@
// with the switch off they offer Translate instead; ⋯ → Translate asks for
// any post. The registrar's /translate is stubbed here (window.fetch shim).
// Run: bun tools/translate-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -70,9 +70,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/video-autoplay-test.js b/tools/video-autoplay-test.js
index 6b943f2..6d245b3 100644
--- a/tools/video-autoplay-test.js
+++ b/tools/video-autoplay-test.js
@@ -4,7 +4,7 @@
// loaded for a box that never comes into view. Set CLIP to a webm to serve;
// the default is made with ffmpeg on the fly.
// Run: bun tools/video-autoplay-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -53,9 +53,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/video-compress-test.js b/tools/video-compress-test.js
index 19735c8..2b83edc 100644
--- a/tools/video-compress-test.js
+++ b/tools/video-compress-test.js
@@ -4,7 +4,7 @@
// upload is caught and its bytes checked with ffprobe.
//
// Run: bun tools/video-compress-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
@@ -73,8 +73,8 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(600);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
- await click('skip verification');
+ await seedWords(page);
+ await skipSeedCheck(page);
await waitText('receive', 20000);
check('the browser can encode H.264 (WebCodecs)', await page.evaluate(async () => (await VideoEncoder.isConfigSupported({ codec: 'avc1.42001f', width: 1280, height: 720, bitrate: 2.5e6 })).supported));
// the post composer: the pencil on the feed
diff --git a/tools/video-events-test.js b/tools/video-events-test.js
index bef0956..56ea486 100644
--- a/tools/video-events-test.js
+++ b/tools/video-events-test.js
@@ -6,7 +6,7 @@
// version. A note linking such a file, typed video/* in its imeta, plays it
// too. And the clips are in the pager.
// Run: bun tools/video-events-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -90,9 +90,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk]) => {
@@ -128,6 +128,8 @@ try {
await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('A normal clip')); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); });
await sleep(500);
const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('A normal clip')); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; });
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
await page.mouse.click(at.x, at.y);
check('a tap opens the pager', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000));
const slides = await page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => s.getAttribute('data-k').split('|')[1].replace(location.origin, '')));
diff --git a/tools/video-fallback-test.js b/tools/video-fallback-test.js
index 93726e1..ff45846 100644
--- a/tools/video-fallback-test.js
+++ b/tools/video-fallback-test.js
@@ -4,7 +4,7 @@
// post gave no poster wears the server's still. The server (vmedia) is
// stood in for here: its answers are canned.
// Run: bun tools/video-fallback-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -81,9 +81,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/video-gone-test.js b/tools/video-gone-test.js
new file mode 100644
index 0000000..48f2956
--- /dev/null
+++ b/tools/video-gone-test.js
@@ -0,0 +1,129 @@
+// A post whose clip its host no longer has (deleted from Primal's blossom:
+// a 404 with no CORS headers, which the probe can't read) sat in the feed
+// as a dead player under "Tap to play" that did nothing when tapped —
+// "Video in this post won't play" (Adam, 2026-11-01). The player is the
+// first to know the file is gone: it now says so where it stood, with the
+// file's link, its controls and cover gone; the next paint of the post says
+// it at once, with no request. A clip on a live host plays as before.
+// Run: bun tools/video-gone-test.js
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { existsSync } from 'node:fs';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const GOOD = '/tmp/coinos-vgone-good.webm';
+if (!existsSync(GOOD)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x180:rate=10', '-t', '8', '-c:v', 'libvpx', '-b:v', '100k', GOOD]);
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const html = await buildHtml({ minify: !process.env.VGONE_LOG, pwa: false });
+const app = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+// the clips' host: another origin, no CORS headers at all (Primal's blossom)
+let deadHits = 0;
+const host = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/dead.mp4') { deadHits++; if (process.env.VGONE_LOG) console.error('dead.mp4 asked:', req.method, req.headers.get('range') || '-', req.headers.get('sec-fetch-dest'), req.headers.get('sec-fetch-mode')); return new Response('Not Found', { status: 404, headers: { 'content-type': 'text/plain' } }); }
+ if (path === '/good.webm') {
+ const f = Bun.file(GOOD), range = /^bytes=(\d+)-(\d*)$/.exec(req.headers.get('range') || '');
+ if (!range) return new Response(f, { headers: { 'content-type': 'video/webm', 'accept-ranges': 'bytes' } });
+ const a = +range[1], b = range[2] ? Math.min(+range[2], f.size - 1) : f.size - 1;
+ return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'video/webm', 'accept-ranges': 'bytes', 'content-range': 'bytes ' + a + '-' + b + '/' + f.size, 'content-length': String(b - a + 1) } });
+ }
+ return new Response('no', { status: 404 });
+} });
+const DEAD = host.url.origin + '/dead.mp4', LIVE = host.url.origin + '/good.webm';
+const post = (content, url, ago, m) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['imeta', 'url ' + url, 'm ' + m]], content }, SK);
+const seeded = [post('lol beat it ' + DEAD, DEAD, 100, 'video/mp4'), post('a clip that is there ' + LIVE, LIVE, 200, 'video/webm'),
+ ...Array.from({ length: 4 }, (_, i) => finalizeEvent({ kind: 1, created_at: now - 600 - i * 60, tags: [], content: 'filler ' + i + ' lorem ipsum dolor sit amet. '.repeat(4) }, SK))];
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument(() => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+});
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+if (process.env.VGONE_LOG) page.on('request', (r) => { if (r.url().endsWith('/dead.mp4')) console.error('REQ', r.resourceType(), JSON.stringify(r.initiator()).slice(0, 900)); });
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+const boxOf = (word) => page.evaluate((w) => {
+ const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w));
+ const b = r && r.querySelector('.note-video-box'); if (!b) return null;
+ const v = b.querySelector('video');
+ return { gone: b.classList.contains('gone'), line: (b.querySelector('.vid-nopic') || {}).textContent || '', link: (b.querySelector('.vid-nopic a') || {}).href || '', cover: !!b.querySelector('.vid-heavy'), pp: !!b.querySelector('.vid-pp'), controls: !!v.controls, height: Math.round(b.getBoundingClientRect().height), playing: !v.paused, src: v.getAttribute('src') };
+}, word);
+const scrollTo = async (word) => {
+ await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('.note-video-box').scrollIntoView({ block: 'center' }); }, word);
+ await sleep(1500);
+};
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(app.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await seedWords(page);
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await skipSeedCheck(page);
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ const open = async () => {
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the clips are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed video.note-video').length >= 2, null, 20000));
+ };
+
+ console.log('\n[the clip whose host answers 404, without CORS]');
+ await open();
+ await scrollTo('beat it');
+ let b = await boxOf('beat it');
+ check('the probe could not tell: the clip waits under a cover', !!b && b.cover && !b.gone, JSON.stringify(b));
+ // the tap that used to do nothing
+ await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('.vid-heavy').click(); }, 'beat it');
+ check('the player finds the file gone and says so', await waitFor((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); return !!r.querySelector('.note-video-box.gone .vid-nopic'); }, 'beat it', 8000));
+ b = await boxOf('beat it');
+ check('…the line names the host, with the file\'s link', /couldn’t be loaded from its host/.test(b.line) && b.link === DEAD, JSON.stringify(b));
+ check('…no cover, no play button, no controls', !b.cover && !b.pp && !b.controls, JSON.stringify(b));
+ check('…a short box, not a screen of black', b.height > 40 && b.height <= 200, String(b.height));
+ const hitsThen = deadHits;
+ await sleep(2500); // repaints, the in-view hook: nothing asks again
+ b = await boxOf('beat it');
+ check('the line stays through repaints', b.gone && !!b.line && !b.cover, JSON.stringify(b));
+
+ console.log('\n[the clip on a live host]');
+ await scrollTo('that is there');
+ // the same quiet host: the cover, and the tap that plays it
+ await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const c = r.querySelector('.vid-heavy'); if (c) c.click(); }, 'that is there');
+ check('plays as before', await waitFor((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const v = r.querySelector('video'); return !!v && !v.paused && v.readyState >= 2; }, 'that is there', 10000), JSON.stringify(await boxOf('that is there')));
+ b = await boxOf('that is there');
+ check('…and is not marked gone', !b.gone && !b.line, JSON.stringify(b));
+
+ console.log('\n[the next time the post is painted]');
+ await open();
+ await scrollTo('beat it');
+ b = await boxOf('beat it');
+ check('the post says it at once', !!b && b.gone && /couldn’t be loaded/.test(b.line) && !b.cover, JSON.stringify(b));
+ await sleep(1500);
+ check('…without asking the host again', deadHits === hitsThen, deadHits + ' vs ' + hitsThen);
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); app.stop(true); host.stop(true); }
+console.log(ok ? '\n✅ a clip its host no longer has says so, in place of a dead player' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/video-hls-play-test.js b/tools/video-hls-play-test.js
index f1aea1c..4a18e76 100644
--- a/tools/video-hls-play-test.js
+++ b/tools/video-hls-play-test.js
@@ -4,7 +4,7 @@
// and a post linking an .m3u8 stream plays that. vmedia runs here for real,
// on a local copy of the clip.
// Run: bun tools/video-hls-play-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -90,9 +90,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/video-tap-test.js b/tools/video-tap-test.js
index f82238b..f03aef6 100644
--- a/tools/video-tap-test.js
+++ b/tools/video-tap-test.js
@@ -1,10 +1,9 @@
-// Tapping a video: in the feed it opens the media pager (feed-video-pager-
-// test.js has the rest). In a thread, anywhere on the picture (not the
-// player's own play button or control bar) goes full screen — the corner
-// button is a reach on a phone. Full screen, a tap pauses a playing clip; a
-// tap on a paused one comes back out. Taps on the controls stay the player's.
+// Tapping a video: inline, a playing clip pauses. A paused one, in the feed,
+// and in a thread opens the media pager (feed-video-pager-test.js has the
+// rest). With a mouse the middle of the clip is a big play/pause button that
+// never goes full screen.
// Run: bun tools/video-tap-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -50,9 +49,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
@@ -69,8 +68,18 @@ try {
for (let i = 0; i < 40 && (await st()).paused; i++) await sleep(250);
check('the clip autoplays in the feed', !(await st()).paused);
+ // the hover button: pauses and plays in place
+ const pp = async () => { const b = await page.evaluate(() => { const r = document.querySelector('.notes-feed .note-video-box').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.move(b.x - 40, b.y); await page.mouse.move(b.x, b.y, { steps: 3 }); await sleep(250); if (!(await page.evaluate(() => getComputedStyle(document.querySelector('.notes-feed .vid-pp')).display === 'flex'))) check('a mouse over the clip brings up the middle button', false); await page.mouse.click(b.x, b.y); await sleep(700); };
+ check('no middle button until a mouse comes over the clip', await page.evaluate(() => getComputedStyle(document.querySelector('.notes-feed .vid-pp')).display === 'none'));
+ await pp();
+ check('the middle button pauses the clip in place', await page.evaluate(() => document.querySelector('video.note-video').paused && !document.querySelector('.media-pager') && !document.fullscreenElement));
+ check('...and shows play while paused', await page.evaluate(() => !document.querySelector('.vid-pp').classList.contains('playing')));
+ await pp();
+ check('the middle button plays it again, still inline', await page.evaluate(() => !document.querySelector('video.note-video').paused && !document.querySelector('.media-pager') && !document.fullscreenElement));
await tap(0.2, 0.3);
- check('in the feed, a tap opens the media pager', await page.evaluate(() => !!document.querySelector('.media-pager.videos')));
+ check('in the feed, a tap on a playing clip pauses it, no pager', await page.evaluate(() => document.querySelector('video.note-video').paused && !document.querySelector('.media-pager')));
+ await tap(0.2, 0.3);
+ check('a tap on the paused clip opens the media pager', await page.evaluate(() => !!document.querySelector('.media-pager.videos')));
await page.keyboard.press('Escape'); await sleep(700);
check('...which Escape closes', await page.evaluate(() => !document.querySelector('.media-pager') && !document.fullscreenElement));
// the thread: the post's words open it
@@ -87,22 +96,11 @@ try {
for (let i = 0; i < 40 && (await st()).paused; i++) await sleep(250);
await tap(0.2, 0.3);
let s = await st();
- check('in a thread, a tap on the picture goes full screen', s.fs, JSON.stringify(s));
- check('...and not the pager', await page.evaluate(() => !document.querySelector('.media-pager')));
- await tap(0.25, 0.3);
- s = await st();
- check('full screen, a tap pauses the playing clip', s.fs && s.paused, JSON.stringify(s));
- await tap(0.5, 0.5);
- s = await st();
- check('a tap on the paused clip\'s play button is left to the player', s.fs, JSON.stringify(s));
- await page.evaluate(() => document.querySelector('.thread-card video.note-video').pause()); await sleep(300);
+ check('in a thread, a tap on the playing clip pauses it in place', !s.fs && s.paused, JSON.stringify(s));
await tap(0.2, 0.3);
- s = await st();
- check('a tap elsewhere on the paused clip comes out of full screen', !s.fs, JSON.stringify(s));
- await tap(0.3, 0.97);
- s = await st();
- check('a tap on the control bar stays the player\'s', !s.fs, JSON.stringify(s));
+ check('a tap on the paused clip opens the media pager there too', await page.evaluate(() => !!document.querySelector('.media-pager.videos')));
+ await page.keyboard.press('Escape'); await sleep(700);
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
-console.log(ok ? '\n✅ video taps: full screen, pause, back out' : '\n❌ failed');
+console.log(ok ? '\n✅ video taps: pause in place, pager when paused, middle button' : '\n❌ failed');
process.exit(ok ? 0 : 1);
diff --git a/tools/video-tile-poster-test.js b/tools/video-tile-poster-test.js
index 48385fe..ed672c2 100644
--- a/tools/video-tile-poster-test.js
+++ b/tools/video-tile-poster-test.js
@@ -4,7 +4,7 @@
// picture for falls back to the frame grab, and one the browser can't load
// either (deleted, its 404 without CORS headers) leaves the grid.
// Run: bun tools/video-tile-poster-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -75,9 +75,9 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/video-touch-tap-test.js b/tools/video-touch-tap-test.js
index 71ffe9c..ff25095 100644
--- a/tools/video-touch-tap-test.js
+++ b/tools/video-touch-tap-test.js
@@ -5,7 +5,7 @@
// on the control bar or the paused clip's play button stays the player's, a
// drag is a scroll, and where the click does come it isn't taken twice.
// Run: bun tools/video-touch-tap-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -51,9 +51,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
diff --git a/tools/web-comment-test.js b/tools/web-comment-test.js
new file mode 100644
index 0000000..513217b
--- /dev/null
+++ b/tools/web-comment-test.js
@@ -0,0 +1,123 @@
+// Comments scoped to a web address (NIP-22 kind 1111 with an I tag:
+// DaddyTube's, on a film's IMDb page) belong with the person's replies.
+// Adam's own ("Charming and wholesome", 2026-11-02, on relay.primal.net)
+// showed on neither his profile's Replies tab — the profile page never
+// asked for kind 1111 — nor said what it was a comment on. Now the page
+// asks for comments, and a web-scoped comment carries the page it is on
+// where "Replying to" would go, named by its alt tag when it has one.
+// Also here: a report naming a post as 'other', with the author's bare p
+// tag beside it, counted as a report of the PERSON (es stayed 0 for an
+// 'other' e tag) — two such reports from follows folded the author's every
+// post. Run: bun tools/web-comment-test.js
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+
+const html = await buildHtml({ minify: true, pwa: false });
+const keys = () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; };
+const B = keys(), C = keys(), D = keys(); // B comments; C and D report
+const now = () => Math.floor(Date.now() / 1000);
+const ev = (sk, kind, content, ago, tags = []) => finalizeEvent({ kind, created_at: now() - ago, tags, content }, sk);
+const IMDB = 'https://www.imdb.com/title/tt32565993/';
+const post = ev(B.sk, 1, 'a plain post about sheep', 3600);
+const comment = ev(B.sk, 1111, 'Charming and wholesome', 1800, [['I', IMDB], ['K', 'web'], ['i', IMDB], ['k', 'web']]);
+const named = ev(B.sk, 1111, 'The dog steals every scene', 1700, [['I', IMDB], ['K', 'web'], ['i', IMDB], ['k', 'web'], ['alt', 'Comment on Sheep Detectives (2025) in DaddyTube']]);
+const reports = [C, D].map((r) => ev(r.sk, 1984, '', 900, [['e', post.id, 'other'], ['p', B.pk]]));
+const stored = [post, comment, named, ...reports];
+const reqLog = [], sentLog = [];
+const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id)) && (!f.since || e.created_at >= f.since);
+const server = Bun.serve({ port: 0, fetch: (req, srv) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/relay') return srv.upgrade(req) ? undefined : new Response('ws', { status: 426 });
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+}, websocket: {
+ message(ws, raw) {
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ if (m[0] === 'EVENT') { ws.send(JSON.stringify(['OK', m[1].id, true, ''])); return; }
+ if (m[0] === 'REQ') {
+ reqLog.push(m.slice(2));
+ for (const e of stored) if (m.slice(2).some((f) => match(f, e))) { sentLog.push(e.content.slice(0, 20)); ws.send(JSON.stringify(['EVENT', m[1], e])); }
+ ws.send(JSON.stringify(['EOSE', m[1]]));
+ }
+ },
+} });
+const PORT = server.port;
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.setViewport({ width: 390, height: 844 });
+await page.evaluateOnNewDocument((port) => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (url, p) { if (/relay\.coinos\.io/.test(String(url))) url = `ws://localhost:${port}/relay`; return p ? new Real(url, p) : new Real(url); };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+}, PORT);
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { const r = await page.evaluate(fn, arg); if (r) return r; await sleep(100); } return null; };
+const rowWith = (text) => page.evaluate((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); return r ? { folded: !!r.querySelector('.note-folded') || r.classList.contains('note-folded'), scope: r.querySelector('.note-scope a') ? { href: r.querySelector('.note-scope a').href, text: r.querySelector('.note-scope a').textContent.trim() } : null, text: r.textContent.replace(/\s+/g, ' ').trim().slice(0, 120) } : null; }, text);
+
+try {
+ await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await seedWords(page);
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await skipSeedCheck(page);
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pks, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: pks.map((p) => ['p', p]), c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pks[0]]: { name: 'Shaun', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, [B.pk, C.pk, D.pk], [post]]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the post is in Following', !!(await waitFor((x) => [...document.querySelectorAll('.notes-feed > .row[data-key]')].some((r) => r.textContent.includes(x)), 'plain post', 20000)));
+ await sleep(5000); // the follows' reports arrive and the feed repaints
+ const plain = await rowWith('plain post');
+ check('two "other" reports of the post, each with the author\'s bare p tag, do not fold the author', plain && !plain.folded, plain ? plain.text : 'row gone');
+
+ console.log('\n[Conversations]');
+ await click('conversations');
+ const row = await waitFor((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); return !!r; }, 'Charming and wholesome', 15000);
+ check('the web-scoped comment is listed', !!row);
+ if (process.env.DEBUG) {
+ console.log(' rows:', JSON.stringify(await page.evaluate(() => [...document.querySelectorAll('.row[data-key]')].map((r) => r.textContent.replace(/\s+/g, ' ').trim().slice(0, 50)))));
+ console.log(' chips:', JSON.stringify(await page.evaluate(() => [...document.querySelectorAll('.media-mode')].map((b) => b.textContent + (b.classList.contains('on') ? '*' : '')))));
+ console.log(' REQs with 1111:', JSON.stringify(reqLog.filter((fs) => fs.some((f) => (f.kinds || []).includes(1111))).map((fs) => fs.map((f) => ({ kinds: f.kinds, authors: (f.authors || []).length, limit: f.limit, since: !!f.since })))));
+ console.log(' sent:', JSON.stringify(sentLog));
+ }
+ const c1 = await rowWith('Charming and wholesome');
+ check('…under the page it is on, linked', !!c1?.scope && c1.scope.href === IMDB && c1.scope.text.includes('Comment on imdb.com/title/tt32565993'), c1 ? JSON.stringify(c1.scope) : '');
+ check('…not tagged as a plain reply', !!c1 && !/↩\s*reply/i.test(c1.text), c1 ? c1.text : '');
+ const c2 = await rowWith('dog steals');
+ check('a comment with an alt tag is labelled by it', !!c2?.scope && c2.scope.text.includes('Comment on Sheep Detectives (2025) in DaddyTube'), c2 ? JSON.stringify(c2.scope) : 'row missing');
+
+ console.log('\n[the profile\'s Replies tab]');
+ await page.evaluate((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); r.querySelector('.note-avatar').click(); }, 'Charming and wholesome');
+ check('the profile opens', await waitText('replies', 10000));
+ await click('replies');
+ const onProfile = await waitFor((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); return !!r; }, 'Charming and wholesome', 12000);
+ check('the comment is on the Replies tab', !!onProfile);
+ const p1 = await rowWith('Charming and wholesome');
+ check('…with its page above it', !!p1?.scope && p1.scope.href === IMDB, p1 ? JSON.stringify(p1.scope) : '');
+ console.log('\n[the comment opened]');
+ await page.evaluate((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); r.querySelector('.note-content').click(); }, 'Charming and wholesome');
+ const opened = await waitFor((id) => location.pathname.startsWith('/nevent1') && !!document.querySelector('.note-post[data-zap-post="' + id + '"] .note-scope a'), comment.id, 10000);
+ check('the thread opens on it, its page above it', !!opened);
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ web-scoped comments show with their page, and an "other" report stays off the author' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/wn-only-device-test.js b/tools/wn-only-device-test.js
index a91262b..7b19876 100644
--- a/tools/wn-only-device-test.js
+++ b/tools/wn-only-device-test.js
@@ -6,7 +6,7 @@
// a fresh one of ours. A device whose key was retired by another sees the
// deletion and stops putting a key back; the card says so there.
// Run: bun tools/wn-only-device-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
@@ -76,9 +76,9 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
const SK = privateKeyFromSeedWords(mn), ME = getPublicKey(SK);
diff --git a/tools/youtube-tap-test.js b/tools/youtube-tap-test.js
index 271277f..bf2832f 100644
--- a/tools/youtube-tap-test.js
+++ b/tools/youtube-tap-test.js
@@ -3,8 +3,10 @@
// sound, full screen. The player's own taps never reach the page, so a layer
// over the picture takes them; YouTube's control bar along the bottom is
// left to YouTube, and full screen the layer steps aside.
+// Full screen, YouTube's own full-screen button leaves it. Under a mouse
+// there is no layer: the player takes its own clicks (pause, full screen).
// Run: bun tools/youtube-tap-test.js
-import puppeteer from './puppeteer.js';
+import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
@@ -26,7 +28,7 @@ const server = Bun.serve({ port: 0, fetch: (req) => {
return new Response(html, { headers: { 'content-type': 'text/html' } });
} });
// YouTube, stood in for: the player page notes every command it is sent
-const PLAYER = '<!doctype html><html><body style="margin:0;background:#222;height:100vh"><script>window.__cmds=[];addEventListener("message",(e)=>{try{const m=JSON.parse(e.data);if(m.event==="command")window.__cmds.push(m.func)}catch{}});</script></body></html>';
+const PLAYER = '<!doctype html><html><body style="margin:0;background:#222;height:100vh"><script>window.__cmds=[];addEventListener("message",(e)=>{try{const m=JSON.parse(e.data);if(m.event==="command")window.__cmds.push(m.func)}catch{}});window.__clicks=0;addEventListener("click",()=>{window.__clicks++;if(!window.__fsButton)return;if(document.fullscreenElement)document.exitFullscreen();else document.documentElement.requestFullscreen().catch(()=>{})});</script></body></html>';
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
async function run(saveData) {
@@ -55,14 +57,15 @@ async function run(saveData) {
const st = () => page.evaluate(() => { const b = document.querySelector('.yt-embed'); const f = b && b.querySelector('iframe'); const sh = b && b.querySelector('.yt-shield'); return { fs: !!b && document.fullscreenElement === b, iframe: !!f, muted: !!f && /mute=1/.test(f.src), shield: !!sh && getComputedStyle(sh).display !== 'none', thread: !!document.querySelector('.thread-card') }; });
const cmds = async () => { const f = page.frames().find((x) => x.url().startsWith('https://www.youtube-nocookie.com/')); return f ? f.evaluate(() => window.__cmds.slice()) : null; };
try {
- await page.setViewport({ width: 390, height: 844 });
+ // a phone: a finger, no hover (a mouse is run apart, below)
+ await page.setViewport({ width: 390, height: 844, hasTouch: true, isMobile: true });
await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
- await page.waitForSelector('.words .w .t');
+ await seedWords(page);
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
- await click('skip verification');
+ await skipSeedCheck(page);
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
@@ -85,13 +88,13 @@ async function run(saveData) {
await sleep(500);
// YouTube's control bar along the bottom is YouTube's
let b = await box();
- await page.mouse.click(b.x + b.w * 0.3, b.y + b.h - 20); await sleep(500);
+ await page.touchscreen.tap(b.x + b.w * 0.3, b.y + b.h - 20); await sleep(500);
check('a tap on YouTube\'s control bar stays YouTube\'s', !(await st()).fs && !(await st()).thread, JSON.stringify(await st()));
} else {
check('with Data Saver, the still and a play button', !s.iframe && await page.evaluate(() => !!document.querySelector('.yt-embed .yt-play')), JSON.stringify(s));
}
const b = await box();
- await page.mouse.click(b.x + b.w * 0.3, b.y + b.h * 0.35);
+ await page.touchscreen.tap(b.x + b.w * 0.3, b.y + b.h * 0.35);
check('one tap on the picture: full screen', await waitFor(() => document.fullscreenElement === document.querySelector('.yt-embed'), null, 3000), JSON.stringify(await st()));
s = await st();
check('…the player there', s.iframe);
@@ -102,9 +105,82 @@ async function run(saveData) {
await page.evaluate(() => document.exitFullscreen()); await sleep(500);
s = await st();
check('out of full screen, the layer is back', s.shield && !s.fs, JSON.stringify(s));
+ // YouTube's own full-screen button, pressed while we hold the screen:
+ // the frame asks for full screen from inside — that press means "out"
+ const b2 = await box();
+ await page.touchscreen.tap(b2.x + b2.w * 0.3, b2.y + b2.h * 0.35);
+ await waitFor(() => document.fullscreenElement === document.querySelector('.yt-embed'), null, 3000);
+ await page.frames().find((x) => x.url().startsWith('https://www.youtube-nocookie.com/')).evaluate(() => { window.__fsButton = true; });
+ await page.touchscreen.tap(200, 400); // inside the frame now
+ check('full screen, the player\'s own full-screen button leaves it', await waitFor(() => !document.fullscreenElement, null, 3000), await page.evaluate(() => String(document.fullscreenElement && document.fullscreenElement.tagName)));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await ctx.close(); }
}
-try { await run(false); await run(true); } finally { await browser.close(); server.stop(true); }
+// A mouse reaches the player's own buttons, so its clicks are the player's:
+// the middle one pauses, the corner one goes full screen and back.
+async function runMouse() {
+ console.log('\n[a mouse: the player takes its own clicks]');
+ const ctx = await browser.createBrowserContext();
+ const page = await ctx.newPage();
+ await page.setRequestInterception(true);
+ page.on('request', (r) => {
+ const u = r.url();
+ if (u.startsWith('https://www.youtube-nocookie.com/embed/')) return r.respond({ status: 200, contentType: 'text/html', body: PLAYER });
+ if (u.startsWith('https://i.ytimg.com/')) return r.respond({ status: 200, contentType: 'image/png', body: png });
+ return r.continue();
+ });
+ await page.evaluateOnNewDocument(() => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+ });
+ const errs = [];
+ page.on('pageerror', (e) => errs.push(String(e)));
+ const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+ const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+ const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+ const box = () => page.evaluate(() => { const b = document.querySelector('.notes-feed .yt-embed').getBoundingClientRect(); return { x: b.x, y: b.y, w: b.width, h: b.height }; });
+ const fsTag = () => page.evaluate(() => (document.fullscreenElement ? document.fullscreenElement.tagName : ''));
+ const player = () => page.frames().find((x) => x.url().startsWith('https://www.youtube-nocookie.com/'));
+ try {
+ await page.setViewport({ width: 1280, height: 900 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await seedWords(page);
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await skipSeedCheck(page);
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Tuber', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, POSTS]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => { const n = document.querySelector('.app-nav-button[aria-label="Feed"]') || [...document.querySelectorAll('a,button')].find((x) => /^feed$/i.test((x.getAttribute('aria-label') || x.textContent || '').trim())); if (n) n.click(); });
+ await page.waitForSelector('.notes-feed .yt-embed', { timeout: 20000 });
+ await page.evaluate(() => document.querySelector('.notes-feed .yt-embed').scrollIntoView({ block: 'center' }));
+ await waitFor(() => !!document.querySelector('.yt-embed iframe'), null, 6000);
+ await sleep(800);
+ const b = await box();
+ await page.mouse.move(b.x + b.w * 0.4, b.y + b.h * 0.4); await sleep(200);
+ check('under a mouse, no layer over the player', await page.evaluate(() => { const sh = document.querySelector('.yt-embed .yt-shield'); return !sh || getComputedStyle(sh).display === 'none'; }));
+ await player().evaluate(() => { window.__fsButton = true; }); // from here every click is the corner button
+ await page.mouse.click(b.x + b.w * 0.5, b.y + b.h * 0.5); await sleep(600);
+ check('a click in the middle reaches the player', (await player().evaluate(() => window.__clicks)) === 1);
+ check('…and the page does not take the box full screen', (await fsTag()) !== 'DIV', await fsTag());
+ check('…nor open the thread', !(await page.evaluate(() => !!document.querySelector('.thread-card'))));
+ // (the stand-in goes full screen on a click, as YouTube's corner button does)
+ check('the player\'s own full screen is left to it', (await fsTag()) === 'IFRAME', await fsTag());
+ await page.mouse.click(400, 300); await sleep(600);
+ check('…and its button brings it back', (await fsTag()) === '', await fsTag());
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+ } finally { await ctx.close(); }
+}
+try { await run(false); await run(true); await runMouse(); } finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ a YouTube clip: one tap, full screen, with sound' : '\n❌ failed');
process.exit(ok ? 0 : 1);