gitoriaLog in with ident

calendar

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit6722b72d6722b72ddeploy.sh: never send .git or .gitignore to Byrodinmre6722b72d/tests/browser.mjs

40.1 KB

  1. // tests/browser.mjs — THE GATE of calendar.worldapi.org: its own server on its own storage, its OWN ident (a copy of
  2. // ident's code without .env, codes to a mail sink: tests/identkit.mjs), real headless Chrome.
  3. // node tests/browser.mjs (CALENDAR_GATE_PORT server, default 8700; CALENDAR_GATE_IDENT_PORT ident, default 8701;
  4. // CALENDAR_GATE_CHROME "8702-8709" = Chrome debug ports)
  5. // Screenshots (390 / 1280 px) land in .scratch/gate-*.png. Every process started is stopped by PID.
  6. import { spawn } from 'node:child_process';
  7. import { writeFileSync, existsSync, mkdirSync, rmSync } from 'node:fs';
  8. import { dirname, join, resolve } from 'node:path';
  9. import { fileURLToPath } from 'node:url';
  10. import { launchBrowser } from './cdp.mjs';
  11. import { startIdent } from './identkit.mjs';
  12. if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';
  13. const HERE = dirname(fileURLToPath(import.meta.url));
  14. const APP = resolve(HERE, '..');
  15. const PORT = Number(process.env.CALENDAR_GATE_PORT || 8700);
  16. const [CH_FROM, CH_TO] = (process.env.CALENDAR_GATE_CHROME || '8702-8709').split('-').map(Number);
  17. const IDENT_PORT = Number(process.env.CALENDAR_GATE_IDENT_PORT || 8701);
  18. const BASE = `http://127.0.0.1:${PORT}`;
  19. const STORE = join(APP, '.scratch', 'gate-store');
  20. 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')));
  21. let ident = null;
  22. const SCRATCH = join(APP, '.scratch');
  23. const SESSIONS = join(SCRATCH, 'gate-sessions');
  24. rmSync(SESSIONS, { recursive: true, force: true });
  25. rmSync(STORE, { recursive: true, force: true });
  26. mkdirSync(SESSIONS, { recursive: true });
  27. let failures = 0, passes = 0;
  28. function check(label, ok, detail = '') {
  29. console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`);
  30. if (ok) passes++; else failures++;
  31. }
  32. const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  33. let spawnServer = null, log = '', server = null, browser = null, lastPage = null;
  34. async function stop() {
  35. if (ident) { try { await ident.stop(); } catch {} ident = null; }
  36. if (browser) { try { await browser.close(); } catch {} browser = null; }
  37. if (server) {
  38. try { server.kill('SIGTERM'); } catch {}
  39. await new Promise(r => { if (server.exitCode !== null || server.signalCode !== null) return r(); server.once('exit', r); setTimeout(r, 3000); });
  40. server = null;
  41. }
  42. }
  43. async function main() {
  44. ident = await startIdent({ identDir: IDENT_DIR, workDir: join(STORE, 'ident'), port: IDENT_PORT });
  45. const alice = await ident.signIn('[email protected]');
  46. const bob = await ident.signIn('[email protected]');
  47. const APPKEY = await ident.registerApp(alice, 'calendar (gate)', [BASE]);
  48. 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')));
  49. spawnServer = () => {
  50. 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',
  51. 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'] });
  52. server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);
  53. };
  54. spawnServer();
  55. for (let i = 0; ; i++) {
  56. try { if ((await fetch(BASE + '/')).ok) break; } catch {}
  57. if (i > 80) throw new Error('server did not come up\n' + log);
  58. await sleep(250);
  59. }
  60. // the app's first page is the month view, rendered on the server: 6 weeks of 7 days, one today
  61. const home = await (await fetch(BASE + '/')).text();
  62. check('server renders 42 days and one today', (home.match(/<li class=/g) || []).length === 42 && (home.match(/class="today"/g) || []).length === 1);
  63. browser = await launchBrowser({ debugPortRange: [CH_FROM, CH_TO] });
  64. const page = await browser.newPage();
  65. lastPage = page;
  66. const SLOW = 3;
  67. const goto = page.goto.bind(page);
  68. page.goto = (u, o = {}) => goto(u, { ...o, timeout: 15000 * SLOW });
  69. await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
  70. await page.goto(BASE + '/');
  71. await page.waitForSelector('#month-title');
  72. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  73. const shot = async (name) => { const { data } = await page.send('Page.captureScreenshot', { format: 'png' }); writeFileSync(join(SCRATCH, `gate-${name}.png`), Buffer.from(data, 'base64')); };
  74. const ev = (e) => page.evaluate(e);
  75. const title = () => ev('document.querySelector("#month-title").textContent');
  76. 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()); })()`);
  77. const expectTitle = (y, m) => ev(`new Intl.DateTimeFormat(navigator.language, { month: 'long' }).format(new Date(Date.UTC(${y}, ${m - 1}, 15, 12))) + ' ${y}'`);
  78. const [ty, tm] = localToday.split('-').map(Number);
  79. // ticket #6: the FIRST render (no mouse yet) already has the names, in the language of the request, when the server knows it
  80. const firstDays = await ev('[...document.querySelectorAll("#month-weekdays li")].map(l => l.textContent).join(" ")');
  81. 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(' ')`);
  82. 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);
  83. await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 400, y: 300 }); // a real user's pointer reaches the month view
  84. await page.waitFor(`document.querySelector("#month-title").textContent.toLowerCase() === (${JSON.stringify(await expectTitle(ty, tm))}).toLowerCase()`, { label: 'title in the browser language' });
  85. check('title is the current month and year in the browser language', true);
  86. check('today is marked (browser date)', (await ev(`document.querySelector('li.today time').getAttribute('datetime')`)) === localToday, localToday);
  87. check('42 days, 6 weeks', (await ev('document.querySelectorAll("#month-days li").length')) === 42);
  88. const t0 = await title();
  89. // next / prev
  90. await page.click('#month-next');
  91. await page.waitFor(`document.querySelector("#month-title").textContent !== ${JSON.stringify(t0)}`, { label: 'next month' });
  92. const nm = tm === 12 ? 1 : tm + 1, ny = tm === 12 ? ty + 1 : ty;
  93. await page.waitFor(`document.querySelector("#month-title").textContent.toLowerCase() === ${JSON.stringify((await expectTitle(ny, nm)).toLowerCase())}`, { label: 'next month title' });
  94. check('next shows the next month', true);
  95. await page.click('#month-prev');
  96. await page.waitFor(`document.querySelector("#month-title").textContent === ${JSON.stringify(t0)}`, { label: 'prev month' });
  97. check('prev returns to the month, today marked again', (await ev('document.querySelectorAll("li.today").length')) === 1);
  98. // walk to February 2028 (leap year) and check its days and its first weekday (Tuesday)
  99. let guard = 0;
  100. const feb28 = (await expectTitle(2028, 2)).toLowerCase();
  101. while ((await title()).toLowerCase() !== feb28 && guard++ < 40) {
  102. const before = await title();
  103. await page.click('#month-next');
  104. await page.waitFor(`document.querySelector("#month-title").textContent !== ${JSON.stringify(before)}`, { label: 'paging' });
  105. }
  106. 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 }; })()`);
  107. check('Feb 2028: 29 days, the 1st is a Tuesday (column 2)', feb.n === 29 && feb.col === 1, JSON.stringify(feb));
  108. // year rollover: go back to December 2027 and January 2028 by prev
  109. await page.click('#month-prev');
  110. await page.click('#month-prev');
  111. const dec27 = (await expectTitle(2027, 12)).toLowerCase();
  112. await page.waitFor(`document.querySelector("#month-title").textContent.toLowerCase() === ${JSON.stringify(dec27)}`, { label: 'back to December' });
  113. 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 }))()`);
  114. check('Dec 2027: 31 days, the 1st is a Wednesday', dec.n === 31 && dec.col === 2, JSON.stringify(dec));
  115. check('no console errors', page.problems().filter(m => !/favicon|Failed to load resource/.test(m.text)).length === 0, page.dumpConsole());
  116. await languagePart();
  117. await shot('wide');
  118. await page.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 800, deviceScaleFactor: 1, mobile: true });
  119. await sleep(300);
  120. check('no horizontal overflow at 390 px', await ev('document.documentElement.scrollWidth <= window.innerWidth'));
  121. await shot('narrow');
  122. if (!process.env.GATE_SKIP_EVENTS) await eventsPart(page, { alice, bob, APPKEY });
  123. await remindersPart(page, { alice, APPKEY });
  124. }
  125. // ---- ticket calendar#2: login with ident, private events ------------------------------------------------------
  126. async function eventsPart(page, { alice, bob, APPKEY }) {
  127. const ev = (e) => page.evaluate(e);
  128. const J = JSON.stringify;
  129. // (a user's pointer reaches the month view first, and takes a moment before the click: the month view reads the browser then)
  130. const hydrated = async () => {
  131. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  132. 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) }; })()`);
  133. await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: at.x, y: at.y });
  134. await sleep(700);
  135. };
  136. const setField = (sel, v) => ev(`(() => { const e = document.querySelector(${J(sel)}); e.value = ${J(v)}; e.dispatchEvent(new Event('input', { bubbles: true })); })()`);
  137. const pad = (n) => String(n).padStart(2, '0');
  138. const now = new Date();
  139. const ym = `${now.getFullYear()}-${pad(now.getMonth() + 1)}`;
  140. const day = (d) => `${ym}-${pad(d)}`;
  141. const cell = (d) => `#month-days li:has(time[datetime="${day(d)}"])`;
  142. const evTexts = (d) => ev(`[...document.querySelectorAll(${J(cell(d) + ' button.event')})].map(b => b.textContent)`);
  143. const editorOpen = () => ev(`document.querySelector('#eventeditor').classList.contains('open')`);
  144. const emitRaw = async (cookie, event, payload) => {
  145. const r = await fetch(BASE + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', cookie }, body: J({ t: 'emit', i: 1, event, payload }) });
  146. const t = await r.text(); let j = null; try { j = JSON.parse(t); } catch {}
  147. return j && j.value !== undefined ? j.value : t;
  148. };
  149. const loginAs = async (who) => {
  150. const code = await ident.selectorCode(who, APPKEY, BASE);
  151. await page.goto(BASE + '/login/callback?ident_code=' + code);
  152. await page.waitForSelector('#logout');
  153. await hydrated();
  154. };
  155. const cookieOf = async () => (await page.cookies([BASE])).map(c => c.name + '=' + c.value).join('; ');
  156. // signed out: no login, no editor, the faces refuse
  157. await page.goto(BASE + '/');
  158. await hydrated();
  159. check('signed out: "Log in with ident" shows', (await ev(`!!document.querySelector('#loginbutton')`)) && !(await ev(`!!document.querySelector('#logout')`)));
  160. await page.click(cell(15));
  161. await sleep(500);
  162. check('signed out: clicking a day opens no editor', !(await editorOpen()));
  163. 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)]);
  164. check('signed out: the server refuses to save an event', refused && refused.error === 'log in with ident first', J(refused));
  165. let r = await fetch(BASE + '/login/callback', { redirect: 'manual' });
  166. check('login: /login/callback without a code → /login/failed', r.status === 302 && r.headers.get('location') === '/login/failed');
  167. // log in as alice, add an event on the 15th
  168. await loginAs(alice);
  169. check('logged in: "Log out" shows', await ev(`!!document.querySelector('#logout')`));
  170. await page.click(cell(15));
  171. await page.waitFor(`document.querySelector('#eventeditor').classList.contains('open')`, { label: 'editor opens' });
  172. await page.waitFor(`document.querySelector('#f-startdate').value === ${J(day(15))}`, { label: 'form shows the clicked date' });
  173. 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))`));
  174. await page.click('#f-title');
  175. await page.type('#f-title', 'Dentist');
  176. await setField('#f-starttime', '18:00');
  177. await setField('#f-endtime', '19:00');
  178. await setField('#f-place', 'Dr. Huber');
  179. await setField('#f-notes', 'Bring the insurance card');
  180. check('typing into the title kept its focus and text', (await ev(`document.querySelector('#f-title').value`)) === 'Dentist');
  181. await page.click('#event-save');
  182. await page.waitFor(`document.querySelectorAll(${J(cell(15) + ' button.event')}).length === 1`, { label: 'event shows on its day' });
  183. check('the event shows on that day with its time', J(await evTexts(15)) === '["18:00 Dentist"]', J(await evTexts(15)));
  184. check('the editor closed after saving', !(await editorOpen()));
  185. // it survives a reload (server render for the signed-in user)
  186. await page.goto(BASE + '/');
  187. await hydrated();
  188. await page.waitFor(`document.querySelectorAll(${J(cell(15) + ' button.event')}).length === 1`, { label: 'event after reload' });
  189. check('after a reload the event is still there', J(await evTexts(15)) === '["18:00 Dentist"]');
  190. const html = await (await fetch(BASE + '/', { headers: { cookie: await cookieOf() } })).text();
  191. check('the server renders the event into the page for its owner', html.includes('18:00 Dentist'));
  192. check('a visitor without login gets no event in the page', !(await (await fetch(BASE + '/')).text()).includes('Dentist'));
  193. // edit
  194. const aliceEventId = await ev(`document.querySelector(${J(cell(15) + ' button.event')}).value`);
  195. await page.click(cell(15) + ' button.event');
  196. await page.waitFor(`document.querySelector('#eventeditor').classList.contains('open')`, { label: 'editor opens to edit' });
  197. 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');
  198. await setField('#f-title', 'Dentist (moved)');
  199. await setField('#f-startdate', day(16));
  200. await setField('#f-enddate', day(16));
  201. await page.click('#event-save');
  202. await page.waitFor(`document.querySelectorAll(${J(cell(16) + ' button.event')}).length === 1`, { label: 'event moved' });
  203. 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)]));
  204. // all-day, several days
  205. await page.click(cell(20));
  206. await page.waitFor(`document.querySelector('#eventeditor').classList.contains('open')`, { label: 'editor opens (all day)' });
  207. await page.click('#kind-allday');
  208. await page.click('#f-title');
  209. await page.type('#f-title', 'Vacation');
  210. await setField('#f-enddate', day(22));
  211. check('all-day hides the time fields', !(await ev(`(() => { const e = document.querySelector('#f-starttime'); return e.offsetWidth > 0; })()`)));
  212. await page.click('#event-save');
  213. await page.waitFor(`document.querySelectorAll('#month-days button.event').length === 4`, { label: 'three all-day days' });
  214. 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"],[]]');
  215. // validation: the end before the start is refused with a message, nothing changes
  216. await page.click(cell(25));
  217. await page.waitFor(`document.querySelector('#eventeditor').classList.contains('open')`, { label: 'editor opens (bad)' });
  218. await page.click('#f-title');
  219. await page.type('#f-title', 'Backwards');
  220. await setField('#f-starttime', '10:00');
  221. await setField('#f-endtime', '09:00');
  222. await page.click('#event-save');
  223. await page.waitFor(`document.querySelector('#formerror').textContent.length > 0`, { label: 'error shows' });
  224. 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()));
  225. await page.click('#event-cancel');
  226. check('cancel closes the editor', !(await editorOpen()));
  227. // paging loads the events of that month
  228. await page.click('#month-next');
  229. await page.waitFor(`document.querySelector('#month-title').textContent !== ${J(await ev(`document.querySelector('#month-title').textContent`))}`, { label: 'next month' });
  230. await page.click('#month-prev');
  231. await page.waitFor(`document.querySelectorAll('#month-days button.event').length === 4`, { label: 'events back after paging' });
  232. check('paging away and back shows the events again', true);
  233. // private: bob sees nothing of alice's and cannot change or delete them
  234. const aliceCookie = await cookieOf();
  235. await page.click('#logout');
  236. await page.waitForSelector('#loginbutton');
  237. await sleep(300);
  238. await page.goto(BASE + '/');
  239. await hydrated();
  240. check('after log out no event shows', (await ev(`document.querySelectorAll('#month-days button.event').length`)) === 0);
  241. await loginAs(bob);
  242. await page.goto(BASE + '/');
  243. await hydrated();
  244. await sleep(500);
  245. check("another identity does not see alice's events", (await ev(`document.querySelectorAll('#month-days button.event').length`)) === 0);
  246. const bobCookie = await cookieOf();
  247. const del = await emitRaw(bobCookie, 'calDelete', [aliceEventId, day(1), day(28)]);
  248. check("another identity cannot delete alice's event", del && del.error === 'no such event', J(del));
  249. 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)]);
  250. check("another identity cannot change alice's event", chg && chg.error === 'no such event', J(chg));
  251. const rows = await emitRaw(bobCookie, 'calLoad', [day(1), day(28)]);
  252. check('another identity loads no events', rows && Array.isArray(rows.events) && rows.events.length === 0, J(rows));
  253. const bad = await emitRaw(bobCookie, 'calSave', [{ title: '', allDay: false, startDate: 'nope', startTime: '09:00', endDate: '', endTime: '', place: '', notes: '', zone: 'UTC' }, day(1), day(28)]);
  254. check('the server validates an event strictly', bad && typeof bad.error === 'string' && bad.error.length > 0, J(bad));
  255. // alice again: her events are all there; delete one
  256. await page.click('#logout');
  257. await page.waitForSelector('#loginbutton');
  258. await sleep(300);
  259. await loginAs(alice);
  260. await page.goto(BASE + '/');
  261. await hydrated();
  262. await page.waitFor(`document.querySelectorAll('#month-days button.event').length === 4`, { label: 'alice events back' });
  263. check('logging in again shows her events', J(await evTexts(16)) === '["18:00 Dentist (moved)"]');
  264. { const { data } = await page.send('Page.captureScreenshot', { format: 'png' }); writeFileSync(join(SCRATCH, 'gate-events-wide.png'), Buffer.from(data, 'base64')); }
  265. await page.click(cell(16) + ' button.event');
  266. await page.waitFor(`document.querySelector('#eventeditor').classList.contains('open')`, { label: 'editor opens to delete' });
  267. await page.click('#event-delete');
  268. await page.waitFor(`document.querySelectorAll(${J(cell(16) + ' button.event')}).length === 0`, { label: 'event deleted' });
  269. check('delete removes the event', (await evTexts(16)).length === 0 && !(await editorOpen()) && (await ev(`document.querySelectorAll('#month-days button.event').length`)) === 3);
  270. await page.goto(BASE + '/');
  271. await hydrated();
  272. await sleep(500);
  273. check('the deleted event stays gone after a reload', (await ev(`document.querySelectorAll('#month-days button.event').length`)) === 3);
  274. await page.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 800, deviceScaleFactor: 1, mobile: true });
  275. await sleep(300);
  276. await page.click(cell(9));
  277. await page.waitFor(`document.querySelector('#eventeditor').classList.contains('open')`, { label: 'editor opens on a phone' });
  278. await sleep(200);
  279. const { data } = await page.send('Page.captureScreenshot', { format: 'png' });
  280. writeFileSync(join(SCRATCH, 'gate-editor-narrow.png'), Buffer.from(data, 'base64'));
  281. check('the editor fits a phone (no horizontal overflow)', await ev('document.documentElement.scrollWidth <= window.innerWidth'));
  282. // THE INSTALLABLE APP (ticket #3): manifest (served as manifest JSON, installable fields), icons, link, theme colour
  283. const mhref = await (async () => { const h = await (await fetch(BASE + '/')).text(); const m = h.match(/<link rel="manifest" href="([^"]+)"/); return m ? m[1] : null; })();
  284. check('the page head links the manifest', !!mhref, String(mhref));
  285. const mres = await fetch(BASE + (mhref || '/manifest.webmanifest'));
  286. const man = await mres.json();
  287. 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);
  288. const sizes = [];
  289. 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])); }
  290. 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'));
  291. await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
  292. await page.goto(BASE + '/');
  293. await hydrated();
  294. // installable: Chrome finds the manifest, and hl:web's own service worker (offline = [ Calendar ]) is registered
  295. await page.waitFor(`navigator.serviceWorker.getRegistration().then(r => !!(r && (r.active || r.installing || r.waiting)))`, { label: 'service worker registered' }).catch(() => {});
  296. await sleep(1500);
  297. const inst = await page.send('Page.getInstallabilityErrors');
  298. check('Chrome reports no installability error', (inst.installabilityErrors || []).length === 0, JSON.stringify(inst));
  299. check('a service worker is registered for the whole app', await ev(`navigator.serviceWorker.getRegistration().then(r => !!r && new URL(r.scope).pathname === '/')`));
  300. check('the page carries the theme colour', (await ev(`document.querySelector('meta[name="theme-color"]').getAttribute('content')`)) === '#191e23');
  301. check('no console errors during the event steps', page.problems().filter(m => !/favicon|Failed to load resource/.test(m.text)).length === 0, page.dumpConsole());
  302. }
  303. // ---- calendar#6 through hybriel #105: the page reads `headers['accept-language']` (no framework patch) ------------------
  304. // A fresh tab per language; its Accept-Language is set by CDP and the BROWSER's own language (Intl locale and
  305. // navigator.language) is set to the OTHER one, so
  306. // the names can only come from the request header. First page (no pointer yet), then two pages forward and one back
  307. // with a real pointer: the names stay in the header's language.
  308. async function languagePart() {
  309. const NAMES = {
  310. de: { months: ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'], days: 'Mo Di Mi Do Fr Sa So' },
  311. en: { months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], days: 'Mon Tue Wed Thu Fri Sat Sun' },
  312. };
  313. 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']]) {
  314. const p = await browser.newPage();
  315. try {
  316. await p.send('Network.enable');
  317. await p.send('Network.setExtraHTTPHeaders', { headers: { 'Accept-Language': header } });
  318. await p.send('Emulation.setLocaleOverride', { locale: other });
  319. // navigator.language follows the machine (German on Loreana), not the override: pin it to the other language too
  320. await p.addInitScript(`Object.defineProperty(Navigator.prototype, 'language', { get: () => ${JSON.stringify(other)} }); Object.defineProperty(Navigator.prototype, 'languages', { get: () => [${JSON.stringify(other)}] });`);
  321. await p.goto(BASE + '/', { timeout: 45000 });
  322. await p.waitForSelector('#month-title');
  323. await p.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated (' + lang + ')' });
  324. const t = NAMES[lang];
  325. 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(" ")') });
  326. const monthOf = (title) => t.months.indexOf(title.replace(/ \d{4}$/, ''));
  327. const first = await seen();
  328. const m0 = monthOf(first.title);
  329. const own = await p.evaluate('navigator.language + " / Intl " + Intl.DateTimeFormat().resolvedOptions().locale');
  330. 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);
  331. // the server's own document (before any script): the names are in it
  332. const html = await (await fetch(BASE + '/', { headers: { 'accept-language': header } })).text();
  333. const days = [...html.matchAll(/<li>([^<]*)<\/li>/g)].map(x => x[1]).slice(0, 7).join(' ');
  334. check(`language ${lang}: the server's HTML for Accept-Language ${header} carries ${lang} weekday names`, days === t.days, days);
  335. const steps = [['#month-next', 1], ['#month-next', 2], ['#month-prev', 1]];
  336. let ok = true, trail = [];
  337. for (const [btn, off] of steps) {
  338. const before = (await seen()).title;
  339. await p.click(btn); // a real pointer: the browser's own language would be read now if the server had not known one
  340. await p.waitFor(`document.querySelector("#month-title").textContent !== ${JSON.stringify(before)}`, { label: 'paging ' + lang });
  341. const now = await seen();
  342. trail.push(now.title + ' / ' + now.days);
  343. if (monthOf(now.title) !== (m0 + off) % 12 || now.days !== t.days) ok = false;
  344. }
  345. check(`language ${lang}: after paging (next, next, prev) the names are still ${lang}`, ok, trail.join(' | '));
  346. } finally { await p.close(); }
  347. }
  348. }
  349. // ---- ticket calendar#4: reminders and notifications, also offline -------------------------------------------------
  350. async function remindersPart(page, { alice, APPKEY }) {
  351. const ev = (e) => page.evaluate(e);
  352. const J = JSON.stringify;
  353. const pad = (n) => String(n).padStart(2, '0');
  354. const hydrated = async () => {
  355. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { timeout: 30000, label: 'hydrated' });
  356. 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) }; })()`);
  357. await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: at.x, y: at.y });
  358. await sleep(700);
  359. };
  360. 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 })); })()`);
  361. const dateOf = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
  362. const timeOf = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
  363. const shown = () => ev(`document.querySelector('#notify-planned').textContent`);
  364. const notices = () => ev(`navigator.serviceWorker.getRegistration().then(r => r.getNotifications()).then(l => l.map(n => n.title + '|' + n.body))`);
  365. const cell = (d) => `#month-days li:has(time[datetime="${dateOf(d)}"])`;
  366. const openEditorOn = async (start) => {
  367. await ev(`document.querySelector(${J(cell(start) + ' time')}).click()`); // the day number: an event chip in the middle would edit that event
  368. await page.waitFor(`document.querySelector('#eventeditor').classList.contains('open')`, { label: 'editor opens' });
  369. await setField('#f-startdate', dateOf(start));
  370. await setField('#f-starttime', timeOf(start));
  371. await setField('#f-enddate', '');
  372. await setField('#f-endtime', '');
  373. const t = await ev(`document.querySelector('#f-title').value`);
  374. check('a new event starts with an empty title', t === '', J(t));
  375. };
  376. const addEvent = async (name, start, own) => {
  377. await openEditorOn(start);
  378. await setField('#f-title', name);
  379. if (own) {
  380. await page.click('#rem-own');
  381. await ev(`document.querySelectorAll('#rem-rows reminder-row button').forEach((b, i) => { if (i > 0) b.click(); })`);
  382. await sleep(300);
  383. await setField('#rem-rows reminder-row select', 'minutes', 'change');
  384. await setField('#rem-rows reminder-row input[type=number]', String(own), 'change');
  385. }
  386. await page.click('#event-save');
  387. await page.waitFor(`!document.querySelector('#eventeditor').classList.contains('open')`, { label: 'editor closed after save', timeout: 15000 });
  388. };
  389. const wanted = async (titles, ms) => {
  390. const t0 = Date.now(); let got = [];
  391. while (Date.now() - t0 < ms) { got = await notices(); if (titles.every(t => got.some(g => g.startsWith(t + '|')))) return got; await sleep(2000); }
  392. return got;
  393. };
  394. await page.send('Browser.grantPermissions', { origin: BASE, permissions: ['notifications'] });
  395. const code = await ident.selectorCode(alice, APPKEY, BASE);
  396. await page.goto(BASE + '/login/callback?ident_code=' + code);
  397. await page.waitForSelector('#logout');
  398. await hydrated();
  399. await page.waitFor(`navigator.serviceWorker.getRegistration().then(r => !!(r && r.active))`, { label: 'service worker active', timeout: 30000 });
  400. 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')`));
  401. // THE CALENDAR SETTINGS: the defaults are 1 hour before and the day before at 20:00
  402. await page.click('#settings-open');
  403. await page.waitFor(`document.querySelector('#settingseditor').classList.contains('open') && document.querySelectorAll('#set-rows reminder-row').length === 2`, { label: 'settings open with two rows' });
  404. 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(' '))`);
  405. const first = await rowsOf('#set-rows');
  406. 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));
  407. await page.click('#set-cancel');
  408. // THE TICKET'S FIRST TEST: an event 70 minutes from now — with the defaults, one reminder (1 hour before) is planned
  409. const in70 = new Date(Date.now() + 70 * 60000);
  410. await addEvent('Bus to the lake', in70);
  411. const dayBefore = new Date(in70.getFullYear(), in70.getMonth(), in70.getDate() - 1, 20, 0, 0).getTime() > Date.now() ? 1 : 0;
  412. await page.waitFor(`document.querySelector('#notify-planned').textContent.length > 0`, { label: 'reminders planned' });
  413. const want = 1 + dayBefore;
  414. 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());
  415. // an event in another time zone: 30 minutes ahead in Tokyo → its 1-hour reminder is already past, whatever this browser's zone
  416. const 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');
  417. const browserZone = await ev('Intl.DateTimeFormat().resolvedOptions().timeZone');
  418. const tk = tokyo(new Date(Date.now() + 30 * 60000));
  419. const cookie = (await page.cookies([BASE])).map(c => c.name + '=' + c.value).join('; ');
  420. const win = [dateOf(new Date(Date.now() - 86400000)), dateOf(new Date(Date.now() + 86400000 * 5))];
  421. const emitRaw = async (event, payload) => {
  422. const r = await fetch(BASE + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', cookie }, body: J({ t: 'emit', i: 1, event, payload }) });
  423. const j = JSON.parse(await r.text()); return j.value;
  424. };
  425. const tkHour = Number(tk.slice(11, 13));
  426. if (browserZone !== 'Asia/Tokyo' && tkHour < 19) {
  427. 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]]);
  428. await page.goto(BASE + '/');
  429. await hydrated();
  430. await sleep(500);
  431. 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));
  432. } else { console.log('skip Tokyo zone check (browser in Tokyo, or Tokyo evening)'); }
  433. // OWN REMINDERS: switch to "Own", the calendar's two rows are the start; drop one; it is kept
  434. const later = new Date(Date.now() + 5 * 3600000);
  435. await openEditorOn(later);
  436. await setField('#f-title', 'Own reminders');
  437. 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');
  438. await page.click('#rem-own');
  439. await page.waitFor(`document.querySelectorAll('#rem-rows reminder-row').length === 2`, { label: 'own rows start from the calendar\'s' });
  440. await page.click('#rem-add');
  441. await page.waitFor(`document.querySelectorAll('#rem-rows reminder-row').length === 3`, { label: 'row added' });
  442. await setField('#rem-rows reminder-row:nth-child(3) input[type=number]', '15', 'change');
  443. await ev(`document.querySelector('#rem-rows reminder-row:nth-child(2) button').click()`);
  444. await page.waitFor(`document.querySelectorAll('#rem-rows reminder-row').length === 2`, { label: 'row removed' });
  445. const own = await rowsOf('#rem-rows');
  446. check('own reminders: add, change and remove a row', own[0] === '1 hours 09:00' && own[1] === '15 minutes 09:00', J(own));
  447. await page.click('#event-save');
  448. await page.waitFor(`!document.querySelector('#eventeditor').classList.contains('open')`, { label: 'saved' });
  449. await page.goto(BASE + '/');
  450. await hydrated();
  451. await page.click(`${cell(later)} button.event:last-child`).catch(() => {});
  452. await sleep(300);
  453. const evList = await emitRaw('calLoad', [win[0], win[1]]);
  454. const stored = (evList.events || []).find(e => e.title === 'Own reminders');
  455. 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));
  456. const plain = (evList.events || []).find(e => e.title === 'Bus to the lake');
  457. check('an event without its own reminders inherits (custom = false)', plain && plain.custom === false, J(evList.events));
  458. 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]]);
  459. check('a bad reminder is refused by the server', bad && bad.error, J(bad));
  460. const badSet = await emitRaw('calSettingsSave', [[{ unit: 'weeks', n: '1', time: '' }]]);
  461. check('bad default reminders are refused', badSet && badSet.error, J(badSet));
  462. // SETTINGS SAVED: only "1 minute before"
  463. await page.goto(BASE + '/');
  464. await hydrated();
  465. await page.click('#settings-open');
  466. await page.waitFor(`document.querySelectorAll('#set-rows reminder-row').length === 2`, { label: 'settings rows' });
  467. await ev(`document.querySelector('#set-rows reminder-row:nth-child(2) button').click()`);
  468. await page.waitFor(`document.querySelectorAll('#set-rows reminder-row').length === 1`, { label: 'one row' });
  469. await setField('#set-rows reminder-row select', 'minutes', 'change');
  470. await setField('#set-rows reminder-row input[type=number]', '1', 'change');
  471. await page.click('#set-save');
  472. await page.waitFor(`!document.querySelector('#settingseditor').classList.contains('open')`, { label: 'settings saved' });
  473. await page.goto(BASE + '/');
  474. await hydrated();
  475. await page.click('#settings-open');
  476. await page.waitFor(`document.querySelectorAll('#set-rows reminder-row').length === 1`, { label: 'settings reload' });
  477. check('settings are kept: 1 minute before', J(await rowsOf('#set-rows')) === '["1 minutes 09:00"]', J(await rowsOf('#set-rows')));
  478. await page.click('#set-cancel');
  479. // NOTIFICATIONS ON TIME: A inherits (1 minute before the start), B has its own (2 minutes before)
  480. await addEvent('Inherits the default', new Date(Date.now() + 3 * 60000));
  481. await addEvent('Has its own reminder', new Date(Date.now() + 4 * 60000), 2);
  482. check('reminders are planned for both', Number((await shown()).split(' ')[0]) >= 4, await shown());
  483. const got = await wanted(['Inherits the default', 'Has its own reminder'], 240000);
  484. 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));
  485. // OFFLINE: the server is gone; an event with a reminder is added anyway and its notification still comes
  486. server.kill('SIGTERM');
  487. await new Promise(r => { server.once('exit', r); setTimeout(r, 3000); });
  488. await page.waitFor(`window.__hl.socket == null || window.__hl.socket.readyState !== 1`, { label: 'socket closed', timeout: 20000 });
  489. // the count only holds reminders still to come (the ones that fired are gone once the page counts again): the three
  490. // still waiting (Bus to the lake, the two of Own reminders) plus the new one; that it FIRES is the next check
  491. await addEvent('Added offline', new Date(Date.now() + 3 * 60000));
  492. check('offline: the event shows on its day and its reminder is planned', Number((await shown()).split(' ')[0]) >= 4, await shown());
  493. const got2 = await wanted(['Added offline'], 200000);
  494. check('offline: the notification still comes', got2.some(g => g.startsWith('Added offline|Starts in 1 minute')), J(got2));
  495. // back online: the queued save reaches the server
  496. spawnServer();
  497. let saved = false;
  498. for (let i = 0; i < 60 && !saved; i++) {
  499. await sleep(1500);
  500. try { const l = await emitRaw('calLoad', [win[0], win[1]]); saved = (l.events || []).some(e => e.title === 'Added offline'); } catch {}
  501. }
  502. check('back online: the event saved while offline reaches the server', saved);
  503. 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());
  504. }
  505. try { await main(); } catch (e) { check('gate ran', false, String(e && e.stack || e)); }
  506. if (failures) console.log('---- server log (tail)\n' + log.slice(-3000) + (lastPage ? '\n---- browser console\n' + lastPage.dumpConsole() : ''));
  507. await stop();
  508. console.log(`${passes} passed, ${failures} failed`);
  509. process.exit(failures ? 1 : 0);

Branches

Latest commits

  • 6722b72ddeploy.sh: never send .git or .gitignore to Byrodinmre
  • be099807State of 2026-09-27, before the move to gitoriamre