notes
All repositories: gitoria
30.6 KB
// tests/browser.mjs — THE GATE of notes.worldapi.org (tickets notes#1: sidebar + Markdown editor; notes#2: offline + sync).// Its own notes server on its own storage, its OWN ident (a copy of ident's code without .env — codes go to a mail// sink, tests/identkit.mjs), real headless Chromes for everything visible. `*.notes.test` is mapped to 127.0.0.1// inside Chrome (--host-resolver-rules) so ident and the app are the same site, as on worldapi.org.//// node tests/browser.mjs (NOTES_GATE_PORT server, default 8702; NOTES_GATE_IDENT_PORT, default 8701;// NOTES_GATE_CHROME "8713-8719" = 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 { rmSync, mkdirSync, writeFileSync, existsSync } 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 BIN = join(APP, 'bin/hybriel');const PORT = Number(process.env.NOTES_GATE_PORT || 8702);const IDENT_PORT = Number(process.env.NOTES_GATE_IDENT_PORT || 8701);const API = `http://127.0.0.1:${PORT}`;const BASE = `http://notes.test:${PORT}`;const IDENT_B = `http://ident.notes.test:${IDENT_PORT}`;const [CH_FROM, CH_TO] = (process.env.NOTES_GATE_CHROME || '8703-8709').split('-').map(Number);process.env.HL_CHROME_ARGS = '--host-resolver-rules=MAP *.notes.test 127.0.0.1, MAP notes.test 127.0.0.1|--unsafely-treat-insecure-origin-as-secure=' + `http://notes.test:${PORT}`; // a service worker needs a secure origin (https on worldapi.org)const SCRATCH = join(APP, '.scratch');const STORE = join(SCRATCH, 'gate-store');const IDENT_DIR = process.env.NOTES_GATE_IDENT_DIR || [resolve(APP, '../ident.worldapi.org'), '/media/STORAGE/projects/ident.worldapi.org'].find(d => existsSync(join(d, 'project.hl')));rmSync(STORE, { recursive: true, force: true });mkdirSync(STORE, { 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));const J = JSON.stringify;let log = '';let server = null, ident = null;const browsers = [];function startServer(extraEnv = {}) {log += '\n==== notes server start\n';server = spawn(BIN, ['project.hl'], {cwd: APP,env: { ...process.env, NOTES_PORT: String(PORT), NOTES_STORAGE: join(STORE, 'mpackdb'), NOTES_SESSIONS: join(STORE, 'sessions') + '/', NOTES_WATCH: '0',NOTES_PUBLIC_URL: BASE, IDENT_URL: IDENT_B, IDENT_EXCHANGE_URL: ident.base, ...extraEnv },stdio: ['ignore', 'pipe', 'pipe'],});server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);}async function serverUp() {for (let i = 0; i < 80; i++) { try { const r = await fetch(API + '/'); if (r.ok) return; } catch {} await sleep(250); }throw new Error('notes did not come up\n' + log);}async function stopServer() {if (!server) return;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;}const SLOW = Number(process.env.NOTES_GATE_SLOW || 3);function patient(page) {const waitFor = page.waitFor.bind(page);page.waitFor = (expr, o = {}) => waitFor(expr, { ...o, timeout: (o.timeout || 10000) * SLOW });const goto = page.goto.bind(page);page.goto = (url, o = {}) => goto(url, { ...o, timeout: (o.timeout || 15000) * SLOW });return page;}// a page of a NEW Chrome (own cookies), or — given `like` — another tab of the same Chrome (same login)async function newPage(like = null) {if (like) return patient(await like.__browser.newPage());const b = await launchBrowser({ debugPortRange: [CH_FROM, CH_TO] });browsers.push(b);const p = patient(await b.newPage());p.__browser = b;return p;}async function viewport(page, width, height) {await page.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 600 });await sleep(250);}async function shot(page, name) {await page.send('Page.bringToFront');const { data } = await page.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });writeFileSync(join(SCRATCH, `gate-${name}.png`), Buffer.from(data, 'base64'));}const noOverflow = (page) => page.evaluate('document.documentElement.scrollWidth <= window.innerWidth');const hydrated = (page) => page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label: 'page hydrated' });const txt = (page, sel) => page.evaluate(`(document.querySelector(${J(sel)}) || {}).textContent || null`);const has = (page, sel) => page.evaluate(`!!document.querySelector(${J(sel)})`);const subjects = (page) => page.evaluate(`[...document.querySelectorAll('#notes a')].map(a => a.textContent)`);const visible = (page, sel) => page.evaluate(`(() => { const e = document.querySelector(${J(sel)}); return !!e && e.getBoundingClientRect().width > 0; })()`);async function identSignIn(page, email) {await page.goto(IDENT_B + '/');await page.waitForSelector('#email');await hydrated(page);await page.type('#email', email);await page.click('#sendcode');await page.waitFor('/\\/code$/.test(location.pathname) && !!document.querySelector("#code")', { label: 'ident /code page' });await hydrated(page);await page.type('#code', ident.lastCode(email));await page.click('#verify');await page.waitForSelector('#signout', { timeout: 10000 });}// the login button of the page we are on → ident's "choose an identity" → backasync function buttonLogin(page) {await page.click('#loginbutton');await page.waitForSelector('#chooselist', { timeout: 10000 });await hydrated(page);await page.click('#chooselist li:nth-child(1) .choose');}// ---- REAL clicks and keys into the editor's shadow DOM (as in tickets' gate) ----------------------------const mdR = (expr) => `(() => { const t = document.getElementById('notetext'); const h = t && t.closest('md-editor'); const r = h && h.shadowRoot; if (!r) return null; return (${expr}); })()`;const CTRL = 2;async function mdClick(page, sel) {const bx = await page.evaluate(mdR(`(() => { const el = r.querySelector(${J(sel)}); if (!el) return null; el.scrollIntoView({ block: 'center' }); const q = el.getBoundingClientRect(); return { x: q.left, y: q.top, w: q.width, h: q.height }; })()`));if (!bx || !bx.w) throw new Error('mdClick: ' + sel + ' is not laid out');const x = Math.round(bx.x + bx.w / 2), y = Math.round(bx.y + bx.h / 2);for (const [type, buttons] of [['mouseMoved', 0], ['mousePressed', 1], ['mouseReleased', 0]]) await page.send('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1, buttons });await sleep(80);}async function mdPress(page, key, mods = 0) {const K = { Enter: [13, 'Enter', '\r'], End: [35, 'End'] }[key];const [vk, code, text] = K;const t = text && !(mods & CTRL) ? text : undefined;await page.send('Input.dispatchKeyEvent', { type: t ? 'keyDown' : 'rawKeyDown', modifiers: mods, key, code, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk, ...(t ? { text: t, unmodifiedText: t } : {}) });await page.send('Input.dispatchKeyEvent', { type: 'keyUp', modifiers: mods, key, code, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk });await sleep(30);}async function mdType(page, text) {for (const ch of text) {await page.send('Input.dispatchKeyEvent', { type: 'keyDown', text: ch, unmodifiedText: ch, key: ch });await page.send('Input.dispatchKeyEvent', { type: 'keyUp', key: ch });}await sleep(60);}const taVal = (page) => page.evaluate(`document.getElementById('notetext').value`);const editorReady = (page, label) => page.waitFor(mdR(`!!r.querySelector('[part~=editor]')`), { label });const emitFace = async (event, payload, cookie) => {const r = await fetch(API + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: J({ t: 'emit', i: 1, event, payload }) });const t = await r.text(); let j = null; try { j = JSON.parse(t); } catch {}return { status: r.status, raw: t, value: j && j.value };};try {ident = await startIdent({ identDir: IDENT_DIR, workDir: join(STORE, 'ident'), port: IDENT_PORT });const alice = await ident.signIn('[email protected]');await ident.signIn('[email protected]');const APPKEY = await ident.registerApp(alice, 'notes (gate)', [BASE]);check('ident: our own ident runs (a copy without .env), notes is registered', /^pk_[0-9a-f]{32}$/.test(APPKEY.key) && !existsSync(join(STORE, 'ident', 'ident-code', '.env')));startServer({ IDENT_API_KEY: APPKEY.key, IDENT_API_SECRET: APPKEY.secret });await serverUp();// ---- signed out -------------------------------------------------------------------------------------let html = await (await fetch(API + '/')).text();check('signed out: the page only asks to log in (no list, no editor, no notes)', /Log in with ident/.test(html) && !/id="newnote"|<note-list|<md-editor|id="notesapp"/.test(html), html.slice(html.indexOf('<body')).slice(0, 300));let r = await fetch(API + '/login/callback', { redirect: 'manual' });check('login: /login/callback without a code → /login/failed', r.status === 302 && r.headers.get('location') === '/login/failed');r = await fetch(API + '/api/notes');check('signed out: the notes API answers 401', r.status === 401);r = await (await fetch(API + '/api/me')).json();check('signed out: /api/me says nobody', r.user === null, J(r));// ---- log in with ident (the button flow) on device A ----------------------------------------------------const a = await newPage();await viewport(a, 1280, 900);await identSignIn(a, '[email protected]');await a.goto(BASE + '/');await hydrated(a);check('signed out (browser): "Log in with ident" shows, no "New note"', await has(a, '#loginbutton') && !(await has(a, '#newnote')));await buttonLogin(a);await a.waitForSelector('#newnote', { timeout: 15000 });check('login: alice sees her (empty) list and "New note"', (await txt(a, '#empty')) !== null && (await subjects(a)).length === 0 && await has(a, '#logout'));const cookieOf = async (page) => (await page.cookies([BASE])).filter(c => c.name === 'notessid').map(c => 'notessid=' + c.value)[0];const cookieA = await cookieOf(a);const apiGet = async (path, cookie) => { const x = await fetch(API + path, { headers: { cookie } }); return { status: x.status, json: await x.json().catch(() => null) }; };const apiPost = async (body, cookie) => { const x = await fetch(API + '/api/notes', { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: typeof body === 'string' ? body : J(body) }); return { status: x.status, json: await x.json().catch(() => null) }; };const serverNotes = async (cookie) => {const list = (await apiGet('/api/notes', cookie)).json.notes.filter(n => !n.gone);const out = [];for (const n of list) out.push({ ...n, text: (await apiGet('/api/notes?id=' + n.id, cookie)).json.text });return out;};const untilServer = async (cookie, pred, label) => {for (let i = 0; i < 80; i++) { const ns = await serverNotes(cookie); if (pred(ns)) return ns; await sleep(250); }throw new Error('server never showed: ' + label + ' ' + J(await serverNotes(cookie)));};const syncNow = (page) => page.evaluate('window.__notesOffline.sync().then(() => true)');const shellKept = async (page) => { for (let i = 0; i < 80; i++) { if (await page.evaluate(`caches.open('notes-shell-v1').then(c => c.match('/shell')).then(x => !!x)`)) return; await sleep(250); } throw new Error('the page was never kept for offline use'); };await shellKept(a);check('offline: the service worker keeps the page and its assets', await a.evaluate(`caches.open('notes-shell-v1').then(c => c.keys()).then(k => k.map(x => new URL(x.url).pathname).sort().join(' '))`).then(v => /\/md-editor\.js/.test(v) && /\/notes-offline\.js/.test(v) && /\/shell/.test(v) && /app\.css|__hl/.test(v) ? true : v));// ---- THE INSTALLABLE APP (mission 046): hl:web's own manifest from project.hl appIcons — but NOT its service worker:// notes has no `offline = [...]`, its own /sw.js (registered by notes-offline.js) stays the one worker for scope /.const pwaHtml = await (await fetch(API + '/')).text();const mhref = (pwaHtml.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 header token `darker`)',mhref === '/__hl/manifest.webmanifest' && pwaHtml.includes('<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">')&& pwaHtml.includes('<link rel="icon" href="/icons/favicon.svg">') && pwaHtml.includes('<meta name="theme-color" content="rgb(15, 20, 25)">'), pwaHtml.slice(0, 900));const mres = await fetch(API + mhref);const man = await mres.json();check('pwa: the manifest is served as application/manifest+json: name "notes", start_url /, scope /, display standalone, the token colours',/manifest\+json/.test(mres.headers.get('content-type') || '') && man.name === 'notes' && man.short_name === 'notes' && man.start_url === '/' && man.scope === '/'&& man.display === 'standalone' && man.theme_color === 'rgb(15, 20, 25)' && man.background_color === 'rgb(25, 30, 35)', J(man));const iconOk = [];for (const ic of man.icons || []) {const ir = await fetch(API + ic.src);const buf = Buffer.from(await ir.arrayBuffer());iconOk.push(ir.ok && ic.type === 'image/png' && buf.readUInt32BE(16) === Number(ic.sizes.split('x')[0]) && buf.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))), J(man.icons) + ' ' + J(iconOk));const touch = await fetch(API + '/icons/apple-touch-icon.png');const touchBytes = Buffer.from(await touch.arrayBuffer());const fav = await fetch(API + '/icons/favicon.svg');const favText = await fav.text();const ico = await fetch(API + '/favicon.ico');const icoBytes = Buffer.from(await ico.arrayBuffer());check('pwa: apple-touch-icon is a 180×180 PNG, the favicon an SVG in notes yellow, /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('#dcdcaa')&& ico.ok && icoBytes.readUInt32BE(0) === 0x00000100, `${touch.status} ${fav.status} ${ico.status}`);const am = await a.send('Page.getAppManifest');check('pwa: Chrome parses the manifest without errors', am.url && am.url.endsWith('/__hl/manifest.webmanifest') && (am.errors || []).length === 0, J(am.errors));await a.waitFor('!!navigator.serviceWorker.controller', { label: 'page controlled by /sw.js' });const inst = await a.send('Page.getInstallabilityErrors');check('pwa: Chrome reports no installability error', (inst.installabilityErrors || []).length === 0, J(inst));const regs = await a.evaluate(`navigator.serviceWorker.getRegistrations().then(rs => rs.map(r => new URL(r.scope).pathname + ' ' + new URL((r.active || r.waiting || r.installing).scriptURL).pathname).join(','))`);check("pwa: exactly one service worker — notes' own /sw.js for scope /, it controls the page; hl:web serves none (/__hl/sw.js 404)",regs === '/ /sw.js' && new URL(await a.evaluate('navigator.serviceWorker.controller.scriptURL')).pathname === '/sw.js' && (await fetch(API + '/__hl/sw.js')).status === 404, regs);// ---- new note: heading and text; saved on the server ------------------------------------------------------await a.click('#newnote');await a.waitFor(`/^\\/note\\/l[0-9a-f]+$/.test(location.pathname)`, { label: 'the new note is open' });await a.waitForSelector('#notetext');await editorReady(a, 'editor on the new note');check('new note: opens on the right, the sidebar lists it as "Untitled note"', (await subjects(a)).join('|') === 'Untitled note' && await visible(a, 'note-list') && await visible(a, 'note-editor'), J(await subjects(a)));await mdClick(a, '[part~=editor]');await mdType(a, '# Groceries');await mdPress(a, 'Enter');await mdType(a, 'milk and bread');await a.waitFor(`document.querySelector('#notes a').textContent === 'Groceries'`, { label: 'sidebar shows the heading as the subject' });const t1 = await taVal(a);check('editor: "# " became a heading, the textarea holds the Markdown', t1 === '# Groceries\n\nmilk and bread' || t1 === '# Groceries\nmilk and bread', J(t1));check('editor: the heading is formatted (h1 in the editor)', await a.evaluate(mdR(`!!r.querySelector('[part~=editor] h1')`)));let sn = await untilServer(cookieA, ns => ns.length === 1 && /^# Groceries/.test(ns[0].text) && /milk and bread$/.test(ns[0].text), 'Groceries');check('online: the note reached the server, which gave it its own id', /^[0-9a-z]{12}$/.test(sn[0].id), J(sn));await a.waitFor(`location.pathname === '/note/${sn[0].id}'`, { label: 'the address follows the server id' });const firstUrl = '/note/' + sn[0].id;await shot(a, 'desktop-one-note');// ---- a second note; the edited one goes to the top ------------------------------------------------------------await a.click('#newnote');await a.waitFor(`/^\\/note\\/l/.test(location.pathname)`, { label: 'second note' });await a.waitFor(`document.getElementById('notetext') && document.getElementById('notetext').value === ''`, { label: 'the second note is empty' });await editorReady(a, 'editor on the second note');await mdClick(a, '[part~=editor]');await mdType(a, 'Zebra facts');await a.waitFor(`document.querySelector('#notes a').textContent === 'Zebra facts'`, { label: 'Zebra on top' });check('sidebar: two notes, the newest edited on top', (await subjects(a)).join('|') === 'Zebra facts|Groceries', J(await subjects(a)));await untilServer(cookieA, ns => ns.length === 2, 'two notes');await a.click(`#notes a[href="${firstUrl}"]`);await a.waitFor(`document.getElementById('notetext').value.startsWith('# Groceries')`, { label: 'Groceries text loaded' });check('sidebar click: the note opens on the right with its text', location_ok(await a.evaluate('location.pathname'), firstUrl));function location_ok(x, y) { return x === y; }// ---- device B (another browser, same login) gets the notes -------------------------------------------------------const b = await newPage();await viewport(b, 1280, 900);await identSignIn(b, '[email protected]');await b.goto(BASE + '/');await hydrated(b);await buttonLogin(b);await b.waitForSelector('#newnote', { timeout: 15000 });await b.waitFor(`document.querySelectorAll('#notes a').length === 2`, { label: 'device B pulled the notes' });check('second device: the same two notes appear after login', (await subjects(b)).join('|') === 'Zebra facts|Groceries', J(await subjects(b)));await b.goto(BASE + firstUrl);await b.waitForSelector('#notetext');await b.waitFor(`document.getElementById('notetext').value.startsWith('# Groceries')`, { label: 'B opened Groceries by its address' });check('second device: a note address opens the note', true);const cookieB = await cookieOf(b);await shellKept(b);// ---- OFFLINE on A: the server is stopped (a real outage) ---------------------------------------------------------await stopServer();await a.goto(BASE + firstUrl);await a.waitForSelector('#notetext', { timeout: 20000 });await a.waitFor(`document.getElementById('notetext').value.startsWith('# Groceries')`, { label: 'offline: the note opens from the browser copy' });check('offline: the page reloads with no server and shows the note and the list', (await subjects(a)).join('|') === 'Zebra facts|Groceries', J(await subjects(a)));await editorReady(a, 'offline editor');await mdClick(a, '[part~=editor] p:last-child');await mdPress(a, 'End', CTRL);await mdType(a, ' and eggs');await a.waitFor(`document.querySelector('#notes a').textContent === 'Groceries'`, { label: 'offline edit moves the note to the top' });await a.click('#newnote');await a.waitFor(`/^\\/note\\/l/.test(location.pathname)`, { label: 'offline: new note' });await editorReady(a, 'offline editor 2');await mdClick(a, '[part~=editor]');await mdType(a, 'Written offline');await a.waitFor(`document.querySelector('#notes a').textContent === 'Written offline'`, { label: 'offline note in the list' });await sleep(1500);await a.waitFor(`/Offline/.test((document.getElementById('syncstate') || {}).textContent || '')`, { label: 'the page says it is offline' });check('offline: the sidebar says the changes are kept here', true);await a.goto(BASE + '/');await a.waitForSelector('#notes a');await a.waitFor(`/Offline/.test((document.getElementById('syncstate') || {}).textContent || '')`, { label: 'offline / says it is offline' });check('offline: a reload of / with the server gone opens the shell (header, brand, logout, the list) and says it is offline (mission 046)',(await txt(a, 'application-header .brand')) === 'notes' && await has(a, '#logout') && await has(a, 'note-list') && /Offline/.test(await txt(a, '#syncstate')));await viewport(a, 390, 844);await shot(a, 'pwa-phone-offline');await viewport(a, 1280, 900);check('offline: after another reload the offline edits are all there', (await subjects(a)).join('|') === 'Written offline|Groceries|Zebra facts', J(await subjects(a)));await a.click('#notes li:nth-child(2) a');await a.waitFor(`document.getElementById('notetext').value.endsWith('milk and bread and eggs')`, { label: 'the offline edit is in the note' });check('offline: the edited text survived the reload', true);// ---- back online: it reaches the server, and device B --------------------------------------------------------------startServer({ IDENT_API_KEY: APPKEY.key, IDENT_API_SECRET: APPKEY.secret });await serverUp();await syncNow(a);sn = await untilServer(cookieA, ns => ns.length === 3 && ns.some(n => /milk and bread and eggs$/.test(n.text)) && ns.some(n => n.text === 'Written offline'), 'the offline edits');check('back online: the offline edit and the offline note reached the server', true, J(sn));await a.waitFor(`[...document.querySelectorAll('#notes a')].every(x => !/\\/note\\/l/.test(x.getAttribute('href')))`, { label: 'the offline note got a server id' });check('back online: the offline note now has a server id', true);await syncNow(b);await b.waitFor(`document.querySelectorAll('#notes a').length === 3`, { label: 'B got the offline note' });await b.waitFor(`document.getElementById('notetext').value.endsWith('and eggs')`, { label: 'B shows the edit in the open note' });check('second device: sees the edit made offline on the first (open note refreshed) and the new note', (await subjects(b)).join('|') === 'Written offline|Groceries|Zebra facts', J(await subjects(b)));// ---- the same note edited on two devices while both are offline: nothing is lost -----------------------------------await b.click('#notes li:nth-child(3) a');await b.waitFor(`document.getElementById('notetext').value === 'Zebra facts'`, { label: 'B opened Zebra' });await a.click('#notes li:nth-child(3) a');await a.waitFor(`document.getElementById('notetext').value === 'Zebra facts'`, { label: 'A opened Zebra' });await stopServer();await editorReady(a, 'A zebra editor');await mdClick(a, '[part~=editor] p:last-child');await mdPress(a, 'End', CTRL);await mdType(a, ' on A');await sleep(1200);await editorReady(b, 'B zebra editor');await mdClick(b, '[part~=editor] p:last-child');await mdPress(b, 'End', CTRL);await mdType(b, ' on B');await sleep(300);startServer({ IDENT_API_KEY: APPKEY.key, IDENT_API_SECRET: APPKEY.secret });await serverUp();await syncNow(a);await syncNow(b);sn = await untilServer(cookieA, ns => ns.length === 4, 'the conflict copy');const texts = sn.map(n => n.text);check('conflict: the later edit (B) is the note, the other (A) is kept as a copy — nothing lost', texts.includes('Zebra facts on B') && texts.some(t => t.startsWith('Zebra facts on A') && /kept as a copy/.test(t)), J(texts));await syncNow(a);await a.waitFor(`document.querySelectorAll('#notes a').length === 4`, { label: 'A shows the copy' });check('conflict: both devices end with the same four notes', (await subjects(a)).slice().sort().join('|') === (await subjects(b)).slice().sort().join('|'), J(await subjects(a)) + J(await subjects(b)));// ---- delete on A reaches B -------------------------------------------------------------------------------------------------await a.click('#notes li:nth-child(1) a');await a.waitFor(`!!document.getElementById('notetext')`, { label: 'A opened a note to delete' });const doomed = await a.evaluate('document.getElementById("notetext").value');check('delete: the question is hidden until "Delete note" is clicked', !(await visible(a, '#deleteyes')));await a.click('#delete');await a.waitFor(`document.querySelector('#deleteyes').getBoundingClientRect().width > 0`, { label: 'delete question' });await a.click('#deleteno');await a.waitFor(`document.querySelector('#deleteyes').getBoundingClientRect().width === 0`, { label: 'kept' });check('delete: "Keep" changes nothing', (await subjects(a)).length === 4);await a.click('#delete');await a.waitFor(`document.querySelector('#deleteyes').getBoundingClientRect().width > 0`, { label: 'delete question again' });await a.click('#deleteyes');await a.waitFor(`location.pathname === '/' && document.querySelectorAll('#notes a').length === 3`, { label: 'back to the list, one note less' });check('delete: the note is gone from the list and the page is the list again', await has(a, '#pick'));await untilServer(cookieA, ns => ns.length === 3 && !ns.some(n => n.text === doomed), 'the delete');await syncNow(b);await b.waitFor(`document.querySelectorAll('#notes a').length === 3`, { label: 'B follows the delete' });check('delete: the other device follows', true);// ---- somebody else sees nothing of it ----------------------------------------------------------------------------------------const bob = await newPage();await viewport(bob, 1280, 900);await identSignIn(bob, '[email protected]');await bob.goto(BASE + '/');await hydrated(bob);await buttonLogin(bob);await bob.waitForSelector('#newnote', { timeout: 15000 });await sleep(800);check('another user: an empty list', (await subjects(bob)).length === 0);const cookieBob = await cookieOf(bob);const alicePick = (await serverNotes(cookieA))[0];const aliceId = alicePick.id;check("another user: alice's note is not in bob's API", (await apiGet('/api/notes?id=' + aliceId, cookieBob)).status === 404 && (await apiGet('/api/notes', cookieBob)).json.notes.length === 0);await apiPost({ op: 'save', id: aliceId, local: '', text: 'hacked', base: 0, at: 1 }, cookieBob);await apiPost({ op: 'delete', id: aliceId, base: 0 }, cookieBob);check("another user: saving over / deleting alice's note does not touch it", (await serverNotes(cookieA)).some(n => n.id === aliceId && n.text === alicePick.text));check('a note over the size limit is refused, not cut', (await apiPost({ op: 'save', id: '', local: 'x1', text: 'x'.repeat(200001), base: 0, at: 1 }, cookieA)).status === 400);check('the API refuses invalid JSON and wrong fields with 400', (await apiPost('{nope', cookieA)).status === 400 && (await apiPost({ op: 'save', id: 5 }, cookieA)).status === 400 && (await apiPost({ op: 'zap' }, cookieA)).status === 400);// a retried push (the answer was lost) does not make a second noteawait apiPost({ op: 'save', id: '', local: 'lretry', text: 'retry me', base: 0, at: 5 }, cookieA);await apiPost({ op: 'save', id: '', local: 'lretry', text: 'retry me', base: 0, at: 5 }, cookieA);check('a push repeated after a lost answer makes no second note', (await serverNotes(cookieA)).filter(n => n.text === 'retry me').length === 1);// ---- phone and desktop layout ----------------------------------------------------------------------------------------------------await a.goto(BASE + '/');await a.waitForSelector('#notes a');await viewport(a, 390, 844);check('phone: the list alone, no note', await visible(a, 'note-list') && !(await visible(a, 'note-reader')) && await noOverflow(a));await shot(a, 'phone-list');await a.click('#notes a');await a.waitForSelector('#notetext');await editorReady(a, 'phone editor');check('phone: the opened note replaces the list, with a way back', !(await visible(a, 'note-list')) && await visible(a, 'note-editor') && await visible(a, '.back') && await noOverflow(a));await shot(a, 'phone-note');await a.click('.back');await a.waitFor(`location.pathname === '/' && document.querySelector('note-list').getBoundingClientRect().width > 0`, { label: 'phone: back to the list' });check('phone: "All notes" brings the list back', await visible(a, 'note-list') && !(await visible(a, 'note-reader')));await viewport(a, 1280, 900);await a.click('#notes a');await editorReady(a, 'desktop editor');check('desktop: list and note side by side, no "All notes" link', await visible(a, 'note-list') && await visible(a, 'note-editor') && !(await visible(a, '.back')) && await noOverflow(a));await shot(a, 'desktop-two');// ---- log out: the browser forgets the notes ---------------------------------------------------------------------------------------await a.click('#logout');await a.waitForSelector('#loginhint');check('log out: the notes are gone from the page, the login hint shows', !(await has(a, '#notes')) && await has(a, '#loginbutton'));await sleep(500);check('log out: this browser deleted its copy of the notes', await a.evaluate(`(async () => (await indexedDB.databases()).filter(d => /^notes-/.test(d.name)).length === 0 && !localStorage.getItem('notes.user'))()`));check('the server logged no error', !/error|Error/.test(log.replace(/error absorbed/g, '')), log.slice(-400));} catch (e) {console.log('FAIL gate crashed — ' + (e && e.stack || e));failures++;console.log(log.slice(-1500));} finally {for (const b of browsers) { try { await b.close(); } catch {} }await stopServer();if (ident) await ident.stop();console.log(`${passes} passed, ${failures} failed`);process.exit(failures ? 1 : 0);}
Branches
- mainmain branch
Latest commits
- 2149e902notes mission 002 (4/4): code order — README file map + same-output test, STATUS, LOG, report; tests/letcount.py, tests/realdata-baseline.mjs, tests/realdata-compare.pymre
- 47db4fc1notes mission 002 (3/4): code order — let only where reassigned (58 dropped; 34 left: 19 reassigned, 15 loop-bound); gates 50/0 + 18/0, live-data run = step 2mre
- 3f8cb383notes mission 002 (2/4): code order — topics, map, thin wrappers: lib/util.hl, lib/notes(-helpers).hl, lib/users.hl (+userOfLoginCode, tagOf), lib/api(-helpers).hl; project.hl = map; login routes take &req/&sessions (failed-login reason now kept); dead notes#1 functions removed; gates 50/0 + 18/0mre
- 8cda5412notes mission 002 (1/4): code order — files moved: lib/notes.hl, lib/users.hl, lib/jsoncheck.hl, components/styles.hl (imports only); gates 50/0 + 18/0, live-data run identicalmre
- 47dad68bnotes: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); gates 50/0 + 18/0mre
- a4a2b2aeantcolony#40: tracker missions moved too — references to them in missions/reports/LOG.md updatedmre
- 3dea0ef2notes: Hybriel master 190aa11d (fc838894 GC correctness, #126 closure scopes, #127); gate 50/0mre
- e27c7d71notes: Hybriel master 8efba065 (#126 GC by bytes, #48 lambda params copy; audit: nothing to fix; gate 50/0)mre
- 124613b6antcolony#40: mission references point to the moved missionsmre
- 1ca2f34dantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
- 2bebebdanotes: Hybriel master ff51cf46 (re-vendor round)mre
- 3eff126dnotes#3: installable app (manifest + own icon/favicon; notes' own sw.js kept)mre
- 9883c540deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
- eee693b8deploy.sh: never send .git or .gitignore to Byrodinmre
- c8904061State of 2026-09-27, before the move to gitoriamre