diff options
| author | Adam Soltys <[email protected]> | 2026-10-07 08:40:21 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-07 08:40:21 -0700 |
| commit | 3aff6e4cc9eb97fc0fa488f1f95001f69f24330a (patch) | |
| tree | 932ed498851f9e2167a1c1419c96d630be0d02e2 | |
| parent | 6d288aeb194d2765282cf9fd72bd1f5fdd747a71 (diff) | |
| parent | 6c51dcf2542314d5fe883f756f0840ef51860d67 (diff) | |
| download | coinosv3-3aff6e4cc9eb97fc0fa488f1f95001f69f24330a.tar.gz coinosv3-3aff6e4cc9eb97fc0fa488f1f95001f69f24330a.zip | |
Merge branch 'worktree-agent-ad1d843e9e394974e'
| -rw-r--r-- | src/features/messages.js | 4 | ||||
| -rw-r--r-- | tools/feed-custom-test.js | 27 | ||||
| -rw-r--r-- | tools/feed-editor-relays-test.js | 13 | ||||
| -rw-r--r-- | tools/feed-follow-test.js | 43 | ||||
| -rw-r--r-- | tools/feed-list-test.js | 10 | ||||
| -rw-r--r-- | tools/feed-live-test.js | 37 | ||||
| -rw-r--r-- | tools/feed-media-cache-test.js | 5 | ||||
| -rw-r--r-- | tools/feed-media-views-test.js | 4 | ||||
| -rw-r--r-- | tools/feed-stability-test.js | 53 | ||||
| -rw-r--r-- | tools/feed-streaming-test.js | 24 | ||||
| -rw-r--r-- | tools/feed-switch-seed-test.js | 5 | ||||
| -rw-r--r-- | tools/feed-window-dom-test.js | 5 | ||||
| -rw-r--r-- | tools/markdown-note-test.js | 6 | ||||
| -rw-r--r-- | tools/moderation-test.js | 14 | ||||
| -rw-r--r-- | tools/poll-test.js | 3 | ||||
| -rw-r--r-- | tools/relays-settings-test.js | 4 |
16 files changed, 156 insertions, 101 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index bbec23e..3ab64d9 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -5680,7 +5680,9 @@ export function messagesFeature(ctx) { const task = (async () => { const got = (await storedMediaThumb(url)) || (await makeMediaThumb(url).catch(() => null)); if (got) { - if (got.thumb) await decodeAhead(got.thumb); + // no copy (small already, or a GIF): the original is what paints, + // so it's the one decoded ahead — the row mustn't decode it on sight + await decodeAhead(got.thumb || url); mediaReady.set(url, got); return; } diff --git a/tools/feed-custom-test.js b/tools/feed-custom-test.js index 039b166..c2c94de 100644 --- a/tools/feed-custom-test.js +++ b/tools/feed-custom-test.js @@ -73,7 +73,10 @@ 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 chips = () => page.evaluate(() => [...document.querySelectorAll('.feed-chip')].map((c) => (c.classList.contains('on') ? '*' : '') + c.textContent.trim())); +// the feeds' own chips (not the All / Images / … view switch under them) +const chips = () => page.evaluate(() => [...document.querySelectorAll('.feed-chips > .feed-chip')].map((c) => (c.classList.contains('on') ? '*' : '') + c.textContent.trim())); +// the Feed is a tab in the bottom nav (it was a row on the chat home) +const openFeed = () => page.evaluate(() => [...document.querySelectorAll('.app-nav-button')].find((b) => b.getAttribute('aria-label') === 'Feed')?.click()); const type = async (sel, text) => { await page.evaluate((q) => { const i = document.querySelector(q); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); }, sel); await page.click(sel); await page.type(sel, text); @@ -99,12 +102,11 @@ try { 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('btc-wallet-feed:' + k, 'following'); // a wallet with no feed remembered opens on Popular }, [base, AUTHOR, seeded]); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); - await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); - await sleep(800); - await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /feed/i.test(n.textContent)); if (e) e.click(); }); + await openFeed(); await sleep(1200); check('the feed opens on Following, with a + chip', JSON.stringify(await chips()) === JSON.stringify(['*Following', 'Popular', '#gardenstr', '+']), JSON.stringify(await chips())); @@ -112,7 +114,7 @@ try { await page.click('.feed-chip.add'); await sleep(300); check('the editor opens', await waitText('new feed', 3000)); await type('input[placeholder="e.g. Bitcoin builders"]', 'Coin talk'); - await page.click('input[type=checkbox]'); + await page.click('input[type=checkbox]:not(.feed-all-toggle)'); // everyone I follow (the first box is the relays' every-post one) await type('input[placeholder="#bitcoin #nostr"]', '#Bitcoin, lightning'); await click('save'); await sleep(1500); check('the new feed is on screen and selected', JSON.stringify(await chips()) === JSON.stringify(['Following', 'Popular', '#gardenstr', '*Coin talk', '+']), JSON.stringify(await chips())); @@ -157,15 +159,18 @@ try { console.log('\n[the feeds survive a reload; a feed can be deleted]'); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); - await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); await sleep(800); - await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /feed/i.test(n.textContent)); if (e) e.click(); }); await sleep(1200); - check('the saved feeds are still there; the session topic is not', JSON.stringify(await chips()) === JSON.stringify(['*Following', 'Popular', '#gardenstr', 'Coin talk', 'Pack feed', '+']), JSON.stringify(await chips())); - check('the home list shows the pack feed with its pack', await page.evaluate(() => { const b = document.querySelector('.chat-back'); if (b) b.click(); return !!b; }) && await waitText('test pack', 3000)); - await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => (n.querySelector('.chat-name') || {}).textContent === 'Feed'); if (e) e.click(); }); await sleep(800); + await openFeed(); await sleep(1200); + // (the feed you were on is remembered: the pack feed, saved last) + check('the saved feeds are still there; the session topic is not', JSON.stringify(await chips()) === JSON.stringify(['Following', 'Popular', '#gardenstr', 'Coin talk', '*Pack feed', '+']), JSON.stringify(await chips())); + // (the chat home no longer lists the feed with its summary: its editor shows the pack) + await page.click('[aria-label="Feed options"]'); await sleep(300); + await page.evaluate(() => [...document.querySelectorAll('.msg-sheet button')].find((b) => /edit feed/i.test(b.textContent)).click()); await sleep(400); + check('the pack feed kept its pack', await waitText('test pack', 3000)); + await page.click('.chat-back'); await sleep(600); await page.evaluate(() => [...document.querySelectorAll('.feed-chip')].find((c) => c.textContent.trim() === 'Coin talk').click()); await sleep(800); await page.click('[aria-label="Feed options"]'); await sleep(300); // the editor lives in the feed's ⋯ menu now await page.evaluate(() => [...document.querySelectorAll('.msg-sheet button')].find((b) => /edit feed/i.test(b.textContent)).click()); await sleep(400); - check('the editor shows what was saved', await page.evaluate(() => document.querySelector('input[placeholder="#bitcoin #nostr"]').value === '#bitcoin #lightning' && document.querySelector('input[type=checkbox]').checked)); + check('the editor shows what was saved', await page.evaluate(() => document.querySelector('input[placeholder="#bitcoin #nostr"]').value === '#bitcoin #lightning' && document.querySelector('input[type=checkbox]:not(.feed-all-toggle)').checked)); await click('delete feed'); await sleep(1000); check('deleting it lands back on Following', JSON.stringify(await chips()) === JSON.stringify(['*Following', 'Popular', '#gardenstr', 'Pack feed', '+']), JSON.stringify(await chips())); check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); diff --git a/tools/feed-editor-relays-test.js b/tools/feed-editor-relays-test.js index 7e3edb5..5a808a7 100644 --- a/tools/feed-editor-relays-test.js +++ b/tools/feed-editor-relays-test.js @@ -10,16 +10,21 @@ 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 html = await buildHtml({ minify: true, pwa: false }); -const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +// nos.lol itself is a stand-in served here (an empty relay: EOSE to every +// REQ), so the test doesn't hang on whether the real one answers today +const server = Bun.serve({ port: 0, + fetch: (req, srv) => (new URL(req.url).pathname === '/relay' && srv.upgrade(req) ? undefined : new Response(html, { headers: { 'content-type': 'text/html' } })), + websocket: { message: (ws, data) => { try { const m = JSON.parse(data); if (m[0] === 'REQ') ws.send(JSON.stringify(['EOSE', m[1]])); } catch {} } } }); +const RELAY = 'ws://localhost:' + server.port + '/relay'; const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); const page = await browser.newPage(); const errs = []; page.on('pageerror', (e) => errs.push(String(e))); // record what the page asks which relay, without letting it publish anything -await page.evaluateOnNewDocument(() => { +await page.evaluateOnNewDocument((stand) => { window.__reqs = []; const Real = window.WebSocket; window.WebSocket = function (url, ...a) { - const ws = new Real(url, ...a); + const ws = new Real(/^wss:\/\/nos\.lol\/?$/.test(String(url)) ? stand : url, ...a); const send = ws.send.bind(ws); ws.send = (data) => { try { const m = JSON.parse(data); if (m[0] === 'REQ') window.__reqs.push({ url: String(url), filters: m.slice(2) }); if (m[0] === 'EVENT') return; } catch {} @@ -28,7 +33,7 @@ await page.evaluateOnNewDocument(() => { return ws; }; window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); -}); +}, RELAY); 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 text = () => page.evaluate(() => document.body.innerText); const waitFor = async (fn, ms = 15000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; }; diff --git a/tools/feed-follow-test.js b/tools/feed-follow-test.js index 50a572b..bae06a9 100644 --- a/tools/feed-follow-test.js +++ b/tools/feed-follow-test.js @@ -20,7 +20,9 @@ const server = Bun.serve({ port: 5237, fetch: () => new Response(html, { headers const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); const page = await browser.newPage(); 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 clickItem = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('.item, button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +// the Feed is a tab in the bottom nav (it used to be offered on the messages home) +const openFeed = () => page.evaluate(() => { const b = [...document.querySelectorAll('.app-nav-button')].find((e) => e.getAttribute('aria-label') === 'Feed'); if (b) b.click(); return !!b; }); +const chip = (name) => page.evaluate((n) => { const e = [...document.querySelectorAll('.feed-chips > .feed-chip')].find((b) => b.textContent.trim() === n); if (e) e.click(); return !!e; }, name); const text = () => page.evaluate(() => document.body.innerText); const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await text()).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; const FOLLOWED = 'e'.repeat(63) + '1'; @@ -40,12 +42,10 @@ try { await waitText('receive', 20000); const base = cacheKeyFor(mnemonic + '\n'); - // chat → the feed is offered before the conversations - await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); - await sleep(1200); - check('the feed is on the messages home', /follow people to fill this/i.test(await text()), (await text()).split('\n').slice(0, 6).join(' | ')); - - await clickItem('feed'); + check('the bottom nav has a Feed tab', await openFeed()); + await sleep(1000); + // a new wallet opens on Popular; Following is the people you follow + check('...whose chips include Following', await chip('Following')); await sleep(1000); check('a wallet that follows nobody is told so', /don't follow anyone yet/i.test(await text())); check('...and offered somewhere to start', /find people/i.test(await text())); @@ -56,9 +56,11 @@ try { }, [base, FOLLOWED]); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); - await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); + await openFeed(); await sleep(1200); - check('the follow count shows on the messages home', /posts from the one person you follow/i.test(await text()), (await text()).match(/Posts from[^\n]*/)?.[0] || 'no line'); + // (the feed you were on, Following, is remembered) + check('Following no longer says you follow nobody', /\bFollowing\b/.test(await page.evaluate(() => document.querySelector('.feed-chips > .feed-chip.on')?.textContent || '')) + && !/don't follow anyone yet/i.test(await text())); // and their own profile page knows we follow them await page.goto('http://localhost:5237/' + npubEncode(FOLLOWED), { waitUntil: 'domcontentloaded' }); @@ -89,9 +91,7 @@ try { }, [base, FOLLOWED, OTHER]); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); - await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); - await sleep(1000); - await clickItem('feed'); + await openFeed(); await sleep(6000); const dialled = await page.evaluate(() => window.__dialled || []); check('each author is read on their own write relay', @@ -114,35 +114,32 @@ try { }, [base, FOLLOWED]); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); - await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); - await sleep(1000); - await clickItem('feed'); + await openFeed(); await sleep(6000); const wide = await page.evaluate(() => window.__dialled || []); const hit = [1, 2, 3, 4, 5, 6].filter((n) => wide.some((u) => u.includes('r' + n + '.example.invalid'))); check('an author who publishes to six relays is read on all six', hit.length === 6, 'reached ' + (hit.join(',') || 'none')); - // The feed opens at twenty posts and grows as you reach the bottom, however - // many it is holding. + // The feed opens at ten posts and grows as you reach the bottom, however + // many it is holding. (Tall posts: a page of short ones doesn't reach the + // distance from the bottom where the next page is read.) await page.evaluate((k) => { const notes = []; const now = Math.floor(Date.now() / 1000); - for (let i = 0; i < 60; i++) notes.push({ id: String(i).padStart(64, '0'), pubkey: 'f'.repeat(64), kind: 1, created_at: now - i * 60, content: 'post ' + i, tags: [] }); + for (let i = 0; i < 60; i++) notes.push({ id: String(i).padStart(64, '0'), pubkey: 'f'.repeat(64), kind: 1, created_at: now - i * 60, content: 'post ' + i + ' ' + 'with enough words to stand tall '.repeat(30), tags: [] }); localStorage.setItem(k + ':feedNotes', JSON.stringify(notes)); }, base); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); - await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); - await sleep(1000); - await clickItem('feed'); + await openFeed(); await sleep(1500); const first = await page.evaluate(() => document.querySelectorAll('.notes-feed > .row').length); - check('the feed opens at twenty posts, not sixty', first === 20, first + ' rows'); + check('the feed opens at ten posts, not sixty', first === 10, first + ' rows'); await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); await sleep(1200); const second = await page.evaluate(() => document.querySelectorAll('.notes-feed > .row').length); - check('reaching the bottom shows twenty more', second === 40, second + ' rows'); + check('reaching the bottom shows ten more', second === 20, second + ' rows'); } finally { await browser.close(); server.stop(true); } console.log(ok ? '\n✅ follows and feed' : '\n❌ failed'); process.exit(ok ? 0 : 1); diff --git a/tools/feed-list-test.js b/tools/feed-list-test.js index bd88846..641d43d 100644 --- a/tools/feed-list-test.js +++ b/tools/feed-list-test.js @@ -71,9 +71,8 @@ const type = async (sel, text) => { await page.click(sel); await page.type(sel, text); }; const openFeed = async () => { - await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); - await sleep(800); - await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => (n.querySelector('.chat-name') || {}).textContent === 'Feed'); if (e) e.click(); }); + // the Feed is a tab in the bottom nav (it was a row on the chat home) + await page.evaluate(() => [...document.querySelectorAll('.app-nav-button')].find((b) => b.getAttribute('aria-label') === 'Feed')?.click()); await sleep(1200); }; @@ -97,6 +96,7 @@ try { 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('btc-wallet-feed:' + k, 'following'); // a wallet with no feed remembered opens on Popular }, [base, AUTHOR, seeded]); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); @@ -149,7 +149,9 @@ try { const d2 = pub2 && (pub2.tags.find((x) => x[0] === 'd') || [])[1]; console.log('\n[unticking removes the list from the relays; the feed stays]'); - await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /edit feed/i.test(b.getAttribute('aria-label') || '')).click()); await sleep(400); + // the editor lives in the feed's ⋯ menu now + await page.click('[aria-label="Feed options"]'); await sleep(300); + await page.evaluate(() => [...document.querySelectorAll('.msg-sheet button')].find((b) => /edit feed/i.test(b.textContent)).click()); await sleep(400); check('the editor has the list toggle on', await page.evaluate(() => { const c = document.querySelector('.feed-list-toggle'); return c && c.checked; })); await page.evaluate(() => { window.__published = []; document.querySelector('.feed-list-toggle').click(); }); await click('save'); await sleep(1500); diff --git a/tools/feed-live-test.js b/tools/feed-live-test.js index 9f98fde..8f80d46 100644 --- a/tools/feed-live-test.js +++ b/tools/feed-live-test.js @@ -105,12 +105,12 @@ try { 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: 'Test Author', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); // a new wallet would open on Popular }, [base, AUTHOR, seeded]); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); - await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); - await sleep(1000); - await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /feed/i.test(n.textContent)); if (e) e.click(); }); + // the Feed is a tab in the bottom nav (it was a row on the chat home) + await page.evaluate(() => [...document.querySelectorAll('.app-nav-button')].find((b) => b.getAttribute('aria-label') === 'Feed')?.click()); await sleep(4000); check('the feed has posts in it', (await page.evaluate(() => document.querySelectorAll('.notes-feed > .row').length)) > 0); @@ -144,8 +144,12 @@ try { await page.evaluate(() => document.querySelector('.feed-new-pill').click()); await sleep(600); - const opened = await page.evaluate(() => ({ y: Math.round(window.scrollY), pill: !!document.querySelector('.feed-new-pill') })); - check('tapping it takes you up to the post', opened.y < 120, 'scrollY ' + opened.y); + // the pill lands on the (oldest) new post, at the top of the screen + const opened = await page.evaluate(() => { + const r = [...document.querySelectorAll('.notes-feed > .row')].find((n) => /BRAND NEW POST/.test(n.innerText)); + return { top: r ? Math.round(r.getBoundingClientRect().top) : null, pill: !!document.querySelector('.feed-new-pill') }; + }); + check('tapping it takes you up to the post', opened.top !== null && opened.top >= 0 && opened.top < 120, 'post top ' + opened.top); check('...and the pill is done', !opened.pill); console.log('\n[scrolling back up on your own]'); @@ -238,6 +242,12 @@ try { const r = [...document.querySelectorAll('.notes-feed > .row')].find((n) => eval(label)(n) === text); return r ? { text, top: Math.round(r.getBoundingClientRect().top), y: Math.round(window.scrollY) } : null; }, label.toString(), text); + // up to the top the way a reader goes, a screen at a time (one jump from + // far down lands before the rows up there are mounted again) + const scrollUp = async () => { + while (await page.evaluate(() => { scrollBy(0, -Math.min(scrollY, innerHeight)); return scrollY > 0; })) await sleep(80); + await sleep(600); + }; const topRow = () => page.evaluate(() => (document.querySelector('.notes-feed > .row') || {}).innerText || ''); console.log('\n[coming back after being away, a few new posts]'); @@ -260,23 +270,20 @@ try { await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); await sleep(600); check('...older posts that merely missed the cache went in quietly too', /not in the cache/.test(await feedText())); - await page.evaluate(() => window.scrollTo(0, 0)); - await sleep(600); + await scrollUp(); check('...scrolling up reaches the newest post, and clears the pill', /CATCH-UP 1/.test(await topRow()) && !(await page.$('.feed-new-pill')), (await topRow()).slice(0, 40)); console.log('\n[coming back while sitting at the top]'); const wasTop = await anchor(); await away(() => [signed('AT-THE-TOP A', 0), signed('AT-THE-TOP B', 1)]); const a2 = await rowAt(wasTop.text); - check('the header and first post stay in place', !!wasTop && !!a2 && a2.text === wasTop.text && Math.abs(a2.top - wasTop.top) <= 2 && a2.y === wasTop.y, + // new posts go in above the first post, which is held where it was (the + // page grows upward, scrolled by what went in above it) + check('the first post stays in place', !!wasTop && !!a2 && a2.text === wasTop.text && Math.abs(a2.top - wasTop.top) <= 2, JSON.stringify({ was: wasTop, now: a2 })); - check('...new posts wait while the header is visible', !/AT-THE-TOP A/.test(await topRow())); - await page.evaluate(() => { - const first = document.querySelector('.notes-feed > .row'); - scrollBy(0, Math.ceil(first.getBoundingClientRect().top)); - }); - await sleep(600); - check('...then arrive offscreen above it, with a notice', /AT-THE-TOP A/.test(await topRow()) && /2 new posts/i.test(await page.evaluate(() => document.querySelector('.feed-new-pill')?.textContent || ''))); + const atTop = await rowAt('AT-THE-TOP A'); + check('...the new posts went in above it', /AT-THE-TOP A/.test(await topRow()) && !!atTop && !!a2 && atTop.top < a2.top, JSON.stringify(atTop)); + check('...with a notice', /2 new posts/i.test(await page.evaluate(() => document.querySelector('.feed-new-pill')?.textContent || ''))); console.log('\n[coming back after a long time away]'); await page.evaluate(() => window.scrollTo(0, 900)); diff --git a/tools/feed-media-cache-test.js b/tools/feed-media-cache-test.js index fa90a15..46c485d 100644 --- a/tools/feed-media-cache-test.js +++ b/tools/feed-media-cache-test.js @@ -79,7 +79,7 @@ await page.evaluateOnNewDocument(([evs, pk]) => { 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, limit: f.limit }); + window.__reqs.push({ until: f.until, limit: f.limit, since: f.since }); out = evs.filter((e) => !f.until || e.created_at <= f.until).slice(0, f.limit || 30); } setTimeout(() => { @@ -142,7 +142,8 @@ try { check('all six clips are found, the deepest 150 posts down', await waitFor(() => document.querySelectorAll('.media-grid .media-tile.video').length === 6, 15000), String(await tiles())); const took = Date.now() - t0; check('...without waiting on the slow picture along the way', took < 8000, took + ' ms'); - const reqs = await page.evaluate(() => window.__reqs); + // (the feed's live watch for new posts — a since-only subscription — isn't the crawl) + const reqs = await page.evaluate(() => window.__reqs.filter((r) => !(r.since && !r.until && !r.limit))); const steps = [...new Set(reqs.map((r) => r.until))]; // each step asks every relay check('...asking a hundred posts at a time, each step older than the last', reqs.length >= 1 && reqs.every((r) => r.limit === 100 && r.until) && steps.every((u, i) => !i || u < steps[i - 1]), JSON.stringify(steps)); diff --git a/tools/feed-media-views-test.js b/tools/feed-media-views-test.js index ccfd636..e29d346 100644 --- a/tools/feed-media-views-test.js +++ b/tools/feed-media-views-test.js @@ -126,8 +126,8 @@ try { await page.waitForSelector('.notes-feed:not([data-booting])', { timeout: 20000 }); console.log('\n[the switch]'); - check('All / Images / Videos sit under the chips, All on', await page.evaluate(() => - [...document.querySelectorAll('.media-mode')].map((b) => b.textContent + (b.classList.contains('on') ? '*' : '')).join(',') === 'All*,Images,Videos')); + check('All / Images / Videos / Articles sit under the chips, All on', await page.evaluate(() => + [...document.querySelectorAll('.media-mode')].map((b) => b.textContent + (b.classList.contains('on') ? '*' : '')).join(',') === 'All*,Images,Videos,Articles')); console.log('\n[Images: a grid]'); await mode('images'); diff --git a/tools/feed-stability-test.js b/tools/feed-stability-test.js index 8b969f1..1426bba 100644 --- a/tools/feed-stability-test.js +++ b/tools/feed-stability-test.js @@ -83,6 +83,8 @@ await page.evaluateOnNewDocument((fixtures) => { }, { profile, quote }); const click = (text) => page.evaluate((text) => [...document.querySelectorAll('button')].find((e) => e.textContent.toLowerCase().includes(text))?.click(), text); const row = (id) => `.notes-feed > [data-key="${id}"]`; +// the Feed is a tab in the bottom nav (it was a row on the chat home) +const feedTab = () => [...document.querySelectorAll('.app-nav-button')].find((b) => b.getAttribute('aria-label') === 'Feed'); const snapshot = () => page.evaluate(() => [...document.querySelectorAll('.notes-feed > [data-zap-post]')].map((n) => { const b = n.getBoundingClientRect(); return { id: n.dataset.key, top: b.top, height: b.height, text: n.innerText, avatar: n.querySelector('.note-avatar')?.getAttribute('style') }; @@ -103,15 +105,14 @@ try { localStorage.setItem(base + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); localStorage.setItem(base + ':feedNotes', JSON.stringify(seeded)); localStorage.setItem(base + ':profiles', JSON.stringify({ [pk]: { name: 'Cached Author', picture: origin + '/avatar.png', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + base, 'following'); // a wallet with no feed remembered opens on Popular }, [base, pk, seeded, origin]); await page.reload(); await page.waitForFunction(() => document.body.innerText.toLowerCase().includes('receive')); await sleep(2000); check('a small feed query starts before opening the feed', await page.evaluate(() => - !document.querySelector('.notes-feed') && (window.__feedRequests || []).some((f) => f.kinds?.length === 1 && f.kinds[0] === 1 && f.limit === 10))); - await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || ''))?.click()); - await page.waitForSelector('.item'); - await page.evaluate(() => [...document.querySelectorAll('.item')].find((e) => /feed/i.test(e.textContent))?.click()); + !document.querySelector('.notes-feed') && (window.__feedRequests || []).some((f) => f.kinds?.includes(1) && !f.kinds.some((k) => k !== 1 && k !== 1068) && f.limit === 10))); + await page.evaluate((tab) => eval(tab)()?.click(), feedTab.toString()); await sleep(1000); // Cached posts paint at once — never a spinner over posts we hold — and // settle into their prepared presentation once faces and pictures are in. @@ -127,21 +128,30 @@ try { const settledRows = new Map((await snapshot()).map((r) => [r.id, r])); check('late image responses do not change admitted rows', initial.every((r) => JSON.stringify(r) === JSON.stringify(settledRows.get(r.id)))); - // With the header visible, a prepend must wait: anchoring only the first - // post would otherwise scroll the header offscreen. - const headerBefore = await page.evaluate(() => ({ y: scrollY, text: document.querySelector('.chat-page > .row').innerText })); + // With the header visible, a new post goes in above the first post, which + // is held where it is (the page grows upward; the header scrolls off by + // what went in above it). + const firstBefore = await page.evaluate(() => { const r = document.querySelector('.notes-feed > [data-zap-post]'); return { id: r.dataset.key, top: r.getBoundingClientRect().top }; }); const fresh = event('A new live post', now + 1); check('a live subscription received the event', await page.evaluate((e) => window.__inject(e), fresh) > 0); - await sleep(1000); - check('live arrival leaves the visible header and posts untouched', await page.evaluate((before) => - scrollY === before.y && document.querySelector('.chat-page > .row').innerText === before.text, headerBefore)); - check('new post waits until it can be inserted offscreen', !(await page.$(row(fresh.id)))); + await page.waitForSelector(row(fresh.id), { timeout: 5000 }).catch(() => {}); + check('live arrival at the top holds the first post still', await page.evaluate((b) => { + const r = document.querySelector('.notes-feed > [data-key="' + b.id + '"]'); + return !!r && Math.abs(r.getBoundingClientRect().top - b.top) <= 1; + }, firstBefore)); + check('...and goes in above it', await page.evaluate(([f, id]) => { + const a = document.querySelector('.notes-feed > [data-key="' + f + '"]'), b = document.querySelector('.notes-feed > [data-key="' + id + '"]'); + return !!a && !!b && a.getBoundingClientRect().top < b.getBoundingClientRect().top; + }, [fresh.id, firstBefore.id])); - // Once the reader scrolls the header away, preserve every visible row on - // every animation frame, including the first frame of the prepend. + // Once the reader has scrolled the header away, preserve every visible row + // on every animation frame, including the first frame of the prepend. await page.evaluate(() => { - const first = document.querySelector('.notes-feed > [data-zap-post]'); + const first = [...document.querySelectorAll('.notes-feed > [data-zap-post]')][1]; scrollBy(0, Math.ceil(first.getBoundingClientRect().top)); + }); + await sleep(500); + await page.evaluate(() => { window.__drift = []; window.__held = [...document.querySelectorAll('.notes-feed > [data-zap-post]')].filter((r) => { const b = r.getBoundingClientRect(); return b.bottom > 0 && b.top < innerHeight; @@ -152,11 +162,13 @@ try { if (window.__sampling) requestAnimationFrame(frame); }; requestAnimationFrame(frame); }); - await page.waitForSelector(row(fresh.id)); + const fresh2 = event('Another live post', now + 2); + check('a second live post is received', await page.evaluate((e) => window.__inject(e), fresh2) > 0); + await page.waitForSelector(row(fresh2.id)); await sleep(500); const drift = await page.evaluate(() => { window.__sampling = false; return window.__drift; }); check('offscreen arrival never moves a visible row, even for one frame', !drift.length, JSON.stringify(drift.slice(0, 3))); - check('new rows do not animate their height', await page.$eval(row(fresh.id), (n) => !n.getAnimations().length)); + check('new rows do not animate their height', await page.$eval(row(fresh2.id), (n) => !n.getAnimations().length)); const gap = event('Gap inside visible posts', now - 630); await page.evaluate((e) => { @@ -206,12 +218,11 @@ try { }, [base, seeded.filter((_, i) => ![1, 3, 8, 12].includes(i)).slice(0, 10)]); await page.reload(); await sleep(5500); - await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || ''))?.click()); - await page.waitForSelector('.item'); - check('first opening after background preparation has rows and no spinner', await page.evaluate(() => { - [...document.querySelectorAll('.item')].find((e) => /feed/i.test(e.textContent))?.click(); + await page.waitForSelector('.app-nav-button'); + check('first opening after background preparation has rows and no spinner', await page.evaluate((tab) => { + eval(tab)()?.click(); return !!document.querySelector('.notes-feed > .row') && !document.querySelector('.chat-page .spinner'); - })); + }, feedTab.toString())); check('no browser errors', !errors.length, errors.join(' | ')); } finally { await browser.close(); server.stop(true); } process.exit(ok ? 0 : 1); diff --git a/tools/feed-streaming-test.js b/tools/feed-streaming-test.js index e662b42..3070771 100644 --- a/tools/feed-streaming-test.js +++ b/tools/feed-streaming-test.js @@ -51,6 +51,7 @@ const server = Bun.serve({ port: 0, fetch(req) { if (path.startsWith('/punks')) return new Response(png, { headers: { 'Content-Type': 'image/png' } }); return new Response(html, { headers: { 'Content-Type': 'text/html' } }); } }); +const heldPng = { status: 200, contentType: 'image/png', body: png }; const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); const errors = []; try { @@ -61,10 +62,23 @@ try { await page.emulateMediaFeatures([{ name: 'prefers-reduced-motion', value: 'reduce' }]); await page.setRequestInterception(true); page.on('request', (r) => { - if (r.url() === 'https://media.example/held.png') { page.heldImage = r; return; } + if (r.url() === 'https://media.example/held.png') { + // held until the test lets it go; once it has, asking again (the + // picture is read more than once on its way in) is answered at once + if (page.released) r.respond(heldPng); else page.heldImage = r; + return; + } if (r.url().startsWith(server.url.origin) || r.url().startsWith('data:')) r.continue(); else r.abort(); }); await page.evaluateOnNewDocument(() => { + // the stub wallet's feed is Following (a wallet with none remembered opens on Popular) + try { localStorage.setItem('btc-wallet-feed:', 'following'); } catch {} + // rows that went on screen wearing the new-post tint (it fades the + // moment a row is seen, so the class alone can't be read afterwards) + window.freshSeen = new Set(); + new MutationObserver((ms) => { for (const m of ms) for (const n of m.type === 'attributes' ? [m.target] : m.addedNodes) + if (n.classList?.contains('note-fresh')) window.freshSeen.add(n.getAttribute('data-key')); }) + .observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] }); const sockets = []; window.feedClosed = 0; class FakeSocket extends EventTarget { @@ -125,7 +139,7 @@ try { assert.equal(await count(page), 2, 'out-of-order posts count and duplicates do not'); assert(Math.abs(await page.$eval(row(reading), (r) => r.getBoundingClientRect().top) - held) <= 1, 'streaming preserves reading position'); assert(page.heldImage, 'slow media is being prepared'); - await page.heldImage.respond({ status: 200, contentType: 'image/png', body: png }); + page.released = true; await page.heldImage.respond(heldPng); await page.waitForSelector(row(slow.id), { timeout: 2000 }); assert.equal(await count(page), 3); assert(await page.$eval(row(slow.id), (r) => { const img = r.querySelector('.note-img'); return img?.complete && img.naturalWidth > 0; }), 'photo is decoded when admitted'); @@ -173,9 +187,9 @@ try { assert(await page.evaluate(() => !window.bootDone), 'new-post notice does not wait for cached media'); await page.click('.feed-new-pill'); await page.waitForSelector(row(fast.id)); - assert(await page.$eval(row(fast.id), (r) => r.classList.contains('note-fresh')), 'prepared new rows keep their stable presentation during cache warm-up'); + assert(await page.evaluate((id) => window.freshSeen.has(id), fast.id), 'prepared new rows keep their stable presentation during cache warm-up'); assert(page.heldImage); - await page.heldImage.respond({ status: 200, contentType: 'image/png', body: png }); + page.released = true; await page.heldImage.respond(heldPng); await page.waitForFunction(() => window.bootDone); await page.evaluate(() => window.endQueries()); await page.waitForFunction(() => window.refreshDone); @@ -192,7 +206,7 @@ try { await page.click('.feed-new-pill'); await page.waitForSelector(row(fast.id)); assert(page.heldImage); - await page.heldImage.respond({ status: 200, contentType: 'image/png', body: png }); + page.released = true; await page.heldImage.respond(heldPng); await page.waitForFunction(() => window.batchDone); await page.evaluate(() => window.endQueries()); await page.waitForFunction(() => window.refreshDone); diff --git a/tools/feed-switch-seed-test.js b/tools/feed-switch-seed-test.js index e747744..1bad161 100644 --- a/tools/feed-switch-seed-test.js +++ b/tools/feed-switch-seed-test.js @@ -81,8 +81,9 @@ try { localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); localStorage.setItem(k + ':profiles', JSON.stringify({ [a]: { name: 'Alice', t }, [b]: { name: 'Bob', t } })); const s = JSON.parse(localStorage.getItem(k + ':messages') || '{}'); - s.feeds = [{ id: 'bob', name: 'Bobfeed', follows: false, authors: [b], topics: [], packs: [] }, - { id: 'btc', name: 'Btcfeed', follows: false, authors: [], topics: ['bitcoin'], packs: [] }]; + // (a feed carries its edit time, as the editor writes it: the feeds sync merge drops one without) + s.feeds = [{ id: 'bob', name: 'Bobfeed', follows: false, authors: [b], topics: [], packs: [], at: t }, + { id: 'btc', name: 'Btcfeed', follows: false, authors: [], topics: ['bitcoin'], packs: [], at: t }]; localStorage.setItem(k + ':messages', JSON.stringify(s)); localStorage.setItem('btc-wallet-feed:' + k, 'following'); // this wallet's last feed }, [base, PK_A, PK_B, alice]); diff --git a/tools/feed-window-dom-test.js b/tools/feed-window-dom-test.js index 7f89785..72ca535 100644 --- a/tools/feed-window-dom-test.js +++ b/tools/feed-window-dom-test.js @@ -87,11 +87,12 @@ try { 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: 'Prolific Poster', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); // a wallet with no feed remembered opens on Popular }, [base, AUTHOR, seeded]); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); - await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); await sleep(800); - await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => (n.querySelector('.chat-name') || {}).textContent === 'Feed'); if (e) e.click(); }); + // the Feed is a tab in the bottom nav (it was a row on the chat home) + await page.evaluate(() => [...document.querySelectorAll('.app-nav-button')].find((b) => b.getAttribute('aria-label') === 'Feed')?.click()); await sleep(2500); console.log('\n[a long scroll]'); check('the feed opens on its first posts', (await dom()).first === 'post number 1', JSON.stringify(await dom())); diff --git a/tools/markdown-note-test.js b/tools/markdown-note-test.js index e27336f..8e7cca1 100644 --- a/tools/markdown-note-test.js +++ b/tools/markdown-note-test.js @@ -69,12 +69,12 @@ try { 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: 'Markdown Poster', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); // a wallet with no feed remembered opens on Popular }, [cacheKeyFor(mn + '\n'), AUTHOR, notes]); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); - await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); - await sleep(1000); - await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /feed/i.test(n.textContent)); if (e) e.click(); }); + // the Feed is a tab in the bottom nav (it was a row on the chat home) + await page.evaluate(() => [...document.querySelectorAll('.app-nav-button')].find((b) => b.getAttribute('aria-label') === 'Feed')?.click()); // rows are admitted once their pictures have been tried (a dead host takes the deadline) for (let i = 0; i < 60; i++) { if ((await page.evaluate(() => document.querySelectorAll('.notes-feed > .row').length)) >= 6) break; await sleep(250); } await sleep(500); diff --git a/tools/moderation-test.js b/tools/moderation-test.js index 1f4deb8..d6df879 100644 --- a/tools/moderation-test.js +++ b/tools/moderation-test.js @@ -33,7 +33,12 @@ const REPLIES = [reply(OTHER, 'reply from ok person', 200), reply(MUTED, 'reply const LONG = 'buy the dip now, best exchange ever, sign up with my link and get free sats today!!!'; const html = await buildHtml({ minify: true, pwa: false }); -const server = Bun.serve({ port: 5299, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +// nos.lol (one of the relays the mute list is read from) is a stand-in +// served here — an empty relay — so a real one that won't connect can't hold +// the list back past the checks +const server = Bun.serve({ port: 5299, + fetch: (req, srv) => (new URL(req.url).pathname === '/relay' && srv.upgrade(req) ? undefined : new Response(html, { headers: { 'content-type': 'text/html' } })), + websocket: { message: (ws, data) => { try { const m = JSON.parse(data); if (m[0] === 'REQ') ws.send(JSON.stringify(['EOSE', m[1]])); } catch {} } } }); const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); const page = await browser.newPage(); await page.evaluateOnNewDocument((fx) => { @@ -46,6 +51,7 @@ await page.evaluateOnNewDocument((fx) => { ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', id]) })); }, 0); window.WebSocket = function (...a) { + if (/^wss:\/\/nos\.lol\/?$/.test(String(a[0]))) a[0] = 'ws://localhost:5299/relay'; const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (data) => { @@ -96,9 +102,8 @@ const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { const waitFor = async (fn, ms = 10000) => { for (let i = 0; i < ms / 250; i++) { if (await page.evaluate(fn)) return true; await sleep(250); } return false; }; const has = async (x) => (await text()).includes(x); const openFeed = async () => { - await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); - await sleep(800); - await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => (n.querySelector('.chat-name') || {}).textContent === 'Feed'); if (e) e.click(); }); + // the Feed is a tab in the bottom nav (it was a row on the chat home) + await page.evaluate(() => [...document.querySelectorAll('.app-nav-button')].find((b) => b.getAttribute('aria-label') === 'Feed')?.click()); await sleep(1200); }; @@ -122,6 +127,7 @@ try { 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('btc-wallet-feed:' + k, 'following'); // a wallet with no feed remembered opens on Popular }, [base, AUTHOR.pk, seeded]); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); diff --git a/tools/poll-test.js b/tools/poll-test.js index b88a4db..bc1715e 100644 --- a/tools/poll-test.js +++ b/tools/poll-test.js @@ -52,11 +52,12 @@ try { const quoting = { id: 'd'.repeat(64), pubkey: q, kind: 1, created_at: now, content: 'vote on this nostr:' + ref, tags: [] }; localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', poll.pubkey], ['p', q]], c: '', at: now })); localStorage.setItem(k + ':feedNotes', JSON.stringify([quoting, poll])); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); // a wallet with no feed remembered opens on Popular localStorage.setItem(k + ':profiles', JSON.stringify({ [poll.pubkey]: { name: 'Poll Author', t: Date.now() }, [q]: { name: 'Quoter', t: Date.now() } })); }, [base, poll, Q, neventOf(poll.id, poll.pubkey)]); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); - await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click()); + await page.evaluate(() => [...document.querySelectorAll('.app-nav-button')].find((b) => b.getAttribute('aria-label') === 'Feed').click()); await waitFor(() => document.querySelectorAll('.notes-feed .poll').length >= 2, 20000); const feedPoll = await page.evaluate(() => { diff --git a/tools/relays-settings-test.js b/tools/relays-settings-test.js index 7045b21..df89388 100644 --- a/tools/relays-settings-test.js +++ b/tools/relays-settings-test.js @@ -94,7 +94,9 @@ try { check('...and the DM inbox list', JSON.stringify(await dms()) === JSON.stringify(['relay.coinos.io', 'inbox.example.com']), JSON.stringify(await dms())); console.log('\n[editing, then saving]'); - await click('save'); await sleep(400); + // (a new identity has already put out its DM inbox list at boot; the card's + // Save is .relays-save — the page has other Save buttons now) + await page.evaluate(() => { window.__published = []; document.querySelector('.relays-save').click(); }); await sleep(400); check('saving with nothing changed publishes nothing', !(await page.evaluate(() => window.__published)).some((e) => e.kind === 10002 || e.kind === 10050)); // nos.lol becomes read+write; relay.example.com goes; a bare host is added await page.evaluate(() => { |
