aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-07 08:40:21 -0700
committerAdam Soltys <[email protected]>2026-10-07 08:40:21 -0700
commit3aff6e4cc9eb97fc0fa488f1f95001f69f24330a (patch)
tree932ed498851f9e2167a1c1419c96d630be0d02e2 /tools
parent6d288aeb194d2765282cf9fd72bd1f5fdd747a71 (diff)
parent6c51dcf2542314d5fe883f756f0840ef51860d67 (diff)
downloadcoinosv3-3aff6e4cc9eb97fc0fa488f1f95001f69f24330a.tar.gz
coinosv3-3aff6e4cc9eb97fc0fa488f1f95001f69f24330a.zip
Merge branch 'worktree-agent-ad1d843e9e394974e'
Diffstat (limited to 'tools')
-rw-r--r--tools/feed-custom-test.js27
-rw-r--r--tools/feed-editor-relays-test.js13
-rw-r--r--tools/feed-follow-test.js43
-rw-r--r--tools/feed-list-test.js10
-rw-r--r--tools/feed-live-test.js37
-rw-r--r--tools/feed-media-cache-test.js5
-rw-r--r--tools/feed-media-views-test.js4
-rw-r--r--tools/feed-stability-test.js53
-rw-r--r--tools/feed-streaming-test.js24
-rw-r--r--tools/feed-switch-seed-test.js5
-rw-r--r--tools/feed-window-dom-test.js5
-rw-r--r--tools/markdown-note-test.js6
-rw-r--r--tools/moderation-test.js14
-rw-r--r--tools/poll-test.js3
-rw-r--r--tools/relays-settings-test.js4
15 files changed, 153 insertions, 100 deletions
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(() => {