gitoriaLog in with ident

components

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitd29f35b1d29f35b1components code order (mission 002) 1/4: styles.hl -> components/styles.hl; gate 220/0, same outputmred29f35b1/tests/browser.mjs

71.3 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. await stopServer();
  29. }
  30. // the server alone goes down and up again in the offline checks (mission 046)
  31. async function stopServer() {
  32. if (server) {
  33. try { server.kill('SIGTERM'); } catch {}
  34. await new Promise(r => { if (server.exitCode !== null || server.signalCode !== null) return r(); server.once('exit', r); setTimeout(r, 3000); });
  35. server = null;
  36. }
  37. }
  38. async function startServer() {
  39. 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'] });
  40. server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);
  41. for (let i = 0; ; i++) {
  42. try { if ((await fetch(BASE + '/')).ok) break; } catch {}
  43. if (i > 80) throw new Error('server did not come up\n' + log);
  44. await sleep(250);
  45. }
  46. }
  47. async function main() {
  48. await startServer();
  49. // the home lists the component; its README is served
  50. const home = await (await fetch(BASE + '/')).text();
  51. 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')));
  52. check('home lists the month view', home.includes('id="open-month-view"'));
  53. check('home shows only the list, no component embedded (creator 2026-09-25)', !home.includes('<month-view'));
  54. const readme = await fetch(BASE + '/month-view/README.md');
  55. check('README is served as Markdown', readme.ok && /markdown/.test(readme.headers.get('content-type')) && (await readme.text()).includes('Month view'));
  56. // first render on the server: 6 weeks of 7 days, one today
  57. const demo = await (await fetch(BASE + '/month-view')).text();
  58. check('server renders 42 days and one today', (demo.match(/<li class=/g) || []).length === 42 && (demo.match(/class="today"/g) || []).length === 1);
  59. browser = await launchBrowser({ debugPortRange: [CH_FROM, CH_TO] });
  60. const page = await browser.newPage();
  61. const SLOW = 3;
  62. const goto = page.goto.bind(page);
  63. page.goto = (u, o = {}) => goto(u, { ...o, timeout: 15000 * SLOW });
  64. await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
  65. // a zone already on the next day (UTC+14) compared with the server's UTC date: the visitor's today must win (ticket #11)
  66. await page.send('Emulation.setTimezoneOverride', { timezoneId: 'Pacific/Kiritimati' });
  67. await page.goto(BASE + '/month-view');
  68. // the month view's own demo page (the home only lists the components)
  69. await page.waitForSelector('#month-title');
  70. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  71. const shot = async (name) => { const { data } = await page.send('Page.captureScreenshot', { format: 'png' }); writeFileSync(join(SCRATCH, `gate-${name}.png`), Buffer.from(data, 'base64')); };
  72. const ev = (e) => page.evaluate(e);
  73. const title = () => ev('document.querySelector("#month-title").textContent');
  74. 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()); })()`);
  75. const expectTitle = (y, m) => ev(`new Intl.DateTimeFormat(navigator.language, { month: 'long' }).format(new Date(Date.UTC(${y}, ${m - 1}, 15, 12))) + ' ${y}'`);
  76. const [ty, tm] = localToday.split('-').map(Number);
  77. // the browser's today and language arrive at load, with no pointer needed
  78. await page.waitFor(`!!document.querySelector('li.today time') && document.querySelector('li.today time').getAttribute('datetime') === ${JSON.stringify(localToday)}`, { label: 'browser today marked at load' });
  79. await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 300, y: 200 });
  80. await page.waitFor(`document.querySelector("#month-title").textContent.toLowerCase() === (${JSON.stringify(await expectTitle(ty, tm))}).toLowerCase()`, { label: 'title in the browser language' });
  81. check('title is the current month and year in the browser language', true);
  82. check('today is marked (browser date)', (await ev(`document.querySelector('li.today time').getAttribute('datetime')`)) === localToday, localToday);
  83. check('42 days, 6 weeks', (await ev('document.querySelectorAll("#month-days li").length')) === 42);
  84. const t0 = await title();
  85. // next / prev
  86. await page.click('#month-next');
  87. await page.waitFor(`document.querySelector("#month-title").textContent !== ${JSON.stringify(t0)}`, { label: 'next month' });
  88. const nm = tm === 12 ? 1 : tm + 1, ny = tm === 12 ? ty + 1 : ty;
  89. await page.waitFor(`document.querySelector('#month-title').textContent.toLowerCase() === ${JSON.stringify((await expectTitle(ny, nm)).toLowerCase())}`, { label: 'next month in the browser language' });
  90. check('next shows the next month', true);
  91. await page.click('#month-prev');
  92. await page.waitFor(`document.querySelector("#month-title").textContent === ${JSON.stringify(t0)}`, { label: 'prev month' });
  93. check('prev returns to the month, today marked again', (await ev('document.querySelectorAll("li.today").length')) === 1);
  94. // walk to February 2028 (leap year) and check its days and its first weekday (Tuesday)
  95. let guard = 0;
  96. const feb28 = (await expectTitle(2028, 2)).toLowerCase();
  97. while ((await title()).toLowerCase() !== feb28 && guard++ < 40) {
  98. const before = await title();
  99. await page.click('#month-next');
  100. await page.waitFor(`document.querySelector("#month-title").textContent !== ${JSON.stringify(before)}`, { label: 'paging' });
  101. }
  102. 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 }; })()`);
  103. check('Feb 2028: 29 days, the 1st is a Tuesday (column 2)', feb.n === 29 && feb.col === 1, JSON.stringify(feb));
  104. // year rollover: go back to December 2027 and January 2028 by prev
  105. await page.click('#month-prev');
  106. await page.click('#month-prev');
  107. const dec27 = (await expectTitle(2027, 12)).toLowerCase();
  108. await page.waitFor(`document.querySelector("#month-title").textContent.toLowerCase() === ${JSON.stringify(dec27)}`, { label: 'back to December' });
  109. 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 }))()`);
  110. check('Dec 2027: 31 days, the 1st is a Wednesday', dec.n === 31 && dec.col === 2, JSON.stringify(dec));
  111. check('no console errors', page.problems().filter(m => !/favicon|Failed to load resource/.test(m.text)).length === 0, page.dumpConsole());
  112. // the date picker: click the field, pick a day, the value is YYYY-MM-DD; typing works
  113. check('home lists the date picker', home.includes('id="open-date-picker"'));
  114. check('date picker README is served', (await fetch(BASE + '/date-picker/README.md')).ok);
  115. await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
  116. await page.goto(BASE + '/date-picker');
  117. await page.waitForSelector('date-picker input');
  118. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  119. await sleep(1500);
  120. 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);
  121. const pop = (n) => `getComputedStyle(document.querySelectorAll('date-picker picker-popup')[${n}]).display !== 'none'`;
  122. const val = (n) => ev(`document.querySelectorAll('date-picker input')[${n}].value`);
  123. const realClick = async (sel) => {
  124. 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 }; })()`);
  125. await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: r.x, y: r.y, button: 'none' });
  126. await sleep(120);
  127. await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: r.x, y: r.y, button: 'left', clickCount: 1 });
  128. await sleep(100);
  129. await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: r.x, y: r.y, button: 'left', clickCount: 1 });
  130. await sleep(700);
  131. };
  132. const typeInto = async (n, text) => {
  133. await ev(`document.querySelectorAll('date-picker input')[${n}].focus()`);
  134. await ev(`document.querySelectorAll('date-picker input')[${n}].select()`);
  135. await page.send('Input.insertText', { text });
  136. await sleep(400);
  137. };
  138. check('two fields with their names', (await ev(`[...document.querySelectorAll('date-picker input')].map(i => i.name).join()`)) === 'arrival,departure');
  139. check('the month is closed at first', !(await ev(pop(0))) && !(await ev(pop(1))));
  140. await realClick('date-picker input[name=arrival]');
  141. await page.waitFor(pop(0), { label: 'popup open' });
  142. check('click on the field opens the month under it', (await ev(pop(0))) && !(await ev(pop(1))));
  143. 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 }; })()`);
  144. check('the month sits right under the field', pos.dx < 2 && pos.dy >= 0 && pos.dy < 12, JSON.stringify(pos));
  145. await ev(`document.querySelector('date-picker input[name=arrival]').__mark = 1`);
  146. await realClick('date-picker date-field:nth-child(1) ol.days li:not(.outside) time[datetime$="-15"]');
  147. const picked = await val(0);
  148. check('the field element is the same one after picking (not re-created)', (await ev(`document.querySelector('date-picker input[name=arrival]').__mark`)) === 1);
  149. check('a picked day is in the field as YYYY-MM-DD', /^\d{4}-\d{2}-15$/.test(picked), picked);
  150. check('the month closes after picking', !(await ev(pop(0))));
  151. // the month arrows page
  152. await realClick('date-picker input[name=departure]');
  153. await page.waitFor(pop(1), { label: 'popup open 2' });
  154. const dt0 = await ev(`document.querySelectorAll('date-picker h2')[1].textContent`);
  155. await realClick('date-picker date-field:nth-child(2) button.next');
  156. check('the next-month arrow changes the month', (await ev(`document.querySelectorAll('date-picker h2')[1].textContent`)) !== dt0);
  157. await realClick('date-picker date-field:nth-child(2) ol.days li:not(.outside) time[datetime$="-20"]');
  158. 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));
  159. // typing: a date in another month opens on that month and is marked
  160. await typeInto(0, '2028-02-29');
  161. check('typing works: the field holds the typed date', (await val(0)) === '2028-02-29', await val(0));
  162. 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');
  163. await typeInto(0, '2027-02-30');
  164. check('an impossible date marks the field invalid', (await ev(`document.querySelectorAll('date-picker input')[0].getAttribute('aria-invalid')`)) === 'true');
  165. await typeInto(0, '2028-02-29');
  166. await sleep(300); await shot('picker');
  167. await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
  168. await sleep(300);
  169. check('Escape closes the month', !(await ev(pop(0))));
  170. await realClick('h1'); await sleep(200);
  171. // the form posts both fields by name
  172. await ev(`document.querySelector('date-picker-demo form button[type=submit]').click()`);
  173. await page.waitFor(`location.search.includes('arrival=2028-02-29')`, { label: 'form posted' });
  174. 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'));
  175. 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());
  176. // the date range picker: a start click, an end click, both in the field, the days between highlighted
  177. check('home lists the range picker', home.includes('id="open-range-picker"'));
  178. check('range picker README is served', (await fetch(BASE + '/range-picker/README.md')).ok);
  179. await page.goto(BASE + '/range-picker');
  180. await page.waitForSelector('#range-picker-input');
  181. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  182. await sleep(1500);
  183. const rvis = () => ev(`getComputedStyle(document.querySelector('range-picker picker-popup')).display !== 'none'`);
  184. const rval = () => ev(`document.getElementById('range-picker-input').value`);
  185. const day = (n) => ev(`document.querySelector('range-picker ol.days li:not(.outside) time[datetime$="-${n}"]').closest('li').click()`);
  186. check('range: month is closed at first', !(await rvis()));
  187. await page.click('#range-picker-input');
  188. await page.waitFor(`getComputedStyle(document.querySelector('range-picker picker-popup')).display !== 'none'`, { label: 'popup open' });
  189. await day('20');
  190. check('range: the first click is the start, the month stays open', /^\d{4}-\d{2}-20 – $/.test(await rval()) && await rvis(), await rval());
  191. await day('10');
  192. const rv = await rval();
  193. check('range: second (earlier) click swaps: start – end in the field', /^\d{4}-\d{2}-10 – \d{4}-\d{2}-20$/.test(rv), rv);
  194. await sleep(500);
  195. check('range: the month closes after the end', !(await rvis()));
  196. await page.click('#range-picker-input');
  197. await page.waitFor(`getComputedStyle(document.querySelector('range-picker picker-popup')).display !== 'none'`, { label: 'popup reopen' });
  198. 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) }))()`);
  199. check('range: start and end marked, the 9 days between highlighted', hl.sel.join() === '10,20' && hl.between.length === 9, JSON.stringify(hl));
  200. await shot('range');
  201. 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 })); })()`);
  202. await page.waitFor(`document.querySelectorAll('range-picker li.between').length === 27`, { label: 'typed range marked' });
  203. check('range: a typed range shows its month, 27 days between', true);
  204. await ev(`(() => { const f = document.getElementById('range-picker-input'); f.value = '2028-02-01 – 2028-02-30'; f.dispatchEvent(new Event('input', { bubbles: true })); })()`);
  205. await page.waitFor(`document.getElementById('range-picker-input').getAttribute('aria-invalid') === 'true'`, { label: 'impossible range marked' });
  206. check('range: an impossible range marks the field invalid', true);
  207. await ev(`(() => { const f = document.getElementById('range-picker-input'); f.value = '2028-02-01 – 2028-02-29'; f.dispatchEvent(new Event('input', { bubbles: true })); })()`);
  208. await page.waitFor(`document.getElementById('range-picker-input').getAttribute('aria-invalid') === 'false'`, { label: 'valid again' });
  209. await ev(`document.querySelector('form button[type=submit]').click()`);
  210. await page.waitFor(`location.search.length > 0`, { label: 'form posted' });
  211. const q = decodeURIComponent(await ev(`location.search`)).replace(/\+/g, ' ');
  212. check('range: the form posts the period by the field name', q.includes('range=2028-02-01 – 2028-02-29'), q);
  213. check('range picker has no .js file', !existsSync(join(APP, 'components/range-picker/range-picker.js')));
  214. 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());
  215. // ticket #4: the one-file upload (pure Hybriel): choose / drop -> name, size, progress -> listed; the app stores it
  216. {
  217. check('home lists the file upload', home.includes('id="open-file-upload"'));
  218. check('file upload README is served', (await fetch(BASE + '/file-upload/README.md')).ok);
  219. check('file upload has no .js file', !existsSync(join(APP, 'components/file-upload/file-upload.js')));
  220. 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());
  221. await page.goto(BASE + '/file-upload');
  222. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  223. await sleep(600);
  224. const FU = (s) => `document.querySelector('file-upload ${s}')`;
  225. const mk = (name, n) => `const dt = new DataTransfer(); dt.items.add(new File([new Uint8Array(${n}).fill(66)], '${name}', { type: 'text/plain' }));`;
  226. 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 })); })()`);
  227. await page.waitFor(`${FU('.state')}.textContent === 'Uploaded'`, { timeout: 30000, label: 'uploaded' });
  228. check('upload: name and size shown', (await ev(FU('.name') + '.textContent')) === 'report.txt' && (await ev(FU('.size') + '.textContent')) === '3.0 MB');
  229. check('upload: progress bar reaches 100%', (await ev(FU('progress') + '.value')) === 100 && (await ev('window.__max')) >= 1);
  230. check('upload: listed under Uploaded', (await ev(FU('ul.uploaded li') + '.textContent')) === 'report.txt — 3.0 MB');
  231. const stored = execSync(`ls -l ${join(APP, 'storage/uploads')} | grep -c ' 3145728 '`).toString().trim();
  232. check('upload: the app stored all bytes', Number(stored) >= 1, stored);
  233. // choosing a second file (input) adds a second row
  234. const { root } = await page.send('DOM.getDocument');
  235. const { nodeId } = await page.send('DOM.querySelector', { nodeId: root.nodeId, selector: 'file-upload input[type=file]' });
  236. writeFileSync(join(SCRATCH, 'pick.txt'), 'hello');
  237. await page.send('DOM.setFileInputFiles', { files: [join(SCRATCH, 'pick.txt')], nodeId });
  238. await page.waitFor(`document.querySelectorAll('file-upload ul.uploaded li').length === 2`, { timeout: 30000, label: 'chosen file listed' });
  239. 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');
  240. check('upload: no console errors', page.problems().filter(m => !/favicon|Failed to load resource/.test(m.text)).length === 0, page.dumpConsole());
  241. await page.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 800, deviceScaleFactor: 1, mobile: true });
  242. await sleep(300); await shot('file-upload-narrow');
  243. await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
  244. await sleep(200); await shot('file-upload');
  245. }
  246. // the pagination: 89 pages; the ticket's three shapes
  247. check('home lists the pagination', home.includes('id="open-pagination"'));
  248. check('pagination README is served', (await fetch(BASE + '/pagination/README.md')).ok);
  249. await page.goto(BASE + '/pagination');
  250. await page.waitForSelector('#pagination-items');
  251. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  252. await sleep(500);
  253. const pg = () => ev(`[...document.querySelectorAll('#pagination-items button')].map(b => b.textContent).join(' ')`);
  254. const clickPg = (t) => ev(`[...document.querySelectorAll('#pagination-items button')].find(b => b.textContent === ${JSON.stringify(t)}).click()`);
  255. const waitPg = (t) => page.waitFor(`[...document.querySelectorAll('#pagination-items button')].map(b => b.textContent).join(' ') === ${JSON.stringify(t)}`, { label: 'pagination ' + t });
  256. check('pagination: first page shows 1 2 3 4 … 89 Next', (await pg()) === '1 2 3 4 … 89 Next', await pg());
  257. await clickPg('4'); await waitPg('Previous 1 2 3 4 5 … 89 Next');
  258. check('pagination: page 4 fills a one-page gap: Previous 1 2 3 4 5 … 89 Next', true);
  259. 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');
  260. await waitPg('Previous 1 … 6 7 8 … 89 Next');
  261. check('pagination: middle shows first, last, current ± 1', true);
  262. check('pagination: current page is marked', (await ev(`document.querySelector('#pagination-items li.current button').textContent`)) === '7');
  263. await ev(`document.querySelector('#pagination-items li.gap button').click()`); await sleep(300);
  264. check('pagination: a click on … does nothing', (await pg()) === 'Previous 1 … 6 7 8 … 89 Next');
  265. await clickPg('Previous'); await waitPg('Previous 1 … 5 6 7 … 89 Next');
  266. check('pagination: Previous goes back one page', true);
  267. await clickPg('89'); await waitPg('Previous 1 … 86 87 88 89');
  268. check('pagination: last page shows Previous 1 … 86 87 88 89, no Next', true);
  269. await clickPg('1'); await waitPg('1 2 3 4 … 89 Next');
  270. check('pagination: back on page 1 no Previous', true);
  271. await shot('pagination');
  272. check('no console errors on the pagination page', page.problems().filter(m => !/favicon|Failed to load resource/.test(m.text)).length === 0, page.dumpConsole());
  273. // the Markdown editor: two on one page, real typing and real clicks
  274. check('home lists the Markdown editor', home.includes('id="open-md-editor"'));
  275. const mdr = await fetch(BASE + '/md-editor/README.md');
  276. check('Markdown editor README is served as Markdown', mdr.ok && /markdown/.test(mdr.headers.get('content-type')) && (await mdr.text()).includes('Markdown editor'));
  277. 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());
  278. await page.send('Emulation.setDeviceMetricsOverride', { width: 1000, height: 1000, deviceScaleFactor: 1, mobile: false });
  279. await page.goto(BASE + '/md-editor');
  280. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  281. const ED = 'md-editor:nth-of-type(1)';
  282. const md1 = (q) => `document.querySelectorAll('md-editor')[0].${q}`;
  283. const md2 = (q) => `document.querySelectorAll('md-editor')[1].${q}`;
  284. const val1 = () => ev(md1(`querySelector('input.value').value`));
  285. const val2 = () => ev(md2(`querySelector('input.value').value`));
  286. const art1 = () => ev(md1(`querySelector('article').innerHTML`));
  287. await page.waitFor(md1(`querySelector('article h1')`) + ' !== null', { label: 'the value shows formatted' });
  288. 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());
  289. 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());
  290. check('the canonical form of the value equals it (round trip)', await ev(md1(`mdCanon`) + ` === ` + md1(`querySelector('input.value').value`)));
  291. check('the second editor is empty and shows its placeholder', (await ev(`getComputedStyle(document.querySelectorAll('md-editor')[1].querySelector('.placeholder')).display`)) === 'block');
  292. 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 }); };
  293. const typeText = async (t) => { for (const ch of t) await key(ch, '', undefined, ch); };
  294. const enter = () => key('Enter', 'Enter', 13, '\r');
  295. // type into the second editor: heading, bold, code, list
  296. await page.click('md-editor:nth-of-type(2) article');
  297. check('the placeholder goes away when it has text', true);
  298. await typeText('# Title'); await typeText(' '); await typeText('here'); await enter();
  299. await typeText('a **bold** and `code` x'); await enter();
  300. check('typing "# " makes a heading while you type', (await ev(md2(`querySelector('article h1')`) + ' !== null')));
  301. const html2 = await ev(md2(`querySelector('article').innerHTML`));
  302. check('typing **x** and `x` formats them', /<strong>bold<\/strong>/.test(html2) && /<code>code<\/code>/.test(html2), html2);
  303. await typeText('- '); await sleep(100);
  304. check('typing "- " makes a list', (await ev(md2(`querySelector('article ul')`) + ' !== null')));
  305. await typeText('item one'); await enter(); await typeText('item two');
  306. await sleep(400);
  307. const v2 = await val2();
  308. 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));
  309. check('the demo shows that value below the editor', (await ev(`document.getElementById('md-value-summary').textContent`)) === v2);
  310. check('the first editor was not touched by typing in the second', (await val1()).startsWith('# Hello'));
  311. check('the editor content survives re-renders of the page (same article element)', (await ev(md2(`querySelector('article h1')`) + ' !== null')));
  312. // toolbar: select the first list item in the first editor, Bold, then Undo
  313. 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)})()`);
  314. await selectIn(0, 'li:nth-child(1)');
  315. await page.click('md-editor:nth-of-type(1) nav button.bold'); await sleep(250);
  316. check('toolbar Bold: the selection is **bold** in the value', (await val1()).endsWith('- **first**\n- second'), await val1());
  317. await page.click('md-editor:nth-of-type(1) nav button.undo'); await sleep(250);
  318. check('toolbar Undo takes it back', (await val1()).endsWith('- first\n- second'), await val1());
  319. await selectIn(0, 'h1');
  320. await page.click('md-editor:nth-of-type(1) nav button.heading'); await sleep(250);
  321. check('toolbar heading button: H1 becomes H2', (await val1()).startsWith('## Hello'), await val1());
  322. await selectIn(0, 'ul');
  323. await page.click('md-editor:nth-of-type(1) nav button.ol'); await sleep(250);
  324. check('toolbar numbered list: the bullets become 1. 2.', (await val1()).endsWith('1. first\n2. second'), await val1());
  325. await selectIn(0, 'p');
  326. await page.click('md-editor:nth-of-type(1) nav button.pre'); await sleep(250);
  327. check('toolbar code block: the paragraph becomes a fenced block', /```\nSome .*\n```/.test(await val1()), await val1());
  328. // link: Ctrl+K opens the small form, Apply
  329. 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()})()`);
  330. await page.click('md-editor:nth-of-type(2) nav button.link'); await sleep(250);
  331. check('the link form opens', (await ev(`getComputedStyle(document.querySelectorAll('md-editor')[1].querySelector('link-form')).display`)) === 'flex');
  332. await ev(md2(`querySelector('link-form input').focus()`));
  333. await typeText('https://example.org/x'); await enter(); await sleep(300);
  334. check('a link is added: [text](url) in the value', (await val2()).includes('# [Title here](https://example.org/x)'), await val2());
  335. 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:'); })());
  336. // paste Markdown in: formatted, plain text; html in it stays text
  337. 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}))})()`);
  338. await sleep(300);
  339. const a1 = await art1();
  340. check('pasted Markdown shows formatted', /<h2>Pasted<\/h2><p>hello <strong>there<\/strong> /.test(a1), a1);
  341. check('pasted HTML stays text (no element made from it)', !/<b>x<\/b>/.test(a1) && (await val1()).includes('\\<b>x\\</b>'), a1);
  342. // the Markdown source switch
  343. await page.click('md-editor:nth-of-type(1) footer button'); await sleep(300);
  344. 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');
  345. 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 }))`);
  346. await sleep(200);
  347. 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()));
  348. await page.click('md-editor:nth-of-type(1) footer button'); await sleep(300);
  349. 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());
  350. // the form posts both editors by name
  351. await page.click('#md-form button[type=submit]'); await sleep(800);
  352. const url = new URL(await ev('location.href'));
  353. 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);
  354. await shot('md-editor');
  355. 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());
  356. // typed Markdown shortcuts are CONSUMED: the marker disappears, only the formatting stays (real keys, one character at a time)
  357. await page.goto(BASE + '/md-editor');
  358. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  359. await page.waitFor(md1(`querySelector('article h1')`) + ' !== null', { label: 'the value shows formatted' });
  360. 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); };
  361. const typeR = async (t) => { for (const ch of t) await keyR(ch, ch === ' ' ? 'Space' : '', ch === ' ' ? 32 : undefined, ch); };
  362. const enterR = () => keyR('Enter', 'Enter', 13, '\r');
  363. 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); };
  364. const valR = (i) => ev(`document.querySelectorAll('md-editor')[${i}].querySelector('input.value').value`);
  365. const artR = (i) => ev(`document.querySelectorAll('md-editor')[${i}].querySelector('article').innerHTML`);
  366. await page.click('md-editor:nth-of-type(2) article');
  367. await typeR('# Heading'); await sleep(200);
  368. 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)]));
  369. await wipe(1);
  370. await typeR('# Again'); await sleep(200);
  371. check('typing "# " after the editor was emptied (an empty heading is left): "# Again", not "# # Again"', (await valR(1)) === '# Again', JSON.stringify(await valR(1)));
  372. await wipe(1); await keyR('Backspace', 'Backspace', 8);
  373. await typeR('- one'); await enterR(); await typeR('- two'); await sleep(200);
  374. 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)]));
  375. await enterR(); await typeR('1. three'); await sleep(200);
  376. 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)));
  377. await wipe(1); await keyR('Backspace', 'Backspace', 8);
  378. await typeR('1. a'); await enterR(); await typeR('2. b'); await sleep(200);
  379. 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)));
  380. await wipe(1); await keyR('Backspace', 'Backspace', 8);
  381. await typeR('# One'); await enterR(); await typeR('text'); await enterR(); await typeR('## Two'); await sleep(200);
  382. 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)]));
  383. check('no console errors while typing shortcuts', page.problems().filter(m => !/favicon|Failed to load resource/.test(m.text)).length === 0, page.dumpConsole());
  384. await page.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 800, deviceScaleFactor: 1, mobile: true });
  385. await page.goto(BASE + '/md-editor'); await sleep(800);
  386. check('the Markdown editor fits at 390 px', await ev('document.documentElement.scrollWidth <= window.innerWidth'));
  387. await shot('md-editor-narrow');
  388. await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
  389. // ticket #9: files and images in the Markdown editor (drop, paste, choose) go through emit, with progress, and land in the text
  390. {
  391. await page.goto(BASE + '/md-editor');
  392. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  393. await sleep(600);
  394. const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
  395. 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' }));`;
  396. 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 })); })()`);
  397. await page.waitFor(md2(`querySelector('input.value').value`) + '.includes("doc.pdf")', { timeout: 30000, label: 'files uploaded' });
  398. const v = await ev(md2(`querySelector('input.value').value`));
  399. 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));
  400. 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);
  401. 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))`));
  402. 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]')));
  403. check('progress rows are gone afterwards', (await ev(md2(`querySelector('ul.files').children.length`))) === 0);
  404. const url = v.match(/\]\((\/md-editor\/files\/[\w.-]+\.pdf)\)/)[1];
  405. const got = await fetch(BASE + url);
  406. 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') || ''));
  407. // a pasted image lands too, at the caret
  408. 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 })); })()`);
  409. await page.waitFor(md1(`querySelector('input.value').value`) + '.includes("![one.png]")', { timeout: 30000, label: 'pasted image' });
  410. check('a pasted image is added too', true);
  411. // an image in typed-in Markdown is drawn; only http(s) and site paths are images
  412. 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 })); })()`);
  413. await sleep(300);
  414. const srcs = await ev(`JSON.stringify([...document.querySelectorAll('md-editor')[1].querySelectorAll('article img')].map(i => i.getAttribute('src')))`);
  415. check('javascript: and data: images are not made', !srcs.includes('javascript') && !srcs.includes('data:') && srcs.includes('/x.png'), srcs);
  416. // 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
  417. {
  418. const one = mkFiles.replace("dt.items.add(new File([png], 'two.png', { type: 'image/png' }));", '').replace(/dt\.items\.add\(new File\(\[big\][^;]*;/, '');
  419. const fire = {
  420. drop: `a.dispatchEvent(new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true, clientX: 0, clientY: 0 }));`,
  421. paste: `a.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));`,
  422. choose: `const inp = ed.querySelector('input.pick'); inp.files = dt.files; inp.dispatchEvent(new Event('change', { bubbles: true }));`,
  423. };
  424. for (const way of ['drop', 'paste', 'choose']) {
  425. for (const caret of ['in item', 'none']) {
  426. 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 })); })()`);
  427. await sleep(300);
  428. 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', ''));
  429. await page.waitFor(md2(`querySelector('input.value').value`) + '.includes("![one.png]")', { timeout: 30000, label: 'list ' + way });
  430. const lv = await ev(md2(`querySelector('input.value').value`));
  431. 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));
  432. }
  433. }
  434. }
  435. check('no console errors on files/images', page.problems().filter(m => !/favicon|Failed to load resource/.test(m.text)).length === 0, page.dumpConsole());
  436. await shot('md-editor-files');
  437. }
  438. // the code editor: real keys, real typing
  439. {
  440. check('home lists the code editor', home.includes('id="open-code-editor"'));
  441. const cer = await fetch(BASE + '/code-editor/README.md');
  442. check('Code editor README is served as Markdown', cer.ok && /markdown/.test(cer.headers.get('content-type')) && (await cer.text()).includes('Code editor'));
  443. check('no .js of its own in components/code-editor', !execSync(`find ${join(APP, 'components/code-editor')} -name '*.js' -o -name '*.mjs'`).toString().trim());
  444. await page.send('Emulation.setDeviceMetricsOverride', { width: 1000, height: 1000, deviceScaleFactor: 1, mobile: false });
  445. await page.goto(BASE + '/code-editor');
  446. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  447. const CE = (i, q) => `document.querySelectorAll('code-editor')[${i}].${q}`;
  448. const cval = (i) => ev(CE(i, `querySelector('input.value').value`));
  449. const ctext = (i) => ev(CE(i, `querySelector('article').textContent`));
  450. const cgut = (i) => ev(CE(i, `querySelector('code-gutter').textContent`));
  451. await page.waitFor(CE(0, `querySelector('article .tok-keyword')`) + ' !== null', { label: 'the code is highlighted' });
  452. check('Hybriel code is highlighted: comment, keyword, string, function', await ev(`['comment','keyword','string','function'].every(k => ${CE(0, `querySelector('article .tok-' + k)`)} !== null)`));
  453. check('JavaScript is highlighted too: number, comment, string', await ev(`['comment','string','number','keyword'].every(k => ${CE(1, `querySelector('article .tok-' + k)`)} !== null)`));
  454. 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`));
  455. 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)));
  456. check('the value is the plain text (no markup)', (await cval(0)).startsWith('// a Hybriel component\nString title') && !(await cval(0)).includes('<span'));
  457. 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 }); };
  458. const ctype = async (t) => { for (const ch of t) { if (ch === '\n') await ckey('Enter', 'Enter', 13, '\r'); else await ckey(ch, '', undefined, ch); } };
  459. const CTRL = 2, SHIFT = 8;
  460. const tab = (shift) => ckey('Tab', 'Tab', 9, undefined, shift ? SHIFT : 0);
  461. const undo = () => ckey('z', 'KeyZ', 90, undefined, CTRL);
  462. const redo = () => ckey('z', 'KeyZ', 90, undefined, CTRL | SHIFT);
  463. // put the caret at text offsets (start..end) of editor i and focus it
  464. 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)})()`);
  465. await caret(0, (await cval(0)).length);
  466. await ctype('\nlet n = 5');
  467. 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)));
  468. 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')})()`));
  469. check('the demo shows that text below the editor', (await ev(`document.getElementById('code-value-hybriel').textContent`)) === (await cval(0)));
  470. check('the other editor was not touched', (await cval(1)).startsWith('function add(a, b) {'));
  471. // Tab / Shift+Tab on one line
  472. await ctype('\n'); await tab(false); await ctype('x');
  473. check('Tab inserts a tab character', (await cval(0)).endsWith('let n = 5\n\tx'), JSON.stringify((await cval(0)).slice(-14)));
  474. await tab(true);
  475. check('Shift+Tab removes it again', (await cval(0)).endsWith('let n = 5\nx'), JSON.stringify((await cval(0)).slice(-14)));
  476. // Enter keeps the indent; { closes itself; Enter between the braces opens an indented line
  477. await ctype('\n'); await tab(false); await ctype('if (a) {');
  478. check('typing { adds the closing }', (await cval(0)).endsWith('\tif (a) {}'), JSON.stringify((await cval(0)).slice(-14)));
  479. await ctype('\n');
  480. 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)));
  481. await ctype('y'); await ctype('\n');
  482. 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)));
  483. // typing the closer steps over it; a quote closes itself
  484. await ckey('Backspace', 'Backspace', 8); await ckey('Backspace', 'Backspace', 8); await ctype('call("a"');
  485. 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)));
  486. // undo / redo: back through typing, then forward again
  487. const before = await cval(0);
  488. await undo(); await sleep(50);
  489. const u1 = await cval(0);
  490. check('Ctrl+Z undoes the last change', u1 !== before && u1.length < before.length + 4, JSON.stringify(u1.slice(-20)));
  491. await undo(); await undo(); await sleep(50);
  492. const u3 = await cval(0);
  493. check('Ctrl+Z again goes further back', u3.length < u1.length, JSON.stringify(u3.slice(-20)));
  494. await redo(); await redo(); await redo(); await sleep(50);
  495. check('Ctrl+Shift+Z redoes them: the text is back', (await cval(0)) === before, JSON.stringify((await cval(0)).slice(-20)));
  496. await ckey('y', 'KeyY', 89, undefined, CTRL);
  497. 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'); })());
  498. check('the line numbers follow undo', (await cgut(0)) === '1\n2\n3\n4\n5\n6\n7\n8\n9\n10', JSON.stringify(await cgut(0)));
  499. // several lines selected: Tab indents all, Shift+Tab outdents all
  500. const v0 = await cval(0);
  501. await caret(0, 0, v0.length);
  502. await tab(false);
  503. 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)));
  504. await tab(true);
  505. check('Shift+Tab outdents them all again', (await cval(0)) === v0, JSON.stringify((await cval(0)).slice(0, 60)));
  506. // the second editor indents with two spaces
  507. const w0 = await cval(1);
  508. await caret(1, 0); await tab(false);
  509. check('spaces = 2: Tab inserts two spaces', (await cval(1)) === ' ' + w0, JSON.stringify((await cval(1)).slice(0, 30)));
  510. await tab(true);
  511. check('spaces = 2: Shift+Tab removes them', (await cval(1)) === w0);
  512. // text inserted as a whole (paste): several lines, highlighted, numbered
  513. await caret(1, (await cval(1)).length);
  514. 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}))})()`);
  515. await sleep(150);
  516. 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)));
  517. // the form posts both by name
  518. await page.click('#code-form button[type=submit]'); await sleep(800);
  519. const curl = new URL(await ev('location.href'));
  520. 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));
  521. // the code is only ever text: broken / hostile code shows literally, the page structure stays, nothing runs
  522. await page.goto(BASE + '/code-editor');
  523. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  524. await page.waitFor(CE(0, `querySelector('article .tok-keyword')`) + ' !== null', { label: 'the code is highlighted' });
  525. 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`);
  526. const shape0 = await shape();
  527. await caret(1, (await cval(1)).length);
  528. await ctype('\nif (x) {');
  529. await sleep(300);
  530. check('unbalanced code renders inside the field as typed', (await cval(1)).includes('if (x) {}') && (await shape()) === shape0, await shape() + ' vs ' + shape0);
  531. await ctype('\n'); await ctype('}');
  532. 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)));
  533. await ev(`window.__pwned = undefined`);
  534. await ctype('\n</div><b>x');
  535. await ctype('\n<img src=x onerror=window.__pwned=1>');
  536. await sleep(500);
  537. 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);
  538. 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>'));
  539. check('typed code did not run (window.__pwned stays undefined)', (await ev(`typeof window.__pwned`)) === 'undefined');
  540. await page.goto(BASE + '/code-editor'); await sleep(800);
  541. await page.goto(BASE + '/code-editor'); await sleep(800);
  542. await shot('code-editor');
  543. 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());
  544. await page.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 800, deviceScaleFactor: 1, mobile: true });
  545. await page.goto(BASE + '/code-editor'); await sleep(800);
  546. check('the code editor page fits at 390 px', await ev('document.documentElement.scrollWidth <= window.innerWidth'));
  547. await shot('code-editor-narrow');
  548. await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
  549. }
  550. // 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
  551. {
  552. check('home lists the modal', home.includes('id="open-modal"'));
  553. const mor = await fetch(BASE + '/modal/README.md');
  554. check('modal README is served as Markdown', mor.ok && /markdown/.test(mor.headers.get('content-type')) && (await mor.text()).includes('# Modal'));
  555. check('no .js of its own in components/modal', !execSync(`find ${join(APP, 'components/modal')} -name '*.js' -o -name '*.mjs'`).toString().trim());
  556. const modalRun = async (w, h, mobile, tag) => {
  557. await page.send('Emulation.setDeviceMetricsOverride', { width: w, height: h, deviceScaleFactor: 1, mobile });
  558. await page.goto(BASE + '/modal');
  559. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  560. await sleep(500);
  561. const at = async (x, y) => {
  562. await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, button: 'none' });
  563. await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
  564. await sleep(60);
  565. await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
  566. await sleep(300);
  567. };
  568. 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); };
  569. 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); };
  570. const openIds = () => ev(`[...document.querySelectorAll('dialog[open]')].map(d => d.id).join()`);
  571. const last = () => ev(`document.getElementById('modal-last').textContent`);
  572. const active = () => ev(`document.activeElement.id || document.activeElement.className || document.activeElement.tagName`);
  573. const inside = (id) => ev(`document.getElementById(${JSON.stringify(id)}).contains(document.activeElement)`);
  574. // a corner of the dim background, outside every window
  575. const bg = async () => at(4, h - 4);
  576. check(`modal ${tag}: closed at first (no dialog open)`, (await openIds()) === '' && (await ev(`getComputedStyle(document.getElementById('info')).display`)) === 'none');
  577. // open by the page's button: title, content, focus inside, page inert behind
  578. await on('#open-info');
  579. 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`)));
  580. check(`modal ${tag}: the focus is inside when it opens`, await inside('info'), await active());
  581. const box = await ev(`JSON.stringify(document.getElementById('info').getBoundingClientRect())`);
  582. const b = JSON.parse(box);
  583. check(`modal ${tag}: the window is inside the screen`, b.left >= 0 && b.top >= 0 && b.right <= w && b.bottom <= h, box);
  584. 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')`));
  585. await shot(`modal-open-${w}`);
  586. // Esc
  587. await mkey('Escape', 'Escape', 27);
  588. 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()}`);
  589. // ×
  590. await on('#open-info'); await on('#info .modal-x');
  591. 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()}`);
  592. // the dim background
  593. await on('#open-info'); await bg();
  594. 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()}`);
  595. // a press inside the window that ends on the background (a text selection dragged out) does not close it
  596. await on('#open-info');
  597. const p = await ev(`(() => { const r = document.querySelector('#info modal-body p').getBoundingClientRect(); return { x: r.x + 5, y: r.y + 5 }; })()`);
  598. await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: p.x, y: p.y, button: 'none' });
  599. await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: p.x, y: p.y, button: 'left', clickCount: 1 });
  600. await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 4, y: h - 4, button: 'left' });
  601. await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 4, y: h - 4, button: 'left', clickCount: 1 });
  602. await sleep(300);
  603. check(`modal ${tag}: a press started inside and let go on the background keeps it open`, (await openIds()) === 'info');
  604. await mkey('Escape', 'Escape', 27);
  605. // footer buttons: the value is the reason
  606. await on('#open-confirm');
  607. check(`modal ${tag}: footer buttons are shown`, (await ev(`[...document.querySelectorAll('#confirm footer button')].map(b => b.textContent).join()`)) === 'Cancel,Delete');
  608. check(`modal ${tag}: footer buttons have no border (creator's rule)`, await ev(`[...document.querySelectorAll('#confirm button')].every(b => getComputedStyle(b).borderTopWidth === '0px')`));
  609. await on('#confirm footer button[value=ok]');
  610. 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()}`);
  611. await on('#open-confirm'); await on('#confirm footer button[value=cancel]');
  612. check(`modal ${tag}: Cancel closes it with "cancel"`, (await last()) === 'confirm: cancel');
  613. // backdrop close switched off; focus goes round inside with Tab / Shift+Tab
  614. await on('#open-form'); await bg();
  615. check(`modal ${tag}: backdropClose = "false": a click on the background leaves it open`, (await openIds()) === 'form' && (await last()) === 'confirm: cancel' && await inside('form'));
  616. const seen = [];
  617. for (let i = 0; i < 9; i++) { await mkey('Tab', 'Tab', 9); seen.push((await inside('form')) ? 'in' : 'OUT:' + (await active())); }
  618. check(`modal ${tag}: Tab ×9 never leaves the window`, seen.every(s => s === 'in'), seen.join());
  619. await ev(`document.getElementById('modal-save').focus()`); await mkey('Tab', 'Tab', 9);
  620. check(`modal ${tag}: Tab on the last control goes round to the first (×)`, (await active()) === 'modal-x', await active());
  621. await mkey('Tab', 'Tab', 9, 8);
  622. check(`modal ${tag}: Shift+Tab on the first goes round to the last (Save)`, (await active()) === 'modal-save', await active());
  623. for (let i = 0; i < 6; i++) { await mkey('Tab', 'Tab', 9, 8); seen.push((await inside('form')) ? 'in' : 'OUT:' + (await active())); }
  624. check(`modal ${tag}: Shift+Tab never leaves the window`, seen.every(s => s === 'in'), seen.join());
  625. await ev(`document.getElementById('modal-name').focus()`);
  626. await page.send('Input.insertText', { text: 'Ada' }); await sleep(150);
  627. await shot(`modal-form-${w}`);
  628. // the page closes it itself, with its own reason
  629. await on('#modal-save');
  630. 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()}`);
  631. // no scroll behind: a long page scrolled down stays where it is; the long window scrolls itself
  632. await ev(`(() => { const d = document.createElement('div'); d.id = 'gate-tall'; d.style.height = '3000px'; document.querySelector('main').appendChild(d); scrollTo(0, 200); })()`);
  633. await sleep(100);
  634. await ev(`document.getElementById('open-long').click()`); await sleep(300);
  635. const y0 = await ev('scrollY');
  636. check(`modal ${tag}: while open the page cannot scroll (html overflow hidden)`, (await ev(`getComputedStyle(document.documentElement).overflow`)) === 'hidden');
  637. await page.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: 4, y: h - 4, deltaX: 0, deltaY: 600 }); await sleep(400);
  638. 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 }; })()`);
  639. await page.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: mb.x, y: mb.y, deltaX: 0, deltaY: 600 }); await sleep(400);
  640. check(`modal ${tag}: the wheel does not scroll the page behind`, (await ev('scrollY')) === y0, `${y0} → ${await ev('scrollY')}`);
  641. 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`)));
  642. await shot(`modal-long-${w}`);
  643. await mkey('Escape', 'Escape', 27);
  644. check(`modal ${tag}: after closing the page scrolls again`, (await ev(`getComputedStyle(document.documentElement).overflow`)) !== 'hidden' && (await last()) === 'long: escape');
  645. await ev(`document.getElementById('gate-tall').remove(); scrollTo(0, 0)`); await sleep(100);
  646. check(`modal ${tag}: no horizontal overflow`, await ev('document.documentElement.scrollWidth <= window.innerWidth'));
  647. await shot(`modal-closed-${w}`);
  648. 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());
  649. };
  650. await modalRun(1280, 900, false, '1280 px');
  651. await modalRun(390, 800, true, '390 px');
  652. await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
  653. }
  654. await shot('wide');
  655. await page.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 800, deviceScaleFactor: 1, mobile: true });
  656. await sleep(300);
  657. check('no horizontal overflow at 390 px', await ev('document.documentElement.scrollWidth <= window.innerWidth'));
  658. await shot('narrow');
  659. // ==== mission 046: THE INSTALLABLE APP — hl:web's own manifest + service worker (project.hl appIcons /
  660. // offline = [ Index ]), as tracker.worldapi.org#10. The manifest and every icon over HTTP; Chrome's own verdict
  661. // (installability, manifest parse); the worker registered for the whole app; then OFFLINE: the tab loses the
  662. // network (the note appears), the server is stopped too (CDP's offline emulation does not reach the service worker's
  663. // own fetches, so only a dead server proves the cache), and `/` still opens: header + the note + the home list. A
  664. // demo page gets hl:web's "Unavailable offline" page. A separate 390 px tab, so its expected socket errors stay out
  665. // of the main tab's console checks. Phone screenshots: .scratch/gate-pwa-phone-{online,offline}.png.
  666. const html = await (await fetch(BASE + '/')).text();
  667. const mhref = (html.match(/<link rel="manifest" href="([^"]+)"/) || [])[1];
  668. check('pwa: every page head links the manifest, the apple-touch-icon, the favicon and the theme colour (the token `darker`)',
  669. mhref === '/__hl/manifest.webmanifest' && html.includes('<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">')
  670. && html.includes('<link rel="icon" href="/icons/favicon.svg">') && html.includes('<meta name="theme-color" content="rgb(15, 20, 25)">'), html.slice(0, 900));
  671. const mres = await fetch(BASE + mhref);
  672. const man = await mres.json();
  673. check('pwa: the manifest is served as application/manifest+json: name "Hybriel components", short name "Components", start_url /, scope /, display standalone, the token colours',
  674. /manifest\+json/.test(mres.headers.get('content-type') || '') && man.name === 'Hybriel components' && man.short_name === 'Components' && man.start_url === '/' && man.scope === '/'
  675. && man.display === 'standalone' && man.theme_color === 'rgb(15, 20, 25)' && man.background_color === 'rgb(25, 30, 35)', JSON.stringify(man));
  676. const iconOk = [];
  677. for (const ic of man.icons || []) {
  678. const ir = await fetch(BASE + ic.src);
  679. const b = Buffer.from(await ir.arrayBuffer());
  680. iconOk.push(ir.ok && ic.type === 'image/png' && b.readUInt32BE(16) === Number(ic.sizes.split('x')[0]) && b.readUInt32BE(20) === Number(ic.sizes.split('x')[1]));
  681. }
  682. check('pwa: every manifest icon loads and is a real PNG of its declared size (192 + 512, any + maskable)',
  683. iconOk.length === 4 && iconOk.every(Boolean) && ['any', 'maskable'].every(pu => ['192x192', '512x512'].every(sz => man.icons.some(i => i.purpose === pu && i.sizes === sz))), JSON.stringify(man.icons) + ' ' + JSON.stringify(iconOk));
  684. const touch = await fetch(BASE + '/icons/apple-touch-icon.png');
  685. const touchBytes = Buffer.from(await touch.arrayBuffer());
  686. const fav = await fetch(BASE + '/icons/favicon.svg');
  687. const favText = await fav.text();
  688. const ico = await fetch(BASE + '/favicon.ico');
  689. const icoBytes = Buffer.from(await ico.arrayBuffer());
  690. check('pwa: apple-touch-icon is a 180×180 PNG, the favicon an SVG in green-light #b5cea8, /favicon.ico a real icon file',
  691. touch.ok && touchBytes.readUInt32BE(16) === 180 && fav.ok && /image\/svg\+xml/.test(fav.headers.get('content-type') || '') && favText.includes('#b5cea8')
  692. && ico.ok && icoBytes.readUInt32BE(0) === 0x00000100, `${touch.status} ${fav.status} ${ico.status}`);
  693. await page.goto('about:blank'); // the server goes down below: the main tab must not sit on a page meanwhile
  694. const app = await browser.newPage();
  695. const aev = (e) => app.evaluate(e);
  696. const ashot = async (name) => { const { data } = await app.send('Page.captureScreenshot', { format: 'png' }); writeFileSync(join(SCRATCH, `gate-${name}.png`), Buffer.from(data, 'base64')); };
  697. const appHydrated = () => app.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 15000, label: 'app tab hydrated' });
  698. await app.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 2, mobile: true });
  699. await app.goto(BASE + '/');
  700. await appHydrated();
  701. await app.waitFor(`navigator.serviceWorker.getRegistration().then(r => !!(r && r.active))`, { timeout: 15000, label: 'service worker active' });
  702. await app.waitFor(`!!navigator.serviceWorker.controller`, { timeout: 10000, label: 'page controlled by the worker' });
  703. const inst = await app.send('Page.getInstallabilityErrors');
  704. check('pwa: Chrome reports no installability error', (inst.installabilityErrors || []).length === 0, JSON.stringify(inst));
  705. const am = await app.send('Page.getAppManifest');
  706. check('pwa: Chrome parses the manifest without errors', am.url && am.url.endsWith('/__hl/manifest.webmanifest') && (am.errors || []).length === 0, JSON.stringify(am.errors));
  707. check('pwa: hl:web\'s service worker is registered for the whole app (scope /) and controls the page',
  708. (await aev(`navigator.serviceWorker.getRegistration().then(r => new URL(r.scope).pathname + ' ' + new URL(r.active.scriptURL).pathname)`)) === '/ /__hl/sw.js' && (await aev('!!navigator.serviceWorker.controller')));
  709. check('pwa: online, no offline note', !(await aev(`!!document.querySelector('#offline')`)));
  710. check('pwa: no horizontal overflow at 390 px', await aev('document.documentElement.scrollWidth <= window.innerWidth'));
  711. await ashot('pwa-phone-online');
  712. await app.setOffline(true);
  713. await app.waitFor(`!!document.querySelector('#offline')`, { timeout: 5000, label: 'offline note (live)' }).catch(() => {});
  714. check('offline: the open page says so within seconds (the shell\'s tick)', (await aev(`document.querySelector('#offline') ? document.querySelector('#offline').textContent : ''`)) === 'You are offline. The demos need the network.');
  715. await stopServer();
  716. let down = false;
  717. try { await fetch(BASE + '/'); } catch { down = true; }
  718. await app.goto(BASE + '/');
  719. await app.waitFor(`!!document.querySelector('#offline')`, { timeout: 8000, label: 'offline note after reload' }).catch(() => {});
  720. check('offline: with the server gone too, a reload of / still opens the shell from the worker\'s cache — header, brand, the note, the home list',
  721. down && (await aev(`document.querySelector('application-header .brand') ? document.querySelector('application-header .brand').textContent : ''`)) === 'Hybriel components'
  722. && (await aev(`!!document.querySelector('#offline')`)) && (await aev(`document.querySelectorAll('#components li').length`)) === 8
  723. && (await aev(`getComputedStyle(document.querySelector('application-header')).backgroundColor`)) === 'rgb(15, 20, 25)', 'server down: ' + down);
  724. await ashot('pwa-phone-offline');
  725. await app.goto(BASE + '/month-view');
  726. check('offline: a demo page (/month-view) is not kept — hl:web\'s "Unavailable offline" page', (await aev(`!!document.querySelector('main[data-hl-offline]') && document.querySelector('h1').textContent`)) === 'Unavailable offline');
  727. await app.setOffline(false);
  728. await startServer();
  729. await app.goto(BASE + '/');
  730. await appHydrated();
  731. await app.waitFor(`!document.querySelector('#offline')`, { timeout: 5000, label: 'note gone' }).catch(() => {});
  732. check('back online: / from the server again, no offline note', !(await aev(`!!document.querySelector('#offline')`)));
  733. await app.close();
  734. }
  735. try { await main(); } catch (e) { check('gate ran', false, String(e && e.stack || e)); }
  736. await stop();
  737. console.log(`${passes} passed, ${failures} failed`);
  738. process.exit(failures ? 1 : 0);

Branches

Latest commits

  • d29f35b1components code order (mission 002) 1/4: styles.hl -> components/styles.hl; gate 220/0, same outputmre
  • 09945750components: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, http1 773de63e); gate 220/0mre
  • ede4d19acomponents: Hybriel master 190aa11d (fc838894 GC correctness, #126 closure scopes, #127); gate 220/0mre
  • 2bb9a935components: Hybriel master 8efba065 (re-vendor round 069: #126 memory, #48 lambda copy)mre
  • e4f01768antcolony#40: mission references point to the moved missionsmre
  • d32b44e2antcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 0fb80b57components: Hybriel master ff51cf46 (re-vendor round)mre
  • 57408b6ecomponents#14: installable app (manifest, service worker, offline index), own iconmre
  • 317bd09fdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • 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