aboutsummaryrefslogtreecommitdiffstats
path: root/tools/post-actions-test.js
blob: 766cfa3dfe23b0a9e17150a7f5484b500cdf53e5 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
// What you can do to a post: like it, boost it, quote it, and — behind the
// ellipsis — mute or block whoever wrote it. Muting hides their posts here
// and goes onto the NIP-51 list that follows you to other clients.
//
// The like and boost tallies come back in the same REQ as the zaps; this
// checks the rendering and the local half of each action (nothing here needs
// a relay to answer, so the test doesn't depend on one).
//
// Run: bun tools/post-actions-test.js
import puppeteer from './puppeteer.js';
import { generateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import { cacheKeyFor } from '../src/wallet.js';
import { buildHtml } from '../build.js';
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 });
// a picture served locally (a remote one that fails to load stays a link)
const PNG = '/tmp/coinos-post-actions-test.png';
Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'color=c=green:s=600x400', '-frames:v', '1', PNG]);
const png = await Bun.file(PNG).arrayBuffer().catch(() => null);
const server = Bun.serve({ port: 5258, async fetch(req) {
  const u = new URL(req.url);
  if (u.pathname === '/pic.png' && png) return new Response(png, { headers: { 'content-type': 'image/png' } });
  return new Response(html, { headers: { 'content-type': 'text/html' } });
} });
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
const errs = [];
page.on('pageerror', (e) => errs.push(String(e).slice(0, 120)));
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 AUTHOR = 'a'.repeat(63) + '9';
// a fresh id each run: '1'.repeat(64) is a real, much-reacted-to id on nostr
// and the relays cheerfully return four likes for it
const NOTE_ID = [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, '0')).join('');
try {
  await page.setViewport({ width: 390, height: 844 });
  await page.goto('http://localhost:5258/', { waitUntil: 'domcontentloaded' });
  await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
  await page.reload({ waitUntil: 'domcontentloaded' });
  await sleep(400);
  await click('create a new wallet'); await sleep(500);
  await click('to import'); await sleep(400);
  await page.waitForSelector('textarea');
  const mn = generateMnemonic(wordlist);
  await page.type('textarea', mn);
  await click('open wallet');
  await waitText('receive', 20000);
  const base = cacheKeyFor(mn + '\n');

  await page.evaluate(([k, pk, nid]) => {
    localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now()/1000) }));
    localStorage.setItem(k + ':feedNotes', JSON.stringify([
      { id: nid, pubkey: pk, kind: 1, created_at: Math.floor(Date.now()/1000), content: 'More flowers http://localhost:5258/pic.png', tags: [] },
    ]));
    // a known face: a row is admitted once its author's picture has been tried
    localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Flower Poster', t: Date.now() } }));
    localStorage.setItem('btc-wallet-feed:' + k, 'following'); // new wallets open on Popular
  }, [base, AUTHOR, NOTE_ID]);
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  // the Feed is a tab in the bottom nav
  await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
  for (let i = 0; i < 48; i++) { if (await page.$('.note-act')) break; await sleep(250); }
  await sleep(600);

  const acts = await page.evaluate(() => [...document.querySelectorAll('.note-act')].map((b) => b.getAttribute('aria-label')));
  check('every action sits under the post', acts.slice(0, 4).join(',') === 'Reply,Boost,Quote,Like', acts.join(',') || 'none');

  // the heart opens the emoji row; picking one paints before any relay answers
  const likeBtn = () => page.evaluate(() => { const b = [...document.querySelectorAll('.note-act')].find((x) => x.getAttribute('aria-label') === 'Like'); return b ? { on: b.className.includes('on'), n: Number(b.textContent.replace(/\D/g, '') || 0), emoji: b.textContent.replace(/[\d\s]/g, '') } : null; });
  const before = (await likeBtn()).n;
  await page.evaluate(() => { const b = [...document.querySelectorAll('.note-act')].find((x) => x.getAttribute('aria-label') === 'Like'); b.click(); });
  await sleep(600);
  const emojis = await page.evaluate(() => [...document.querySelectorAll('.msg-sheet-emojis button')].map((b) => b.textContent));
  check('the heart offers a choice of emoji', emojis.length >= 5, emojis.join(' '));
  await page.evaluate(() => { const b = [...document.querySelectorAll('.msg-sheet-emojis button')].find((x) => x.textContent === '🔥'); b.click(); });
  await sleep(1200);
  const liked = await likeBtn();
  check('the one you picked is the one it shows', liked.on && liked.n === before + 1 && liked.emoji.includes('🔥'), JSON.stringify(liked) + ' was ' + before);

  // quoting carries the post into the composer as a nostr: reference
  await page.evaluate(() => { const b = [...document.querySelectorAll('.note-act')].find((x) => x.getAttribute('aria-label') === 'Quote'); b.click(); });
  await sleep(900);
  const draft = await page.evaluate(() => (document.querySelector('.chat-page coinos-text') || {}).value || '');
  check('quoting opens the composer with the reference in it', /nostr:nevent1/.test(draft), draft.slice(0, 40) || 'empty');

  // A reply can carry a picture, the same as a post can.
  await page.evaluate(() => { const b = [...document.querySelectorAll('.note-act')].find((x) => x.getAttribute('aria-label') === 'Reply'); if (b) b.click(); });
  await sleep(2500);
  const reply = await page.evaluate(() => {
    const box = document.querySelector('.thread-reply-input');
    if (!box) return { box: false };
    const row = box.closest('.thread-reply');
    return {
      box: true,
      // in the field's corner now, beside the GIF button
      attach: !!row.querySelector('.compose-box .compose-tool:not(.gif-btn)'),
      file: !!row.querySelector('#reply-file'),
      accepts: (row.querySelector('#reply-file') || {}).accept,
    };
  });
  check('the reply box has the same paperclip', reply.box && reply.attach && reply.file, JSON.stringify(reply));
  check('...taking pictures and video', /image/.test(reply.accepts || '') && /video/.test(reply.accepts || ''), reply.accepts || 'none');
  // back out of the thread the reply check opened
  await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((x) => x.textContent.trim() === 'Back' || x.textContent.trim() === '← Back'); if (b) b.click(); });
  await sleep(1500);

  // the ellipsis sheet, and muting from it
  await page.evaluate(() => { const b = [...document.querySelectorAll('.notes-feed .btn-sm')].find((x) => x.textContent.trim() === '⋯'); if (b) b.click(); });
  await sleep(600);
  const sheet = await page.evaluate(() => { const s = document.querySelector('.confirm-pop'); return s ? s.innerText.replace(/\n+/g, ' | ') : ''; });
  check('the ellipsis offers mute and block', /Mute/.test(sheet) && /Block/.test(sheet), sheet.slice(0, 70));

  await page.evaluate(() => { const b = [...document.querySelectorAll('.confirm-pop button')].find((x) => /^🔇/.test(x.textContent)); if (b) b.click(); });
  await sleep(2500);
  const after = await page.evaluate((k) => ({
    rows: document.querySelectorAll('.notes-feed > .row').length,
    muted: (JSON.parse(localStorage.getItem(k + ':mutes') || 'null') || {}).tags || [],
  }), base);
  check('a muted author leaves the feed at once', after.rows === 0, after.rows + ' posts still shown');
  check('...and goes onto the published mute list', after.muted.some((tg) => tg[1] === AUTHOR), JSON.stringify(after.muted).slice(0, 50));
  // An image in a DM opens the same way a post's does — both bubbles render
  // their text through the same function, so this is a check that they keep
  // doing so. From someone else: the author was just muted, and a muted
  // person's conversation stays off the list.
  const PEER = 'b'.repeat(64);
  await page.evaluate(([k, peer]) => {
    const st = JSON.parse(localStorage.getItem(k + ':messages') || '{}');
    st.dms = { [peer]: [{ id: '2'.repeat(64), from: peer, text: 'look http://localhost:5258/pic.png', t: Math.floor(Date.now() / 1000) }] };
    localStorage.setItem(k + ':messages', JSON.stringify(st));
  }, [base, PEER]);
  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(2500);
  const opened = await page.evaluate(() => {
    const row = [...document.querySelectorAll('.chat-thread-row')].find((r) => !/feed|notifications/i.test(r.textContent));
    if (!row) return 'no dm row';
    row.click();
    return 'opened';
  });
  await sleep(1200);
  // a real tap on the picture itself (a tap beside a fitted picture is a tap on the post)
  for (let i = 0; i < 20 && !(await page.evaluate(() => { const i = document.querySelector('.chat-bubble img.note-img'); return i && i.complete && i.naturalWidth > 0; })); i++) await sleep(250);
  const at = await page.evaluate(() => { const i = document.querySelector('.chat-bubble img.note-img'); if (!i) return null; i.scrollIntoView({ block: 'center' }); const b = i.getBoundingClientRect(); return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; });
  const img = !!at;
  if (at) await page.mouse.click(at.x, at.y);
  await sleep(600);
  check('an image in a DM opens full screen too', !!img && await page.evaluate(() => !!document.querySelector('.lightbox')), img ? 'tapped' : 'no image in the bubble (' + opened + ')');

  check('no page errors', !errs.length, errs.join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ posts answer back' : '\n❌ failed');
process.exit(ok ? 0 : 1);