components
All repositories: gitoria
71.3 KB
// tests/browser.mjs — THE GATE of components.hybriel.worldapi.org: its own server, real headless Chrome.// node tests/browser.mjs (COMPONENTS_GATE_PORT server, default 8700; COMPONENTS_GATE_CHROME "8701-8709" = Chrome debug ports)// Screenshots (390 / 1280 px) land in .scratch/gate-*.png. Every process started is stopped by PID.import { spawn, execSync } from 'node:child_process';import { writeFileSync, existsSync, mkdirSync, rmSync } from 'node:fs';import { dirname, join, resolve } from 'node:path';import { fileURLToPath } from 'node:url';import { launchBrowser } from './cdp.mjs';if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';const HERE = dirname(fileURLToPath(import.meta.url));const APP = resolve(HERE, '..');const PORT = Number(process.env.COMPONENTS_GATE_PORT || 8700);const [CH_FROM, CH_TO] = (process.env.COMPONENTS_GATE_CHROME || '8701-8709').split('-').map(Number);const BASE = `http://127.0.0.1:${PORT}`;const SCRATCH = join(APP, '.scratch');const SESSIONS = join(SCRATCH, 'gate-sessions');rmSync(SESSIONS, { recursive: true, force: true });mkdirSync(SESSIONS, { recursive: true });let failures = 0, passes = 0;function check(label, ok, detail = '') {console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`);if (ok) passes++; else failures++;}const sleep = (ms) => new Promise(r => setTimeout(r, ms));let log = '', server = null, browser = null;async function stop() {if (browser) { try { await browser.close(); } catch {} browser = null; }await stopServer();}// the server alone goes down and up again in the offline checks (mission 046)async function stopServer() {if (server) {try { server.kill('SIGTERM'); } catch {}await new Promise(r => { if (server.exitCode !== null || server.signalCode !== null) return r(); server.once('exit', r); setTimeout(r, 3000); });server = null;}}async function startServer() {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'] });server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);for (let i = 0; ; i++) {try { if ((await fetch(BASE + '/')).ok) break; } catch {}if (i > 80) throw new Error('server did not come up\n' + log);await sleep(250);}}async function main() {await startServer();// the home lists the component; its README is servedconst home = await (await fetch(BASE + '/')).text();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')));check('home lists the month view', home.includes('id="open-month-view"'));check('home shows only the list, no component embedded (creator 2026-09-25)', !home.includes('<month-view'));const readme = await fetch(BASE + '/month-view/README.md');check('README is served as Markdown', readme.ok && /markdown/.test(readme.headers.get('content-type')) && (await readme.text()).includes('Month view'));// first render on the server: 6 weeks of 7 days, one todayconst demo = await (await fetch(BASE + '/month-view')).text();check('server renders 42 days and one today', (demo.match(/<li class=/g) || []).length === 42 && (demo.match(/class="today"/g) || []).length === 1);browser = await launchBrowser({ debugPortRange: [CH_FROM, CH_TO] });const page = await browser.newPage();const SLOW = 3;const goto = page.goto.bind(page);page.goto = (u, o = {}) => goto(u, { ...o, timeout: 15000 * SLOW });await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });// a zone already on the next day (UTC+14) compared with the server's UTC date: the visitor's today must win (ticket #11)await page.send('Emulation.setTimezoneOverride', { timezoneId: 'Pacific/Kiritimati' });await page.goto(BASE + '/month-view');// the month view's own demo page (the home only lists the components)await page.waitForSelector('#month-title');await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });const shot = async (name) => { const { data } = await page.send('Page.captureScreenshot', { format: 'png' }); writeFileSync(join(SCRATCH, `gate-${name}.png`), Buffer.from(data, 'base64')); };const ev = (e) => page.evaluate(e);const title = () => ev('document.querySelector("#month-title").textContent');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()); })()`);const expectTitle = (y, m) => ev(`new Intl.DateTimeFormat(navigator.language, { month: 'long' }).format(new Date(Date.UTC(${y}, ${m - 1}, 15, 12))) + ' ${y}'`);const [ty, tm] = localToday.split('-').map(Number);// the browser's today and language arrive at load, with no pointer neededawait page.waitFor(`!!document.querySelector('li.today time') && document.querySelector('li.today time').getAttribute('datetime') === ${JSON.stringify(localToday)}`, { label: 'browser today marked at load' });await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 300, y: 200 });await page.waitFor(`document.querySelector("#month-title").textContent.toLowerCase() === (${JSON.stringify(await expectTitle(ty, tm))}).toLowerCase()`, { label: 'title in the browser language' });check('title is the current month and year in the browser language', true);check('today is marked (browser date)', (await ev(`document.querySelector('li.today time').getAttribute('datetime')`)) === localToday, localToday);check('42 days, 6 weeks', (await ev('document.querySelectorAll("#month-days li").length')) === 42);const t0 = await title();// next / prevawait page.click('#month-next');await page.waitFor(`document.querySelector("#month-title").textContent !== ${JSON.stringify(t0)}`, { label: 'next month' });const nm = tm === 12 ? 1 : tm + 1, ny = tm === 12 ? ty + 1 : ty;await page.waitFor(`document.querySelector('#month-title').textContent.toLowerCase() === ${JSON.stringify((await expectTitle(ny, nm)).toLowerCase())}`, { label: 'next month in the browser language' });check('next shows the next month', true);await page.click('#month-prev');await page.waitFor(`document.querySelector("#month-title").textContent === ${JSON.stringify(t0)}`, { label: 'prev month' });check('prev returns to the month, today marked again', (await ev('document.querySelectorAll("li.today").length')) === 1);// walk to February 2028 (leap year) and check its days and its first weekday (Tuesday)let guard = 0;const feb28 = (await expectTitle(2028, 2)).toLowerCase();while ((await title()).toLowerCase() !== feb28 && guard++ < 40) {const before = await title();await page.click('#month-next');await page.waitFor(`document.querySelector("#month-title").textContent !== ${JSON.stringify(before)}`, { label: 'paging' });}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 }; })()`);check('Feb 2028: 29 days, the 1st is a Tuesday (column 2)', feb.n === 29 && feb.col === 1, JSON.stringify(feb));// year rollover: go back to December 2027 and January 2028 by prevawait page.click('#month-prev');await page.click('#month-prev');const dec27 = (await expectTitle(2027, 12)).toLowerCase();await page.waitFor(`document.querySelector("#month-title").textContent.toLowerCase() === ${JSON.stringify(dec27)}`, { label: 'back to December' });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 }))()`);check('Dec 2027: 31 days, the 1st is a Wednesday', dec.n === 31 && dec.col === 2, JSON.stringify(dec));check('no console errors', page.problems().filter(m => !/favicon|Failed to load resource/.test(m.text)).length === 0, page.dumpConsole());// the date picker: click the field, pick a day, the value is YYYY-MM-DD; typing workscheck('home lists the date picker', home.includes('id="open-date-picker"'));check('date picker README is served', (await fetch(BASE + '/date-picker/README.md')).ok);await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });await page.goto(BASE + '/date-picker');await page.waitForSelector('date-picker input');await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });await sleep(1500);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);const pop = (n) => `getComputedStyle(document.querySelectorAll('date-picker picker-popup')[${n}]).display !== 'none'`;const val = (n) => ev(`document.querySelectorAll('date-picker input')[${n}].value`);const realClick = 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 page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: r.x, y: r.y, button: 'none' });await sleep(120);await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: r.x, y: r.y, button: 'left', clickCount: 1 });await sleep(100);await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: r.x, y: r.y, button: 'left', clickCount: 1 });await sleep(700);};const typeInto = async (n, text) => {await ev(`document.querySelectorAll('date-picker input')[${n}].focus()`);await ev(`document.querySelectorAll('date-picker input')[${n}].select()`);await page.send('Input.insertText', { text });await sleep(400);};check('two fields with their names', (await ev(`[...document.querySelectorAll('date-picker input')].map(i => i.name).join()`)) === 'arrival,departure');check('the month is closed at first', !(await ev(pop(0))) && !(await ev(pop(1))));await realClick('date-picker input[name=arrival]');await page.waitFor(pop(0), { label: 'popup open' });check('click on the field opens the month under it', (await ev(pop(0))) && !(await ev(pop(1))));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 }; })()`);check('the month sits right under the field', pos.dx < 2 && pos.dy >= 0 && pos.dy < 12, JSON.stringify(pos));await ev(`document.querySelector('date-picker input[name=arrival]').__mark = 1`);await realClick('date-picker date-field:nth-child(1) ol.days li:not(.outside) time[datetime$="-15"]');const picked = await val(0);check('the field element is the same one after picking (not re-created)', (await ev(`document.querySelector('date-picker input[name=arrival]').__mark`)) === 1);check('a picked day is in the field as YYYY-MM-DD', /^\d{4}-\d{2}-15$/.test(picked), picked);check('the month closes after picking', !(await ev(pop(0))));// the month arrows pageawait realClick('date-picker input[name=departure]');await page.waitFor(pop(1), { label: 'popup open 2' });const dt0 = await ev(`document.querySelectorAll('date-picker h2')[1].textContent`);await realClick('date-picker date-field:nth-child(2) button.next');check('the next-month arrow changes the month', (await ev(`document.querySelectorAll('date-picker h2')[1].textContent`)) !== dt0);await realClick('date-picker date-field:nth-child(2) ol.days li:not(.outside) time[datetime$="-20"]');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));// typing: a date in another month opens on that month and is markedawait typeInto(0, '2028-02-29');check('typing works: the field holds the typed date', (await val(0)) === '2028-02-29', await val(0));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');await typeInto(0, '2027-02-30');check('an impossible date marks the field invalid', (await ev(`document.querySelectorAll('date-picker input')[0].getAttribute('aria-invalid')`)) === 'true');await typeInto(0, '2028-02-29');await sleep(300); await shot('picker');await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });await sleep(300);check('Escape closes the month', !(await ev(pop(0))));await realClick('h1'); await sleep(200);// the form posts both fields by nameawait ev(`document.querySelector('date-picker-demo form button[type=submit]').click()`);await page.waitFor(`location.search.includes('arrival=2028-02-29')`, { label: 'form posted' });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'));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());// the date range picker: a start click, an end click, both in the field, the days between highlightedcheck('home lists the range picker', home.includes('id="open-range-picker"'));check('range picker README is served', (await fetch(BASE + '/range-picker/README.md')).ok);await page.goto(BASE + '/range-picker');await page.waitForSelector('#range-picker-input');await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });await sleep(1500);const rvis = () => ev(`getComputedStyle(document.querySelector('range-picker picker-popup')).display !== 'none'`);const rval = () => ev(`document.getElementById('range-picker-input').value`);const day = (n) => ev(`document.querySelector('range-picker ol.days li:not(.outside) time[datetime$="-${n}"]').closest('li').click()`);check('range: month is closed at first', !(await rvis()));await page.click('#range-picker-input');await page.waitFor(`getComputedStyle(document.querySelector('range-picker picker-popup')).display !== 'none'`, { label: 'popup open' });await day('20');check('range: the first click is the start, the month stays open', /^\d{4}-\d{2}-20 – $/.test(await rval()) && await rvis(), await rval());await day('10');const rv = await rval();check('range: second (earlier) click swaps: start – end in the field', /^\d{4}-\d{2}-10 – \d{4}-\d{2}-20$/.test(rv), rv);await sleep(500);check('range: the month closes after the end', !(await rvis()));await page.click('#range-picker-input');await page.waitFor(`getComputedStyle(document.querySelector('range-picker picker-popup')).display !== 'none'`, { label: 'popup reopen' });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) }))()`);check('range: start and end marked, the 9 days between highlighted', hl.sel.join() === '10,20' && hl.between.length === 9, JSON.stringify(hl));await shot('range');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 })); })()`);await page.waitFor(`document.querySelectorAll('range-picker li.between').length === 27`, { label: 'typed range marked' });check('range: a typed range shows its month, 27 days between', true);await ev(`(() => { const f = document.getElementById('range-picker-input'); f.value = '2028-02-01 – 2028-02-30'; f.dispatchEvent(new Event('input', { bubbles: true })); })()`);await page.waitFor(`document.getElementById('range-picker-input').getAttribute('aria-invalid') === 'true'`, { label: 'impossible range marked' });check('range: an impossible range marks the field invalid', true);await ev(`(() => { const f = document.getElementById('range-picker-input'); f.value = '2028-02-01 – 2028-02-29'; f.dispatchEvent(new Event('input', { bubbles: true })); })()`);await page.waitFor(`document.getElementById('range-picker-input').getAttribute('aria-invalid') === 'false'`, { label: 'valid again' });await ev(`document.querySelector('form button[type=submit]').click()`);await page.waitFor(`location.search.length > 0`, { label: 'form posted' });const q = decodeURIComponent(await ev(`location.search`)).replace(/\+/g, ' ');check('range: the form posts the period by the field name', q.includes('range=2028-02-01 – 2028-02-29'), q);check('range picker has no .js file', !existsSync(join(APP, 'components/range-picker/range-picker.js')));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());// ticket #4: the one-file upload (pure Hybriel): choose / drop -> name, size, progress -> listed; the app stores it{check('home lists the file upload', home.includes('id="open-file-upload"'));check('file upload README is served', (await fetch(BASE + '/file-upload/README.md')).ok);check('file upload has no .js file', !existsSync(join(APP, 'components/file-upload/file-upload.js')));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());await page.goto(BASE + '/file-upload');await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });await sleep(600);const FU = (s) => `document.querySelector('file-upload ${s}')`;const mk = (name, n) => `const dt = new DataTransfer(); dt.items.add(new File([new Uint8Array(${n}).fill(66)], '${name}', { type: 'text/plain' }));`;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 })); })()`);await page.waitFor(`${FU('.state')}.textContent === 'Uploaded'`, { timeout: 30000, label: 'uploaded' });check('upload: name and size shown', (await ev(FU('.name') + '.textContent')) === 'report.txt' && (await ev(FU('.size') + '.textContent')) === '3.0 MB');check('upload: progress bar reaches 100%', (await ev(FU('progress') + '.value')) === 100 && (await ev('window.__max')) >= 1);check('upload: listed under Uploaded', (await ev(FU('ul.uploaded li') + '.textContent')) === 'report.txt — 3.0 MB');const stored = execSync(`ls -l ${join(APP, 'storage/uploads')} | grep -c ' 3145728 '`).toString().trim();check('upload: the app stored all bytes', Number(stored) >= 1, stored);// choosing a second file (input) adds a second rowconst { root } = await page.send('DOM.getDocument');const { nodeId } = await page.send('DOM.querySelector', { nodeId: root.nodeId, selector: 'file-upload input[type=file]' });writeFileSync(join(SCRATCH, 'pick.txt'), 'hello');await page.send('DOM.setFileInputFiles', { files: [join(SCRATCH, 'pick.txt')], nodeId });await page.waitFor(`document.querySelectorAll('file-upload ul.uploaded li').length === 2`, { timeout: 30000, label: 'chosen file listed' });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');check('upload: no console errors', page.problems().filter(m => !/favicon|Failed to load resource/.test(m.text)).length === 0, page.dumpConsole());await page.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 800, deviceScaleFactor: 1, mobile: true });await sleep(300); await shot('file-upload-narrow');await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });await sleep(200); await shot('file-upload');}// the pagination: 89 pages; the ticket's three shapescheck('home lists the pagination', home.includes('id="open-pagination"'));check('pagination README is served', (await fetch(BASE + '/pagination/README.md')).ok);await page.goto(BASE + '/pagination');await page.waitForSelector('#pagination-items');await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });await sleep(500);const pg = () => ev(`[...document.querySelectorAll('#pagination-items button')].map(b => b.textContent).join(' ')`);const clickPg = (t) => ev(`[...document.querySelectorAll('#pagination-items button')].find(b => b.textContent === ${JSON.stringify(t)}).click()`);const waitPg = (t) => page.waitFor(`[...document.querySelectorAll('#pagination-items button')].map(b => b.textContent).join(' ') === ${JSON.stringify(t)}`, { label: 'pagination ' + t });check('pagination: first page shows 1 2 3 4 … 89 Next', (await pg()) === '1 2 3 4 … 89 Next', await pg());await clickPg('4'); await waitPg('Previous 1 2 3 4 5 … 89 Next');check('pagination: page 4 fills a one-page gap: Previous 1 2 3 4 5 … 89 Next', true);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');await waitPg('Previous 1 … 6 7 8 … 89 Next');check('pagination: middle shows first, last, current ± 1', true);check('pagination: current page is marked', (await ev(`document.querySelector('#pagination-items li.current button').textContent`)) === '7');await ev(`document.querySelector('#pagination-items li.gap button').click()`); await sleep(300);check('pagination: a click on … does nothing', (await pg()) === 'Previous 1 … 6 7 8 … 89 Next');await clickPg('Previous'); await waitPg('Previous 1 … 5 6 7 … 89 Next');check('pagination: Previous goes back one page', true);await clickPg('89'); await waitPg('Previous 1 … 86 87 88 89');check('pagination: last page shows Previous 1 … 86 87 88 89, no Next', true);await clickPg('1'); await waitPg('1 2 3 4 … 89 Next');check('pagination: back on page 1 no Previous', true);await shot('pagination');check('no console errors on the pagination page', page.problems().filter(m => !/favicon|Failed to load resource/.test(m.text)).length === 0, page.dumpConsole());// the Markdown editor: two on one page, real typing and real clickscheck('home lists the Markdown editor', home.includes('id="open-md-editor"'));const mdr = await fetch(BASE + '/md-editor/README.md');check('Markdown editor README is served as Markdown', mdr.ok && /markdown/.test(mdr.headers.get('content-type')) && (await mdr.text()).includes('Markdown editor'));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());await page.send('Emulation.setDeviceMetricsOverride', { width: 1000, height: 1000, deviceScaleFactor: 1, mobile: false });await page.goto(BASE + '/md-editor');await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });const ED = 'md-editor:nth-of-type(1)';const md1 = (q) => `document.querySelectorAll('md-editor')[0].${q}`;const md2 = (q) => `document.querySelectorAll('md-editor')[1].${q}`;const val1 = () => ev(md1(`querySelector('input.value').value`));const val2 = () => ev(md2(`querySelector('input.value').value`));const art1 = () => ev(md1(`querySelector('article').innerHTML`));await page.waitFor(md1(`querySelector('article h1')`) + ' !== null', { label: 'the value shows formatted' });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());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());check('the canonical form of the value equals it (round trip)', await ev(md1(`mdCanon`) + ` === ` + md1(`querySelector('input.value').value`)));check('the second editor is empty and shows its placeholder', (await ev(`getComputedStyle(document.querySelectorAll('md-editor')[1].querySelector('.placeholder')).display`)) === 'block');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 }); };const typeText = async (t) => { for (const ch of t) await key(ch, '', undefined, ch); };const enter = () => key('Enter', 'Enter', 13, '\r');// type into the second editor: heading, bold, code, listawait page.click('md-editor:nth-of-type(2) article');check('the placeholder goes away when it has text', true);await typeText('# Title'); await typeText(' '); await typeText('here'); await enter();await typeText('a **bold** and `code` x'); await enter();check('typing "# " makes a heading while you type', (await ev(md2(`querySelector('article h1')`) + ' !== null')));const html2 = await ev(md2(`querySelector('article').innerHTML`));check('typing **x** and `x` formats them', /<strong>bold<\/strong>/.test(html2) && /<code>code<\/code>/.test(html2), html2);await typeText('- '); await sleep(100);check('typing "- " makes a list', (await ev(md2(`querySelector('article ul')`) + ' !== null')));await typeText('item one'); await enter(); await typeText('item two');await sleep(400);const v2 = await val2();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));check('the demo shows that value below the editor', (await ev(`document.getElementById('md-value-summary').textContent`)) === v2);check('the first editor was not touched by typing in the second', (await val1()).startsWith('# Hello'));check('the editor content survives re-renders of the page (same article element)', (await ev(md2(`querySelector('article h1')`) + ' !== null')));// toolbar: select the first list item in the first editor, Bold, then Undoconst 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)})()`);await selectIn(0, 'li:nth-child(1)');await page.click('md-editor:nth-of-type(1) nav button.bold'); await sleep(250);check('toolbar Bold: the selection is **bold** in the value', (await val1()).endsWith('- **first**\n- second'), await val1());await page.click('md-editor:nth-of-type(1) nav button.undo'); await sleep(250);check('toolbar Undo takes it back', (await val1()).endsWith('- first\n- second'), await val1());await selectIn(0, 'h1');await page.click('md-editor:nth-of-type(1) nav button.heading'); await sleep(250);check('toolbar heading button: H1 becomes H2', (await val1()).startsWith('## Hello'), await val1());await selectIn(0, 'ul');await page.click('md-editor:nth-of-type(1) nav button.ol'); await sleep(250);check('toolbar numbered list: the bullets become 1. 2.', (await val1()).endsWith('1. first\n2. second'), await val1());await selectIn(0, 'p');await page.click('md-editor:nth-of-type(1) nav button.pre'); await sleep(250);check('toolbar code block: the paragraph becomes a fenced block', /```\nSome .*\n```/.test(await val1()), await val1());// link: Ctrl+K opens the small form, Applyawait 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()})()`);await page.click('md-editor:nth-of-type(2) nav button.link'); await sleep(250);check('the link form opens', (await ev(`getComputedStyle(document.querySelectorAll('md-editor')[1].querySelector('link-form')).display`)) === 'flex');await ev(md2(`querySelector('link-form input').focus()`));await typeText('https://example.org/x'); await enter(); await sleep(300);check('a link is added: [text](url) in the value', (await val2()).includes('# [Title here](https://example.org/x)'), await val2());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:'); })());// paste Markdown in: formatted, plain text; html in it stays textawait 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}))})()`);await sleep(300);const a1 = await art1();check('pasted Markdown shows formatted', /<h2>Pasted<\/h2><p>hello <strong>there<\/strong> /.test(a1), a1);check('pasted HTML stays text (no element made from it)', !/<b>x<\/b>/.test(a1) && (await val1()).includes('\\<b>x\\</b>'), a1);// the Markdown source switchawait page.click('md-editor:nth-of-type(1) footer button'); await sleep(300);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');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 }))`);await sleep(200);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()));await page.click('md-editor:nth-of-type(1) footer button'); await sleep(300);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 < y</pre>', await art1());// the form posts both editors by nameawait page.click('#md-form button[type=submit]'); await sleep(800);const url = new URL(await ev('location.href'));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);await shot('md-editor');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());// typed Markdown shortcuts are CONSUMED: the marker disappears, only the formatting stays (real keys, one character at a time)await page.goto(BASE + '/md-editor');await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });await page.waitFor(md1(`querySelector('article h1')`) + ' !== null', { label: 'the value shows formatted' });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); };const typeR = async (t) => { for (const ch of t) await keyR(ch, ch === ' ' ? 'Space' : '', ch === ' ' ? 32 : undefined, ch); };const enterR = () => keyR('Enter', 'Enter', 13, '\r');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); };const valR = (i) => ev(`document.querySelectorAll('md-editor')[${i}].querySelector('input.value').value`);const artR = (i) => ev(`document.querySelectorAll('md-editor')[${i}].querySelector('article').innerHTML`);await page.click('md-editor:nth-of-type(2) article');await typeR('# Heading'); await sleep(200);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)]));await wipe(1);await typeR('# Again'); await sleep(200);check('typing "# " after the editor was emptied (an empty heading is left): "# Again", not "# # Again"', (await valR(1)) === '# Again', JSON.stringify(await valR(1)));await wipe(1); await keyR('Backspace', 'Backspace', 8);await typeR('- one'); await enterR(); await typeR('- two'); await sleep(200);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)]));await enterR(); await typeR('1. three'); await sleep(200);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)));await wipe(1); await keyR('Backspace', 'Backspace', 8);await typeR('1. a'); await enterR(); await typeR('2. b'); await sleep(200);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)));await wipe(1); await keyR('Backspace', 'Backspace', 8);await typeR('# One'); await enterR(); await typeR('text'); await enterR(); await typeR('## Two'); await sleep(200);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)]));check('no console errors while typing shortcuts', page.problems().filter(m => !/favicon|Failed to load resource/.test(m.text)).length === 0, page.dumpConsole());await page.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 800, deviceScaleFactor: 1, mobile: true });await page.goto(BASE + '/md-editor'); await sleep(800);check('the Markdown editor fits at 390 px', await ev('document.documentElement.scrollWidth <= window.innerWidth'));await shot('md-editor-narrow');await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });// ticket #9: files and images in the Markdown editor (drop, paste, choose) go through emit, with progress, and land in the text{await page.goto(BASE + '/md-editor');await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });await sleep(600);const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';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' }));`;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 })); })()`);await page.waitFor(md2(`querySelector('input.value').value`) + '.includes("doc.pdf")', { timeout: 30000, label: 'files uploaded' });const v = await ev(md2(`querySelector('input.value').value`));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));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);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))`));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]')));check('progress rows are gone afterwards', (await ev(md2(`querySelector('ul.files').children.length`))) === 0);const url = v.match(/\]\((\/md-editor\/files\/[\w.-]+\.pdf)\)/)[1];const got = await fetch(BASE + url);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') || ''));// a pasted image lands too, at the caretawait 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 })); })()`);await page.waitFor(md1(`querySelector('input.value').value`) + '.includes("![one.png]")', { timeout: 30000, label: 'pasted image' });check('a pasted image is added too', true);// an image in typed-in Markdown is drawn; only http(s) and site paths are imagesawait ev(`(() => { const a = document.querySelectorAll('md-editor')[1].querySelector('article'); a.focus(); const dt = new DataTransfer(); dt.setData('text/plain', ' ) '); a.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); })()`);await sleep(300);const srcs = await ev(`JSON.stringify([...document.querySelectorAll('md-editor')[1].querySelectorAll('article img')].map(i => i.getAttribute('src')))`);check('javascript: and data: images are not made', !srcs.includes('javascript') && !srcs.includes('data:') && srcs.includes('/x.png'), srcs);// 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{const one = mkFiles.replace("dt.items.add(new File([png], 'two.png', { type: 'image/png' }));", '').replace(/dt\.items\.add\(new File\(\[big\][^;]*;/, '');const fire = {drop: `a.dispatchEvent(new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true, clientX: 0, clientY: 0 }));`,paste: `a.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));`,choose: `const inp = ed.querySelector('input.pick'); inp.files = dt.files; inp.dispatchEvent(new Event('change', { bubbles: true }));`,};for (const way of ['drop', 'paste', 'choose']) {for (const caret of ['in item', 'none']) {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 })); })()`);await sleep(300);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', ''));await page.waitFor(md2(`querySelector('input.value').value`) + '.includes("![one.png]")', { timeout: 30000, label: 'list ' + way });const lv = await ev(md2(`querySelector('input.value').value`));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));}}}check('no console errors on files/images', page.problems().filter(m => !/favicon|Failed to load resource/.test(m.text)).length === 0, page.dumpConsole());await shot('md-editor-files');}// the code editor: real keys, real typing{check('home lists the code editor', home.includes('id="open-code-editor"'));const cer = await fetch(BASE + '/code-editor/README.md');check('Code editor README is served as Markdown', cer.ok && /markdown/.test(cer.headers.get('content-type')) && (await cer.text()).includes('Code editor'));check('no .js of its own in components/code-editor', !execSync(`find ${join(APP, 'components/code-editor')} -name '*.js' -o -name '*.mjs'`).toString().trim());await page.send('Emulation.setDeviceMetricsOverride', { width: 1000, height: 1000, deviceScaleFactor: 1, mobile: false });await page.goto(BASE + '/code-editor');await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });const CE = (i, q) => `document.querySelectorAll('code-editor')[${i}].${q}`;const cval = (i) => ev(CE(i, `querySelector('input.value').value`));const ctext = (i) => ev(CE(i, `querySelector('article').textContent`));const cgut = (i) => ev(CE(i, `querySelector('code-gutter').textContent`));await page.waitFor(CE(0, `querySelector('article .tok-keyword')`) + ' !== null', { label: 'the code is highlighted' });check('Hybriel code is highlighted: comment, keyword, string, function', await ev(`['comment','keyword','string','function'].every(k => ${CE(0, `querySelector('article .tok-' + k)`)} !== null)`));check('JavaScript is highlighted too: number, comment, string', await ev(`['comment','string','number','keyword'].every(k => ${CE(1, `querySelector('article .tok-' + k)`)} !== null)`));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`));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)));check('the value is the plain text (no markup)', (await cval(0)).startsWith('// a Hybriel component\nString title') && !(await cval(0)).includes('<span'));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 }); };const ctype = async (t) => { for (const ch of t) { if (ch === '\n') await ckey('Enter', 'Enter', 13, '\r'); else await ckey(ch, '', undefined, ch); } };const CTRL = 2, SHIFT = 8;const tab = (shift) => ckey('Tab', 'Tab', 9, undefined, shift ? SHIFT : 0);const undo = () => ckey('z', 'KeyZ', 90, undefined, CTRL);const redo = () => ckey('z', 'KeyZ', 90, undefined, CTRL | SHIFT);// put the caret at text offsets (start..end) of editor i and focus itconst 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)})()`);await caret(0, (await cval(0)).length);await ctype('\nlet n = 5');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)));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')})()`));check('the demo shows that text below the editor', (await ev(`document.getElementById('code-value-hybriel').textContent`)) === (await cval(0)));check('the other editor was not touched', (await cval(1)).startsWith('function add(a, b) {'));// Tab / Shift+Tab on one lineawait ctype('\n'); await tab(false); await ctype('x');check('Tab inserts a tab character', (await cval(0)).endsWith('let n = 5\n\tx'), JSON.stringify((await cval(0)).slice(-14)));await tab(true);check('Shift+Tab removes it again', (await cval(0)).endsWith('let n = 5\nx'), JSON.stringify((await cval(0)).slice(-14)));// Enter keeps the indent; { closes itself; Enter between the braces opens an indented lineawait ctype('\n'); await tab(false); await ctype('if (a) {');check('typing { adds the closing }', (await cval(0)).endsWith('\tif (a) {}'), JSON.stringify((await cval(0)).slice(-14)));await ctype('\n');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)));await ctype('y'); await ctype('\n');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)));// typing the closer steps over it; a quote closes itselfawait ckey('Backspace', 'Backspace', 8); await ckey('Backspace', 'Backspace', 8); await ctype('call("a"');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)));// undo / redo: back through typing, then forward againconst before = await cval(0);await undo(); await sleep(50);const u1 = await cval(0);check('Ctrl+Z undoes the last change', u1 !== before && u1.length < before.length + 4, JSON.stringify(u1.slice(-20)));await undo(); await undo(); await sleep(50);const u3 = await cval(0);check('Ctrl+Z again goes further back', u3.length < u1.length, JSON.stringify(u3.slice(-20)));await redo(); await redo(); await redo(); await sleep(50);check('Ctrl+Shift+Z redoes them: the text is back', (await cval(0)) === before, JSON.stringify((await cval(0)).slice(-20)));await ckey('y', 'KeyY', 89, undefined, CTRL);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'); })());check('the line numbers follow undo', (await cgut(0)) === '1\n2\n3\n4\n5\n6\n7\n8\n9\n10', JSON.stringify(await cgut(0)));// several lines selected: Tab indents all, Shift+Tab outdents allconst v0 = await cval(0);await caret(0, 0, v0.length);await tab(false);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)));await tab(true);check('Shift+Tab outdents them all again', (await cval(0)) === v0, JSON.stringify((await cval(0)).slice(0, 60)));// the second editor indents with two spacesconst w0 = await cval(1);await caret(1, 0); await tab(false);check('spaces = 2: Tab inserts two spaces', (await cval(1)) === ' ' + w0, JSON.stringify((await cval(1)).slice(0, 30)));await tab(true);check('spaces = 2: Shift+Tab removes them', (await cval(1)) === w0);// text inserted as a whole (paste): several lines, highlighted, numberedawait caret(1, (await cval(1)).length);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}))})()`);await sleep(150);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)));// the form posts both by nameawait page.click('#code-form button[type=submit]'); await sleep(800);const curl = new URL(await ev('location.href'));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));// the code is only ever text: broken / hostile code shows literally, the page structure stays, nothing runsawait page.goto(BASE + '/code-editor');await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });await page.waitFor(CE(0, `querySelector('article .tok-keyword')`) + ' !== null', { label: 'the code is highlighted' });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`);const shape0 = await shape();await caret(1, (await cval(1)).length);await ctype('\nif (x) {');await sleep(300);check('unbalanced code renders inside the field as typed', (await cval(1)).includes('if (x) {}') && (await shape()) === shape0, await shape() + ' vs ' + shape0);await ctype('\n'); await ctype('}');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)));await ev(`window.__pwned = undefined`);await ctype('\n</div><b>x');await ctype('\n<img src=x onerror=window.__pwned=1>');await sleep(500);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);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>'));check('typed code did not run (window.__pwned stays undefined)', (await ev(`typeof window.__pwned`)) === 'undefined');await page.goto(BASE + '/code-editor'); await sleep(800);await page.goto(BASE + '/code-editor'); await sleep(800);await shot('code-editor');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());await page.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 800, deviceScaleFactor: 1, mobile: true });await page.goto(BASE + '/code-editor'); await sleep(800);check('the code editor page fits at 390 px', await ev('document.documentElement.scrollWidth <= window.innerWidth'));await shot('code-editor-narrow');await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });}// 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{check('home lists the modal', home.includes('id="open-modal"'));const mor = await fetch(BASE + '/modal/README.md');check('modal README is served as Markdown', mor.ok && /markdown/.test(mor.headers.get('content-type')) && (await mor.text()).includes('# Modal'));check('no .js of its own in components/modal', !execSync(`find ${join(APP, 'components/modal')} -name '*.js' -o -name '*.mjs'`).toString().trim());const modalRun = async (w, h, mobile, tag) => {await page.send('Emulation.setDeviceMetricsOverride', { width: w, height: h, deviceScaleFactor: 1, mobile });await page.goto(BASE + '/modal');await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });await sleep(500);const at = async (x, y) => {await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, button: 'none' });await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });await sleep(60);await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });await sleep(300);};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); };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); };const openIds = () => ev(`[...document.querySelectorAll('dialog[open]')].map(d => d.id).join()`);const last = () => ev(`document.getElementById('modal-last').textContent`);const active = () => ev(`document.activeElement.id || document.activeElement.className || document.activeElement.tagName`);const inside = (id) => ev(`document.getElementById(${JSON.stringify(id)}).contains(document.activeElement)`);// a corner of the dim background, outside every windowconst bg = async () => at(4, h - 4);check(`modal ${tag}: closed at first (no dialog open)`, (await openIds()) === '' && (await ev(`getComputedStyle(document.getElementById('info')).display`)) === 'none');// open by the page's button: title, content, focus inside, page inert behindawait on('#open-info');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`)));check(`modal ${tag}: the focus is inside when it opens`, await inside('info'), await active());const box = await ev(`JSON.stringify(document.getElementById('info').getBoundingClientRect())`);const b = JSON.parse(box);check(`modal ${tag}: the window is inside the screen`, b.left >= 0 && b.top >= 0 && b.right <= w && b.bottom <= h, box);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')`));await shot(`modal-open-${w}`);// Escawait mkey('Escape', 'Escape', 27);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()}`);// ×await on('#open-info'); await on('#info .modal-x');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()}`);// the dim backgroundawait on('#open-info'); await bg();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()}`);// a press inside the window that ends on the background (a text selection dragged out) does not close itawait on('#open-info');const p = await ev(`(() => { const r = document.querySelector('#info modal-body p').getBoundingClientRect(); return { x: r.x + 5, y: r.y + 5 }; })()`);await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: p.x, y: p.y, button: 'none' });await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: p.x, y: p.y, button: 'left', clickCount: 1 });await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 4, y: h - 4, button: 'left' });await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 4, y: h - 4, button: 'left', clickCount: 1 });await sleep(300);check(`modal ${tag}: a press started inside and let go on the background keeps it open`, (await openIds()) === 'info');await mkey('Escape', 'Escape', 27);// footer buttons: the value is the reasonawait on('#open-confirm');check(`modal ${tag}: footer buttons are shown`, (await ev(`[...document.querySelectorAll('#confirm footer button')].map(b => b.textContent).join()`)) === 'Cancel,Delete');check(`modal ${tag}: footer buttons have no border (creator's rule)`, await ev(`[...document.querySelectorAll('#confirm button')].every(b => getComputedStyle(b).borderTopWidth === '0px')`));await on('#confirm footer button[value=ok]');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()}`);await on('#open-confirm'); await on('#confirm footer button[value=cancel]');check(`modal ${tag}: Cancel closes it with "cancel"`, (await last()) === 'confirm: cancel');// backdrop close switched off; focus goes round inside with Tab / Shift+Tabawait on('#open-form'); await bg();check(`modal ${tag}: backdropClose = "false": a click on the background leaves it open`, (await openIds()) === 'form' && (await last()) === 'confirm: cancel' && await inside('form'));const seen = [];for (let i = 0; i < 9; i++) { await mkey('Tab', 'Tab', 9); seen.push((await inside('form')) ? 'in' : 'OUT:' + (await active())); }check(`modal ${tag}: Tab ×9 never leaves the window`, seen.every(s => s === 'in'), seen.join());await ev(`document.getElementById('modal-save').focus()`); await mkey('Tab', 'Tab', 9);check(`modal ${tag}: Tab on the last control goes round to the first (×)`, (await active()) === 'modal-x', await active());await mkey('Tab', 'Tab', 9, 8);check(`modal ${tag}: Shift+Tab on the first goes round to the last (Save)`, (await active()) === 'modal-save', await active());for (let i = 0; i < 6; i++) { await mkey('Tab', 'Tab', 9, 8); seen.push((await inside('form')) ? 'in' : 'OUT:' + (await active())); }check(`modal ${tag}: Shift+Tab never leaves the window`, seen.every(s => s === 'in'), seen.join());await ev(`document.getElementById('modal-name').focus()`);await page.send('Input.insertText', { text: 'Ada' }); await sleep(150);await shot(`modal-form-${w}`);// the page closes it itself, with its own reasonawait on('#modal-save');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()}`);// no scroll behind: a long page scrolled down stays where it is; the long window scrolls itselfawait ev(`(() => { const d = document.createElement('div'); d.id = 'gate-tall'; d.style.height = '3000px'; document.querySelector('main').appendChild(d); scrollTo(0, 200); })()`);await sleep(100);await ev(`document.getElementById('open-long').click()`); await sleep(300);const y0 = await ev('scrollY');check(`modal ${tag}: while open the page cannot scroll (html overflow hidden)`, (await ev(`getComputedStyle(document.documentElement).overflow`)) === 'hidden');await page.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: 4, y: h - 4, deltaX: 0, deltaY: 600 }); await sleep(400);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 }; })()`);await page.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: mb.x, y: mb.y, deltaX: 0, deltaY: 600 }); await sleep(400);check(`modal ${tag}: the wheel does not scroll the page behind`, (await ev('scrollY')) === y0, `${y0} → ${await ev('scrollY')}`);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`)));await shot(`modal-long-${w}`);await mkey('Escape', 'Escape', 27);check(`modal ${tag}: after closing the page scrolls again`, (await ev(`getComputedStyle(document.documentElement).overflow`)) !== 'hidden' && (await last()) === 'long: escape');await ev(`document.getElementById('gate-tall').remove(); scrollTo(0, 0)`); await sleep(100);check(`modal ${tag}: no horizontal overflow`, await ev('document.documentElement.scrollWidth <= window.innerWidth'));await shot(`modal-closed-${w}`);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());};await modalRun(1280, 900, false, '1280 px');await modalRun(390, 800, true, '390 px');await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });}await shot('wide');await page.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 800, deviceScaleFactor: 1, mobile: true });await sleep(300);check('no horizontal overflow at 390 px', await ev('document.documentElement.scrollWidth <= window.innerWidth'));await shot('narrow');// ==== mission 046: THE INSTALLABLE APP — hl:web's own manifest + service worker (project.hl appIcons /// offline = [ Index ]), as tracker.worldapi.org#10. The manifest and every icon over HTTP; Chrome's own verdict// (installability, manifest parse); the worker registered for the whole app; then OFFLINE: the tab loses the// network (the note appears), the server is stopped too (CDP's offline emulation does not reach the service worker's// own fetches, so only a dead server proves the cache), and `/` still opens: header + the note + the home list. A// demo page gets hl:web's "Unavailable offline" page. A separate 390 px tab, so its expected socket errors stay out// of the main tab's console checks. Phone screenshots: .scratch/gate-pwa-phone-{online,offline}.png.const html = await (await fetch(BASE + '/')).text();const mhref = (html.match(/<link rel="manifest" href="([^"]+)"/) || [])[1];check('pwa: every page head links the manifest, the apple-touch-icon, the favicon and the theme colour (the token `darker`)',mhref === '/__hl/manifest.webmanifest' && html.includes('<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">')&& html.includes('<link rel="icon" href="/icons/favicon.svg">') && html.includes('<meta name="theme-color" content="rgb(15, 20, 25)">'), html.slice(0, 900));const mres = await fetch(BASE + mhref);const man = await mres.json();check('pwa: the manifest is served as application/manifest+json: name "Hybriel components", short name "Components", start_url /, scope /, display standalone, the token colours',/manifest\+json/.test(mres.headers.get('content-type') || '') && man.name === 'Hybriel components' && man.short_name === 'Components' && man.start_url === '/' && man.scope === '/'&& man.display === 'standalone' && man.theme_color === 'rgb(15, 20, 25)' && man.background_color === 'rgb(25, 30, 35)', JSON.stringify(man));const iconOk = [];for (const ic of man.icons || []) {const ir = await fetch(BASE + ic.src);const b = Buffer.from(await ir.arrayBuffer());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]));}check('pwa: every manifest icon loads and is a real PNG of its declared size (192 + 512, any + maskable)',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));const touch = await fetch(BASE + '/icons/apple-touch-icon.png');const touchBytes = Buffer.from(await touch.arrayBuffer());const fav = await fetch(BASE + '/icons/favicon.svg');const favText = await fav.text();const ico = await fetch(BASE + '/favicon.ico');const icoBytes = Buffer.from(await ico.arrayBuffer());check('pwa: apple-touch-icon is a 180×180 PNG, the favicon an SVG in green-light #b5cea8, /favicon.ico a real icon file',touch.ok && touchBytes.readUInt32BE(16) === 180 && fav.ok && /image\/svg\+xml/.test(fav.headers.get('content-type') || '') && favText.includes('#b5cea8')&& ico.ok && icoBytes.readUInt32BE(0) === 0x00000100, `${touch.status} ${fav.status} ${ico.status}`);await page.goto('about:blank'); // the server goes down below: the main tab must not sit on a page meanwhileconst app = await browser.newPage();const aev = (e) => app.evaluate(e);const ashot = async (name) => { const { data } = await app.send('Page.captureScreenshot', { format: 'png' }); writeFileSync(join(SCRATCH, `gate-${name}.png`), Buffer.from(data, 'base64')); };const appHydrated = () => app.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 15000, label: 'app tab hydrated' });await app.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 2, mobile: true });await app.goto(BASE + '/');await appHydrated();await app.waitFor(`navigator.serviceWorker.getRegistration().then(r => !!(r && r.active))`, { timeout: 15000, label: 'service worker active' });await app.waitFor(`!!navigator.serviceWorker.controller`, { timeout: 10000, label: 'page controlled by the worker' });const inst = await app.send('Page.getInstallabilityErrors');check('pwa: Chrome reports no installability error', (inst.installabilityErrors || []).length === 0, JSON.stringify(inst));const am = await app.send('Page.getAppManifest');check('pwa: Chrome parses the manifest without errors', am.url && am.url.endsWith('/__hl/manifest.webmanifest') && (am.errors || []).length === 0, JSON.stringify(am.errors));check('pwa: hl:web\'s service worker is registered for the whole app (scope /) and controls the page',(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')));check('pwa: online, no offline note', !(await aev(`!!document.querySelector('#offline')`)));check('pwa: no horizontal overflow at 390 px', await aev('document.documentElement.scrollWidth <= window.innerWidth'));await ashot('pwa-phone-online');await app.setOffline(true);await app.waitFor(`!!document.querySelector('#offline')`, { timeout: 5000, label: 'offline note (live)' }).catch(() => {});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.');await stopServer();let down = false;try { await fetch(BASE + '/'); } catch { down = true; }await app.goto(BASE + '/');await app.waitFor(`!!document.querySelector('#offline')`, { timeout: 8000, label: 'offline note after reload' }).catch(() => {});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',down && (await aev(`document.querySelector('application-header .brand') ? document.querySelector('application-header .brand').textContent : ''`)) === 'Hybriel components'&& (await aev(`!!document.querySelector('#offline')`)) && (await aev(`document.querySelectorAll('#components li').length`)) === 8&& (await aev(`getComputedStyle(document.querySelector('application-header')).backgroundColor`)) === 'rgb(15, 20, 25)', 'server down: ' + down);await ashot('pwa-phone-offline');await app.goto(BASE + '/month-view');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');await app.setOffline(false);await startServer();await app.goto(BASE + '/');await appHydrated();await app.waitFor(`!document.querySelector('#offline')`, { timeout: 5000, label: 'note gone' }).catch(() => {});check('back online: / from the server again, no offline note', !(await aev(`!!document.querySelector('#offline')`)));await app.close();}try { await main(); } catch (e) { check('gate ran', false, String(e && e.stack || e)); }await stop();console.log(`${passes} passed, ${failures} failed`);process.exit(failures ? 1 : 0);
Branches
- mainmain branch
Latest commits
- 172914a3components code order (mission 002) 2/4: lib/uploads.hl (demo upload storage), thin demo handlers, project.hl map; gate 220/0, same outputmre
- 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