calendar
All repositories: gitoria
40.6 KB
// tests/browser.mjs — THE GATE of calendar.worldapi.org: its own server on its own storage, its OWN ident (a copy of// ident's code without .env, codes to a mail sink: tests/identkit.mjs), real headless Chrome.// node tests/browser.mjs (CALENDAR_GATE_PORT server, default 8700; CALENDAR_GATE_IDENT_PORT ident, default 8701;// CALENDAR_GATE_CHROME "8702-8709" = Chrome debug ports)// Screenshots (390 / 1280 px) land in .scratch/gate-*.png. Every process started is stopped by PID.import { spawn } 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';import { startIdent } from './identkit.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.CALENDAR_GATE_PORT || 8700);const [CH_FROM, CH_TO] = (process.env.CALENDAR_GATE_CHROME || '8702-8709').split('-').map(Number);const IDENT_PORT = Number(process.env.CALENDAR_GATE_IDENT_PORT || 8701);const BASE = `http://127.0.0.1:${PORT}`;const STORE = join(APP, '.scratch', 'gate-store');const IDENT_DIR = process.env.CALENDAR_GATE_IDENT_DIR || [resolve(APP, '../ident.worldapi.org'), '/media/STORAGE/projects/ident.worldapi.org'].find(d => existsSync(join(d, 'project.hl')));let ident = null;const SCRATCH = join(APP, '.scratch');const SESSIONS = join(SCRATCH, 'gate-sessions');rmSync(SESSIONS, { recursive: true, force: true });rmSync(STORE, { 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 spawnServer = null, log = '', server = null, browser = null, lastPage = null;async function stop() {if (ident) { try { await ident.stop(); } catch {} ident = null; }if (browser) { try { await browser.close(); } catch {} browser = null; }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 main() {ident = await startIdent({ identDir: IDENT_DIR, workDir: join(STORE, 'ident'), port: IDENT_PORT });const alice = await ident.signIn('[email protected]');const bob = await ident.signIn('[email protected]');const APPKEY = await ident.registerApp(alice, 'calendar (gate)', [BASE]);check('ident: our own ident runs (a copy without .env), the calendar is registered', /^pk_[0-9a-f]{32}$/.test(APPKEY.key) && !existsSync(join(STORE, 'ident', 'ident-code', '.env')));spawnServer = () => {server = spawn(join(APP, 'bin/hybriel'), ['project.hl'], { cwd: APP, env: { ...process.env, CALENDAR_PORT: String(PORT), CALENDAR_SESSIONS: SESSIONS + '/', CALENDAR_STORAGE: join(STORE, 'mpackdb'), CALENDAR_WATCH: '0',CALENDAR_PUBLIC_URL: BASE, IDENT_URL: ident.base, IDENT_EXCHANGE_URL: ident.base, IDENT_API_KEY: APPKEY.key, IDENT_API_SECRET: APPKEY.secret }, stdio: ['ignore', 'pipe', 'pipe'] });server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);};spawnServer();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);}// the app's first page is the month view, rendered on the server: 6 weeks of 7 days, one todayconst home = await (await fetch(BASE + '/')).text();check('server renders 42 days and one today', (home.match(/<li class=/g) || []).length === 42 && (home.match(/class="today"/g) || []).length === 1);browser = await launchBrowser({ debugPortRange: [CH_FROM, CH_TO] });const page = await browser.newPage();lastPage = page;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 });await page.goto(BASE + '/');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);// ticket #6: the FIRST render (no mouse yet) already has the names, in the language of the request, when the server knows itconst firstDays = await ev('[...document.querySelectorAll("#month-weekdays li")].map(l => l.textContent).join(" ")');const wantDays = await ev(`[1,2,3,4,5,6,7].map(i => new Intl.DateTimeFormat(navigator.language, { weekday: 'short' }).format(new Date(Date.UTC(2024, 0, i, 12)))).join(' ')`);if (/^(en|de|fr|es|it|nl|pt)/.test(await ev('navigator.language'))) check('first page already shows the weekday names in the browser language', firstDays === wantDays, firstDays + ' / ' + wantDays);await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 400, y: 300 }); // a real user's pointer reaches the month viewawait 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 title' });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());await languagePart();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');if (!process.env.GATE_SKIP_EVENTS) await eventsPart(page, { alice, bob, APPKEY });await remindersPart(page, { alice, APPKEY });}// ---- ticket calendar#2: login with ident, private events ------------------------------------------------------async function eventsPart(page, { alice, bob, APPKEY }) {const ev = (e) => page.evaluate(e);const J = JSON.stringify;// (a user's pointer reaches the month view first, and takes a moment before the click: the month view reads the browser then)const hydrated = async () => {await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });const at = await page.evaluate(`(() => { const r = document.querySelector('#month-title').getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })()`);await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: at.x, y: at.y });await sleep(700);};const setField = (sel, v) => ev(`(() => { const e = document.querySelector(${J(sel)}); e.value = ${J(v)}; e.dispatchEvent(new Event('input', { bubbles: true })); })()`);const pad = (n) => String(n).padStart(2, '0');const now = new Date();const ym = `${now.getFullYear()}-${pad(now.getMonth() + 1)}`;const day = (d) => `${ym}-${pad(d)}`;const cell = (d) => `#month-days li:has(time[datetime="${day(d)}"])`;const evTexts = (d) => ev(`[...document.querySelectorAll(${J(cell(d) + ' button.event')})].map(b => b.textContent)`);const editorOpen = () => ev(`document.querySelector('#eventeditor').classList.contains('open')`);const emitRaw = async (cookie, event, payload) => {const r = await fetch(BASE + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', cookie }, body: J({ t: 'emit', i: 1, event, payload }) });const t = await r.text(); let j = null; try { j = JSON.parse(t); } catch {}return j && j.value !== undefined ? j.value : t;};const loginAs = async (who) => {const code = await ident.selectorCode(who, APPKEY, BASE);await page.goto(BASE + '/login/callback?ident_code=' + code);await page.waitForSelector('#logout');await hydrated();};const cookieOf = async () => (await page.cookies([BASE])).map(c => c.name + '=' + c.value).join('; ');// signed out: no login, no editor, the faces refuseawait page.goto(BASE + '/');await hydrated();check('signed out: "Log in with ident" shows', (await ev(`!!document.querySelector('#loginbutton')`)) && !(await ev(`!!document.querySelector('#logout')`)));await page.click(cell(15));await sleep(500);check('signed out: clicking a day opens no editor', !(await editorOpen()));const refused = await emitRaw('', 'calSave', [{ title: 'x', allDay: false, startDate: day(15), startTime: '09:00', endDate: '', endTime: '', place: '', notes: '', zone: 'UTC' }, day(1), day(28)]);check('signed out: the server refuses to save an event', refused && refused.error === 'log in with ident first', J(refused));let r = await fetch(BASE + '/login/callback', { redirect: 'manual' });check('login: /login/callback without a code → /login/failed', r.status === 302 && r.headers.get('location') === '/login/failed');// log in as alice, add an event on the 15thawait loginAs(alice);check('logged in: "Log out" shows', await ev(`!!document.querySelector('#logout')`));await page.click(cell(15));await page.waitFor(`document.querySelector('#eventeditor').classList.contains('open')`, { label: 'editor opens' });await page.waitFor(`document.querySelector('#f-startdate').value === ${J(day(15))}`, { label: 'form shows the clicked date' });check('a click on a day opens the editor at that date', (await ev(`document.querySelector('#f-startdate').value`)) === day(15), await ev(`JSON.stringify([...document.querySelectorAll('#eventform input, #eventform textarea')].map(i => i.id + '=' + i.value))`));await page.click('#f-title');await page.type('#f-title', 'Dentist');await setField('#f-starttime', '18:00');await setField('#f-endtime', '19:00');await setField('#f-place', 'Dr. Huber');await setField('#f-notes', 'Bring the insurance card');check('typing into the title kept its focus and text', (await ev(`document.querySelector('#f-title').value`)) === 'Dentist');await page.click('#event-save');await page.waitFor(`document.querySelectorAll(${J(cell(15) + ' button.event')}).length === 1`, { label: 'event shows on its day' });check('the event shows on that day with its time', J(await evTexts(15)) === '["18:00 Dentist"]', J(await evTexts(15)));check('the editor closed after saving', !(await editorOpen()));// it survives a reload (server render for the signed-in user)await page.goto(BASE + '/');await hydrated();await page.waitFor(`document.querySelectorAll(${J(cell(15) + ' button.event')}).length === 1`, { label: 'event after reload' });check('after a reload the event is still there', J(await evTexts(15)) === '["18:00 Dentist"]');const html = await (await fetch(BASE + '/', { headers: { cookie: await cookieOf() } })).text();check('the server renders the event into the page for its owner', html.includes('18:00 Dentist'));check('a visitor without login gets no event in the page', !(await (await fetch(BASE + '/')).text()).includes('Dentist'));// editconst aliceEventId = await ev(`document.querySelector(${J(cell(15) + ' button.event')}).value`);await page.click(cell(15) + ' button.event');await page.waitFor(`document.querySelector('#eventeditor').classList.contains('open')`, { label: 'editor opens to edit' });check('a click on an event opens it with its values', (await ev(`[document.querySelector('#f-title').value, document.querySelector('#f-starttime').value, document.querySelector('#f-endtime').value, document.querySelector('#f-place').value, document.querySelector('#f-notes').value].join('|')`)) === 'Dentist|18:00|19:00|Dr. Huber|Bring the insurance card');await setField('#f-title', 'Dentist (moved)');await setField('#f-startdate', day(16));await setField('#f-enddate', day(16));await page.click('#event-save');await page.waitFor(`document.querySelectorAll(${J(cell(16) + ' button.event')}).length === 1`, { label: 'event moved' });check('editing moves and renames the event', J(await evTexts(16)) === '["18:00 Dentist (moved)"]' && (await evTexts(15)).length === 0, J([await evTexts(15), await evTexts(16)]));// all-day, several daysawait page.click(cell(20));await page.waitFor(`document.querySelector('#eventeditor').classList.contains('open')`, { label: 'editor opens (all day)' });await page.click('#kind-allday');await page.click('#f-title');await page.type('#f-title', 'Vacation');await setField('#f-enddate', day(22));check('all-day hides the time fields', !(await ev(`(() => { const e = document.querySelector('#f-starttime'); return e.offsetWidth > 0; })()`)));await page.click('#event-save');await page.waitFor(`document.querySelectorAll('#month-days button.event').length === 4`, { label: 'three all-day days' });check('an all-day event of three days shows on each day', J([await evTexts(20), await evTexts(21), await evTexts(22), await evTexts(23)]) === '[["Vacation"],["Vacation"],["Vacation"],[]]');// validation: the end before the start is refused with a message, nothing changesawait page.click(cell(25));await page.waitFor(`document.querySelector('#eventeditor').classList.contains('open')`, { label: 'editor opens (bad)' });await page.click('#f-title');await page.type('#f-title', 'Backwards');await setField('#f-starttime', '10:00');await setField('#f-endtime', '09:00');await page.click('#event-save');await page.waitFor(`document.querySelector('#formerror').textContent.length > 0`, { label: 'error shows' });check('an end before the start is refused with a message', (await ev(`document.querySelector('#formerror').textContent`)).includes('before the start') && (await evTexts(25)).length === 0 && (await editorOpen()));await page.click('#event-cancel');check('cancel closes the editor', !(await editorOpen()));// paging loads the events of that monthawait page.click('#month-next');await page.waitFor(`document.querySelector('#month-title').textContent !== ${J(await ev(`document.querySelector('#month-title').textContent`))}`, { label: 'next month' });await page.click('#month-prev');await page.waitFor(`document.querySelectorAll('#month-days button.event').length === 4`, { label: 'events back after paging' });check('paging away and back shows the events again', true);// private: bob sees nothing of alice's and cannot change or delete themconst aliceCookie = await cookieOf();await page.click('#logout');await page.waitForSelector('#loginbutton');await sleep(300);await page.goto(BASE + '/');await hydrated();check('after log out no event shows', (await ev(`document.querySelectorAll('#month-days button.event').length`)) === 0);await loginAs(bob);await page.goto(BASE + '/');await hydrated();await sleep(500);check("another identity does not see alice's events", (await ev(`document.querySelectorAll('#month-days button.event').length`)) === 0);const bobCookie = await cookieOf();const del = await emitRaw(bobCookie, 'calDelete', [aliceEventId, day(1), day(28)]);check("another identity cannot delete alice's event", del && del.error === 'no such event', J(del));const chg = await emitRaw(bobCookie, 'calSave', [{ id: aliceEventId, title: 'hacked', allDay: false, startDate: day(16), startTime: '09:00', endDate: '', endTime: '', place: '', notes: '', zone: 'UTC' }, day(1), day(28)]);check("another identity cannot change alice's event", chg && chg.error === 'no such event', J(chg));const rows = await emitRaw(bobCookie, 'calLoad', [day(1), day(28)]);check('another identity loads no events', rows && Array.isArray(rows.events) && rows.events.length === 0, J(rows));// validWindow / soonOf are plain (non-static) members of calendar.hl called from server faces (hybriel #115/#116, mission 048)const badWin = await emitRaw(bobCookie, 'calLoad', ['nope', day(28)]);check('the server refuses a window that is not two dates', badWin && badWin.error === 'not a date', J(badWin));const bad = await emitRaw(bobCookie, 'calSave', [{ title: '', allDay: false, startDate: 'nope', startTime: '09:00', endDate: '', endTime: '', place: '', notes: '', zone: 'UTC' }, day(1), day(28)]);check('the server validates an event strictly', bad && typeof bad.error === 'string' && bad.error.length > 0, J(bad));// alice again: her events are all there; delete oneawait page.click('#logout');await page.waitForSelector('#loginbutton');await sleep(300);await loginAs(alice);await page.goto(BASE + '/');await hydrated();await page.waitFor(`document.querySelectorAll('#month-days button.event').length === 4`, { label: 'alice events back' });check('logging in again shows her events', J(await evTexts(16)) === '["18:00 Dentist (moved)"]');{ const { data } = await page.send('Page.captureScreenshot', { format: 'png' }); writeFileSync(join(SCRATCH, 'gate-events-wide.png'), Buffer.from(data, 'base64')); }await page.click(cell(16) + ' button.event');await page.waitFor(`document.querySelector('#eventeditor').classList.contains('open')`, { label: 'editor opens to delete' });await page.click('#event-delete');await page.waitFor(`document.querySelectorAll(${J(cell(16) + ' button.event')}).length === 0`, { label: 'event deleted' });check('delete removes the event', (await evTexts(16)).length === 0 && !(await editorOpen()) && (await ev(`document.querySelectorAll('#month-days button.event').length`)) === 3);await page.goto(BASE + '/');await hydrated();await sleep(500);check('the deleted event stays gone after a reload', (await ev(`document.querySelectorAll('#month-days button.event').length`)) === 3);await page.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 800, deviceScaleFactor: 1, mobile: true });await sleep(300);await page.click(cell(9));await page.waitFor(`document.querySelector('#eventeditor').classList.contains('open')`, { label: 'editor opens on a phone' });await sleep(200);const { data } = await page.send('Page.captureScreenshot', { format: 'png' });writeFileSync(join(SCRATCH, 'gate-editor-narrow.png'), Buffer.from(data, 'base64'));check('the editor fits a phone (no horizontal overflow)', await ev('document.documentElement.scrollWidth <= window.innerWidth'));// THE INSTALLABLE APP (ticket #3): manifest (served as manifest JSON, installable fields), icons, link, theme colourconst mhref = await (async () => { const h = await (await fetch(BASE + '/')).text(); const m = h.match(/<link rel="manifest" href="([^"]+)"/); return m ? m[1] : null; })();check('the page head links the manifest', !!mhref, String(mhref));const mres = await fetch(BASE + (mhref || '/manifest.webmanifest'));const man = await mres.json();check('manifest is served as a web manifest with start_url, standalone display, name', /manifest\+json/.test(mres.headers.get('content-type') || '') && man.start_url === '/' && man.display === 'standalone' && !!man.name);const sizes = [];for (const ic of man.icons) { const r = await fetch(BASE + ic.src); const b = Buffer.from(await r.arrayBuffer()); sizes.push(r.ok && ic.type === 'image/png' && b.readUInt32BE(16) === Number(ic.sizes.split('x')[0])); }check('every icon of the manifest is a real PNG of its declared size (192 and 512, any and maskable)', sizes.length === 4 && sizes.every(Boolean) && man.icons.some(i => i.sizes === '512x512' && i.purpose === 'maskable'));await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });await page.goto(BASE + '/');await hydrated();// installable: Chrome finds the manifest, and hl:web's own service worker (offline = [ Calendar ]) is registeredawait page.waitFor(`navigator.serviceWorker.getRegistration().then(r => !!(r && (r.active || r.installing || r.waiting)))`, { label: 'service worker registered' }).catch(() => {});await sleep(1500);const inst = await page.send('Page.getInstallabilityErrors');check('Chrome reports no installability error', (inst.installabilityErrors || []).length === 0, JSON.stringify(inst));check('a service worker is registered for the whole app', await ev(`navigator.serviceWorker.getRegistration().then(r => !!r && new URL(r.scope).pathname === '/')`));check('the page carries the theme colour', (await ev(`document.querySelector('meta[name="theme-color"]').getAttribute('content')`)) === '#191e23');check('no console errors during the event steps', page.problems().filter(m => !/favicon|Failed to load resource/.test(m.text)).length === 0, page.dumpConsole());}// ---- calendar#6 through hybriel #105: the page reads `headers['accept-language']` (no framework patch) ------------------// A fresh tab per language; its Accept-Language is set by CDP and the BROWSER's own language (Intl locale and// navigator.language) is set to the OTHER one, so// the names can only come from the request header. First page (no pointer yet), then two pages forward and one back// with a real pointer: the names stay in the header's language.async function languagePart() {const NAMES = {de: { months: ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'], days: 'Mo Di Mi Do Fr Sa So' },en: { months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], days: 'Mon Tue Wed Thu Fri Sat Sun' },};for (const [lang, header, other] of [['de', 'de-DE,de;q=0.9,en;q=0.8', 'en-US'], ['en', 'en-US,en;q=0.9,de;q=0.8', 'de-DE']]) {const p = await browser.newPage();try {await p.send('Network.enable');await p.send('Network.setExtraHTTPHeaders', { headers: { 'Accept-Language': header } });await p.send('Emulation.setLocaleOverride', { locale: other });// navigator.language follows the machine (German on Loreana), not the override: pin it to the other language tooawait p.addInitScript(`Object.defineProperty(Navigator.prototype, 'language', { get: () => ${JSON.stringify(other)} }); Object.defineProperty(Navigator.prototype, 'languages', { get: () => [${JSON.stringify(other)}] });`);await p.goto(BASE + '/', { timeout: 45000 });await p.waitForSelector('#month-title');await p.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated (' + lang + ')' });const t = NAMES[lang];const seen = async () => ({ title: await p.evaluate('document.querySelector("#month-title").textContent'), days: await p.evaluate('[...document.querySelectorAll("#month-weekdays li")].map(l => l.textContent).join(" ")') });const monthOf = (title) => t.months.indexOf(title.replace(/ \d{4}$/, ''));const first = await seen();const m0 = monthOf(first.title);const own = await p.evaluate('navigator.language + " / Intl " + Intl.DateTimeFormat().resolvedOptions().locale');check(`language ${lang}: first page (browser itself: ${own}) has ${lang} names`, m0 >= 0 && first.days === t.days && !own.startsWith(lang) && own.endsWith('Intl ' + other), JSON.stringify(first) + ' ' + own);// the server's own document (before any script): the names are in itconst html = await (await fetch(BASE + '/', { headers: { 'accept-language': header } })).text();const days = [...html.matchAll(/<li>([^<]*)<\/li>/g)].map(x => x[1]).slice(0, 7).join(' ');check(`language ${lang}: the server's HTML for Accept-Language ${header} carries ${lang} weekday names`, days === t.days, days);const steps = [['#month-next', 1], ['#month-next', 2], ['#month-prev', 1]];let ok = true, trail = [];for (const [btn, off] of steps) {const before = (await seen()).title;await p.click(btn); // a real pointer: the browser's own language would be read now if the server had not known oneawait p.waitFor(`document.querySelector("#month-title").textContent !== ${JSON.stringify(before)}`, { label: 'paging ' + lang });const now = await seen();trail.push(now.title + ' / ' + now.days);if (monthOf(now.title) !== (m0 + off) % 12 || now.days !== t.days) ok = false;}check(`language ${lang}: after paging (next, next, prev) the names are still ${lang}`, ok, trail.join(' | '));} finally { await p.close(); }}}// ---- ticket calendar#4: reminders and notifications, also offline -------------------------------------------------async function remindersPart(page, { alice, APPKEY }) {const ev = (e) => page.evaluate(e);const J = JSON.stringify;const pad = (n) => String(n).padStart(2, '0');const hydrated = async () => {await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });const at = await page.evaluate(`(() => { const r = document.querySelector('#month-title').getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })()`);await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: at.x, y: at.y });await sleep(700);};const setField = (sel, v, type = 'input') => ev(`(() => { const e = document.querySelector(${J(sel)}); e.value = ${J(v)}; e.dispatchEvent(new Event(${J(type)}, { bubbles: true })); })()`);const dateOf = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;const timeOf = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;const shown = () => ev(`document.querySelector('#notify-planned').textContent`);const notices = () => ev(`navigator.serviceWorker.getRegistration().then(r => r.getNotifications()).then(l => l.map(n => n.title + '|' + n.body))`);const cell = (d) => `#month-days li:has(time[datetime="${dateOf(d)}"])`;const openEditorOn = async (start) => {await ev(`document.querySelector(${J(cell(start) + ' time')}).click()`); // the day number: an event chip in the middle would edit that eventawait page.waitFor(`document.querySelector('#eventeditor').classList.contains('open')`, { label: 'editor opens' });await setField('#f-startdate', dateOf(start));await setField('#f-starttime', timeOf(start));await setField('#f-enddate', '');await setField('#f-endtime', '');const t = await ev(`document.querySelector('#f-title').value`);check('a new event starts with an empty title', t === '', J(t));};const addEvent = async (name, start, own) => {await openEditorOn(start);await setField('#f-title', name);if (own) {await page.click('#rem-own');await ev(`document.querySelectorAll('#rem-rows reminder-row button').forEach((b, i) => { if (i > 0) b.click(); })`);await sleep(300);await setField('#rem-rows reminder-row select', 'minutes', 'change');await setField('#rem-rows reminder-row input[type=number]', String(own), 'change');}await page.click('#event-save');await page.waitFor(`!document.querySelector('#eventeditor').classList.contains('open')`, { label: 'editor closed after save', timeout: 15000 });};const wanted = async (titles, ms) => {const t0 = Date.now(); let got = [];while (Date.now() - t0 < ms) { got = await notices(); if (titles.every(t => got.some(g => g.startsWith(t + '|')))) return got; await sleep(2000); }return got;};await page.send('Browser.grantPermissions', { origin: BASE, permissions: ['notifications'] });const code = await ident.selectorCode(alice, APPKEY, BASE);await page.goto(BASE + '/login/callback?ident_code=' + code);await page.waitForSelector('#logout');await hydrated();await page.waitFor(`navigator.serviceWorker.getRegistration().then(r => !!(r && r.active))`, { label: 'service worker active', timeout: 30000 });check('reminders: notifications are allowed, so no "Turn on notifications" button', await ev(`document.querySelector('#notify-ask').classList.contains('hidden')`) && await ev(`document.querySelector('#notify-blocked').classList.contains('hidden')`));// THE CALENDAR SETTINGS: the defaults are 1 hour before and the day before at 20:00await page.click('#settings-open');await page.waitFor(`document.querySelector('#settingseditor').classList.contains('open') && document.querySelectorAll('#set-rows reminder-row').length === 2`, { label: 'settings open with two rows' });const rowsOf = (sel) => ev(`[...document.querySelectorAll(${J(sel + ' reminder-row')})].map(r => [r.querySelector('input[type=number]').value, r.querySelector('select').value, r.querySelector('input[type=time]').value].join(' '))`);const first = await rowsOf('#set-rows');check('settings: the default reminders are 1 hour before and the day before at 20:00', first[0] === '1 hours 09:00' && first[1] === '1 days 20:00', J(first));await page.click('#set-cancel');// THE TICKET'S FIRST TEST: an event 70 minutes from now — with the defaults, one reminder (1 hour before) is plannedconst in70 = new Date(Date.now() + 70 * 60000);await addEvent('Bus to the lake', in70);const dayBefore = new Date(in70.getFullYear(), in70.getMonth(), in70.getDate() - 1, 20, 0, 0).getTime() > Date.now() ? 1 : 0;await page.waitFor(`document.querySelector('#notify-planned').textContent.length > 0`, { label: 'reminders planned' });const want = 1 + dayBefore;check('an event 70 minutes ahead gets the default reminders planned', (await shown()) === (want === 1 ? '1 reminder set for the next 7 days' : want + ' reminders set for the next 7 days'), await shown());// an event in another time zone: 30 minutes ahead in Tokyo → its 1-hour reminder is already past, whatever this browser's zoneconst tokyo = (t) => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Tokyo', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(t).replace(' ', 'T');const browserZone = await ev('Intl.DateTimeFormat().resolvedOptions().timeZone');const tk = tokyo(new Date(Date.now() + 30 * 60000));const cookie = (await page.cookies([BASE])).map(c => c.name + '=' + c.value).join('; ');const win = [dateOf(new Date(Date.now() - 86400000)), dateOf(new Date(Date.now() + 86400000 * 5))];const emitRaw = async (event, payload) => {const r = await fetch(BASE + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', cookie }, body: J({ t: 'emit', i: 1, event, payload }) });const j = JSON.parse(await r.text()); return j.value;};const tkHour = Number(tk.slice(11, 13));if (browserZone !== 'Asia/Tokyo' && tkHour < 19) {const saved = await emitRaw('calSave', [{ title: 'Tokyo call', allDay: false, startDate: tk.slice(0, 10), startTime: tk.slice(11, 16), endDate: '', endTime: '', place: '', notes: '', zone: 'Asia/Tokyo', custom: false, reminders: [] }, win[0], win[1]]);await page.goto(BASE + '/');await hydrated();await sleep(500);check('a time zone is honoured: 30 minutes ahead in Tokyo has no 1-hour reminder left', (await shown()) === (want === 1 ? '1 reminder set for the next 7 days' : want + ' reminders set for the next 7 days') && !saved.error, (await shown()) + ' ' + J(saved && saved.error));} else { console.log('skip Tokyo zone check (browser in Tokyo, or Tokyo evening)'); }// OWN REMINDERS: switch to "Own", the calendar's two rows are the start; drop one; it is keptconst later = new Date(Date.now() + 5 * 3600000);await openEditorOn(later);await setField('#f-title', 'Own reminders');check('the editor offers "Calendar\'s" reminders first, in words', (await ev(`document.querySelector('#rem-default-text').textContent`)) === '1 hour before, the day before at 20:00');await page.click('#rem-own');await page.waitFor(`document.querySelectorAll('#rem-rows reminder-row').length === 2`, { label: 'own rows start from the calendar\'s' });await page.click('#rem-add');await page.waitFor(`document.querySelectorAll('#rem-rows reminder-row').length === 3`, { label: 'row added' });await setField('#rem-rows reminder-row:nth-child(3) input[type=number]', '15', 'change');await ev(`document.querySelector('#rem-rows reminder-row:nth-child(2) button').click()`);await page.waitFor(`document.querySelectorAll('#rem-rows reminder-row').length === 2`, { label: 'row removed' });const own = await rowsOf('#rem-rows');check('own reminders: add, change and remove a row', own[0] === '1 hours 09:00' && own[1] === '15 minutes 09:00', J(own));await page.click('#event-save');await page.waitFor(`!document.querySelector('#eventeditor').classList.contains('open')`, { label: 'saved' });await page.goto(BASE + '/');await hydrated();await page.click(`${cell(later)} button.event:last-child`).catch(() => {});await sleep(300);const evList = await emitRaw('calLoad', [win[0], win[1]]);const stored = (evList.events || []).find(e => e.title === 'Own reminders');check('the server keeps the own reminders as WorldAPI $reminders rows', stored && stored.custom === true && stored.rows.length === 2 && stored.rows[1].unit === 'minutes' && stored.rows[1].n === '15', J(stored));const plain = (evList.events || []).find(e => e.title === 'Bus to the lake');check('an event without its own reminders inherits (custom = false)', plain && plain.custom === false, J(evList.events));check('calLoad answers the soon list (soonOf, a plain member called from a face)', Array.isArray(evList.soon) && evList.soon.length > 0, J(evList.soon));const bad = await emitRaw('calSave', [{ title: 'x', allDay: false, startDate: dateOf(later), startTime: '10:00', endDate: '', endTime: '', place: '', notes: '', zone: 'UTC', custom: true, reminders: [{ unit: 'minutes', n: '-5', time: '' }] }, win[0], win[1]]);check('a bad reminder is refused by the server', bad && bad.error, J(bad));const badSet = await emitRaw('calSettingsSave', [[{ unit: 'weeks', n: '1', time: '' }]]);check('bad default reminders are refused', badSet && badSet.error, J(badSet));// SETTINGS SAVED: only "1 minute before"await page.goto(BASE + '/');await hydrated();await page.click('#settings-open');await page.waitFor(`document.querySelectorAll('#set-rows reminder-row').length === 2`, { label: 'settings rows' });await ev(`document.querySelector('#set-rows reminder-row:nth-child(2) button').click()`);await page.waitFor(`document.querySelectorAll('#set-rows reminder-row').length === 1`, { label: 'one row' });await setField('#set-rows reminder-row select', 'minutes', 'change');await setField('#set-rows reminder-row input[type=number]', '1', 'change');await page.click('#set-save');await page.waitFor(`!document.querySelector('#settingseditor').classList.contains('open')`, { label: 'settings saved' });await page.goto(BASE + '/');await hydrated();await page.click('#settings-open');await page.waitFor(`document.querySelectorAll('#set-rows reminder-row').length === 1`, { label: 'settings reload' });check('settings are kept: 1 minute before', J(await rowsOf('#set-rows')) === '["1 minutes 09:00"]', J(await rowsOf('#set-rows')));await page.click('#set-cancel');// NOTIFICATIONS ON TIME: A inherits (1 minute before the start), B has its own (2 minutes before)await addEvent('Inherits the default', new Date(Date.now() + 3 * 60000));await addEvent('Has its own reminder', new Date(Date.now() + 4 * 60000), 2);check('reminders are planned for both', Number((await shown()).split(' ')[0]) >= 4, await shown());const got = await wanted(['Inherits the default', 'Has its own reminder'], 240000);check('the notifications came on time, the default one saying "Starts in 1 minute"', got.includes('Inherits the default|Starts in 1 minute') && got.some(g => g.startsWith('Has its own reminder|Starts in 2 minutes')), J(got));// OFFLINE: the server is gone; an event with a reminder is added anyway and its notification still comesserver.kill('SIGTERM');await new Promise(r => { server.once('exit', r); setTimeout(r, 3000); });await page.waitFor(`window.__hl.socket == null || window.__hl.socket.readyState !== 1`, { label: 'socket closed', timeout: 20000 });// the count only holds reminders still to come (the ones that fired are gone once the page counts again): the three// still waiting (Bus to the lake, the two of Own reminders) plus the new one; that it FIRES is the next checkawait addEvent('Added offline', new Date(Date.now() + 3 * 60000));check('offline: the event shows on its day and its reminder is planned', Number((await shown()).split(' ')[0]) >= 4, await shown());const got2 = await wanted(['Added offline'], 200000);check('offline: the notification still comes', got2.some(g => g.startsWith('Added offline|Starts in 1 minute')), J(got2));// back online: the queued save reaches the serverspawnServer();let saved = false;for (let i = 0; i < 60 && !saved; i++) {await sleep(1500);try { const l = await emitRaw('calLoad', [win[0], win[1]]); saved = (l.events || []).some(e => e.title === 'Added offline'); } catch {}}check('back online: the event saved while offline reaches the server', saved);check('no console errors in the reminder steps', page.problems().filter(m => !/favicon|Failed to load resource|WebSocket|ERR_|calSave did not reach/.test(m.text)).length === 0, page.dumpConsole());}try { await main(); } catch (e) { check('gate ran', false, String(e && e.stack || e)); }if (failures) console.log('---- server log (tail)\n' + log.slice(-3000) + (lastPage ? '\n---- browser console\n' + lastPage.dumpConsole() : ''));await stop();console.log(`${passes} passed, ${failures} failed`);process.exit(failures ? 1 : 0);
Branches
- mainmain branch
Latest commits
- 76edaa62calendar: Hybriel master ff51cf46 (re-vendor round, static workaround removed)mre
- 90a3fc2cdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
- 6722b72ddeploy.sh: never send .git or .gitignore to Byrodinmre
- be099807State of 2026-09-27, before the move to gitoriamre