aboutsummaryrefslogtreecommitdiffstats
path: root/tsconfig.json
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2024-12-02 22:50:11 -0800
committerAdam Soltys <[email protected]>2024-12-02 22:50:11 -0800
commit5a8c49ad031dcb175ff4a0dec71c818eeab0b491 (patch)
tree9e78528c72256700df1b966519ec200cb8b86f05 /tsconfig.json
parent2484b61e149273d788170c4d64e679b5b0f8155d (diff)
downloadcoinos-server-5a8c49ad031dcb175ff4a0dec71c818eeab0b491.tar.gz
coinos-server-5a8c49ad031dcb175ff4a0dec71c818eeab0b491.zip
detect duplicate keysend
Diffstat (limited to 'tsconfig.json')
0 files changed, 0 insertions, 0 deletions
ref='#n156'>156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185
// On a phone, a fast flick up or down in the media pager flies past several
// posts and lands snapped on one, the way a spin of the wheel does; a
// gentler swipe is one post, as before; a fling at the end lands on the
// last post and goes no further (no going round to the first); a tap
// mid-flight stops it on the post most on screen, and a swipe back from
// there goes to the one before.
// Run: bun tools/pager-swipe-fly-test.js
import puppeteer from './puppeteer.js';
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 png = (size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
const N = 12, PIC = png('360x360');
const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
const now = Math.floor(Date.now() / 1000);
const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 0, fetch: (req) => {
  const path = new URL(req.url).pathname;
  if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
  if (/^\/p\d+\.png$/.test(path)) return new Response(PIC, { headers: { 'content-type': 'image/png' } });
  return new Response(html, { headers: { 'content-type': 'text/html' } });
} });
const U = (n) => server.url.origin + '/p' + n + '.png';
const seeded = Array.from({ length: N }, (_, i) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 100, tags: [], content: 'picture post ' + (i + 1) + ' ' + U(i + 1) }, SK));

const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
await page.evaluateOnNewDocument(() => { try { localStorage.setItem('coinos-clips-fit', 'all'); } catch {} }); // (every shape: this test isn't about the screen's way round)
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 waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
// the post the pager rests square on (its number), or 'between'
const at = () => page.evaluate(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 1 && Math.abs(r.left) < 1; }); return s ? Number(/p(\d+)\.png/.exec(s.getAttribute('data-k'))[1]) : 'between'; });
const settled = async () => { let last = null; for (let i = 0; i < 100; i++) { /* (a long flight takes up to 4 s) */ const t = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop); if (t === last) { await sleep(100); if (await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop) === t) return; } last = t; await sleep(50); } };
// a swipe up the screen: px of travel over about `ms` milliseconds. A
// quick one is dispatched inside the page, as fast as the handlers take
// the moves (about 30–50 ms each here, headless): the browser's own touch
// simulation takes a round trip per move on top, so a flick through it is
// never faster than a stroll.
const swipe = async (px, ms, steps = 6) => {
  if (ms >= 100) {
    await page.touchscreen.touchStart(195, 700);
    for (let i = 1; i <= steps; i++) { await page.touchscreen.touchMove(195, 700 - px * i / steps); await sleep(ms / steps); }
    await page.touchscreen.touchEnd();
    return;
  }
  await page.evaluate(([px, ms, steps]) => {
    const el = document.querySelector('.mp-scroll'), target = document.elementFromPoint(195, 700) || el;
    const touch = (y) => new Touch({ identifier: 1, target, clientX: 195, clientY: y, pageX: 195, pageY: y });
    const fire = (type, y, up) => target.dispatchEvent(new TouchEvent(type, { bubbles: true, cancelable: true, touches: up ? [] : [touch(y)], changedTouches: [touch(y)], targetTouches: up ? [] : [touch(y)] }));
    // on a schedule from the start, not a pause after each move: a move's
    // handling (the slides following the finger) takes its own time
    const t0 = performance.now();
    fire('touchstart', 700);
    for (let i = 1; i <= steps; i++) { while (performance.now() < t0 + ms * i / steps); fire('touchmove', 700 - px * i / steps); }
    fire('touchend', 700 - px, true);
  }, [px, ms, steps]);
};
// A swipe whose moves are made on schedule (each event stamped as the finger
// moved) but handled late, the main thread busy: held back and delivered in
// a burst at the end (burst), or the last move and the lift after a stall.
const queued = (px, ms, steps, mode) => page.evaluate(([px, ms, steps, mode]) => {
  const el = document.querySelector('.mp-scroll'), target = document.elementFromPoint(195, 700) || el;
  const touch = (y) => new Touch({ identifier: 1, target, clientX: 195, clientY: y, pageX: 195, pageY: y });
  const make = (type, y, up) => new TouchEvent(type, { bubbles: true, cancelable: true, touches: up ? [] : [touch(y)], changedTouches: [touch(y)], targetTouches: up ? [] : [touch(y)] });
  const t0 = performance.now(), evs = [make('touchstart', 700)];
  for (let i = 1; i <= steps; i++) { while (performance.now() < t0 + ms * i / steps); evs.push(make('touchmove', 700 - px * i / steps)); }
  evs.push(make('touchend', 700 - px, true));
  if (mode === 'burst') { for (const e of evs) target.dispatchEvent(e); return; }
  // stall: all but the last move handled as they come, then 300 ms busy
  const tail = evs.splice(-2);
  for (const e of evs) target.dispatchEvent(e);
  const t1 = performance.now(); while (performance.now() < t1 + 300);
  for (const e of tail) target.dispatchEvent(e);
}, [px, ms, steps, mode]);
try {
  await page.setViewport({ width: 390, height: 844, hasTouch: true, isMobile: true });
  await page.goto(server.url.href, { 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');
  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: 'Picture Poster', t: Date.now() } }));
    localStorage.setItem('btc-wallet-feed:' + k, 'following');
  }, [base, AUTHOR, seeded]);
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
  check('the pictures are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 3, null, 20000));
  const pic = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('picture post 1 ')); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; });
  await page.touchscreen.tap(pic.x, pic.y);
  check('the first picture opens in the pager', await waitFor(() => !!document.querySelector('.media-pager'), null, 4000) && (await (async () => { await settled(); return at(); })()) === 1, String(await at()));
  check('…with all the posts loaded', await waitFor((n) => document.querySelectorAll('.mp-group').length >= n, N, 15000));
  check('…and decoded, in the carousel (ten ready before it flies)', await waitFor((n) => document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)').length >= n, N, 10000), String(await page.evaluate(() => document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)').length)));

  console.log('\n[a gentle swipe: one post]');
  await swipe(320, 130); await settled();
  check('a 320px swipe over 130ms: the next post', (await at()) === 2, String(await at()));

  console.log('\n[a fast flick: several posts, landing snapped]');
  const trace = async (sw) => { await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); window.__tr = []; const t0 = performance.now(); const rec = () => { window.__tr.push(sc.scrollTop); if (performance.now() - t0 < 1500) requestAnimationFrame(rec); }; requestAnimationFrame(rec); }); await sw(); await sleep(1600); return page.evaluate(() => window.__tr); };
  await page.evaluate(() => { const el = document.querySelector('.mp-position'); window.__counts = [el.textContent]; new MutationObserver(() => { if (window.__counts.at(-1) !== el.textContent) window.__counts.push(el.textContent); }).observe(el, { childList: true, characterData: true, subtree: true }); });
  const tr = await trace(() => swipe(600, 80, 3)); await settled(); // three big moves: 3–6 px/ms as their handling takes, a long coast's worth (past the nine loaded, maybe)
  const landed = await at();
  check('a 600px flick in a few frames flies several posts on', landed !== 'between' && landed >= 5, String(landed));
  check('…landing square on a post', landed !== 'between');
  check('…in a glide, never back', tr.every((x, i) => !i || x >= tr[i - 1] - 0.5), tr.slice(0, 12).map((x) => Math.round(x)).join(' '));
  check('the counter ran through the posts on the way, not jumping on landing', (await page.evaluate(() => window.__counts)).length >= 4, (await page.evaluate(() => window.__counts)).join(' → '));
  const seen = await page.evaluate(() => document.querySelector('.mp-position').textContent);
  check('the counter says where it landed', seen === landed + ' / ' + N, seen);

  console.log('\n[the ends are ends]');
  while ((await at()) !== 7) { await page.keyboard.press((await at()) < 7 ? 'ArrowDown' : 'ArrowUp'); await settled(); }
  const before = await at();
  await swipe(600, 80, 3); await settled();
  check('from post ' + before + ', a flick whose flight would be past the end lands on the last post', before === 7 && (await at()) === N, String(await at()));
  await swipe(600, 80, 3); await settled();
  check('…a flick on from the last stays there (no going round to the first)', (await at()) === N, String(await at()));
  await page.keyboard.press('ArrowDown'); await settled();
  check('…and so does ↓', (await at()) === N, String(await at()));
  while ((await at()) !== 1) { await page.keyboard.press('ArrowUp'); await settled(); }
  await swipe(-600, 80, 3); await settled();
  check('a flick back up from the first stays on the first', (await at()) === 1, String(await at()));
  await swipe(320, 130); await settled();
  check('a gentle swipe from the first: the second, as ever', (await at()) === 2, String(await at()));

  console.log('\n[a busy main thread: the speed is the finger\'s, not the handling\'s]');
  // (the dispatch loop runs all the events in one go: built on schedule, each stamped as it was made)
  while ((await at()) !== 1) { await page.keyboard.press('ArrowUp'); await settled(); }
  await queued(200, 200, 8, 'burst'); await settled();
  check('from post 1, a gentle 200px swipe over 200ms, its moves handled in one burst: one post on', (await at()) === 2, String(await at()));
  await queued(600, 80, 4, 'stall'); await settled();
  check('a fast flick whose last move was handled 300ms late still flies', typeof (await at()) === 'number' && (await at()) >= 5, String(await at()));
  while ((await at()) !== 1) { await page.keyboard.press('ArrowUp'); await settled(); }

  console.log('\n[a tap mid-flight stops it on the post most on screen]');
  // the post most on screen as the finger comes down (its number), then the touch, in one go
  const down = () => page.evaluate(() => {
    const sc = document.querySelector('.mp-scroll'), y = sc.scrollTop;
    const g = [...sc.querySelectorAll(':scope > .mp-group:not(.mp-wait):not(.mp-off)')].reduce((b, x) => (Math.abs(x.offsetTop - y) < Math.abs(b.offsetTop - y) ? x : b));
    const target = window.__tt = document.elementFromPoint(195, 500), t = new Touch({ identifier: 2, target, clientX: 195, clientY: 500, pageX: 195, pageY: 500 });
    target.dispatchEvent(new TouchEvent('touchstart', { bubbles: true, cancelable: true, touches: [t], changedTouches: [t], targetTouches: [t] }));
    return Number(/p(\d+)\.png/.exec(g.querySelector('.mp-slide').getAttribute('data-k'))[1]);
  });
  // the same finger moving (to y), or lifted there
  const finger = (y, up) => page.evaluate(([y, up]) => {
    const target = window.__tt, t = new Touch({ identifier: 2, target, clientX: 195, clientY: y, pageX: 195, pageY: y });
    target.dispatchEvent(new TouchEvent(up ? 'touchend' : 'touchmove', { bubbles: true, cancelable: true, touches: up ? [] : [t], changedTouches: [t], targetTouches: up ? [] : [t] }));
  }, [y, up]);
  await swipe(600, 80, 3);
  await sleep(150);
  const flying = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop);
  const most = await down();
  await sleep(60); await finger(500, true);
  await sleep(250);
  check('a tap mid-flight: on the post most on screen in a moment', flying > 0 && (await at()) === most, most + ' wanted, ' + (await at()));
  await settled();
  check('…and stays there', (await at()) === most, String(await at()));
  check('…the tap only stopped it (no details brought up)', !(await page.evaluate(() => document.querySelector('.media-pager').classList.contains('show'))));

  console.log('\n[a swipe back mid-flight: the post before]');
  while ((await at()) !== 1) { await page.keyboard.press('ArrowUp'); await settled(); }
  await swipe(600, 80, 3);
  await sleep(150);
  const most2 = await down();
  for (const y of [520, 560, 640, 760]) { await finger(y); await sleep(20); }
  await finger(760, true); await settled();
  check('caught on post ' + most2 + ' and swiped back: post ' + (most2 - 1), most2 > 1 && (await at()) === most2 - 1, String(await at()));
  check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ a fast flick flies through the pager' : '\n❌ failed');
process.exit(ok ? 0 : 1);