diff options
| author | Adam Soltys <[email protected]> | 2026-10-14 00:34:46 +0000 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-14 00:34:46 +0000 |
| commit | 928da70638fceb622d5e83ed0792817e1bcd908e (patch) | |
| tree | c7c819d957fd40eba8259e28f48e90ee62a722fc /tools | |
| parent | 8a5118839502adb52cd504867bb8ac602160a48c (diff) | |
| download | coinosv3-master.tar.gz coinosv3-master.zip | |
The × was a blind history.back(), and the entry under the pager isn't
always the feed: the browser's older entries outlive a background reload of
the app, and boot merges entries. It took the reader to the wallet, then the
chat list. closeLayer still steps Back (so the phone's Back doesn't reopen
it), but if Back lands anywhere but the page under the layer, the reader is
put on that page and that entry rewritten to it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/image-zoom-test.js | 1 | ||||
| -rw-r--r-- | tools/pager-close-test.js | 140 |
2 files changed, 141 insertions, 0 deletions
diff --git a/tools/image-zoom-test.js b/tools/image-zoom-test.js index 3ada708..0a11f0c 100644 --- a/tools/image-zoom-test.js +++ b/tools/image-zoom-test.js @@ -19,6 +19,7 @@ const goBack = (fn) => { if (backDelay) setTimeout(() => { fn(); render(); }, backDelay); else { fn(); render(); } }; +const closeLayer = (field) => goBack(() => { ui[field] = null; }); function render() { morphChildren(document.querySelector('#app'), ui.lightbox ? [imageViewer()] : []); } window.test = { ui, render, get backCalls() { return backCalls; }, get backSawViewer() { return backSawViewer; }, delayBack(ms) { backDelay = ms; }, diff --git a/tools/pager-close-test.js b/tools/pager-close-test.js new file mode 100644 index 0000000..73aa15b --- /dev/null +++ b/tools/pager-close-test.js @@ -0,0 +1,140 @@ +// Closing the pager lands on the feed under it, whatever the browser's +// history says. The × was Back, and the entry Back reached isn't always the +// feed (the browser's older entries outlive a reload of the app in the +// background; entries merge at boot): it took the reader to the wallet, then +// to the chat list. Now landing anywhere but the page under the pager puts +// the reader on that page, and that entry becomes it. And the ordinary case +// still steps Back, so the phone's Back doesn't reopen the pager. +// Run: bun tools/pager-close-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +import { existsSync } from 'node:fs'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const CLIP = '/tmp/coinos-tail-clip.webm'; +if (!existsSync(CLIP)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=180x320:rate=10', '-t', '20', '-c:v', 'libvpx', '-b:v', '100k', CLIP]); + +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (/^\/c\d\.webm$/.test(path)) return new Response(Bun.file(CLIP), { headers: { 'content-type': 'video/webm' } }); + if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const U = (n) => server.url.origin + '/' + n + '.webm'; +// what the phone has: two clips among words; what only the relays have (slowly): two older clips +const cached = [ + post('clip zero ' + U('c0'), 100), post('words between ' + 'lorem ipsum. '.repeat(6), 200), post('clip one ' + U('c1'), 300), +]; +const older = [post('clip two ' + U('c2'), 5000), post('more words ' + 'lorem ipsum. '.repeat(6), 5100), post('clip three ' + U('c3'), 5200)]; + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); +const page = await browser.newPage(); +// The relays: the newest posts at once; anything asked for by age (until) comes 3 s later +await page.evaluateOnNewDocument(([fresh, old]) => { + const all = [...fresh, ...old]; + const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id)) + && (!f.until || e.created_at <= f.until) && (!f.since || e.created_at >= f.since) && !Object.keys(f).some((k) => k.startsWith('#')); + window.__olderAsks = 0; + class FakeWS { + constructor(url) { this.url = String(url); this.readyState = 0; setTimeout(() => { this.readyState = 1; this.onopen && this.onopen({}); }, 5); } + send(raw) { + const m = JSON.parse(raw); + const reply = (x, ms = 5) => setTimeout(() => this.readyState === 1 && this.onmessage && this.onmessage({ data: JSON.stringify(x) }), ms); + if (m[0] === 'REQ') { + const filters = m.slice(2).filter((f) => !f.cache); + const slow = filters.some((f) => f.until); + if (slow) window.__olderAsks++; + const pool = slow ? all : fresh; + const ms = slow ? 3000 : 5; + for (const f of filters) { + const hits = pool.filter((e) => match(f, e)).sort((a, b) => b.created_at - a.created_at); + for (const e of f.limit ? hits.slice(0, f.limit) : hits) reply(['EVENT', m[1], e], ms); + } + reply(['EOSE', m[1]], ms + 5); + } else if (m[0] === 'EVENT') reply(['OK', m[1].id, true, '']); + else if (m[0] === 'COUNT') reply(['COUNT', m[1], { count: 0 }]); + } + close() { this.readyState = 3; this.onclose && this.onclose({ code: 1000 }); } + addEventListener(t, fn) { this['on' + t] = fn; } + removeEventListener() {} + } + FakeWS.CONNECTING = 0; FakeWS.OPEN = 1; FakeWS.CLOSING = 2; FakeWS.CLOSED = 3; + window.WebSocket = FakeWS; +}, [cached, older]); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; }; +// what the pager rests on: a clip's name, or 'tail' +const AT = () => { const sc = document.querySelector('.mp-scroll'); if (!sc) return null; const s = [...sc.querySelectorAll('.mp-slide, .mp-tail')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return !s ? 'between' : s.classList.contains('mp-tail') ? 'tail' : s.getAttribute('data-k').split('/').pop().replace('.webm', ''); }; +const at = () => page.evaluate(AT); +const resting = (name, ms = 5000) => waitFor(([f, n]) => eval(f)() === n, ['(' + AT + ')', name], ms); +const playing = (name) => waitFor((n) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => (x.getAttribute('data-k') || '').endsWith('/' + n + '.webm')); const v = s && s.querySelector('video.mp-media'); return v && !v.paused && v.currentTime > 0; }, name, 6000); +const tail = () => page.evaluate(() => { const t = document.querySelector('.mp-tail'); return t ? { text: t.textContent.trim(), done: t.classList.contains('done'), last: t === t.parentElement.lastElementChild } : null; }); + +const st = () => page.evaluate(() => ({ pager: !!document.querySelector('.media-pager'), feed: !!document.querySelector('.notes-feed'), chats: !!document.querySelector('.chat-list, .msg-list'), wallet: !document.querySelector('.notes-feed') && /receive/i.test(document.body.innerText), i: history.state && history.state.i })); +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); + await page.mouse.click(r.x, r.y); + return waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000); +}; +const close = async () => { await page.evaluate(() => document.querySelector('.mp-x').click()); await sleep(1200); }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); + await click('create a new wallet'); await sleep(600); + await 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 base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, cached]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await sleep(800); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Messages"]').click()); + await sleep(800); + const chatsNav = await page.evaluate(() => history.state); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + await page.waitForSelector('.notes-feed video.note-video', { timeout: 20000 }); + await sleep(1200); + + console.log('\n[the ordinary case: × steps Back, onto the feed]'); + const before = await page.evaluate(() => history.state.i); + check('the pager opens', await openFirst()); + await page.keyboard.press('ArrowDown'); await sleep(800); + await close(); + let s = await st(); + check('× lands on the feed', !s.pager && s.feed, JSON.stringify(s)); + check('…by stepping Back (the pager\'s entry gone, not one more on top)', s.i === before, s.i + ' vs ' + before); + + console.log('\n[the entry under the pager isn\'t the feed (it says: the chat list)]'); + await page.evaluate((nav) => history.replaceState({ ...history.state, nav: nav.nav }, ''), chatsNav); + check('the pager opens', await openFirst()); + await page.keyboard.press('ArrowDown'); await sleep(800); + await close(); + s = await st(); + check('× still lands on the feed, not the chat list', !s.pager && s.feed, JSON.stringify(s)); + const fixed = await page.evaluate(() => history.state && history.state.nav && history.state.nav.msgView); + check('…and that entry is the feed now', fixed === 'feed', String(fixed)); + check('the phone\'s Back from there leaves the feed (no pager reopening)', await (async () => { await page.evaluate(() => history.back()); await sleep(1000); return !(await st()).pager; })()); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ closing the pager lands on the feed under it' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
