aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/account-access-test.js82
-rw-r--r--tools/post-emoji-test.js79
-rw-r--r--tools/profile-recovery-test.js86
-rw-r--r--tools/registry-ownership-test.js18
-rw-r--r--tools/spending-restore-test.js119
-rw-r--r--tools/thread-reply-test.js93
6 files changed, 477 insertions, 0 deletions
diff --git a/tools/account-access-test.js b/tools/account-access-test.js
new file mode 100644
index 0000000..ad6ce3f
--- /dev/null
+++ b/tools/account-access-test.js
@@ -0,0 +1,82 @@
+// A watch-only account without remembered Nostr metadata must still offer
+// account switching and logout. Uses an unfunded, generated public key only.
+// Run: bun tools/account-access-test.js
+import assert from 'node:assert/strict';
+import puppeteer from 'puppeteer-core';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { accountXpubFor, cacheKeyFor } from '../src/wallet.js';
+import { buildHtml } from '../build.js';
+
+const xpub = accountXpubFor({ mnemonic: generateMnemonic(wordlist) });
+const html = await buildHtml({ minify: false, pwa: false });
+const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errors = [];
+page.on('pageerror', (e) => errors.push(e.stack));
+await page.setRequestInterception(true);
+page.on('request', (r) => r.url().startsWith(server.url.origin) ? r.continue() : r.abort());
+const check = (name, value) => { assert(value, name); console.log(' ✓ ' + name); };
+const click = (text) => page.evaluate((label) => {
+ const button = [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === label);
+ if (!button) throw new Error('Missing button: ' + label);
+ button.click();
+}, text);
+try {
+ await page.goto(server.url.href);
+ await page.evaluate((xpub) => {
+ localStorage.setItem('btc-wallet-watch', JSON.stringify([{ id: 'test-watch', label: 'Saved wallet', xpub }]));
+ }, xpub);
+ await page.reload();
+ await page.waitForSelector('.header-avatar');
+ check('fresh local account defaults to mainnet', await page.evaluate(() => JSON.parse(localStorage.getItem('btc-wallet-watch'))[0].network === 'mainnet'));
+ check('anonymous avatar has an accessible Accounts label', await page.$eval('.header-avatar', (e) => e.getAttribute('aria-label') === 'Accounts'));
+ await page.click('.header-avatar');
+ check('avatar opens Accounts with sign-in and logout', await page.evaluate(() => ['Accounts', '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');
+ // Expand the Nostr login card without supplying a private key.
+ await click('Sign in with 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');
+ 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));
+ // 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 }) => {
+ localStorage.setItem(key + ':nostrlogin', JSON.stringify({ pubkey: 'a'.repeat(64), type: 'extension' }));
+ history.replaceState(null, '');
+ }, { key: cacheKeyFor(xpub) });
+ await page.reload();
+ await page.waitForSelector('[aria-label="Messages"]');
+ await click('Accounts');
+ const onAccounts = () => page.evaluate(() => [...document.querySelectorAll('h3')].some(e => e.textContent === 'Accounts'));
+ for (const label of ['Search', 'Messages', 'Your profile', 'Settings']) {
+ check(label + ' starts on Accounts', await onAccounts());
+ await page.click('[aria-label="' + label + '"]');
+ check(label + ' opens from Accounts', await page.evaluate(label => {
+ const nav = history.state.nav;
+ return nav.screen === 'wallet' && ![...document.querySelectorAll('h3')].some(e => e.textContent === 'Accounts')
+ && (label === 'Search' ? !!document.querySelector('.user-search-input')
+ : label === 'Messages' ? nav.chatOpen && nav.msgView === 'home'
+ : label === 'Your profile' ? !!document.querySelector('.npub-box') && nav.profilePk === 'a'.repeat(64)
+ : nav.tab === 'settings');
+ }, label));
+ await page.goBack();
+ await page.waitForFunction(() => [...document.querySelectorAll('h3')].some(e => e.textContent === 'Accounts'));
+ check('Back returns from ' + label + ' to Accounts', await onAccounts());
+ }
+ await page.click('[aria-label="Lock wallet"]');
+ check('Lock opens its password prompt from Accounts', await page.evaluate(() => !!document.querySelector('input[type="password"]')));
+ await click('Not now');
+ await page.click('.brand');
+ check('the logo returns to the wallet', await page.evaluate(() => history.state.nav.screen === 'wallet' && ![...document.querySelectorAll('h3')].some(e => e.textContent === 'Accounts')));
+ assert.deepEqual(errors, []);
+ check('no browser errors', true);
+} finally { await browser.close(); server.stop(true); }
diff --git a/tools/post-emoji-test.js b/tools/post-emoji-test.js
new file mode 100644
index 0000000..6c7a9aa
--- /dev/null
+++ b/tools/post-emoji-test.js
@@ -0,0 +1,79 @@
+// Exercise public reaction ingestion, rendering, signing and withdrawal.
+// No requests or events leave this local fixture. Run: bun tools/post-emoji-test.js
+import assert from 'node:assert/strict';
+import puppeteer from 'puppeteer-core';
+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';
+${dom}
+const mine = 'a'.repeat(64), author = 'b'.repeat(64);
+const note = { id: 'c'.repeat(64), pubkey: author, kind: 1, tags: [], content: 'A post' };
+const ui = { screen: 'wallet' }, data = {};
+window.sent = [];
+let seq = 0, feature;
+const signer = { signEvent: async e => ({ ...e, pubkey: mine, id: 'signed-' + ++seq }) };
+const wallet = { loaded: true, loadFeatureState: (k, d) => data[k] || d, saveFeatureState: (k, v) => data[k] = v };
+function render() {
+ if (!feature) return;
+ document.querySelector('#panel').replaceChildren(feature.panel(note));
+ document.querySelector('#actions').replaceChildren(feature.actions(author, note, { canZap: false }));
+}
+feature = messagesFeature({ h, wallet, ui, render, toast() {}, brandHeader: () => null,
+ hook: k => k === 'nostrLoginIdentity' ? { pubkey: mine, signer } : null });
+window.test = {
+ add: (content, code, url, pk = author) => {
+ const ev = { id: 'received-' + ++seq, pubkey: pk, kind: 7, content,
+ tags: [['e', note.id], ...(code ? [['emoji', code, url]] : [])] };
+ feature.ingest(ev); render(); return ev;
+ },
+ duplicate: ev => { feature.ingest(ev); render(); },
+ send: async emoji => { await feature.send(note, emoji); render(); },
+ remove: async () => { await feature.remove(note); render(); },
+ count: () => feature.count(note.id), mine,
+};
+`;
+const bundle = await Bun.build({ entrypoints: ['post-emoji-entry'], target: 'browser', plugins: [{ name: 'post-emoji', setup(build) {
+ build.onResolve({ filter: /^post-emoji-entry$/ }, () => ({ path: 'entry', namespace: 'post-emoji' }));
+ build.onLoad({ filter: /.*/, namespace: 'post-emoji' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() }));
+ build.onLoad({ filter: /src\/features\/messages\.js$/ }, async ({ path }) => ({ loader: 'js', contents: (await Bun.file(path).text())
+ .replace("id: 'messages',", "id: 'messages', ingest: noteEvent, panel: whoPanel, actions: noteActions, send: reactTo, remove: unreact, count: whoCount,") }));
+ build.onLoad({ filter: /src\/nostr\.js$/ }, async ({ path }) => ({ loader: 'js', contents: (await Bun.file(path).text())
+ .replace('export async function queryOn(relays, filter, maxWait = 1500) {', 'export async function queryOn(relays, filter, maxWait = 1500) { return [];')
+ .replace('export async function publishOn(relays, evt) {', 'export async function publishOn(relays, evt) { window.sent.push(evt); return true;') }));
+} }] });
+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="panel"></div><div id="actions"></div><script type="module">${await bundle.outputs[0].text()}</script>`;
+const gif = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');
+const server = Bun.serve({ port: 0, fetch: req => new URL(req.url).pathname.endsWith('.gif')
+ ? new Response(gif, { headers: { 'content-type': 'image/gif' } })
+ : 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));
+await page.setRequestInterception(true);
+page.on('request', r => r.url().startsWith(server.url.origin) ? r.continue() : r.abort());
+const check = (name, value) => { assert(value, name); console.log('✓ ' + name); };
+try {
+ await page.goto(server.url.href);
+ await page.waitForFunction(() => !!window.test);
+ await page.evaluate(() => { window.first = test.add(':catJAM:', 'catJAM', location.origin + '/cat.gif'); });
+ await page.waitForFunction(() => document.querySelector('#panel .cemoji')?.naturalWidth > 0);
+ check('tagged catJAM renders a decodable image with its shortcode as alt text', await page.$eval('#panel .cemoji', e => e.alt === ':catJAM:' && e.src.endsWith('/cat.gif')));
+ await page.evaluate(() => test.duplicate(first));
+ check('duplicate events do not inflate the tally', await page.evaluate(() => test.count() === 1));
+ await page.evaluate(() => test.add(':catJAM:', 'catJAM', location.origin + '/different.gif', 'd'.repeat(64)));
+ check('identical shortcodes with different images stay separate', await page.evaluate(() => [...document.querySelectorAll('#panel .cemoji')].map(e => e.src).join('|') === location.origin + '/cat.gif|' + location.origin + '/different.gif'));
+ await page.evaluate(() => test.add(':a_very_long_custom_emoji:', 'a_very_long_custom_emoji', location.origin + '/long.gif'));
+ check('long shortcodes are preserved', await page.evaluate(() => !!document.querySelector('img[alt=":a_very_long_custom_emoji:"]')));
+ await page.evaluate(() => { test.add(':missing:'); test.add(':unsafe:', 'unsafe', 'javascript:alert(1)'); test.add('+'); test.add('🔥'); });
+ check('missing or unsafe tags fall back to text and standard reactions still render', await page.evaluate(() => [':missing:', ':unsafe:', '❤️', '🔥'].every(s => document.querySelector('#panel').innerText.includes(s)) && !document.querySelector('img[src^="javascript:"]')));
+ await page.evaluate(() => test.send(':catJAM:'));
+ check('outgoing custom reactions carry their image tag', await page.evaluate(() => sent.at(-1).tags.some(t => t[0] === 'emoji' && t[1] === 'catJAM' && t[2] === location.origin + '/different.gif')));
+ check('our custom reaction renders on the action button', await page.evaluate(() => document.querySelector('#actions .cemoji')?.getAttribute('alt') === ':catJAM:'));
+ await page.evaluate(() => test.remove());
+ check('withdrawing it preserves other reactions and publishes deletion', await page.evaluate(() => !document.querySelector('#actions .cemoji') && sent.at(-1).kind === 5 && test.count() === 7));
+ assert.deepEqual(errors, []);
+ console.log('✓ no browser errors');
+} finally { await browser.close(); server.stop(true); }
diff --git a/tools/profile-recovery-test.js b/tools/profile-recovery-test.js
new file mode 100644
index 0000000..b2c871a
--- /dev/null
+++ b/tools/profile-recovery-test.js
@@ -0,0 +1,86 @@
+// Profile misses must preserve cached details, fall back to index relays,
+// and remain retryable. Tests real rendering with deterministic relay replies.
+// Run: bun tools/profile-recovery-test.js
+import assert from 'node:assert/strict';
+import puppeteer from 'puppeteer-core';
+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 { fallbackAvatar, punkUrl, punkSmallUrl } from './src/recipient-search.js';
+${dom}
+const pk = 'a'.repeat(64), other = 'b'.repeat(64);
+const cached = { name: 'Cached person', picture: 'https://example.invalid/photo.jpg', about: 'Cached bio' };
+const data = { profiles: { [pk]: { ...cached, t: Date.now(), eventAt: 1 } }, profPages: { full: { [pk]: { t: Date.now(), v: cached } } } };
+const ui = { screen: 'wallet', profilePk: pk };
+let feature, mode = 'empty', release;
+const asks = [];
+const event = (author, name, created_at = 2) => ({ pubkey: author, kind: 0, created_at, content: JSON.stringify({ name, about: name + ' bio', picture: '' }), tags: [] });
+window.__profileQuery = async (relays, filter) => {
+ asks.push({ relays, kinds: filter.kinds });
+ if (filter.kinds[0] !== 0) return [];
+ if (mode === 'hold') return new Promise(r => { release = r; });
+ if (mode === 'index' && relays.includes('wss://purplepag.es')) return [event(filter.authors[0], 'Recovered person')];
+ return [];
+};
+const wallet = { mnemonic: 'test', loadFeatureState: (key, fallback) => data[key] || fallback,
+ saveFeatureState: (key, value) => data[key] = value, nostr: null };
+function render() {
+ if (feature) document.querySelector('#profile').replaceChildren(feature.testView(pk));
+}
+feature = messagesFeature({ h, ui, wallet, render, hook: () => null, brandHeader: () => null, toast() {} });
+window.test = {
+ pk, other, asks, data, feature, event, render,
+ fetch: (author = pk) => feature.testFetch(author),
+ show: author => { ui.profilePk = author; render(); },
+ mode: value => mode = value,
+ release: () => { mode = 'empty'; release([]); },
+ avatar: () => { document.querySelector('#avatar').replaceChildren(fallbackAvatar(h, pk, 'Person', 'chat-avatar profile-avatar')); },
+ urls: () => [punkUrl(pk), punkSmallUrl(pk)],
+};
+render();
+`;
+const bundle = await Bun.build({ entrypoints: ['profile-test-entry'], target: 'browser', plugins: [{ name: 'profile-test', setup(build) {
+ build.onResolve({ filter: /^profile-test-entry$/ }, () => ({ path: 'entry', namespace: 'profile-test' }));
+ build.onLoad({ filter: /.*/, namespace: 'profile-test' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() }));
+ build.onLoad({ filter: /src\/features\/messages\.js$/ }, async ({ path }) => ({ contents: (await Bun.file(path).text())
+ .replace("id: 'messages',", "id: 'messages', testFetch: fetchFullProfile, testView: profileScreen, testApply: applyProfile, testLight: (pk) => profiles.get(pk), testFull: (pk) => fullProfiles.get(pk),"), loader: 'js' }));
+ build.onLoad({ filter: /src\/nostr\.js$/ }, async ({ path }) => ({ contents: (await Bun.file(path).text())
+ .replace('export async function queryOn(relays, filter, maxWait = 1500) {', 'export async function queryOn(relays, filter, maxWait = 1500) { if (globalThis.__profileQuery) return globalThis.__profileQuery(relays, filter);'), loader: 'js' }));
+} }] });
+assert(bundle.success, bundle.logs.join('\n'));
+const css = await Bun.file('src/style.css').text();
+const html = `<!doctype html><meta charset="utf-8"><style>${css}</style><div id="profile"></div><div id="avatar"></div><script type="module">${await bundle.outputs[0].text()}</script>`;
+const server = Bun.serve({ port: 0, fetch: req => new URL(req.url).pathname.startsWith('/punks')
+ ? new Response('missing', { status: 404 }) : 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));
+await page.setRequestInterception(true);
+page.on('request', r => r.url().startsWith(server.url.origin) ? r.continue() : r.abort());
+const check = (name, value) => { assert(value, name); console.log(' ✓ ' + name); };
+try {
+ await page.goto(server.url.href + 'someone/');
+ await page.waitForFunction(() => !!window.test);
+ await page.evaluate(() => test.fetch());
+ check('an empty fetch preserves cached name, photo and bio', await page.evaluate(() => test.feature.testLight(test.pk).name === 'Cached person' && test.feature.testFull(test.pk).about === 'Cached bio' && test.data.profiles[test.pk].picture === 'https://example.invalid/photo.jpg'));
+ check('a miss offers Retry instead of becoming permanent', await page.evaluate(() => document.body.innerText.includes('Couldn’t load the latest profile.') && [...document.querySelectorAll('button')].some(b => b.textContent === 'Retry')));
+ await page.evaluate(() => { test.mode('index'); [...document.querySelectorAll('button')].find(b => b.textContent === 'Retry').click(); });
+ await page.waitForFunction(() => document.body.innerText.includes('Recovered person bio'));
+ check('retry recovers a profile from an index relay', await page.evaluate(() => test.feature.testLight(test.pk).name === 'Recovered person' && !document.body.innerText.includes('Couldn’t load the latest profile.')));
+ await page.evaluate(() => { test.mode('hold'); window.pending = test.fetch(test.other); test.feature.testApply(test.other, test.event(test.other, 'Arrived in batch', 10)); test.release(); });
+ await page.evaluate(() => window.pending);
+ check('a slow empty page fetch cannot erase a successful batch answer', await page.evaluate(() => test.feature.testLight(test.other).name === 'Arrived in batch' && test.feature.testFull(test.other).name === 'Arrived in batch'));
+ await page.evaluate(() => test.feature.testApply(test.other, test.event(test.other, 'Old answer', 5)));
+ check('an older relay response cannot overwrite newer metadata', await page.evaluate(() => test.feature.testLight(test.other).name === 'Arrived in batch'));
+ await page.evaluate(async () => { const missing = 'c'.repeat(64); test.mode('empty'); test.show(missing); await test.fetch(missing); });
+ check('a key without cached or published details shows a neutral missing-profile message and Retry', await page.evaluate(() => document.body.innerText.includes('No profile details found on the available relays.') && !document.body.innerText.includes('Couldn’t load the latest profile.') && [...document.querySelectorAll('button')].some(b => b.textContent === 'Retry')));
+ check('avatar paths are rooted even on a trailing-slash profile URL', await page.evaluate(() => test.urls().every(url => url.startsWith('/punks'))));
+ await page.evaluate(() => test.avatar());
+ await page.waitForFunction(() => !document.querySelector('#avatar img'));
+ await page.evaluate(() => test.avatar());
+ check('failed fallback images stay removed across redraws', await page.evaluate(() => !document.querySelector('#avatar img') && document.querySelector('#avatar').textContent === 'Pe'));
+ assert.deepEqual(errors, []);
+ console.log(' ✓ no browser errors');
+} finally { await browser.close(); server.stop(true); }
diff --git a/tools/registry-ownership-test.js b/tools/registry-ownership-test.js
new file mode 100644
index 0000000..43683a1
--- /dev/null
+++ b/tools/registry-ownership-test.js
@@ -0,0 +1,18 @@
+import assert from 'node:assert/strict';
+import { registrationIdentity } from '../names/ownership.js';
+
+const owner = 'a'.repeat(64), wallet = 'b'.repeat(64), stranger = 'c'.repeat(64);
+const grant = { pubkey: owner, manager: wallet };
+for (const signer of [owner, wallet]) {
+ assert.deepEqual(registrationIdentity({ signer, grant, granted: true }), { pubkey: owner, manager: wallet });
+}
+console.log('✓ owner and manager registrations both preserve the migration identity');
+assert.deepEqual(registrationIdentity({ signer: wallet, manager: wallet, grant, granted: true }), { pubkey: owner, manager: wallet });
+console.log('✓ the signing wallet can remain the nominated manager');
+const existing = { pubkey: owner, manager: wallet };
+assert.deepEqual(registrationIdentity({ existing, signer: wallet, grant: { pubkey: stranger }, granted: false }), existing);
+console.log('✓ later wallet updates preserve existing ownership and management');
+assert.deepEqual(registrationIdentity({ signer: wallet, grant, granted: false }), { pubkey: wallet, manager: undefined });
+assert.deepEqual(registrationIdentity({ signer: owner, manager: wallet }), existing);
+assert.deepEqual(registrationIdentity({ signer: owner, manager: owner }), { pubkey: owner, manager: undefined });
+console.log('✓ ordinary claims retain their signer and ignore unauthorized grants');
diff --git a/tools/spending-restore-test.js b/tools/spending-restore-test.js
new file mode 100644
index 0000000..34cdd59
--- /dev/null
+++ b/tools/spending-restore-test.js
@@ -0,0 +1,119 @@
+// Snapshot-first Spending restore, without real relays, keys, or payments.
+// Run: bun tools/spending-restore-test.js
+import assert from 'node:assert/strict';
+import { slimArkForSync, mergeArkStates, arkFeature } from '../src/features/ark.js';
+import { installSyncWallet } from '../src/features/sync.js';
+import { ArkManager } from '../src/ark/manager.js';
+
+class Storage {
+ data = new Map();
+ get length() { return this.data.size; }
+ key(i) { return [...this.data.keys()][i] ?? null; }
+ getItem(k) { return this.data.get(k) ?? null; }
+ setItem(k, v) { this.data.set(k, String(v)); }
+ removeItem(k) { this.data.delete(k); }
+}
+globalThis.localStorage = new Storage();
+globalThis.fetch = async () => { throw new Error('Network disabled in restore test'); };
+const serverPubkey = '02' + 'ab'.repeat(32);
+const empty = () => ({ v: 1, serverPubkey, mailboxCheckpoint: 0, vtxos: [], actions: [], movements: [], scheduled: [] });
+const full = {
+ ...empty(), mailboxCheckpoint: 123456, nextKeyIndex: 401,
+ vtxos: Array.from({ length: 400 }, (_, i) => ({ id: 'coin-' + i, state: 'spent', amountSat: 100, keyIndex: 0 })),
+ movements: Array.from({ length: 400 }, (_, i) => ({ id: 'move-' + i, type: 'receive', status: 'complete', vtxoId: 'coin-' + i, amountSat: 100, ts: 1700000000000 + i })),
+ actions: Array.from({ length: 120 }, (_, i) => ({ id: 'send-' + i, type: 'send', step: 'done', amountSat: 50, inputIds: ['coin-' + i] })),
+};
+const snapshot = slimArkForSync(full);
+assert.equal(snapshot.movements.length, 400);
+assert.equal(snapshot.vtxos.length, 400);
+assert.equal(snapshot.actions.length, 120);
+assert.deepEqual(slimArkForSync(snapshot).actions[0].inputIds, ['coin-0']);
+assert.equal(mergeArkStates(empty(), snapshot).mailboxCheckpoint, 123456);
+const local = { ...empty(), mailboxCheckpoint: 10, movements: [{ id: 'local' }] };
+assert.equal(mergeArkStates(local, snapshot).mailboxCheckpoint, 10);
+assert.equal(mergeArkStates({ ...empty(), actions: [{ id: 'pending', step: 'signing' }] }, snapshot).mailboxCheckpoint, 0);
+const older = { ...empty(), vtxos: [{ id: 'coin-0', amountSat: 100, state: 'spendable' }] };
+assert.equal(mergeArkStates(snapshot, older).vtxos.find(v => v.id === 'coin-0').state, 'spent');
+console.log('✓ complete history, spent stubs and spend provenance survive repeated snapshotting');
+console.log('✓ fresh restores inherit the cursor; existing or unfinished local work does not skip messages');
+
+const hooks = { realtime: [], load: [] };
+const wallet = {
+ offline: false, netName: 'mainnet', txs: [], _savedAt: 0,
+ registerCacheSavedHook() {}, registerLoadHook: f => hooks.load.push(f),
+ registerRealtimeHook: h => hooks.realtime.push(h),
+ _mergeSnapshotExtensions(s) { if (s.arkState) this.arkState = mergeArkStates(this.arkState, s.arkState); },
+ _applySnapshot() {}, emit() {}, saveCache() {},
+};
+installSyncWallet(wallet, { outbox: null });
+wallet.nostr.load('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');
+let fetched = 0, subscribed = 0, finish;
+wallet.nostr.fetchAllStates = () => { fetched++; return new Promise(r => { finish = r; }); };
+wallet.nostr.subscribeStates = () => { subscribed++; return () => {}; };
+hooks.realtime[0].start();
+const restore = wallet.syncFromNostr();
+assert.equal(fetched, 1);
+assert.equal(subscribed, 0);
+finish([
+ { created_at: 2, dtag: 'bitcoin-wallet:x:one:ark', state: { netName: 'mainnet', arkState: snapshot } },
+ { created_at: 1, dtag: 'bitcoin-wallet:x:two:ark', state: { netName: 'mainnet', arkState: older } },
+]);
+assert.equal(await restore, true);
+await Promise.resolve();
+assert.equal(subscribed, 1);
+assert.equal(wallet.arkState.movements.length, 400);
+assert.equal(wallet.arkState.vtxos[0].state, 'spent');
+console.log('✓ initial fetch is shared and sorted before live snapshot delivery begins');
+hooks.realtime[0].stop();
+wallet.nostrRestoreReady = null;
+hooks.realtime[0].start();
+hooks.realtime[0].stop();
+const stopped = wallet.nostrRestoreReady;
+finish([]); await stopped; await Promise.resolve();
+assert.equal(subscribed, 1);
+console.log('✓ logout during restore does not restart the old identity subscription');
+
+// Real Ark feature lifecycle, with only the manager's network operations
+// replaced. Exercise the first-login case where state is held until the ASP
+// handshake establishes which server owns it.
+const original = {};
+for (const name of ['init', 'sync', 'startMailboxStream', 'stopMailboxStream']) original[name] = ArkManager.prototype[name];
+let syncs = 0, streams = 0, releaseRestore, releaseCatchup, manager;
+const restored = new Promise(r => { releaseRestore = r; });
+const catchup = new Promise(r => { releaseCatchup = r; });
+ArkManager.prototype.init = async function () {
+ manager = this;
+ this.info = { serverPubkey };
+ this.state = this.storage.adopt(serverPubkey) || this.storage.load() || empty();
+ return this;
+};
+ArkManager.prototype.sync = async function () { syncs++; assert.equal(this.state.mailboxCheckpoint, 123456); await catchup; };
+ArkManager.prototype.startMailboxStream = function () { streams++; };
+ArkManager.prototype.stopMailboxStream = function () {};
+let extension;
+const arkWallet = {
+ _cacheKey: () => 'fresh-spending-test', account: () => ({}),
+ registerCacheExtension: e => { extension = e; },
+ nostrRestoreReady: restored, saveCache() {}, loadFeatureState: () => ({}),
+};
+const feature = arkFeature({ wallet: arkWallet, ui: {}, render() {}, hook() {}, getAccount: () => 'spending' });
+const pause = () => new Promise(r => setTimeout(r, 20));
+try {
+ feature.init(); // no local Spending state yet
+ extension.load({ arkState: snapshot });
+ await pause();
+ assert.ok(manager, 'held snapshot starts a connection immediately, without the old 20-second throttle');
+ assert.equal(syncs, 0);
+ assert.equal(streams, 0);
+ assert.equal(manager.state.movements.length, 400);
+ releaseRestore(true); await pause();
+ assert.equal(syncs, 1);
+ assert.equal(streams, 0);
+ releaseCatchup(); await pause();
+ assert.equal(streams, 1);
+ console.log('✓ fresh Spending adopts the whole snapshot before catch-up, then starts its live stream');
+} finally {
+ feature.stop();
+ for (const [name, fn] of Object.entries(original)) ArkManager.prototype[name] = fn;
+}
+process.exit(0);
diff --git a/tools/thread-reply-test.js b/tools/thread-reply-test.js
new file mode 100644
index 0000000..c125ef8
--- /dev/null
+++ b/tools/thread-reply-test.js
@@ -0,0 +1,93 @@
+// Real reply buttons/composer with controlled relay replies and no publishing.
+// Run: bun tools/thread-reply-test.js
+import assert from 'node:assert/strict';
+import puppeteer from 'puppeteer-core';
+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';
+${dom}
+const author = 'a'.repeat(64), mine = 'b'.repeat(64), rootId = 'c'.repeat(64);
+const root = { id: rootId, pubkey: author, kind: 1, content: 'Root post', tags: [], created_at: 1 };
+const reply = { ...root, id: 'd'.repeat(64), content: 'The reply you tapped', tags: [['e', rootId, '', 'root']], created_at: 2 };
+const other = { ...reply, id: 'e'.repeat(64), content: 'Another reply not returned by the relay', created_at: 3 };
+const ui = { screen: 'wallet' }, waiting = [];
+window.sent = [];
+window.query = filter => filter.kinds?.[0] === 1 && (filter.ids || filter['#e']) ? new Promise(resolve => waiting.push({ filter, resolve })) : Promise.resolve([]);
+const signer = { signEvent: async e => ({ ...e, id: 'f'.repeat(64), pubkey: mine }) };
+let feature;
+const wallet = { loaded: true, nostrRelays: () => [], loadFeatureState: (k, d) => d, saveFeatureState() {} };
+function render() {
+ if (!feature) return;
+ document.querySelector('#app').replaceChildren(feature.screenView() || feature.row(author, reply, 'Someone'));
+}
+feature = messagesFeature({ h, wallet, ui, render, toast() {}, brandHeader: () => null,
+ hook: k => k === 'nostrLoginIdentity' ? { pubkey: mine, signer } : null });
+window.test = { ui, reply, other, root, render, waiting,
+ release: () => { for (const q of waiting.splice(0)) q.resolve(q.filter.ids ? [root] : []); },
+ overlay: () => {
+ ui.profilePk = author; ui.profOverThread = true; render();
+ document.querySelector('#outside').replaceChildren(feature.actions(author, other, { canZap: false }));
+ },
+ missing: () => {
+ const ev = { ...other, id: '1'.repeat(64), tags: [['e', '2'.repeat(64), '', 'root']] };
+ document.querySelector('#outside').replaceChildren(feature.actions(author, ev, { canZap: false }));
+ },
+ empty: () => { for (const q of waiting.splice(0)) q.resolve([]); },
+};
+render();
+`;
+const bundle = await Bun.build({ entrypoints: ['thread-test-entry'], target: 'browser', plugins: [{ name: 'thread-test', setup(build) {
+ build.onResolve({ filter: /^thread-test-entry$/ }, () => ({ path: 'entry', namespace: 'thread-test' }));
+ build.onLoad({ filter: /.*/, namespace: 'thread-test' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() }));
+ build.onLoad({ filter: /src\/features\/messages\.js$/ }, async ({ path }) => ({ loader: 'js', contents: (await Bun.file(path).text())
+ .replace("id: 'messages',", "id: 'messages', row: noteRow, actions: noteActions,") }));
+ build.onLoad({ filter: /src\/nostr\.js$/ }, async ({ path }) => ({ loader: 'js', contents: (await Bun.file(path).text())
+ .replace('export async function queryOn(relays, filter, maxWait = 1500) {', 'export async function queryOn(relays, filter, maxWait = 1500) { return window.query(filter);')
+ .replace('export async function publishOn(relays, evt) {', 'export async function publishOn(relays, evt) { window.sent.push(evt); return true;')
+ .replace(/export function subscribeOn\(([^)]*)\) \{/, 'export function subscribeOn($1) { return () => {};') }));
+} }] });
+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><div id="outside"></div><input id="elsewhere"><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(), 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 check = (name, value) => { assert(value, name); console.log('✓ ' + name); };
+const inline = () => page.evaluate(() => {
+ const input = document.querySelector('.thread-reply-input');
+ const box = input?.parentElement.parentElement;
+ return !!input && box.previousElementSibling?.dataset.zapPost === test.ui.noteThread.focusId;
+});
+try {
+ await page.goto(server.url.href);
+ await page.waitForSelector('#app .note-act');
+ 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')));
+ await page.type('.thread-reply-input', 'A reply before the root loads');
+ await page.click('.thread-reply-send');
+ await page.waitForFunction(() => sent.length === 1);
+ check('publishing targets the selected reply and its original root', await page.evaluate(() => sent[0].tags.some(t => t[0] === 'e' && t[1] === test.reply.id && t[3] === 'reply') && sent[0].tags.some(t => t[0] === 'e' && t[1] === test.root.id && t[3] === 'root')));
+ await page.waitForFunction(() => test.waiting.length === 2);
+ await page.evaluate(() => test.release());
+ await page.waitForFunction(() => document.body.innerText.includes('Root post'));
+ check('a relay result omitting the tapped reply keeps it and its composer', await inline());
+ await page.evaluate(() => test.overlay());
+ check('fixture starts with a profile covering the existing thread', await page.evaluate(() => test.ui.profOverThread && !document.querySelector('.thread-reply-input')));
+ await page.click('#outside .note-act');
+ check('Reply leaves the covering profile and inserts an uncached reply into the thread', await inline() && await page.evaluate(() => !test.ui.profOverThread && document.activeElement.matches('.thread-reply-input')));
+ await page.evaluate(() => test.missing());
+ await page.click('#outside .note-act');
+ await page.waitForFunction(() => test.waiting.length === 2);
+ await page.evaluate(() => test.empty());
+ await page.waitForFunction(() => !document.querySelector('#app .spinner'));
+ check('an unavailable root still leaves the selected post replyable', await inline());
+ await page.evaluate(() => { test.ui.noteThread = null; test.ui.profilePk = null; test.render(); document.querySelector('#elsewhere').focus(); });
+ await new Promise(r => setTimeout(r, 120));
+ check('deferred focus does not steal focus after leaving the thread', await page.evaluate(() => document.activeElement.id === 'elsewhere'));
+ assert.deepEqual(errors, []);
+ console.log('✓ no browser errors');
+} finally { await browser.close(); server.stop(true); }