aboutsummaryrefslogtreecommitdiffstats
path: root/tools/markdown-note-test.js
blob: e27336faebad57ec623a896cf03b8c6931861baa (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
// A kind 1 is specified as plain text — NIP-10 says markup SHOULD NOT be
// used — but bridges and cross-posters emit markdown anyway, and what we did
// with it was the worst of the three options available: the URL inside
// ![cover](…) rendered as a picture while the "![cover](" sat above it as
// litter, in a real post from a real account.
//
// So: images and links are understood, and nothing else is. # opens a hashtag
// and * turns up in ordinary prose, so honouring headings and bold would
// mangle normal posts to pretty up the rare bridged one.
//
// Run: bun tools/markdown-note-test.js
import puppeteer from 'puppeteer-core';
import { generateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
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 SK = generateSecretKey();
const AUTHOR = getPublicKey(SK);
// served below: a picture the feed can actually decode — a row shows a
// picture it has decoded and links the ones it could not
const IMG = 'http://localhost:5298/pic.png';
const NOEXT = 'http://localhost:5298/media/9f2a1c';
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');
// the shape the real post had: the paren, then a newline, then the URL
const CASES = [
  ['md-image', 'here it is\n![cover](' + IMG + ')\nthat was it'],
  ['md-image-newline', 'wrapped\n![cover](\n' + IMG + ')\ndone'],
  ['md-image-noext', 'no extension ![cover](' + NOEXT + ')'],
  ['md-link', 'read it at [my site](https://example.invalid/post/1) today'],
  ['bare', 'plain old ' + IMG + ' here'],
  ['not-md', 'a list [one] and (two) and #hashtag and *stars* stay put'],
];
const notes = CASES.map(([, content], i) =>
  finalizeEvent({ kind: 1, created_at: Math.floor(Date.now() / 1000) - i * 60, tags: [], content }, SK));

const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 5298, fetch: (req) => {
  const path = new URL(req.url).pathname;
  if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); // the author's face, so rows are admitted at once
  if (path === '/pic.png' || path === '/media/9f2a1c') 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)));
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; };

try {
  await page.setViewport({ width: 390, height: 844 });
  await page.goto('http://localhost:5298/', { 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);
  await page.evaluate(([k, pk, ns]) => {
    localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
    localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
    localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Markdown Poster', t: Date.now() } }));
  }, [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(); });
  // 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);

  const rows = await page.evaluate(() => [...document.querySelectorAll('.notes-feed > .row')].map((r) => {
    const b = r.querySelector('.note-text');
    return {
      text: b ? b.innerText : '',
      imgs: b ? [...b.querySelectorAll('img')].map((i) => i.getAttribute('src')) : [],
      links: b ? [...b.querySelectorAll('a')].map((a) => ({ href: a.getAttribute('href'), txt: a.textContent })) : [],
    };
  }));
  check('all six posts are on screen', rows.length === 6, rows.length + ' rows');
  const by = (i) => rows[i] || { text: '', imgs: [], links: [] };

  // 0: the reported case
  check('![alt](url) shows the picture', by(0).imgs.includes(IMG), by(0).imgs.join(' '));
  check('...and leaves no "![cover](" behind', !/!\[|\]\(/.test(by(0).text), JSON.stringify(by(0).text));
  // 1: with the newline the real post had
  check('...even with a newline after the paren', by(1).imgs.includes(IMG) && !/!\[|\]\(/.test(by(1).text),
    JSON.stringify(by(1).text));
  // 2: markdown knows it's an image even without an extension
  check('![…] means picture even with no file extension', by(2).imgs.includes(NOEXT), by(2).imgs.join(' '));
  // 3: a plain markdown link is a link with its own words
  const l = by(3).links.find((x) => /example\.invalid\/post\/1/.test(x.href || ''));
  check('[text](url) is a link labelled with its text', !!l && l.txt === 'my site', l ? l.txt : 'no link');
  check('...and the brackets are gone', !/\[my site\]/.test(by(3).text), JSON.stringify(by(3).text));
  // 4: bare URLs unchanged
  check('a bare image URL still just works', by(4).imgs.includes(IMG));
  // 5: everything that ISN'T markdown is left exactly alone
  check('brackets, parens, hashtags and stars in prose are untouched',
    by(5).text.includes('[one]') && by(5).text.includes('(two)')
      && by(5).text.includes('#hashtag') && by(5).text.includes('*stars*'),
    JSON.stringify(by(5).text));

  check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ markdown reads as what it means' : '\n❌ failed');
process.exit(ok ? 0 : 1);