aboutsummaryrefslogtreecommitdiffstats
path: root/tools/chat-layout-test.js
blob: 772e38f4f240935fea4c8b1ac2a720111b4cb431 (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
// You must always be able to type. The chat card is sized to the viewport and
// only the message log flexes, so the header, an open invite or member panel,
// and a long backlog all shrink the scroll area rather than pushing the
// composer below the fold.
//
// Run: bun tools/chat-layout-test.js
import puppeteer from 'puppeteer-core';
import { generateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import { buildHtml } from '../build.js';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

// the shot goes somewhere every machine has, not one session's scratch dir
const shotPath = join(tmpdir(), 'chat-layout.png');

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: 5229, fetch: () => 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 click = (sel, t) => page.evaluate((s, x) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, t);
const setInput = (sel, v) => page.evaluate((s, val) => { const e = document.querySelector(s); if (!e) return false; e.value = val; e.dispatchEvent(new Event('input', { bubbles: true })); return true; }, sel, v);
const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; };

// Is the composer fully inside the window, without scrolling?
const composerFits = () => page.evaluate(() => {
  const c = document.querySelector('.chat-compose');
  if (!c) return { found: false };
  const r = c.getBoundingClientRect();
  return { found: true, top: Math.round(r.top), bottom: Math.round(r.bottom), vh: window.innerHeight, fits: r.bottom <= window.innerHeight && r.top >= 0 };
});

try {
  for (const vp of [{ width: 1280, height: 800, name: 'laptop' }, { width: 390, height: 844, name: 'phone' }, { width: 1024, height: 600, name: 'short laptop' }]) {
    console.log(`\n[${vp.name} ${vp.width}x${vp.height}]`);
    await page.setViewport({ width: vp.width, height: vp.height });
    if (vp.name === 'laptop') {
      await page.goto('http://localhost:5229/', { waitUntil: 'domcontentloaded' });
      await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
      await page.reload({ waitUntil: 'domcontentloaded' });
      await sleep(400);
      await click('button', 'Create a new wallet');
      await sleep(300);
      await click('button', 'Import existing');
      await sleep(300);
      await page.waitForSelector('textarea');
      await page.type('textarea', generateMnemonic(wordlist));
      await click('button', 'Open wallet');
      await waitText('receive', 15000);
      // .header-msgs is the shared header-button class — settings, lock and
      // search wear it too, and the first one is no longer this one
      await page.evaluate(() => [...document.querySelectorAll('button')]
        .find((b) => /message/i.test(b.getAttribute('aria-label') || ''))?.click());
      await sleep(800);
      await click('button', 'Create');
      await sleep(300);
      await setInput('input[placeholder*="name" i]', 'layout test');
      await sleep(200);
      await page.evaluate(() => { const i = document.querySelector('input[placeholder*="name" i]'); const b = i && i.parentElement.querySelector('button'); if (b) b.click(); });
      await waitText('layout test', 25000);
      await click('.item', 'layout test');
      await sleep(2000);
    }
    await sleep(600);

    let r = await composerFits();
    check('composer is on screen', r.found && r.fits, `bottom ${r.bottom} of ${r.vh}`);

    // the state from the report: the invite panel open above the log
    await page.evaluate(() => { const b = [...document.querySelectorAll('.chat-head button')].find((e) => /add person/i.test(e.textContent)); if (b) b.click(); });
    await sleep(600);
    const panelOpen = await page.$('.chat-invite');
    r = await composerFits();
    check('...and still on screen with the invite panel open', !!panelOpen && r.fits, `bottom ${r.bottom} of ${r.vh}`);

    // and with the member list open too
    await page.evaluate(() => { const e = [...document.querySelectorAll('.chat-head .col')].find((n) => n.className.includes('clickable')); if (e) e.click(); });
    await sleep(600);
    r = await composerFits();
    check('...and with the member list open as well', r.fits, `bottom ${r.bottom} of ${r.vh}`);

    // NB not asserting the page never scrolls: the footer legitimately sits
    // below the fold. What matters is that the composer doesn't.

    // put it back for the next viewport
    await page.evaluate(() => { const e = [...document.querySelectorAll('.chat-head .col')].find((n) => n.className.includes('clickable')); if (e) e.click(); });
    await sleep(300);
    await page.evaluate(() => { const b = [...document.querySelectorAll('.chat-head button')].find((e) => /add person/i.test(e.textContent)); if (b) b.click(); });
    await sleep(300);
  }
  await page.setViewport({ width: 1280, height: 800 });
  await sleep(500);
  await page.screenshot({ path: shotPath });

  console.log('\n[the channel header]');
  const one = await page.evaluate(() => ({
    picker: !!document.querySelector('.chan-pick'),
    named: document.body.innerText.includes('#general'),
    placeholder: (document.querySelector('#msg-draft') || {}).placeholder,
  }));
  check('a single channel is not named', !one.picker && !one.named, JSON.stringify(one));
  check('and the composer says just "Message"', /^Message/.test(one.placeholder || '') && !/#/.test(one.placeholder || ''), one.placeholder);

  console.log('\n[adding a channel]');
  await page.evaluate(() => { const b = [...document.querySelectorAll('.chat-head button')].find((e) => /add person/i.test(e.textContent)); if (b) b.click(); });
  await sleep(600);
  const gated = await page.evaluate(() => ({
    field: !!document.querySelector('input[placeholder*="channel" i]'),
    button: [...document.querySelectorAll('.chat-invite button')].some((b) => /new channel/i.test(b.textContent)),
  }));
  check('the naming field is not just sitting there', !gated.field, JSON.stringify(gated));
  check('there is a New channel button instead', gated.button);

  await click('.chat-invite button', 'New channel');
  await sleep(500);
  check('which reveals the field', !!(await page.$('input[placeholder*="channel" i]')));
  await setInput('input[placeholder*="channel" i]', 'random');
  await sleep(200);
  await page.evaluate(() => { const i = document.querySelector('input[placeholder*="channel" i]'); const b = i && i.parentElement.querySelector('button'); if (b) b.click(); });

  for (let i = 0; i < 60 && !(await page.$('.chan-pick')); i++) await sleep(500);
  // the picker appears on the relay echo, which can beat createChannel's own
  // continuation — give the field a moment to put itself away
  for (let i = 0; i < 20 && (await page.$('input[placeholder*="channel" i]')); i++) await sleep(500);
  const two = await page.evaluate(() => {
    const sel = document.querySelector('.chan-pick');
    return { picker: !!sel, tag: sel && sel.tagName, opts: sel ? [...sel.options].map((o) => o.textContent) : [], field: !!document.querySelector('input[placeholder*="channel" i]') };
  });
  check('two channels become a select', two.picker && two.tag === 'SELECT' && two.opts.length === 2, JSON.stringify(two.opts));
  check('and the naming field puts itself away', !two.field);
  const r2 = await composerFits();
  check('composer still on screen', r2.fits, `bottom ${r2.bottom} of ${r2.vh}`);
} catch (e) {
  check('run completed', false, e.message);
} finally {
  await browser.close();
  server.stop(true);
}
console.log(ok ? '\n✅ the composer stays put' : '\n❌ failures above');
process.exit(ok ? 0 : 1);