gitoriaLog in with ident

components

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitbecbd59bbecbd59bcomponents#13: modal dialog (<dialog> based, focus wrap, backdrop/Esc/x close with reason, scroll lock, footer buttons)mrebecbd59b/tests/browser.mjs

64.0 KB

  1. // tests/browser.mjs — THE GATE of components.hybriel.worldapi.org: its own server, real headless Chrome.
  2. // node tests/browser.mjs (COMPONENTS_GATE_PORT server, default 8700; COMPONENTS_GATE_CHROME "8701-8709" = Chrome debug ports)
  3. // Screenshots (390 / 1280 px) land in .scratch/gate-*.png. Every process started is stopped by PID.
  4. import { spawn, execSync } from 'node:child_process';
  5. import { writeFileSync, existsSync, mkdirSync, rmSync } from 'node:fs';
  6. import { dirname, join, resolve } from 'node:path';
  7. import { fileURLToPath } from 'node:url';
  8. import { launchBrowser } from './cdp.mjs';
  9. if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';
  10. const HERE = dirname(fileURLToPath(import.meta.url));
  11. const APP = resolve(HERE, '..');
  12. const PORT = Number(process.env.COMPONENTS_GATE_PORT || 8700);
  13. const [CH_FROM, CH_TO] = (process.env.COMPONENTS_GATE_CHROME || '8701-8709').split('-').map(Number);
  14. const BASE = `http://127.0.0.1:${PORT}`;
  15. const SCRATCH = join(APP, '.scratch');
  16. const SESSIONS = join(SCRATCH, 'gate-sessions');
  17. rmSync(SESSIONS, { recursive: true, force: true });
  18. mkdirSync(SESSIONS, { recursive: true });
  19. let failures = 0, passes = 0;
  20. function check(label, ok, detail = '') {
  21. console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`);
  22. if (ok) passes++; else failures++;
  23. }
  24. const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  25. let log = '', server = null, browser = null;
  26. async function stop() {
  27. if (browser) { try { await browser.close(); } catch {} browser = null; }
  28. if (server) {
  29. try { server.kill('SIGTERM'); } catch {}
  30. await new Promise(r => { if (server.exitCode !== null || server.signalCode !== null) return r(); server.once('exit', r); setTimeout(r, 3000); });
  31. server = null;
  32. }
  33. }
  34. async function main() {
  35. server = spawn(join(APP, 'bin/hybriel'), ['project.hl'], { cwd: APP, env: { ...process.env, COMPONENTS_PORT: String(PORT), COMPONENTS_SESSIONS: SESSIONS + '/', COMPONENTS_WATCH: '0' }, stdio: ['ignore', 'pipe', 'pipe'] });
  36. server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);
  37. for (let i = 0; ; i++) {
  38. try { if ((await fetch(BASE + '/')).ok) break; } catch {}
  39. if (i > 80) throw new Error('server did not come up\n' + log);
  40. await sleep(250);
  41. }
  42. // the home lists the component; its README is served
  43. const home = await (await fetch(BASE + '/')).text();
  44. check('no month-view.js served', (await fetch(BASE + '/month-view/month-view.js')).status === 404 && !existsSync(join(APP, 'components/month-view/month-view.js')));
  45. check('home lists the month view', home.includes('id="open-month-view"'));
  46. check('home shows only the list, no component embedded (creator 2026-09-25)', !home.includes('<month-view'));
  47. const readme = await fetch(BASE + '/month-view/README.md');
  48. check('README is served as Markdown', readme.ok && /markdown/.test(readme.headers.get('content-type')) && (await readme.text()).includes('Month view'));
  49. // first render on the server: 6 weeks of 7 days, one today
  50. const demo = await (await fetch(BASE + '/month-view')).text();
  51. check('server renders 42 days and one today', (demo.match(/<li class=/g) || []).length === 42 && (demo.match(/class="today"/g) || []).length === 1);
  52. browser = await launchBrowser({ debugPortRange: [CH_FROM, CH_TO] });
  53. const page = await browser.newPage();
  54. const SLOW = 3;
  55. const goto = page.goto.bind(page);
  56. page.goto = (u, o = {}) => goto(u, { ...o, timeout: 15000 * SLOW });
  57. await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
  58. // a zone already on the next day (UTC+14) compared with the server's UTC date: the visitor's today must win (ticket #11)
  59. await page.send('Emulation.setTimezoneOverride', { timezoneId: 'Pacific/Kiritimati' });
  60. await page.goto(BASE + '/month-view');
  61. // the month view's own demo page (the home only lists the components)
  62. await page.waitForSelector('#month-title');
  63. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  64. const shot = async (name) => { const { data } = await page.send('Page.captureScreenshot', { format: 'png' }); writeFileSync(join(SCRATCH, `gate-${name}.png`), Buffer.from(data, 'base64')); };
  65. const ev = (e) => page.evaluate(e);
  66. const title = () => ev('document.querySelector("#month-title").textContent');
  67. const localToday = await ev(`(() => { const n = new Date(), p = x => String(x).padStart(2, '0'); return n.getFullYear() + '-' + p(n.getMonth() + 1) + '-' + p(n.getDate()); })()`);
  68. const expectTitle = (y, m) => ev(`new Intl.DateTimeFormat(navigator.language, { month: 'long' }).format(new Date(Date.UTC(${y}, ${m - 1}, 15, 12))) + ' ${y}'`);
  69. const [ty, tm] = localToday.split('-').map(Number);
  70. // the browser's today and language arrive at load, with no pointer needed
  71. await page.waitFor(`!!document.querySelector('li.today time') && document.querySelector('li.today time').getAttribute('datetime') === ${JSON.stringify(localToday)}`, { label: 'browser today marked at load' });
  72. await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 300, y: 200 });
  73. await page.waitFor(`document.querySelector("#month-title").textContent.toLowerCase() === (${JSON.stringify(await expectTitle(ty, tm))}).toLowerCase()`, { label: 'title in the browser language' });
  74. check('title is the current month and year in the browser language', true);
  75. check('today is marked (browser date)', (await ev(`document.querySelector('li.today time').getAttribute('datetime')`)) === localToday, localToday);
  76. check('42 days, 6 weeks', (await ev('document.querySelectorAll("#month-days li").length')) === 42);
  77. const t0 = await title();
  78. // next / prev
  79. await page.click('#month-next');
  80. await page.waitFor(`document.querySelector("#month-title").textContent !== ${JSON.stringify(t0)}`, { label: 'next month' });
  81. const nm = tm === 12 ? 1 : tm + 1, ny = tm === 12 ? ty + 1 : ty;
  82. await page.waitFor(`document.querySelector('#month-title').textContent.toLowerCase() === ${JSON.stringify((await expectTitle(ny, nm)).toLowerCase())}`, { label: 'next month in the browser language' });
  83. check('next shows the next month', true);
  84. await page.click('#month-prev');
  85. await page.waitFor(`document.querySelector("#month-title").textContent === ${JSON.stringify(t0)}`, { label: 'prev month' });
  86. check('prev returns to the month, today marked again', (await ev('document.querySelectorAll("li.today").length')) === 1);
  87. // walk to February 2028 (leap year) and check its days and its first weekday (Tuesday)
  88. let guard = 0;
  89. const feb28 = (await expectTitle(2028, 2)).toLowerCase();
  90. while ((await title()).toLowerCase() !== feb28 && guard++ < 40) {
  91. const before = await title();
  92. await page.click('#month-next');
  93. await page.waitFor(`document.querySelector("#month-title").textContent !== ${JSON.stringify(before)}`, { label: 'paging' });
  94. }
  95. const feb = await ev(`(() => { const inM = [...document.querySelectorAll('month-view #month-days li:not(.outside) time')]; const first = document.querySelector('#month-days li:not(.outside)'); return { n: inM.length, col: [...first.parentNode.children].indexOf(first) % 7 }; })()`);
  96. check('Feb 2028: 29 days, the 1st is a Tuesday (column 2)', feb.n === 29 && feb.col === 1, JSON.stringify(feb));
  97. // year rollover: go back to December 2027 and January 2028 by prev
  98. await page.click('#month-prev');
  99. await page.click('#month-prev');
  100. const dec27 = (await expectTitle(2027, 12)).toLowerCase();
  101. await page.waitFor(`document.querySelector("#month-title").textContent.toLowerCase() === ${JSON.stringify(dec27)}`, { label: 'back to December' });
  102. const dec = await ev(`(() => ({ n: document.querySelectorAll('#month-days li:not(.outside)').length, col: [...document.querySelectorAll('#month-days li')].indexOf(document.querySelector('#month-days li:not(.outside)')) % 7 }))()`);
  103. check('Dec 2027: 31 days, the 1st is a Wednesday', dec.n === 31 && dec.col === 2, JSON.stringify(dec));
  104. check('no console errors', page.problems().filter(m => !/favicon|Failed to load resource/.test(m.text)).length === 0, page.dumpConsole());
  105. // the date picker: click the field, pick a day, the value is YYYY-MM-DD; typing works
  106. check('home lists the date picker', home.includes('id="open-date-picker"'));
  107. check('date picker README is served', (await fetch(BASE + '/date-picker/README.md')).ok);
  108. await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
  109. await page.goto(BASE + '/date-picker');
  110. await page.waitForSelector('date-picker input');
  111. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  112. await sleep(1500);
  113. check('the date picker page loads no script file of its own', (await ev(`[...document.scripts].filter(s => s.src && !s.src.includes('__hl')).length`)) === 0);
  114. const pop = (n) => `getComputedStyle(document.querySelectorAll('date-picker picker-popup')[${n}]).display !== 'none'`;
  115. const val = (n) => ev(`document.querySelectorAll('date-picker input')[${n}].value`);
  116. const realClick = async (sel) => {
  117. const r = await ev(`(() => { const b = document.querySelector(${JSON.stringify(sel)}).getBoundingClientRect(); return { x: b.x + b.width / 2, y: b.y + b.height / 2 }; })()`);
  118. await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: r.x, y: r.y, button: 'none' });
  119. await sleep(120);
  120. await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: r.x, y: r.y, button: 'left', clickCount: 1 });
  121. await sleep(100);
  122. await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: r.x, y: r.y, button: 'left', clickCount: 1 });
  123. await sleep(700);
  124. };
  125. const typeInto = async (n, text) => {
  126. await ev(`document.querySelectorAll('date-picker input')[${n}].focus()`);
  127. await ev(`document.querySelectorAll('date-picker input')[${n}].select()`);
  128. await page.send('Input.insertText', { text });
  129. await sleep(400);
  130. };
  131. check('two fields with their names', (await ev(`[...document.querySelectorAll('date-picker input')].map(i => i.name).join()`)) === 'arrival,departure');
  132. check('the month is closed at first', !(await ev(pop(0))) && !(await ev(pop(1))));
  133. await realClick('date-picker input[name=arrival]');
  134. await page.waitFor(pop(0), { label: 'popup open' });
  135. check('click on the field opens the month under it', (await ev(pop(0))) && !(await ev(pop(1))));
  136. const pos = await ev(`(() => { const f = document.querySelectorAll('date-picker input')[0].getBoundingClientRect(), p = document.querySelectorAll('date-picker picker-popup')[0].getBoundingClientRect(); return { dx: Math.abs(p.left - f.left), dy: p.top - f.bottom }; })()`);
  137. check('the month sits right under the field', pos.dx < 2 && pos.dy >= 0 && pos.dy < 12, JSON.stringify(pos));
  138. await ev(`document.querySelector('date-picker input[name=arrival]').__mark = 1`);
  139. await realClick('date-picker date-field:nth-child(1) ol.days li:not(.outside) time[datetime$="-15"]');
  140. const picked = await val(0);
  141. check('the field element is the same one after picking (not re-created)', (await ev(`document.querySelector('date-picker input[name=arrival]').__mark`)) === 1);
  142. check('a picked day is in the field as YYYY-MM-DD', /^\d{4}-\d{2}-15$/.test(picked), picked);
  143. check('the month closes after picking', !(await ev(pop(0))));
  144. // the month arrows page
  145. await realClick('date-picker input[name=departure]');
  146. await page.waitFor(pop(1), { label: 'popup open 2' });
  147. const dt0 = await ev(`document.querySelectorAll('date-picker h2')[1].textContent`);
  148. await realClick('date-picker date-field:nth-child(2) button.next');
  149. check('the next-month arrow changes the month', (await ev(`document.querySelectorAll('date-picker h2')[1].textContent`)) !== dt0);
  150. await realClick('date-picker date-field:nth-child(2) ol.days li:not(.outside) time[datetime$="-20"]');
  151. check('the second field gets its own day, the first keeps its', /^\d{4}-\d{2}-20$/.test(await val(1)) && (await val(0)) === picked, await val(1));
  152. // typing: a date in another month opens on that month and is marked
  153. await typeInto(0, '2028-02-29');
  154. check('typing works: the field holds the typed date', (await val(0)) === '2028-02-29', await val(0));
  155. check('a typed date shows its month and is marked', (await ev(`document.querySelectorAll('date-picker date-field')[0].querySelector('li.selected time').getAttribute('datetime')`)) === '2028-02-29');
  156. await typeInto(0, '2027-02-30');
  157. check('an impossible date marks the field invalid', (await ev(`document.querySelectorAll('date-picker input')[0].getAttribute('aria-invalid')`)) === 'true');
  158. await typeInto(0, '2028-02-29');
  159. await sleep(300); await shot('picker');
  160. await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
  161. await sleep(300);
  162. check('Escape closes the month', !(await ev(pop(0))));
  163. await realClick('h1'); await sleep(200);
  164. // the form posts both fields by name
  165. await ev(`document.querySelector('date-picker-demo form button[type=submit]').click()`);
  166. await page.waitFor(`location.search.includes('arrival=2028-02-29')`, { label: 'form posted' });
  167. check('the form posts both fields by name', /arrival=2028-02-29&departure=\d{4}-\d{2}-20/.test(await ev('location.search')), await ev('location.search'));
  168. check('no console errors on the date picker page', page.problems().filter(m => !/favicon|Failed to load resource/.test(m.text)).length === 0, page.dumpConsole());
  169. // the date range picker: a start click, an end click, both in the field, the days between highlighted
  170. check('home lists the range picker', home.includes('id="open-range-picker"'));
  171. check('range picker README is served', (await fetch(BASE + '/range-picker/README.md')).ok);
  172. await page.goto(BASE + '/range-picker');
  173. await page.waitForSelector('#range-picker-input');
  174. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  175. await sleep(1500);
  176. const rvis = () => ev(`getComputedStyle(document.querySelector('range-picker picker-popup')).display !== 'none'`);
  177. const rval = () => ev(`document.getElementById('range-picker-input').value`);
  178. const day = (n) => ev(`document.querySelector('range-picker ol.days li:not(.outside) time[datetime$="-${n}"]').closest('li').click()`);
  179. check('range: month is closed at first', !(await rvis()));
  180. await page.click('#range-picker-input');
  181. await page.waitFor(`getComputedStyle(document.querySelector('range-picker picker-popup')).display !== 'none'`, { label: 'popup open' });
  182. await day('20');
  183. check('range: the first click is the start, the month stays open', /^\d{4}-\d{2}-20 – $/.test(await rval()) && await rvis(), await rval());
  184. await day('10');
  185. const rv = await rval();
  186. check('range: second (earlier) click swaps: start – end in the field', /^\d{4}-\d{2}-10 – \d{4}-\d{2}-20$/.test(rv), rv);
  187. await sleep(500);
  188. check('range: the month closes after the end', !(await rvis()));
  189. await page.click('#range-picker-input');
  190. await page.waitFor(`getComputedStyle(document.querySelector('range-picker picker-popup')).display !== 'none'`, { label: 'popup reopen' });
  191. const hl = await ev(`(() => ({ sel: [...document.querySelectorAll('range-picker li.selected time')].map(t => t.textContent), between: [...document.querySelectorAll('range-picker li.between time')].map(t => t.textContent) }))()`);
  192. check('range: start and end marked, the 9 days between highlighted', hl.sel.join() === '10,20' && hl.between.length === 9, JSON.stringify(hl));
  193. await shot('range');
  194. await ev(`(() => { const f = document.getElementById('range-picker-input'); f.focus(); f.value = '2028-02-01 – 2028-02-29'; f.dispatchEvent(new Event('input', { bubbles: true })); })()`);
  195. await page.waitFor(`document.querySelectorAll('range-picker li.between').length === 27`, { label: 'typed range marked' });
  196. check('range: a typed range shows its month, 27 days between', true);
  197. await ev(`(() => { const f = document.getElementById('range-picker-input'); f.value = '2028-02-01 – 2028-02-30'; f.dispatchEvent(new Event('input', { bubbles: true })); })()`);
  198. await page.waitFor(`document.getElementById('range-picker-input').getAttribute('aria-invalid') === 'true'`, { label: 'impossible range marked' });
  199. check('range: an impossible range marks the field invalid', true);
  200. await ev(`(() => { const f = document.getElementById('range-picker-input'); f.value = '2028-02-01 – 2028-02-29'; f.dispatchEvent(new Event('input', { bubbles: true })); })()`);
  201. await page.waitFor(`document.getElementById('range-picker-input').getAttribute('aria-invalid') === 'false'`, { label: 'valid again' });
  202. await ev(`document.querySelector('form button[type=submit]').click()`);
  203. await page.waitFor(`location.search.length > 0`, { label: 'form posted' });
  204. const q = decodeURIComponent(await ev(`location.search`)).replace(/\+/g, ' ');
  205. check('range: the form posts the period by the field name', q.includes('range=2028-02-01 – 2028-02-29'), q);
  206. check('range picker has no .js file', !existsSync(join(APP, 'components/range-picker/range-picker.js')));
  207. check('no console errors on the range picker page', page.problems().filter(m => !/favicon|Failed to load resource/.test(m.text)).length === 0, page.dumpConsole());
  208. // ticket #4: the one-file upload (pure Hybriel): choose / drop -> name, size, progress -> listed; the app stores it
  209. {
  210. check('home lists the file upload', home.includes('id="open-file-upload"'));
  211. check('file upload README is served', (await fetch(BASE + '/file-upload/README.md')).ok);
  212. check('file upload has no .js file', !existsSync(join(APP, 'components/file-upload/file-upload.js')));
  213. check('no component file uses XMLHttpRequest or fetch(', !execSync(`grep -rlE "XMLHttpRequest|fetch\\(" ${join(APP, 'components')} || true`).toString().trim(), execSync(`grep -rnE "XMLHttpRequest|fetch\\(" ${join(APP, 'components')} || true`).toString());
  214. await page.goto(BASE + '/file-upload');
  215. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  216. await sleep(600);
  217. const FU = (s) => `document.querySelector('file-upload ${s}')`;
  218. const mk = (name, n) => `const dt = new DataTransfer(); dt.items.add(new File([new Uint8Array(${n}).fill(66)], '${name}', { type: 'text/plain' }));`;
  219. await ev(`(() => { window.__max = 0; const bar = ${FU('progress')}; new MutationObserver(() => { if (bar.max > 1) window.__max = Math.max(window.__max, bar.value / bar.max) }).observe(bar, { attributes: true }); ${mk('report.txt', 3145728)} ${FU('drop-zone')}.dispatchEvent(new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true })); })()`);
  220. await page.waitFor(`${FU('.state')}.textContent === 'Uploaded'`, { timeout: 30000, label: 'uploaded' });
  221. check('upload: name and size shown', (await ev(FU('.name') + '.textContent')) === 'report.txt' && (await ev(FU('.size') + '.textContent')) === '3.0 MB');
  222. check('upload: progress bar reaches 100%', (await ev(FU('progress') + '.value')) === 100 && (await ev('window.__max')) >= 1);
  223. check('upload: listed under Uploaded', (await ev(FU('ul.uploaded li') + '.textContent')) === 'report.txt — 3.0 MB');
  224. const stored = execSync(`ls -l ${join(APP, 'storage/uploads')} | grep -c ' 3145728 '`).toString().trim();
  225. check('upload: the app stored all bytes', Number(stored) >= 1, stored);
  226. // choosing a second file (input) adds a second row
  227. const { root } = await page.send('DOM.getDocument');
  228. const { nodeId } = await page.send('DOM.querySelector', { nodeId: root.nodeId, selector: 'file-upload input[type=file]' });
  229. writeFileSync(join(SCRATCH, 'pick.txt'), 'hello');
  230. await page.send('DOM.setFileInputFiles', { files: [join(SCRATCH, 'pick.txt')], nodeId });
  231. await page.waitFor(`document.querySelectorAll('file-upload ul.uploaded li').length === 2`, { timeout: 30000, label: 'chosen file listed' });
  232. check('choose: second file shown and listed', (await ev(FU('.name') + '.textContent')) === 'pick.txt' && (await ev(FU('ul.uploaded li:nth-child(2)') + '.textContent')) === 'pick.txt — 5 B');
  233. check('upload: no console errors', page.problems().filter(m => !/favicon|Failed to load resource/.test(m.text)).length === 0, page.dumpConsole());
  234. await page.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 800, deviceScaleFactor: 1, mobile: true });
  235. await sleep(300); await shot('file-upload-narrow');
  236. await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
  237. await sleep(200); await shot('file-upload');
  238. }
  239. // the pagination: 89 pages; the ticket's three shapes
  240. check('home lists the pagination', home.includes('id="open-pagination"'));
  241. check('pagination README is served', (await fetch(BASE + '/pagination/README.md')).ok);
  242. await page.goto(BASE + '/pagination');
  243. await page.waitForSelector('#pagination-items');
  244. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  245. await sleep(500);
  246. const pg = () => ev(`[...document.querySelectorAll('#pagination-items button')].map(b => b.textContent).join(' ')`);
  247. const clickPg = (t) => ev(`[...document.querySelectorAll('#pagination-items button')].find(b => b.textContent === ${JSON.stringify(t)}).click()`);
  248. const waitPg = (t) => page.waitFor(`[...document.querySelectorAll('#pagination-items button')].map(b => b.textContent).join(' ') === ${JSON.stringify(t)}`, { label: 'pagination ' + t });
  249. check('pagination: first page shows 1 2 3 4 … 89 Next', (await pg()) === '1 2 3 4 … 89 Next', await pg());
  250. await clickPg('4'); await waitPg('Previous 1 2 3 4 5 … 89 Next');
  251. check('pagination: page 4 fills a one-page gap: Previous 1 2 3 4 5 … 89 Next', true);
  252. await clickPg('5'); await waitPg('Previous 1 … 4 5 6 … 89 Next'); await clickPg('6'); await waitPg('Previous 1 … 5 6 7 … 89 Next'); await clickPg('7');
  253. await waitPg('Previous 1 … 6 7 8 … 89 Next');
  254. check('pagination: middle shows first, last, current ± 1', true);
  255. check('pagination: current page is marked', (await ev(`document.querySelector('#pagination-items li.current button').textContent`)) === '7');
  256. await ev(`document.querySelector('#pagination-items li.gap button').click()`); await sleep(300);
  257. check('pagination: a click on … does nothing', (await pg()) === 'Previous 1 … 6 7 8 … 89 Next');
  258. await clickPg('Previous'); await waitPg('Previous 1 … 5 6 7 … 89 Next');
  259. check('pagination: Previous goes back one page', true);
  260. await clickPg('89'); await waitPg('Previous 1 … 86 87 88 89');
  261. check('pagination: last page shows Previous 1 … 86 87 88 89, no Next', true);
  262. await clickPg('1'); await waitPg('1 2 3 4 … 89 Next');
  263. check('pagination: back on page 1 no Previous', true);
  264. await shot('pagination');
  265. check('no console errors on the pagination page', page.problems().filter(m => !/favicon|Failed to load resource/.test(m.text)).length === 0, page.dumpConsole());
  266. // the Markdown editor: two on one page, real typing and real clicks
  267. check('home lists the Markdown editor', home.includes('id="open-md-editor"'));
  268. const mdr = await fetch(BASE + '/md-editor/README.md');
  269. check('Markdown editor README is served as Markdown', mdr.ok && /markdown/.test(mdr.headers.get('content-type')) && (await mdr.text()).includes('Markdown editor'));
  270. check('no .js of its own in components/md-editor', !existsSync(join(APP, 'components/md-editor')) || !execSync(`find ${join(APP, 'components/md-editor')} -name '*.js' -o -name '*.mjs'`).toString().trim());
  271. await page.send('Emulation.setDeviceMetricsOverride', { width: 1000, height: 1000, deviceScaleFactor: 1, mobile: false });
  272. await page.goto(BASE + '/md-editor');
  273. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  274. const ED = 'md-editor:nth-of-type(1)';
  275. const md1 = (q) => `document.querySelectorAll('md-editor')[0].${q}`;
  276. const md2 = (q) => `document.querySelectorAll('md-editor')[1].${q}`;
  277. const val1 = () => ev(md1(`querySelector('input.value').value`));
  278. const val2 = () => ev(md2(`querySelector('input.value').value`));
  279. const art1 = () => ev(md1(`querySelector('article').innerHTML`));
  280. await page.waitFor(md1(`querySelector('article h1')`) + ' !== null', { label: 'the value shows formatted' });
  281. check('a value shows formatted: heading, bold, italic, code, link, list', (await art1()) === '<h1>Hello</h1><p>Some <strong>bold</strong> and <em>italic</em> text with <code>code</code> and a <a href="https://example.com">link</a>.</p><ul><li>first</li><li>second</li></ul>', await art1());
  282. check('an untouched value is the same Markdown', (await val1()) === '# Hello\n\nSome **bold** and *italic* text with `code` and a [link](https://example.com).\n\n- first\n- second', await val1());
  283. check('the canonical form of the value equals it (round trip)', await ev(md1(`mdCanon`) + ` === ` + md1(`querySelector('input.value').value`)));
  284. check('the second editor is empty and shows its placeholder', (await ev(`getComputedStyle(document.querySelectorAll('md-editor')[1].querySelector('.placeholder')).display`)) === 'block');
  285. const key = async (k, code, vk, text) => { await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: k, code, windowsVirtualKeyCode: vk, text }); await page.send('Input.dispatchKeyEvent', { type: 'keyUp', key: k, code, windowsVirtualKeyCode: vk }); };
  286. const typeText = async (t) => { for (const ch of t) await key(ch, '', undefined, ch); };
  287. const enter = () => key('Enter', 'Enter', 13, '\r');
  288. // type into the second editor: heading, bold, code, list
  289. await page.click('md-editor:nth-of-type(2) article');
  290. check('the placeholder goes away when it has text', true);
  291. await typeText('# Title'); await typeText(' '); await typeText('here'); await enter();
  292. await typeText('a **bold** and `code` x'); await enter();
  293. check('typing "# " makes a heading while you type', (await ev(md2(`querySelector('article h1')`) + ' !== null')));
  294. const html2 = await ev(md2(`querySelector('article').innerHTML`));
  295. check('typing **x** and `x` formats them', /<strong>bold<\/strong>/.test(html2) && /<code>code<\/code>/.test(html2), html2);
  296. await typeText('- '); await sleep(100);
  297. check('typing "- " makes a list', (await ev(md2(`querySelector('article ul')`) + ' !== null')));
  298. await typeText('item one'); await enter(); await typeText('item two');
  299. await sleep(400);
  300. const v2 = await val2();
  301. check('the value is plain Markdown of what you typed', v2.startsWith('# Title here\n\na **bold** and `code` x\n\n') && /- item one\n- item two$/.test(v2), JSON.stringify(v2));
  302. check('the demo shows that value below the editor', (await ev(`document.getElementById('md-value-summary').textContent`)) === v2);
  303. check('the first editor was not touched by typing in the second', (await val1()).startsWith('# Hello'));
  304. check('the editor content survives re-renders of the page (same article element)', (await ev(md2(`querySelector('article h1')`) + ' !== null')));
  305. // toolbar: select the first list item in the first editor, Bold, then Undo
  306. const selectIn = (i, css) => ev(`(()=>{const e=document.querySelectorAll('md-editor')[${i}].querySelector(${JSON.stringify('article ' + css)});const r=document.createRange();r.selectNodeContents(e);const s=getSelection();s.removeAllRanges();s.addRange(r)})()`);
  307. await selectIn(0, 'li:nth-child(1)');
  308. await page.click('md-editor:nth-of-type(1) nav button.bold'); await sleep(250);
  309. check('toolbar Bold: the selection is **bold** in the value', (await val1()).endsWith('- **first**\n- second'), await val1());
  310. await page.click('md-editor:nth-of-type(1) nav button.undo'); await sleep(250);
  311. check('toolbar Undo takes it back', (await val1()).endsWith('- first\n- second'), await val1());
  312. await selectIn(0, 'h1');
  313. await page.click('md-editor:nth-of-type(1) nav button.heading'); await sleep(250);
  314. check('toolbar heading button: H1 becomes H2', (await val1()).startsWith('## Hello'), await val1());
  315. await selectIn(0, 'ul');
  316. await page.click('md-editor:nth-of-type(1) nav button.ol'); await sleep(250);
  317. check('toolbar numbered list: the bullets become 1. 2.', (await val1()).endsWith('1. first\n2. second'), await val1());
  318. await selectIn(0, 'p');
  319. await page.click('md-editor:nth-of-type(1) nav button.pre'); await sleep(250);
  320. check('toolbar code block: the paragraph becomes a fenced block', /```\nSome .*\n```/.test(await val1()), await val1());
  321. // link: Ctrl+K opens the small form, Apply
  322. await ev(`(()=>{const a=document.querySelectorAll('md-editor')[1].querySelector('article');const e=a.querySelector('h1');const r=document.createRange();r.selectNodeContents(e);const s=getSelection();s.removeAllRanges();s.addRange(r);a.focus()})()`);
  323. await page.click('md-editor:nth-of-type(2) nav button.link'); await sleep(250);
  324. check('the link form opens', (await ev(`getComputedStyle(document.querySelectorAll('md-editor')[1].querySelector('link-form')).display`)) === 'flex');
  325. await ev(md2(`querySelector('link-form input').focus()`));
  326. await typeText('https://example.org/x'); await enter(); await sleep(300);
  327. check('a link is added: [text](url) in the value', (await val2()).includes('# [Title here](https://example.org/x)'), await val2());
  328. check('a javascript: link is refused', await (async () => { await selectIn(1, 'li:nth-child(1)'); await page.click('md-editor:nth-of-type(2) nav button.link'); await ev(md2(`querySelector('link-form input').focus()`)); await typeText('javascript:alert(1)'); await enter(); await sleep(250); return !(await val2()).includes('javascript:'); })());
  329. // paste Markdown in: formatted, plain text; html in it stays text
  330. await ev(`(()=>{const a=document.querySelectorAll('md-editor')[0].querySelector('article');a.focus();const s=getSelection();s.selectAllChildren(a);s.collapseToEnd();const dt=new DataTransfer();dt.setData('text/plain','## Pasted\\n\\nhello **there** <b>x</b>');a.dispatchEvent(new ClipboardEvent('paste',{clipboardData:dt,bubbles:true,cancelable:true}))})()`);
  331. await sleep(300);
  332. const a1 = await art1();
  333. check('pasted Markdown shows formatted', /<h2>Pasted<\/h2><p>hello <strong>there<\/strong> /.test(a1), a1);
  334. check('pasted HTML stays text (no element made from it)', !/<b>x<\/b>/.test(a1) && (await val1()).includes('\\<b>x\\</b>'), a1);
  335. // the Markdown source switch
  336. await page.click('md-editor:nth-of-type(1) footer button'); await sleep(300);
  337. check('the source switch shows a textarea with the Markdown, hides the visual editor', (await ev(md1(`querySelector('textarea').value`))) === (await val1()) && (await ev(`getComputedStyle(document.querySelectorAll('md-editor')[0].querySelector('article')).display`)) === 'none');
  338. await ev(md1(`querySelector('textarea')`) + `.value = '# From source\\n\\n1. a\\n2. b\\n\\n~~~\\nx < y\\n~~~'; ` + md1(`querySelector('textarea')`) + `.dispatchEvent(new Event('input', { bubbles: true }))`);
  339. await sleep(200);
  340. check('typing in the source is the value (and shown below)', (await val1()) === '# From source\n\n1. a\n2. b\n\n~~~\nx < y\n~~~' && (await ev(`document.getElementById('md-value').textContent`)) === (await val1()));
  341. await page.click('md-editor:nth-of-type(1) footer button'); await sleep(300);
  342. check('back to the visual editor: the source text is formatted', (await art1()) === '<h1>From source</h1><ol><li>a</li><li>b</li></ol><pre>x &lt; y</pre>', await art1());
  343. // the form posts both editors by name
  344. await page.click('#md-form button[type=submit]'); await sleep(800);
  345. const url = new URL(await ev('location.href'));
  346. check('the form posts both values by their names', url.searchParams.get('note').replaceAll('\r\n', '\n') === '# From source\n\n1. a\n2. b\n\n~~~\nx < y\n~~~' && url.searchParams.get('summary') != null && url.searchParams.get('summary').startsWith('# [Title here]'), url.search);
  347. await shot('md-editor');
  348. check('no console errors on the Markdown editor page', page.problems().filter(m => !/favicon|Failed to load resource/.test(m.text)).length === 0, page.dumpConsole());
  349. // typed Markdown shortcuts are CONSUMED: the marker disappears, only the formatting stays (real keys, one character at a time)
  350. await page.goto(BASE + '/md-editor');
  351. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  352. await page.waitFor(md1(`querySelector('article h1')`) + ' !== null', { label: 'the value shows formatted' });
  353. const keyR = async (k, code, vk, text, modifiers) => { await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: k, code, windowsVirtualKeyCode: vk, text, modifiers }); await page.send('Input.dispatchKeyEvent', { type: 'keyUp', key: k, code, windowsVirtualKeyCode: vk, modifiers }); await sleep(20); };
  354. const typeR = async (t) => { for (const ch of t) await keyR(ch, ch === ' ' ? 'Space' : '', ch === ' ' ? 32 : undefined, ch); };
  355. const enterR = () => keyR('Enter', 'Enter', 13, '\r');
  356. const wipe = async (i) => { await ev(`document.querySelectorAll('md-editor')[${i}].querySelector('article').focus()`); await keyR('a', 'KeyA', 65, undefined, 2); await keyR('Backspace', 'Backspace', 8); await sleep(150); };
  357. const valR = (i) => ev(`document.querySelectorAll('md-editor')[${i}].querySelector('input.value').value`);
  358. const artR = (i) => ev(`document.querySelectorAll('md-editor')[${i}].querySelector('article').innerHTML`);
  359. await page.click('md-editor:nth-of-type(2) article');
  360. await typeR('# Heading'); await sleep(200);
  361. check('typing "# Heading" in an empty editor: heading, value "# Heading"', (await valR(1)) === '# Heading' && (await artR(1)) === '<h1>Heading</h1>', JSON.stringify([await valR(1), await artR(1)]));
  362. await wipe(1);
  363. await typeR('# Again'); await sleep(200);
  364. check('typing "# " after the editor was emptied (an empty heading is left): "# Again", not "# # Again"', (await valR(1)) === '# Again', JSON.stringify(await valR(1)));
  365. await wipe(1); await keyR('Backspace', 'Backspace', 8);
  366. await typeR('- one'); await enterR(); await typeR('- two'); await sleep(200);
  367. check('typing "- two" in a list item: the marker is consumed, value "- one\n- two"', (await valR(1)) === '- one\n- two', JSON.stringify([await valR(1), await artR(1)]));
  368. await enterR(); await typeR('1. three'); await sleep(200);
  369. check('typing "1. " in a bulleted list switches to a numbered list without the marker', !(await valR(1)).includes('1. 1.') && !/- 1\. three/.test(await valR(1)) && (await valR(1)).endsWith('three'), JSON.stringify(await valR(1)));
  370. await wipe(1); await keyR('Backspace', 'Backspace', 8);
  371. await typeR('1. a'); await enterR(); await typeR('2. b'); await sleep(200);
  372. check('typing "2. b" in a numbered list: the marker is consumed, value "1. a\n2. b"', (await valR(1)) === '1. a\n2. b', JSON.stringify(await valR(1)));
  373. await wipe(1); await keyR('Backspace', 'Backspace', 8);
  374. await typeR('# One'); await enterR(); await typeR('text'); await enterR(); await typeR('## Two'); await sleep(200);
  375. check('a heading, a paragraph and a second heading typed one after the other', (await valR(1)) === '# One\n\ntext\n\n## Two', JSON.stringify([await valR(1), await artR(1)]));
  376. check('no console errors while typing shortcuts', page.problems().filter(m => !/favicon|Failed to load resource/.test(m.text)).length === 0, page.dumpConsole());
  377. await page.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 800, deviceScaleFactor: 1, mobile: true });
  378. await page.goto(BASE + '/md-editor'); await sleep(800);
  379. check('the Markdown editor fits at 390 px', await ev('document.documentElement.scrollWidth <= window.innerWidth'));
  380. await shot('md-editor-narrow');
  381. await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
  382. // ticket #9: files and images in the Markdown editor (drop, paste, choose) go through emit, with progress, and land in the text
  383. {
  384. await page.goto(BASE + '/md-editor');
  385. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  386. await sleep(600);
  387. const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
  388. const mkFiles = `const png = Uint8Array.from(atob('${PNG}'), c => c.charCodeAt(0)); const big = new Uint8Array(3145728).fill(65); const dt = new DataTransfer(); dt.items.add(new File([png], 'one.png', { type: 'image/png' })); dt.items.add(new File([png], 'two.png', { type: 'image/png' })); dt.items.add(new File([big], 'doc.pdf', { type: 'application/pdf' }));`;
  389. await ev(`(() => { const ed = document.querySelectorAll('md-editor')[1]; window.__rows = []; window.__max = 0; new MutationObserver(() => { const l = ed.querySelector('ul.files'); window.__rows.push(l.children.length); const p = l.querySelector('progress'); if (p && p.max > 1) window.__max = Math.max(window.__max, p.value / p.max) }).observe(ed.querySelector('ul.files'), { subtree: true, attributes: true, childList: true }); const a = ed.querySelector('article'); a.focus(); ${mkFiles} a.dispatchEvent(new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true, clientX: 50, clientY: 50 })); })()`);
  390. await page.waitFor(md2(`querySelector('input.value').value`) + '.includes("doc.pdf")', { timeout: 30000, label: 'files uploaded' });
  391. const v = await ev(md2(`querySelector('input.value').value`));
  392. check('three dropped files: two images and a link in the Markdown', /^!\[one\.png\]\(\/md-editor\/files\/[\w.-]+\.png\)\n\n!\[two\.png\]\(\/md-editor\/files\/[\w.-]+\.png\)\n\n\[doc\.pdf\]\(\/md-editor\/files\/[\w.-]+\.pdf\)$/.test(v), JSON.stringify(v));
  393. check('the editor shows two images and a link', (await ev(md2(`querySelectorAll('article img').length`))) === 2 && (await ev(md2(`querySelectorAll('article a').length`))) === 1);
  394. check('the images load (the app stored and serves them)', await ev(`new Promise(r => setTimeout(() => r([...document.querySelectorAll('md-editor')[1].querySelectorAll('article img')].every(i => i.complete && i.naturalWidth === 1)), 500))`));
  395. check('progress rows were shown, three files, and reached 100%', (await ev('Math.max(...window.__rows)')) === 3 && (await ev('window.__max')) >= 1, JSON.stringify(await ev('[window.__rows.length, window.__max]')));
  396. check('progress rows are gone afterwards', (await ev(md2(`querySelector('ul.files').children.length`))) === 0);
  397. const url = v.match(/\]\((\/md-editor\/files\/[\w.-]+\.pdf)\)/)[1];
  398. const got = await fetch(BASE + url);
  399. check('the stored file is served, not sniffed, sandboxed', got.ok && (await got.arrayBuffer()).byteLength === 3145728 && got.headers.get('x-content-type-options') === 'nosniff' && /sandbox/.test(got.headers.get('content-security-policy') || ''));
  400. // a pasted image lands too, at the caret
  401. await ev(`(() => { const a = document.querySelectorAll('md-editor')[0].querySelector('article'); a.focus(); const s = getSelection(); s.selectAllChildren(a); s.collapseToEnd(); ${mkFiles.replace("dt.items.add(new File([png], 'two.png', { type: 'image/png' }));", '').replace("dt.items.add(new File([big], 'doc.pdf', { type: 'application/pdf' }));", '')} a.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); })()`);
  402. await page.waitFor(md1(`querySelector('input.value').value`) + '.includes("![one.png]")', { timeout: 30000, label: 'pasted image' });
  403. check('a pasted image is added too', true);
  404. // an image in typed-in Markdown is drawn; only http(s) and site paths are images
  405. await ev(`(() => { const a = document.querySelectorAll('md-editor')[1].querySelector('article'); a.focus(); const dt = new DataTransfer(); dt.setData('text/plain', '![a](/x.png) ![b](javascript:alert(1)) ![c](data:image/png;base64,AAAA)'); a.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); })()`);
  406. await sleep(300);
  407. const srcs = await ev(`JSON.stringify([...document.querySelectorAll('md-editor')[1].querySelectorAll('article img')].map(i => i.getAttribute('src')))`);
  408. check('javascript: and data: images are not made', !srcs.includes('javascript') && !srcs.includes('data:') && srcs.includes('/x.png'), srcs);
  409. // ticket #12: a file added (dropped, pasted, chosen) with the caret at the end of a list item is its own block after the list
  410. {
  411. const one = mkFiles.replace("dt.items.add(new File([png], 'two.png', { type: 'image/png' }));", '').replace(/dt\.items\.add\(new File\(\[big\][^;]*;/, '');
  412. const fire = {
  413. drop: `a.dispatchEvent(new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true, clientX: 0, clientY: 0 }));`,
  414. paste: `a.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));`,
  415. choose: `const inp = ed.querySelector('input.pick'); inp.files = dt.files; inp.dispatchEvent(new Event('change', { bubbles: true }));`,
  416. };
  417. for (const way of ['drop', 'paste', 'choose']) {
  418. for (const caret of ['in item', 'none']) {
  419. await ev(`(() => { const ed = document.querySelectorAll('md-editor')[1]; const a = ed.querySelector('article'); a.focus(); const s = getSelection(); s.selectAllChildren(a); s.deleteFromDocument(); const dt = new DataTransfer(); dt.setData('text/plain', '- first\\n- second'); a.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); })()`);
  420. await sleep(300);
  421. await ev(`(() => { const ed = document.querySelectorAll('md-editor')[1]; const a = ed.querySelector('article'); ${caret === 'none' ? 's0' : ''} const li = a.querySelector('li:last-child'); a.focus(); const s = getSelection(); if (${caret === 'none'}) s.removeAllRanges(); else { s.selectAllChildren(li); s.collapseToEnd(); } ${one} ${fire[way]} })()`.replace('s0', ''));
  422. await page.waitFor(md2(`querySelector('input.value').value`) + '.includes("![one.png]")', { timeout: 30000, label: 'list ' + way });
  423. const lv = await ev(md2(`querySelector('input.value').value`));
  424. check(`an image (${way}, caret ${caret}) after the last list item is its own block`, /^- first\n- second\n\n!\[one\.png\]\(\/md-editor\/files\/[\w.-]+\.png\)$/.test(lv), JSON.stringify(lv));
  425. }
  426. }
  427. }
  428. check('no console errors on files/images', page.problems().filter(m => !/favicon|Failed to load resource/.test(m.text)).length === 0, page.dumpConsole());
  429. await shot('md-editor-files');
  430. }
  431. // the code editor: real keys, real typing
  432. {
  433. check('home lists the code editor', home.includes('id="open-code-editor"'));
  434. const cer = await fetch(BASE + '/code-editor/README.md');
  435. check('Code editor README is served as Markdown', cer.ok && /markdown/.test(cer.headers.get('content-type')) && (await cer.text()).includes('Code editor'));
  436. check('no .js of its own in components/code-editor', !execSync(`find ${join(APP, 'components/code-editor')} -name '*.js' -o -name '*.mjs'`).toString().trim());
  437. await page.send('Emulation.setDeviceMetricsOverride', { width: 1000, height: 1000, deviceScaleFactor: 1, mobile: false });
  438. await page.goto(BASE + '/code-editor');
  439. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  440. const CE = (i, q) => `document.querySelectorAll('code-editor')[${i}].${q}`;
  441. const cval = (i) => ev(CE(i, `querySelector('input.value').value`));
  442. const ctext = (i) => ev(CE(i, `querySelector('article').textContent`));
  443. const cgut = (i) => ev(CE(i, `querySelector('code-gutter').textContent`));
  444. await page.waitFor(CE(0, `querySelector('article .tok-keyword')`) + ' !== null', { label: 'the code is highlighted' });
  445. check('Hybriel code is highlighted: comment, keyword, string, function', await ev(`['comment','keyword','string','function'].every(k => ${CE(0, `querySelector('article .tok-' + k)`)} !== null)`));
  446. check('JavaScript is highlighted too: number, comment, string', await ev(`['comment','string','number','keyword'].every(k => ${CE(1, `querySelector('article .tok-' + k)`)} !== null)`));
  447. check('the keyword is coloured differently from the plain text', await ev(`getComputedStyle(${CE(0, `querySelector('article .tok-keyword')`)}).color !== getComputedStyle(${CE(0, `querySelector('article')`)}).color`));
  448. check('line numbers: 1..10 for the ten lines', (await cgut(0)) === '1\n2\n3\n4\n5\n6\n7\n8\n9\n10', JSON.stringify(await cgut(0)));
  449. check('the value is the plain text (no markup)', (await cval(0)).startsWith('// a Hybriel component\nString title') && !(await cval(0)).includes('<span'));
  450. const ckey = async (k, code, vk, text, mods = 0) => { await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: k, code, windowsVirtualKeyCode: vk, text, modifiers: mods }); await page.send('Input.dispatchKeyEvent', { type: 'keyUp', key: k, code, windowsVirtualKeyCode: vk, modifiers: mods }); };
  451. const ctype = async (t) => { for (const ch of t) { if (ch === '\n') await ckey('Enter', 'Enter', 13, '\r'); else await ckey(ch, '', undefined, ch); } };
  452. const CTRL = 2, SHIFT = 8;
  453. const tab = (shift) => ckey('Tab', 'Tab', 9, undefined, shift ? SHIFT : 0);
  454. const undo = () => ckey('z', 'KeyZ', 90, undefined, CTRL);
  455. const redo = () => ckey('z', 'KeyZ', 90, undefined, CTRL | SHIFT);
  456. // put the caret at text offsets (start..end) of editor i and focus it
  457. const caret = (i, s, e = s) => ev(`(()=>{const a=${CE(i, `querySelector('article')`)};a.focus();const w=document.createTreeWalker(a,4);let pos=0,sn=null,so=0,en=null,eo=0,n;while((n=w.nextNode())){const l=n.data.length;if(!sn&&${s}<=pos+l){sn=n;so=${s}-pos}if(!en&&${e}<=pos+l){en=n;eo=${e}-pos}pos+=l}getSelection().setBaseAndExtent(sn,so,en,eo)})()`);
  458. await caret(0, (await cval(0)).length);
  459. await ctype('\nlet n = 5');
  460. check('typing at the end: the value follows, the new line is numbered 11', (await cval(0)).endsWith('}\nlet n = 5') && (await cgut(0)).endsWith('\n11'), JSON.stringify((await cval(0)).slice(-20)) + ' / ' + JSON.stringify((await cgut(0)).slice(-8)));
  461. check('the word you typed is highlighted while typing (let = keyword, 5 = number)', await ev(`(()=>{const k=[...${CE(0, `querySelectorAll('article .tok-keyword')`)}].map(x=>x.textContent);const n=[...${CE(0, `querySelectorAll('article .tok-number')`)}].map(x=>x.textContent);return k.includes('let')&&n.includes('5')})()`));
  462. check('the demo shows that text below the editor', (await ev(`document.getElementById('code-value-hybriel').textContent`)) === (await cval(0)));
  463. check('the other editor was not touched', (await cval(1)).startsWith('function add(a, b) {'));
  464. // Tab / Shift+Tab on one line
  465. await ctype('\n'); await tab(false); await ctype('x');
  466. check('Tab inserts a tab character', (await cval(0)).endsWith('let n = 5\n\tx'), JSON.stringify((await cval(0)).slice(-14)));
  467. await tab(true);
  468. check('Shift+Tab removes it again', (await cval(0)).endsWith('let n = 5\nx'), JSON.stringify((await cval(0)).slice(-14)));
  469. // Enter keeps the indent; { closes itself; Enter between the braces opens an indented line
  470. await ctype('\n'); await tab(false); await ctype('if (a) {');
  471. check('typing { adds the closing }', (await cval(0)).endsWith('\tif (a) {}'), JSON.stringify((await cval(0)).slice(-14)));
  472. await ctype('\n');
  473. check('Enter between { } indents the new line and puts } on its own line', (await cval(0)).endsWith('\tif (a) {\n\t\t\n\t}'), JSON.stringify((await cval(0)).slice(-20)));
  474. await ctype('y'); await ctype('\n');
  475. check('Enter keeps the indent of the line', (await cval(0)).endsWith('\tif (a) {\n\t\ty\n\t\t\n\t}'), JSON.stringify((await cval(0)).slice(-24)));
  476. // typing the closer steps over it; a quote closes itself
  477. await ckey('Backspace', 'Backspace', 8); await ckey('Backspace', 'Backspace', 8); await ctype('call("a"');
  478. check('quotes close themselves and typing the closing quote / ) steps over it', (await cval(0)).includes('\ncall("a")'), JSON.stringify((await cval(0)).slice(-20)));
  479. // undo / redo: back through typing, then forward again
  480. const before = await cval(0);
  481. await undo(); await sleep(50);
  482. const u1 = await cval(0);
  483. check('Ctrl+Z undoes the last change', u1 !== before && u1.length < before.length + 4, JSON.stringify(u1.slice(-20)));
  484. await undo(); await undo(); await sleep(50);
  485. const u3 = await cval(0);
  486. check('Ctrl+Z again goes further back', u3.length < u1.length, JSON.stringify(u3.slice(-20)));
  487. await redo(); await redo(); await redo(); await sleep(50);
  488. check('Ctrl+Shift+Z redoes them: the text is back', (await cval(0)) === before, JSON.stringify((await cval(0)).slice(-20)));
  489. await ckey('y', 'KeyY', 89, undefined, CTRL);
  490. check('Ctrl+Z past the start stops at the first text', await (async () => { for (let i = 0; i < 80; i++) await undo(); return (await cval(0)).startsWith('// a Hybriel component') && !(await cval(0)).includes('let n'); })());
  491. check('the line numbers follow undo', (await cgut(0)) === '1\n2\n3\n4\n5\n6\n7\n8\n9\n10', JSON.stringify(await cgut(0)));
  492. // several lines selected: Tab indents all, Shift+Tab outdents all
  493. const v0 = await cval(0);
  494. await caret(0, 0, v0.length);
  495. await tab(false);
  496. check('Tab with several lines selected indents every non-empty line', (await cval(0)) === v0.split('\n').map(l => l === '' ? l : '\t' + l).join('\n'), JSON.stringify((await cval(0)).slice(0, 60)));
  497. await tab(true);
  498. check('Shift+Tab outdents them all again', (await cval(0)) === v0, JSON.stringify((await cval(0)).slice(0, 60)));
  499. // the second editor indents with two spaces
  500. const w0 = await cval(1);
  501. await caret(1, 0); await tab(false);
  502. check('spaces = 2: Tab inserts two spaces', (await cval(1)) === ' ' + w0, JSON.stringify((await cval(1)).slice(0, 30)));
  503. await tab(true);
  504. check('spaces = 2: Shift+Tab removes them', (await cval(1)) === w0);
  505. // text inserted as a whole (paste): several lines, highlighted, numbered
  506. await caret(1, (await cval(1)).length);
  507. await ev(`(()=>{const a=${CE(1, `querySelector('article')`)};const dt=new DataTransfer();dt.setData('text/plain','\\n// pasted\\nlet z = 1');a.dispatchEvent(new ClipboardEvent('paste',{clipboardData:dt,bubbles:true,cancelable:true}))})()`);
  508. await sleep(150);
  509. check('inserted text (paste) is taken over, highlighted and numbered', (await cval(1)).endsWith('\n// pasted\nlet z = 1') && (await cgut(1)).split('\n').length === (await cval(1)).split('\n').length && (await ev(CE(1, `querySelectorAll('article .tok-comment').length`))) === 2, JSON.stringify(await cval(1)) + ' / ' + JSON.stringify(await cgut(1)));
  510. // the form posts both by name
  511. await page.click('#code-form button[type=submit]'); await sleep(800);
  512. const curl = new URL(await ev('location.href'));
  513. check('the form posts both texts by name', curl.searchParams.get('hybriel') != null && curl.searchParams.get('hybriel').replaceAll('\r\n', '\n') === v0 && curl.searchParams.get('js').includes('let z = 1'), curl.search.slice(0, 120));
  514. // the code is only ever text: broken / hostile code shows literally, the page structure stays, nothing runs
  515. await page.goto(BASE + '/code-editor');
  516. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  517. await page.waitFor(CE(0, `querySelector('article .tok-keyword')`) + ' !== null', { label: 'the code is highlighted' });
  518. const shape = () => ev(`[...document.body.querySelectorAll('*')].filter(e => !e.closest('article')).length + ':' + document.querySelectorAll('code-editor').length + ':' + document.querySelectorAll('pre').length + ':' + [...document.querySelectorAll('b,img,div')].filter(e => !e.closest('main')).length + ':' + document.querySelectorAll('main b, main img, main div').length`);
  519. const shape0 = await shape();
  520. await caret(1, (await cval(1)).length);
  521. await ctype('\nif (x) {');
  522. await sleep(300);
  523. check('unbalanced code renders inside the field as typed', (await cval(1)).includes('if (x) {}') && (await shape()) === shape0, await shape() + ' vs ' + shape0);
  524. await ctype('\n'); await ctype('}');
  525. check('typing } after Enter inside { } does not leave two: if (x) {\\n} only', (await cval(1)).endsWith('if (x) {\n}'), JSON.stringify((await cval(1)).slice(-20)));
  526. await ev(`window.__pwned = undefined`);
  527. await ctype('\n</div><b>x');
  528. await ctype('\n<img src=x onerror=window.__pwned=1>');
  529. await sleep(500);
  530. check('markup typed is shown literally and the page structure is unchanged', (await cval(1)).includes('</div><b>x') && (await ctext(1)).includes('<img src=x onerror=window.__pwned=1>') && (await shape()) === shape0, await shape() + ' vs ' + shape0);
  531. check('the code below the editor shows it literally too', (await ev(`document.getElementById('code-value-js').textContent`)).includes('<img src=x onerror=window.__pwned=1>'));
  532. check('typed code did not run (window.__pwned stays undefined)', (await ev(`typeof window.__pwned`)) === 'undefined');
  533. await page.goto(BASE + '/code-editor'); await sleep(800);
  534. await page.goto(BASE + '/code-editor'); await sleep(800);
  535. await shot('code-editor');
  536. check('no console errors on the code editor page', page.problems().filter(m => !/favicon|Failed to load resource/.test(m.text)).length === 0, page.dumpConsole());
  537. await page.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 800, deviceScaleFactor: 1, mobile: true });
  538. await page.goto(BASE + '/code-editor'); await sleep(800);
  539. check('the code editor page fits at 390 px', await ev('document.documentElement.scrollWidth <= window.innerWidth'));
  540. await shot('code-editor-narrow');
  541. await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
  542. }
  543. // ticket #13: the modal — open by the page, every way to close it with its reason, focus kept inside and given back, no scroll behind
  544. {
  545. check('home lists the modal', home.includes('id="open-modal"'));
  546. const mor = await fetch(BASE + '/modal/README.md');
  547. check('modal README is served as Markdown', mor.ok && /markdown/.test(mor.headers.get('content-type')) && (await mor.text()).includes('# Modal'));
  548. check('no .js of its own in components/modal', !execSync(`find ${join(APP, 'components/modal')} -name '*.js' -o -name '*.mjs'`).toString().trim());
  549. const modalRun = async (w, h, mobile, tag) => {
  550. await page.send('Emulation.setDeviceMetricsOverride', { width: w, height: h, deviceScaleFactor: 1, mobile });
  551. await page.goto(BASE + '/modal');
  552. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  553. await sleep(500);
  554. const at = async (x, y) => {
  555. await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, button: 'none' });
  556. await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
  557. await sleep(60);
  558. await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
  559. await sleep(300);
  560. };
  561. const on = async (sel) => { const r = await ev(`(() => { const b = document.querySelector(${JSON.stringify(sel)}).getBoundingClientRect(); return { x: b.x + b.width / 2, y: b.y + b.height / 2 }; })()`); await at(r.x, r.y); };
  562. const mkey = async (k, code, vk, mods = 0) => { await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: k, code, windowsVirtualKeyCode: vk, modifiers: mods }); await page.send('Input.dispatchKeyEvent', { type: 'keyUp', key: k, code, windowsVirtualKeyCode: vk, modifiers: mods }); await sleep(150); };
  563. const openIds = () => ev(`[...document.querySelectorAll('dialog[open]')].map(d => d.id).join()`);
  564. const last = () => ev(`document.getElementById('modal-last').textContent`);
  565. const active = () => ev(`document.activeElement.id || document.activeElement.className || document.activeElement.tagName`);
  566. const inside = (id) => ev(`document.getElementById(${JSON.stringify(id)}).contains(document.activeElement)`);
  567. // a corner of the dim background, outside every window
  568. const bg = async () => at(4, h - 4);
  569. check(`modal ${tag}: closed at first (no dialog open)`, (await openIds()) === '' && (await ev(`getComputedStyle(document.getElementById('info')).display`)) === 'none');
  570. // open by the page's button: title, content, focus inside, page inert behind
  571. await on('#open-info');
  572. check(`modal ${tag}: the page's button opens it (title and content shown)`, (await openIds()) === 'info' && (await ev(`document.querySelector('#info h2').textContent`)) === 'Information' && (await ev(`document.querySelector('#info modal-body p').getBoundingClientRect().height > 0`)));
  573. check(`modal ${tag}: the focus is inside when it opens`, await inside('info'), await active());
  574. const box = await ev(`JSON.stringify(document.getElementById('info').getBoundingClientRect())`);
  575. const b = JSON.parse(box);
  576. check(`modal ${tag}: the window is inside the screen`, b.left >= 0 && b.top >= 0 && b.right <= w && b.bottom <= h, box);
  577. check(`modal ${tag}: the page behind is blocked (the element under a point outside is the dialog, not the page)`, await ev(`document.elementFromPoint(4, ${h - 4}) === document.getElementById('info')`));
  578. await shot(`modal-open-${w}`);
  579. // Esc
  580. await mkey('Escape', 'Escape', 27);
  581. check(`modal ${tag}: Esc closes it, reason "escape", focus back on the opener`, (await openIds()) === '' && (await last()) === 'info: escape' && (await active()) === 'open-info', `${await openIds()} / ${await last()} / ${await active()}`);
  582. // ×
  583. await on('#open-info'); await on('#info .modal-x');
  584. check(`modal ${tag}: × closes it, reason "close", focus back on the opener`, (await openIds()) === '' && (await last()) === 'info: close' && (await active()) === 'open-info', `${await openIds()} / ${await last()} / ${await active()}`);
  585. // the dim background
  586. await on('#open-info'); await bg();
  587. check(`modal ${tag}: a click on the dim background closes it, reason "backdrop"`, (await openIds()) === '' && (await last()) === 'info: backdrop' && (await active()) === 'open-info', `${await openIds()} / ${await last()} / ${await active()}`);
  588. // a press inside the window that ends on the background (a text selection dragged out) does not close it
  589. await on('#open-info');
  590. const p = await ev(`(() => { const r = document.querySelector('#info modal-body p').getBoundingClientRect(); return { x: r.x + 5, y: r.y + 5 }; })()`);
  591. await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: p.x, y: p.y, button: 'none' });
  592. await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: p.x, y: p.y, button: 'left', clickCount: 1 });
  593. await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 4, y: h - 4, button: 'left' });
  594. await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 4, y: h - 4, button: 'left', clickCount: 1 });
  595. await sleep(300);
  596. check(`modal ${tag}: a press started inside and let go on the background keeps it open`, (await openIds()) === 'info');
  597. await mkey('Escape', 'Escape', 27);
  598. // footer buttons: the value is the reason
  599. await on('#open-confirm');
  600. check(`modal ${tag}: footer buttons are shown`, (await ev(`[...document.querySelectorAll('#confirm footer button')].map(b => b.textContent).join()`)) === 'Cancel,Delete');
  601. check(`modal ${tag}: footer buttons have no border (creator's rule)`, await ev(`[...document.querySelectorAll('#confirm button')].every(b => getComputedStyle(b).borderTopWidth === '0px')`));
  602. await on('#confirm footer button[value=ok]');
  603. check(`modal ${tag}: a footer button closes it with its value ("ok")`, (await openIds()) === '' && (await last()) === 'confirm: ok' && (await active()) === 'open-confirm', `${await openIds()} / ${await last()} / ${await active()}`);
  604. await on('#open-confirm'); await on('#confirm footer button[value=cancel]');
  605. check(`modal ${tag}: Cancel closes it with "cancel"`, (await last()) === 'confirm: cancel');
  606. // backdrop close switched off; focus goes round inside with Tab / Shift+Tab
  607. await on('#open-form'); await bg();
  608. check(`modal ${tag}: backdropClose = "false": a click on the background leaves it open`, (await openIds()) === 'form' && (await last()) === 'confirm: cancel' && await inside('form'));
  609. const seen = [];
  610. for (let i = 0; i < 9; i++) { await mkey('Tab', 'Tab', 9); seen.push((await inside('form')) ? 'in' : 'OUT:' + (await active())); }
  611. check(`modal ${tag}: Tab ×9 never leaves the window`, seen.every(s => s === 'in'), seen.join());
  612. await ev(`document.getElementById('modal-save').focus()`); await mkey('Tab', 'Tab', 9);
  613. check(`modal ${tag}: Tab on the last control goes round to the first (×)`, (await active()) === 'modal-x', await active());
  614. await mkey('Tab', 'Tab', 9, 8);
  615. check(`modal ${tag}: Shift+Tab on the first goes round to the last (Save)`, (await active()) === 'modal-save', await active());
  616. for (let i = 0; i < 6; i++) { await mkey('Tab', 'Tab', 9, 8); seen.push((await inside('form')) ? 'in' : 'OUT:' + (await active())); }
  617. check(`modal ${tag}: Shift+Tab never leaves the window`, seen.every(s => s === 'in'), seen.join());
  618. await ev(`document.getElementById('modal-name').focus()`);
  619. await page.send('Input.insertText', { text: 'Ada' }); await sleep(150);
  620. await shot(`modal-form-${w}`);
  621. // the page closes it itself, with its own reason
  622. await on('#modal-save');
  623. check(`modal ${tag}: the page closes it itself (close('saved')), reason "saved", it read the field`, (await openIds()) === '' && (await last()) === 'form: saved' && (await ev(`document.getElementById('modal-saved').textContent`)) === 'Ada' && (await active()) === 'open-form', `${await last()} / ${await active()}`);
  624. // no scroll behind: a long page scrolled down stays where it is; the long window scrolls itself
  625. await ev(`(() => { const d = document.createElement('div'); d.id = 'gate-tall'; d.style.height = '3000px'; document.querySelector('main').appendChild(d); scrollTo(0, 200); })()`);
  626. await sleep(100);
  627. await ev(`document.getElementById('open-long').click()`); await sleep(300);
  628. const y0 = await ev('scrollY');
  629. check(`modal ${tag}: while open the page cannot scroll (html overflow hidden)`, (await ev(`getComputedStyle(document.documentElement).overflow`)) === 'hidden');
  630. await page.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: 4, y: h - 4, deltaX: 0, deltaY: 600 }); await sleep(400);
  631. const mb = await ev(`(() => { const r = document.querySelector('#long modal-body').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; })()`);
  632. await page.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: mb.x, y: mb.y, deltaX: 0, deltaY: 600 }); await sleep(400);
  633. check(`modal ${tag}: the wheel does not scroll the page behind`, (await ev('scrollY')) === y0, `${y0} → ${await ev('scrollY')}`);
  634. check(`modal ${tag}: a long window stays inside the screen and scrolls its content`, (await ev(`document.getElementById('long').getBoundingClientRect().bottom <= innerHeight`)) && (await ev(`document.querySelector('#long modal-body').scrollTop > 0`)));
  635. await shot(`modal-long-${w}`);
  636. await mkey('Escape', 'Escape', 27);
  637. check(`modal ${tag}: after closing the page scrolls again`, (await ev(`getComputedStyle(document.documentElement).overflow`)) !== 'hidden' && (await last()) === 'long: escape');
  638. await ev(`document.getElementById('gate-tall').remove(); scrollTo(0, 0)`); await sleep(100);
  639. check(`modal ${tag}: no horizontal overflow`, await ev('document.documentElement.scrollWidth <= window.innerWidth'));
  640. await shot(`modal-closed-${w}`);
  641. check(`modal ${tag}: no console errors or warnings`, page.problems().filter(m => !/favicon|Failed to load resource/.test(m.text)).length === 0 && !/not a standard DOM event/.test(page.dumpConsole()), page.dumpConsole());
  642. };
  643. await modalRun(1280, 900, false, '1280 px');
  644. await modalRun(390, 800, true, '390 px');
  645. await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
  646. }
  647. await shot('wide');
  648. await page.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 800, deviceScaleFactor: 1, mobile: true });
  649. await sleep(300);
  650. check('no horizontal overflow at 390 px', await ev('document.documentElement.scrollWidth <= window.innerWidth'));
  651. await shot('narrow');
  652. }
  653. try { await main(); } catch (e) { check('gate ran', false, String(e && e.stack || e)); }
  654. await stop();
  655. console.log(`${passes} passed, ${failures} failed`);
  656. process.exit(failures ? 1 : 0);

Branches

Latest commits

  • becbd59bcomponents#13: modal dialog (<dialog> based, focus wrap, backdrop/Esc/x close with reason, scroll lock, footer buttons)mre
  • 367dd19adeploy.sh: never send .git or .gitignore to Byrodinmre
  • 4b4dabe5State of 2026-09-27, before the move to gitoriamre