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-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/amount-field-unit-test.js16
-rw-r--r--tools/ark-htlc-orphan.test.js33
-rw-r--r--tools/ark-ln-savings-small-test.js112
-rw-r--r--tools/ark-offline-init.test.js22
-rw-r--r--tools/ark-store-test.js21
-rw-r--r--tools/ark/cooperative-exit-ui-test.js25
-rw-r--r--tools/ark/custody-foreign-test.js102
-rw-r--r--tools/ark/custody-test.js151
-rw-r--r--tools/ark/custody-ui-test.js136
-rw-r--r--tools/ark/offboard-split-test.js128
-rw-r--r--tools/ark/refresh-test.js9
-rw-r--r--tools/avatar-profile-test.js6
-rw-r--r--tools/blob-alt-test.js92
-rw-r--r--tools/call-test.js57
-rw-r--r--tools/call-wake-test.js10
-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.js15
-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.js10
-rw-r--r--tools/feed-media-views-test.js27
-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.js11
-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.js163
-rw-r--r--tools/grid-early-player-test.js138
-rw-r--r--tools/history-export-test.js81
-rw-r--r--tools/image-tap-test.js6
-rw-r--r--tools/image-thumbnails-test.js19
-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-chat-actions-test.js74
-rw-r--r--tools/live-reactions-test.js13
-rw-r--r--tools/live-stream-test.js136
-rw-r--r--tools/ln-pay-double-wake.test.js74
-rw-r--r--tools/ln-receive-twin-test.js14
-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.js169
-rw-r--r--tools/media-grid-order-test.js128
-rw-r--r--tools/media-moments-feed-test.js193
-rw-r--r--tools/media-timeline-test.js80
-rw-r--r--tools/media-upload-identity-test.js63
-rw-r--r--tools/mention-ac-test.js6
-rw-r--r--tools/moderation-test.js6
-rw-r--r--tools/names-invoice-expiry-test.js70
-rw-r--r--tools/names-listening-test.js70
-rw-r--r--tools/negentropy-sync-test.js6
-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.js12
-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.js201
-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.js28
-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.js133
-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.js42
-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/profile-pager-test.js6
-rw-r--r--tools/profile-publish-race-test.js111
-rw-r--r--tools/profile-repost-test.js6
-rw-r--r--tools/puppeteer.js17
-rw-r--r--tools/push-resubscribe-test.js103
-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/relay-link-test.js66
-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/safe-boot-test.js77
-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.js89
-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.js46
-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.js6
-rw-r--r--tools/zap-feedback-test.js7
-rw-r--r--tools/zap-signer-test.js43
148 files changed, 5537 insertions, 422 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-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/amount-field-unit-test.js b/tools/amount-field-unit-test.js
index b984064..596969d 100644
--- a/tools/amount-field-unit-test.js
+++ b/tools/amount-field-unit-test.js
@@ -2,6 +2,8 @@
// showing: in fiat that is money to the cent, floored so a filled-in maximum
// never parses back to more sats than were offered — not a BTC figure under a
// CAD label (2026-10-08: Max on an ark recipient filled 0.00004978 'CAD').
+// Switching the unit with an amount already typed into the send form converts
+// it (2026-10-23): the same money in the new unit, round-tripping exactly.
// Run: bun tools/amount-field-unit-test.js
import assert from 'node:assert/strict';
import puppeteer from './puppeteer.js';
@@ -9,7 +11,7 @@ import puppeteer from './puppeteer.js';
const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true,
plugins: [{ name: 'amount-field-test', setup(build) {
build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + `
-window.amtApp = { ctx, toggleUnit, get unit() { return unit; } };
+window.amtApp = { ctx, ui, toggleUnit, get unit() { return unit; } };
` }));
build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' }));
} }] });
@@ -37,6 +39,13 @@ try {
while (amtApp.unit !== 'sats') amtApp.toggleUnit();
out.sats = ctx.amountField(62212);
amtApp.toggleUnit(); out.btc = ctx.amountField(62212);
+ // typed into the send form, then the unit toggled around the ring
+ while (amtApp.unit !== 'sats') amtApp.toggleUnit();
+ const rs = amtApp.ui.send.recipients;
+ rs[0].amount = '62212'; rs.push({ address: '', amount: '93.318' }, { address: '', amount: '' }, { address: '', amount: 'abc' });
+ out.ring = [];
+ for (let i = 0; i < 3; i++) { amtApp.toggleUnit(); out.ring.push([amtApp.unit, ...rs.map((r) => r.amount)]); }
+ rs.splice(1);
return out;
});
console.log(r);
@@ -46,6 +55,11 @@ try {
assert.equal(r.typed, 62207, 'the same money typed as something else parses as typed');
assert.equal(r.sats, '62212');
assert.equal(r.btc, '0.00062212');
+ assert.deepEqual(r.ring, [
+ ['btc', '0.00062212', '0.00000093', '', 'abc'],
+ ['fiat', '93.31', '0.13', '', 'abc'],
+ ['sats', '62212', '93', '', 'abc'],
+ ], 'a typed amount follows the unit around the ring and comes back exact; blanks and junk are left alone');
assert.deepEqual(errors, []);
console.log('✅ amount fills follow the display unit');
} finally { await browser.close(); server.stop(true); }
diff --git a/tools/ark-htlc-orphan.test.js b/tools/ark-htlc-orphan.test.js
new file mode 100644
index 0000000..cc127be
--- /dev/null
+++ b/tools/ark-htlc-orphan.test.js
@@ -0,0 +1,33 @@
+// bun test tools/ark-htlc-orphan.test.js — an HTLC coin whose payment's
+// action has been pruned is an orphan the server settles: spent means spent
+// (it stopped counting as pending money), still-open stays pending, and a
+// coin a live payment holds is never asked about.
+import { test, expect, mock } from 'bun:test';
+import * as proto from '../src/ark/proto.js';
+const asked = [];
+mock.module('../src/ark/proto.js', () => ({ ...proto, getVtxoStatus: async (url, raw) => { asked.push(raw[0]); return raw[0] === 1 ? proto.VTXO_STATE_SPENT : proto.VTXO_STATE_SPENDABLE; } }));
+const { ArkManager } = await import('../src/ark/manager.js');
+test('orphan HTLC coins are settled by the server\'s answer', async () => {
+ const m = Object.create(ArkManager.prototype);
+ m.state = {
+ vtxos: [
+ { id: 'gone', state: 'pending', amountSat: 22, bytes: 'x', policy: 'serverHtlcSend', tag: 1 }, // payment done long ago, action pruned: server says spent
+ { id: 'open', state: 'pending', amountSat: 22, bytes: 'x', policy: 'serverHtlcSend', tag: 2 }, // orphan the server still holds open
+ { id: 'held', state: 'pending', amountSat: 22, bytes: 'x', policy: 'serverHtlcSend', tag: 3 }, // a live payment's: not asked about
+ { id: 'coin', state: 'pending', amountSat: 500, bytes: 'x', policy: 'pubkey', tag: 2 }, // a plain orphan the server counts: spendable again
+ ],
+ actions: [{ type: 'ln-pay', step: 'initiated', parts: [{ inputId: 'in1' }], htlcVtxoIds: ['held'] }],
+ scheduled: [],
+ };
+ m.arkUrl = 'x';
+ m._decoded = (v) => ({ point: { raw: Uint8Array.of(v.tag) }, policy: { type: v.policy } });
+ m._keyForVtxo = () => ({ privkey: new Uint8Array(32) });
+ m._save = () => {}; m._movement = () => {}; m._remoteBusy = () => new Set();
+ await m.reconcile();
+ const st = Object.fromEntries(m.state.vtxos.map((v) => [v.id, v.state]));
+ expect(st.gone).toBe('spent');
+ expect(st.open).toBe('pending');
+ expect(st.held).toBe('pending');
+ expect(st.coin).toBe('spendable');
+ expect(asked.sort()).toEqual([1, 2, 2]);
+});
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-offline-init.test.js b/tools/ark-offline-init.test.js
new file mode 100644
index 0000000..922682d
--- /dev/null
+++ b/tools/ark-offline-init.test.js
@@ -0,0 +1,22 @@
+// bun test tools/ark-offline-init.test.js — the Ark server gone: with the
+// server's last published info in hand the manager still opens (coins read,
+// exits can start); without it, init fails as before.
+import { test, expect, mock } from 'bun:test';
+import * as proto from '../src/ark/proto.js';
+mock.module('../src/ark/proto.js', () => ({ ...proto,
+ getArkInfo: async () => { throw new Error('fetch failed'); },
+ handshake: async () => { throw new Error('fetch failed'); },
+}));
+const { ArkManager } = await import('../src/ark/manager.js');
+const serverPubkey = '02' + 'ab'.repeat(32);
+const hint = { serverPubkey, vtxoExpiryDelta: 8640, vtxoExitDelta: 144, refreshFees: { baseFeeSat: 300, ppmExpiryTable: [] }, offboardFees: { baseFeeSat: 0, fixedAdditionalVb: 0, ppmExpiryTable: [] }, lnSendFees: {}, lnReceiveFees: {} };
+const mk = (infoHint) => new ArkManager({ account: null, storage: { load: () => ({ v: 1, serverPubkey, vtxos: [], actions: [], movements: [], scheduled: [] }), save() {} }, arkUrl: 'http://x', esploraUrl: 'http://y', network: 'mainnet', infoHint });
+test('with cached info the manager opens offline', async () => {
+ const m = await mk(hint).init();
+ expect(m.offline).toBe(true);
+ expect(m.info.refreshFees.baseFeeSat).toBe(300);
+ expect(Buffer.from(m.serverPub).toString('hex')).toBe(serverPubkey);
+});
+test('without cached info the old failure stands', async () => {
+ await expect(mk(null).init()).rejects.toThrow('fetch failed');
+});
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/cooperative-exit-ui-test.js b/tools/ark/cooperative-exit-ui-test.js
index d8fa0d8..03e65d5 100644
--- a/tools/ark/cooperative-exit-ui-test.js
+++ b/tools/ark/cooperative-exit-ui-test.js
@@ -66,10 +66,9 @@ try {
});
await page.waitForSelector('.coin-table');
};
- const coopButton = () => page.evaluate(() => [...document.querySelectorAll('.card')]
- .find((c) => c.querySelector('h4')?.textContent === 'Co-operative exit').querySelector('button').click());
+ const coopButton = () => page.evaluate(() => document.querySelector('.ark-coop-go').click());
const send = () => page.evaluate(() => [...document.querySelectorAll('.card')]
- .find((c) => c.querySelector('h3')?.textContent === 'Co-operative exit').querySelector('.btn-primary').click());
+ .find((c) => c.querySelector('h3')?.textContent === 'Withdraw').querySelector('.btn-primary').click());
const edit = (selector, value) => page.$eval(selector, (e, value) => { e.value = value; e.dispatchEvent(new Event('input', { bubbles: true })); }, value);
// The balance card's gear is the way in: Spending's settings lead with
// its coins and carry what a unilateral exit costs; the home screen no
@@ -114,13 +113,16 @@ try {
await page.evaluate(() => { window.forceArkReady = false; coopApp.wallet.saveFeatureState('arkDepth', null); coopApp.ui.screen = 'wallet'; coopApp.render(); });
await manage();
- assert.deepEqual(await page.$$eval('.card h4', (es) => es.map((e) => e.textContent)), ['Renewal', 'Co-operative exit', 'Unilateral exit']);
+ // the three actions sit right under the table, named for the ticked total; no explainer cards
+ assert.deepEqual(await page.$$eval('.ark-coin-actions button', (es) => es.map((e) => e.textContent)), ['Renew now', 'Withdraw 20,000 sats', 'Exit 20,000 sats']);
+ assert.deepEqual(await page.$$eval('.card h4', (es) => es.map((e) => e.textContent)), []);
await coopButton();
await page.waitForSelector('input.mono-input');
const savings = await page.evaluate(() => coopApp.wallet.freshReceive().address);
assert.equal(await page.$eval('input.mono-input', (e) => e.value), savings);
assert.equal(await page.$eval('input[type="number"]', (e) => e.value), '20000');
assert.equal(await page.$eval('.card .btn-primary', (e) => e.textContent), 'Send');
+ assert.match(await page.evaluate(() => document.body.innerText), /one transaction into Savings, ready after a single confirmation/, 'the withdraw explainer is on the form');
assert.deepEqual(await page.evaluate(() => coopCalls), [], 'opening the form never starts an exit');
await page.evaluate(() => [...document.querySelectorAll('.card button')].find((b) => b.textContent.includes('Back')).click());
await page.waitForSelector('.coin-table');
@@ -162,12 +164,25 @@ try {
assert.deepEqual(await page.evaluate(() => coopCalls[1].script), await page.evaluate(() => [...coopApp.wallet.derive(0, 1).script]));
console.log('✓ Editing the destination and amount offboards only the requested amount to the chosen on-chain address');
+ // ticking a subset on Manage: exactly those coins go, no split, the amount is theirs and not a field
+ await manage();
+ await page.evaluate(() => { coopApp.ui.arkCoinsSel = new Set(['coin-b']); coopApp.render(); });
+ assert.deepEqual(await page.$$eval('.ark-coin-actions button', (es) => es.map((e) => e.textContent)), ['Renew 1 selected for free', 'Withdraw 8,000 sats', 'Exit 8,000 sats'], 'the buttons name the ticked total');
+ await coopButton();
+ await page.waitForSelector('.ark-coop-chosen');
+ assert.match(await page.$eval('.ark-coop-chosen', (e) => e.textContent), /1 selected coin · 8,000 sats/);
+ assert.equal(await page.$('input[type="number"]'), null, 'no amount field for chosen coins');
+ await send();
+ await page.waitForFunction(() => coopCalls.length === 3 && !coopApp.ui.arkBusy);
+ assert.deepEqual(await page.evaluate(() => ({ type: coopCalls[2].type, ids: coopCalls[2].ids, address: coopCalls[2].address })), { type: 'offboard', ids: ['coin-b'], address: savings });
+ console.log('✓ Ticking a subset on Manage offboards exactly those coins, without splitting');
+
await manage(); await coopButton();
await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Wallet"]').click());
assert.equal(await page.evaluate(() => coopApp.ui.arkOffboardSend), null);
await manage();
await page.evaluate(() => { coopManager.state.vtxos.forEach((v) => v.state = 'pending'); coopManager.state.actions = [{ type: 'refresh', step: 'submitted', inputIds: ['coin-a', 'coin-b'], inAmountSat: 20000 }]; coopApp.render(); });
- assert(await page.evaluate(() => [...document.querySelectorAll('.card')].find((c) => c.querySelector('h4')?.textContent === 'Co-operative exit').querySelector('button').disabled));
+ assert(await page.evaluate(() => document.querySelector('.ark-coop-go').disabled));
await page.evaluate(() => {
const { ui, render } = coopApp;
ui.arkCoinsPage = null; ui.tab = 'send';
diff --git a/tools/ark/custody-foreign-test.js b/tools/ark/custody-foreign-test.js
new file mode 100644
index 0000000..148622d
--- /dev/null
+++ b/tools/ark/custody-foreign-test.js
@@ -0,0 +1,102 @@
+// On a server that does not honor a coin past its timelock (Second, a custom
+// captaind) "coinos holds my coins" is not a tier: whatever is stored, the
+// tier in force is "self" (or "exit" when chosen), the card offers only those
+// two, and the renewal pass registers backstops and renews what is due.
+// Real app with a local Ark manager stub; nothing is sent anywhere.
+// Run: bun tools/ark/custody-foreign-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';
+
+const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true,
+ plugins: [{ name: 'custody-foreign-test', setup(build) {
+ build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + `
+window.coopApp = { ui, wallet, ctx, render, enterWallet, openWalletSettings, get ready() { return !_bootDeciding; },
+ setSpending: (on = true) => { const a = activeAccount(); if (a) { if (on) a.kind = 'spending'; else delete a.kind; } } };
+` }));
+ build.onLoad({ filter: /src\/features\/ark\.js$/ }, async ({ path }) => ({ loader: 'js', contents:
+ (await Bun.file(path).text()).replace(" return {\n id: 'ark',", `
+ window.coopFeature = { setManager: (mgr) => { ark = mgr; }, getCustody, applyCustody, maybeAutoRefresh, autoRefreshCalls: [] };
+ return {\n id: 'ark',`) }));
+ build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' }));
+ } }] });
+assert(bundle.success, bundle.logs.join('\n'));
+const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`;
+const server = Bun.serve({ port: 0, fetch: (req) => new URL(req.url).pathname === '/'
+ ? new Response(html, { headers: { 'Content-Type': 'text/html' } }) : new Response(null, { status: 404 }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage(), errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.setViewport({ width: 390, height: 844 });
+ const cdp = await page.createCDPSession();
+ await cdp.send('Network.enable');
+ await cdp.send('Network.setBlockedURLs', { urls: ['wss://*', 'ws://*'] });
+ await page.setRequestInterception(true);
+ page.on('request', (r) => r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort());
+ await page.evaluateOnNewDocument(() => {
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ // a custom server: no honorsExpired on its record, like Second's preset
+ localStorage.setItem('btc-wallet-ark-provider:regtest', 'custom');
+ localStorage.setItem('btc-wallet-ark-custom:regtest', JSON.stringify({ ark: 'http://localhost:3535', esplora: 'http://localhost:30002' }));
+ });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => window.coopApp?.ready);
+ await page.evaluate(async (mn) => {
+ await coopApp.enterWallet(mn, '', { fresh: true, generated: true });
+ coopApp.ui.onb = null; localStorage.removeItem('btc-wallet-onb-step');
+ coopApp.wallet.scan = async () => {};
+ const table = [{ thresholdBlocks: 0, ppm: 0 }, { thresholdBlocks: 288, ppm: 2000 }, { thresholdBlocks: 1008, ppm: 4000 }, { thresholdBlocks: 2016, ppm: 5000 }];
+ window.coopManager = {
+ honorsExpired: false,
+ state: { vtxos: [
+ { id: 'coin-0', amountSat: 20000, state: 'spendable', expiryHeight: 973921, depth: 2 }, // weeks left
+ { id: 'coin-1', amountSat: 5000, state: 'spendable', expiryHeight: 969760, depth: 1 }, // 9 blocks from its timelock
+ ], actions: [], movements: [], scheduled: [] },
+ info: { network: 'regtest', vtxoExpiryDelta: 4032, roundIntervalSecs: 3600, refreshFees: { baseFeeSat: 0, ppmExpiryTable: table } }, _tipH: 969751,
+ chain: { tipHeight: async () => 969751 },
+ vtxos() { return this.state.vtxos; },
+ _decoded(v) { return { genesis: new Array(v.depth || 1).fill({}), policy: { type: 'pubkey' } }; },
+ balance() { return { spendableSat: 25000, pendingSat: 0, boardingSat: 0, expiredSat: 0 }; },
+ refreshFee(inputs, tip) {
+ let u = 0;
+ for (const v of inputs) u += v.amountSat * (table.filter((e) => e.thresholdBlocks <= v.expiryHeight - tip).pop()?.ppm ?? 0);
+ return Math.ceil(u / 1e6);
+ },
+ async ensureScheduledRefreshes(margin, max, grid) { coopFeature.autoRefreshCalls.push(['ensure', margin, max, grid]); return 0; },
+ async refresh(ids) { coopFeature.autoRefreshCalls.push(['refresh', ids]); return 'refresh-1'; },
+ unseenReceives() { return []; }, ackReceives() {},
+ address() { return 'test-ark-address'; },
+ };
+ coopFeature.setManager(coopManager);
+ coopApp.setSpending(true);
+ coopApp.openWalletSettings('spending');
+ }, generateMnemonic(wordlist));
+ await page.waitForSelector('.ark-custody');
+
+ // nothing stored: the tier in force is "self", and the card offers no custodial option
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'self');
+ assert.equal(await page.evaluate(() => coopApp.wallet.loadFeatureState('arkCustody', null)), null, 'nothing was written to the record');
+ const opts = await page.$$eval('#ark-custody-select option', (os) => os.map((o) => [o.value, o.selected]));
+ assert.deepEqual(opts, [['self', true], ['exit', false]]);
+ console.log('✓ a server that does not honor expired coins has no "coinos holds my coins" tier; "self" is in force');
+
+ // the stored default from a coinos wallet does not switch renewals off here
+ await page.evaluate(() => coopApp.wallet.saveFeatureState('arkCustody', 'custodial'));
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'self');
+ console.log('✓ a stored "custodial" choice reads as "self" on such a server');
+
+ // the renewal pass runs: backstop orders are registered and the coin at its timelock renews now
+ const calls = await page.evaluate(async () => { coopFeature.autoRefreshCalls.length = 0; await coopFeature.maybeAutoRefresh(coopManager); return coopFeature.autoRefreshCalls; });
+ assert.deepEqual(calls[0], ['ensure', 6, 8, 1], 'backstop orders registered (regtest margin 6)');
+ assert.deepEqual(calls[1], ['refresh', ['coin-1']], 'the coin close to its timelock is renewed now');
+ console.log('✓ automatic renewal runs on such a server with nothing opted into');
+
+ // the exit tier can still be chosen, and is kept
+ await page.evaluate(() => coopFeature.applyCustody('exit'));
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'exit');
+ assert.equal(await page.evaluate(() => coopApp.wallet.loadFeatureState('arkCustody', null)), 'exit');
+ console.log('✓ "exit" can be chosen there and is saved');
+ assert.deepEqual(errors, [], 'no page errors');
+} finally { await browser.close(); server.stop(true); }
diff --git a/tools/ark/custody-test.js b/tools/ark/custody-test.js
new file mode 100644
index 0000000..68e7191
--- /dev/null
+++ b/tools/ark/custody-test.js
@@ -0,0 +1,151 @@
+// The custody tiers, without a network: what each policy aims for, how the
+// manager treats a coin past its timelock on a server that honors it, how
+// backstop orders snap to a grid so wallets share rounds, and that the
+// receive-side validator can be told to accept such a coin.
+// Run: bun tools/ark/custody-test.js
+import assert from 'node:assert/strict';
+import { hex } from '@scure/base';
+import { ArkManager, renewPolicy, CUSTODY_TIERS, MAX_SEND_INPUTS } from '../../src/ark/manager.js';
+import { validateVtxo, VtxoValidationError } from '../../src/ark/validate.js';
+
+// policies
+assert.deepEqual(CUSTODY_TIERS, ['custodial', 'self', 'exit']);
+assert.equal(renewPolicy('custodial', 'mainnet'), null, 'the default tier renews nothing');
+assert.equal(renewPolicy('nonsense', 'mainnet'), null);
+assert.deepEqual(renewPolicy('self', 'mainnet'), { window: 96, margin: 36, grid: 48 });
+assert.deepEqual(renewPolicy('exit', 'mainnet'), { window: 96, margin: 36, grid: 48, maxDepth: 4 }, 'exit = self + a depth trigger');
+assert.deepEqual(renewPolicy('early', 'mainnet'), renewPolicy('exit', 'mainnet'), 'the old name still means the exit tier');
+assert.deepEqual(renewPolicy('self', 'regtest'), { window: 24, margin: 6, grid: 1 });
+// the snap never leaves the free bracket: under 96 blocks out
+for (let h = 100000; h < 100200; h++) { const s = h - 36; assert.ok(h - (s - (s % 48)) < 96); }
+console.log('✓ renewal policies: custodial renews nothing; self and exit aim inside the free bracket');
+
+const mk = (honorsExpired) => {
+ const mgr = new ArkManager({ account: null, storage: { load: () => null, save() {} }, arkUrl: 'http://x', esploraUrl: 'http://y', network: 'mainnet', honorsExpired });
+ mgr.state = { v: 1, nextKeyIndex: 1, vtxos: [], actions: [], movements: [], scheduled: [] };
+ mgr.info = { maxVtxoAmountSat: 5_000_000, refreshFees: { baseFeeSat: 300, ppmExpiryTable: [] } };
+ mgr._tipH = 1000;
+ return mgr;
+};
+const coin = (id, amountSat, expiryHeight, extra = {}) => ({ id, amountSat, expiryHeight, state: 'spendable', policy: 'pubkey', keyIndex: 1, ...extra });
+
+// a coin past its timelock: unusable on a server that refuses it, money on one that honors it
+{
+ const strict = mk(false), lenient = mk(true);
+ for (const m of [strict, lenient]) m.state.vtxos = [coin('a', 5000, 900), coin('b', 7000, 1200), coin('c', 100, 900)];
+ assert.equal(strict._expired(strict.state.vtxos[0], 1000), true);
+ assert.equal(strict._expired(strict.state.vtxos[2], 1000), false, 'sub-dust was always honored');
+ assert.deepEqual(strict.balance(), { spendableSat: 7100, pendingSat: 0, boardingSat: 0, expiredSat: 5000 });
+ assert.equal(lenient._expired(lenient.state.vtxos[0], 1000), false);
+ assert.deepEqual(lenient.balance(), { spendableSat: 12100, pendingSat: 0, boardingSat: 0, expiredSat: 0 });
+ assert.deepEqual(lenient._selectInputs(5000).map((v) => v.id), ['b'], 'a coin still inside its timelock is preferred…');
+ assert.deepEqual(lenient._selectInputs(7001).map((v) => v.id), ['b', 'a'], '…and the expired one joins only when needed');
+ lenient.state.vtxos.push(coin('d', 9000, 900));
+ assert.deepEqual(lenient._selectInputs(8000).map((v) => v.id), ['d'], 'a single expired coin beats a many-coin package');
+ lenient.state.vtxos.pop();
+ assert.deepEqual(strict._selectInputs(5000).map((v) => v.id), ['b']);
+ // the server's word still wins: a refusal naming the coin excludes it
+ lenient.state.vtxos[0].expiryRejected = true;
+ assert.equal(lenient._expired(lenient.state.vtxos[0], 1000), true);
+ assert.equal(lenient.balance().spendableSat, 7100);
+ console.log('✓ honorsExpired: a coin past its timelock stays spendable; a server refusal still excludes it');
+}
+
+// the send cap is wider now that nothing consolidates on its own
+{
+ const m = mk(true);
+ m.state.vtxos = Array.from({ length: 40 }, (_, i) => coin('c' + i, 100, 2000));
+ assert.equal(m._selectInputs(3500).length, 35, 'a 35-coin send is fine');
+ m.state.vtxos = Array.from({ length: MAX_SEND_INPUTS + 5 }, (_, i) => coin('d' + i, 100, 2000));
+ assert.throws(() => m._selectInputs(100 * (MAX_SEND_INPUTS + 1)), /too many coins/);
+ console.log(`✓ a send takes up to ${MAX_SEND_INPUTS} coins`);
+}
+
+// backstop orders snap down to the grid, and skip what the snap would put in the past
+{
+ const m = mk(true);
+ const orders = [];
+ m._tipMemo = async () => 1000;
+ m.scheduleRefresh = async (ids, height) => { orders.push([ids[0], height]); };
+ m.state.vtxos = [
+ coin('x', 10000, 1100), // 1100-36 = 1064 → 1056
+ coin('y', 20000, 1041), // 1005 → 960 ≤ tip: skipped (the app itself renews it)
+ coin('z', 30000, 1200), // 1164 → 1152
+ coin('w', 40000, 1200, { state: 'pending' }),
+ ];
+ assert.equal(await m.ensureScheduledRefreshes(36, 8, 48), 2);
+ assert.deepEqual(orders, [['z', 1152], ['x', 1056]], 'largest first, snapped to multiples of 48');
+ assert.ok(orders.every(([, h]) => h % 48 === 0));
+ orders.length = 0;
+ m.state.scheduled = [];
+ assert.equal(await m.ensureScheduledRefreshes(36, 8, 1), 3, 'grid 1 = the old exact heights');
+ assert.deepEqual(orders, [['z', 1164], ['y', 1005], ['x', 1064]]);
+ console.log('✓ backstop orders snap to the grid (and never into the past)');
+}
+
+// the validator: an expired coin is refused by default, accepted when told the server honors it
+{
+ const serverPub = new Uint8Array(33).fill(2);
+ const v = { policy: { type: 'pubkey', userPubkey: 'aa' }, serverPubkey: hex.encode(serverPub), genesis: [{ transition: {} }],
+ expiryHeight: 500, amountSat: 1000, anchorPoint: { txid: 'ab'.repeat(32), vout: 0 }, point: { txid: 'cd'.repeat(32), vout: 0 }, id: 'x' };
+ const chain = { tipHeight: async () => 600, getTxStatus: async () => ({ confirmed: false }), getTxHex: async () => null };
+ await assert.rejects(validateVtxo(v, { serverPubkey: serverPub, chain }), (e) => e instanceof VtxoValidationError && /expired at 500/.test(e.message));
+ await assert.rejects(validateVtxo(v, { serverPubkey: serverPub, chain, acceptExpired: true }), (e) => e instanceof VtxoValidationError && /not confirmed/.test(e.message), 'past the expiry check, on to the anchor');
+ console.log('✓ validateVtxo: acceptExpired lets a coin past its timelock through the expiry check');
+}
+
+// deep coins: the server's depth cap and its base-fee waiver, mirrored
+{
+ const m = mk(true);
+ m.info.refreshFees.deepWaiverDepth = 60;
+ m.info.maxVtxoExitDepth = 100;
+ m.exitDepth = (v) => v.depth || 0;
+ m._tipMemo = async () => 1000;
+ const shallow = coin('s', 5000, 2000, { depth: 7 }), deep = coin('d', 4000, 2000, { depth: 60 });
+ const capped = coin('x', 3000, 2000, { depth: 100 }), near = coin('n', 2500, 2000, { depth: 92 });
+ const expiredDeep = coin('e', 6000, 900, { depth: 70 });
+ m.state.vtxos = [shallow, deep, capped, near, expiredDeep];
+ assert.equal(m.refreshFee([deep], 1000), 0, 'a refresh of deep coins alone pays no base fee');
+ assert.equal(m.refreshFee([deep, expiredDeep], 1000), 0);
+ assert.equal(m.refreshFee([deep, shallow], 1000), 300, '…one shallow coin in the set and it pays');
+ assert.equal(m.refreshFee([shallow], 1000), 300);
+ assert.deepEqual(m.deepCoins().map((v) => v.id).sort(), ['d', 'e', 'n', 'x'], 'deep = at the waiver depth or within 8 hops of the cap, expired or not');
+ assert.deepEqual(m._selectInputs(2900).map((v) => v.id), ['d'], 'a coin at the cap is never an input (the cosign would be refused)');
+ m.state.vtxos = [capped];
+ assert.throws(() => m._selectInputs(1000), /waiting to be renewed/, 'and the shortfall says why');
+ m.state.vtxos = [shallow, deep, capped, near, expiredDeep];
+ // the worker's orders: one per deep coin still inside its timelock, two blocks out
+ const orders = [];
+ m.scheduleRefresh = async (ids, h) => { orders.push([ids.join(), h]); (m.state.scheduled ||= []).push({ inputIds: ids, unlockHash: 'u' + ids }); };
+ assert.equal(await m.ensureDeepRefreshes(3), 3);
+ assert.deepEqual(orders, [['x', 1002], ['n', 1002], ['d', 1002]], 'deepest first, at tip + 2; the expired one waits for the app');
+ assert.equal(await m.ensureDeepRefreshes(3), 0, 'covered coins are not ordered twice');
+ // no waiver and no cap advertised: nothing is deep
+ const plain = mk(true); plain.exitDepth = () => 999; plain.state.vtxos = [coin('p', 5000, 2000)];
+ assert.deepEqual(plain.deepCoins(), []);
+ assert.equal(plain.refreshFee(plain.state.vtxos, 1000), 300, 'no waiver advertised: the base fee stands however deep');
+ 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/custody-ui-test.js b/tools/ark/custody-ui-test.js
new file mode 100644
index 0000000..9c238f5
--- /dev/null
+++ b/tools/ark/custody-ui-test.js
@@ -0,0 +1,136 @@
+// The custody card on the Spending settings page: three tiers, coinos holding
+// the coins by default, a coin past its timelock shown as held (not expired),
+// and switching tiers persists and reprices the page. Real app with a local
+// Ark manager stub; nothing is sent anywhere.
+// Run: bun tools/ark/custody-ui-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';
+
+const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true,
+ plugins: [{ name: 'custody-test', setup(build) {
+ build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + `
+window.coopApp = { ui, wallet, ctx, render, enterWallet, openWalletSettings, get ready() { return !_bootDeciding; },
+ setSpending: (on = true) => { const a = activeAccount(); if (a) { if (on) a.kind = 'spending'; else delete a.kind; } } };
+` }));
+ build.onLoad({ filter: /src\/features\/ark\.js$/ }, async ({ path }) => ({ loader: 'js', contents:
+ (await Bun.file(path).text()).replace(" return {\n id: 'ark',", `
+ window.coopFeature = { setManager: (mgr) => { ark = mgr; }, getCustody, autoRefreshCalls: [] };
+ return {\n id: 'ark',`) }));
+ build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' }));
+ } }] });
+assert(bundle.success, bundle.logs.join('\n'));
+const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`;
+const server = Bun.serve({ port: 0, fetch: (req) => new URL(req.url).pathname === '/'
+ ? new Response(html, { headers: { 'Content-Type': 'text/html' } }) : new Response(null, { status: 404 }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage(), errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.setViewport({ width: 390, height: 844 });
+ const cdp = await page.createCDPSession();
+ await cdp.send('Network.enable');
+ await cdp.send('Network.setBlockedURLs', { urls: ['wss://*', 'ws://*'] });
+ await page.setRequestInterception(true);
+ page.on('request', (r) => r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort());
+ await page.evaluateOnNewDocument(() => {
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ localStorage.setItem('btc-wallet-ark-provider:regtest', 'local'); // the local preset honors expired coins
+ });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => window.coopApp?.ready);
+ await page.evaluate(async (mn) => {
+ await coopApp.enterWallet(mn, '', { fresh: true, generated: true });
+ coopApp.ui.onb = null; localStorage.removeItem('btc-wallet-onb-step');
+ coopApp.wallet.scan = async () => {};
+ const table = [{ thresholdBlocks: 0, ppm: 0 }, { thresholdBlocks: 97, ppm: 250 }, { thresholdBlocks: 199, ppm: 1000 }, { thresholdBlocks: 2161, ppm: 2000 }];
+ window.coopManager = {
+ honorsExpired: true,
+ state: { vtxos: [
+ { id: 'coin-0', amountSat: 20000, state: 'spendable', expiryHeight: 973921, depth: 5 }, // 29 days left, five hops deep
+ { id: 'coin-1', amountSat: 5000, state: 'spendable', expiryHeight: 960000, depth: 1 }, // past its timelock
+ { id: 'coin-2', amountSat: 800, state: 'spendable', expiryHeight: 973921, depth: 6 }, // deep but not worth 300 sats
+ ], actions: [], movements: [], scheduled: [] },
+ info: { network: 'regtest', vtxoExpiryDelta: 8640, roundIntervalSecs: 120, refreshFees: { baseFeeSat: 300, ppmExpiryTable: table } }, _tipH: 969751,
+ chain: { tipHeight: async () => 969751 },
+ vtxos() { return this.state.vtxos; },
+ _decoded(v) { return { genesis: new Array(v.depth || 1).fill({}), policy: { type: 'pubkey' } }; },
+ balance() { return { spendableSat: 25800, pendingSat: 0, boardingSat: 0, expiredSat: 0 }; },
+ refreshFee(inputs, tip) {
+ let u = 0;
+ for (const v of inputs) u += v.amountSat * (table.filter((e) => e.thresholdBlocks <= v.expiryHeight - tip).pop()?.ppm ?? 0);
+ return 300 + Math.ceil(u / 1e6);
+ },
+ async ensureScheduledRefreshes(margin, max, grid) { coopFeature.autoRefreshCalls.push(['ensure', margin, max, grid]); return 0; },
+ async refresh(ids) { coopFeature.autoRefreshCalls.push(['refresh', ids]); return 'refresh-1'; },
+ unseenReceives() { return []; }, ackReceives() {},
+ address() { return 'test-ark-address'; },
+ };
+ coopFeature.setManager(coopManager);
+ coopApp.setSpending(true);
+ coopApp.openWalletSettings('spending');
+ }, generateMnemonic(wordlist));
+ await page.waitForSelector('.ark-custody');
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+ const text = () => page.evaluate(() => document.body.innerText);
+
+ let tx = await text();
+ assert.match(tx, /Custody/);
+ const opts = await page.$$eval('.ark-custody option', (es) => es.map((e) => e.value + '=' + e.textContent));
+ assert.deepEqual(opts, ['custodial=Allow coins to expire', 'self=Minimize renewal cost', 'exit=Minimize exit cost']);
+ assert.equal(await page.$eval('.ark-custody-cost', (e) => e.textContent), 'no fees');
+ assert.equal(await page.$eval('#ark-custody-select', (e) => e.value), 'custodial', 'coins expire by default');
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'custodial');
+ assert.match(await page.$eval('.ark-custody-desc', (e) => e.textContent), /keeps its own exit until it expires/);
+ assert.doesNotMatch(tx, /Coins renew in the last hours|Every transfer adds a hop/, 'only the chosen strategy is described');
+ assert.doesNotMatch(tx, /timelock|Spending coin carries|old Coinos/, 'no jargon on the card');
+ const order = await page.$$eval('.card h4, .card h3', (es) => es.map((e) => e.textContent).filter((x) => /Spending coins|Custody strategy|Renewal/.test(x)));
+ assert.deepEqual(order, ['Spending coins', 'Custody strategy'], 'coins first, then the strategy; no explainer cards');
+ assert.match(tx, /held by Coinos/, 'the coin past its timelock is held, not "expired"');
+ assert.doesNotMatch(tx, /5,000 sats\s+expired/, 'the row never says "expired"');
+ assert.doesNotMatch(tx, /nothing renews on its own/, 'the renewal explainer is not on the page…');
+ const renewSheet = async () => { await page.click('.ark-renew-ask'); await sleep(200); const t = await text(); await page.evaluate(() => [...document.querySelectorAll('.ark-renew-confirm button')].find((b) => b.textContent === 'Cancel').click()); await sleep(200); return t; };
+ assert.match(await renewSheet(), /With “Allow coins to expire”, nothing renews on its own[\s\S]*more than 15 days left/, '…it is on the Renew confirmation, with the fee schedule');
+ if (process.env.SHOT) await page.screenshot({ path: process.env.SHOT, fullPage: true });
+ console.log('✓ the custody card: a drop-down of three strategies with their prices, coins expire by default, one description, a past-timelock coin shown as held');
+
+ // the unilateral exit only counts the coin still inside its timelock
+ assert.equal(await page.$eval('.ark-exit-ask', (b) => b.textContent), 'Exit 20,800 sats', 'the exit button names the ticked coins still inside their timelock');
+ assert.equal(await page.$eval('.ark-exit-ask', (b) => b.disabled), false);
+ await page.click('.ark-exit-ask'); await sleep(200);
+ tx = await text();
+ assert.match(tx, /Coins\s*2/); assert.match(tx, /Amount\s*20,800 sats/);
+ assert.match(tx, /If the server vanished today/, 'the exit explainer is on the confirmation');
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent === 'Cancel').click()); await sleep(200);
+ console.log('✓ a unilateral exit counts only the coin still inside its timelock');
+
+ // opt into keeping the exit: persisted, the page reprices, renewal starts covering
+ await page.select('#ark-custody-select', 'self'); await sleep(400);
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'self');
+ assert.equal(await page.evaluate(() => coopApp.wallet.loadFeatureState('arkCustody')), 'self', 'saved with the wallet');
+ tx = await text();
+ assert.match(tx, /Custody choice saved/);
+ assert.match(await renewSheet(), /renews coins automatically once they are close to expiring/);
+ assert.match(await page.$eval('.ark-custody-desc', (e) => e.textContent), /Coins renew in the last hours/);
+ assert.equal(await page.$eval('.ark-custody-cost', (e) => e.textContent), '300 sats per renewal');
+ const calls = await page.evaluate(() => coopFeature.autoRefreshCalls);
+ assert.deepEqual(calls[0], ['ensure', 6, 8, 1], 'backstop orders registered at once (regtest margin 6)');
+ assert.deepEqual(calls[1], ['refresh', ['coin-1', 'coin-2']], 'the coin past its timelock is renewed now with the small change; the deep healthy one is left alone');
+ console.log('✓ switching to "Keep my own exit" persists, reprices the page and starts renewing');
+
+ // the exit tier also flattens the deep coin — but not the deep 800-sat one on its own merits
+ await page.evaluate(() => { coopFeature.autoRefreshCalls.length = 0; });
+ await page.select('#ark-custody-select', 'exit'); await sleep(400);
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'exit');
+ assert.equal(await page.$eval('.ark-custody-cost', (e) => e.textContent), '300 sats near expiry, 0.2% + 300 sats when early');
+ const calls2 = await page.evaluate(() => coopFeature.autoRefreshCalls);
+ assert.deepEqual(calls2[1], ['refresh', ['coin-1', 'coin-0', 'coin-2']], 'expired + deep (worth it) + small change ride one round');
+ console.log('✓ "Keep my exit cheap" renews the deep coin too, when the fee is under a tenth of it');
+ await page.select('#ark-custody-select', 'custodial'); await sleep(300);
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'custodial');
+ assert.match(await renewSheet(), /nothing renews on its own/);
+ console.log('✓ the other tiers switch the same way, and back');
+ assert.deepEqual(errors, [], 'no page errors');
+ console.log('\n✅ custody is a choice, made on the Spending page');
+} finally { await browser.close(); server.stop(true); }
diff --git a/tools/ark/offboard-split-test.js b/tools/ark/offboard-split-test.js
new file mode 100644
index 0000000..561ee2b
--- /dev/null
+++ b/tools/ark/offboard-split-test.js
@@ -0,0 +1,128 @@
+// A partial on-chain send from Spending moves whole coins: the amount is
+// covered by coins that already add up to it, else split off via a self-send
+// that lands as one coin per input — all of them go, not one of the exact
+// size (Jarryd, 2026-10-23: three splits, no send). Local manager stub.
+// Run: bun tools/ark/offboard-split-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';
+
+const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true,
+ plugins: [{ name: 'offboard-split-test', setup(build) {
+ build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + `
+window.coopApp = { ui, wallet, ctx, render, enterWallet, get ready() { return !_bootDeciding; } };
+` }));
+ build.onLoad({ filter: /src\/features\/ark\.js$/ }, async ({ path }) => ({ loader: 'js', contents:
+ (await Bun.file(path).text()).replace(" return {\n id: 'ark',", `
+ window.coopFeature = { setManager: (mgr) => { ark = mgr; }, offboard: doArkOffboard, exactCoins };
+ return {\n id: 'ark',`) }));
+ build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' }));
+ } }] });
+assert(bundle.success, bundle.logs.join('\n'));
+const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`;
+const server = Bun.serve({ port: 0, fetch: (req) => new URL(req.url).pathname === '/'
+ ? new Response(html, { headers: { 'Content-Type': 'text/html' } }) : new Response(null, { status: 404 }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage(), errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.setViewport({ width: 390, height: 844 });
+ const cdp = await page.createCDPSession();
+ await cdp.send('Network.enable');
+ await cdp.send('Network.setBlockedURLs', { urls: ['wss://*', 'ws://*'] });
+ await page.setRequestInterception(true);
+ page.on('request', (r) => r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort());
+ await page.evaluateOnNewDocument(() => {
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ localStorage.setItem('btc-wallet-ark-provider:regtest', 'off');
+ });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => window.coopApp?.ready);
+ await page.evaluate(async (mn) => {
+ await coopApp.enterWallet(mn, '', { fresh: true, generated: true });
+ coopApp.ui.onb = null; localStorage.removeItem('btc-wallet-onb-step');
+ coopApp.wallet.scan = async () => {};
+ window.coopCalls = [];
+ window.coopManager = {
+ state: { vtxos: [], actions: [], movements: [], nextKeyIndex: 5 },
+ async offboardQuote() { return { chainSat: 736, serviceSat: 204, totalSat: 940, minSat: 1270 }; },
+ info: { network: 'regtest' }, _tipH: 100,
+ vtxos() { return this.state.vtxos; },
+ refreshFee() { return 0; }, unseenReceives() { return []; }, ackReceives() {},
+ address() { return 'test-ark-address'; },
+ // the real thing: largest inputs first until covered, one destination
+ // coin per input (the last carries the remainder), change on a new key;
+ // the destination coins land on the receive key at the next sync
+ async send(address, amount) {
+ coopCalls.push({ type: 'split', address, amount });
+ const free = this.state.vtxos.filter((v) => v.state === 'spendable').sort((a, b) => b.amountSat - a.amountSat);
+ const inputs = []; let sum = 0;
+ for (const v of free) { inputs.push(v); sum += v.amountSat; if (sum >= amount) break; }
+ let remaining = amount; const landing = [];
+ for (const v of inputs) {
+ const dest = Math.min(remaining, v.amountSat); remaining -= dest;
+ landing.push({ id: `dest-${v.id}`, amountSat: dest, state: 'spendable', keyIndex: 0 });
+ if (v.amountSat > dest) this.state.vtxos.push({ id: `change-${v.id}`, amountSat: v.amountSat - dest, state: 'spendable', keyIndex: this.state.nextKeyIndex++ });
+ v.state = 'spent';
+ }
+ this.pendingLanding = landing;
+ return 'send-1';
+ },
+ async sync() { if (this.pendingLanding) { this.state.vtxos.push(...this.pendingLanding); this.pendingLanding = null; } },
+ async startOffboard(spk, address, ids) {
+ coopCalls.push({ type: 'offboard', address, ids: ids || null });
+ return { txid: 'ab'.repeat(32), netSat: 1, feeSat: 1 };
+ },
+ };
+ coopFeature.setManager(coopManager);
+ coopApp.ui.screen = 'wallet'; coopApp.ui.tab = 'send'; coopApp.render();
+ }, generateMnemonic(wordlist));
+ const run = (coins, amount) => page.evaluate(async (coins, amount) => {
+ coopManager.state.vtxos = coins.map((c) => ({ state: 'spendable', keyIndex: 0, ...c }));
+ coopCalls.length = 0;
+ coopApp.ui.arkError = ''; coopApp.ui.arkOffboarded = null;
+ await coopFeature.offboard(amount, coopApp.wallet.derive(0, 1).address);
+ return { calls: coopCalls, err: coopApp.ui.arkError, done: !!coopApp.ui.arkOffboarded };
+ }, coins, amount);
+
+ // Jarryd's balance after one split: the halves add up, so nothing is split again
+ let r = await run([{ id: 'a', amountSat: 109958 }, { id: 'b', amountSat: 75042 }, { id: 'c', amountSat: 24958, keyIndex: 2 }], 185000);
+ assert.equal(r.err, '');
+ assert(r.done);
+ assert.deepEqual(r.calls.map((c) => c.type), ['offboard']);
+ assert.deepEqual([...r.calls[0].ids].sort(), ['a', 'b']);
+ console.log('✓ coins that already add up to the amount go as they are, no split');
+
+ // one coin of the exact size
+ r = await run([{ id: 'a', amountSat: 109958 }, { id: 'c', amountSat: 24958 }], 24958);
+ assert.deepEqual(r.calls, [{ type: 'offboard', address: r.calls[0].address, ids: ['c'] }]);
+ console.log('✓ a single coin of the exact size is the one that goes');
+
+ // Jarryd's first attempt: 185,000 out of 100,000 + 109,958 — nothing adds
+ // up, so a self-send splits it off and lands as two coins; both go
+ r = await run([{ id: 'a', amountSat: 100000 }, { id: 'b', amountSat: 109958 }], 185000);
+ assert.equal(r.err, '');
+ assert(r.done);
+ assert.deepEqual(r.calls.map((c) => c.type), ['split', 'offboard']);
+ assert.equal(r.calls[0].amount, 185000);
+ assert.deepEqual([...r.calls[1].ids].sort(), ['dest-a', 'dest-b']);
+ const left = await page.evaluate(() => coopManager.state.vtxos.filter((v) => v.state === 'spendable').map((v) => [v.id, v.amountSat, v.keyIndex]));
+ assert.deepEqual(left.filter(([id]) => id.startsWith('change')), [['change-a', 24958, 5]]);
+ console.log('✓ a split that lands as two coins sends both; the change stays behind');
+
+ // everything: no carving at all
+ r = await run([{ id: 'a', amountSat: 100000 }, { id: 'b', amountSat: 109958 }], 0);
+ assert.deepEqual(r.calls, [{ type: 'offboard', address: r.calls[0].address, ids: null }]);
+ console.log('✓ the whole balance goes without a split');
+
+ // the subset search: prefers few large coins, gives up on no fit
+ const sub = await page.evaluate(() => {
+ const coins = (a) => a.map((amountSat, i) => ({ id: 'c' + i, amountSat }));
+ const pick = (a, n) => { const r = coopFeature.exactCoins(coins(a), n); return r && r.map((v) => v.amountSat).sort((x, y) => y - x); };
+ return [pick([5000, 3000, 2000, 1000, 1000], 6000), pick([5000, 3000], 7000), pick([5000, 3000, 2000], 5000), pick([7000, 3000], 3000)];
+ });
+ assert.deepEqual(sub, [[5000, 1000], null, [5000], [3000]]);
+ console.log('✓ exactCoins finds a subset, the single coin, or nothing');
+ assert.deepEqual(errors, []);
+} finally { await browser.close(); server.stop(true); }
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/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 5bfa38c..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';
@@ -53,7 +53,8 @@ const server = Bun.serve({ port: PORT,
},
});
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new',
- args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'] });
+ args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required',
+ '--auto-select-desktop-capture-source=Entire screen'] }); // a screen to share, chosen without asking
// our DM relays lead to the test's relay
const toLocal = (port) => {
const Real = window.WebSocket;
@@ -73,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 };
}
@@ -106,6 +107,7 @@ try {
console.log('[a video call]');
await openDm(A, B.pk);
check('the conversation has voice and video call buttons', await A.page.evaluate(() => document.querySelectorAll('.chat-head .call-start').length === 2));
+ const w0 = wrapIds.size;
await A.page.evaluate(() => document.querySelector('.call-start[data-call="video"]').click());
check('the caller sees it calling, their own camera on', await waitFor(A.page, () => /calling/i.test(document.querySelector('.call-overlay .call-status')?.textContent || '') && document.querySelector('video.call-local')?.videoWidth > 0, 10000));
check('the callee, on their wallet page, is rung: who, and a video call', await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept') && /Alice Caller/.test(document.querySelector('.call-overlay').innerText) && /video call/i.test(document.querySelector('.call-overlay').innerText), 15000));
@@ -119,6 +121,9 @@ try {
await sleep(1500);
const ma = await media(A.page), mb = await media(B.page);
check('each sees the other\'s picture and gets their sound', ma.video && ma.audio && mb.video && mb.audio, JSON.stringify({ ma, mb }));
+ // the routes ride in the offer and answer: a household's two devices
+ // calling each other mustn't flood the relays (a per-IP limit ate the answer)
+ check('...in a handful of messages, not one per route', wrapIds.size - w0 <= 6, (wrapIds.size - w0) + ' wraps');
await B.page.evaluate(() => document.querySelector('.call-bar .call-btn[aria-label="Mute"]').click());
check('mute turns the microphone off', await waitFor(B.page, () => !!document.querySelector('.call-btn.on[aria-label="Unmute"]'), 3000));
await A.page.evaluate(() => document.querySelector('.call-btn[aria-label="Minimize"]').click());
@@ -147,6 +152,50 @@ try {
await A.page.evaluate(() => document.querySelector('.call-decline').click());
await waitFor(B.page, () => !document.querySelector('.call-overlay'), 8000);
+ console.log('\n[a voice call turned video]');
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
+ await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept'), 15000);
+ await B.page.evaluate(() => document.querySelector('.call-accept').click());
+ const live = (p) => waitFor(p, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 20000);
+ check('a voice call connects, no cameras', await live(A.page) && await live(B.page) && !(await A.page.evaluate(() => !!document.querySelector('video.call-local'))));
+ check('...each side offered Turn on camera', await waitFor(A.page, () => !!document.querySelector('.call-add-video'), 3000) && await waitFor(B.page, () => !!document.querySelector('.call-add-video'), 3000));
+ await A.page.evaluate(() => document.querySelector('.call-add-video').click());
+ check('Alice turns her camera on: she sees herself, and gets camera off and flip', await waitFor(A.page, () => !!document.querySelector('video.call-local')?.videoWidth && !document.querySelector('.call-add-video')
+ && !!document.querySelector('.call-btn[aria-label="Switch camera"]'), 10000));
+ check('...Bob sees her', await waitFor(B.page, () => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && !v.paused; }, 15000), JSON.stringify(await media(B.page)));
+ check('...and can turn his on too', await waitFor(B.page, () => !!document.querySelector('.call-add-video'), 3000));
+ await B.page.evaluate(() => document.querySelector('.call-add-video').click());
+ check('Bob turns his on: Alice sees him, both still talking', await waitFor(A.page, () => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && !v.paused; }, 15000)
+ && (await media(A.page)).audio && (await media(B.page)).audio && await live(A.page), JSON.stringify(await media(A.page)));
+
+ console.log('\n[sharing a screen]');
+ const dims = (p) => p.evaluate(() => { const v = document.querySelector('video.call-remote'); return v ? v.videoWidth + 'x' + v.videoHeight : ''; });
+ const camDims = await dims(B.page);
+ await A.page.evaluate(() => document.querySelector('.call-share').click());
+ check('Alice shares her screen: her preview shows it, the button says Stop sharing', await waitFor(A.page, () => !!document.querySelector('video.call-local.sharing') && !!document.querySelector('.call-share.on[aria-label="Stop sharing"]'), 10000));
+ check('...Bob sees the screen in place of her camera', await waitFor(B.page, (d) => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && v.videoWidth + 'x' + v.videoHeight !== d; }, 15000, camDims), camDims + ' → ' + await dims(B.page));
+ await A.page.evaluate(() => document.querySelector('.call-share').click());
+ const camOf = (p) => p.evaluate(() => { const v = document.querySelector('video.call-local'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return t ? t.label + ' ' + t.readyState : ''; });
+ check('stopping brings her camera back: it\'s her camera she sends, Bob still sees her', await waitFor(A.page, () => { const v = document.querySelector('video.call-local:not(.sharing)'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return !!t && !/^screen/.test(t.label) && t.readyState === 'live'; }, 10000)
+ && await waitFor(B.page, () => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && !v.paused; }, 10000), await camOf(A.page) + ' | Bob ' + await dims(B.page));
+ await A.page.evaluate(() => document.querySelector('.call-hang').click());
+ await waitFor(A.page, () => !document.querySelector('.call-overlay'), 8000);
+ await waitFor(B.page, () => !document.querySelector('.call-overlay'), 8000);
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
+ await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept'), 15000);
+ await B.page.evaluate(() => document.querySelector('.call-accept').click());
+ await live(A.page); await live(B.page);
+ await waitFor(A.page, () => !!document.querySelector('.call-share'), 3000);
+ await A.page.evaluate(() => document.querySelector('.call-share').click());
+ check('in a voice call, a shared screen shows on Bob\'s side', await waitFor(B.page, () => !!document.querySelector('.call-overlay.has-video') && document.querySelector('video.call-remote').videoWidth > 0, 15000));
+ await A.page.evaluate(() => document.querySelector('.call-share').click());
+ check('...and goes when she stops (no camera to come back), the call carrying on', await waitFor(B.page, () => !document.querySelector('.call-overlay.has-video'), 15000)
+ && await live(B.page) && (await media(B.page)).audio);
+ check('...and her camera never came on', await A.page.evaluate(() => !document.querySelector('video.call-local')));
+ await A.page.evaluate(() => document.querySelector('.call-hang').click());
+ await waitFor(A.page, () => !document.querySelector('.call-overlay'), 8000);
+ await waitFor(B.page, () => !document.querySelector('.call-overlay'), 8000);
+
console.log('\n[a remote signer\'s way: the candidates inside the offer and answer]');
for (const w of [A, B]) await w.page.evaluate(() => localStorage.setItem('coinos-call-trickle', '0'));
const before = wrapIds.size;
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/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 e762d1a..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]) => {
@@ -67,7 +67,7 @@ try {
const following = await rowIds();
check('…only posts: no replies in Following', following.includes(A1.id) && following.includes(B1.id) && !following.includes(R1.id) && !following.includes(R2.id), following.map((x) => x.slice(0, 6)).join(','));
const modes = () => page.$$eval('.media-mode', (l) => l.map((e) => e.getAttribute('data-mode') + '=' + e.textContent.trim() + (e.classList.contains('on') ? '*' : '')).join(' '));
- check('Notes and Conversations lead the view chips', await modes() === 'all=Notes* replies=Conversations images=Images videos=Videos articles=Articles', await modes());
+ check('Notes and Conversations lead the view chips', await modes() === 'all=Notes* replies=Conversations images=Images videos=Videos live=Live articles=Articles', await modes());
check('no Conversations feed chip of its own', !(await page.$$eval('.feed-chips > .feed-chip', (l) => l.some((e) => /Conversations/.test(e.textContent)))));
console.log('\n[Conversations]');
@@ -79,7 +79,8 @@ try {
const c1 = await contextOf(R1.id);
check('a reply sits under the post it answers (in hand from Following, no relay asked)', !!c1 && c1.of === B1.id && c1.card && /Replying to/.test(c1.text) && /Bob Asker/.test(c1.text) && /bob root here/.test(c1.text), JSON.stringify(c1));
check('…and the header no longer repeats "reply"', !(await page.$eval('.notes-feed .note-post[data-zap-post="' + R1.id + '"] .note-header', (e) => /\breply\b/i.test(e.innerText))));
- check('a parent nobody has: the card says so', await waitFor((id) => { const c = document.querySelector('.note-post[data-zap-post="' + id + '"] .note-context'); return !!c && /find that note/i.test(c.innerText); }, R2.id, 8000), (await contextOf(R2.id) || {}).text);
+ // (a "Couldn't find that note" card over it told nobody anything: Adam, 2026-10-20)
+ check('a parent nobody has: the reply leaves the feed once every lookup comes back without it', await waitFor((ids) => !document.querySelector('.note-post[data-zap-post="' + ids[0] + '"]') && !!document.querySelector('.note-post[data-zap-post="' + ids[1] + '"]'), [R2.id, R1.id], 12000), (await rowIds()).map((x) => x.slice(0, 6)).join(','));
// the reply's own tap (its text, not the card above it): its thread, at the reply
await page.evaluate((id) => document.querySelector('.note-post[data-zap-post="' + id + '"] > .note-content > .note-text').click(), R1.id);
check('tapping the reply opens the thread at the reply', await waitFor((id) => !!document.querySelector('[data-focus-note="1"][data-zap-post="' + id + '"]'), R1.id, 8000));
@@ -114,7 +115,7 @@ try {
});
await click('save'); await sleep(600);
check('saved as a chip', await waitFor(() => [...document.querySelectorAll('.feed-chip')].some((e) => e.textContent.trim() === 'Alice talks' && e.classList.contains('on')), null, 6000));
- check('…opened in the Conversations view, the replies borrowed from the feeds in hand', /replies=Conversations\*/.test(await modes()) && await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 2 && got.includes(ids[0]) && got.includes(ids[1]); }, [R1.id, R2.id], 8000), (await rowIds()).map((x) => x.slice(0, 6)).join(','));
+ check('…opened in the Conversations view, the replies borrowed from the feeds in hand', /replies=Conversations\*/.test(await modes()) && await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 1 && got.includes(ids[0]) && !got.includes(ids[1]); }, [R1.id, R2.id], 8000), (await rowIds()).map((x) => x.slice(0, 6)).join(','));
check('…each with its context', (await contextOf(R1.id) || {}).of === B1.id);
await page.evaluate(() => document.querySelector('.media-mode[data-mode="all"]').click());
check('Notes: the same feed\'s posts', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 2 && got.includes(ids[0]) && got.includes(ids[1]); }, [A1.id, B1.id], 8000), (await rowIds()).map((x) => x.slice(0, 6)).join(','));
@@ -122,7 +123,7 @@ try {
await page.evaluate(() => document.querySelector('.feed-chip[data-key="chip:following"]').click());
check('Following in Notes is posts only', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.includes(ids[0]) && !got.includes(ids[1]); }, [A1.id, R1.id], 8000));
await page.evaluate(() => document.querySelector('.media-mode[data-mode="replies"]').click());
- check('…and its Conversations view is back at once', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 2 && got[0] === ids[0]; }, [R2.id], 8000));
+ check('…and its Conversations view is back at once', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 1 && got[0] === ids[0]; }, [R1.id], 8000));
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
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 eee88c5..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';
@@ -14,7 +14,9 @@ let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
const dir = mkdtempSync(tmpdir() + '/mcache-');
const ff = (args) => { const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', ...args]); if (p.exitCode !== 0) throw new Error('ffmpeg failed'); };
-for (const n of ['v1', 'v2', 'v3', 'h4', 'n5', 'r6']) ff(['-f', 'lavfi', '-i', 'testsrc=size=360x640:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${n}.mp4`]);
+// (six different files: one person's clips of one size to the byte are taken
+// for one clip uploaded twice)
+for (const [i, n] of ['v1', 'v2', 'v3', 'h4', 'n5', 'r6'].entries()) ff(['-f', 'lavfi', '-i', 'testsrc=size=360x640:rate=10', '-t', String(2 + i / 4), '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${n}.mp4`]);
ff(['-f', 'lavfi', '-i', 'testsrc=size=800x600', '-frames:v', '1', `${dir}/slow.jpg`]);
const html = await buildHtml({ minify: true, pwa: false });
@@ -113,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 58e942b..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';
@@ -16,7 +16,9 @@ let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
const dir = mkdtempSync(tmpdir() + '/media-');
const ff = (args) => { const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', ...args]); if (p.exitCode !== 0) throw new Error('ffmpeg failed'); };
-for (const n of ['a', 'b', 'c']) ff(['-f', 'lavfi', '-i', 'testsrc=size=360x640:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${n}.mp4`]);
+// (three different files: one person's clips of one size to the byte are
+// taken for one clip uploaded twice)
+for (const [n, t] of [['a', '2'], ['b', '2.5'], ['c', '3']]) ff(['-f', 'lavfi', '-i', 'testsrc=size=360x640:rate=10', '-t', t, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${n}.mp4`]);
// index last (no faststart), from a server that ignores ranges
ff(['-f', 'lavfi', '-i', 'testsrc=size=480x270:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', `${dir}/slow.mp4`]);
for (const n of ['p1', 'p2', 'p3']) ff(['-f', 'lavfi', '-i', 'testsrc=size=800x600', '-frames:v', '1', `${dir}/${n}.jpg`]);
@@ -95,7 +97,8 @@ const pagerState = () => page.evaluate(() => {
const p = document.querySelector('.media-pager');
if (!p) return null;
const sc = p.querySelector('.mp-scroll');
- const slides = [...sc.querySelectorAll('.mp-slide')]; // (not the loading screen at the end)
+ // (not the loading screen at the end, nor a clip held out for its shape)
+ const slides = [...sc.querySelectorAll('.mp-slide')].filter((x) => !x.closest('.mp-off'));
const i = slides.findIndex((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); // the slide on screen
const vids = slides.map((s) => s.querySelector('video'));
return { n: slides.length, i, src: (slides[i]?.querySelector('.mp-media')?.getAttribute('data-src') || '').split('/').pop(),
@@ -109,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]) => {
@@ -126,8 +129,8 @@ try {
await page.waitForSelector('.notes-feed:not([data-booting])', { timeout: 20000 });
console.log('\n[the switch]');
- check('Notes / Conversations / Images / Videos / Articles sit under the chips, Notes on', await page.evaluate(() =>
- [...document.querySelectorAll('.media-mode')].map((b) => b.textContent + (b.classList.contains('on') ? '*' : '')).join(',') === 'Notes*,Conversations,Images,Videos,Articles'));
+ check('Notes / Conversations / Images / Videos / Live / Articles sit under the chips, Notes on', await page.evaluate(() =>
+ [...document.querySelectorAll('.media-mode')].map((b) => b.textContent + (b.classList.contains('on') ? '*' : '')).join(',') === 'Notes*,Conversations,Images,Videos,Live,Articles'));
// five chips are wider than a phone: the strip scrolls sideways, the page does not get wider
check('…the strip scrolls sideways rather than widening the page', await page.evaluate(() => {
const s = document.querySelector('.media-modes');
@@ -193,22 +196,22 @@ try {
check('...another plays it on, the details down', st.playing[0] && !st.show, JSON.stringify(st));
await sleep(1000);
let ss = await srcs();
- // (the next two get a head start fetched into the cache — pager-eager-test — but no player of their own)
- check('only the clip on screen has its file; the rest nothing yet',
- ss[0].src && ss[0].pre === 'auto' && !ss[1].src && !ss[2].src, JSON.stringify(ss));
+ // (the next gets a warm player, read to its first frame — pager-eager-test; the one after, its opening in the cache only)
+ check('the clip on screen has its file, the next a warm player, the one after nothing yet',
+ ss[0].src && ss[0].pre === 'auto' && ss[1].src && ss[1].pre === 'metadata' && !ss[2].src, JSON.stringify(ss));
await waitFor(() => Object.keys(JSON.parse((window.coinosCache || localStorage).getItem('coinos-video-info-v2') || '[]')).length >= 3, 6000);
await sleep(1500);
const vi = await info();
check('a probe reads the size, ranges, layout and picture size from 64 KB', vi['a.mp4']?.probed && vi['a.mp4'].ranges && vi['a.mp4'].faststart
&& vi['a.mp4'].size > 1000 && vi['a.mp4'].w === 360 && vi['a.mp4'].h === 640, JSON.stringify(vi['a.mp4']));
- check('the landscape clip is left out of Videos', !(await page.evaluate(() => [...document.querySelectorAll('.media-pager video')].some((v) => /slow/.test(v.getAttribute('data-src'))))));
+ check('the landscape clip is left out of Videos', !(await page.evaluate(() => [...document.querySelectorAll('.media-pager video')].some((v) => /slow/.test(v.getAttribute('data-src')) && !v.closest('.mp-off')))));
check('an index-last file from a server without ranges is seen for what it is', vi['slow.mp4']?.probed && vi['slow.mp4'].faststart === false && vi['slow.mp4'].ranges === false, JSON.stringify(vi['slow.mp4']));
check('the 50 MB clip is known as such before any of it plays', vi['c.mp4']?.size === 50000000, JSON.stringify(vi['c.mp4']));
await page.keyboard.press('ArrowDown'); await sleep(1200);
st = await pagerState();
check('↓ flips to the next, which plays while the first stops', st.i === 1 && st.src === 'b.mp4' && st.playing[1] && !st.playing[0], JSON.stringify(st));
ss = await srcs();
- check('...and the window moves with it: the first lets go, the big one has no player yet', !ss[0].src && ss[1].pre === 'auto' && !ss[2].src, JSON.stringify(ss));
+ check('...and the window moves with it: the first keeps its player (for the swipe back), the big one next is warm', ss[0].src && ss[1].pre === 'auto' && ss[2].src && ss[2].pre === 'metadata', JSON.stringify(ss));
await page.keyboard.press('ArrowDown'); await sleep(1200);
st = await pagerState();
check('flipping is asking to watch: the 50 MB clip plays when it comes up', st.src === 'c.mp4' && st.playing[2] && await page.evaluate(() => !document.querySelectorAll('.mp-slide')[2].querySelector('.vid-heavy')), JSON.stringify(st));
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 add9208..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]) => {
@@ -120,6 +122,9 @@ try {
check('the seek bar sits clear of the bottom edge', geo.bottom >= 32, JSON.stringify(geo));
check('…and in from the sides', geo.left >= 16 && geo.right >= 16, JSON.stringify(geo));
if (process.env.SHOT) { await page.evaluate(() => document.querySelector('.media-pager').classList.add('show')); await sleep(300); await page.screenshot({ path: process.env.SHOT }); await page.evaluate(() => document.querySelector('.media-pager').classList.remove('show')); }
+ // (out of sight while it plays: a tap near the foot brings it up)
+ { const r = await page.evaluate((cur) => { const b = eval(cur)().querySelector('.mp-seek').getBoundingClientRect(); return { x: b.x + b.width / 2, y: b.y - 30 }; }, '(' + CUR + ')'); await page.mouse.click(r.x, r.y); await sleep(300); }
+ check('a tap near the foot brings the seek bar up', await page.evaluate((cur) => getComputedStyle(eval(cur)().querySelector('.mp-seek')).opacity === '1', '(' + CUR + ')'));
const bar = await page.evaluate((cur) => { const r = eval(cur)().querySelector('.mp-seek').getBoundingClientRect(); return { x: r.x, y: r.y + r.height - 4, w: r.width }; }, '(' + CUR + ')');
await page.mouse.move(bar.x + bar.w * 0.1, bar.y);
await page.mouse.down();
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 82d6c7c..3990fa6 100644
--- a/tools/go-live-test.js
+++ b/tools/go-live-test.js
@@ -4,11 +4,12 @@
// announced as its kind 30311 pointing at the HLS — and someone else opening
// the stream's link watches it. Ending says so in a last 30311 carrying the
// 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/)
-import puppeteer from './puppeteer.js';
+// 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, { seedWords, skipSeedCheck } from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { naddrEncode } from 'nostr-tools/nip19';
-import { mkdtempSync, rmSync } from 'node:fs';
+import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
@@ -18,32 +19,76 @@ const dir = mkdtempSync(tmpdir() + '/go-live-');
if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
// the registrar, as the live server asks it: the name is whoever is sending's
-let owner = null;
-const names = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => Response.json(new URL(req.url).pathname.endsWith('/alice') && owner ? { taken: true, pubkey: owner } : { taken: false }) });
+let owner = null, nameCreated = null;
+const names = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => Response.json(new URL(req.url).pathname.endsWith('/alice') && owner ? { taken: true, pubkey: owner, created: nameCreated } : { taken: false }) });
process.env.LIVE_DATA = dir;
process.env.NAMES_URL = names.url.origin;
process.env.LIVE_BASE = `http://localhost:${PORT}/live`;
-const live = await import(process.env.LIVE_SERVER || '../../coinosv3-server/live/server.js');
-const server = Bun.serve({ port: PORT, websocket: live.websocket, fetch: async (req, srv) => {
+const LIVE_PATH = process.env.LIVE_SERVER || '../../coinosv3-server/live/server.js';
+let live = await import(LIVE_PATH);
+// each socket stays with the server (module) that took it, so a "restart"
+// (a fresh import) leaves the old connections to the old one
+const sockMod = new WeakMap();
+const websocket = {
+ open: (ws) => { sockMod.set(ws, live); live.websocket.open?.(ws); },
+ message: (ws, m) => (sockMod.get(ws) || live).websocket.message?.(ws, m),
+ close: (ws, c, r) => (sockMod.get(ws) || live).websocket.close?.(ws, c, r),
+};
+const server = Bun.serve({ port: PORT, websocket, fetch: async (req, srv) => {
const path = new URL(req.url).pathname;
if (path.startsWith('/live/')) {
if (path === '/live/ingest') try { owner = JSON.parse(atob(new URL(req.url).searchParams.get('auth'))).pubkey; } catch {}
+ if (path === '/live/allowed') try { owner = JSON.parse(atob((req.headers.get('authorization') || '').replace(/^Nostr /, ''))).pubkey; } catch {}
return live.handle(req, srv);
}
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', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'] });
+ args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required',
+ '--auto-select-desktop-capture-source=Entire screen',
+ // LATENCY=1: both pages keep full speed (a background tab's timers crawl)
+ ...(process.env.LATENCY ? ['--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows'] : [])] });
+// LATENCY=1: the "camera" is a clock — the time in ms (24 bits) as black and
+// white blocks — and the viewer reads it off the frame it shows: how far
+// behind the broadcaster a watcher sees, end to end
+const clockCam = () => {
+ const real = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
+ navigator.mediaDevices.getUserMedia = async (c) => {
+ if (!c || !c.video) return real(c);
+ const cv = document.createElement('canvas'); cv.width = 1280; cv.height = 720;
+ const x = cv.getContext('2d');
+ setInterval(() => {
+ const v = Date.now() % 16777216;
+ x.fillStyle = '#808080'; x.fillRect(0, 0, 1280, 720);
+ for (let i = 0; i < 24; i++) { x.fillStyle = (v >> i) & 1 ? '#fff' : '#000'; x.fillRect(160 + (i % 6) * 160, 120 + Math.floor(i / 6) * 120, 160, 120); }
+ }, 1000 / 30);
+ const s = cv.captureStream(30);
+ if (c.audio) { const ac = new AudioContext(); const o = ac.createOscillator(); const d = ac.createMediaStreamDestination(); o.connect(d); o.start(); for (const t of d.stream.getAudioTracks()) s.addTrack(t); }
+ return s;
+ };
+};
+const readClock = () => {
+ const v = document.querySelector('video.live-video');
+ if (!v || !v.videoWidth) return null;
+ const cv = document.createElement('canvas'); cv.width = v.videoWidth; cv.height = v.videoHeight;
+ const x = cv.getContext('2d'); x.drawImage(v, 0, 0);
+ const k = v.videoWidth / 1280;
+ let n = 0;
+ for (let i = 0; i < 24; i++) { const d = x.getImageData(Math.round((240 + (i % 6) * 160) * k), Math.round((180 + Math.floor(i / 6) * 120) * k), 1, 1).data; if (d[0] + d[1] + d[2] > 384) n |= 1 << i; }
+ return ((Date.now() % 16777216) - n + 16777216) % 16777216;
+};
const relayStub = (fx) => {
+ // what the relays held before a reload (a test can carry events across one)
+ try { fx = [...fx, ...JSON.parse(sessionStorage.getItem('__fx') || '[]')]; } catch {}
window.__published = [];
const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey))
&& Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1])));
const Real = window.WebSocket;
window.WebSocket = function (...a) {
const ws = new Real(...a);
- if (/\/live\/ingest/.test(String(a[0]))) return ws; // the live server itself
+ if (/\/live\/ingest/.test(String(a[0]))) { (window.__ingest = window.__ingest || []).push(ws); return ws; } // the live server itself
const send = ws.send.bind(ws);
ws.send = (data) => {
try {
@@ -72,6 +117,7 @@ try {
const page = await browser.newPage();
await page.setViewport({ width: 390, height: 844 });
await page.evaluateOnNewDocument(relayStub, []);
+ if (process.env.LATENCY) await page.evaluateOnNewDocument(clockCam);
// the registrar on the open internet is not asked: the name below is what the wallet holds
await page.setRequestInterception(true);
page.on('request', (r) => (/names\.coinos\.io|nwcpush/.test(r.url()) ? r.respond({ status: 404, body: '{}' }) : r.continue()));
@@ -83,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) => {
@@ -100,6 +146,16 @@ try {
await waitFor(() => !!document.querySelector('.feed-post-fab'), 15000);
await page.evaluate(() => document.querySelector('.feed-post-fab').click());
check('the composer has a Go live button', await waitFor(() => !!document.querySelector('.go-live-tool'), 5000));
+ // a name claimed an hour ago can't stream yet: the screen says when it can
+ nameCreated = Date.now() - 3600_000;
+ await page.evaluate(() => document.querySelector('.go-live-tool').click());
+ check('a name claimed an hour ago is told it can go live a day after, Start off', await waitFor(() => !!document.querySelector('.go-live-refused') && /a day after you claimed it/.test(document.querySelector('.go-live-refused').textContent), 10000)
+ && await page.evaluate(() => document.querySelector('.go-live-start').disabled));
+ await page.evaluate(() => document.querySelector('.live-page .chat-back, .live-page .iconbtn')?.click());
+ nameCreated = Date.now() - 2 * 86400_000;
+ await waitFor(() => !document.querySelector('.go-live-start'), 5000);
+ if (!(await page.$('.go-live-tool'))) await page.evaluate(() => document.querySelector('.feed-post-fab').click());
+ await waitFor(() => !!document.querySelector('.go-live-tool'), 5000);
await page.evaluate(() => document.querySelector('.go-live-tool').click());
check('Go live shows the camera', await waitFor(() => { const v = document.querySelector('video.bc-preview'); return !!v && v.videoWidth > 0 && !v.paused; }, 10000));
check('...mirrored, as a front camera is', await page.evaluate(() => document.querySelector('video.bc-preview').classList.contains('mirror')));
@@ -118,19 +174,64 @@ try {
const m3u8 = await (await fetch(hls)).text();
check('the server is making HLS of it, three segments in when it\'s announced', (m3u8.match(/\.ts/g) || []).length >= 3 && !/ENDLIST/.test(m3u8), (m3u8.match(/\.ts/g) || []).length + ' segments');
+ console.log('\n[camera ⇄ screen while live]');
+ const sending = () => page.evaluate(() => { const v = document.querySelector('.live-page video.bc-preview'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return t ? t.label + ' ' + t.readyState : ''; });
+ await page.evaluate(() => document.querySelector('.bc-switch').click());
+ check('Share screen, live: the stream carries on with the screen', await waitFor(() => { const v = document.querySelector('.live-page video.bc-preview'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return !!t && /^screen/.test(t.label) && t.readyState === 'live'; }, 10000)
+ && !!(await page.$('.bc-bar')) && await page.evaluate(() => document.querySelector('.bc-switch').textContent === 'Back to camera'), await sending());
+ await page.evaluate(() => document.querySelector('.bc-switch').click());
+ check('...and Back to camera returns to it, still live', await waitFor(() => { const v = document.querySelector('.live-page video.bc-preview'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return !!t && !/^screen/.test(t.label) && t.readyState === 'live'; }, 10000) && !!(await page.$('.bc-bar')), await sending());
+ await sleep(4000);
+ const segsNow = ((await (await fetch(hls)).text()).match(/\.ts/g) || []).length;
+ check('...the server still making HLS through the switches', segsNow >= 3, segsNow + ' segments in the window');
+
console.log('\n[someone watching]');
const viewer = await browser.newPage();
+ if (process.env.LATENCY === '2') await viewer.evaluateOnNewDocument(() => { window.__ev = []; const add = HTMLMediaElement.prototype.addEventListener; for (const n of ['error', 'ratechange', 'waiting', 'playing', 'emptied', 'abort', 'stalled']) document.addEventListener(n, (e) => { const v = e.target; window.__ev.push([Math.round(performance.now()), n, v.playbackRate, v.error && v.error.code + ':' + v.error.message, v.currentTime.toFixed(1)]); }, true); });
await viewer.setViewport({ width: 390, height: 844 });
await viewer.evaluateOnNewDocument(relayStub, [ann]);
const v = helpers(viewer);
await viewer.goto(`http://localhost:${PORT}/` + naddrEncode({ kind: 30311, pubkey: ann.pubkey, identifier: tag(ann, 'd') }), { waitUntil: 'domcontentloaded' });
check('the stream\'s link opens it', await v.waitFor(() => !!document.querySelector('.live-page'), 15000) && await v.waitText('Testing the live button', 3000));
- check('...and it plays', await v.waitFor(() => { const x = document.querySelector('video.live-video'); return !!x && !x.paused && x.currentTime > 1 && x.videoWidth > 0; }, 30000),
+ const playsOk = await v.waitFor(() => { const x = document.querySelector('video.live-video'); return !!x && !x.paused && x.currentTime > 1 && x.videoWidth > 0; }, 30000);
+ if (process.env.LATENCY === '2') console.log(' events:', JSON.stringify(await viewer.evaluate(() => window.__ev.slice(0, 30))));
+ check('...and it plays', playsOk || await v.waitFor(() => { const x = document.querySelector('video.live-video'); return !!x && !x.paused && x.currentTime > 1 && x.videoWidth > 0; }, 30000),
JSON.stringify(await viewer.evaluate(() => { const x = document.querySelector('video.live-video'); return x && { t: x.currentTime, w: x.videoWidth, err: x.error && x.error.code }; })));
+ // LIVE_CONFIG with the SFU's key ({sfuAppId, sfuSecret}): the app's viewer
+ // watches over WebRTC through Cloudflare; without it, HLS
+ const overRtc = await viewer.evaluate(() => { const x = document.querySelector('video.live-video'); return !!x && x.srcObject instanceof MediaStream; });
+ check(process.env.LIVE_CONFIG ? '...over WebRTC (Cloudflare\'s SFU)' : '...over HLS (no SFU key here)', overRtc === !!process.env.LIVE_CONFIG);
+ if (process.env.LATENCY) {
+ await viewer.evaluate(() => { window.__stalls = 0; document.querySelector('video.live-video').addEventListener('waiting', () => window.__stalls++); });
+ const lags = [];
+ for (let i = 0; i < 30; i++) { const l = await viewer.evaluate(readClock); if (l != null && l < 60000) lags.push(l); await sleep(500); }
+ if (process.env.LATENCY === '2') console.log(' player:', JSON.stringify(await viewer.evaluate(() => { const x = document.querySelector('video.live-video'); const r = (tr) => [...Array(tr.length).keys()].map((i) => tr.start(i).toFixed(1) + '-' + tr.end(i).toFixed(1)); return { t: x.currentTime.toFixed(1), seekable: r(x.seekable), buffered: r(x.buffered), rate: x.playbackRate, dur: x.duration }; })));
+ lags.sort((a, b) => a - b);
+ console.log(` behind the broadcaster: median ${(lags[lags.length >> 1] / 1000).toFixed(1)} s, min ${(lags[0] / 1000).toFixed(1)} s, max ${(lags.at(-1) / 1000).toFixed(1)} s (${lags.length} reads), ${await viewer.evaluate(() => window.__stalls)} stall(s)`);
+ }
const st = await (await fetch(hls.replace('live.m3u8', 'stats'))).json();
check('the server counts the watcher', st.viewers >= 1, JSON.stringify(st));
await viewer.close();
+ console.log('\n[the server restarts mid-stream]');
+ // a fresh server, and the old one's shutdown: its streams end
+ live = await import(LIVE_PATH + '?restart=1');
+ const oldId = tag(ann, 'd');
+ const sj = `${dir}/${oldId}/stream.json`;
+ writeFileSync(sj, JSON.stringify({ ...JSON.parse(readFileSync(sj, 'utf8')), state: 'ended', ended: Date.now() }));
+ const before = (await lives(page)).length;
+ await page.evaluate(() => window.__ingest.forEach((w) => w.close()));
+ check('the app comes back as a new stream and announces it', await waitFor((n) => window.__published.filter((e) => e.kind === 30311).slice(n)
+ .some((e) => e.tags.some((x) => x[0] === 'status' && x[1] === 'live')), 40000, before));
+ const after = (await lives(page)).slice(before);
+ const again = after.find((e) => tag(e, 'status') === 'live');
+ check('...at its new address, not the dead one', again && tag(again, 'd') !== oldId && tag(again, 'streaming').includes(tag(again, 'd')), JSON.stringify(after.map((e) => [tag(e, 'd'), tag(e, 'status')])));
+ const gone = after.find((e) => tag(e, 'd') === oldId);
+ check('...the old one said to be over, with no recording', gone && tag(gone, 'status') === 'ended' && !gone.tags.some((x) => x[0] === 'recording'));
+ const pl = await (await fetch(tag(again, 'streaming'))).text();
+ check('...and the new one plays', (pl.match(/\.ts/g) || []).length >= 3, (pl.match(/\.ts/g) || []).length + ' segments');
+ check('the streamer\'s own page follows to it', await waitFor((a) => !!document.querySelector('.live-page .bc-bar'), 5000));
+
console.log('\n[the end]');
await page.evaluate(() => document.querySelector('.bc-end').click());
check('End leaves the stream\'s page', await waitFor(() => !document.querySelector('.bc-bar'), 5000));
@@ -140,6 +241,44 @@ try {
await sleep(1500);
const rec = await (await fetch(tag(fin, 'recording'))).text();
check('...and the recording is whole', /ENDLIST/.test(rec) && (rec.match(/\.ts/g) || []).length >= 3, (rec.match(/\.ts/g) || []).length + ' segments');
+
+ console.log('\n[a stream the app lost]');
+ await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' });
+ await waitText('receive');
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
+ if (!(await waitFor(() => !!document.querySelector('.feed-post-fab, .go-live-tool'), 15000))) console.log(' at:', await page.evaluate(() => location.pathname + ' ' + document.body.innerText.slice(0, 300).replace(/\s+/g, ' ')));
+ if (!(await page.$('.go-live-tool'))) await page.evaluate(() => document.querySelector('.feed-post-fab').click());
+ await waitFor(() => !!document.querySelector('.go-live-tool'), 5000);
+ await page.evaluate(() => document.querySelector('.go-live-tool').click());
+ await waitFor(() => { const v = document.querySelector('video.bc-preview'); return !!v && v.videoWidth > 0; }, 10000);
+ await page.type('.go-live-title', 'Lost stream');
+ const n0 = (await lives(page)).length;
+ await page.evaluate(() => document.querySelector('.go-live-start').click());
+ await waitFor((n) => window.__published.filter((e) => e.kind === 30311).length > n, 40000, n0);
+ const lost = (await lives(page)).at(-1);
+ // the tab goes (a reload): the relays still say live; the server waits a minute for the sender, then ends it
+ await page.evaluate((e) => sessionStorage.setItem('__fx', JSON.stringify([e])), lost);
+ await page.goto(`http://localhost:${PORT}/` + naddrEncode({ kind: 30311, pubkey: lost.pubkey, identifier: tag(lost, 'd') }), { waitUntil: 'domcontentloaded' });
+ await sleep(64000);
+ check('opened, it says it\'s over (the server ended it), not live', await waitFor(() => !!document.querySelector('.live-page') && /This stream has ended/.test(document.body.innerText) && !document.querySelector('.live-page .live-badge.live'), 30000),
+ await page.evaluate(() => document.querySelector('.live-page')?.innerText.slice(0, 200)));
+ check('...and its host gets End', await waitFor(() => !!document.querySelector('.live-end-stale'), 5000));
+ const n1 = (await lives(page)).length;
+ await page.evaluate(() => document.querySelector('.live-end-stale').click());
+ check('End says it ended', await waitFor((n) => window.__published.filter((e) => e.kind === 30311).slice(n).some((e) => e.tags.some((x) => x[0] === 'status' && x[1] === 'ended')), 10000, n1));
+ const endedEv = (await lives(page)).at(-1);
+ check('...the same stream, with its recording', tag(endedEv, 'd') === tag(lost, 'd') && /index\.m3u8$/.test(tag(endedEv, 'recording') || ''), JSON.stringify(endedEv.tags));
+ check('...and the End button goes', await waitFor(() => !document.querySelector('.live-end-stale'), 5000));
+
+ console.log('\n[deleting your recording]');
+ check('the streamer has Delete recording on their ended stream', await waitFor(() => !!document.querySelector('.live-delete'), 25000));
+ const n2 = (await lives(page)).length;
+ await page.evaluate(() => document.querySelector('.live-delete').click());
+ check('...deleting it says so, and the button goes', await waitFor(() => /deleted this recording/.test(document.body.innerText) && !document.querySelector('.live-delete'), 8000),
+ await page.evaluate(() => document.querySelector('.live-page')?.innerText.slice(0, 200)));
+ check('...the server has let it go (410)', (await fetch(tag(lost, 'streaming').replace('live.m3u8', 'index.m3u8'))).status === 410);
+ const redone = (await lives(page)).slice(n2).at(-1);
+ check('...and the stream is announced again, ended, with no recording', !!redone && tag(redone, 'd') === tag(lost, 'd') && tag(redone, 'status') === 'ended' && !redone.tags.some((x) => x[0] === 'recording'), JSON.stringify(redone && redone.tags));
check('no page errors', !errs.length, errs.join(' | '));
} catch (e) { console.error(e); ok = false; } finally {
await browser.close(); server.stop(true); names.stop(true);
diff --git a/tools/grid-early-player-test.js b/tools/grid-early-player-test.js
new file mode 100644
index 0000000..04e44fb
--- /dev/null
+++ b/tools/grid-early-player-test.js
@@ -0,0 +1,138 @@
+// Opening a clip from the Videos grid: the clip hosts are preconnected,
+// and the player starts loading when the finger comes down on a tile —
+// the pager adopts that very element, so it's moving sooner after the tap.
+// A press that isn't followed by a tap (a scroll) lets its player go.
+// Run: bun tools/grid-early-player-test.js
+//
+// (adapted from pager-eager-test:)
+// Flipping through clips in the pager is asking to watch them: a clip plays
+// at once whatever it costs — even one over the size budget, which in the
+// feed waits under a tap-to-play cover — and the next two get a head start:
+// their opening fetched into the cache, no more of a big file than that, and
+// the player takes it from there. How much: a couple of seconds' worth of
+// the clip on a link well ahead of its bitrate (the second clip ahead, half
+// 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, { 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, statSync } 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; };
+
+// small clips with sound (a tone), ~2 MB each: under the 25 MB a warm player is primed up to
+const BIG = '/tmp/coinos-prime-small.webm';
+if (!existsSync(BIG)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=s=180x320:r=15', '-f', 'lavfi', '-i', 'sine=f=440', '-t', '50', '-c:v', 'libvpx', '-b:v', '300k', '-c:a', 'libvorbis', BIG]);
+const SIZE = statSync(BIG).size;
+const FILE = new Uint8Array(await Bun.file(BIG).arrayBuffer());
+const NAMES = ['c0', 'c1', 'c2', 'c3', 'c4'];
+// one file each, a byte or two apart (trailing padding): one person's clips of
+// one size to the byte are taken for one clip uploaded twice
+const FILES = Object.fromEntries(NAMES.map((n, i) => { const f = new Uint8Array(SIZE + i); f.set(FILE); return [n, f]; }));
+
+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 html = await buildHtml({ minify: true, pwa: false });
+// every request for a clip's bytes: who asked (the player, or a fetch), the
+// range, and the bytes actually sent (pulled by the reader, in 64 KB pieces)
+let log = [];
+let pace = 0; // bytes a second the server sends a clip at (0: as fast as it can) — a slow link
+const server = Bun.serve({ port: 0, idleTimeout: 60, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ const m = /^\/(c\d)\.webm$/.exec(path);
+ if (m) {
+ const headers = { 'access-control-allow-origin': '*', 'access-control-expose-headers': 'content-range, content-length', 'content-type': 'video/webm', 'accept-ranges': 'bytes', etag: '"' + m[1] + '-v1"', 'last-modified': 'Mon, 05 Oct 2026 00:00:00 GMT', 'cache-control': 'public, max-age=3600' };
+ const F = FILES[m[1]], S = F.length;
+ if (req.method === 'HEAD') return new Response(null, { headers: { ...headers, 'content-length': String(S) } });
+ const rg = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ const start = rg ? +rg[1] : 0, end = rg && rg[2] ? Math.min(+rg[2], S - 1) : S - 1;
+ const entry = { clip: m[1], dest: req.headers.get('sec-fetch-dest') || '', start, end, sent: 0 };
+ log.push(entry);
+ let at = start;
+ const t0 = Date.now();
+ const body = new ReadableStream({ async pull(ctl) {
+ if (at > end) { ctl.close(); return; }
+ if (pace) { const due = t0 + ((at - start) / pace) * 1000; if (due > Date.now()) await new Promise((r) => setTimeout(r, due - Date.now())); }
+ const n = Math.min(65536, end - at + 1);
+ ctl.enqueue(F.subarray(at, at + n)); at += n; entry.sent += n;
+ } }, { highWaterMark: 0 });
+ return new Response(body, { status: rg ? 206 : 200, headers: { ...headers, 'content-length': String(end - start + 1), ...(rg ? { 'content-range': `bytes ${start}-${end}/${S}` } : {}) } });
+ }
+ 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 U = (n) => server.url.origin.replace('localhost', '127.0.0.1') + '/' + n + '.webm'; // another origin than the page's: one to preconnect to
+const seeded = [
+ // each says its size and length (as Primal's and others' imeta do): a bitrate of SIZE / 50 bytes a second
+ ...NAMES.map((n, i) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 100, tags: [['imeta', 'url ' + U(n), 'm video/webm', 'size ' + (SIZE + i), 'duration 50', 'dim 180x320']], content: 'clip ' + n + ' here ' + U(n) }, SK)),
+ ...Array.from({ length: 6 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 900 + i * 60)),
+];
+const sent = (clip, dest) => log.filter((e) => e.clip === clip && (!dest || e.dest === dest)).reduce((a, e) => a + e.sent, 0);
+const kb = (n) => Math.round(n / 1024) + ' KB';
+const RATE = SIZE / 50, HEAD_MAX = 4 * 1024 * 1024;
+const heads = (clip) => log.filter((e) => e.clip === clip && e.dest === 'empty');
+const near = (a, b) => Math.abs(a - b) <= b * 0.02 + 1;
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+async function run() {
+ log = [];
+ const ctx = await browser.createBrowserContext();
+ const page = await ctx.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)));
+ 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 vid = (n) => page.evaluate((n) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/' + n + '.webm')); const v = s && s.querySelector('video.mp-media'); return v ? { src: !!v.getAttribute('src'), paused: v.paused, t: v.currentTime, volume: v.volume, muted: v.muted } : null; }, n);
+ try {
+ await page.setViewport({ width: 390, height: 844 });
+ pace = 12_500_000;
+ 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: 'Prime Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ await page.waitForSelector('.media-mode[data-mode="videos"]', { timeout: 20000 });
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="videos"]').click());
+ check('the Videos grid shows the clips', await waitFor(() => document.querySelectorAll('.media-tile.video').length >= 3, null, 15000));
+ check('…their host is connected to ahead', await page.evaluate((o) => [...document.querySelectorAll('link[rel=preconnect]')].some((l) => l.href.startsWith(o)), server.url.origin.replace('localhost', '127.0.0.1')));
+ // a press with no tap after it (the start of a scroll): its player is let go
+ await page.evaluate(() => document.querySelectorAll('.media-tile.video')[1].dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })));
+ await sleep(600);
+ const pressed = await page.evaluate(() => { const v = document.querySelector('body > video[data-early]'); return v && { src: v.getAttribute('src'), rs: v.readyState }; });
+ check('a press starts the clip loading before any tap: its player has its picture', pressed && /c1\.webm$/.test(pressed.src) && pressed.rs >= 2, JSON.stringify(pressed));
+ await sleep(2500);
+ check('…and let go when no tap follows', await page.evaluate(() => !document.querySelector('body > video[data-early]')));
+ // a press, then the tap: the pager plays the very player the press made
+ await page.evaluate(() => { const t = document.querySelectorAll('.media-tile.video')[0]; t.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); setTimeout(() => t.click(), 120); });
+ check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000));
+ check('…on the player the press started, adopted', await waitFor(() => { const v = document.querySelector('.mp-cur video.mp-media'); return v && v.dataset.early === '1' && !v.paused && v.currentTime > 0; }, null, 8000), JSON.stringify(await page.evaluate(() => { const v = document.querySelector('.mp-cur video.mp-media'); return v && { early: v.dataset.early, paused: v.paused, t: v.currentTime }; })));
+ check('…and only one player was ever made for it', await page.evaluate(() => document.querySelectorAll('.media-pager video.mp-media[data-early]').length) === 1);
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+ } finally { await ctx.close(); }
+}
+try { await run(); } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ a clip opened from the grid starts loading at the press' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/history-export-test.js b/tools/history-export-test.js
new file mode 100644
index 0000000..877b95e
--- /dev/null
+++ b/tools/history-export-test.js
@@ -0,0 +1,81 @@
+// Settings → Wallet → Export CSV: every payment in Spending (seeded Ark
+// 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, { seedWords, skipSeedCheck } from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.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 ns = (url) => { let h = 5381; for (const c of url) h = ((h * 33) ^ c.charCodeAt(0)) >>> 0; return h.toString(36); };
+const T0 = Date.UTC(2026, 9, 1, 12, 0, 0);
+const movements = [
+ { id: 'm1', type: 'ln-receive', status: 'complete', amountSat: 5000, invoice: 'lnbcrt50u1test', ts: T0 },
+ { id: 'm2', type: 'ln-send', status: 'complete', amountSat: 1000, detail: 'fee 3 sat', paymentHash: 'ab'.repeat(32), invoice: 'lnbcrt10u1test', ts: T0 + 60_000 },
+ { id: 'm3', type: 'send', status: 'complete', amountSat: 700, to: 'tark1someone', vtxoId: 'cd'.repeat(32) + ':0', ts: T0 + 120_000 },
+ { id: 'm4', type: 'send', status: 'failed', amountSat: 999, detail: 'nope', ts: T0 + 180_000 },
+ { id: 'm5', type: 'refresh', status: 'complete', amountSat: 3297, feeSat: 300, manual: true, txid: 'ef'.repeat(32), ts: T0 + 240_000 },
+];
+
+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 = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+// the file the page hands the browser to save
+await page.evaluateOnNewDocument(() => {
+ const make = URL.createObjectURL;
+ URL.createObjectURL = (b) => { b.text().then((x) => { window.__csv = x; window.__csvType = b.type; }); return make(b); };
+ const click = HTMLAnchorElement.prototype.click;
+ HTMLAnchorElement.prototype.click = function () { if (this.download) { window.__csvName = this.download; return; } return click.call(this); };
+});
+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; };
+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);
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ 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');
+ await page.evaluate(([k, v]) => localStorage.setItem(k, JSON.stringify(v)), [key, { v: 1, _rev: 999, vtxos: [], actions: [], movements, scheduled: [] }]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive');
+ 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);
+ check('Settings → Wallet has the export', await waitText('transaction history', 5000) && !!(await page.$('.history-export')));
+ await page.evaluate(() => document.querySelector('.history-export').click());
+ for (let i = 0; i < 20 && !(await page.evaluate(() => window.__csv)); i++) await sleep(100);
+ const { csv, name, type } = await page.evaluate(() => ({ csv: window.__csv, name: window.__csvName, type: window.__csvType }));
+ check('a .csv file is saved, named for today', /^coinos-history-\d{4}-\d{2}-\d{2}\.csv$/.test(name || '') && /text\/csv/.test(type || ''), name);
+ const rows = (csv || '').trim().split('\r\n').map((l) => l.split(','));
+ console.log((csv || '').trim().split('\r\n').map((l) => ' ' + l).join('\n'));
+ const head = rows[0] || [];
+ const col = (r, n) => r[head.indexOf(n)];
+ check('a header names the columns', head[0] === 'Date (UTC)' && head.includes('Net (sats)') && head.includes('Fee (sats)'));
+ const body = rows.slice(1);
+ check('...one line per payment, the failed one left out, oldest first', body.length === 4 && body.map((r) => col(r, 'Type')).join() === 'ln-receive,ln-send,send,refresh', body.map((r) => col(r, 'Type')).join());
+ check('...dated in UTC', col(body[0], 'Date (UTC)') === '2026-10-01 12:00:00');
+ check('a receive: + its amount, on Lightning, in Spending', col(body[0], 'Account') === 'Spending' && col(body[0], 'Rail') === 'lightning' && col(body[0], 'Net (sats)') === '5000');
+ check('a Lightning payment: what was paid, its fee, both off the balance', col(body[1], 'Amount (sats)') === '-1000' && col(body[1], 'Fee (sats)') === '3' && col(body[1], 'Net (sats)') === '-1003' && col(body[1], 'Transaction / payment hash') === 'ab'.repeat(32));
+ check('an Ark payment names who it went to', col(body[2], 'Net (sats)') === '-700' && col(body[2], 'To') === 'tark1someone' && col(body[2], 'Rail') === 'ark');
+ check('a renewal costs its fee alone', col(body[3], 'Amount (sats)') === '0' && col(body[3], 'Net (sats)') === '-300');
+ check('the lines add up to the balance they leave', body.reduce((s, r) => s + Number(col(r, 'Net (sats)')), 0) === 5000 - 1003 - 700 - 300);
+ check('no page errors', !errs.length, errs.join(' | '));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close();
+ server.stop(true);
+}
+console.log(ok ? '\n✅ history exports as CSV' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
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/image-thumbnails-test.js b/tools/image-thumbnails-test.js
index ede7e11..6ece275 100644
--- a/tools/image-thumbnails-test.js
+++ b/tools/image-thumbnails-test.js
@@ -1,4 +1,4 @@
-// Real minified blob worker: both thumbnail sizes, animated originals,
+// Real minified blob worker: both thumbnail sizes (the tile in the picture's own shape), animated originals,
// bounded concurrency and graceful fallback, with no page-thread encoding.
import assert from 'node:assert/strict';
import puppeteer from './puppeteer.js';
@@ -30,25 +30,30 @@ try {
thumbnails(new Blob(['broken'],{type:'image/jpeg'}),{width:1100}), thumbnails(blob,{width:1100}),
]);
const media = await decode(big.media), tile = await decode(big.tile);
- const square = new OffscreenCanvas(360,360);square.getContext('2d').drawImage(tile,0,0);
- const pixel=[...square.getContext('2d').getImageData(0,180,1,1).data];
+ const whole = new OffscreenCanvas(tile.width,tile.height);whole.getContext('2d').drawImage(tile,0,0);
+ const pixel=[...whole.getContext('2d').getImageData(tile.width>>1,180,1,1).data], edge=[...whole.getContext('2d').getImageData(2,180,1,1).data];
+ // a panorama past 2:1 keeps its middle, 2:1
+ const pano=new OffscreenCanvas(4000,1000);pano.getContext('2d').fillStyle='green';pano.getContext('2d').fillRect(0,0,4000,1000);
+ const panoTile=await decode((await thumbnails(await encode.call(pano,{type:'image/png'}),{tileSize:360})).tile);
const smallCanvas=new OffscreenCanvas(32,20);smallCanvas.getContext('2d');
const smallBlob=await encode.call(smallCanvas,{type:'image/png'});
const small=await thumbnails(smallBlob,{width:1100,tileSize:360});
const smallTile=await decode(small.tile);
const gif=new Blob([Uint8Array.from(atob('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'),c=>c.charCodeAt(0))],{type:'image/gif'});
const animated=await thumbnails(gif,{width:1100,tileSize:360});
- const out={mainCalls,maxPending,big:[big.width,big.height],media:[media.width,media.height],tile:[tile.width,tile.height],pixel,
+ const out={mainCalls,maxPending,big:[big.width,big.height],media:[media.width,media.height],tile:[tile.width,tile.height],pixel,edge,pano:[panoTile.width,panoTile.height],
tileOnly:!tiles.media&&!!tiles.tile,bad,recovered:!!recovered.media,smallOriginal:small.media===null,smallTile:[smallTile.width,smallTile.height],
animated: animated && [animated.width,animated.height,animated.media,animated.tile]};
media.close();tile.close();smallTile.close();return out;
});
console.log(result);
assert.equal(result.mainCalls,0);assert.equal(result.maxPending,1);
- assert.deepEqual(result.big,[3000,2000]);assert.deepEqual(result.media,[1100,733]);assert.deepEqual(result.tile,[360,360]);
- assert(result.pixel[2]>200&&result.pixel[0]<30,'tile crops the center');
+ assert.deepEqual(result.big,[3000,2000]);assert.deepEqual(result.media,[1100,733]);assert.deepEqual(result.tile,[540,360]);
+ // the grid's justified rows: a tile in the picture's own shape, 360 tall, uncut up to 2:1
+ assert(result.pixel[2]>200&&result.pixel[0]<30&&result.edge[0]>200,'tile keeps the whole picture');
+ assert.deepEqual(result.pano,[720,360]);
assert(result.tileOnly&&result.recovered&&result.smallOriginal);assert.equal(result.bad,null);
- assert.deepEqual(result.smallTile,[20,20]);assert.deepEqual(result.animated,[1,1,null,null]);
+ assert.deepEqual(result.smallTile,[32,20]);assert.deepEqual(result.animated,[1,1,null,null]);
const fallback=await browser.newPage();await fallback.goto(server.url.href);await fallback.waitForFunction(()=>window.thumbnails);
assert.equal(await fallback.evaluate(async()=>{window.Worker=class {constructor(){throw Error('blocked');}};return thumbnails(new Blob(['x']),{width:1100});}),null);
console.log('✓ thumbnails stay off the page thread; sizes, crop, originals, queue, bad-image recovery and worker fallback pass');
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-chat-actions-test.js b/tools/live-chat-actions-test.js
new file mode 100644
index 0000000..4e4d4ff
--- /dev/null
+++ b/tools/live-chat-actions-test.js
@@ -0,0 +1,74 @@
+// Real live-chat controls with local relays and a fake payment handler.
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.js';
+const app = await Bun.file('src/app.js').text();
+const dom = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing'));
+const entry = `
+import {messagesFeature} from './src/features/messages.js';
+import {getPublicKey} from 'nostr-tools/pure';
+import {liveReactions, zapAddrOf} from './src/live.js';
+${dom}
+const sk = new Uint8Array(32).fill(1), me = getPublicKey(sk), fan = '2'.repeat(64);
+const ev = {id:'3'.repeat(64),pubkey:fan,kind:1311,created_at:1,content:'A viewer comment',tags:[]};
+const w = {a:'30311:'+'4'.repeat(64)+':stream',lines:new Map([[ev.id,ev]]),actions:new Map(),reacting:new Set(),relays:['wss://fixture'],stops:[],sheet:null};
+const ui = {}, calls = [], toasts = [];
+window.__published=[]; window.__publishOK=true;
+let feature;
+const wallet = {mnemonic:'test',nostr:{pk:me,sk},nostrRelays:()=>[],loadFeatureState:()=>({}),saveFeatureState(){},registerCacheExtension(){}};
+function render(){if(feature) document.querySelector('#app').replaceChildren(feature.testLine(ev,w,liveReactions([...w.actions.values()])), ...(w.sheet?[feature.testSheet(w)]:[]));}
+feature = messagesFeature({h,ui,wallet,render,toast:s=>toasts.push(s),zapDefaultSat:()=>21,zapSound:()=>false,
+ hook(name,...args){if(name==='arkReady')return true;if(name==='zapNpub'){calls.push(args);return true;}return null;}});
+feature.testStart(w,ev); render();
+window.test={ui,w,ev,me,fan,calls,toasts,render,addr:()=>zapAddrOf(ev.id),self:()=>{ev.pubkey=me;render();},other:()=>{ev.pubkey=fan;render();},settle:()=>feature.zapSettled(ev.id,true,21)};
+`;
+const built = await Bun.build({entrypoints:['test-entry'],target:'browser',plugins:[{name:'test',setup(b){
+ b.onResolve({filter:/^test-entry$/},()=>({path:'entry',namespace:'test'}));
+ b.onLoad({filter:/.*/,namespace:'test'},()=>({contents:entry,loader:'js',resolveDir:process.cwd()}));
+ b.onLoad({filter:/src\/features\/messages\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()).replace("id: 'messages',", "id: 'messages', testLine: liveLine, testSheet: liveMessageSheet, testStart(w,e){liveWatch=w;setZapAddr(e.id,w.a);},")}));
+ b.onLoad({filter:/src\/nostr\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text())
+ .replace(/export async function publishOn\(relays, evt, \{[^}]*\} = \{\}\) \{/, (m) => m + ' window.__published.push(evt); return window.__publishOK;')
+ .replace('export function subscribeOn(relays, filter, onEvent, extra = {}) {','export function subscribeOn(relays, filter, onEvent, extra = {}) { return () => {};')
+ .replace('export async function queryOn(relays, filter, maxWait = 1500) {','export async function queryOn(relays, filter, maxWait = 1500) { return [];') }));
+}}]});
+assert(built.success,built.logs.join('\n'));
+const html = '<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>'+await Bun.file('src/style.css').text()+'</style><div id="app"></div><script type="module">'+await built.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(), errors=[];
+page.on('pageerror',e=>errors.push(e.message));
+await page.setRequestInterception(true);
+page.on('request',r=>r.url().startsWith(server.url.origin)?r.continue():r.abort());
+const pause=ms=>new Promise(r=>setTimeout(r,ms));
+try {
+ await page.setViewport({width:390,height:844});
+ await page.goto(server.url.href);
+ await page.waitForSelector('.chat-zap', {timeout:5000}).catch(async e => { throw new Error(errors.join(' | ') || e.message); });
+ await page.click('.chat-zap');
+ assert(await page.evaluate(()=>test.calls.length===1 && test.calls[0][0]===test.fan && test.calls[0][2]===test.ev.id && test.addr()===test.w.a),'zap must target the commenter, comment and stream');
+ await page.evaluate(()=>{test.settle();test.self();});
+ assert(!(await page.$('.chat-zap')),'no self-zap button');
+ await page.evaluate(()=>test.other());
+ await page.click('.chat-bubble');
+ await page.waitForSelector('.msg-sheet-emojis');
+ await page.evaluate(()=>[...document.querySelectorAll('.msg-sheet-emojis button')].find(b=>b.textContent==='❤️').click());
+ await page.waitForSelector('.chat-react.on:not(.zap-tally)');
+ const reaction=await page.evaluate(()=>window.__published.find(e=>e.kind===7));
+ assert(reaction.tags.some(t=>t[0]==='k'&&t[1]==='1311'));
+ await page.click('.chat-react.on:not(.zap-tally)'); await pause(100);
+ assert(!(await page.$('.chat-react.on:not(.zap-tally)')),'tapping again removes our reaction');
+ await page.click('.chat-bubble');
+ await page.evaluate(()=>[...document.querySelectorAll('.msg-sheet-emojis button')].find(b=>b.textContent==='❤️').click());
+ await page.waitForSelector('.chat-react.on:not(.zap-tally)');
+ assert(await page.evaluate(()=>new Set(window.__published.filter(e=>e.kind===7).map(e=>e.id)).size===2),'immediately re-adding a removed reaction must have a new event id');
+ await page.click('.chat-react.on:not(.zap-tally)'); await pause(100);
+ await page.evaluate(()=>window.__publishOK=false);
+ await page.click('.chat-bubble');
+ await page.evaluate(()=>[...document.querySelectorAll('.msg-sheet-emojis button')].find(b=>b.textContent==='❤️').click());
+ await page.waitForFunction(() => !document.querySelector('.chat-react.on:not(.zap-tally)'), {timeout:3000});
+ assert(await page.evaluate(()=>test.toasts.length>0));
+ const box = await (await page.$('.chat-zap')).boundingBox();
+ await page.mouse.move(box.x+box.width/2,box.y+box.height/2); await page.mouse.down(); await pause(650); await page.mouse.up();
+ assert(await page.evaluate(()=>test.ui.zapSetup.pk===test.fan && test.ui.zapSetup.eventId===test.ev.id && test.ui.zapSetup.edit),'hold opens the commenter’s zap amount settings');
+ assert.deepEqual(errors,[]);
+ console.log('✓ Live chat zap recipient/event/stream, no self-zaps, emoji actions, toggle/re-add and rejection rollback');
+} finally {await browser.close();server.stop(true);}
diff --git a/tools/live-reactions-test.js b/tools/live-reactions-test.js
new file mode 100644
index 0000000..b142528
--- /dev/null
+++ b/tools/live-reactions-test.js
@@ -0,0 +1,13 @@
+import assert from 'node:assert/strict';
+import { liveReactions } from '../src/live.js';
+const one = {kind:7,id:'one',pubkey:'alice',created_at:1,content:'❤️',tags:[['e','parent'],['e','comment']]};
+const two = {...one,id:'two',created_at:2,content:'👍'};
+const other = {...one,id:'other',pubkey:'bob'};
+const del = {kind:5,id:'delete',pubkey:'alice',created_at:3,tags:[['e','one'],['e','two']]};
+assert.equal(liveReactions([two, one]).get('comment').get('alice').content,'👍');
+assert.equal(liveReactions([one, one, other]).get('comment').size,2);
+assert.equal(liveReactions([del, two, one, other]).get('comment').size,1);
+assert.equal(liveReactions([{...del,pubkey:'mallory'},two]).get('comment').size,1);
+assert.equal(liveReactions([one,two,{...del,tags:[['e','one']]}]).get('comment').get('alice').id,'two');
+assert.equal(liveReactions([two,{...two,id:'zzz'}]).get('comment').get('alice').id,'zzz');
+console.log('✓ Live reaction deduplication, replacement, out-of-order deletion and author validation');
diff --git a/tools/live-stream-test.js b/tools/live-stream-test.js
index 88d2b17..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';
@@ -45,17 +45,41 @@ const chat = finalizeEvent({ kind: 1311, created_at: now - 20, content: 'hello f
const zapReq = finalizeEvent({ kind: 9734, created_at: now - 15, content: 'great stream', tags: [['p', HOST.pk], ['a', A], ['amount', '21000']] }, FAN.sk);
const zap = finalizeEvent({ kind: 9735, created_at: now - 15, content: '', tags: [['p', HOST.pk], ['a', A], ['bolt11', 'lnbc210n1invalid'], ['description', JSON.stringify(zapReq)], ['P', FAN.pk]] }, SERVICE.sk);
const post = finalizeEvent({ kind: 1, created_at: now - 100, content: 'come watch nostr:' + NADDR, tags: [] }, HOST.sk);
-const FIXTURES = [live, stale, chat, zap];
+// yesterday's stream, over, its recording kept
+const past = finalizeEvent({ kind: 30311, created_at: now - 86000, content: '', tags: [
+ ['d', 'stream-0'], ['title', 'Yesterday on coinos'], ['status', 'ended'], ['streaming', STREAM], ['recording', STREAM],
+ ['starts', String(now - 90000)], ['ends', String(now - 86000)], ['p', HOST.pk, '', 'Host'],
+] }, SERVICE.sk);
+// one kept on a video site: its "recording" is a web page
+const pastPage = finalizeEvent({ kind: 30311, created_at: now - 50000, content: '', tags: [
+ ['d', 'stream-p'], ['title', 'Zelda on Rumble'], ['status', 'ended'], ['streaming', STREAM], ['recording', 'https://rumble.com/v7gt2va-zelda.html'],
+ ['ends', String(now - 50000)], ['p', HOST.pk, '', 'Host'],
+] }, SERVICE.sk);
+// who's watching (NIP-53 presence): the fan, just now; someone two hours ago is gone
+const here = finalizeEvent({ kind: 10312, created_at: now - 60, content: '', tags: [['a', A, '', 'root']] }, FAN.sk);
+const longGone = finalizeEvent({ kind: 10312, created_at: now - 7200, content: '', tags: [['a', A, '', 'root']] }, STALE.sk);
+const reaction = finalizeEvent({kind:7,created_at:now-10,content:'👍',tags:[['e',chat.id],['p',FAN.pk],['k','1311']]},HOST.sk);
+const messageZap = finalizeEvent({kind:9737,created_at:now-9,content:'nice comment',tags:[['e',chat.id],['p',FAN.pk],['P',HOST.pk],['amount','5'],['network','regtest']]},HOST.sk);
+// and months of older ones, past the first ask's month: the grid pages back through them
+const OLD = Array.from({ length: 130 }, (_, i) => finalizeEvent({ kind: 30311, created_at: now - 31 * 86400 - i * 3600, content: '', tags: [
+ ['d', 'old-' + i], ['title', 'Old stream ' + i], ['status', 'ended'], ['streaming', STREAM], ['recording', STREAM],
+ ['ends', String(now - 31 * 86400 - i * 3600)], ['p', HOST.pk, '', 'Host'],
+] }, SERVICE.sk));
+const FIXTURES = [live, stale, past, pastPage, chat, zap, reaction, messageZap, here, longGone, ...OLD];
const ended = finalizeEvent({ ...live, id: undefined, sig: undefined, created_at: now - 5, tags: live.tags.map((x) => (x[0] === 'status' ? ['status', 'ended'] : x)) }, SERVICE.sk);
// the site as built (the split build: hls.js arrives as its own chunk)
if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
-const server = Bun.serve({ port: PORT, fetch: async (req) => {
+const server = Bun.serve({ port: PORT, fetch: async (req, srv) => {
+ if (new URL(req.url).pathname === "/relay" && srv.upgrade(req)) return;
const path = new URL(req.url).pathname;
if (path.startsWith('/live/')) return new Response(Bun.file(dir + path.slice(5)), { headers: { 'content-type': path.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t', 'access-control-allow-origin': '*' } });
const f = Bun.file('dist' + path);
return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
-} });
+}, websocket: { message(ws, data) {
+ const m = JSON.parse(String(data));
+ if (m[0] === 'EVENT') ws.send(JSON.stringify(['OK', m[1].id, true, '']));
+} } });
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
// the relays: they hold the fixtures, answer by the filter, and keep what's published
@@ -65,22 +89,24 @@ const relayStub = (fx) => {
// a new event, to every open subscription it matches (a relay's live feed)
window.__push = (e) => { for (const [ws, id, fs] of subs) if (ws.readyState === 1 && fs.some((f) => match(f, e))) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, e]) })); };
const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey))
- && (!f.since || e.created_at >= f.since)
+ && (!f.since || e.created_at >= f.since) && (!f.until || e.created_at <= f.until)
&& Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1])));
const Real = window.WebSocket;
window.WebSocket = function (...a) {
- const ws = new Real(...a);
+ const ws = new Real('ws://localhost:5302/relay'); // local fixture relay; never publish test events outside the test
const send = ws.send.bind(ws);
ws.send = (data) => {
try {
const m = JSON.parse(data);
if (m[0] === 'REQ' && m[2]) {
window.__reqs.push([a[0], ...m.slice(2)]);
- subs.push([ws, m[1], m.slice(2).map((f) => ({ ...f, since: undefined, limit: undefined }))]);
- const hits = [...fx, ...window.__published].filter((e) => m.slice(2).some((f) => match(f, e)));
+ subs.push([ws, m[1], m.slice(2).map((f) => ({ ...f, since: undefined, until: undefined, limit: undefined }))]);
+ // each filter its newest `limit`, as a relay answers
+ const hits = [...new Set(m.slice(2).flatMap((f) => [...fx, ...window.__published].filter((e) => match(f, e)).sort((x, y) => y.created_at - x.created_at).slice(0, f.limit || Infinity)))];
if (hits.length) setTimeout(() => { for (const e of hits) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], e]) })); }, 0);
}
- if (m[0] === 'EVENT' && m[1]) window.__published.push(m[1]);
+ if (m[0] === 'EVENT' && m[1] && !window.__published.some(e => e.id === m[1].id)) { window.__published.push(m[1]); setTimeout(() => window.__push(m[1]), 0); }
+ if (m[0] === 'CLOSE') for (let i = subs.length - 1; i >= 0; i--) if (subs[i][0] === ws && subs[i][1] === m[1]) subs.splice(i, 1);
} catch {}
return send(data);
};
@@ -124,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]) => {
@@ -146,46 +172,122 @@ try {
}, 15000));
check('...and no streams above the posts', !(await page.evaluate(() => document.querySelector('.live-grid, .live-strip'))));
await page.evaluate(() => document.querySelector('.media-mode[data-mode="live"]').click());
- check('the Live chip shows the stream', await waitFor(() => !!document.querySelector('.live-grid .live-tile'), 15000));
- const tile = await page.evaluate(() => [...document.querySelectorAll('.live-grid .live-tile')].map((t) => t.innerText.replace(/\s+/g, ' ')));
+ check('the Live chip shows the stream', await waitFor(() => !!document.querySelector('.live-grid:not(.live-past) .live-tile'), 15000));
+ const tile = await page.evaluate(() => [...document.querySelectorAll('.live-grid:not(.live-past) .live-tile')].map((t) => t.innerText.replace(/\s+/g, ' ')));
check('...titled, marked LIVE, with how many watch and who', tile.length && /LIVE/.test(tile[0]) && /12 watching/.test(tile[0]) && /Building coinos live/.test(tile[0]), JSON.stringify(tile));
+ check('past streams follow, the recording kept, its end time shown', await waitFor(() => [...document.querySelectorAll('.live-past .live-tile')].some((t) => /Yesterday on coinos/.test(t.innerText) && /RECORDING/.test(t.innerText)), 8000)
+ && await page.evaluate(() => /Past streams/.test(document.body.innerText)));
+ console.log('\n[past streams scroll on]');
+ const pastCount = () => page.evaluate(() => document.querySelectorAll('.live-past .live-tile').length);
+ const first = await pastCount();
+ check('a page of them at first, not all', first > 0 && first <= 30, first + ' tiles');
+ let last = first;
+ for (let i = 0; i < 120 && !(await page.evaluate(() => document.body.innerText.includes('Old stream 129'))); i++) { // (this relay never says EOSE: each page waits out its 5 s)
+ await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
+ await sleep(250);
+ last = await pastCount();
+ }
+ check('scrolling down brings older ones, from the relays, back to the oldest', await page.evaluate(() => document.body.innerText.includes('Old stream 129')), last + ' tiles');
+ check('...asked for page by page (until the oldest so far)', await page.evaluate(() => window.__reqs.filter((r) => r.slice(1).some((f) => f.kinds && f.kinds.includes(30311) && f.until)).length >= 2));
+ check('...all of them there, once each', last === 132 && await page.evaluate(() => new Set([...document.querySelectorAll('.live-past .live-tile')].map((t) => t.dataset.key)).size === 132), last + ' tiles');
+ await page.evaluate(() => window.scrollTo(0, 0));
check('...and a stream unrefreshed for two hours is not there', tile.length === 1 && !(await page.evaluate(() => document.body.innerText.includes('Forgotten stream'))));
await page.evaluate(() => document.querySelector('.media-mode[data-mode="all"]').click());
check('a post linking the stream shows it as a card', await waitFor(() => !!document.querySelector('.notes-feed .live-card'), 15000));
check('the host\'s face on their post wears the live ring', await waitFor((pk) => !!document.querySelector('.notes-feed .note-post .note-avatar.live-ring'), 5000));
await page.evaluate(() => document.querySelector('.notes-feed .note-post .note-avatar.live-ring').click());
check('...and a tap on it opens the stream', await waitFor(() => !!document.querySelector('.live-page'), 5000));
+ const lay = await page.evaluate(() => {
+ const r = (q) => document.querySelector(q)?.getBoundingClientRect();
+ const p = r('.live-page .live-player'), c = r('.live-page .live-chat');
+ return { w: document.body.clientWidth, player: p && [p.left, p.width], chat: c && [c.left, c.width] };
+ });
+ check('on a phone the picture runs edge to edge and the chat is as wide', lay.player && lay.player[0] === 0 && lay.player[1] === lay.w && lay.chat && lay.chat[0] === 0 && lay.chat[1] === lay.w, JSON.stringify(lay));
await page.goBack();
await waitFor(() => !document.querySelector('.live-page') && !!document.querySelector('.media-modes'), 5000);
await page.evaluate(() => document.querySelector('.media-mode[data-mode="live"]').click());
- await waitFor(() => !!document.querySelector('.live-grid .live-tile'), 5000);
+ await waitFor(() => !!document.querySelector('.live-grid:not(.live-past) .live-tile'), 5000);
console.log('\n[the stream\'s page]');
- await page.evaluate(() => document.querySelector('.live-grid .live-tile').click());
+ await page.evaluate(() => document.querySelector('.live-grid:not(.live-past) .live-tile').click());
check('a tap opens it, the address bar naming the stream', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor((n) => location.pathname === '/' + n, 5000, NADDR), await page.evaluate(() => location.pathname));
check('the stream plays', await waitFor(playing, 20000), JSON.stringify(await page.evaluate(() => { const v = document.querySelector('video.live-video'); return v && { t: v.currentTime, paused: v.paused, err: v.error && v.error.code, src: v.currentSrc.slice(0, 60) }; })));
- check('...from its playlist, natively in Chrome', await page.evaluate((s) => document.querySelector('video.live-video').currentSrc === s, STREAM));
+ // a stream that's on plays through hls.js (kept near the live edge), not Chrome's own player
+ check('...through hls.js, kept near the live edge', await page.evaluate(() => document.querySelector('video.live-video').currentSrc.startsWith('blob:')));
check('the chat shows what was said', await waitText('hello from the chat', 8000));
+ const who = () => page.evaluate(() => { const x = document.querySelector('.live-page .live-watching'); return x && { text: x.textContent, title: x.getAttribute('title') || '' }; });
+ check('"watching" names who, on hover: the fan and this viewer, not someone long gone', await waitFor(() => (document.querySelector('.live-page .live-watching')?.getAttribute('title') || '').split(', ').length === 2, 8000), JSON.stringify(await who()));
+ check('...this viewer said they\'re here (a 10312 for the stream)', await page.evaluate((a) => window.__published.some((e) => e.kind === 10312 && e.tags.some((t) => t[0] === 'a' && t[1] === a)), A));
+ await page.evaluate(() => document.querySelector('.live-page .live-watching').click());
+ check('...a tap shows their faces', await waitFor(() => document.querySelectorAll('.live-page .live-who .live-who-p').length === 2, 3000));
check('...and the zap, its amount and words', await waitText('zapped 21 sats', 5000) && await waitText('great stream', 1000));
- await page.focus('.live-input');
+ const rowSel = '.live-message[data-key="' + chat.id + '"]';
+ check('comments use the group-chat bubble and shared composer', await page.$(rowSel + ' .chat-bubble') && await page.$('.live-page coinos-text#msg-draft'));
+ check('...drawn plain on a phone, no bubble walls', await page.evaluate((q) => { const b = document.querySelector(q); return b && getComputedStyle(b).borderStyle === 'none' && getComputedStyle(b).display === 'block'; }, rowSel + ' .chat-bubble'));
+ check('another client’s e-only reaction arrives on the comment', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .chat-reacts')?.textContent.includes('👍'), 6000, chat.id));
+ check('per-comment zap receipts show under the bubble', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .zap-tally')?.textContent.includes('5'), 6000, chat.id));
+ await page.click(rowSel + ' .chat-bubble');
+ check('tapping the bubble opens the shared emoji/action sheet', await waitFor(() => !!document.querySelector('.msg-sheet-emojis'), 2000));
+ await page.evaluate(() => [...document.querySelectorAll('.msg-sheet-emojis button')].find(b => b.textContent === '❤️').click());
+ check('a reaction is published against the comment and its author', await waitFor(id => window.__published.some(e => e.kind === 7 && e.tags.some(t => t[0] === 'e' && t[1] === id)), 5000, chat.id));
+ const myReaction = await page.evaluate(id => window.__published.find(e => e.kind === 7 && e.tags.some(t => t[0] === 'e' && t[1] === id)), chat.id);
+ check('reaction carries the stream and kind for other clients', myReaction?.tags.some(t => t[0] === 'a' && t[1] === A) && myReaction.tags.some(t => t[0] === 'p' && t[1] === FAN.pk) && myReaction.tags.some(t => t[0] === 'k' && t[1] === '1311'));
+ check('our reaction highlights immediately', await waitFor(id => !!document.querySelector('.live-message[data-key="'+id+'"] .chat-react.on'), 3000, chat.id));
+ await page.click(rowSel + ' .chat-react.on');
+ check('tapping it again publishes its deletion', await waitFor(id => window.__published.some(e => e.kind === 5 && e.tags.some(t => t[0] === 'e' && t[1] === id)), 5000, myReaction.id));
+ check('the removed reaction disappears', await waitFor(id => !document.querySelector('.live-message[data-key="'+id+'"] .chat-react.on'), 3000, chat.id));
+ const remoteReaction = finalizeEvent({kind:7,created_at:now+1,content:'🔥',tags:[['e',chat.id],['p',FAN.pk]]},HOST.sk);
+ await page.evaluate(e => window.__push(e), remoteReaction);
+ check('a new remote reaction updates without reloading', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .chat-reacts')?.textContent.includes('🔥'), 3000, chat.id));
+ // The existing zap button must select this fan's npub, not the host.
+ const zapButton = await page.$(rowSel + ' .chat-zap');
+ check('the comment has the same one-tap zap control as a DM', !!zapButton);
+ await page.click(rowSel + ' .chat-bubble');
+ await page.evaluate(() => [...document.querySelectorAll('.msg-sheet-item')].find(b => b.textContent.includes('Reply')).click());
+ check('Reply opens the group-chat quote bar', await waitFor(() => !!document.querySelector('.live-page .reply-bar'), 2000));
+ await page.focus('.live-page #msg-draft');
await page.keyboard.type('first time here');
await page.keyboard.press('Enter');
check('a line typed in the chat shows at once', await waitText('first time here', 3000));
const sent = await page.evaluate(() => window.__published.find((e) => e.kind === 1311));
check('...and goes out as a kind 1311 addressed to the stream', !!sent && sent.content === 'first time here' && sent.tags.some((x) => x[0] === 'a' && x[1] === A), JSON.stringify(sent && sent.tags));
+ check('reply names the parent comment', !!sent && sent.tags.some(t => t[0] === 'e' && t[1] === chat.id) && sent.tags.some(t => t[0] === 'p' && t[1] === FAN.pk));
check('the player keeps playing through the renders', await waitFor(playing, 3000));
+ if (process.env.SHOT) { await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await sleep(400); await page.screenshot({ path: process.env.SHOT }); }
+
+ console.log('\n[ways out]');
+ await page.evaluate(() => document.querySelector('.live-page .chat-avatar.mini.live-ring').click());
+ check('on the stream, the host\'s ringed face opens their profile (not the same stream again)', await waitFor(() => !document.querySelector('.live-page') && !!document.querySelector('.live-banner'), 8000));
+ await page.goBack();
+ await waitFor(() => !!document.querySelector('.live-page'), 5000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('Feed in the nav leaves the stream on the first tap', await waitFor(() => !document.querySelector('.live-page') && !!document.querySelector('.media-modes'), 5000));
+ await page.goBack();
+ await waitFor(() => !!document.querySelector('.live-page'), 5000);
console.log('\n[leaving]');
await page.goBack();
check('Back leaves the stream\'s page', await waitFor(() => !document.querySelector('.live-page'), 5000));
+ check('...and takes back this viewer\'s presence (an empty 10312)', await waitFor(() => window.__published.some((e) => e.kind === 10312 && !e.tags.some((t) => t[0] === 'a')), 3000));
check('...and nothing plays on behind it', await page.evaluate(() => [...document.querySelectorAll('video')].every((v) => v.paused || !v.isConnected)));
await page.evaluate(() => document.querySelector('.media-mode[data-mode="all"]').click());
await waitFor(() => !!document.querySelector('.notes-feed .live-card'), 5000);
await page.evaluate(() => document.querySelector('.notes-feed .live-card').click());
check('the card in the post opens the stream too', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor(playing, 20000));
await page.evaluate(() => { const a = [...document.querySelectorAll('.live-page a.quote-name')].find((x) => /Stream Host/.test(x.textContent)); a.click(); });
+ check('the host\'s profile has a Streams tab', await waitFor(() => !!document.querySelector('.prof-tabs [data-tab="streams"]'), 10000) && !(await page.$('.live-past')));
+ await page.evaluate(() => document.querySelector('.prof-tabs [data-tab="streams"]').click());
+ check('...listing their past streams', await waitFor(() => [...document.querySelectorAll('.live-past .live-tile')].some((t) => /Yesterday on coinos/.test(t.innerText)), 10000));
+ await page.evaluate(() => [...document.querySelectorAll('.live-past .live-tile')].find((t) => /Zelda on Rumble/.test(t.innerText)).click());
+ check('a recording kept as a web page: ended, and a link to watch it there, no player', await waitFor(() => !!document.querySelector('.live-page a.live-rec-link[href="https://rumble.com/v7gt2va-zelda.html"]'), 5000)
+ && await page.evaluate(() => /This stream has ended/.test(document.body.innerText) && !document.querySelector('.live-page video.live-video')));
+ await page.goBack();
+ await waitFor(() => !document.querySelector('.live-page'), 5000);
check('the host\'s profile says they\'re on', await waitFor(() => !!document.querySelector('.live-banner'), 10000) && await waitText('Building coinos live', 2000));
check('...the player stopped when the page went', await page.evaluate(() => [...document.querySelectorAll('video')].every((v) => v.paused || !v.isConnected)));
+ await page.evaluate(() => [...document.querySelectorAll('.live-past .live-tile')].find((t) => /Yesterday on coinos/.test(t.innerText)).click());
+ check('a past stream opens on its recording, playing', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor(playing, 20000)
+ && await page.evaluate(() => !document.body.innerText.includes('This stream has ended')));
check('no page errors', !page.errs.length, page.errs.join(' | '));
await page.close();
diff --git a/tools/ln-pay-double-wake.test.js b/tools/ln-pay-double-wake.test.js
new file mode 100644
index 0000000..18a1943
--- /dev/null
+++ b/tools/ln-pay-double-wake.test.js
@@ -0,0 +1,74 @@
+// 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 () => { asp.checks++; 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);
+});
diff --git a/tools/ln-receive-twin-test.js b/tools/ln-receive-twin-test.js
index 20d4f48..167a26b 100644
--- a/tools/ln-receive-twin-test.js
+++ b/tools/ln-receive-twin-test.js
@@ -35,5 +35,19 @@ f = lnFoldedIds([rcv('A', 1000, T), { ...rcv('B', 1000, T + 2000), vtxoId: 'v-A'
check('...or the earliest when none is tagged', f.size === 1 && f.has('B'));
f = lnFoldedIds([rcv('A', 1000, T), rcv('B', 1000, T + 2000)]);
check('two coins of the same amount stay two rows', f.size === 0);
+// a Lightning payment the closed app's worker made: its own ln-send row
+// (merged from the mirror) plus the app's "while closed" note of the same
+// preimage — one row, the one that names its inputs
+const snd = (id, ts, extra = {}) => ({ id, type: 'ln-send', status: 'complete', amountSat: 1, ts, ...extra });
+f = lnFoldedIds([snd('W', T, { preimage: 'pi1', paymentHash: 'h1', inputIds: ['v1'] }), snd('N', T + 60_000, { preimage: 'pi1', detail: 'while closed · fee 0 sat' })]);
+check('a closed-app payment told twice shows once, the row with inputs', f.size === 1 && f.has('N'));
+f = lnFoldedIds([snd('N', T, { preimage: 'pi1' }), snd('W', T + 5000, { preimage: 'pi1', paymentHash: 'h1', inputIds: ['v1'] })]);
+check('...whichever order they were written', f.size === 1 && f.has('N'));
+f = lnFoldedIds([snd('W', T, { paymentHash: 'h1', preimage: 'pi1', inputIds: ['v1'] }), snd('N', T + 1000, { paymentHash: 'h1' })]);
+check('...matched by payment hash when the note has no preimage', f.size === 1 && f.has('N'));
+f = lnFoldedIds([snd('A', T, { preimage: 'pi1', inputIds: ['v1'] }), snd('B', T + 1000, { preimage: 'pi2', inputIds: ['v2'] })]);
+check('two payments stay two rows', f.size === 0);
+f = lnFoldedIds([snd('A', T, { preimage: 'pi1' }), snd('B', T + 1000, { preimage: 'pi1' })]);
+check('two notes with no inputs keep the earliest', f.size === 1 && f.has('B'));
console.log(ok ? '\n✅ a claimed LN receive is told once' : '\n❌ failed');
process.exit(ok ? 0 : 1);
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
new file mode 100644
index 0000000..1a00222
--- /dev/null
+++ b/tools/media-grid-dups-test.js
@@ -0,0 +1,169 @@
+// The Videos grid shows a clip once, however many posts carry it: the same
+// file (or a Blossom hash's mirror), or the same words from the same person
+// with a fresh upload, or the same file uploaded twice under two hashes (a
+// note and a video event: one size to the byte) (Adam, 2026-10-22). A picture
+// 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, { 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 png = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x180', '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
+const clip = (name, size, t = 30) => {
+ const f = '/tmp/coinos-vpager-' + name + (t === 30 ? '' : '-' + t) + '.webm';
+ if (!existsSync(f)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size + ':rate=10', '-t', String(t), '-c:v', 'libvpx', '-b:v', '100k', f]);
+ return f;
+};
+// (the re-uploaded post's files differ from the reposted one's: one size from
+// one person is taken for one file)
+const CLIPS = { wide: clip('wide', '320x180'), again: clip('wide', '320x180', 25), other: clip('wide', '320x180', 26), tall: clip('tall', '180x320'), last: clip('last', '240x240') };
+// the twice-uploaded clip: its own file (the ones above are all one file, so a
+// size match would take them for one clip too)
+const TWIN = '/tmp/coinos-vpager-twin.webm';
+if (!existsSync(TWIN)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x180:rate=10', '-t', '20', '-c:v', 'libvpx', '-b:v', '100k', TWIN]);
+const pic = (size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
+const picWide = pic('640x360'), picTall = pic('360x640');
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ const m = /^\/(wide|again|other|tall|last|twina|twinb)\.webm$/.exec(path);
+ if (m) {
+ const f = Bun.file(CLIPS[m[1]] || TWIN), 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) } });
+ }
+ const pm = /^\/pic-(wide|tall)\.png$/.exec(path);
+ if (pm) return new Response(pm[1] === 'wide' ? picWide : picTall, { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*' } });
+ if (path === '/dead.webm' || path === '/deadpic.png') return new Response('gone', { status: 404 });
+ if (path === '/still.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 U = (n) => server.url.origin + '/' + n + '.webm';
+const post = (content, url, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['imeta', 'url ' + url, 'image ' + server.url.origin + '/still.png', 'm video/webm']], content }, SK);
+const seeded = [
+ // one clip, three posts (a promo posted again and again)
+ post('see you at the conference ' + U('wide'), U('wide'), 100),
+ post('tickets going fast ' + U('wide'), U('wide'), 150),
+ post('last call ' + U('wide') + '?x=1', U('wide') + '?x=1', 160),
+ // the same words from the same person, uploaded afresh each time
+ post('Transylvania lightning conference 2026 ' + U('again'), U('again'), 200),
+ post('Transylvania lightning conference 2026 ' + U('other'), U('other'), 300),
+ post('the last clip ' + U('last'), U('last'), 400),
+ // one person's clip twice, as a note and as a video event, a fresh hash
+ // each (the server stores it again): different words, same bytes
+ finalizeEvent({ kind: 1, created_at: now - 410, tags: [['imeta', 'url ' + U('twina'), 'm video/webm', 'dim 320x180', 'size ' + Bun.file(TWIN).size]], content: '#goodmorning another beat ' + U('twina') }, SK),
+ finalizeEvent({ kind: 22, created_at: now - 420, tags: [['imeta', 'url ' + U('twinb'), 'm video/webm', 'dim 320x180', 'size ' + Bun.file(TWIN).size], ['title', 'Random Beat']], content: 'Random Beat' }, SK),
+ // a picture its host has deleted
+ finalizeEvent({ kind: 1, created_at: now - 125, tags: [], content: 'gone now ' + server.url.origin + '/deadpic.png' }, SK),
+ // pictures, one wide and one tall, the post saying nothing of their size
+ finalizeEvent({ kind: 1, created_at: now - 120, tags: [], content: 'a wide view ' + server.url.origin + '/pic-wide.png' }, SK),
+ finalizeEvent({ kind: 1, created_at: now - 130, tags: [], content: 'a tall view ' + server.url.origin + '/pic-tall.png' }, SK),
+ // a tall one: out of a wide screen's grid until "All shapes"
+ finalizeEvent({ kind: 1, created_at: now - 450, tags: [['imeta', 'url ' + U('tall'), 'm video/webm', 'dim 180x320']], content: 'standing up ' + U('tall') }, SK),
+ // a clip its host has since deleted (it says so: 404) — no blank tile for it
+ finalizeEvent({ kind: 1, created_at: now - 500, tags: [['imeta', 'url ' + U('dead'), 'm video/webm', 'dim 320x180']], content: 'deleted since ' + U('dead') }, 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)));
+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; };
+try {
+ await page.setViewport({ width: 1600, 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: 'Clip Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ // (the feed paints its first rows: seven of the clips' posts are among them)
+ check('the clips are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed video.note-video').length >= 7, null, 20000), String(await page.evaluate(() => document.querySelectorAll('.notes-feed video.note-video').length)));
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="videos"]').click());
+ // a desktop-sized window starts on every shape (justified rows, the tall clip in)
+ check('a desktop starts on every shape', await waitFor(() => !!document.querySelector('.media-grid.justified') && [...document.querySelectorAll('.media-grid .media-tile')].some((t) => t.getAttribute('data-key').endsWith('/tall.webm')), null, 15000));
+ // the rest starts from the screen's way round: wide only
+ await page.evaluate(() => { localStorage.setItem('coinos-clips-fit', 'fit'); document.querySelector('.media-mode[data-mode="images"]').click(); });
+ await sleep(300);
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="videos"]').click());
+ await waitFor(() => document.querySelectorAll('.media-grid .media-tile').length >= 4, null, 15000);
+ await waitFor(() => document.querySelectorAll('.media-grid .media-tile').length === 4, null, 8000);
+ await sleep(1500);
+ const tiles = await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile').length);
+ check('a wide screen lays the clips out one to a row, wide', await page.evaluate(() => { const g = document.querySelector('.media-grid'), t = g.querySelector('.media-tile').getBoundingClientRect(); return g.classList.contains('wide') && Math.abs(t.width / t.height - 16 / 9) < 0.05 && t.width > g.getBoundingClientRect().width * 0.95; }));
+ check('one tile per clip: the reposted file once, the re-uploaded post once, the twice-uploaded file once, the other one, none for the deleted one', tiles === 4, tiles + ' tiles: ' + (await page.evaluate(() => [...document.querySelectorAll('.media-grid .media-tile')].map((t) => t.getAttribute('data-key').split('/').pop()).join(' '))));
+ await page.evaluate(() => document.querySelector('.media-grid .media-tile').click());
+ check('the pager opens', await waitFor(() => !!document.querySelector('.mp-slide.mp-cur video.mp-media'), null, 6000));
+ check('the clip is drawn no bigger than 1.5× its pixels', await waitFor(() => { const v = document.querySelector('.mp-slide.mp-cur video.mp-media'); return v && v.videoWidth && v.getBoundingClientRect().width <= v.videoWidth * 1.5 + 1 && v.style.maxWidth; }, null, 8000),
+ JSON.stringify(await page.evaluate(() => { const v = document.querySelector('.mp-slide.mp-cur video.mp-media'); return v && { vw: v.videoWidth, w: v.getBoundingClientRect().width, max: v.style.maxWidth }; })));
+ // "All shapes", from the pager's top bar: the tall clip is there to flick to
+ const tallShown = () => page.evaluate(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/tall.webm')); return !!s && !s.closest('.mp-group').classList.contains('mp-off'); });
+ check('in the pager, the tall clip is held out on a wide screen', !(await tallShown()));
+ await page.evaluate(() => document.querySelector('.mp-fit').click());
+ check('…and "All shapes" brings it in', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/tall.webm')); return !!s && !s.closest('.mp-group').classList.contains('mp-off'); }, null, 3000));
+ await page.keyboard.press('Escape');
+ check('…which, being the desktop\'s default, is kept as no setting at all', await page.evaluate(() => localStorage.getItem('coinos-clips-fit') === null));
+ check('the grid has it too', await waitFor(() => [...document.querySelectorAll('.media-grid .media-tile')].some((t) => t.getAttribute('data-key').endsWith('/tall.webm')), null, 5000));
+ const rows = () => page.evaluate(() => {
+ const g = document.querySelector('.media-grid'), gw = g.getBoundingClientRect().width;
+ const ts = [...g.querySelectorAll('.media-tile')].map((t) => ({ k: t.getAttribute('data-key').split('/').pop(), r: t.getBoundingClientRect() }));
+ const by = new Map(); for (const t of ts) { const y = Math.round(t.r.top); (by.get(y) || by.set(y, []).get(y)).push(t); }
+ return { justified: g.classList.contains('justified'), gw, rows: [...by.values()].map((row) => ({ ks: row.map((t) => t.k), hs: row.map((t) => Math.round(t.r.height)), ars: row.map((t) => +(t.r.width / t.r.height).toFixed(2)), w: Math.round(row.reduce((a, t) => a + t.r.width, 0) + 3 * (row.length - 1)) })) };
+ });
+ await waitFor(() => { const t = [...document.querySelectorAll('.media-grid .media-tile')].find((x) => x.getAttribute('data-key').endsWith('/tall.webm')); return t && t.getBoundingClientRect().width < t.getBoundingClientRect().height; }, null, 5000);
+ const R = await rows();
+ const tallT = R.rows.flatMap((r) => r.ks.map((k, i) => ({ k, ar: r.ars[i] }))).find((x) => x.k === 'tall.webm');
+ check('…in justified rows: every tile its clip\'s shape (the tall one tall, the wide ones wide)', R.justified && tallT && Math.abs(tallT.ar - 0.5625) < 0.03 && R.rows.flatMap((r) => r.ks.map((k, i) => ({ k, ar: r.ars[i] }))).filter((x) => x.k !== 'tall.webm' && x.k !== 'last.webm').every((x) => Math.abs(x.ar - 16 / 9) < 0.03), JSON.stringify(R));
+ check('…one height across each row', R.rows.every((r) => Math.max(...r.hs) - Math.min(...r.hs) <= 1), JSON.stringify(R.rows.map((r) => r.hs)));
+ // the Images grid: justified rows too, each picture in its own shape
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="images"]').click());
+ const picAr = (name) => page.evaluate((n) => { const t = [...document.querySelectorAll('.media-grid .media-tile')].find((x) => x.getAttribute('data-key').endsWith(n)); if (!t) return null; const r = t.getBoundingClientRect(); return +(r.width / r.height).toFixed(2); }, name);
+ check('the Images grid lays pictures out in justified rows, each its own shape', await waitFor(() => { const g = document.querySelector('.media-grid.justified'); if (!g) return false; const ar = (n) => { const t = [...g.querySelectorAll('.media-tile')].find((x) => x.getAttribute('data-key').endsWith(n)); if (!t) return 0; const r = t.getBoundingClientRect(); return r.width / r.height; }; return Math.abs(ar('pic-wide.png') - 16 / 9) < 0.05 && Math.abs(ar('pic-tall.png') - 9 / 16) < 0.05; }, null, 10000),
+ JSON.stringify({ wide: await picAr('pic-wide.png'), tall: await picAr('pic-tall.png') }));
+ // the deleted picture: gone from the grid, and still gone after repaints
+ // (they rewrite each tile's class list)
+ await sleep(1500);
+ await page.evaluate(() => { document.querySelector('.media-mode[data-mode="videos"]').click(); });
+ await sleep(300);
+ await page.evaluate(() => { document.querySelector('.media-mode[data-mode="images"]').click(); });
+ await sleep(1500);
+ check('a picture that won\'t load leaves the grid for good', await page.evaluate(() => {
+ const ts = [...document.querySelectorAll('.media-grid .media-tile')];
+ return ts.length >= 2 && !ts.some((t) => t.getAttribute('data-key').endsWith('deadpic.png') && getComputedStyle(t).display !== 'none');
+ }), await page.evaluate(() => [...document.querySelectorAll('.media-grid .media-tile')].map((t) => t.getAttribute('data-key').split('/').pop() + (getComputedStyle(t).display === 'none' ? '(hidden)' : '')).join(' ')));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally {
+ await browser.close();
+ server.stop(true);
+}
+console.log(ok ? '\n✅ the grid shows each clip once; small clips stay sharp on a big screen' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/media-grid-order-test.js b/tools/media-grid-order-test.js
new file mode 100644
index 0000000..0e333f0
--- /dev/null
+++ b/tools/media-grid-order-test.js
@@ -0,0 +1,128 @@
+// The Videos grid's order holds for the session (Adam, 2026-10-23: clips the
+// crawl found landed in the middle of the grid and the tiles already there
+// 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, { 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 { mkdtempSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+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 dir = mkdtempSync(tmpdir() + '/morder-');
+const NAMES = ['a', 'b', 'c', 'gap1', 'gap2', 'old1', 'old2', 'fresh'];
+for (const [i, n] of NAMES.entries()) {
+ const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=360x640:rate=10', '-t', String(2 + i / 4), '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${n}.mp4`]);
+ if (p.exitCode !== 0) throw new Error('ffmpeg failed');
+}
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, idleTimeout: 30, fetch: async (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/v/')) {
+ const f = Bun.file(dir + path.slice(2));
+ const cors = { 'access-control-allow-origin': '*', 'access-control-expose-headers': 'content-range, content-length', 'content-type': 'video/mp4', 'accept-ranges': 'bytes' };
+ if (req.method === 'HEAD') return new Response(null, { headers: { ...cors, 'content-length': String(f.size) } });
+ const m = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ if (m) { const a = +m[1], b = Math.min(m[2] ? +m[2] : f.size - 1, f.size - 1); return new Response(f.slice(a, b + 1), { status: 206, headers: { ...cors, 'content-range': `bytes ${a}-${b}/${f.size}` } }); }
+ return new Response(f, { headers: cors });
+ }
+ 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 page0 = server.url.origin, base = page0.replace('localhost', '127.0.0.1');
+const SK = generateSecretKey(), PK = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const clip = (name, at) => finalizeEvent({ kind: 1, created_at: at, content: name + ' ' + base + '/v/' + name + '.mp4',
+ tags: [['imeta', 'url ' + base + '/v/' + name + '.mp4', 'm video/mp4', 'dim 360x640']] }, SK);
+const words = (i, at) => finalizeEvent({ kind: 1, created_at: at, tags: [], content: 'just words ' + i }, SK);
+// the page: words and three clips minutes apart; the crawl (a hundred at a
+// time) from under the page finds only two clips from long ago; the feed's
+// own next page of posts (asked as the grid is scrolled to its end) brings
+// two clips dated between c and old1
+const abc = [clip('a', now - 3000), clip('b', now - 3400), clip('c', now - 3800)];
+const gap = [clip('gap1', now - 12000), clip('gap2', now - 14000)];
+const tail = [clip('old1', now - 20000), clip('old2', now - 30000)];
+const wordsAll = []; for (let i = 0; i < 10; i++) wordsAll.push(words(i, i < 5 ? now - 100 - i * 400 : now - 4000 - (i - 5) * 400));
+const fresh = clip('fresh', now + 5);
+const all = [...wordsAll, ...abc, ...gap, ...tail].sort((x, y) => y.created_at - x.created_at);
+
+const browser = await puppeteer.launch({ relays: 'read', executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument(([all, tail, fresh, pk]) => {
+ window.__crawls = 0; window.__pages = 0; window.__live = []; window.__reqs = [];
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const send = ws.send.bind(ws);
+ const emit = (sub, ev) => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', sub, ev]) }));
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ') {
+ const f = m[2] || {};
+ let out = [];
+ if ((f.kinds || []).includes(1) && (f.authors || []).includes(pk) && !f['#e'] && !f.ids) {
+ window.__reqs.push({ until: f.until, since: f.since, limit: f.limit });
+ if (f.since && !f.until) window.__live.push(() => emit(m[1], fresh));
+ else if (f.limit === 100 && f.until) { window.__crawls++; out = tail.filter((e) => e.created_at <= f.until); }
+ else { window.__pages += f.until ? 1 : 0; out = all.filter((e) => !f.until || e.created_at <= f.until).slice(0, f.limit || 30); }
+ }
+ setTimeout(() => {
+ for (const ev of out) emit(m[1], ev);
+ ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) }));
+ }, 0);
+ return;
+ }
+ if (m[0] === 'EVENT') return;
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+}, [all, tail, fresh, PK]);
+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 = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn)) return true; await sleep(100); } return false; };
+const order = () => page.evaluate(() => [...document.querySelectorAll('.media-grid .media-tile.video')].map((t) => t.getAttribute('data-key').split('/').pop().replace('.mp4', '')));
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(page0, { 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 key = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [key, PK]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => [...document.querySelectorAll('.app-nav-button, button')].find((b) => b.textContent.trim() === 'Feed')?.click());
+ await waitFor(() => !!document.querySelector('.media-modes'), 20000);
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="videos"]').click());
+ check('the grid: the page\'s three clips, newest first', await waitFor(() => document.querySelectorAll('.media-grid .media-tile.video').length === 3, 20000) && (await order()).join(' ') === 'a b c', (await order()).join(' '));
+ check('the crawl finds the two from long ago, under them', await waitFor(() => window.__crawls >= 1 && document.querySelectorAll('.media-grid .media-tile.video').length === 5, 25000) && (await order()).join(' ') === 'a b c old1 old2', (await order()).join(' ') + ' crawls ' + await page.evaluate(() => window.__crawls));
+ for (let i = 0; i < 40 && !(await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile.video').length === 7)); i++) {
+ await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
+ await sleep(500);
+ }
+ check('the feed\'s next page, asked at the grid\'s end, brings the two dated between c and old1', await page.evaluate(() => window.__pages >= 1 && document.querySelectorAll('.media-grid .media-tile.video').length === 7), (await order()).join(' ') + ' pages ' + await page.evaluate(() => window.__pages));
+ check('...shown under the tiles already there, in their own date order', (await order()).join(' ') === 'a b c old1 old2 gap1 gap2', (await order()).join(' '));
+ await page.evaluate(() => { for (const f of window.__live) f(); });
+ check('a post newer than the top tile goes above it', await waitFor(() => document.querySelectorAll('.media-grid .media-tile.video').length === 8, 20000) && (await order()).join(' ') === 'fresh a b c old1 old2 gap1 gap2', (await order()).join(' '));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ media grid: found later, shown under' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/media-moments-feed-test.js b/tools/media-moments-feed-test.js
new file mode 100644
index 0000000..5b4c8fb
--- /dev/null
+++ b/tools/media-moments-feed-test.js
@@ -0,0 +1,193 @@
+// Full feed integration with local media and relay acknowledgements; no events leave the test.
+// Run: bun tools/media-moments-feed-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 { mkdtempSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+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 dir = mkdtempSync(tmpdir() + '/media-');
+const ff = (args) => { const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', ...args]); if (p.exitCode !== 0) throw new Error('ffmpeg failed'); };
+for (const n of ['a', 'b', 'c']) ff(['-f', 'lavfi', '-i', 'testsrc=size=360x640:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${n}.mp4`]);
+// index last (no faststart), from a server that ignores ranges
+ff(['-f', 'lavfi', '-i', 'testsrc=size=480x270:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', `${dir}/slow.mp4`]);
+for (const n of ['p1', 'p2', 'p3']) ff(['-f', 'lavfi', '-i', 'testsrc=size=800x600', '-frames:v', '1', `${dir}/${n}.jpg`]);
+
+ff(['-f', 'lavfi', '-i', 'sine=frequency=440:duration=12', `${dir}/audio.mp3`]);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, idleTimeout: 30, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/audio.mp3') {
+ const f = Bun.file(dir + '/audio.mp3'), m = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ if (m) { const a = +m[1], b = Math.min(m[2] ? +m[2] : f.size - 1, f.size - 1); return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'audio/mpeg', 'content-range': `bytes ${a}-${b}/${f.size}`, 'accept-ranges': 'bytes' } }); }
+ return new Response(f, { headers: { 'content-type': 'audio/mpeg' } });
+ }
+ if (path.startsWith('/v/')) {
+ const f = Bun.file(dir + path.slice(2));
+ const m = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ if (m && path !== '/v/slow.mp4') {
+ const a = +m[1], b = Math.min(m[2] ? +m[2] : f.size - 1, f.size - 1);
+ // c.mp4 stands in for a 50 MB file: its server says so
+ const total = path === '/v/c.mp4' && m[2] === '65535' ? 50000000 : f.size; // (to the probe; a player gets the real file)
+ return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'video/mp4', 'content-range': `bytes ${a}-${b}/${total}`, 'accept-ranges': 'bytes' } });
+ }
+ return new Response(f, { headers: { 'content-type': 'video/mp4' } });
+ }
+ if (path.startsWith('/i/')) return new Response(Bun.file(dir + path.slice(2)), { headers: { 'content-type': 'image/jpeg' } });
+ 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 base = server.url.origin;
+const SK = generateSecretKey(), PK = getPublicKey(SK);
+const FAN = generateSecretKey();
+const now = Math.floor(Date.now() / 1000);
+const post = (i, content, tags = []) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 60, tags, content }, SK);
+const notes = [
+ post(0, 'first clip, look at this ' + base + '/v/a.mp4'),
+ post(1, 'a plain post with no media at all'),
+ post(2, 'sunset over the bay ' + base + '/i/p1.jpg'),
+ post(3, base + '/v/b.mp4'),
+ post(4, 'two at once ' + base + '/i/p2.jpg ' + base + '/i/p3.jpg'),
+ // the post says this one is 50 MB (it isn't; the post is believed)
+ post(5, 'a huge clip ' + base + '/v/c.mp4', [['imeta', 'url ' + base + '/v/c.mp4', 'm video/mp4', 'size 50000000', 'dim 360x640']]),
+ post(6, 'index last ' + base + '/v/slow.mp4'),
+];
+notes.unshift(post(9, 'Listen ' + base + '/audio.mp3', [['imeta', 'url ' + base + '/audio.mp3', 'waveform 1 5 2 8 4 10']]));
+const LIKE = finalizeEvent({ kind: 7, created_at: now - 50, tags: [['e', notes[2].id], ['p', PK]], content: '+' }, FAN);
+
+const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument((like) => {
+ 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 f = m[2] || {};
+ const evs = (f.kinds || []).includes(7) && (f['#e'] || []).includes(like.tags[0][1]) ? [like] : [];
+ 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] === 'EVENT') return send(data);
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+}, LIKE);
+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 = 10000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; };
+const mode = (m) => page.evaluate((x) => document.querySelector(`.media-mode[data-mode="${x}"]`).click(), m);
+const shot = async (name) => { if (process.env.SHOTS) await page.screenshot({ path: `${process.env.SHOTS}/${name}.png` }); };
+// which slide fills the screen, and what's playing
+const pagerState = () => page.evaluate(() => {
+ const p = document.querySelector('.media-pager');
+ if (!p) return null;
+ const sc = p.querySelector('.mp-scroll');
+ const slides = [...sc.querySelectorAll('.mp-slide')]; // (not the loading screen at the end)
+ const i = slides.findIndex((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); // the slide on screen
+ const vids = slides.map((s) => s.querySelector('video'));
+ return { n: slides.length, i, src: (slides[i]?.querySelector('.mp-media')?.getAttribute('data-src') || '').split('/').pop(),
+ playing: vids.map((v) => !!(v && !v.paused)), show: p.classList.contains('show'),
+ meta: getComputedStyle(slides[i].querySelector('.mp-meta')).opacity };
+});
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(base, { 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 key = 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: 'Shutterbug', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [key, PK, notes]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => [...document.querySelectorAll('.app-nav-button, button')].find((b) => b.textContent.trim() === 'Feed')?.click());
+ await page.waitForSelector('.notes-feed:not([data-booting])', { timeout: 20000 });
+
+
+ await page.waitForSelector('.note-audio audio');
+ await page.evaluate(() => document.querySelector('.note-audio audio').play());
+ await page.waitForSelector('.note-audio .media-timeline');
+ await page.waitForFunction(() => document.querySelectorAll('.note-audio .media-wave i').length === 6);
+ check('published audio waveform renders in the actual feed', true);
+ await page.evaluate(() => { const a = document.querySelector('.note-audio audio'); a.pause(); a.currentTime = 5; });
+ await sleep(100);
+ // the post's own Like carries where the clip is: no buttons of its own for that
+ const act = (sel, label) => page.evaluate(([sel, label]) => document.querySelector(sel).closest('[data-zap-post]').querySelector('.note-acts [aria-label="' + label + '"]').click(), [sel, label]);
+ check('no "at this time" buttons', await page.evaluate(() => !document.body.innerText.includes('at this time')));
+ await act('.note-audio', 'Like');
+ await page.waitForSelector('.msg-sheet');
+ await page.evaluate(() => document.querySelector('.msg-sheet button').click());
+ await sleep(400);
+ const reaction = await page.evaluate(() => window.__relayGuard.events.find(e => e.kind === 7 && e.tags.some(t => t[0] === 'media')));
+ check('reaction is signed with the selected file and position', reaction?.tags.some(t => t[0] === 'media' && t[2] === '5'));
+ check('reaction marker appears while paused', await page.evaluate(() => !!document.querySelector('.note-audio .media-mark')));
+ check('...on the bar at its moment', await page.evaluate(() => { const m = document.querySelector('.note-audio .media-mark'); return Math.abs(parseFloat(m.style.left) - 5 / document.querySelector('.note-audio audio').duration * 100) < 0.5; }));
+ await page.screenshot({ path: '/tmp/media-timeline-playing.png' });
+ await act('.note-audio', 'Reply');
+ await page.waitForSelector('.thread-reply-input');
+ check('timestamp is visible in the reply composer', (await page.evaluate(() => document.body.innerText)).includes('Comment at 0:05'));
+ await page.evaluate(() => { const input = document.querySelector('.thread-reply-input'); input.value = 'Great moment'; input.dispatchEvent(new Event('input', { bubbles: true })); });
+ await page.click('.thread-reply-send');
+ await sleep(500);
+ const comment = await page.evaluate(() => window.__relayGuard.events.find(e => e.kind === 1 && e.content.includes('Great moment')));
+ check('comment includes a readable timestamp and signed media context', comment?.content === '[0:05] Great moment' && comment.tags.some(t => t[0] === 'media' && t[2] === '5'));
+ check('comment shows a jump-to-time link', await page.evaluate(() => !!document.querySelector('.media-time-link')));
+ await page.evaluate(() => document.querySelector('.media-time-link').click());
+ await page.waitForFunction(() => document.querySelector('.note-audio audio')?.currentTime >= 5);
+ check('the comment link starts playback at its timestamp', true);
+ await click('back'); await sleep(250);
+ // a clip in the pager: its ♥ carries the moment, which then sits on its seek bar
+ await page.evaluate(() => { const v = document.querySelector('video.note-video'); const r = v.getBoundingClientRect();
+ v.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: r.left + r.width / 2, clientY: r.top + r.height / 3 })); });
+ await page.waitForSelector('.media-pager .mp-cur video', { timeout: 8000 });
+ await page.waitForFunction(() => document.querySelector('.media-pager .mp-cur video').duration > 0, { timeout: 8000 });
+ await page.evaluate(() => { const v = document.querySelector('.media-pager .mp-cur video'); v.pause(); v.currentTime = 1; });
+ await sleep(150);
+ await page.evaluate(() => document.querySelector('.media-pager .mp-cur .mp-like').click());
+ await page.waitForSelector('.msg-sheet');
+ await page.evaluate(() => document.querySelector('.msg-sheet button').click());
+ await sleep(300);
+ check('video reaction carries the video URL and selected second', await page.evaluate(() => window.__relayGuard.events.some(e => e.kind === 7 && e.tags.some(t => t[0] === 'media' && t[1].endsWith('.mp4') && t[2] === '1'))));
+ 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(); }
+process.exit(ok ? 0 : 1);
diff --git a/tools/media-timeline-test.js b/tools/media-timeline-test.js
new file mode 100644
index 0000000..3b812ff
--- /dev/null
+++ b/tools/media-timeline-test.js
@@ -0,0 +1,80 @@
+import assert from 'node:assert/strict';
+import { mediaMoment, momentTags, readMoment, waveformFromTags, waveformPeaks } from '../src/media-timeline.js';
+import { buildZapRequest } from '../src/lnurl.js';
+import puppeteer from './puppeteer.js';
+const url = 'https://example.com/song.mp3';
+const moment = mediaMoment(url, 65.9);
+assert.deepEqual(moment, { url, seconds: 65 });
+for (const value of [-1, Infinity, NaN]) assert.equal(mediaMoment(url, value), null);
+assert.equal(mediaMoment('javascript:alert(1)', 0), null);
+assert.deepEqual(readMoment({ tags: momentTags(moment) }), moment);
+assert.equal(readMoment({ tags: [['media', url, '']] }), null);
+const request = buildZapRequest({ amountMsat: 21000, relays: [], recipientPk: 'p', eventId: 'e', moment, signFn: e => e });
+assert.equal(request.tags.find(t => t[0] === 'e')[1], 'e');
+assert.deepEqual(readMoment({ kind: 9735, tags: [['description', JSON.stringify(request)]] }), moment);
+assert.equal(readMoment(buildZapRequest({ amountMsat: 21000, relays: [], recipientPk: 'p', eventId: 'e', signFn: e => e })), null);
+assert.deepEqual(waveformFromTags({ tags: [['imeta', 'url ' + url, 'waveform 1 5 2']] }, url), [1, 5, 2]);
+assert.equal(waveformFromTags({ tags: [['imeta', 'url ' + url, 'waveform -1 2']] }, url), null);
+assert.deepEqual(waveformPeaks({ numberOfChannels: 2, getChannelData: n => n ? [0, 0, .8, 0] : [.2, -.5, .1, 0] }, 2), [.5, .8]);
+const wav = new Uint8Array(44 + 16000);
+const dv = new DataView(wav.buffer);
+const word = (at, text) => wav.set(new TextEncoder().encode(text), at);
+word(0, 'RIFF'); dv.setUint32(4, wav.length - 8, true); word(8, 'WAVE'); word(12, 'fmt ');
+dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true); dv.setUint32(24, 8000, true); dv.setUint32(28, 16000, true); dv.setUint16(32, 2, true); dv.setUint16(34, 16, true); word(36, 'data'); dv.setUint32(40, 16000, true);
+for (let i = 0; i < 8000; i++) dv.setInt16(44 + i * 2, Math.sin(i * .3) * 20000, true);
+const server = Bun.serve({ port: 0, fetch(req) {
+ const path = new URL(req.url).pathname;
+ if (path === '/wave.wav') return new Response(wav, { headers: { 'content-type': 'audio/wav' } });
+ if (path === '/large.wav') return new Response('', { headers: { 'content-length': String(21 * 1024 * 1024) } });
+ if (path === '/module.js') return new Response(Bun.file('src/media-timeline.js'), { headers: { 'content-type': 'text/javascript' } });
+ if (path === '/style.css') return new Response(Bun.file('src/style.css'));
+ return new Response('<!doctype html><link rel="stylesheet" href="/style.css"><body>', { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage();
+ await page.goto(server.url.href);
+ const decoded = await page.evaluate(async () => {
+ const { loadWaveform } = await import('/module.js');
+ const peaks = await loadWaveform('/wave.wav');
+ return { count: peaks?.length, nonzero: peaks?.every(x => x > .5), fallback: await loadWaveform('/large.wav') };
+ });
+ assert.equal(decoded.count, 96); assert.equal(decoded.nonzero, true); assert.equal(decoded.fallback, null);
+ const result = await page.evaluate(async () => {
+ const { mediaTimeline } = await import('/module.js');
+ const h = (tag, attrs = {}, ...children) => {
+ const el = document.createElement(tag);
+ for (const [k, v] of Object.entries(attrs)) { if (k.startsWith('on')) el.addEventListener(k.slice(2).toLowerCase(), v); else if (v === true) el.setAttribute(k, ''); else if (v !== false) el.setAttribute(k, v); }
+ el.append(...children); return el;
+ };
+ const player = h('audio');
+ Object.defineProperty(player, 'duration', { value: 120 });
+ let at = 0;
+ Object.defineProperty(player, 'currentTime', { get: () => at, set: x => { at = x; } });
+ const url = 'https://example.com/song.mp3';
+ const ev = { id: 'post', tags: [['imeta', 'url ' + url, 'waveform 1 10 4 8']] };
+ const calls = [], items = [{ id: 'a', moment: { seconds: 30 }, icon: '♡', text: 'Nice!' }];
+ const box = mediaTimeline(player, url, { h, event: () => ev, moments: () => items, glyph: x => x.icon, label: x => x });
+ document.body.append(player, box);
+ player.dispatchEvent(new Event('timeupdate'));
+ const wave = box.querySelectorAll('.media-wave i').length;
+ const range = box.querySelector('input'); range.value = 500; range.dispatchEvent(new Event('input'));
+ const seek = at;
+ box.querySelector('.media-mark').click();
+ const marker = at;
+ items.push({ id: 'b', moment: { seconds: 70 }, icon: '⚡', text: '' });
+ player.dispatchEvent(new Event('mediaactivity'));
+ window.fixture = box;
+ return { wave, seek, marker, calls, markers: box.querySelectorAll('.media-mark').length, left: [...box.querySelectorAll('.media-mark')].map(m => m.style.left), buttons: box.querySelectorAll('button:not(.media-mark)').length, aria: range.getAttribute('aria-valuetext') };
+ });
+ assert.equal(result.wave, 4);
+ assert.equal(result.seek, 60);
+ assert.equal(result.marker, 30);
+ assert.equal(result.markers, 2);
+ assert.deepEqual(result.left, ['25%', '58.3333%']); // each on the bar at its moment
+ assert.equal(result.buttons, 0); // no "at this time" buttons
+ await page.setViewport({ width: 360, height: 740 });
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
+ await page.screenshot({ path: '/tmp/media-timeline-mobile.png' });
+ console.log('✓ Timestamp validation, Lightning receipt round-trip, untimed zap isolation, waveform peaks, browser seeking, markers placed at their moments and mobile layout');
+} finally { await browser.close(); server.stop(); }
diff --git a/tools/media-upload-identity-test.js b/tools/media-upload-identity-test.js
new file mode 100644
index 0000000..6d7e023
--- /dev/null
+++ b/tools/media-upload-identity-test.js
@@ -0,0 +1,63 @@
+import assert from 'node:assert/strict';
+import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from 'nostr-tools/pure';
+import { mediaUploadSigner, uploadPublicMedia, uploadStream, MediaUploadError } from '../src/media-upload.js';
+
+const walletKey = generateSecretKey(), loginKey = generateSecretKey();
+let walletSigns = 0, loginSigns = 0, resumes = 0;
+const wallet = { nostrSign: event => { walletSigns++; return finalizeEvent(event, walletKey); } };
+const signer = { signEvent(event) { loginSigns++; return finalizeEvent(event, loginKey); } };
+const login = {pubkey: getPublicKey(loginKey), signer};
+let id = login, resumed = signer;
+const hook = name => name === 'nostrLoginIdentity' ? id : (resumes++, resumed);
+const file = new File(['image'], 'test.png', {type: 'image/png'});
+const seen = [];
+// Haven's owner/allowlist rule: only this login key can upload.
+const fetcher = async (url, init) => {
+ const event = JSON.parse(atob(init.headers.authorization.slice(6)));
+ assert(verifyEvent(event));
+ seen.push(event.pubkey);
+ if (event.pubkey !== login.pubkey) return new Response('', {status:403, headers:{'x-reason':'only media signed by whitelisted pubkeys are allowed'}});
+ return Response.json({url:'https://haven.example/' + event.tags.find(t => t[0] === 'x')[1]});
+};
+const options = {servers:['https://haven.example'], fetcher};
+await assert.rejects(uploadPublicMedia(file, event => wallet.nostrSign(event), options), e => e instanceof MediaUploadError && e.code === 'unauthorized');
+const walletBefore = walletSigns;
+await uploadPublicMedia(file, await mediaUploadSigner(wallet, hook), options);
+assert.equal(walletSigns, walletBefore);
+assert.equal(loginSigns, 1);
+assert.equal(resumes, 0);
+id = {...login, signer:null};
+await uploadPublicMedia(file, await mediaUploadSigner(wallet, hook), options);
+assert.equal(resumes, 1);
+resumed = null;
+await assert.rejects(mediaUploadSigner(wallet, hook), e => e.code === 'no-identity');
+assert.equal(walletSigns, walletBefore, 'an unavailable login signer must never fall back to the wallet');
+id = null;
+const nativeEvent = await (await mediaUploadSigner(wallet, hook))({kind:24242, created_at:1, tags:[], content:''});
+assert.equal(nativeEvent.pubkey, getPublicKey(walletKey));
+id = login;
+await uploadStream({master:'master.m3u8', files:new Map([
+ ['clip.mp4', new File(['clip'], 'clip.mp4', {type:'video/mp4'})],
+ ['master.m3u8', new File(['#EXTM3U\nclip.mp4'], 'master.m3u8', {type:'application/vnd.apple.mpegurl'})],
+])}, await mediaUploadSigner(wallet, hook), options);
+assert(seen.slice(1).every(pk => pk === login.pubkey));
+
+// Exercise the app's actual routing for both regular files and HLS videos.
+const app = await Bun.file('src/app.js').text();
+const start = app.indexOf('async function publicMediaUpload(file) {');
+const end = app.indexOf('// Whether the wizard', start);
+const body = app.slice(start, end).replace("await import('./video-compress.js')", 'video');
+const stream = {dim:'10x10',seconds:2};
+const signs = [];
+const ui = {};
+const upload = new Function('wallet','featureHook','mediaUploadSigner','uploadPublicMedia','uploadStream','MediaUploadError','mediaProgress','ui','t','video', body + '\nreturn publicMediaUpload;')(
+ wallet, hook, mediaUploadSigner,
+ async (file, sign) => { signs.push(await sign({kind:24242, created_at:1, tags:[], content:''})); return 'photo'; },
+ async (stream, sign) => { signs.push(await sign({kind:24242, created_at:1, tags:[], content:''})); return 'video'; },
+ MediaUploadError, () => {}, ui, key => key,
+ {streamVideo: async () => stream});
+assert.equal(await upload(file), 'photo');
+assert.equal((await upload(new File(['video'], 'clip.mp4', {type:'video/mp4'}))).url, 'video');
+assert(signs.every(e => e.pubkey === login.pubkey));
+assert.equal(ui.mediaProgress, null);
+console.log('✓ Haven-style allowlist: login identity, signer resume, no wallet fallback, native identity and app file/HLS routing');
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-invoice-expiry-test.js b/tools/names-invoice-expiry-test.js
new file mode 100644
index 0000000..511d212
--- /dev/null
+++ b/tools/names-invoice-expiry-test.js
@@ -0,0 +1,70 @@
+// Receive → More → Lightning invoice: the invoice says to keep coinos open
+// until it's paid (the server only finishes a receive while the wallet is
+// there to take it) and gives way to "expired" when its hour is up.
+// The Ark side is faked. Run: bun tools/names-invoice-expiry-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';
+${domHelper}
+const state = { names: { byDomain: { 'coinos.io': { name: 'alice', domain: 'coinos.io' } } } };
+const ui = { screen: 'wallet', namesMore: 'bolt11' };
+const wallet = { loadFeatureState: (k, d) => (k in state ? state[k] : d), saveFeatureState: (k, v) => { state[k] = v; }, nostrSign: async (e) => e, nostrPubkey: () => 'aa'.repeat(32), _cacheKey: () => 'w1' };
+const ctx = {
+ h, ui, wallet, render, toast: () => {}, copy: () => {}, copyBtn: (x, l) => h('button', { class: 'btn-sm' }, l), brandHeader: () => null, goBack: () => {}, setAccount: () => {},
+ hook(name, ...a) {
+ if (name === 'arkReady') return true;
+ if (name === 'arkMakeInvoice') return Promise.resolve({ invoice: 'lnbc10u1testinvoice', amountSat: a[0], expiresAt: Date.now() + (window.__life || 3600_000) });
+ return null;
+ },
+};
+const feature = namesFeature(ctx);
+function render() { document.querySelector('#app').replaceChildren(feature.receiveModes()[0].render(h('div', { class: 'seg' }))); }
+window.test = { render };
+render();
+`;
+const bundle = await Bun.build({
+ entrypoints: ['names-inv-entry'], target: 'browser',
+ plugins: [{ name: 'names-inv', setup(build) {
+ build.onResolve({ filter: /^names-inv-entry$/ }, () => ({ path: 'entry', namespace: 'names-inv' }));
+ build.onLoad({ filter: /.*/, namespace: 'names-inv' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() }));
+ } }],
+});
+assert(bundle.success, bundle.logs.join('\n'));
+const html = `<!doctype html><meta charset="utf-8"><style>${await Bun.file('src/style.css').text()}</style><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));
+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 make = async () => {
+ await page.evaluate(() => { const i = document.querySelector('input[type=number]'); i.value = '1000'; i.dispatchEvent(new Event('input', { bubbles: true })); });
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /create/i.test(b.textContent)).click());
+ await pause(100);
+};
+try {
+ await page.goto(server.url.href);
+ await page.waitForSelector('input[type=number]');
+ await make();
+ check('the invoice shows', /lnbc10u1testinvoice/.test(await text()));
+ check('...saying to keep coinos open until it\'s paid, and that it lasts an hour', /Keep coinos open until this is paid/.test(await text()) && /goes back to the sender/.test(await text()) && /expires in an hour/.test(await text()));
+ await page.evaluate(() => { window.__life = 1500; document.querySelector('.btn-sm:last-child').click(); });
+ await pause(50);
+ await page.evaluate(() => test.render());
+ await make();
+ check('a fresh one shows its QR', !!(await page.$('svg')) && !/expired/.test(await text()));
+ await pause(2300);
+ check('when its time is up it says expired, the QR gone', /This invoice has expired/.test(await text()) && !(await page.$('svg')) && !/lnbc10u1testinvoice/.test(await text()));
+ check('...with a button for a new one', await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'New invoice')));
+ check('no page errors', !errors.length, errors.join(' | '));
+ console.log('\n✅ invoice: keep open, expires');
+} finally {
+ await browser.close();
+ server.stop(true);
+}
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/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 b45b120..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,14 +65,16 @@ 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));
// a cached notifications list: five people reacted to my post
const items = ACTORS.map((a, i) => ({ id: 'r'.repeat(63) + i, what: 'react', actor: a.pk, target: 't'.repeat(64), hint: [], emoji: '❤️', ts: now - 60 * (i + 1) }));
+ // and one zapped my profile: no post to open
+ items.unshift({ id: 'z'.repeat(64), what: 'zap', actor: ACTORS[1].pk, target: null, hint: [], sats: 421, text: 'thanks!', ts: now - 30 });
await page.evaluate(([k, me, items]) => {
const st = JSON.parse(localStorage.getItem(k + ':messages') || '{}');
st.notifs = items; st.notifsPk = me; st.notifsV = 2;
@@ -100,6 +102,10 @@ try {
await sleep(150); // the first paint, not a settled one
const faces = await page.evaluate(() => [...document.querySelectorAll('.notes-feed .chat-avatar')].map((a) => a.classList.contains('ava-img') || (a.style.backgroundImage || '').includes('url')));
check('every row paints with a face on the first frame', faces.length >= 5 && faces.every(Boolean), JSON.stringify(faces));
+ // a zap on my profile (no post): the tap opens the zapper's profile — it did nothing
+ await page.evaluate(() => [...document.querySelectorAll('.alert-row')].find((r) => r.textContent.includes('thanks!')).querySelector('.grow').click());
+ await sleep(500);
+ check('tapping a profile zap opens the zapper\'s profile', await page.evaluate((pk) => !document.querySelector('.alert-row') && /npub1|nprofile1/.test(location.pathname) && document.body.innerText.includes('Actor 1'), ACTORS[1].pk), await page.evaluate(() => location.pathname));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ notifications open with their faces on' : '\n❌ failed');
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 e3bdb7b..7e85ee1 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,11 +47,16 @@ 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 }) };
}
@@ -72,7 +78,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 +124,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 +194,150 @@ 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]');
+{
+ // a payment that goes through: the worker hands the push handler a
+ // "Payment sent" heads-up (unless the user turned those off)
+ const { ArkManager } = await import('../src/ark/manager.js');
+ const orig = { init: ArkManager.prototype.init, pay: ArkManager.prototype.payLnInvoice, wait: ArkManager.prototype.awaitLnPay, act: ArkManager.prototype.lnAction };
+ const done = { id: 'ln-1', step: 'done', amountSat: 21, feeSat: 0, preimage: 'ab'.repeat(32) };
+ ArkManager.prototype.init = async function () { this.state = this.storage.load(); return this; };
+ ArkManager.prototype.payLnInvoice = async () => 'ln-1';
+ ArkManager.prototype.awaitLnPay = async () => done;
+ ArkManager.prototype.lnAction = () => done;
+ try {
+ const { deps, published, decryptReply } = harness();
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ check('a paid invoice answers with the preimage', published.length === 1 && decryptReply(published[0]).result?.preimage === done.preimage, JSON.stringify(handled));
+ check('…and asks for a "Payment sent" notification', handled && handled.notify && handled.notify.title === 'Payment sent', JSON.stringify(handled));
+ const rec = baseRec(); rec.prefs = { paySent: false };
+ const h2 = harness({ rec });
+ const quiet = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, h2.deps);
+ check('payments-sent notifications off → paid quietly', quiet === true && h2.published.length === 1);
+ } finally {
+ Object.assign(ArkManager.prototype, { init: orig.init, payLnInvoice: orig.pay, awaitLnPay: orig.wait, lnAction: orig.act });
+ }
+}
+
+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]');
@@ -332,16 +494,17 @@ console.log('\n[tending renewals while closed]');
if (ran) (this.state.actions ||= []).push({ id: 'r1', type: 'refresh', step: 'submitted', unlockHash: ran.unlockHash });
};
ArkManager.prototype._driveRefresh = async function (a) { calls.push(['claim', a.id, a.step]); a.step = 'done'; };
- ArkManager.prototype.ensureScheduledRefreshes = async function (margin, max) { calls.push(['ensure', margin, max]); return 1; };
+ ArkManager.prototype.ensureScheduledRefreshes = async function (margin, max, grid) { calls.push(['ensure', margin, max, grid]); return 1; };
try {
const rec = baseRec();
rec.key4 = 'ff'.repeat(32);
+ rec.custody = 'self'; // a renewing tier: the worker covers bare coins
rec.mgr.nextKeyIndex = 5;
rec.keys = { 1: 'aa', 5: 'bb', 6: 'cc' }; // two upcoming indices in the window
rec.mgr.scheduled = [{ unlockHash: 'ran', inputIds: ['v1'], outKeyIndex: 2 }, { unlockHash: 'waiting', inputIds: ['v1'], outKeyIndex: 3 }];
const saved = [];
const n = await tendRenewals({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async (k, r) => saved.push(k), log: () => {} });
- check('claims what ran, then covers what is bare: orders limited to the key window', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 2], ['claim', 'r1', 'submitted'], ['ensure', 36, 2]]), JSON.stringify(calls));
+ check('claims what ran, then covers what is bare: orders limited to the key window, snapped to the 48-block grid', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 2], ['claim', 'r1', 'submitted'], ['ensure', 36, 2, 48]]), JSON.stringify(calls));
check('one claim and one order counted, the record saved', n === 2 && saved.includes('w1'));
check('the claimed order is gone from the mirror, its claim done', rec.mgr.scheduled.length === 1 && rec.mgr.scheduled[0].unlockHash === 'waiting' && rec.mgr.actions[0].step === 'done');
// a claim left in flight by an earlier wake (out of time, unconfirmed) is driven on by the next
@@ -351,8 +514,20 @@ console.log('\n[tending renewals while closed]');
const n5 = await tendRenewals({ recordsFn: async () => [{ walletKey: 'w5', rec: rec5 }], saveFn: async () => {} });
check('a renewal claim in flight is driven on; finished ones and other actions are not', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0], ['claim', 'r2', 'issued']]) && n5 === 1, JSON.stringify(calls));
+ // the default tier — coinos holds the coins — registers no order at all,
+ // however many keys the window holds (the claim of an order that already
+ // ran still happens: it was priced and promised when registered)
+ calls.length = 0;
+ const rec6 = baseRec(); rec6.key4 = 'ff'.repeat(32); rec6.mgr.nextKeyIndex = 5; rec6.keys = { 1: 'aa', 5: 'bb', 6: 'cc' };
+ await tendRenewals({ recordsFn: async () => [{ walletKey: 'w6', rec: rec6 }], saveFn: async () => {} });
+ check('custodial (default, or a mirror without the field) → claims only, no orders', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0]]), JSON.stringify(calls));
+ calls.length = 0;
+ const rec7 = baseRec(); rec7.key4 = 'ff'.repeat(32); rec7.custody = 'exit'; rec7.mgr.nextKeyIndex = 5; rec7.keys = { 1: 'aa', 5: 'bb' };
+ await tendRenewals({ recordsFn: async () => [{ walletKey: 'w7', rec: rec7 }], saveFn: async () => {} });
+ check('exit tier → the same backstop as self (depth is the app\'s business)', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0], ['ensure', 36, 1, 48]]), JSON.stringify(calls));
+
calls.length = 0;
- const rec2 = baseRec(); rec2.key4 = 'ff'.repeat(32); rec2.ark.network = 'regtest'; rec2.mgr.nextKeyIndex = 9; rec2.keys = { 1: 'aa' }; // no upcoming key
+ const rec2 = baseRec(); rec2.key4 = 'ff'.repeat(32); rec2.custody = 'self'; rec2.ark.network = 'regtest'; rec2.mgr.nextKeyIndex = 9; rec2.keys = { 1: 'aa' }; // no upcoming key
await tendRenewals({ recordsFn: async () => [{ walletKey: 'w2', rec: rec2 }], saveFn: async () => {} });
check('no key for an order\'s output → claims still, registers nothing (regtest margin would be 6)', JSON.stringify(calls) === JSON.stringify([['init', 'regtest'], ['tip'], ['drive', 0]]), JSON.stringify(calls));
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 15a5076..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';
@@ -88,14 +88,14 @@ async function run(saveData, slow = false) {
const feedRow = (w) => page.evaluate((x) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(x)); r.querySelector('.note-video-box').scrollIntoView({ block: 'center' }); const v = r.querySelector('video.note-video').getBoundingClientRect(); return { cover: !!r.querySelector('.vid-heavy'), x: v.x + v.width * 0.2, y: v.y + v.height * 0.3 }; }, w);
try {
await page.setViewport({ width: 390, height: 844 });
- pace = slow ? 375000 : 0;
+ pace = slow ? 375000 : 12_500_000; // 3 Mbps, or 100 (a loopback's speed is no link: Chrome reads a warm player's whole file before it can stop)
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]) => {
@@ -117,20 +117,25 @@ 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);
const c1 = await slide('c1'), c2 = await slide('c2'), c3 = await slide('c3');
let head1 = 0;
if (!saveData) {
- check('the next clips wait on their pictures, no cover, no player yet', c1 && !c1.src && !c1.cover && c2 && !c2.src, JSON.stringify([c1, c2]));
+ check('the next clip has a warm player: its file, read to its first frame and paused', c1 && c1.src && c1.preload === 'metadata' && !c1.playing && !c1.cover && await page.evaluate(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/c1.webm')); return s.querySelector('video.mp-media').readyState >= 2; }), JSON.stringify(c1));
+ check('…the one after waits on its picture, no player yet', c2 && !c2.src && !c2.cover, JSON.stringify(c2));
const hs = heads('c1'), h2 = heads('c2');
head1 = hs.length ? hs[0].end + 1 : 0;
const head2 = h2.length ? h2[0].end + 1 : 0;
// before any measure of the link: two seconds' worth (the second clip ahead, half)
check('the next clip\'s opening is fetched ahead: two seconds of it', hs.length === 1 && hs[0].start === 0 && near(head1, 2 * RATE), `${kb(head1)} (two seconds: ${kb(2 * RATE)})`);
check('…and half that of the one after', h2.length === 1 && near(head2, RATE), `${kb(head2)}`);
- check('nothing more than that ahead of time', sent('c1') <= head1 && sent('c2') <= head2 && sent('c3') === 0, `c1 ${kb(sent('c1'))}, c2 ${kb(sent('c2'))}, c3 ${kb(sent('c3'))} of ${kb(SIZE)} each`);
+ // the warm player reads on from the cached opening to its first frame and a little past, then stops
+ check('ahead of time: the next clip\'s opening and a few MB more for its warm player, the one after its opening only', sent('c1') <= head1 + 4 * 1024 * 1024 && sent('c2') <= head2 && sent('c3') === 0, `c1 ${kb(sent('c1'))}, c2 ${kb(sent('c2'))}, c3 ${kb(sent('c3'))} of ${kb(SIZE)} each`);
+ check('…the warm player took the opening from the cache, not the host', !log.some((e) => e.clip === 'c1' && e.dest === 'video' && e.start === 0), log.filter((e) => e.clip === 'c1').map((e) => e.dest + ':' + e.start).join(' '));
} else {
check('nothing loads ahead of a clip over budget', c1 && !c1.src && sent('c1') === 0, JSON.stringify(c1) + ' ' + kb(sent('c1')));
}
@@ -139,14 +144,21 @@ async function run(saveData, slow = false) {
const t0 = Date.now();
if (!saveData) {
check('swiped to, the next clip plays at once, no cover', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/c1.webm')); const v = s.querySelector('video.mp-media'); return !s.querySelector('.vid-heavy') && !v.paused && v.currentTime > 0; }, null, 5000) && (Date.now() - t0) < (slow ? 3000 : 1500), (Date.now() - t0) + ' ms');
- const after = log.slice(before).filter((e) => e.clip === 'c1' && e.dest === 'video');
- check('…its player starting where the head start left off (the opening came from the cache)', after.length >= 1 && after[0].start === head1, 'the player asked for ' + (after.map((e) => e.start + '-').join(', ') || 'nothing') + ', head start ' + head1);
+ await waitFor(() => coinosPagerStarts().starts.some((x) => /c1\.webm$/.test(x.url)), null, 3000);
+ const st = await page.evaluate(() => coinosPagerStarts().starts.find((x) => /c1\.webm$/.test(x.url)));
+ check('…from its warm player, the clock says', st && st.warm && st.ms < 1000, JSON.stringify(st));
await sleep(1500);
const h3 = heads('c3'), head3 = h3.length ? h3[0].end + 1 : 0;
check('…the next one gets its head start', h3.length === 1);
// the link measured now: a fast one, still a couple of seconds' worth; one slower than the clip, the cap
if (!slow) check('…a fast link measured: still two seconds\' worth (half, as the second ahead)', near(head3, RATE), kb(head3));
else check('…the link measured slower than the clip: it grows to the cap (half, as the second ahead)', head3 === HEAD_MAX / 2, kb(head3));
+ // the clip just left keeps its player: the swipe back is warm too
+ check('the clip just left keeps its player', !!(await slide('c0'))?.src);
+ await page.keyboard.press('ArrowUp');
+ await waitFor(() => coinosPagerStarts().starts.filter((x) => /c0\.webm$/.test(x.url)).length >= 2, null, 5000);
+ const back = await page.evaluate(() => coinosPagerStarts().starts.filter((x) => /c0\.webm$/.test(x.url)).at(-1));
+ check('…so swiping back to it starts warm', back && back.warm && back.ms < 1000, JSON.stringify(back));
} else {
await sleep(3000);
const s = await slide('c1');
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
new file mode 100644
index 0000000..df20cc7
--- /dev/null
+++ b/tools/pager-prime-test.js
@@ -0,0 +1,133 @@
+// A warm player's sound is started before the swipe lands (an Android
+// phone held a still picture ~250 ms on a player's first play with sound):
+// the next clip plays at a volume nobody hears until its picture moves,
+// and pauses there, a frame or two in — not seeked back. Clips over 25 MB
+// aren't (once played, a player reads the whole file): pager-eager-test.
+// Run: bun tools/pager-prime-test.js
+//
+// (adapted from pager-eager-test:)
+// Flipping through clips in the pager is asking to watch them: a clip plays
+// at once whatever it costs — even one over the size budget, which in the
+// feed waits under a tap-to-play cover — and the next two get a head start:
+// their opening fetched into the cache, no more of a big file than that, and
+// the player takes it from there. How much: a couple of seconds' worth of
+// the clip on a link well ahead of its bitrate (the second clip ahead, half
+// 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, { 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, statSync } 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; };
+
+// small clips with sound (a tone), ~2 MB each: under the 25 MB a warm player is primed up to
+const BIG = '/tmp/coinos-prime-small.webm';
+if (!existsSync(BIG)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=s=180x320:r=15', '-f', 'lavfi', '-i', 'sine=f=440', '-t', '50', '-c:v', 'libvpx', '-b:v', '300k', '-c:a', 'libvorbis', BIG]);
+const SIZE = statSync(BIG).size;
+const FILE = new Uint8Array(await Bun.file(BIG).arrayBuffer());
+const NAMES = ['c0', 'c1', 'c2', 'c3', 'c4'];
+
+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 html = await buildHtml({ minify: true, pwa: false });
+// every request for a clip's bytes: who asked (the player, or a fetch), the
+// range, and the bytes actually sent (pulled by the reader, in 64 KB pieces)
+let log = [];
+let pace = 0; // bytes a second the server sends a clip at (0: as fast as it can) — a slow link
+const server = Bun.serve({ port: 0, idleTimeout: 60, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ const m = /^\/(c\d)\.webm$/.exec(path);
+ if (m) {
+ const headers = { 'content-type': 'video/webm', 'accept-ranges': 'bytes', etag: '"' + m[1] + '-v1"', 'last-modified': 'Mon, 05 Oct 2026 00:00:00 GMT', 'cache-control': 'public, max-age=3600' };
+ if (req.method === 'HEAD') return new Response(null, { headers: { ...headers, 'content-length': String(SIZE) } });
+ const rg = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ const start = rg ? +rg[1] : 0, end = rg && rg[2] ? Math.min(+rg[2], SIZE - 1) : SIZE - 1;
+ const entry = { clip: m[1], dest: req.headers.get('sec-fetch-dest') || '', start, end, sent: 0 };
+ log.push(entry);
+ let at = start;
+ const t0 = Date.now();
+ const body = new ReadableStream({ async pull(ctl) {
+ if (at > end) { ctl.close(); return; }
+ if (pace) { const due = t0 + ((at - start) / pace) * 1000; if (due > Date.now()) await new Promise((r) => setTimeout(r, due - Date.now())); }
+ const n = Math.min(65536, end - at + 1);
+ ctl.enqueue(FILE.subarray(at, at + n)); at += n; entry.sent += n;
+ } }, { highWaterMark: 0 });
+ return new Response(body, { status: rg ? 206 : 200, headers: { ...headers, 'content-length': String(end - start + 1), ...(rg ? { 'content-range': `bytes ${start}-${end}/${SIZE}` } : {}) } });
+ }
+ 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 U = (n) => server.url.origin + '/' + n + '.webm';
+const seeded = [
+ // each says its size and length (as Primal's and others' imeta do): a bitrate of SIZE / 50 bytes a second
+ ...NAMES.map((n, i) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 100, tags: [['imeta', 'url ' + U(n), 'm video/webm', 'size ' + SIZE, 'duration 50', 'dim 180x320']], content: 'clip ' + n + ' here ' + U(n) }, SK)),
+ ...Array.from({ length: 6 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 900 + i * 60)),
+];
+const sent = (clip, dest) => log.filter((e) => e.clip === clip && (!dest || e.dest === dest)).reduce((a, e) => a + e.sent, 0);
+const kb = (n) => Math.round(n / 1024) + ' KB';
+const RATE = SIZE / 50, HEAD_MAX = 4 * 1024 * 1024;
+const heads = (clip) => log.filter((e) => e.clip === clip && e.dest === 'empty');
+const near = (a, b) => Math.abs(a - b) <= b * 0.02 + 1;
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+async function run() {
+ log = [];
+ const ctx = await browser.createBrowserContext();
+ const page = await ctx.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)));
+ 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 vid = (n) => page.evaluate((n) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/' + n + '.webm')); const v = s && s.querySelector('video.mp-media'); return v ? { src: !!v.getAttribute('src'), paused: v.paused, t: v.currentTime, volume: v.volume, muted: v.muted } : null; }, n);
+ try {
+ await page.setViewport({ width: 390, height: 844 });
+ pace = 12_500_000;
+ 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: 'Prime Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ 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')));
+ const c1 = await vid('c1');
+ check('...its volume back to full, and quiet until it\'s on screen', c1.volume === 1 && c1.muted, JSON.stringify(c1));
+ await page.keyboard.press('ArrowDown');
+ await waitFor(() => coinosPagerStarts().starts.some((x) => /c1\.webm$/.test(x.url)), null, 5000);
+ const st = await page.evaluate(() => coinosPagerStarts().starts.find((x) => /c1\.webm$/.test(x.url)));
+ check('swiped to, it plays with sound, primed', st && st.primed && st.warm && st.ms < 1000 && await page.evaluate(() => { const v = document.querySelector('.mp-cur video.mp-media'); return !v.muted && v.volume === 1 && !v.paused; }), JSON.stringify(st));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+ } finally { await ctx.close(); }
+}
+try { await run(); } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ a warm player has its sound going before the swipe lands' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
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 64ff7b5..59a0353 100644
--- a/tools/pager-seek-hide-test.js
+++ b/tools/pager-seek-hide-test.js
@@ -2,10 +2,13 @@
// while the pager is in motion — a glide to the next clip, a finger
// dragging the slides, the snap back — no seek bar shows at all (it rode
// along with the slide, a white line sliding up the screen), and it fades
-// back in once the clip has landed. The position counter is the other way
+// back in once the clip has landed. And at rest it's out of sight too while
+// the clip plays (Adam, 2026-10-19): a tap near the foot of the screen brings
+// it up for a few seconds without pausing, and a paused clip shows it. The
+// 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';
@@ -67,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]) => {
@@ -85,13 +88,34 @@ 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));
- console.log('\n[at rest: the clip on screen has its seek bar]');
+ console.log('\n[at rest: the seek bar comes when asked for]');
check('the clip on screen plays', await waitFor(() => [...document.querySelectorAll('.mp-slide.mp-cur video.mp-media')].some((v) => !v.paused), null, 8000));
- check('…and its seek bar shows', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return !!b && b.classList.contains('ready') && getComputedStyle(b).opacity === '1'; }, null, 6000), JSON.stringify(await bars()));
+ await waitFor(() => !!document.querySelector('.mp-slide.mp-cur .mp-seek.ready'), null, 6000);
+ await sleep(400);
+ const curBar = () => page.evaluate(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); const v = document.querySelector('.mp-slide.mp-cur video.mp-media'); return { opacity: getComputedStyle(b).opacity, pe: getComputedStyle(b).pointerEvents, playing: !v.paused }; });
+ check('…its seek bar out of sight (and out of reach) while it plays', (await curBar()).opacity === '0' && (await curBar()).pe === 'none', JSON.stringify(await curBar()));
+ await page.mouse.click(195, 844 - 70); // near the foot, where the bar sits
+ check('a tap near the foot brings it up, the clip playing on', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return getComputedStyle(b).opacity === '1'; }, null, 2000) && (await curBar()).playing, JSON.stringify(await curBar()));
check('…no other', (await seenBars()).length === 1, (await seenBars()).join(' '));
+ check('…and it goes again a few seconds later', await waitFor(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur .mp-seek')).opacity === '0', null, 6000), JSON.stringify(await curBar()));
+ await page.mouse.click(195, 380);
+ check('a tap mid-screen pauses the clip, and the bar is up', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return getComputedStyle(b).opacity === '1'; }, null, 2000) && !(await curBar()).playing, JSON.stringify(await curBar()));
+ await page.mouse.click(195, 380);
+ // (the test's mouse moved to click, which a desktop answers with the bar for a moment)
+ check('…another plays it on, the bar gone', await waitFor(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur .mp-seek')).opacity === '0', null, 3500) && (await curBar()).playing, JSON.stringify(await curBar()));
+ // a mouse (a desktop) brings it up by moving, and it fades once the mouse is still
+ await page.waitForFunction(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur .mp-seek')).opacity === '0', { timeout: 6000 }).catch(() => {});
+ for (let i = 0; i < 5; i++) { await page.mouse.move(150 + i * 20, 300 + i * 10); await sleep(60); }
+ check('a mouse moving over the clip brings the bar up, the clip playing on', await waitFor(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur .mp-seek')).opacity === '1', null, 1500) && (await curBar()).playing, JSON.stringify(await curBar()));
+ const stillAt = Date.now();
+ check('…and it fades once the mouse is still', await waitFor(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur .mp-seek')).opacity === '0', null, 4000), JSON.stringify(await curBar()));
+ check('…a couple of seconds later, not the tap\'s longer wait', Date.now() - stillAt < 3200, (Date.now() - stillAt) + ' ms');
+ await page.mouse.click(195, 844 - 70); // up again, for the glide
check('the pager is not marked moving', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('moving')));
check('the position counter has faded out', await waitFor(() => +getComputedStyle(document.querySelector('.mp-position')).opacity < 0.01, null, 4000), String(await counterOpacity()));
@@ -106,11 +130,13 @@ try {
check('…and not one seek bar to be seen', between.every((f) => f.seen === 0), between.map((f) => f.seen).join(''));
const landedOn = await waitFor(() => { const s = document.querySelector('.mp-slide.mp-cur'); return !!s && s.getAttribute('data-k').endsWith('/tall.webm'); }, null, 4000);
check('it lands on the next clip', landedOn);
- check('…whose seek bar fades back in', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return !!b && getComputedStyle(b).opacity === '1'; }, null, 6000), JSON.stringify(await bars()));
+ await sleep(600);
+ check('…whose seek bar stays out of sight until asked for', await page.evaluate(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return !!b && getComputedStyle(b).opacity === '0'; }), JSON.stringify(await bars()));
check('…the one before, now off screen, shows none', (await bars()).filter((x) => x.clip === 'wide.webm').every((x) => !x.seen), JSON.stringify(await bars()));
check('the pager is not marked moving once landed', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('moving')));
console.log('\n[a finger holding the slides off their mark: none either]');
+ await page.mouse.click(195, 844 - 70); await sleep(300); // the bar up first
await page.touchscreen.touchStart(195, 600);
await page.touchscreen.touchMove(195, 560); await sleep(30);
await page.touchscreen.touchMove(195, 500); await sleep(250);
@@ -119,7 +145,7 @@ try {
check('…and the position counter is back', (await counterOpacity()) === 1, String(await counterOpacity()));
await page.touchscreen.touchMove(195, 590); await sleep(30);
await page.touchscreen.touchEnd();
- check('let go, it snaps back and the bar returns', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); const el = document.querySelector('.media-pager'); return !!b && getComputedStyle(b).opacity === '1' && !el.classList.contains('moving') && document.querySelector('.mp-slide.mp-cur').getAttribute('data-k').endsWith('/tall.webm'); }, null, 4000), JSON.stringify(await bars()));
+ check('let go, it snaps back', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); const el = document.querySelector('.media-pager'); return !!b && !el.classList.contains('moving') && document.querySelector('.mp-slide.mp-cur').getAttribute('data-k').endsWith('/tall.webm'); }, null, 4000), JSON.stringify(await bars()));
await sleep(500);
check('a moment after, the counter still shows', (await counterOpacity()) > 0.99, String(await counterOpacity()));
check('…then fades out', await waitFor(() => +getComputedStyle(document.querySelector('.mp-position')).opacity < 0.01, null, 4000), String(await counterOpacity()));
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/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
new file mode 100644
index 0000000..becf375
--- /dev/null
+++ b/tools/profile-publish-race-test.js
@@ -0,0 +1,111 @@
+// Two kind-0 publishes at once used to clobber each other: both read the
+// same base off the relays, the later one landed without the earlier one's
+// fields. The migration did exactly that — the rename repointed lud16/nip05
+// to the new name while the legacy-avatar import republished the old base
+// nine seconds later, so a user's published lightning address was the
+// placeholder name their rename had just released (Jarryd, 2026-10-24).
+// Now kind-0 publishes go one at a time and merge over what was last sent.
+// Run: bun tools/profile-publish-race-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, 160) : ''}`);
+ 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; },
+};
+
+// One in-memory relay behind every socket: EVENT stores, REQ answers with
+// what matches (newest first) and EOSE. Enough for a kind-0 round trip.
+const store = [];
+const published = [];
+class FakeSocket {
+ constructor(url) {
+ this.url = 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') { store.push(m[1]); published.push(m[1]); this.reply(['OK', m[1].id, true, '']); }
+ if (m[0] === 'REQ') {
+ const [, sub, ...filters] = m;
+ const hit = store.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; } };
+// the legacy account the migration brings along
+globalThis.fetch = async (url) => {
+ if (/coinos\.io\/api\/users\//.test(String(url))) return { ok: true, json: async () => ({ profile: 'face', banner: 'cover' }) };
+ throw new Error('offline');
+};
+
+const { messagesFeature } = await import('../src/features/messages.js');
+
+const sk = generateSecretKey();
+const pk = getPublicKey(sk);
+const signer = { pubkey: pk, signEvent: async (e) => finalizeEvent(e, sk) };
+let now = Math.floor(Date.now() / 1000) - 100;
+const seed = finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Jarryd', lud16: '[email protected]', nip05: '[email protected]' }) }, sk);
+store.push(seed);
+
+const walletSk = generateSecretKey();
+const wallet = {
+ mnemonic: 'x '.repeat(11) + 'y', utxos: [], txs: [],
+ nostr: { sk: walletSk, pk: getPublicKey(walletSk), ck: new Uint8Array(32) },
+ 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 } : name === 'namesAddress' ? namesAddr : null),
+});
+
+console.log('[the migration: rename and avatar import fired together]');
+await Promise.all([
+ feature.addressRenamed('[email protected]', '[email protected]'),
+ feature.adoptLegacyProfile('jarryd'),
+]);
+const newest = store.filter((e) => e.kind === 0 && e.pubkey === pk).sort((a, b) => b.created_at - a.created_at)[0];
+const final = JSON.parse(newest.content);
+const sent = [...new Map(published.filter((e) => e.kind === 0).map((e) => [e.id, e])).values()];
+check('two kind 0s went out, one after the other', sent.length === 2 && sent[1].created_at > sent[0].created_at, sent.map((e) => e.created_at + ' ' + JSON.parse(e.content).lud16 + ' ' + (JSON.parse(e.content).picture ? 'face' : '-')).join(' → '));
+check('the newest kind 0 carries the new address', final.lud16 === '[email protected]' && final.nip05 === '[email protected]', JSON.stringify(final));
+check('...and the legacy face and cover', /face\.webp$/.test(final.picture || '') && /cover\.webp$/.test(final.banner || ''), final.picture + ' ' + final.banner);
+check('...and the name it had', final.name === 'Jarryd');
+
+console.log('[a second rename right behind, before the relays answer]');
+published.length = 0;
+await Promise.all([feature.addressRenamed('[email protected]', '[email protected]'), feature.publishProfile({ about: 'hi' })]);
+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/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/push-resubscribe-test.js b/tools/push-resubscribe-test.js
new file mode 100644
index 0000000..19998b2
--- /dev/null
+++ b/tools/push-resubscribe-test.js
@@ -0,0 +1,103 @@
+// iOS drops a web push subscription after three pushes that showed no
+// notification, and nothing used to make a new one: the wallet's background
+// switch stayed on, getSubscription() came back empty, and the notifier kept
+// a dead endpoint, so NWC requests stopped waking the closed app until the
+// user toggled it off and on (reported by PullCap). Opening the app, or
+// coming back to it, must subscribe again and tell the notifier.
+//
+// Run: bun tools/push-resubscribe-test.js
+import puppeteer from './puppeteer.js';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { cacheKeyFor } from '../src/wallet.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 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();
+const KEY = 'BP' + 'A'.repeat(85); // any 65-byte key, base64url
+await page.evaluateOnNewDocument((KEY) => {
+ const bytes = () => Uint8Array.from(atob(KEY.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
+ window.__subs = 0; window.__regs = [];
+ let sub = null; // iOS has already dropped it
+ const make = () => ({
+ endpoint: 'https://web.push.apple.com/sub' + window.__subs,
+ options: { applicationServerKey: bytes().buffer },
+ toJSON() { return { endpoint: this.endpoint, keys: { p256dh: 'x', auth: 'y' } }; },
+ unsubscribe: () => { sub = null; return Promise.resolve(true); },
+ });
+ window.__drop = () => { sub = null; };
+ const reg = {
+ pushManager: {
+ getSubscription: () => Promise.resolve(sub),
+ subscribe: () => { window.__subs++; sub = make(); return Promise.resolve(sub); },
+ },
+ showNotification: () => Promise.resolve(),
+ };
+ Object.defineProperty(navigator.serviceWorker, 'ready', { get: () => Promise.resolve(reg) });
+ Notification.requestPermission = () => Promise.resolve('granted');
+ Object.defineProperty(Notification, 'permission', { get: () => 'granted' });
+ const f = window.fetch;
+ window.fetch = async (u, o) => {
+ const url = String(u);
+ if (url.startsWith('https://nwcpush.coinos.io/vapid')) return new Response(JSON.stringify({ publicKey: KEY }));
+ if (url.startsWith('https://nwcpush.coinos.io/register')) {
+ const b = JSON.parse(o.body);
+ if (b.servicePubkeys) window.__regs.push(b.subscription.endpoint);
+ return new Response('{"ok":true}');
+ }
+ return f(u, o);
+ };
+}, KEY);
+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:5263/', { 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(500);
+ await click('to import'); await sleep(400);
+ await page.waitForSelector('textarea');
+ const mn = generateMnemonic(wordlist);
+ await page.type('textarea', mn);
+ await click('open wallet');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+
+ // a wallet answering in the background, whose subscription iOS took away
+ await page.evaluate((k) => localStorage.setItem(k + ':nwc', JSON.stringify({
+ conns: [{ id: 'x', name: 'test', servicePk: 'a'.repeat(64), sk: 'b'.repeat(64), maxSat: 1000, budget: {} }],
+ background: true,
+ })), base);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await sleep(3000);
+ let s = await page.evaluate(() => ({ subs: window.__subs, regs: window.__regs }));
+ check('opening the app subscribes again', s.subs >= 1, `${s.subs} subscribe()`);
+ check('...and registers the new endpoint for the connection', s.regs.includes('https://web.push.apple.com/sub' + s.subs), s.regs.join(' '));
+
+ // dropped again while the app sat in the background; it resumes, no reload
+ await page.evaluate(() => { window.__drop(); window.__regs.length = 0; });
+ await page.evaluate(() => {
+ Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' });
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+ await sleep(3000);
+ const before = s.subs;
+ s = await page.evaluate(() => ({ subs: window.__subs, regs: window.__regs }));
+ check('coming back to the app subscribes again', s.subs > before, `${s.subs} subscribe()`);
+ check('...and registers it', s.regs.includes('https://web.push.apple.com/sub' + s.subs), s.regs.join(' '));
+
+ // a live subscription is left alone
+ await page.evaluate(() => { window.__regs.length = 0; document.dispatchEvent(new Event('visibilitychange')); });
+ await sleep(3000);
+ const again = await page.evaluate(() => ({ subs: window.__subs, regs: window.__regs }));
+ check('a subscription still there is reused', again.subs === s.subs && again.regs.length >= 1);
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ a dropped push subscription comes back' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
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/relay-link-test.js b/tools/relay-link-test.js
new file mode 100644
index 0000000..52984ad
--- /dev/null
+++ b/tools/relay-link-test.js
@@ -0,0 +1,66 @@
+// A relay named in a post (wss://…) is a link: tapping it opens the raw
+// firehose of that relay (/feed/all?r=…), read from that relay alone.
+// Run: bun tools/relay-link-test.js
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.js';
+import { finalizeEvent, generateSecretKey } from 'nostr-tools/pure';
+import { buildHtml, buildVerifyWorker } from '../build.js';
+
+const sk = generateSecretKey(), now = Math.floor(Date.now() / 1000);
+const note = finalizeEvent({ kind: 1, created_at: now, tags: [], content: 'I run a good relay to read directly:\n\nwss://theforest.nostr1.com.\n\nAnyone can read from it.' }, sk);
+const forest = finalizeEvent({ kind: 1, created_at: now - 5, tags: [], content: 'Hello from the forest relay, one of its own posts.' }, generateSecretKey());
+const profile = finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Relay Admin' }) }, sk);
+const html = await buildHtml({ minify: true, pwa: false });
+const worker = await buildVerifyWorker({ minify: true });
+const server = Bun.serve({ port: 0, fetch(req) {
+ const path = new URL(req.url).pathname;
+ if (path === '/api/feed') return Response.json({ version: 1, generatedAt: Date.now(), popUntil: now - 100, notes: [note], profiles: [profile] });
+ if (path === '/verify-worker.js') return new Response(worker, { headers: { 'Content-Type': 'text/javascript' } });
+ if (path.startsWith('/punks')) return new Response(null, { status: 404 });
+ 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 check = (name, ok, detail = '') => { assert(ok, name + (detail ? ' — ' + detail : '')); console.log(' ✓ ' + name); };
+try {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.setRequestInterception(true);
+ page.on('request', (r) => (r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort()));
+ await page.evaluateOnNewDocument((forest) => {
+ window.asked = [];
+ class FakeSocket extends EventTarget {
+ static OPEN = 1; static CLOSED = 3;
+ readyState = 0;
+ constructor(url) { super(); this.url = url; setTimeout(() => { this.readyState = 1; this.onopen?.({}); this.dispatchEvent(new Event('open')); }, 0); }
+ emit(m) { const e = new MessageEvent('message', { data: JSON.stringify(m) }); this.onmessage?.(e); this.dispatchEvent(e); }
+ send(data) {
+ const m = JSON.parse(data);
+ if (m[0] !== 'REQ') return;
+ window.asked.push({ url: this.url, filter: m[2] });
+ setTimeout(() => {
+ if (/theforest/.test(this.url) && m[2].kinds?.includes(1) && !m[2].ids && !m[2].authors) this.emit(['EVENT', m[1], forest]);
+ this.emit(['EOSE', m[1]]);
+ }, 0);
+ }
+ close() { this.readyState = 3; this.onclose?.({}); }
+ }
+ window.WebSocket = FakeSocket;
+ }, forest);
+ await page.goto(server.url.origin + '/feed');
+ await page.waitForFunction(() => document.body.innerText.includes('I run a good relay'), { timeout: 5000 });
+ const link = await page.$$eval('.notes-feed a', (as) => as.filter((a) => /theforest/.test(a.textContent)).map((a) => ({ text: a.textContent, href: a.getAttribute('href') })));
+ check('the relay in the post is a link', link.length === 1, JSON.stringify(link));
+ check('...without the full stop after it', link[0].text === 'wss://theforest.nostr1.com' && /theforest\.nostr1\.com\./.test(await page.evaluate(() => document.querySelector('.notes-feed').innerText)));
+ check('...to that relay\'s firehose', link[0].href === '/feed/all?r=' + encodeURIComponent('wss://theforest.nostr1.com'), link[0].href);
+ await page.evaluate(() => [...document.querySelectorAll('.notes-feed a')].find((a) => /theforest/.test(a.textContent)).click());
+ check('tapping it shows the relay\'s own posts', await page.waitForFunction(() => document.body.innerText.includes('Hello from the forest relay'), { timeout: 8000 }).then(() => true, () => false));
+ const forestAsks = await page.evaluate(() => window.asked.filter((a) => /theforest/.test(a.url) && a.filter.kinds?.includes(1) && !a.filter.authors && !a.filter.ids));
+ check('...asked of that relay, for everything on it', forestAsks.length > 0);
+ check('...and the address bar says so', decodeURIComponent(await page.evaluate(() => location.pathname + location.search)) === '/feed/all?r=wss://theforest.nostr1.com', await page.evaluate(() => location.href));
+ check('no page errors', !errors.length, errors.join(' | '));
+ console.log('\n✅ relay links open the relay');
+} finally {
+ await browser.close();
+ server.stop(true);
+}
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/safe-boot-test.js b/tools/safe-boot-test.js
new file mode 100644
index 0000000..a23c067
--- /dev/null
+++ b/tools/safe-boot-test.js
@@ -0,0 +1,77 @@
+// The crash-loop guard: the inline BOOT_GUARD counts page starts, the app
+// clears the count once it has lived or leaves cleanly, and a third start
+// in a row opens in safe mode — a notice naming the last boot phase, no
+// automatic Ark connection, and "Open normally" back to a plain boot.
+// Run: bun tools/safe-boot-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 { BOOT_GUARD } from '../build.js';
+
+const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true,
+ plugins: [{ name: 'safe-boot-test', setup(build) {
+ build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + `
+window.sbApp = { ui, wallet, render, enterWallet, get ready() { return !_bootDeciding; } };
+` }));
+ build.onLoad({ filter: /src\/features\/ark\.js$/ }, async ({ path }) => ({ loader: 'js', contents:
+ (await Bun.file(path).text()).replace(" return {\n id: 'ark',", `
+ window.sbArk = { get ark() { return ark; }, get connecting() { return !!arkConnectPromise; }, get lastAutoInit() { return lastAutoInit; } };
+ return {\n id: 'ark',`) }));
+ build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' }));
+ } }] });
+assert(bundle.success, bundle.logs.join('\n'));
+const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div>${BOOT_GUARD}<script type="module">${await bundle.outputs[0].text()}</script>`;
+const server = Bun.serve({ port: 0, fetch: (req) => new URL(req.url).pathname === '/'
+ ? new Response(html, { headers: { 'Content-Type': 'text/html' } }) : new Response(null, { status: 404 }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+try {
+ const page = await browser.newPage(), errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.setViewport({ width: 390, height: 844 });
+ await page.setRequestInterception(true);
+ page.on('request', (r) => r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort());
+ await page.evaluateOnNewDocument(() => {
+ if (!localStorage.getItem('sb-seeded')) {
+ localStorage.setItem('sb-seeded', '1');
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ localStorage.setItem('btc-wallet-ark-provider:regtest', 'local');
+ // two starts already, neither cleared: the last one died merging the worker's state
+ localStorage.setItem('coinos-boot', '2');
+ localStorage.setItem('coinos-boot-phase', 'ark-merge');
+ }
+ });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => window.sbApp?.ready);
+ const guard = await page.evaluate(() => ({ safe: window.__safeBoot, phase: window.__bootPhase, count: localStorage.getItem('coinos-boot') }));
+ check('the third start in a row opens in safe mode, knowing the phase the last one reached', guard.safe === 3 && guard.phase === 'ark-merge' && guard.count === '3', JSON.stringify(guard));
+ await page.evaluate(async (mn) => {
+ await sbApp.enterWallet(mn, '', { fresh: true, generated: true });
+ sbApp.ui.onb = null; localStorage.removeItem('btc-wallet-onb-step');
+ sbApp.ui.tab = 'wallet'; sbApp.render();
+ }, generateMnemonic(wordlist));
+ await new Promise((r) => setTimeout(r, 1500));
+ const notice = await page.evaluate(() => { const n = [...document.querySelectorAll('.notice')].find((x) => /safe mode/i.test(x.textContent)); return n ? n.textContent : ''; });
+ check('the wallet home says so and names the phase', /safe mode/i.test(notice) && /ark-merge/.test(notice) && /2 times/.test(notice), notice.slice(0, 120));
+ const arkState = await page.evaluate(() => ({ ark: !!sbArk.ark, connecting: sbArk.connecting, auto: sbArk.lastAutoInit }));
+ check('nothing dialled the Ark server on its own', !arkState.ark && !arkState.connecting && !arkState.auto, JSON.stringify(arkState));
+ // a clean leave clears the count
+ await page.evaluate(() => dispatchEvent(new Event('pagehide')));
+ check('leaving the page cleanly clears the count', (await page.evaluate(() => localStorage.getItem('coinos-boot'))) === null);
+ // "Open normally": the count is cleared and the page reloads into a plain boot
+ await page.evaluate(() => localStorage.setItem('coinos-boot', '3'));
+ await Promise.all([
+ page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
+ page.evaluate(() => { const b = [...document.querySelectorAll('.notice button')].find((x) => /open normally/i.test(x.textContent)); b.click(); }),
+ ]);
+ await page.waitForFunction(() => window.sbApp?.ready);
+ const after = await page.evaluate(() => ({ safe: window.__safeBoot, count: localStorage.getItem('coinos-boot'), notice: [...document.querySelectorAll('.notice')].some((x) => /safe mode/i.test(x.textContent)) }));
+ check('"Open normally" reloads into a plain boot (one start counted, no notice)', after.safe === undefined && after.count === '1' && !after.notice, JSON.stringify(after));
+ await new Promise((r) => setTimeout(r, 8600));
+ check('a boot that lives eight seconds clears its count', (await page.evaluate(() => localStorage.getItem('coinos-boot'))) === null);
+ check('no page errors', errors.length === 0, errors.join(' | ').slice(0, 200));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ safe mode after a crash loop' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
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
new file mode 100644
index 0000000..544c72e
--- /dev/null
+++ b/tools/sw-push-quota.test.js
@@ -0,0 +1,89 @@
+// WebKit drops a push subscription after three pushes that show no
+// notification, so on an Apple endpoint every path through the worker's push
+// handler must end in one. Run: bun test tools/sw-push-quota.test.js
+import { test, expect, mock } from 'bun:test';
+
+let respond = async () => true;
+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)] }],
+ classifyDm: async () => ({ own: true }), shouldNotifyDm: () => false, classifyCall: async () => null,
+}));
+
+const listeners = {};
+let endpoint = 'https://web.push.apple.com/abc';
+let windows = [];
+const shown = [], closed = [];
+globalThis.self = {
+ location: { hostname: 'v3.coinos.io', origin: 'https://v3.coinos.io' },
+ addEventListener: (t, f) => { listeners[t] = f; },
+ clients: { matchAll: async () => windows },
+ registration: {
+ pushManager: { getSubscription: async () => ({ endpoint }) },
+ showNotification: async (title, o) => { shown.push({ title, ...o }); },
+ getNotifications: async ({ tag }) => shown.filter((n) => n.tag === tag).map((n) => ({ close: () => closed.push(n) })),
+ },
+};
+await import('../src/sw-nwc.js');
+
+async function push(data) {
+ shown.length = 0; closed.length = 0;
+ let done;
+ listeners.push({ data: { json: () => data }, waitUntil: (p) => { done = p; } });
+ await done;
+ return shown.map((n) => n.title);
+}
+const nwc = { type: 'nwc', servicePubkey: 'a'.repeat(64), event: { id: 'e'.repeat(64), kind: 23194 } };
+const hidden = { visibilityState: 'hidden', postMessage: () => {} };
+const visible = { visibilityState: 'visible', postMessage: () => {} };
+
+test('apple: every quiet path shows the stand-in', async () => {
+ endpoint = 'https://web.push.apple.com/abc'; windows = [];
+ respond = async () => true; // a lookup answered, or another device took it
+ expect(await push(nwc)).toEqual(['Coinos']);
+ expect(await push({ type: 'notify', reason: 'dm', wrap: {} })).toEqual(['Coinos']);
+ expect(await push({ type: 'notify', reason: 'reaction', from: 'm'.repeat(64) })).toEqual(['Coinos']);
+ expect(await push({ type: 'notify', reason: 'call', wrapId: 'x' })).toEqual(['Coinos']);
+ expect(await push({ type: 'weird' })).toEqual(['Coinos']);
+ 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']);
+ expect(closed.length).toBe(1);
+});
+
+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: '' } }; };
+ // 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);
+});
+
+test('elsewhere: quiet stays quiet', async () => {
+ endpoint = 'https://fcm.googleapis.com/fcm/send/abc'; windows = [];
+ respond = async () => true;
+ expect(await push(nwc)).toEqual([]);
+ windows = [hidden];
+ 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
+ windows = [];
+ respond = async () => false;
+ expect(await push(nwc)).toEqual(['Spend request']);
+});
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 3b5fe93..ed672c2 100644
--- a/tools/video-tile-poster-test.js
+++ b/tools/video-tile-poster-test.js
@@ -1,9 +1,10 @@
// Videos grid: a tile shows the server's still (vmedia thumb.jpg, cached at
// the CDN) at once, instead of waiting on the browser's own probe and frame
// grab — and never touches the clip itself for it. A clip the server has no
-// picture for falls back to the frame grab.
+// 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';
@@ -13,12 +14,17 @@ const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? '
const JPG = '/tmp/coinos-video-tile-poster.jpg';
Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'color=c=orange:s=270x480', '-frames:v', '1', JPG]);
const JPEG = Buffer.from(await Bun.file(JPG).arrayBuffer());
+const MP4 = '/tmp/coinos-video-tile-poster.mp4';
+Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'color=c=blue:s=360x640:d=1', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', MP4]);
+const CLIPBYTES = Buffer.from(await Bun.file(MP4).arrayBuffer());
const AUTHOR = (() => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; })();
const now = Math.floor(Date.now() / 1000);
const CLIP = (n) => 'https://media.test/' + n + '.mp4';
+// (each its own size: one person's clips of one size to the byte are taken
+// for one clip uploaded twice)
const post = (n, ago) => finalizeEvent({ kind: 1, created_at: now - ago, content: 'clip ' + n + ' ' + CLIP(n),
- tags: [['imeta', 'url ' + CLIP(n), 'm video/mp4', 'size 900000', 'dim 360x640']] }, AUTHOR.sk);
-const POSTS = [post('a', 100), post('b', 200), post('nopic', 300)];
+ tags: [['imeta', 'url ' + CLIP(n), 'm video/mp4', 'size ' + (900000 + ago), 'dim 360x640']] }, AUTHOR.sk);
+const POSTS = [post('a', 100), post('b', 200), post('nopic', 300), post('dead', 400)];
const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
@@ -31,10 +37,16 @@ page.on('request', (r) => {
if (u.pathname.startsWith('/vmedia/thumb.jpg')) {
const src = u.searchParams.get('u');
asked.push(['thumb', src]);
- if (src === CLIP('nopic')) return r.respond({ status: 404, body: 'No picture' });
+ if (src === CLIP('nopic') || src === CLIP('dead')) return r.respond({ status: 404, body: 'No picture' });
return r.respond({ status: 200, contentType: 'image/jpeg', body: JPEG, headers: { 'access-control-allow-origin': '*' } });
}
- if (u.host === 'media.test') { asked.push(['clip', r.url()]); return r.abort(); }
+ if (u.host === 'media.test') {
+ asked.push(['clip', r.url()]);
+ // deleted, and the host's 404 says nothing to scripts (Primal's blossom)
+ if (u.pathname === '/dead.mp4') return r.respond({ status: 404, body: 'not found' });
+ if (u.pathname === '/nopic.mp4') return r.respond({ status: 200, contentType: 'video/mp4', body: CLIPBYTES, headers: { 'access-control-allow-origin': '*' } });
+ return r.abort();
+ }
r.continue();
});
await page.evaluateOnNewDocument(() => {
@@ -63,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]) => {
@@ -79,17 +91,31 @@ try {
await waitText('receive', 20000);
await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
- check('the Videos grid has the three clips', await waitFor(() => document.querySelectorAll('.media-grid .media-tile.video').length === 3, 15000),
+ check('the Videos grid has the clips', await waitFor(() => document.querySelectorAll('.media-grid .media-tile.video').length >= 3, 15000),
String(await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile.video').length)));
check('two tiles show the server\'s still, loaded', await waitFor(() => [...document.querySelectorAll('.media-tile.video img')]
.filter((i) => i.src.includes('/vmedia/thumb.jpg') && i.naturalWidth > 0).length === 2, 8000),
JSON.stringify(await page.evaluate(() => [...document.querySelectorAll('.media-tile.video')].map((t) => (t.querySelector('img')?.src || t.querySelector('video') ? 'video' : t.innerHTML).slice(-40)))));
check('the clip without one falls back to the frame grab', await waitFor(() => [...document.querySelectorAll('.media-tile.video')]
.some((t) => !t.querySelector('img') && (t.querySelector('video') || t.querySelector('.media-tile-cost'))), 8000));
+ check('the deleted clip leaves the grid instead of sitting there blank', await waitFor(() => {
+ const tiles = [...document.querySelectorAll('.media-grid .media-tile.video')].filter((t) => getComputedStyle(t).display !== 'none');
+ return tiles.length === 3 && !document.querySelector('.media-grid video[src*="dead"]');
+ }, 10000), String(await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile.video').length)));
await sleep(1500);
- const clipHits = asked.filter(([k, u]) => k === 'clip' && !u.includes('nopic'));
+ const clipHits = asked.filter(([k, u]) => k === 'clip' && !u.includes('nopic') && !u.includes('dead'));
check('the pictured clips themselves were never fetched for a still', clipHits.length === 0, clipHits.map((x) => x[1]).join(', '));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+ // remembered: the next visit leaves it out from the start
+ await sleep(2000);
+ asked.length = 0;
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
+ await waitFor(() => document.querySelectorAll('.media-grid .media-tile.video').length >= 3, 15000);
+ await sleep(3000);
+ check('...and stays out after a reload', await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile.video').length) === 3
+ && !asked.some(([k, u]) => k === 'clip' && u.includes('dead')), String(await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile.video').length)));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ video tiles show the server\'s still at once' : '\n❌ failed');
process.exit(ok ? 0 : 1);
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..e08058b 100644
--- a/tools/youtube-tap-test.js
+++ b/tools/youtube-tap-test.js
@@ -4,7 +4,7 @@
// over the picture takes them; YouTube's control bar along the bottom is
// left to YouTube, and full screen the layer steps aside.
// 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';
@@ -60,9 +60,9 @@ async function run(saveData) {
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/zap-feedback-test.js b/tools/zap-feedback-test.js
index adc7663..80ec434 100644
--- a/tools/zap-feedback-test.js
+++ b/tools/zap-feedback-test.js
@@ -124,7 +124,7 @@ try {
check('the same tap plays the supplied MP3 clip', await page.evaluate(() => {
const samples = zapAudio.buffer?.getChannelData(0);
return zapAudio.starts === 1 && zapAudio.context.state === 'running'
- && zapAudio.buffer.duration > 1.2 && zapAudio.buffer.duration < 1.6
+ && zapAudio.buffer.duration > 0.3 && zapAudio.buffer.duration < 0.5
&& samples.some(v => Math.abs(v) > .05)
&& samples.every(v => Number.isFinite(v));
}));
@@ -202,8 +202,9 @@ try {
// The same screen switches the sound off; the strike and tally stay.
await page.mouse.down(); await pause(650); await page.mouse.up(); await pause(100);
check('the held screen offers the sound switch, on by default', await page.$eval('#setup input[type=checkbox]', (e) => e.checked));
- await page.click('#setup input[type=checkbox]');
- await page.click('#setup .btn-primary'); await pause(100);
+ // the toggle re-renders the screen mid-click: dispatch the clicks in the page, where a morph can't detach the node under the pointer
+ await page.$eval('#setup input[type=checkbox]', (e) => e.click()); await pause(150);
+ await page.$eval('#setup .btn-primary', (e) => e.click()); await pause(100);
const starts = await page.evaluate(() => zapAudio.starts);
await page.click('.note-zap'); await pause(100);
check('with the sound off a tap still strikes and counts, silently', await page.evaluate((n, c) => zapAudio.starts === n && test.calls === c + 2 && !!document.querySelector('.zap-fx'), starts, calls));
diff --git a/tools/zap-signer-test.js b/tools/zap-signer-test.js
new file mode 100644
index 0000000..13f6008
--- /dev/null
+++ b/tools/zap-signer-test.js
@@ -0,0 +1,43 @@
+// A zap is signed as the person zapping: with a login identity (extension,
+// bunker, passkey…) the zap request's pubkey is that identity, not the
+// wallet's own key — a signed-in person's zaps showed in stream chats (and
+// streamers' overlays) under the wallet key's stranger npub. Without a
+// login, the wallet key signs as before.
+// Run: bun tools/zap-signer-test.js
+import assert from 'node:assert/strict';
+import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
+import { zapsFeature } from '../src/features/zaps.js';
+
+const check = (name, ok, detail = '') => { assert(ok, name + (detail ? ' — ' + detail : '')); console.log(' ✓ ' + name); };
+const walletSk = generateSecretKey(), loginSk = generateSecretKey(), host = getPublicKey(generateSecretKey());
+let asked = null;
+globalThis.fetch = async (url) => {
+ const u = new URL(url);
+ if (u.pathname.startsWith('/.well-known/lnurlp/')) return Response.json({ tag: 'payRequest', callback: 'https://ln.example/cb', minSendable: 1000, maxSendable: 1e9, allowsNostr: true, nostrPubkey: host });
+ if (u.pathname === '/cb') { asked = JSON.parse(u.searchParams.get('nostr')); return Response.json({ pr: 'lnbc210n1fake' }); }
+ return new Response('no', { status: 404 });
+};
+async function zapWith(login) {
+ asked = null;
+ let settled = null;
+ const ctx = {
+ h: () => null, ui: {}, render: () => {}, toast: (m) => console.log('toast:', m), fmtAmount: String, unitLabel: () => 'sats', parseAmount: Number, getUnit: () => 'sat', unitTag: () => '', blankSend: () => {},
+ wallet: { watchOnly: false, nostrRelays: () => ['wss://relay.example'], nostrProfile: async () => ({ lud16: '[email protected]' }), nostrSign: (e) => finalizeEvent(e, walletSk) },
+ hook(name, ...a) {
+ if (name === 'canLnPay' || name === 'arkReady') return true;
+ if (name === 'nostrLoginIdentity') return login;
+ if (name === 'arkPayInvoice') return Promise.resolve({});
+ if (name === 'zapSettled') { settled = a; return null; }
+ return null;
+ },
+ };
+ zapsFeature(ctx).lnZapNpub(host, null, 'e'.repeat(64), 21);
+ for (let i = 0; i < 50 && !settled; i++) await new Promise((r) => setTimeout(r, 20));
+ return { req: asked, settled };
+}
+const signedIn = await zapWith({ pubkey: getPublicKey(loginSk), signer: { signEvent: async (e) => finalizeEvent(e, loginSk) } });
+check('signed in: the zap goes through', signedIn.settled && signedIn.settled[1] === true, JSON.stringify(signedIn.settled));
+check('...and its request is signed by the login identity', signedIn.req && signedIn.req.kind === 9734 && signedIn.req.pubkey === getPublicKey(loginSk), signedIn.req && signedIn.req.pubkey);
+const plain = await zapWith(null);
+check('no login: the wallet\'s own key signs it', plain.req && plain.req.pubkey === getPublicKey(walletSk));
+console.log('\n✅ zaps are signed as the person zapping');