aboutsummaryrefslogtreecommitdiffstats
path: root/tools/ark-store-test.js
blob: 4e02fa0f0fcb991a01e6d34044fc1fd653f8e70d (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
// Ark state store: memory-fronted IndexedDB (a memory backend here) with
// localStorage migration by _rev, raw bytes on disk, clone-on-read,
// write-behind flush, wipe by prefix, and the localStorage fallback.
// Run: bun tools/ark-store-test.js
import assert from 'node:assert/strict';
import { ArkStore, memoryBackend } from '../src/ark/store.js';
import { vtxoBytesToStr } from '../src/ark/proto.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); }
}
const raw = new Uint8Array(64).map((_, i) => i * 3);
const b64 = vtxoBytesToStr(raw);
const K = 'btc-wallet-cache:abc123:ark:fsobdp';
const HOLD = 'btc-wallet-cache:abc123:ark:pk:03ff959f88f41d5f';
const OTHER = 'btc-wallet-cache:zzz999:ark:fsobdp';

// ---- migration: newest _rev wins, localStorage retired after the write ----
const ls = new Storage();
ls.setItem(K, JSON.stringify({ _rev: 7, vtxos: [{ id: 'a', state: 'spendable', bytes: b64 }], actions: [], movements: [] }));
ls.setItem(HOLD, JSON.stringify({ vtxos: [{ id: 'h', state: 'spendable', bytes: b64 }] }));
ls.setItem('btc-wallet-cache:abc123', '{"txs":[]}'); // the wallet cache is not ours
const backend = memoryBackend([[K, { _rev: 5, vtxos: [{ id: 'a', state: 'spendable', bytes: raw }], actions: [], movements: [] }],
  [OTHER, { _rev: 1, vtxos: [], actions: [], movements: [] }]]);
const store = new ArkStore({ backend, ls: () => ls });
assert.equal(store.ready, false);
await store.open();
assert.equal(store.get(K)._rev, 7, 'the newer localStorage copy wins');
assert.equal(store.get(HOLD).vtxos[0].bytes, b64, 'hold state migrates too');
await store.flush();
await new Promise((r) => setTimeout(r, 0));
assert.equal(ls.getItem(K), null, 'localStorage copy retired after the write');
assert.equal(ls.getItem(HOLD), null);
assert.equal(ls.getItem('btc-wallet-cache:abc123'), '{"txs":[]}', 'unrelated keys untouched');
assert.ok(backend.dump().get(K).vtxos[0].bytes instanceof Uint8Array, 'bytes are raw on disk');
assert.equal(store.get(K).vtxos[0].bytes, b64, 'and base64 strings in memory');
console.log('✓ localStorage state migrates by _rev, bytes land raw, old copies retired');

// ---- reads are clones; writes are write-behind ----
const got = store.get(K); got.vtxos.push({ id: 'evil' });
assert.equal(store.get(K).vtxos.length, 1, 'a caller cannot mutate the store through a read');
const writes = backend.writes;
store.set(K, { _rev: 8, vtxos: [{ id: 'a', state: 'spent', bytes: b64 }, { id: 'b', state: 'spendable', bytes: b64 }], actions: [], movements: [] });
assert.equal(store.get(K)._rev, 8, 'memory is current immediately');
assert.equal(backend.writes, writes, 'disk write is deferred');
await store.flush();
assert.equal(backend.dump().get(K)._rev, 8);
assert.equal(backend.dump().get(K).vtxos.length, 2);
store.remove(OTHER);
await store.flush();
assert.equal(backend.dump().has(OTHER), false);
console.log('✓ clone-on-read, coalesced write-behind, delete');

// ---- a fresh open reads back what was written ----
const again = new ArkStore({ backend, ls: () => new Storage() });
await again.open();
assert.equal(again.get(K).vtxos[1].bytes, b64, 'round trip through raw bytes');
assert.deepEqual(again.keys('btc-wallet-cache:abc123').sort(), [K, HOLD].sort(), 'keys by prefix');
console.log('✓ reopen restores state');

// ---- wipe by wallet prefix (both stores) ----
const ls2 = new Storage(); ls2.setItem('btc-wallet-cache:abc123:ark', '{"vtxos":[]}');
const wipe = new ArkStore({ backend, ls: () => ls2 });
await wipe.open(); await wipe.flush();
wipe.removePrefix('btc-wallet-cache:abc123');
await wipe.flush();
assert.equal(wipe.keys('btc-wallet-cache:abc123').length, 0);
assert.equal(backend.dump().has(K), false);
assert.equal(ls2.getItem('btc-wallet-cache:abc123:ark'), null);
console.log('✓ wallet wipe clears IndexedDB and localStorage copies');

// ---- no IndexedDB: localStorage exactly as before ----
const ls3 = new Storage();
const fallback = new ArkStore({ backend: null, ls: () => ls3 });
assert.equal(fallback.ready, true);
fallback.set(K, { _rev: 1, vtxos: [] });
assert.equal(JSON.parse(ls3.getItem(K))._rev, 1);
assert.equal(fallback.get(K)._rev, 1);
const broken = new ArkStore({ backend: { list: async () => { throw new Error('nope'); }, write: async () => {} }, ls: () => ls3 });
await broken.open();
assert.equal(broken.lsMode, true, 'a refusing IndexedDB drops to localStorage');
assert.equal(broken.get(K)._rev, 1);
console.log('✓ localStorage fallback');

console.log('\n✅ ark store behaves');
s="nx">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);