gitoriaLog in with ident

notes

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit9883c5409883c540deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre9883c540/tests/browser.mjs

26.4 KB

  1. // tests/browser.mjs — THE GATE of notes.worldapi.org (tickets notes#1: sidebar + Markdown editor; notes#2: offline + sync).
  2. // Its own notes server on its own storage, its OWN ident (a copy of ident's code without .env — codes go to a mail
  3. // sink, tests/identkit.mjs), real headless Chromes for everything visible. `*.notes.test` is mapped to 127.0.0.1
  4. // inside Chrome (--host-resolver-rules) so ident and the app are the same site, as on worldapi.org.
  5. //
  6. // node tests/browser.mjs (NOTES_GATE_PORT server, default 8702; NOTES_GATE_IDENT_PORT, default 8701;
  7. // NOTES_GATE_CHROME "8713-8719" = Chrome debug ports)
  8. // Screenshots (390 / 1280 px) land in .scratch/gate-*.png. Every process started is stopped by PID.
  9. import { spawn } from 'node:child_process';
  10. import { rmSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
  11. import { dirname, join, resolve } from 'node:path';
  12. import { fileURLToPath } from 'node:url';
  13. import { launchBrowser } from './cdp.mjs';
  14. import { startIdent } from './identkit.mjs';
  15. if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';
  16. const HERE = dirname(fileURLToPath(import.meta.url));
  17. const APP = resolve(HERE, '..');
  18. const BIN = join(APP, 'bin/hybriel');
  19. const PORT = Number(process.env.NOTES_GATE_PORT || 8702);
  20. const IDENT_PORT = Number(process.env.NOTES_GATE_IDENT_PORT || 8701);
  21. const API = `http://127.0.0.1:${PORT}`;
  22. const BASE = `http://notes.test:${PORT}`;
  23. const IDENT_B = `http://ident.notes.test:${IDENT_PORT}`;
  24. const [CH_FROM, CH_TO] = (process.env.NOTES_GATE_CHROME || '8703-8709').split('-').map(Number);
  25. 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)
  26. const SCRATCH = join(APP, '.scratch');
  27. const STORE = join(SCRATCH, 'gate-store');
  28. 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')));
  29. rmSync(STORE, { recursive: true, force: true });
  30. mkdirSync(STORE, { recursive: true });
  31. let failures = 0, passes = 0;
  32. function check(label, ok, detail = '') {
  33. console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`);
  34. if (ok) passes++; else failures++;
  35. }
  36. const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  37. const J = JSON.stringify;
  38. let log = '';
  39. let server = null, ident = null;
  40. const browsers = [];
  41. function startServer(extraEnv = {}) {
  42. log += '\n==== notes server start\n';
  43. server = spawn(BIN, ['project.hl'], {
  44. cwd: APP,
  45. env: { ...process.env, NOTES_PORT: String(PORT), NOTES_STORAGE: join(STORE, 'mpackdb'), NOTES_SESSIONS: join(STORE, 'sessions') + '/', NOTES_WATCH: '0',
  46. NOTES_PUBLIC_URL: BASE, IDENT_URL: IDENT_B, IDENT_EXCHANGE_URL: ident.base, ...extraEnv },
  47. stdio: ['ignore', 'pipe', 'pipe'],
  48. });
  49. server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);
  50. }
  51. async function serverUp() {
  52. for (let i = 0; i < 80; i++) { try { const r = await fetch(API + '/'); if (r.ok) return; } catch {} await sleep(250); }
  53. throw new Error('notes did not come up\n' + log);
  54. }
  55. async function stopServer() {
  56. if (!server) return;
  57. try { server.kill('SIGTERM'); } catch {}
  58. await new Promise(r => { if (server.exitCode !== null || server.signalCode !== null) return r(); server.once('exit', r); setTimeout(r, 3000); });
  59. server = null;
  60. }
  61. const SLOW = Number(process.env.NOTES_GATE_SLOW || 3);
  62. function patient(page) {
  63. const waitFor = page.waitFor.bind(page);
  64. page.waitFor = (expr, o = {}) => waitFor(expr, { ...o, timeout: (o.timeout || 10000) * SLOW });
  65. const goto = page.goto.bind(page);
  66. page.goto = (url, o = {}) => goto(url, { ...o, timeout: (o.timeout || 15000) * SLOW });
  67. return page;
  68. }
  69. // a page of a NEW Chrome (own cookies), or — given `like` — another tab of the same Chrome (same login)
  70. async function newPage(like = null) {
  71. if (like) return patient(await like.__browser.newPage());
  72. const b = await launchBrowser({ debugPortRange: [CH_FROM, CH_TO] });
  73. browsers.push(b);
  74. const p = patient(await b.newPage());
  75. p.__browser = b;
  76. return p;
  77. }
  78. async function viewport(page, width, height) {
  79. await page.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 600 });
  80. await sleep(250);
  81. }
  82. async function shot(page, name) {
  83. await page.send('Page.bringToFront');
  84. const { data } = await page.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
  85. writeFileSync(join(SCRATCH, `gate-${name}.png`), Buffer.from(data, 'base64'));
  86. }
  87. const noOverflow = (page) => page.evaluate('document.documentElement.scrollWidth <= window.innerWidth');
  88. const hydrated = (page) => page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label: 'page hydrated' });
  89. const txt = (page, sel) => page.evaluate(`(document.querySelector(${J(sel)}) || {}).textContent || null`);
  90. const has = (page, sel) => page.evaluate(`!!document.querySelector(${J(sel)})`);
  91. const subjects = (page) => page.evaluate(`[...document.querySelectorAll('#notes a')].map(a => a.textContent)`);
  92. const visible = (page, sel) => page.evaluate(`(() => { const e = document.querySelector(${J(sel)}); return !!e && e.getBoundingClientRect().width > 0; })()`);
  93. async function identSignIn(page, email) {
  94. await page.goto(IDENT_B + '/');
  95. await page.waitForSelector('#email');
  96. await hydrated(page);
  97. await page.type('#email', email);
  98. await page.click('#sendcode');
  99. await page.waitFor('/\\/code$/.test(location.pathname) && !!document.querySelector("#code")', { label: 'ident /code page' });
  100. await hydrated(page);
  101. await page.type('#code', ident.lastCode(email));
  102. await page.click('#verify');
  103. await page.waitForSelector('#signout', { timeout: 10000 });
  104. }
  105. // the login button of the page we are on → ident's "choose an identity" → back
  106. async function buttonLogin(page) {
  107. await page.click('#loginbutton');
  108. await page.waitForSelector('#chooselist', { timeout: 10000 });
  109. await hydrated(page);
  110. await page.click('#chooselist li:nth-child(1) .choose');
  111. }
  112. // ---- REAL clicks and keys into the editor's shadow DOM (as in tickets' gate) ----------------------------
  113. 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}); })()`;
  114. const CTRL = 2;
  115. async function mdClick(page, sel) {
  116. 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 }; })()`));
  117. if (!bx || !bx.w) throw new Error('mdClick: ' + sel + ' is not laid out');
  118. const x = Math.round(bx.x + bx.w / 2), y = Math.round(bx.y + bx.h / 2);
  119. for (const [type, buttons] of [['mouseMoved', 0], ['mousePressed', 1], ['mouseReleased', 0]]) await page.send('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1, buttons });
  120. await sleep(80);
  121. }
  122. async function mdPress(page, key, mods = 0) {
  123. const K = { Enter: [13, 'Enter', '\r'], End: [35, 'End'] }[key];
  124. const [vk, code, text] = K;
  125. const t = text && !(mods & CTRL) ? text : undefined;
  126. await page.send('Input.dispatchKeyEvent', { type: t ? 'keyDown' : 'rawKeyDown', modifiers: mods, key, code, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk, ...(t ? { text: t, unmodifiedText: t } : {}) });
  127. await page.send('Input.dispatchKeyEvent', { type: 'keyUp', modifiers: mods, key, code, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk });
  128. await sleep(30);
  129. }
  130. async function mdType(page, text) {
  131. for (const ch of text) {
  132. await page.send('Input.dispatchKeyEvent', { type: 'keyDown', text: ch, unmodifiedText: ch, key: ch });
  133. await page.send('Input.dispatchKeyEvent', { type: 'keyUp', key: ch });
  134. }
  135. await sleep(60);
  136. }
  137. const taVal = (page) => page.evaluate(`document.getElementById('notetext').value`);
  138. const editorReady = (page, label) => page.waitFor(mdR(`!!r.querySelector('[part~=editor]')`), { label });
  139. const emitFace = async (event, payload, cookie) => {
  140. const r = await fetch(API + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: J({ t: 'emit', i: 1, event, payload }) });
  141. const t = await r.text(); let j = null; try { j = JSON.parse(t); } catch {}
  142. return { status: r.status, raw: t, value: j && j.value };
  143. };
  144. try {
  145. ident = await startIdent({ identDir: IDENT_DIR, workDir: join(STORE, 'ident'), port: IDENT_PORT });
  146. const alice = await ident.signIn('[email protected]');
  147. await ident.signIn('[email protected]');
  148. const APPKEY = await ident.registerApp(alice, 'notes (gate)', [BASE]);
  149. 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')));
  150. startServer({ IDENT_API_KEY: APPKEY.key, IDENT_API_SECRET: APPKEY.secret });
  151. await serverUp();
  152. // ---- signed out -------------------------------------------------------------------------------------
  153. let html = await (await fetch(API + '/')).text();
  154. 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));
  155. let r = await fetch(API + '/login/callback', { redirect: 'manual' });
  156. check('login: /login/callback without a code → /login/failed', r.status === 302 && r.headers.get('location') === '/login/failed');
  157. r = await fetch(API + '/api/notes');
  158. check('signed out: the notes API answers 401', r.status === 401);
  159. r = await (await fetch(API + '/api/me')).json();
  160. check('signed out: /api/me says nobody', r.user === null, J(r));
  161. // ---- log in with ident (the button flow) on device A ----------------------------------------------------
  162. const a = await newPage();
  163. await viewport(a, 1280, 900);
  164. await identSignIn(a, '[email protected]');
  165. await a.goto(BASE + '/');
  166. await hydrated(a);
  167. check('signed out (browser): "Log in with ident" shows, no "New note"', await has(a, '#loginbutton') && !(await has(a, '#newnote')));
  168. await buttonLogin(a);
  169. await a.waitForSelector('#newnote', { timeout: 15000 });
  170. check('login: alice sees her (empty) list and "New note"', (await txt(a, '#empty')) !== null && (await subjects(a)).length === 0 && await has(a, '#logout'));
  171. const cookieOf = async (page) => (await page.cookies([BASE])).filter(c => c.name === 'notessid').map(c => 'notessid=' + c.value)[0];
  172. const cookieA = await cookieOf(a);
  173. const apiGet = async (path, cookie) => { const x = await fetch(API + path, { headers: { cookie } }); return { status: x.status, json: await x.json().catch(() => null) }; };
  174. 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) }; };
  175. const serverNotes = async (cookie) => {
  176. const list = (await apiGet('/api/notes', cookie)).json.notes.filter(n => !n.gone);
  177. const out = [];
  178. for (const n of list) out.push({ ...n, text: (await apiGet('/api/notes?id=' + n.id, cookie)).json.text });
  179. return out;
  180. };
  181. const untilServer = async (cookie, pred, label) => {
  182. for (let i = 0; i < 80; i++) { const ns = await serverNotes(cookie); if (pred(ns)) return ns; await sleep(250); }
  183. throw new Error('server never showed: ' + label + ' ' + J(await serverNotes(cookie)));
  184. };
  185. const syncNow = (page) => page.evaluate('window.__notesOffline.sync().then(() => true)');
  186. 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'); };
  187. await shellKept(a);
  188. 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));
  189. // ---- new note: heading and text; saved on the server ------------------------------------------------------
  190. await a.click('#newnote');
  191. await a.waitFor(`/^\\/note\\/l[0-9a-f]+$/.test(location.pathname)`, { label: 'the new note is open' });
  192. await a.waitForSelector('#notetext');
  193. await editorReady(a, 'editor on the new note');
  194. 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)));
  195. await mdClick(a, '[part~=editor]');
  196. await mdType(a, '# Groceries');
  197. await mdPress(a, 'Enter');
  198. await mdType(a, 'milk and bread');
  199. await a.waitFor(`document.querySelector('#notes a').textContent === 'Groceries'`, { label: 'sidebar shows the heading as the subject' });
  200. const t1 = await taVal(a);
  201. check('editor: "# " became a heading, the textarea holds the Markdown', t1 === '# Groceries\n\nmilk and bread' || t1 === '# Groceries\nmilk and bread', J(t1));
  202. check('editor: the heading is formatted (h1 in the editor)', await a.evaluate(mdR(`!!r.querySelector('[part~=editor] h1')`)));
  203. let sn = await untilServer(cookieA, ns => ns.length === 1 && /^# Groceries/.test(ns[0].text) && /milk and bread$/.test(ns[0].text), 'Groceries');
  204. check('online: the note reached the server, which gave it its own id', /^[0-9a-z]{12}$/.test(sn[0].id), J(sn));
  205. await a.waitFor(`location.pathname === '/note/${sn[0].id}'`, { label: 'the address follows the server id' });
  206. const firstUrl = '/note/' + sn[0].id;
  207. await shot(a, 'desktop-one-note');
  208. // ---- a second note; the edited one goes to the top ------------------------------------------------------------
  209. await a.click('#newnote');
  210. await a.waitFor(`/^\\/note\\/l/.test(location.pathname)`, { label: 'second note' });
  211. await a.waitFor(`document.getElementById('notetext') && document.getElementById('notetext').value === ''`, { label: 'the second note is empty' });
  212. await editorReady(a, 'editor on the second note');
  213. await mdClick(a, '[part~=editor]');
  214. await mdType(a, 'Zebra facts');
  215. await a.waitFor(`document.querySelector('#notes a').textContent === 'Zebra facts'`, { label: 'Zebra on top' });
  216. check('sidebar: two notes, the newest edited on top', (await subjects(a)).join('|') === 'Zebra facts|Groceries', J(await subjects(a)));
  217. await untilServer(cookieA, ns => ns.length === 2, 'two notes');
  218. await a.click(`#notes a[href="${firstUrl}"]`);
  219. await a.waitFor(`document.getElementById('notetext').value.startsWith('# Groceries')`, { label: 'Groceries text loaded' });
  220. check('sidebar click: the note opens on the right with its text', location_ok(await a.evaluate('location.pathname'), firstUrl));
  221. function location_ok(x, y) { return x === y; }
  222. // ---- device B (another browser, same login) gets the notes -------------------------------------------------------
  223. const b = await newPage();
  224. await viewport(b, 1280, 900);
  225. await identSignIn(b, '[email protected]');
  226. await b.goto(BASE + '/');
  227. await hydrated(b);
  228. await buttonLogin(b);
  229. await b.waitForSelector('#newnote', { timeout: 15000 });
  230. await b.waitFor(`document.querySelectorAll('#notes a').length === 2`, { label: 'device B pulled the notes' });
  231. check('second device: the same two notes appear after login', (await subjects(b)).join('|') === 'Zebra facts|Groceries', J(await subjects(b)));
  232. await b.goto(BASE + firstUrl);
  233. await b.waitForSelector('#notetext');
  234. await b.waitFor(`document.getElementById('notetext').value.startsWith('# Groceries')`, { label: 'B opened Groceries by its address' });
  235. check('second device: a note address opens the note', true);
  236. const cookieB = await cookieOf(b);
  237. await shellKept(b);
  238. // ---- OFFLINE on A: the server is stopped (a real outage) ---------------------------------------------------------
  239. await stopServer();
  240. await a.goto(BASE + firstUrl);
  241. await a.waitForSelector('#notetext', { timeout: 20000 });
  242. await a.waitFor(`document.getElementById('notetext').value.startsWith('# Groceries')`, { label: 'offline: the note opens from the browser copy' });
  243. 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)));
  244. await editorReady(a, 'offline editor');
  245. await mdClick(a, '[part~=editor] p:last-child');
  246. await mdPress(a, 'End', CTRL);
  247. await mdType(a, ' and eggs');
  248. await a.waitFor(`document.querySelector('#notes a').textContent === 'Groceries'`, { label: 'offline edit moves the note to the top' });
  249. await a.click('#newnote');
  250. await a.waitFor(`/^\\/note\\/l/.test(location.pathname)`, { label: 'offline: new note' });
  251. await editorReady(a, 'offline editor 2');
  252. await mdClick(a, '[part~=editor]');
  253. await mdType(a, 'Written offline');
  254. await a.waitFor(`document.querySelector('#notes a').textContent === 'Written offline'`, { label: 'offline note in the list' });
  255. await sleep(1500);
  256. await a.waitFor(`/Offline/.test((document.getElementById('syncstate') || {}).textContent || '')`, { label: 'the page says it is offline' });
  257. check('offline: the sidebar says the changes are kept here', true);
  258. await a.goto(BASE + '/');
  259. await a.waitForSelector('#notes a');
  260. check('offline: after another reload the offline edits are all there', (await subjects(a)).join('|') === 'Written offline|Groceries|Zebra facts', J(await subjects(a)));
  261. await a.click('#notes li:nth-child(2) a');
  262. await a.waitFor(`document.getElementById('notetext').value.endsWith('milk and bread and eggs')`, { label: 'the offline edit is in the note' });
  263. check('offline: the edited text survived the reload', true);
  264. // ---- back online: it reaches the server, and device B --------------------------------------------------------------
  265. startServer({ IDENT_API_KEY: APPKEY.key, IDENT_API_SECRET: APPKEY.secret });
  266. await serverUp();
  267. await syncNow(a);
  268. 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');
  269. check('back online: the offline edit and the offline note reached the server', true, J(sn));
  270. await a.waitFor(`[...document.querySelectorAll('#notes a')].every(x => !/\\/note\\/l/.test(x.getAttribute('href')))`, { label: 'the offline note got a server id' });
  271. check('back online: the offline note now has a server id', true);
  272. await syncNow(b);
  273. await b.waitFor(`document.querySelectorAll('#notes a').length === 3`, { label: 'B got the offline note' });
  274. await b.waitFor(`document.getElementById('notetext').value.endsWith('and eggs')`, { label: 'B shows the edit in the open note' });
  275. 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)));
  276. // ---- the same note edited on two devices while both are offline: nothing is lost -----------------------------------
  277. await b.click('#notes li:nth-child(3) a');
  278. await b.waitFor(`document.getElementById('notetext').value === 'Zebra facts'`, { label: 'B opened Zebra' });
  279. await a.click('#notes li:nth-child(3) a');
  280. await a.waitFor(`document.getElementById('notetext').value === 'Zebra facts'`, { label: 'A opened Zebra' });
  281. await stopServer();
  282. await editorReady(a, 'A zebra editor');
  283. await mdClick(a, '[part~=editor] p:last-child');
  284. await mdPress(a, 'End', CTRL);
  285. await mdType(a, ' on A');
  286. await sleep(1200);
  287. await editorReady(b, 'B zebra editor');
  288. await mdClick(b, '[part~=editor] p:last-child');
  289. await mdPress(b, 'End', CTRL);
  290. await mdType(b, ' on B');
  291. await sleep(300);
  292. startServer({ IDENT_API_KEY: APPKEY.key, IDENT_API_SECRET: APPKEY.secret });
  293. await serverUp();
  294. await syncNow(a);
  295. await syncNow(b);
  296. sn = await untilServer(cookieA, ns => ns.length === 4, 'the conflict copy');
  297. const texts = sn.map(n => n.text);
  298. 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));
  299. await syncNow(a);
  300. await a.waitFor(`document.querySelectorAll('#notes a').length === 4`, { label: 'A shows the copy' });
  301. 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)));
  302. // ---- delete on A reaches B -------------------------------------------------------------------------------------------------
  303. await a.click('#notes li:nth-child(1) a');
  304. await a.waitFor(`!!document.getElementById('notetext')`, { label: 'A opened a note to delete' });
  305. const doomed = await a.evaluate('document.getElementById("notetext").value');
  306. check('delete: the question is hidden until "Delete note" is clicked', !(await visible(a, '#deleteyes')));
  307. await a.click('#delete');
  308. await a.waitFor(`document.querySelector('#deleteyes').getBoundingClientRect().width > 0`, { label: 'delete question' });
  309. await a.click('#deleteno');
  310. await a.waitFor(`document.querySelector('#deleteyes').getBoundingClientRect().width === 0`, { label: 'kept' });
  311. check('delete: "Keep" changes nothing', (await subjects(a)).length === 4);
  312. await a.click('#delete');
  313. await a.waitFor(`document.querySelector('#deleteyes').getBoundingClientRect().width > 0`, { label: 'delete question again' });
  314. await a.click('#deleteyes');
  315. await a.waitFor(`location.pathname === '/' && document.querySelectorAll('#notes a').length === 3`, { label: 'back to the list, one note less' });
  316. check('delete: the note is gone from the list and the page is the list again', await has(a, '#pick'));
  317. await untilServer(cookieA, ns => ns.length === 3 && !ns.some(n => n.text === doomed), 'the delete');
  318. await syncNow(b);
  319. await b.waitFor(`document.querySelectorAll('#notes a').length === 3`, { label: 'B follows the delete' });
  320. check('delete: the other device follows', true);
  321. // ---- somebody else sees nothing of it ----------------------------------------------------------------------------------------
  322. const bob = await newPage();
  323. await viewport(bob, 1280, 900);
  324. await identSignIn(bob, '[email protected]');
  325. await bob.goto(BASE + '/');
  326. await hydrated(bob);
  327. await buttonLogin(bob);
  328. await bob.waitForSelector('#newnote', { timeout: 15000 });
  329. await sleep(800);
  330. check('another user: an empty list', (await subjects(bob)).length === 0);
  331. const cookieBob = await cookieOf(bob);
  332. const alicePick = (await serverNotes(cookieA))[0];
  333. const aliceId = alicePick.id;
  334. 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);
  335. await apiPost({ op: 'save', id: aliceId, local: '', text: 'hacked', base: 0, at: 1 }, cookieBob);
  336. await apiPost({ op: 'delete', id: aliceId, base: 0 }, cookieBob);
  337. 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));
  338. 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);
  339. 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);
  340. // a retried push (the answer was lost) does not make a second note
  341. await apiPost({ op: 'save', id: '', local: 'lretry', text: 'retry me', base: 0, at: 5 }, cookieA);
  342. await apiPost({ op: 'save', id: '', local: 'lretry', text: 'retry me', base: 0, at: 5 }, cookieA);
  343. check('a push repeated after a lost answer makes no second note', (await serverNotes(cookieA)).filter(n => n.text === 'retry me').length === 1);
  344. // ---- phone and desktop layout ----------------------------------------------------------------------------------------------------
  345. await a.goto(BASE + '/');
  346. await a.waitForSelector('#notes a');
  347. await viewport(a, 390, 844);
  348. check('phone: the list alone, no note', await visible(a, 'note-list') && !(await visible(a, 'note-reader')) && await noOverflow(a));
  349. await shot(a, 'phone-list');
  350. await a.click('#notes a');
  351. await a.waitForSelector('#notetext');
  352. await editorReady(a, 'phone editor');
  353. 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));
  354. await shot(a, 'phone-note');
  355. await a.click('.back');
  356. await a.waitFor(`location.pathname === '/' && document.querySelector('note-list').getBoundingClientRect().width > 0`, { label: 'phone: back to the list' });
  357. check('phone: "All notes" brings the list back', await visible(a, 'note-list') && !(await visible(a, 'note-reader')));
  358. await viewport(a, 1280, 900);
  359. await a.click('#notes a');
  360. await editorReady(a, 'desktop editor');
  361. 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));
  362. await shot(a, 'desktop-two');
  363. // ---- log out: the browser forgets the notes ---------------------------------------------------------------------------------------
  364. await a.click('#logout');
  365. await a.waitForSelector('#loginhint');
  366. check('log out: the notes are gone from the page, the login hint shows', !(await has(a, '#notes')) && await has(a, '#loginbutton'));
  367. await sleep(500);
  368. 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'))()`));
  369. check('the server logged no error', !/error|Error/.test(log.replace(/error absorbed/g, '')), log.slice(-400));
  370. } catch (e) {
  371. console.log('FAIL gate crashed — ' + (e && e.stack || e));
  372. failures++;
  373. console.log(log.slice(-1500));
  374. } finally {
  375. for (const b of browsers) { try { await b.close(); } catch {} }
  376. await stopServer();
  377. if (ident) await ident.stop();
  378. console.log(`${passes} passed, ${failures} failed`);
  379. process.exit(failures ? 1 : 0);
  380. }

Branches

Latest commits

  • 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