diff options
Diffstat (limited to 'tools')
28 files changed, 901 insertions, 26 deletions
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/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/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/feed-video-pager-test.js b/tools/feed-video-pager-test.js index cccde41..61119a0 100644 --- a/tools/feed-video-pager-test.js +++ b/tools/feed-video-pager-test.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); }; diff --git a/tools/fullscreen-notice-test.js b/tools/fullscreen-notice-test.js index cf15f8c..d3b2785 100644 --- a/tools/fullscreen-notice-test.js +++ b/tools/fullscreen-notice-test.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); }; diff --git a/tools/gift-profile-setup-test.js b/tools/gift-profile-setup-test.js new file mode 100644 index 0000000..9304d1a --- /dev/null +++ b/tools/gift-profile-setup-test.js @@ -0,0 +1,96 @@ +// 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>'); + await click(page, 'skip verification'); + 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); + check('Verify backup opens the word check', await click(p2, 'verify') && await waitText(p2, /word #?\d/i, 5000)); + await sleep(500); + // the verify page doesn't show the words: read them from the account + const filled = await p2.evaluate(() => { + const accs = JSON.parse(sessionStorage.getItem('btc-wallet-accounts') || '{}'); + const list = Array.isArray(accs) ? accs : accs.accounts || accs.list || []; + const m = list.map((a) => a.mnemonic).filter(Boolean).pop(); if (!m) return false; + const w = m.split(' '); + for (const lab of document.querySelectorAll('label.field')) { + const n = Number(lab.querySelector('.lab').textContent.match(/\d+/)[0]); + const inp = lab.querySelector('input'); + const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; + set.call(inp, w[n - 1]); inp.dispatchEvent(new Event('input', { bubbles: true })); + } + return true; + }); + if (filled) { + await click(p2, 'open wallet'); + 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 console.log(' (accounts not in localStorage — verify path not driven)'); + 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/live-stream-test.js b/tools/live-stream-test.js index ef76043..2be6051 100644 --- a/tools/live-stream-test.js +++ b/tools/live-stream-test.js @@ -197,6 +197,12 @@ try { 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()); @@ -217,6 +223,7 @@ try { check('...and the zap, its amount and words', await waitText('zapped 21 sats', 5000) && await waitText('great stream', 1000)); 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'); 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/media-grid-order-test.js b/tools/media-grid-order-test.js new file mode 100644 index 0000000..9274d92 --- /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 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 page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + 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 index a188c5d..7b429c9 100644 --- a/tools/media-moments-feed-test.js +++ b/tools/media-moments-feed-test.js @@ -175,6 +175,18 @@ try { check('...and shows over the pager\'s seek bar', await page.waitForFunction(() => !!document.querySelector('.media-pager .mp-cur .mp-seek .mp-mark'), { timeout: 3000 }).then(() => true, () => false)); check('no timeline panel over the pager', await page.evaluate(() => !document.querySelector('.media-pager .media-timeline'))); await page.screenshot({ path: '/tmp/media-timeline-pager.png' }); + // back in the feed: the clip has a moment now, so the moments bar is its + // only seek bar (no player controls), with the middle button to play it + await page.keyboard.press('Escape'); await sleep(500); + await page.evaluate(() => { const v = document.querySelector('video.note-video'); v.pause(); v.dispatchEvent(new Event('timeupdate')); }); + await sleep(200); + const one = await page.evaluate(() => { const v = document.querySelector('video.note-video'); const b = v.closest('.note-video-box'); const tl = b.querySelector('.media-timeline'); + return { controls: v.controls, own: b.classList.contains('own-bar'), bar: !!tl && getComputedStyle(tl).display !== 'none', mark: !!b.querySelector('.media-mark'), pp: getComputedStyle(b.querySelector('.vid-pp')).display }; }); + check('in the feed, a clip with a moment has one seek bar: the moments bar, no player controls', !one.controls && one.own && one.bar && one.mark, JSON.stringify(one)); + check('...and the middle button plays it, on a touch screen too', one.pp === 'flex', JSON.stringify(one)); + const vid2 = await page.evaluate(() => { const b = document.querySelector('.note-video-box .vid-pp').getBoundingClientRect(); return { x: b.x + b.width / 2, y: b.y + b.height / 2 }; }); + await page.mouse.click(vid2.x, vid2.y); await sleep(600); + check('...which plays it in place', await page.evaluate(() => !document.querySelector('video.note-video').paused && !document.querySelector('.media-pager'))); check('no browser exceptions', errs.length === 0, errs.join(' | ')); await page.screenshot({ path: '/tmp/media-timeline-feed.png' }); } finally { await browser.close(); server.stop(); } diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js index f037275..5a15d48 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 = '') => { @@ -118,6 +119,24 @@ console.log('\n[merchant methods (BTCPay)]'); check('list_transactions includes receives', txs.some((t) => t.type === 'incoming' && t.amount === 21000), JSON.stringify(txs)); } { + // 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 } = 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); } @@ -178,6 +197,112 @@ const INV21 = 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0z check('exhausted key window → wake the user', handled === false); } +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[network fit]'); { // a mutinynet-era mirror must not answer a mainnet invoice with a nonsense diff --git a/tools/pager-caption-test.js b/tools/pager-caption-test.js index 5952d81..50ffe4f 100644 --- a/tools/pager-caption-test.js +++ b/tools/pager-caption-test.js @@ -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); }; diff --git a/tools/pager-close-test.js b/tools/pager-close-test.js index 73aa15b..a7c6c12 100644 --- a/tools/pager-close-test.js +++ b/tools/pager-close-test.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); }; diff --git a/tools/pager-eager-test.js b/tools/pager-eager-test.js index 3b5c5c3..3d980fb 100644 --- a/tools/pager-eager-test.js +++ b/tools/pager-eager-test.js @@ -117,6 +117,8 @@ async function run(saveData, slow = false) { await waitFor(() => { const v = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip c0 here')).querySelector('video.note-video'); return v && !v.paused && v.currentTime > 0; }, null, 10000); const r = await feedRow('clip c0 here'); log = log.filter((e) => e.clip === 'c0'); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [r.x, r.y]); await page.mouse.click(r.x, r.y); check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000)); await sleep(slow ? 8000 : 3000); diff --git a/tools/pager-first-frame-test.js b/tools/pager-first-frame-test.js index 6507607..0d477b7 100644 --- a/tools/pager-first-frame-test.js +++ b/tools/pager-first-frame-test.js @@ -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-no-poster-test.js b/tools/pager-no-poster-test.js index 0930834..332eb53 100644 --- a/tools/pager-no-poster-test.js +++ b/tools/pager-no-poster-test.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); }; diff --git a/tools/pager-prime-test.js b/tools/pager-prime-test.js index 7263ee1..4989790 100644 --- a/tools/pager-prime-test.js +++ b/tools/pager-prime-test.js @@ -114,6 +114,8 @@ async function run() { await page.waitForSelector('.notes-feed video.note-video', { timeout: 20000 }); const r = await page.evaluate(() => { const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip c0 here')); row.querySelector('.note-video-box').scrollIntoView({ block: 'center' }); const v = row.querySelector('video.note-video').getBoundingClientRect(); return { x: v.x + v.width * 0.2, y: v.y + v.height * 0.3 }; }); await sleep(800); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [r.x, r.y]); await page.mouse.click(r.x, r.y); check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000)); check('the next clip\'s warm player has played a moment, unheard, and waits paused a frame or two in', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/c1.webm')); const v = s && s.querySelector('video.mp-media'); return v && v.paused && v.currentTime >= 0.03 && v.currentTime < 0.6; }, null, 10000), JSON.stringify(await vid('c1'))); diff --git a/tools/pager-rotate-test.js b/tools/pager-rotate-test.js index 9912b7a..9dcc7e3 100644 --- a/tools/pager-rotate-test.js +++ b/tools/pager-rotate-test.js @@ -91,6 +91,8 @@ async function run(unmuteFirst) { await sleep(800); } const at = await rowOf(); await sleep(300); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]); await page.mouse.click(at.x, at.y); check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000)); await waitFor(() => { const v = document.querySelector('.media-pager video[src]'); return v && !v.paused && v.currentTime > 1.5; }, null, 8000); diff --git a/tools/pager-seek-hide-test.js b/tools/pager-seek-hide-test.js index f25ff49..50863ba 100644 --- a/tools/pager-seek-hide-test.js +++ b/tools/pager-seek-hide-test.js @@ -88,6 +88,8 @@ try { await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }); await sleep(400); const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]); await page.mouse.click(at.x, at.y); check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000)); diff --git a/tools/pager-tail-test.js b/tools/pager-tail-test.js index b294b8f..9d537e1 100644 --- a/tools/pager-tail-test.js +++ b/tools/pager-tail-test.js @@ -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/post-edit-test.js b/tools/post-edit-test.js index 0b8bf6f..2ed1260 100644 --- a/tools/post-edit-test.js +++ b/tools/post-edit-test.js @@ -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-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/quoted-clip-fullscreen-test.js b/tools/quoted-clip-fullscreen-test.js index bd8dd45..41984a2 100644 --- a/tools/quoted-clip-fullscreen-test.js +++ b/tools/quoted-clip-fullscreen-test.js @@ -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/sw-push-quota.test.js b/tools/sw-push-quota.test.js index e7d00f2..cf4a7bf 100644 --- a/tools/sw-push-quota.test.js +++ b/tools/sw-push-quota.test.js @@ -4,8 +4,9 @@ import { test, expect, mock } from 'bun:test'; let respond = async () => true; -mock.module('../src/nwc-respond.js', () => ({ respondFromBg: (...a) => respond(...a), tendRenewals: async () => {} })); -mock.module('../src/nwc-lock.js', () => ({ withRequestLock: async (_id, fn) => fn() })); +mock.module('../src/nwc-respond.js', () => ({ respondFromBg: (...a) => respond(...a), tendRenewals: async () => {}, tendLnPays: async () => [] })); +mock.module('../src/nwc-lock.js', () => ({ withRequestLock: async (_id, fn) => fn(), withWorkLock: async (fn) => fn() })); +mock.module('../src/nwc-bg.js', () => ({ appendBgLog: async () => {}, takeLease: async () => ({ waited: 0, release: async () => {} }) })); mock.module('../src/bg-autowithdraw.js', () => ({ bgAutoWithdraw: async () => {} })); mock.module('../src/dm-inbox.js', () => ({ allInboxes: async () => [{ muted: ['m'.repeat(64)] }], @@ -50,6 +51,13 @@ test('apple: every quiet path shows the stand-in', async () => { expect(closed.length).toBe(0); }); +test('apple: a send finished from an earlier wake shows its own line and drops the stand-in', async () => { + endpoint = 'https://web.push.apple.com/abc'; windows = []; + respond = async () => ({ notices: [{ title: 'Payment sent', body: '-21 sats via a connected app', tag: 'nwc-sent' }] }); + expect(await push(nwc)).toEqual(['Coinos', 'Payment sent']); + expect(closed.length).toBe(1); +}); + test('apple: an app on screen gets the stand-in closed at once', async () => { windows = [visible]; expect(await push({ type: 'notify', reason: 'payment', amountSat: 5 })).toEqual(['Coinos']); @@ -60,7 +68,9 @@ test('apple: a suspended background app does not swallow the request', async () windows = [hidden]; let asked = 0; respond = async () => { asked++; return { notify: { title: 'Payment sent', body: '' } }; }; - expect(await push(nwc)).toEqual(['Payment sent']); + // the stand-in goes up first and the outcome replaces it (91dbee4) + expect(await push(nwc)).toEqual(['Coinos', 'Payment sent']); + expect(closed.length).toBe(1); expect(asked).toBe(1); }); 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/video-events-test.js b/tools/video-events-test.js index bef0956..c024949 100644 --- a/tools/video-events-test.js +++ b/tools/video-events-test.js @@ -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-tap-test.js b/tools/video-tap-test.js index f82238b..50e5a69 100644 --- a/tools/video-tap-test.js +++ b/tools/video-tap-test.js @@ -1,8 +1,7 @@ -// 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 { cacheKeyFor } from '../src/wallet.js'; @@ -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); |
