ident
All repositories: gitoria
58.8 KB
// tests/browser.mjs — THE GATE of ident.worldapi.org (piece 1 of 6, ticket #24: account,// identities, time zone). Starts its OWN servers on their own storage (never touches the// dev server on :8351 or its data):// ident :8356 (IDENT_STORAGE .scratch/gate-store/main, mail sink main-mail.txt)// ident short :8357 (OTP TTL 3 s — the expiry case)// and TWO real headless Chromes (debug ports 8640-8649 / 8650-8659, --disable-gpu, killed// by PID at the end). Every login is typed and clicked in the browser; the one-time code// is read from the mail sink file like a person reads their mail. Each browser runs in an// emulated time zone (A: Pacific/Auckland, B: Asia/Tokyo) so "the browser's// time zone at first use" is observable.//// node tests/browser.mjs// IDENT_GATE_PORT=8732 IDENT_GATE_SHORT_PORT=8733 IDENT_GATE_CHROME=8734-8739 node tests/browser.mjs// (other ports: the two servers, and the Chromes' debug ports split in two halves A/B)//// Screenshots at 390px and 1280px land in .scratch/gate-*.png — LOOK at them. Server// logs: .scratch/gate-{main,short}.log. The face negatives go over the framework's REST// carrier (POST /__hl/emit, the same frame the page sends) with and without a cookie.import { spawn } from 'node:child_process';import { rmSync, mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';import { dirname, join, resolve } from 'node:path';import { fileURLToPath } from 'node:url';import { deflateSync } from 'node:zlib';import { launchBrowser } from './cdp.mjs';if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';const HERE = dirname(fileURLToPath(import.meta.url));const APP = resolve(HERE, '..');const BIN = join(APP, 'bin/hybriel');const SCRATCH = join(APP, '.scratch');const STORE = join(SCRATCH, 'gate-store');rmSync(STORE, { recursive: true, force: true });mkdirSync(STORE, { recursive: true });const P_MAIN = Number(process.env.IDENT_GATE_PORT || 8356), P_SHORT = Number(process.env.IDENT_GATE_SHORT_PORT || 8357);const CHROME = (process.env.IDENT_GATE_CHROME || '8640-8659').split('-').map(Number);const CHROME_MID = Math.floor((CHROME[0] + CHROME[1]) / 2);const IDENT = `http://127.0.0.1:${P_MAIN}`;const SHORT = `http://localhost:${P_SHORT}`;const SINK_MAIN = join(STORE, 'main-mail.txt');const SINK_SHORT = join(STORE, 'short-mail.txt');const TZ_A = 'Pacific/Auckland', TZ_B = 'Asia/Tokyo';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;// ---- the servers ------------------------------------------------------------------------const procs = [];function start(name, env) {let log = '';const p = spawn(BIN, ['project.hl'], { cwd: APP, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'] });p.stdout.on('data', d => log += d); p.stderr.on('data', d => log += d);p.on('exit', () => writeFileSync(join(SCRATCH, `gate-${name}.log`), log));procs.push({ name, p, log: () => log });return p;}start('main', { IDENT_PORT: String(P_MAIN), IDENT_STORAGE: join(STORE, 'main'), IDENT_SESSIONS: join(STORE, 'main-sessions'), IDENT_MAIL_SINK: SINK_MAIN, IDENT_IP_LIMIT: '1000', IDENT_IP_DAY_LIMIT: '1000' });start('short', { IDENT_PORT: String(P_SHORT), IDENT_STORAGE: join(STORE, 'short'), IDENT_SESSIONS: join(STORE, 'short-sessions'), IDENT_MAIL_SINK: SINK_SHORT, IDENT_OTP_TTL_MS: '3000', IDENT_IP_LIMIT: '1000', IDENT_IP_DAY_LIMIT: '1000' });// the latest code the sink holds for an address (one "<address> <code>" line per mail)function mails(sink, email) {if (!existsSync(sink)) return [];return readFileSync(sink, 'utf8').split('\n').filter(l => l.startsWith(email + ' ')).map(l => l.split(' ')[1]);}const lastCode = (sink, email) => mails(sink, email).at(-1);const req = async (method, url, { raw, headers = {} } = {}) => {const r = await fetch(url, { method, redirect: 'manual', headers, body: raw });const text = await r.text();let json = null; try { json = JSON.parse(text); } catch {}return { status: r.status, json, text, location: r.headers.get('location') };};// A FACE CALL over the framework's REST carrier: the frame the page itself sendslet frameNo = 0;// hybriel#16 (mission 036): hl:web itself refuses an emit with one argument too many — the ack is// ok:false "… the `session` parameter is filled by the server, never by the peer"; the face never runs.const framework_refused = (raw) => { try { const j = JSON.parse(raw); return j.ok === false && /the `session` parameter is filled by the server/.test(j.error || ''); } catch { return false; } };async function face(event, payload, cookie) {const r = await req('POST', IDENT + '/__hl/emit', { raw: J({ t: 'emit', i: ++frameNo, event, payload }), headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) } });return r.json && r.json.ok ? r.json.value : { transport: r.status, text: r.text.slice(0, 300), refused: framework_refused(r.text) };}const connected = (page, label) => page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label });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) {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');async function bothWidths(page, name) {await viewport(page, 390, 844); await shot(page, 'phone-' + name);check(`layout: 390px ${name} has no horizontal overflow`, await noOverflow(page));await viewport(page, 1280, 900); await shot(page, 'desktop-' + name);check(`layout: 1280px ${name} has no horizontal overflow`, await noOverflow(page));}const msg = (page) => page.text('#message');const waitMsg = (page, re, label) => page.waitFor(`/${re}/.test((document.querySelector('#message') || {}).textContent || '')`, { label });const waitNotice = (page, re, label) => page.waitFor(`/${re}/.test((document.querySelector('#notice') || {}).textContent || '')`, { label });// the identity list as the page shows it: [{ label, details, isDefault, edit, del }]const listed = (page) => page.evaluate(`[...document.querySelectorAll('#identities li')].map(li => ({ label: li.querySelector('identity-label').textContent, details: li.querySelector('identity-details').textContent, isDefault: !!li.querySelector('default-badge'), edit: !!li.querySelector('button.edit'), del: !!li.querySelector('button.delete') }))`);const labels = async (page) => (await listed(page)).map(r => r.label);// click a row's button by the row's labelasync function rowButton(page, label, cls) {const sel = await page.evaluate(`(() => { const li = [...document.querySelectorAll('#identities li')].find(li => li.querySelector('identity-label').textContent === ${J(label)}); if (!li) return null; const b = li.querySelector('button.${cls}'); if (!b) return null; b.id = 'gate-target'; return '#gate-target'; })()`);if (!sel) throw new Error(`no ${cls} button for ${label}`);await page.click(sel);await page.evaluate(`document.querySelector('#gate-target') && document.querySelector('#gate-target').removeAttribute('id')`);}// window.confirm: answer the NEXT dialog of this page with `accept`function dialogs(page) {page.dialogAnswer = true;page.dialogTexts = [];page.conn.onEvent(m => {if (m.method === 'Page.javascriptDialogOpening' && m.sessionId === page.sessionId) {page.dialogTexts.push(m.params.message);page.send('Page.handleJavaScriptDialog', { accept: page.dialogAnswer }).catch(() => {});}});}// THE LOGIN AS A PERSON DOES IT: type the address, click, read the mail, type the codeasync function askCode(page, email) {await page.waitForSelector('#email');await connected(page, 'socket before asking');await page.type('#email', email, { clear: true });await page.evaluate('document.querySelector("#message").textContent = ""');await page.click('#sendcode');// ident#20: a sent code NAVIGATES to the code page (/code); a refusal stays with a messageawait page.waitFor('(/\\/code$/.test(location.pathname) && !!document.querySelector("#code")) || /\\S/.test(document.querySelector("#message").textContent)', { label: 'code page or message after ' + email });if (await page.evaluate('/\\/code$/.test(location.pathname)')) await connected(page, 'code page hydrated');}// A REAL RELOAD (Page.reload, like a phone reloading the tab), then the hydrated socketasync function reload(page) {page._loaded = false;await page.send('Page.reload', { ignoreCache: false });const deadline = Date.now() + 15000;while (!page._loaded && Date.now() < deadline) await sleep(25);if (!page._loaded) throw new Error('reload: load event never fired');await connected(page, 'socket after reload');}// what the page shows: '<path> code:<address>' | '<path> email' | '<path> signedin' | '<path> ?'const step = (page) => page.evaluate(`location.pathname + ' ' + (document.querySelector('#codeform') && !document.querySelector('#emailform') ? 'code:' + document.querySelector('#sentto strong').textContent : (document.querySelector('#emailform') && !document.querySelector('#codeform') ? 'email' : (document.querySelector('#meemail') ? 'signedin' : '?')))`);// what the SERVER answers a plain GET with this cookie (no JS, no redirect following):// '302 <location>' or '200 code:<address>' / '200 email' / '200 signedin'async function ssr(path, cookie, base = IDENT) {const r = await fetch(base + path, { redirect: 'manual', headers: cookie ? { cookie } : {} });if (r.status >= 300 && r.status < 400) return r.status + ' ' + r.headers.get('location');const t = await r.text();const m = /id="sentto"[^>]*>.*?<strong[^>]*>([^<]*)</s.exec(t);return r.status + ' ' + (t.includes('id="codeform"') && m ? 'code:' + m[1] : (t.includes('id="emailform"') ? 'email' : (t.includes('id="meemail"') ? 'signedin' : '?')));}const cookieOf = async (page, url) => (await page.cookies([url])).filter(c => c.name === 'identsid').map(c => `identsid=${c.value}`)[0];async function enterCode(page, code) {await page.evaluate('document.querySelector("#message").textContent = ""');await page.type('#code', code, { clear: true });await page.click('#verify');}async function fill(page, values) {for (const [id, v] of Object.entries(values)) await page.type('#' + id, v, { clear: true });}// a PNG 300×200 (left half red, right half blue) and a text file, for the avatar uploadfunction makePng() {const W = 300, H = 200, raw = Buffer.alloc((W * 3 + 1) * H);for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { const o = y * (W * 3 + 1); raw[o] = 0; raw[o + 1 + x * 3] = x < W / 2 ? 255 : 0; raw[o + 2 + x * 3] = 0; raw[o + 3 + x * 3] = x < W / 2 ? 0 : 255; }const crcT = []; for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; crcT[n] = c >>> 0; }const crc = (b) => { let c = 0xffffffff; for (const x of b) c = crcT[(c ^ x) & 255] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };const chunk = (t, d) => { const len = Buffer.alloc(4); len.writeUInt32BE(d.length); const td = Buffer.concat([Buffer.from(t), d]); const c = Buffer.alloc(4); c.writeUInt32BE(crc(td)); return Buffer.concat([len, td, c]); };const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(W, 0); ihdr.writeUInt32BE(H, 4); ihdr[8] = 8; ihdr[9] = 2;return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);}const PNG_FILE = join(STORE, 'avatar.png'), TXT_FILE = join(STORE, 'avatar.txt');writeFileSync(PNG_FILE, makePng()); writeFileSync(TXT_FILE, 'not a picture');// pick a file in an <input type=file> like a person does (the browser's own file chooser result)async function upload(page, selector, file) {const { root } = await page.send('DOM.getDocument');const { nodeId } = await page.send('DOM.querySelector', { nodeId: root.nodeId, selector });await page.send('DOM.setFileInputFiles', { files: [file], nodeId });}// a PASTE (one input event with the whole value), unlike page.type()'s per-character keys —// used for the avatar URL so a live `src`-bound preview does not fetch every half-typed prefixasync function paste(page, selector, text) {await page.evaluate(`(() => { const el = document.querySelector(${J(selector)}); el.value = ${J(text)}; el.dispatchEvent(new Event('input', { bubbles: true })); })()`);}let A, B, a, b;try {for (const [name, url] of [['main', IDENT + '/'], ['short', `http://127.0.0.1:${P_SHORT}/`]]) {let up = false;for (let i = 0; i < 80; i++) { try { const r = await fetch(url); if (r.ok) { up = true; break; } } catch {} await sleep(250); }if (!up) throw new Error(`${name} server did not come up\n` + procs.find(p => p.name === name).log());}// ---- the mission-003 app flow is gone; piece 2 rebuilt /login and /api/exchange ------------// (ticket #37: these two checks asserted 404s from before piece 2; they now check that the// mission-003 shapes are refused by the piece-2 routes. The full piece-2 rules: tests/apps.mjs)let r = await req('POST', IDENT + '/api/exchange', { raw: '{"code":"x"}', headers: { 'content-type': 'application/json' } });check('old exchange shape (code only): 400 naming the missing key, no identity', r.status === 400 && r.json && r.json.error === 'missing field: key' && r.json.identity === undefined, J(r));r = await req('GET', IDENT + '/login?app=testclient&return=http://127.0.0.1:8358/callback');check('old /login?app=… (no key): 400 error page, no redirect', r.status === 400 && r.location === null && /the key parameter/.test(r.text), `${r.status} ${r.location}`);r = await req('GET', IDENT + '/continue/abc');check('removed: GET /continue/<rid> is 404', r.status === 404, String(r.status));// ---- faces without a session ----------------------------------------------------------------let v = await face('addIdentity', [{ identityName: 'X' }]);check('face: addIdentity without a session → not signed in', v.error === 'you are not signed in', J(v));v = await face('changeTimeZone', ['Europe/Vienna']);check('face: changeTimeZone without a session → not signed in', v.error === 'you are not signed in', J(v));A = await launchBrowser({ debugPortRange: [CHROME[0], CHROME_MID] });B = await launchBrowser({ debugPortRange: [CHROME_MID + 1, CHROME[1]] });a = await A.newPage();b = await B.newPage();dialogs(a); dialogs(b);await a.send('Emulation.setTimezoneOverride', { timezoneId: TZ_A });await b.send('Emulation.setTimezoneOverride', { timezoneId: TZ_B });// ---- browser A: the first login creates the account --------------------------------------------await a.goto(IDENT + '/');await a.waitForSelector('#emailform');await connected(a, 'A connected');check('home: signed out shows the login (SSR), no identities', await a.evaluate('!!document.querySelector("#emailform") && !document.querySelector("#identities") && document.title === "ident | sign in"'), await a.evaluate('document.title'));check('home: the page says there is no sign-up', /no sign-up/.test(await a.text('ident-card')), await a.text('ident-card'));check('home: palette — accent orange #ce9178, danger #f44747, dark background', await a.evaluate(`(() => { const cs = getComputedStyle(document.documentElement); const s = document.createElement('span'); s.style.color = 'var(--color-danger)'; document.body.append(s); const d = getComputedStyle(s).color; s.remove(); return cs.getPropertyValue('--color-accent').trim() === '#ce9178' && getComputedStyle(document.querySelector('.brand')).color === 'rgb(206, 145, 120)' && getComputedStyle(document.querySelector('#sendcode')).backgroundColor === 'rgb(206, 145, 120)' && d === 'rgb(244, 71, 71)' && getComputedStyle(document.body).backgroundColor === 'rgb(25, 30, 35)'; })()`));await bothWidths(a, 'signin');await askCode(a, 'foo@bar');check('login: an address without a dotted domain is refused', (await msg(a)) === 'that is not an email address', await msg(a));await askCode(a, ' [email protected] ');check('login: code form shows the LOWERCASED address', (await a.text('#sentto strong')) === '[email protected]', await a.text('#sentto'));check('ident#20: "Send me a code" lands on the code page /code', (await step(a)) === '/code code:[email protected]', await step(a));const aliceCode1 = lastCode(SINK_MAIN, '[email protected]');check('mail: the sink got one 6-digit code for alice', mails(SINK_MAIN, '[email protected]').length === 1 && /^\d{6}$/.test(aliceCode1), readFileSync(SINK_MAIN, 'utf8'));await bothWidths(a, 'code');await enterCode(a, aliceCode1 === '000000' ? '111111' : '000000');await waitMsg(a, 'wrong code', 'wrong code message');check('login: a wrong code says so and counts down', (await msg(a)) === 'wrong code (4 tries left)', await msg(a));await enterCode(a, aliceCode1);await a.waitForSelector('#identityform');check('first login: the account exists at once — the default identity is listed', J(await listed(a)) === J([{ label: 'Default', details: 'no names', isDefault: true, edit: true, del: false }]), J(await listed(a)));check('first login: the name fields are shown and say they are OPTIONAL', /optional/i.test(await a.text('#welcome')) && (await a.evaluate('["#fidentityname","#fnickname","#ffirstname","#flastname"].every(s => !!document.querySelector(s) && !document.querySelector(s).required)')) && (await a.evaluate('!!document.querySelector("#skip")')), await a.text('#identityform'));check('first login: the fields are the default identity\'s (identity name "Default", names empty)', J(await a.evaluate('["#fidentityname","#fnickname","#ffirstname","#flastname"].map(s => document.querySelector(s).value)')) === J(['Default', '', '', '']));check('time zone: the browser\'s (emulated ' + TZ_A + ') was stored at first use', (await a.text('#timezone')) === TZ_A, await a.text('#timezone'));await bothWidths(a, 'welcome');// SKIPPED: the form closes, the default identity stays as it isawait a.click('#skip');await a.waitFor('!document.querySelector("#identityform")', { label: 'welcome closed' });check('skip: the form closes, the default identity stays unnamed', J(await listed(a)) === J([{ label: 'Default', details: 'no names', isDefault: true, edit: true, del: false }]), J(await listed(a)));await a.goto(IDENT + '/');await a.waitForSelector('#identities');check('reload: still signed in (SSR), identities and time zone read back, no welcome again', (await a.text('#meemail')) === '[email protected]' && J(await labels(a)) === J(['Default']) && (await a.text('#timezone')) === TZ_A && !(await a.evaluate('!!document.querySelector("#identityform")')), await a.text('ident-card'));check('title: signed in', (await a.evaluate('document.title')) === 'ident | your identities', await a.evaluate('document.title'));check('cookie: ident uses its own cookie name (identsid), httpOnly', await a.cookies([IDENT]).then(cs => cs.some(c => c.name === 'identsid' && c.httpOnly) && !cs.some(c => c.name === 'hlsid')), J(await a.cookies([IDENT])));const aliceCookie = (await a.cookies([IDENT])).filter(c => c.name === 'identsid').map(c => `identsid=${c.value}`)[0];// EDIT the default identity: all four fieldsawait connected(a, 'A before edit');await rowButton(a, 'Default', 'edit');await a.waitForSelector('#identityform');check('edit: the form is headed with the identity and has Cancel, not Skip', (await a.text('#formheading')) === 'Edit Default' && (await a.evaluate('!!document.querySelector("#cancel") && !document.querySelector("#skip") && !document.querySelector("#welcome")')), await a.text('#identityform'));await fill(a, { fidentityname: 'Private', fnickname: 'Ali', ffirstname: 'Alice', flastname: 'Example' });await upload(a, '#favatarfile', TXT_FILE);await a.waitFor(`document.querySelector('#avatarnote').textContent.length > 0`, { label: 'avatar: text file refused' });check('avatar: a text file is refused with a message, nothing is set', (await a.text('#avatarnote')).startsWith('That is not a picture') && (await a.evaluate(`document.querySelector('#favatar').value`)) === '', await a.text('#avatarnote'));await upload(a, '#favatarfile', PNG_FILE);await a.waitFor(`document.querySelector('#favatar').value.startsWith('data:image/')`, { label: 'avatar: picture scaled' });check('avatar: an uploaded picture is scaled to 128×128 and previewed before saving', await a.evaluate(`(async () => { const p = document.querySelector('#avatarpreview'); await new Promise(r => p.complete ? r() : (p.onload = r)); return p.naturalWidth === 128 && p.naturalHeight === 128 && p.src === document.querySelector('#favatar').value && !p.classList.contains('hidden') && document.querySelector('#favatar').value.length < 60000; })()`), await a.evaluate(`document.querySelector('#favatar').value.slice(0, 40)`));check('avatar: the centre-cropped, scaled picture keeps its colours (red left, blue right)', await a.evaluate(`(async () => { const p = document.querySelector('#avatarpreview'); const c = document.createElement('canvas'); c.width = c.height = 128; const x = c.getContext('2d', { willReadFrequently: true }); x.drawImage(p, 0, 0); const l = x.getImageData(10, 64, 1, 1).data, r = x.getImageData(118, 64, 1, 1).data; return l[0] > 200 && l[2] < 60 && r[2] > 200 && r[0] < 60; })()`));await bothWidths(a, 'edit');await a.click('#saveidentity');await waitNotice(a, 'Identity saved', 'saved notice');check('edit: saved — label and names shown', J(await listed(a)) === J([{ label: 'Private', details: '“Ali” · Alice Example', isDefault: true, edit: true, del: false }]), J(await listed(a)));check('avatar: the identity list shows a round avatar image with the saved picture', await a.evaluate(`(() => { const img = document.querySelector('#identities li img.avatar'); return !!img && img.src.startsWith('data:image/') && img.naturalWidth === 128; })()`), await a.evaluate(`document.querySelector('#identities').innerHTML`));// reopening the edit form shows the saved avatar again, and typing focus survives (ticket #15// regression: an `if` next to the text inputs in the SAME view made hl:webex recreate the// form on every keystroke, losing focus after one character — hybriel #32)await rowButton(a, 'Private', 'edit');await a.waitForSelector('#identityform');check('avatar: reopening edit shows the saved picture and preview', (await a.evaluate(`document.querySelector('#favatar').value`)).startsWith('data:image/') && await a.evaluate(`!document.querySelector('#avatarpreview').classList.contains('hidden')`));await a.focus('#fnickname');await a.send('Input.dispatchKeyEvent', { type: 'keyDown', text: 'z', unmodifiedText: 'z', key: 'z' });await a.send('Input.dispatchKeyEvent', { type: 'keyDown', text: 'z', unmodifiedText: 'z', key: 'z' });check('avatar: typing two characters in a row keeps focus (no per-keystroke re-render)', (await a.evaluate(`document.querySelector('#fnickname').value`)) === 'Alizz' && (await a.evaluate('document.activeElement && document.activeElement.id')) === 'fnickname');await a.click('#avatarremove');check('avatar: "Remove picture" hides the preview again', await a.evaluate(`document.querySelector('#avatarpreview').classList.contains('hidden')`));await a.click('#cancel');await a.waitFor('!document.querySelector("#identityform")', { label: 'avatar-check cancel closes' });// A SECOND identity, then a third with NO fields at allawait a.click('#newidentity');await a.waitForSelector('#identityform');check('new: an empty form headed "New identity"', (await a.text('#formheading')) === 'New identity' && J(await a.evaluate('["#fidentityname","#fnickname","#ffirstname","#flastname"].map(s => document.querySelector(s).value)')) === J(['', '', '', '']));await fill(a, { fidentityname: 'Work', ffirstname: 'Alice', flastname: 'Example-Work' });await a.click('#saveidentity');await waitNotice(a, 'Identity created', 'created notice');check('new: the second identity is listed; now both can be deleted, the first stays default', J(await listed(a)) === J([{ label: 'Private', details: '“Ali” · Alice Example', isDefault: true, edit: true, del: true },{ label: 'Work', details: 'Alice Example-Work', isDefault: false, edit: true, del: true }]), J(await listed(a)));// hybriel#121 (mission 048): a face taking the session syncs the session-derived list back — a row added on top would show twicecheck('new: the created identity is listed ONCE (no session-sync double row)', (await labels(a)).filter(l => l === 'Work').length === 1 && (await a.evaluate('document.querySelectorAll("#identities li").length')) === 2, J(await labels(a)));await a.click('#newidentity');await a.waitForSelector('#identityform');await a.click('#saveidentity');await waitNotice(a, 'Identity created', 'created notice 3');check('new: an identity with no fields at all is fine (shown as "Identity 3")', J(await labels(a)) === J(['Private', 'Work', 'Identity 3']), J(await labels(a)));// cancel does not saveawait rowButton(a, 'Work', 'edit');await a.waitForSelector('#identityform');await fill(a, { fnickname: 'SHOULD-NOT-SAVE' });await a.click('#cancel');await a.waitFor('!document.querySelector("#identityform")', { label: 'cancel closes' });// edit the second oneawait rowButton(a, 'Work', 'edit');await a.waitForSelector('#identityform');check('edit: the form holds the stored values (the cancelled change is gone)', J(await a.evaluate('["#fidentityname","#fnickname","#ffirstname","#flastname"].map(s => document.querySelector(s).value)')) === J(['Work', '', 'Alice', 'Example-Work']), J(await a.evaluate('["#fidentityname","#fnickname","#ffirstname","#flastname"].map(s => document.querySelector(s).value)')));await fill(a, { fnickname: 'AE' });await a.click('#saveidentity');await waitNotice(a, 'Identity saved', 'saved notice 2');await a.goto(IDENT + '/');await a.waitForSelector('#identities');check('reload: three identities as saved', J(await listed(a)) === J([{ label: 'Private', details: '“Ali” · Alice Example', isDefault: true, edit: true, del: true },{ label: 'Work', details: '“AE” · Alice Example-Work', isDefault: false, edit: true, del: true },{ label: 'Identity 3', details: 'no names', isDefault: false, edit: true, del: true }]), J(await listed(a)));await bothWidths(a, 'identities');// DELETE: a dismissed confirm keeps it; accepted removes it; the last one cannot goawait connected(a, 'A before delete');a.dialogAnswer = false;await rowButton(a, 'Identity 3', 'delete');await sleep(500);check('delete: a dismissed confirm keeps the identity', a.dialogTexts.at(-1) === 'Delete the identity “Identity 3”?' && J(await labels(a)) === J(['Private', 'Work', 'Identity 3']), J(a.dialogTexts) + J(await labels(a)));a.dialogAnswer = true;await rowButton(a, 'Identity 3', 'delete');await waitNotice(a, 'Identity deleted', 'deleted notice');check('delete: accepted → gone', J(await labels(a)) === J(['Private', 'Work']), J(await labels(a)));await rowButton(a, 'Private', 'delete');await a.waitFor(`document.querySelectorAll('#identities li').length === 1`, { label: 'default deleted' });check('delete: the default one may go while another exists; the remaining one becomes default and has no Delete', J(await listed(a)) === J([{ label: 'Work', details: '“AE” · Alice Example-Work', isDefault: true, edit: true, del: false }]), J(await listed(a)));// ids are mpackdb UUIDs (mission 009): read Alice's remaining identity's id off its Edit buttonconst aliceWorkId = await a.evaluate('document.querySelector("#identities li .edit").value');check('ids: an identity id is a 12-char mpackdb UUID (not a number)', /^[0-9a-z]{12}$/.test(aliceWorkId), J(aliceWorkId));v = await face('dropIdentity', [aliceWorkId], aliceCookie);check('face: deleting the LAST identity is refused', v.error === 'this is your only identity — an account always keeps one', J(v));await a.goto(IDENT + '/');await a.waitForSelector('#identities');check('reload: the last identity is still there', J(await labels(a)) === J(['Work']), J(await labels(a)));// TIME ZONE: change, browser button, unknown name refused in the pageawait connected(a, 'A before tz');await a.type('#tzinput', 'Mars/Olympus_Mons', { clear: true });await a.click('#savetz');await waitMsg(a, 'does not know', 'unknown zone');check('time zone: a name the browser does not know is refused, nothing saved', (await msg(a)) === 'this browser does not know the time zone “Mars/Olympus_Mons”' && (await a.text('#timezone')) === TZ_A, await msg(a));await a.type('#tzinput', 'Europe/Vienna', { clear: true });await a.click('#savetz');await waitNotice(a, 'Time zone saved', 'tz saved');check('time zone: changed to Europe/Vienna', (await a.text('#timezone')) === 'Europe/Vienna', await a.text('#timezone'));await a.click('#browsertz');await a.waitFor(`document.querySelector('#tzinput').value === ${J(TZ_A)}`, { label: 'browser tz button' });check('time zone: "Use this browser\'s" fills in the browser\'s zone (not saved yet)', (await a.text('#timezone')) === 'Europe/Vienna');await a.goto(IDENT + '/');await a.waitForSelector('#timezone');check('reload: the time zone reads back Europe/Vienna', (await a.text('#timezone')) === 'Europe/Vienna' && (await a.evaluate('document.querySelector("#tzinput").value')) === 'Europe/Vienna');// SIGN OUT, then a known address signs in: no welcome, the time zone is NOT resetawait connected(a, 'A before sign out');await a.click('#signout');await a.waitForSelector('#emailform');await a.goto(IDENT + '/');await a.waitForSelector('#emailform');check('sign out: the reload is signed out', !(await a.evaluate('!!document.querySelector("#identities")')));v = await face('addIdentity', [{ identityName: 'after sign out' }], aliceCookie);check('face: the signed-out cookie cannot add an identity', v.error === 'you are not signed in', J(v));await askCode(a, '[email protected]');await enterCode(a, lastCode(SINK_MAIN, '[email protected]'));await a.waitForSelector('#identities');await sleep(300);check('login again: no welcome form, same identity, time zone kept (not the browser\'s)', !(await a.evaluate('!!document.querySelector("#identityform")')) && J(await labels(a)) === J(['Work']) && (await a.text('#timezone')) === 'Europe/Vienna', await a.text('ident-card'));await enterCode(a, lastCode(SINK_MAIN, '[email protected]')).catch(() => {});const aliceCookie2 = (await a.cookies([IDENT])).filter(c => c.name === 'identsid').map(c => `identsid=${c.value}`)[0];// ---- browser B: first login, names FILLED at the welcome; wrong codes; rate limit ----------------await b.goto(IDENT + '/');await askCode(b, '[email protected]');await enterCode(b, lastCode(SINK_MAIN, '[email protected]'));await b.waitForSelector('#identityform');check('B first login: time zone ' + TZ_B + ' from this browser', (await b.text('#timezone')) === TZ_B, await b.text('#timezone'));await fill(b, { fidentityname: 'Bob', fnickname: 'Bobby', ffirstname: 'Bob', flastname: 'Builder' });await b.click('#saveidentity');await waitNotice(b, 'Identity saved', 'B saved');check('B welcome FILLED: the default identity carries the names', J(await listed(b)) === J([{ label: 'Bob', details: '“Bobby” · Bob Builder', isDefault: true, edit: true, del: false }]), J(await listed(b)));const bobCookie = (await b.cookies([IDENT])).filter(c => c.name === 'identsid').map(c => `identsid=${c.value}`)[0];// FACE NEGATIVES (Bob's cookie): strict fields, other accounts' identities, time zone shapesconst neg = [['unknown field', ['addIdentity', [{ identityName: 'x', email: '[email protected]' }]], { error: 'unknown field: email', field: 'email' }],['wrong type', ['addIdentity', [{ nickname: 5 }]], { error: 'field nickname must be a string', field: 'nickname' }],['null value', ['addIdentity', [{ nickname: null }]], { error: 'field nickname must be a string', field: 'nickname' }],['fields a list', ['addIdentity', [['x']]], { error: 'the identity fields must be an object', field: '' }],['fields a string', ['addIdentity', ['x']], { error: 'the identity fields must be an object', field: '' }],['61 characters', ['addIdentity', [{ lastname: 'x'.repeat(61) }]], { error: 'field lastname is longer than 60 characters', field: 'lastname' }],['control character', ['addIdentity', [{ firstname: 'a\u0007b' }]], { error: 'field firstname contains a control character', field: 'firstname' }],['avatar: a link is not a picture', ['addIdentity', [{ avatar: 'https://example.org/pic.png' }]], { error: 'field avatar must be an uploaded PNG, JPEG or WebP picture', field: 'avatar' }],['avatar: svg refused', ['addIdentity', [{ avatar: 'data:image/svg+xml;base64,PHN2Zz4=' }]], { error: 'field avatar must be an uploaded PNG, JPEG or WebP picture', field: 'avatar' }],['avatar: wrong bytes for the type', ['addIdentity', [{ avatar: 'data:image/png;base64,AAAAAAAAAAAA' }]], { error: 'field avatar is not a valid picture', field: 'avatar' }],['avatar: not base64', ['addIdentity', [{ avatar: 'data:image/png;base64,iVBORw0KGgo<script>' }]], { error: 'field avatar is not valid base64', field: 'avatar' }],['avatar: too long', ['addIdentity', [{ avatar: 'data:image/png;base64,iVBORw0KGgo' + 'A'.repeat(60000) }]], { error: 'field avatar is longer than 60000 characters', field: 'avatar' }],['avatar: wrong type', ['addIdentity', [{ avatar: 5 }]], { error: 'field avatar must be a string', field: 'avatar' }],['edit: another account\'s identity', ['editIdentity', [aliceWorkId, { nickname: 'hacked' }]], { error: 'no such identity', field: 'id' }],['edit: id as a number (the old numeric ids)', ['editIdentity', [2, { nickname: 'x' }]], { error: 'no such identity', field: 'id' }],['edit: unknown id', ['editIdentity', ['0zzzzzzzzzzz', {}]], { error: 'no such identity', field: 'id' }],['delete: another account\'s identity', ['dropIdentity', [aliceWorkId]], { error: 'no such identity' }],['delete: an old numeric id', ['dropIdentity', [2]], { error: 'no such identity' }],['delete: own last identity', ['dropIdentity', ['(Bob\'s)']], { error: 'this is your only identity — an account always keeps one' }],['time zone: bad characters', ['changeTimeZone', ['Europe/Vienna; x']], { error: 'that is not a time zone name (like Europe/Vienna)' }],['time zone: a number', ['changeTimeZone', [5]], { error: 'the time zone must be a string' }],['time zone: empty', ['changeTimeZone', [' ']], { error: 'the time zone is empty' }],];const bobIds = await face('addIdentity', [{}], bobCookie); // a 2nd identity: learn Bob's idsconst bobRows = bobIds.identities || [];check('face: Bob can add an identity with an empty object', bobRows.length === 2, J(bobIds));await face('dropIdentity', [bobRows[1] && bobRows[1].id], bobCookie);for (const [label, [ev, payload], want] of neg) {// `own last identity` must name Bob's idconst p = label === 'delete: own last identity' ? [bobRows[0].id] : payload;v = await face(ev, p, bobCookie);check(`face negative: ${label}`, J(v) === J(want), J(v));}// IMPERSONATION: a hand-made frame with one argument too many puts ITS object where the// session goes (hybriel: positional trailing session); ident only takes a real sessionv = await face('addIdentity', [{ identityName: 'forged' }, { user: { id: 1 }, data: {} }]);check('face: a forged session object (extra argument, no cookie) is refused', v.refused === true || v.error === 'you are not signed in', J(v));v = await face('dropIdentity', [aliceWorkId, { user: { id: 1 }, data: {} }], bobCookie);check('face: a forged session with Bob\'s cookie cannot delete Alice\'s identity', v.refused === true || v.error === 'you are not signed in' || v.error === 'no such identity', J(v));await a.goto(IDENT + '/');await a.waitForSelector('#identities');check('Alice\'s identities are untouched by all of the above', J(await listed(a)) === J([{ label: 'Work', details: '“AE” · Alice Example-Work', isDefault: true, edit: true, del: false }]), J(await listed(a)));v = await face('changeTimeZone', ['UTC'], aliceCookie2);check('face: UTC is a valid zone', v.timeZone === 'UTC', J(v));// OTP RULES (kept from mission 003): 5 wrong codes, rate limitawait b.click('#signout');await b.waitForSelector('#emailform');await askCode(b, '[email protected]');const carolCode = lastCode(SINK_MAIN, '[email protected]');const wrong = carolCode === '123456' ? '654321' : '123456';const seen = [];for (let i = 0; i < 5; i++) {await enterCode(b, wrong);await b.waitFor('/\\S/.test(document.querySelector("#message").textContent)', { label: 'wrong try ' + (i + 1) });seen.push(await msg(b));}check('otp: five wrong codes count down and then kill the code', J(seen) === J(['wrong code (4 tries left)', 'wrong code (3 tries left)', 'wrong code (2 tries left)', 'wrong code (1 tries left)', 'too many wrong codes — ask for a new one']), J(seen));await enterCode(b, carolCode);await b.waitFor('/\\S/.test(document.querySelector("#message").textContent)', { label: 'right code after kill' });check('otp: after 5 wrong tries even the RIGHT code is refused, and no account exists', (await msg(b)) === 'no code is waiting for this address — ask for a new one' && !(await b.evaluate('!!document.querySelector("#identities")')), await msg(b));await b.click('#back');await askCode(b, '[email protected]');const carolOld = lastCode(SINK_MAIN, '[email protected]');await b.click('#back');await askCode(b, '[email protected]');check('rate limit: the 3rd code in 10 minutes is still sent', mails(SINK_MAIN, '[email protected]').length === 3, J(mails(SINK_MAIN, '[email protected]')));await enterCode(b, carolOld === lastCode(SINK_MAIN, '[email protected]') ? '000001' : carolOld);await waitMsg(b, 'wrong code', 'old code refused');check('otp: a replaced (older) code no longer works', /^wrong code/.test(await msg(b)), await msg(b));await b.click('#back');await askCode(b, '[email protected]');check('rate limit: the 4th is refused and not mailed', /too many codes were sent/.test(await msg(b)) && mails(SINK_MAIN, '[email protected]').length === 3, await msg(b));v = await face('verifyCode', ['[email protected]', lastCode(SINK_MAIN, '[email protected]'), 'UTC', { user: { id: 1 }, data: {} }]);check('face: verifyCode with a forged trailing session is refused (no sign-in, code not spent)', v.refused === true || v.error === 'no session — reload the page', J(v));v = await face('verifyCode', ['[email protected]', 123456, 'UTC']);check('face: verifyCode with a numeric code → refused', v.error === 'email and code must be strings', J(v));// single use: the code that signed Bob in does not work twice (fresh session, REST)const bobFirst = mails(SINK_MAIN, '[email protected]')[0];const fr = await fetch(IDENT + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J({ t: 'emit', i: 901, event: 'signOut', payload: [] }) });const freshCookie = fr.headers.get('set-cookie').split(';')[0];v = await face('verifyCode', ['[email protected]', bobFirst, 'UTC'], freshCookie);check('otp: a code that was used once is refused the second time', v.error === 'no code is waiting for this address — ask for a new one', J(v));// ---- ident#20 (mission 032): THE CODE PAGE /code SURVIVES A RELOAD --------------------------// "Send me a code" records the address in the session (face rememberPending) and goes to// /code; /code shows the code form while that code is waiting, else 302 back to the form.await b.goto(IDENT + '/');await connected(b, 'B before ident#20');const bCookie = await cookieOf(b, IDENT);check('ident#20: after "Other address" the browser is on the email form', (await step(b)) === '/ email', await step(b));check('ident#20: GET /code with nothing pending → 302 to /', (await ssr('/code', bCookie)) === '302 /', await ssr('/code', bCookie));check('ident#20: GET /code without any cookie → 302 to /', (await ssr('/code')) === '302 /', await ssr('/code'));await askCode(b, '[email protected]');check('ident#20: "Send me a code" → /code shows the code form for dave', (await step(b)) === '/code code:[email protected]', await step(b));check('ident#20: the server renders /code for this session (SSR, plain GET: 200 + code form + address)', (await ssr('/code', bCookie)) === '200 code:[email protected]', await ssr('/code', bCookie));await reload(b);check('ident#20: RELOAD of /code → still the code form, code field there, address named, no email form', (await step(b)) === '/code code:[email protected]' && await b.evaluate('!!document.querySelector("#code") && !document.querySelector("#email") && document.title === "ident | sign in"'), await step(b));await bothWidths(b, 'code-reloaded');const b2 = await B.newPage();await b2.goto(IDENT + '/code');await b2.waitForSelector('ident-card');check('ident#20: a SECOND TAB of the same browser opens /code with the code form too', (await step(b2)) === '/code code:[email protected]', await step(b2));await b2.close();const daveCode = lastCode(SINK_MAIN, '[email protected]');await enterCode(b, daveCode === '000000' ? '111111' : '000000');await waitMsg(b, 'wrong code', 'dave wrong code');await reload(b);check('ident#20: a WRONG code keeps it (reload → code form)', (await step(b)) === '/code code:[email protected]', await step(b));check('ident#20: one mail for dave — the reloads re-sent nothing', mails(SINK_MAIN, '[email protected]').length === 1, J(mails(SINK_MAIN, '[email protected]')));await enterCode(b, daveCode);await b.waitForSelector('#identityform');check('ident#20: the code entered after the reloads signs in (first login: welcome form), address bar back on /', (await b.text('#meemail')) === '[email protected]' && /optional/i.test(await b.text('#welcome')) && (await b.evaluate('location.pathname')) === '/', (await b.evaluate('location.pathname')) + ' ' + await b.text('ident-card'));await b.click('#skip');await b.waitFor('!document.querySelector("#identityform")', { label: 'dave skip' });await reload(b);check('ident#20: signed in → reload shows the account', (await step(b)) === '/ signedin' && (await b.text('#meemail')) === '[email protected]', await step(b));check('ident#20: signed in → GET /code is 302 to / (the sign-in cleared it)', (await ssr('/code', bCookie)) === '302 /', await ssr('/code', bCookie));await b.click('#signout');await b.waitForSelector('#emailform');await b.goto(IDENT + '/code');await b.waitForSelector('#emailform');check('ident#20: signed out again → /code in the browser lands on the email form at /', (await step(b)) === '/ email', await step(b));// "Other address" forgets itawait connected(b, 'B before frank');await askCode(b, '[email protected]');check('ident#20: frank on /code', (await step(b)) === '/code code:[email protected]', await step(b));await b.click('#back');await b.waitFor('location.pathname === "/" && !!document.querySelector("#emailform")', { label: 'back to /' });check('ident#20: "Other address" → the email form at /', (await step(b)) === '/ email', await step(b));check('ident#20: "Other address" → the server forgot it (GET /code is 302 to /)', (await ssr('/code', bCookie)) === '302 /', await ssr('/code', bCookie));await reload(b);check('ident#20: "Other address" → RELOAD shows the email form', (await step(b)) === '/ email', await step(b));// a code killed by 5 wrong tries is no longer waiting → /code sends the browser backawait askCode(b, '[email protected]');const ginaWrong = lastCode(SINK_MAIN, '[email protected]') === '123456' ? '654321' : '123456';for (let i = 0; i < 5; i++) {await enterCode(b, ginaWrong);await b.waitFor('/\\S/.test(document.querySelector("#message").textContent)', { label: 'gina wrong ' + (i + 1) });}check('ident#20: five wrong codes kill gina\'s code', (await msg(b)) === 'too many wrong codes — ask for a new one', await msg(b));await reload(b);check('ident#20: a killed code → reload of /code lands on the email form at /', (await step(b)) === '/ email', await step(b));// THE FACE is honest: it records only an address a code is really waiting forv = await face('rememberPending', ['[email protected]'], bCookie);check('ident#20 face: rememberPending without a waiting code → refused', v.error === 'no code is waiting for this address', J(v));check('ident#20 face: … and /code still sends this browser back (302 /)', (await ssr('/code', bCookie)) === '302 /', await ssr('/code', bCookie));v = await face('rememberPending', [5], bCookie);check('ident#20 face: rememberPending with a number → refused', v.error === 'field email must be a string', J(v));const hq = await req('POST', IDENT + '/api/code', { raw: J({ email: '[email protected]' }), headers: { 'content-type': 'application/json' } });v = await face('rememberPending', ['[email protected]', { user: null, data: {} }], bCookie);check('ident#20 face: a forged trailing session is refused', hq.status === 200 && (v.refused === true || v.error === 'no session — reload the page'), J(v));check('ident#20 face: … and nothing was recorded (302 /)', (await ssr('/code', bCookie)) === '302 /', await ssr('/code', bCookie));v = await face('rememberPending', [' [email protected] '], bCookie);check('ident#20 face: an address with a waiting code is accepted, normalised', v.email === '[email protected]', J(v));check('ident#20: … /code now shows it for THIS session only (other cookie / none → 302)', (await ssr('/code', bCookie)) === '200 code:[email protected]' && (await ssr('/code')) === '302 /' && (await ssr('/code', aliceCookie2)) === '302 /', [await ssr('/code', bCookie), await ssr('/code'), await ssr('/code', aliceCookie2)].join(' | '));v = await face('forgetPending', [], bCookie);check('ident#20 face: forgetPending → /code is 302 to / again', v === true && (await ssr('/code', bCookie)) === '302 /', J(v) + ' ' + await ssr('/code', bCookie));check('ident#20: /signin/<not a request id>/code (xyz, a CR/LF try) → 302 to / (the rid never reaches the Location header)', (await ssr('/signin/%0d%0aX:y/code', bCookie)) === '302 /' && (await ssr('/signin/xyz/code', bCookie)) === '302 /', await ssr('/signin/%0d%0aX:y/code', bCookie));check('ident#20: /signin/<rid>/code with nothing pending → 302 to /signin/<rid>', (await ssr('/signin/0123456789abcdef0123456789abcdef/code', bCookie)) === '302 /signin/0123456789abcdef0123456789abcdef', await ssr('/signin/0123456789abcdef0123456789abcdef/code', bCookie));// ---- expiry (the short-clock server) ---------------------------------------------------------await a.goto(SHORT + '/');await askCode(a, '[email protected]');const erinCode = lastCode(SINK_SHORT, '[email protected]');await reload(a);check('ident#20: short server — reload right after asking → the code form', (await step(a)) === '/code code:[email protected]', await step(a));await sleep(3500);await enterCode(a, erinCode);await waitMsg(a, 'expired', 'expired otp');check('otp: an expired code (TTL 3 s on this server) is refused', (await msg(a)) === 'the code expired — ask for a new one', await msg(a));await reload(a);check('ident#20: an EXPIRED code → reload of /code lands on the email form at /', (await step(a)) === '/ email', await step(a));await askCode(a, '[email protected]');check('ident#20: short server — ivy on /code', (await step(a)) === '/code code:[email protected]', await step(a));await sleep(3500);await reload(a);check('ident#20: expiry alone (no try) → reload of /code lands on the email form', (await step(a)) === '/ email', await step(a));// mission 010: the code request is a fetch to /api/code; its refusals (invalid address 400,// rate limit 429) make Chrome log "Failed to load resource … 400/429" — expected, shown on the pageconst expected = /favicon|status of (400|429) .*\/api\/code$/;const problems = [...a.problems(), ...b.problems()].filter(m => !expected.test(m.text));check('browsers: no console errors or warnings', problems.length === 0, J(problems.slice(0, 5)));// ---- mission 046: THE INSTALLABLE APP (manifest, icons, service worker) and the offline shell ------------const head = await (await fetch(IDENT + '/')).text();const link = (rel) => { const m = new RegExp(`<link rel="${rel}" href="([^"]+)"`).exec(head); return m ? m[1] : null; };const mhref = link('manifest');check('pwa: the head links the manifest, the apple-touch-icon, the favicon and the theme colour (the header colour, token darker)', mhref === '/__hl/manifest.webmanifest' && link('apple-touch-icon') === '/icons/apple-touch-icon.png' && link('icon') === '/favicon.ico' && /<meta name="theme-color" content="rgb\(15, 20, 25\)">/.test(head), J([mhref, link('apple-touch-icon'), link('icon')]));const mres = await fetch(IDENT + (mhref || '/__hl/manifest.webmanifest'));const man = await mres.json();check('pwa: manifest served as manifest JSON: name ident, start_url /start (the data-free start page), standalone, theme = header (darker), background dark', /manifest\+json/.test(mres.headers.get('content-type') || '') && man.name === 'ident' && man.start_url === '/start' && man.display === 'standalone' && man.theme_color === 'rgb(15, 20, 25)' && man.background_color === 'rgb(25, 30, 35)', J(man));const pngOk = async (src, size) => { const r = await fetch(IDENT + src); const bb = Buffer.from(await r.arrayBuffer()); return r.ok && bb.readUInt32BE(0) === 0x89504e47 && bb.readUInt32BE(16) === size && bb.readUInt32BE(20) === size; };const iconOks = [];for (const ic of man.icons) iconOks.push(ic.type === 'image/png' && await pngOk(ic.src, Number(ic.sizes.split('x')[0])));check('pwa: every manifest icon is a real PNG of its declared size (192 + 512, any + maskable)', iconOks.length === 4 && iconOks.every(Boolean) && ['any', 'maskable'].every(pu => ['192x192', '512x512'].every(sz => man.icons.some(i => i.purpose === pu && i.sizes === sz))), J(man.icons));const fav = await fetch(IDENT + '/favicon.ico'); const favB = Buffer.from(await fav.arrayBuffer());check('pwa: apple-touch-icon is a 180 px PNG, /favicon.ico a real ICO', (await pngOk('/icons/apple-touch-icon.png', 180)) && fav.ok && favB.readUInt32BE(0) === 0x00000100, String(fav.status));r = await req('GET', IDENT + '/api/online');check('pwa: /api/online (the shell\'s network probe) answers 204, not cached', r.status === 204 && r.text === '', String(r.status));// THE OFFLINE COPY HOLDS NO PERSONAL DATA (ident is the identity provider; shared devices): browser A// is signed in; with the network gone neither `/` nor `/start`, nor anything in the worker's caches// or hl:web's IndexedDB, may contain the account's address or an identity nameconst w = await A.newPage();await viewport(w, 390, 844);await w.goto(IDENT + '/');await w.waitForSelector('#meemail');await connected(w, 'pwa page');const myEmail = (await w.text('#meemail')).trim();// the identity as it would leak: its details line (names) — a bare label like "Work" is also a word in the framework's codeconst myIdentity = await w.evaluate('(document.querySelector("#identities identity-details") || {}).textContent || ""');check('pwa: browser A is signed in (the data that must not be kept offline)', /@example\.org$/.test(myEmail) && myIdentity !== '', myEmail + ' ' + myIdentity);await w.waitFor(`navigator.serviceWorker.getRegistration().then(r => !!(r && r.active))`, { label: 'service worker active', timeout: 15000 });check('pwa: a service worker is registered for the whole app (scope /) and controls the page', await w.evaluate(`navigator.serviceWorker.getRegistration().then(r => !!r && new URL(r.scope).pathname === '/' && !!navigator.serviceWorker.controller)`));const inst = await w.send('Page.getInstallabilityErrors');check('pwa: Chrome reports no installability error', (inst.installabilityErrors || []).length === 0, J(inst));await sleep(1500); // the probe's first ask (0.5 s after the page is up)check('pwa: online, the header says nothing about offline', await w.evaluate('!document.querySelector("#offline") && !!document.querySelector("application-header")'));// the installed app starts at /start: online it goes on to / by itself (the shell's probe)await w.goto(IDENT + '/start');await w.waitFor('location.pathname === "/" && !!document.querySelector("#meemail")', { label: '/start → /', timeout: 8000 }).catch(() => {});check('pwa: online, /start (the start_url) goes on to / (signed in)', await w.evaluate('location.pathname === "/" && !!document.querySelector("#meemail")'), await w.evaluate('location.pathname'));await connected(w, 'pwa / again');// everything this browser keeps offline, as text: every response in the worker's caches + hl:web's IndexedDBconst keptText = () => w.evaluate(`(async () => {let out = '';for (const name of await caches.keys()) { const c = await caches.open(name); for (const rq of await c.keys()) { const r = await c.match(rq); out += '\\n' + rq.url + '\\n' + await r.clone().text(); } }const db = await new Promise((res) => { const q = indexedDB.open('hl-offline'); q.onsuccess = () => res(q.result); q.onerror = () => res(null); });if (db) { for (const st of db.objectStoreNames) { const all = await new Promise((res) => { const q = db.transaction(st).objectStore(st).getAll(); q.onsuccess = () => res(q.result); q.onerror = () => res([]); }); out += '\\n' + JSON.stringify(all); } db.close(); }return out;})()`);// NO NETWORK: the tab AND the service worker (a separate CDP target — the tab's emulation alone// leaves the worker's fetches online, measured) are cut offconst swSessions = {};const swOffline = async (on) => {const { targetInfos } = await A.conn.send('Target.getTargets');const sws = targetInfos.filter(t => t.type === 'service_worker' && t.url.startsWith(IDENT));for (const t of sws) {const sid = swSessions[t.targetId] ||= (await A.conn.send('Target.attachToTarget', { targetId: t.targetId, flatten: true })).sessionId;await A.conn.send('Network.enable', {}, sid);await A.conn.send('Network.emulateNetworkConditions', { offline: on, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }, sid);}return sws.length;};const nSw = await swOffline(true);await w.setOffline(true);const cutOff = await w.evaluate(`Promise.resolve(window.__hlAttempt(() => fetch('/api/online', { cache: 'no-store' }))).then(r => !r.ok)`);check('pwa: offline set up (the worker and the tab cannot reach ident)', nSw === 1 && cutOff, `${nSw} ${cutOff}`);// a real reload of the signed-in `/`: it is not kept, so the worker answers its data-free "Unavailable offline"await w.send('Page.reload');await w.waitFor('!!document.querySelector("main[data-hl-offline]") || !!document.querySelector("application-header")', { label: 'offline reload of /', timeout: 15000 }).catch(() => {});let doc = await w.evaluate('document.documentElement.outerHTML');check('pwa: OFFLINE reload of the signed-in / → the worker\'s "Unavailable offline" page, NO email or identity in it', /Unavailable offline/.test(doc) && !doc.includes(myEmail) && !doc.includes(myIdentity), doc.slice(0, 300));// the installed app's start: the data-free shell from the cache, and it says it is offlineawait w.goto(IDENT + '/start').catch(() => {});await w.waitFor('!!document.querySelector("application-header") && !!document.querySelector("#start")', { label: 'offline /start', timeout: 15000 }).catch(() => {});await w.waitFor('!!document.querySelector("#offline")', { label: 'offline note', timeout: 8000 }).catch(() => {});check('pwa: OFFLINE /start (the start_url) shows the shell (header, nav, start card) from the worker', await w.evaluate('location.pathname === "/start" && !!document.querySelector("application-header nav #navapps") && !!document.querySelector("#start")'), await w.evaluate('document.body.innerText.slice(0, 200)'));check('pwa: … and the header says it is offline', (await w.evaluate('(document.querySelector("#offline") || {}).textContent || ""')) === 'Offline — ident needs the network to sign in and to save', await w.evaluate('document.body.innerText.slice(0, 200)'));doc = await w.evaluate('document.documentElement.outerHTML');check('pwa: … and it contains NO email or identity of the signed-in user', !doc.includes(myEmail) && !doc.includes(myIdentity) && !/@example\.org/.test(doc));const kept = await keptText();check('pwa: NOTHING the browser keeps offline (worker caches + hl:web IndexedDB) holds the email or an identity', kept.length > 1000 && !kept.includes(myEmail) && !kept.includes(myIdentity) && !/@example\.org/.test(kept), kept.length + ' chars; hits: ' + [myEmail, myIdentity, '@example.org'].map(x => kept.indexOf(x)).join(' ') + ' ' + J(myIdentity));await shot(w, 'phone-pwa-offline');check('layout: 390px offline shell has no horizontal overflow', await noOverflow(w));await w.goto(IDENT + '/apps').catch(() => {});await w.waitFor('!!document.querySelector("main[data-hl-offline]")', { label: 'offline /apps', timeout: 8000 }).catch(() => {});check('pwa: offline, a data page (/apps) needs the network: the worker\'s "Unavailable offline" page', await w.evaluate('location.pathname === "/apps" && /Unavailable offline/.test(document.body.textContent)'), await w.evaluate('document.body.innerText.slice(0, 200)'));// back online, the start page opens ident by itselfawait w.goto(IDENT + '/start').catch(() => {});await w.waitForSelector('#start');await swOffline(false);await w.setOffline(false);await w.waitFor('location.pathname === "/" && !!document.querySelector("#meemail")', { label: 'back online → /', timeout: 30000 }).catch(() => {});await connected(w, 'pwa back online').catch(() => {});await sleep(1000);check('pwa: back online, the offline start page goes on to / by itself (signed in, no offline note)', await w.evaluate('location.pathname === "/" && !!document.querySelector("#meemail") && !document.querySelector("#offline")'), await w.evaluate('location.pathname + " " + document.body.innerText.slice(0, 120)'));await shot(w, 'phone-pwa-online');await w.close();} catch (e) {failures++;console.log('FAIL (aborted) ' + (e && e.stack || e));for (const [n, pg] of [['A', a], ['B', b]]) if (pg) console.log(`console ${n}: ` + J(pg.messages.slice(-8)));} finally {for (const br of [A, B]) { if (br) { try { await br.close(); } catch {} } }for (const { p } of procs) { try { p.kill('SIGTERM'); } catch {} }await sleep(500);for (const { p } of procs) { if (p.exitCode === null && p.signalCode === null) { try { p.kill('SIGKILL'); } catch {} } }await sleep(200);console.log(`\n${passes} passed, ${failures} failed`);process.exit(failures ? 1 : 0);}
Branches
- mainmain branch
Latest commits
- fe183516ident mission 009 (3/4): let only where reassigned — 293 never-reassigned lets are plain declarations; kept: reassigned, loop bodies, names of a file member, a name declared twice in one function; same outputmre
- d2e7f91bident mission 009 (2/4): one lib file per topic (login, accounts, identities, apps, invites, selector, notify + helpers, util), function routes as thin wrappers in lib/api.hl, project.hl = the map; same outputmre
- 91017164ident mission 009 (1/4): file moves — the root .hl files into lib/ (api.hl → lib/api-helpers.hl), styles.hl → components/styles.hl; imports adjusted, no other changemre
- f8bdcbc2ident: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); all gates greenmre
- ff78726cident: Hybriel master 190aa11d (fc838894 GC correctness, #127, #126 closure scopes); gates all greenmre
- a3a7d21aident: Hybriel master 8efba065 (#126 GC by bytes, #48 lambda params copy); session-writing lambdas take &sessionmre
- 98226b41antcolony#40: mission references point to the moved missionsmre
- ff805b9aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
- 51a7bcdfident: Hybriel master 73267707 (#122); /code uses the new page() signature; pending address passed as parameter; once-checksmre
- 836f644fident#24: installable app (manifest, service worker, data-free offline /start), own iconmre
- 8bebbbf2deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
- cc063ea2deploy.sh: never send .git or .gitignore to Byrodinmre
- 81b15b7bState of 2026-09-27, before the move to gitoriamre