From 88636e5c158c9e390eec25c4471207780857abeb Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 15 Sep 2026 21:37:47 -0700 Subject: Offline, the chat shows what it read: messages cache on arrival. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The room cache was written only on our own sends, deletes and attachments, so a member who only reads had nothing on an offline boot — "No messages yet" over a room read the day before, and no faces or names with it. Messages now persist as they arrive (debounced past the backfill burst), so the last fifty per channel are there without a network. Our own face never waits its turn for a thumbnail and never gives it up to the budget, so the header avatar paints offline too. tools/offline-cache-test.js boots the PWA build, caches a room, reloads fully offline and checks messages, names and thumbnails are all there. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z --- tools/offline-cache-test.js | 70 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 tools/offline-cache-test.js (limited to 'tools/offline-cache-test.js') diff --git a/tools/offline-cache-test.js b/tools/offline-cache-test.js new file mode 100644 index 0000000..d5e8e5a --- /dev/null +++ b/tools/offline-cache-test.js @@ -0,0 +1,70 @@ +// What an offline boot has to show: the room's recent messages from the +// local cache (written as they ARRIVE, not only when we send), names from +// the profile cache and most faces from local thumbnails. Boots the PWA +// build from dist/ so the service worker serves the shell offline. +// +// Run: bun run build && bun tools/offline-cache-test.js +import puppeteer from 'puppeteer-core'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { join } from 'node:path'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const dist = join(import.meta.dir, '../dist'); +const server = Bun.serve({ port: 5234, async fetch(req) { + const u = new URL(req.url); let p = u.pathname === '/' ? '/index.html' : u.pathname; + const f = Bun.file(dist + p); + if (await f.exists()) return new Response(f, { headers: { 'cache-control': 'no-store' } }); + return new Response(Bun.file(dist + '/index.html'), { headers: { 'cache-control': 'no-store' } }); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errs = []; page.on('pageerror', (e) => errs.push(e.message)); page.on('console', (m) => { if (m.type() === 'error') errs.push('console: ' + m.text().slice(0, 160)); }); +const click = (sel, t) => page.evaluate((s, x) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, 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.toLowerCase())) return true; await sleep(250); } return false; }; +const openMessages = () => page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /message/i.test(b.getAttribute('aria-label') || ''))?.click()); +const openCoinos = () => page.evaluate(() => { const e = [...document.querySelectorAll('.chat-thread-row')].find((n) => (n.querySelector('.chat-name') || {}).textContent === 'coinos'); if (e) e.click(); }); +const snap = () => page.evaluate(() => ({ + text: document.body.innerText.replace(/\s+/g, ' ').slice(0, 300), + bubbles: document.querySelectorAll('.chat-bubble').length, + rows: document.querySelectorAll('.chat-thread-row').length, + avaImg: document.querySelectorAll('.ava-img').length, + avaImgData: [...document.querySelectorAll('.ava-img')].filter((e) => /url\("?data:/.test(e.style.backgroundImage)).length, + fallback: document.querySelectorAll('.chat-avatar.fallback').length, + header: !!document.querySelector('.header-ava'), + headerIsImg: !!document.querySelector('.header-ava.ava-img'), + offlineBanner: document.body.innerText.includes('offline') || document.body.innerText.includes('Offline'), +})); +await page.setViewport({ width: 420, height: 860 }); +await page.goto('http://localhost:5234/', { waitUntil: 'domcontentloaded' }); +await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); +await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); +await click('button', 'Create a new wallet'); await sleep(300); +await click('button', 'Import existing'); await sleep(300); +await page.waitForSelector('textarea'); await page.type('textarea', generateMnemonic(wordlist)); +await click('button', 'Open wallet'); await waitText('receive', 15000); +await openMessages(); await sleep(800); await openCoinos(); +console.log('online room:', await waitText('GM Coinos community', 30000)); +await sleep(6000); // thumbnails, profiles, caches settle +console.log('online snapshot:', JSON.stringify(await snap())); +const sw = await page.evaluate(async () => { const r = await navigator.serviceWorker.getRegistration(); const ks = await caches.keys(); const c = ks.length ? await (await caches.open(ks[0])).keys() : []; return { reg: !!r, active: !!(r && r.active), caches: ks, cached: c.length }; }); +console.log('sw:', JSON.stringify(sw)); +const cdp = await page.target().createCDPSession(); await cdp.send('Network.enable'); +await cdp.send('Network.emulateNetworkConditions', { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }); +await sleep(500); +try { await page.reload({ waitUntil: 'domcontentloaded', timeout: 20000 }); } catch (e) { console.log('reload:', e.message.slice(0, 100)); } +await sleep(5000); +console.log('offline boot:', JSON.stringify(await snap())); +await openMessages(); await sleep(1500); +console.log('offline messages home:', JSON.stringify(await snap())); +await openCoinos(); await sleep(1500); +const room = await snap(); +console.log('offline room:', JSON.stringify(room).slice(0, 300)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +check('the room shows cached messages offline', room.bubbles >= 20, `${room.bubbles} bubbles`); +check('most faces paint from local thumbnails', room.avaImgData >= room.avaImg * 0.6, `${room.avaImgData} of ${room.avaImg}`); +check('names resolve from the profile cache', !/npub1[a-z0-9]{8}/.test(room.text.slice(0, 120)) || room.text.includes('Adam'), room.text.slice(40, 120)); +await page.screenshot({ path: process.argv[2] || '/tmp/offline.png' }); +await browser.close(); server.stop(true); +console.log(ok ? '\nALL GOOD' : '\nFAILURES'); +process.exit(ok ? 0 : 1); -- cgit v1.2.3