aboutsummaryrefslogtreecommitdiffstats
path: root/tools/post-edit-test.js
blob: 0b8bf6fa7d8d33ea0895941a468656c8d19b37cc (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
158
159
160
161
162
163
164
165
166
// Editing and deleting posts: ⋯ on your own post offers Edit (a kind 1010
// edit beside the original, the draft NIP-41 Amethyst speaks) and Delete (a
// NIP-09 kind 5). Someone else's edits and deletions are honoured when their
// relays carry them — but only when the post's own author signed them, and a
// deletion is remembered across a reload.
// No relay is written for real — the WebSocket shim swallows EVENT.
// Run: bun tools/post-edit-test.js
import puppeteer from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
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 key = () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; };
const AUTHOR = key(), OTHER = key();
const now = Math.floor(Date.now() / 1000);
const post = (who, content, ago, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content }, who.sk);
const EDITED = post(AUTHOR, 'first draft with a tpyo', 300);
const GONE = post(AUTHOR, 'a post its author took back', 400);
const FORGED = post(AUTHOR, 'a post a stranger tried to delete', 500);
const FIX = finalizeEvent({ kind: 1010, created_at: now - 100, tags: [['e', EDITED.id]], content: 'first draft without the typo' }, AUTHOR.sk);
const fixtures = [
  FIX,
  finalizeEvent({ kind: 5, created_at: now - 90, tags: [['e', GONE.id]], content: '' }, AUTHOR.sk),
  finalizeEvent({ kind: 5, created_at: now - 80, tags: [['e', FORGED.id]], content: '' }, OTHER.sk),
  finalizeEvent({ kind: 1010, created_at: now - 70, tags: [['e', FORGED.id]], content: 'a stranger rewrote this' }, OTHER.sk),
];

const html = await buildHtml({ minify: true, pwa: false });
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');
const server = Bun.serve({ port: 5301, fetch: (req) => new URL(req.url).pathname === '/kek.png'
  ? new Response(PNG, { headers: { 'content-type': 'image/png' } })
  : new Response(html, { headers: { 'content-type': 'text/html' } }) });
const browser = await puppeteer.launch({ relays: 'read', executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
await page.evaluateOnNewDocument((fx) => {
  window.__published = [];
  const Real = window.WebSocket;
  const fits = (ev, f) => (!f.kinds || f.kinds.includes(ev.kind)) && (!f['#e'] || ev.tags.some((t) => t[0] === 'e' && f['#e'].includes(t[1])))
    && (!f.authors || f.authors.includes(ev.pubkey));
  window.WebSocket = function (...a) {
    const ws = new Real(...a);
    const send = ws.send.bind(ws);
    ws.send = (data) => {
      try {
        const m = JSON.parse(data);
        if (m[0] === 'REQ') {
          const fs = m.slice(2);
          const evs = fx.concat(window.__extra || []).filter((ev) => fs.some((f) => fits(ev, f)));
          setTimeout(() => {
            for (const ev of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) }));
            ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) }));
          }, 0);
          return;
        }
        if (m[0] === 'EVENT' && m[1]) {
          window.__published.push(m[1]);
          setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', m[1].id, true, '']) })), 0);
          return;
        }
      } catch {}
      return send(data);
    };
    return ws;
  };
  window.WebSocket.prototype = Real.prototype;
  Object.assign(window.WebSocket, Real);
}, fixtures);
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 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 waitGone = async (x, ms = 10000) => { for (let i = 0; i < ms / 250; i++) { if (!(await text()).includes(x)) return true; await sleep(250); } return false; };
const has = async (x) => (await text()).includes(x);
const openFeed = async () => {
  await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
  await sleep(1200);
};
const more = (match) => page.evaluate((m) => {
  const r = [...document.querySelectorAll('[data-zap-post]')].find((n) => n.textContent.includes(m));
  const b = r && [...r.querySelectorAll('button')].find((x) => x.getAttribute('aria-label') === 'More');
  if (b) b.click();
  return !!b;
}, match);
const sheetHas = (label) => page.evaluate((l) => [...document.querySelectorAll('.confirm-pop button')].some((b) => b.textContent.includes(l)), label);
const sheetClick = (label) => page.evaluate((l) => { const b = [...document.querySelectorAll('.confirm-pop button')].find((x) => x.textContent.includes(l)); if (b) b.click(); return !!b; }, label);

try {
  await page.setViewport({ width: 390, height: 844 });
  await page.goto('http://localhost:5301/', { waitUntil: 'domcontentloaded' });
  await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
  await page.reload({ waitUntil: 'domcontentloaded' });
  await sleep(400);
  await click('create a new wallet'); await sleep(600);
  await page.waitForSelector('.words .w .t');
  const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
  await click('skip verification');
  await waitText('receive', 20000);
  const base = cacheKeyFor(mn + '\n');
  const ME_SK = privateKeyFromSeedWords(mn);
  const ME = getPublicKey(ME_SK);
  const MINE = finalizeEvent({ kind: 1, created_at: now - 200, tags: [], content: 'my own post, to be fixed' }, ME_SK);
  await page.evaluate(([k, pks, ns]) => {
    localStorage.setItem(k + ':follows', JSON.stringify({ tags: pks.map((pk) => ['p', pk]), c: '', at: Math.floor(Date.now() / 1000) }));
    localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
    localStorage.setItem(k + ':profiles', JSON.stringify(Object.fromEntries(pks.map((pk, i) => [pk, { name: 'Person ' + i, t: Date.now() }]))));
    localStorage.setItem('btc-wallet-feed:' + k, 'following');
  }, [base, [AUTHOR.pk, ME], [MINE, EDITED, GONE, FORGED]]);
  // my reaction with a custom emoji, arriving after the row is drawn
  const KEK = finalizeEvent({ kind: 7, created_at: now - 50, content: ':kek_lord:', tags: [['e', FORGED.id], ['p', AUTHOR.pk], ['emoji', 'kek_lord', 'http://localhost:5301/kek.png']] }, ME_SK);
  await page.evaluateOnNewDocument((e) => { window.__extra = e; }, [KEK]);
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await openFeed();

  console.log('\n[someone else\'s edits and deletions]');
  check('the feed shows the posts', await waitText('a post a stranger tried to delete', 15000));
  check('an edit by the author replaces the text', await waitText('first draft without the typo', 10000) && !(await has('tpyo')));
  check('...marked as edited', await page.evaluate(() => [...document.querySelectorAll('[data-zap-post]')].some((n) => n.textContent.includes('without the typo') && /edited/.test(n.textContent))));
  check('a deletion by the author hides the post', await waitGone('a post its author took back'));
  check('a stranger\'s deletion and edit are ignored', await has('a post a stranger tried to delete') && !(await has('a stranger rewrote this')));
  check('my custom-emoji reaction shows as its picture, not :kek_lord:', await page.evaluate(() => {
    const r = [...document.querySelectorAll('[data-zap-post]')].find((n) => n.textContent.includes('a stranger tried to delete'));
    return !!r && !!r.querySelector('.note-acts img.cemoji[alt=":kek_lord:"]') && !r.querySelector('.note-acts').textContent.includes(':kek_lord:');
  }));
  check('no Edit or Delete on someone else\'s post', await more('first draft without') && !(await sheetHas('Edit')) && !(await sheetHas('Delete')));
  await sheetClick('Back'); await sleep(300);

  console.log('\n[editing my own post]');
  check('my post is in the feed', await waitText('my own post, to be fixed', 8000));
  await more('my own post, to be fixed'); await sleep(300);
  check('⋯ offers Edit and Delete', await sheetHas('Edit') && await sheetHas('Delete'));
  await sheetClick('Edit'); await sleep(400);
  const pre = await page.evaluate(() => (document.querySelector('.post-edit-input') || {}).value);
  check('the edit box holds the post\'s words', pre === 'my own post, to be fixed', pre);
  await page.evaluate(() => { window.__published = []; const e = document.querySelector('.post-edit-input'); e.focus(); e.value = 'my own post, fixed'; e.dispatchEvent(new Event('input', { bubbles: true })); });
  await sheetClick('Save'); await sleep(1500);
  const ed = (await page.evaluate(() => window.__published)).find((e) => e.kind === 1010);
  check('a kind 1010 goes out, signed by me, naming the post', !!ed && ed.pubkey === ME && ed.content === 'my own post, fixed' && ed.tags.some((x) => x[0] === 'e' && x[1] === MINE.id), JSON.stringify(ed && ed.tags));
  check('the feed shows the new words', await waitText('my own post, fixed', 5000) && !(await has('to be fixed')));

  console.log('\n[deleting my own post]');
  await more('my own post, fixed'); await sleep(300);
  await sheetClick('Delete'); await sleep(300);
  check('a confirmation asks first', await has('Delete this post?'));
  await page.evaluate(() => { window.__published = []; });
  await page.evaluate(() => { const b = [...document.querySelectorAll('.confirm-pop .btn-danger')].find((x) => x.textContent.includes('Delete')); b.click(); });
  await sleep(1500);
  const del = (await page.evaluate(() => window.__published)).find((e) => e.kind === 5);
  check('a kind 5 goes out, signed by me, naming the post', !!del && del.pubkey === ME && del.tags.some((x) => x[0] === 'e' && x[1] === MINE.id), JSON.stringify(del && del.tags));
  check('the post is gone from the feed', await waitGone('my own post, fixed'));

  console.log('\n[after a reload]');
  await sleep(2500); // the index is written behind
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await openFeed();
  check('the feed is back', await waitText('a post a stranger tried to delete', 15000));
  check('my deleted post stays gone', !(await has('my own post')));
  check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ post edits and deletions' : '\n❌ failed');
process.exit(ok ? 0 : 1);