diff options
Diffstat (limited to 'tools')
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'); |
