oxedyne/daimond/dev/verify_i18n.mjs
15.1 KiB, 1 run
created by r2519314175:481, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | // verify_i18n.mjs — every locale is whole, and every surface follows a switch. |
| 2 | // |
| 3 | // Three halves, and the third exists because the first two were blind in the |
| 4 | // same place. |
| 5 | // |
| 6 | // The MECHANICAL half loads each www/i18n/<code>.js in a stub window and diffs |
| 7 | // it against en.js: same key set, same {placeholder} multiset per key, same |
| 8 | // HTML-tag multiset per key. This is what stops a translation drifting as en.js |
| 9 | // grows -- a missing key falls back to English silently in the app, so only a |
| 10 | // census can see it. |
| 11 | // |
| 12 | // The OPEN-AFTER half drives the real page: setLocale for every registered |
| 13 | // locale, THEN open the Admin drawer, and prove the engine resolved every key |
| 14 | // it was asked for. A literal {placeholder} or a key-shaped token is caught too. |
| 15 | // |
| 16 | // The SWITCH-WHILE-OPEN half is the one that was missing, and its absence is a |
| 17 | // bug in its own right. Every check above opens a surface AFTER the switch, so |
| 18 | // every surface it looks at is built fresh in the new language and a surface |
| 19 | // that was ALREADY ON SCREEN when the language changed is untested by |
| 20 | // construction. On 2026-08-10 the Admin drawer, left open, stayed in Spanish |
| 21 | // through a switch to Japanese and to Chinese; nothing here could see it, and |
| 22 | // the same shape had already shipped once before. So: open the surface, switch, |
| 23 | // and read it back WITHOUT closing it. |
| 24 | // |
| 25 | // The reading is done by comparing what is on screen against the outgoing |
| 26 | // table, so it names no surface: anything showing a string that only the |
| 27 | // language we just left produces has not repainted, whether it is a panel that |
| 28 | // existed when this was written or one added tomorrow. |
| 29 | // |
| 30 | // node dev/verify_i18n.mjs |
| 31 | // |
| 32 | // Needs dev/serve.mjs (DAIMOND_PORT, default 8777). No gateway and no model. |
| 33 | import fs from 'node:fs'; |
| 34 | import path from 'node:path'; |
| 35 | import os from 'node:os'; |
| 36 | import { fileURLToPath } from 'node:url'; |
| 37 | import { open } from './harness.mjs'; |
| 38 | |
| 39 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 40 | const I18NDIR = path.join(HERE, '..', 'www', 'i18n'); |
| 41 | const OUT = path.join(os.homedir(), '.cache/daimond/i18n-shots'); |
| 42 | fs.mkdirSync(OUT, { recursive: true }); |
| 43 | |
| 44 | let failures = 0; |
| 45 | const check = (cond, msg, detail) => { |
| 46 | console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail != null ? ' — ' + detail : '')); |
| 47 | if (!cond) failures++; |
| 48 | }; |
| 49 | |
| 50 | // ── Mechanical: every locale file against en.js ───────────────────── |
| 51 | const loadTable = (file) => { |
| 52 | let table = null; |
| 53 | const sandbox = { window: { DaimondI18n: { register: (c, t2) => { table = t2; } } } }; |
| 54 | const src = fs.readFileSync(path.join(I18NDIR, file), 'utf8'); |
| 55 | new Function('window', src)(sandbox.window); |
| 56 | return table; |
| 57 | }; |
| 58 | const multiset = (s, re) => (String(s).match(re) || []).sort().join(','); |
| 59 | |
| 60 | const en = loadTable('en.js'); |
| 61 | const codes = fs.readdirSync(I18NDIR) |
| 62 | .filter(f => f.endsWith('.js') && f !== 'en.js') |
| 63 | .map(f => f.slice(0, -3)); |
| 64 | check(en && Object.keys(en).length > 0, 'en.js loads', Object.keys(en || {}).length + ' keys'); |
| 65 | check(codes.length >= 7, 'all locale files present', codes.join(' ')); |
| 66 | |
| 67 | for (const code of codes) { |
| 68 | const t2 = loadTable(code + '.js'); |
| 69 | const ek = Object.keys(en), tk2 = Object.keys(t2 || {}); |
| 70 | const missing = ek.filter(k => !(k in t2)); |
| 71 | const invented = tk2.filter(k => !(k in en)); |
| 72 | const ph = ek.filter(k => k in t2 && multiset(en[k], /\{\w+\}/g) !== multiset(t2[k], /\{\w+\}/g)); |
| 73 | const tag = ek.filter(k => k in t2 && multiset(en[k], /<\/?\w+/g) !== multiset(t2[k], /<\/?\w+/g)); |
| 74 | check(missing.length === 0, code + ': no missing keys', missing.length ? missing.slice(0, 4).join(' ') : ek.length + '/' + ek.length); |
| 75 | check(invented.length === 0, code + ': no invented keys', invented.slice(0, 4).join(' ') || undefined); |
| 76 | check(ph.length === 0, code + ': placeholders match', ph.slice(0, 4).join(' ') || undefined); |
| 77 | check(tag.length === 0, code + ': HTML tags match', tag.slice(0, 4).join(' ') || undefined); |
| 78 | } |
| 79 | |
| 80 | // ── Browser: the engine serves each locale without a gap ──────────── |
| 81 | // Opening AFTER the switch. Everything below this that opens after a switch is |
| 82 | // blind to the class of bug the switch-while-open half exists for. |
| 83 | const s = await open({ name: 'i18n' + Date.now() }); |
| 84 | const p = s.page; |
| 85 | const missingSince = (mark) => [...new Set(s.logs.slice(mark) |
| 86 | .filter(l => /i18n: no string for/.test(l)) |
| 87 | .map(l => (l.match(/"([^"]+)"/) || [])[1]))].sort(); |
| 88 | |
| 89 | for (const code of ['en'].concat(codes)) { |
| 90 | const mark = s.logs.length; |
| 91 | const loaded = await p.evaluate(c => window.DaimondI18n.setLocale(c), code); |
| 92 | await p.waitForTimeout(300); |
| 93 | await p.evaluate(() => { try { document.getElementById('settings-btn').click(); } catch (e) {} }); |
| 94 | await p.waitForTimeout(700); |
| 95 | const txt = await p.evaluate(() => { |
| 96 | const el = document.getElementById('admin-body'); |
| 97 | return el ? el.innerText : '(no admin-body)'; |
| 98 | }); |
| 99 | const braces = (txt.match(/\{[a-z_]+\}/gi) || []); |
| 100 | const gone = missingSince(mark); |
| 101 | check(loaded === true, code + ': setLocale loads'); |
| 102 | check(gone.length === 0, code + ': no missing-key warnings', gone.slice(0, 4).join(' ') || undefined); |
| 103 | check(braces.length === 0, code + ': no literal placeholders on screen', braces.join(',') || undefined); |
| 104 | await p.screenshot({ path: path.join(OUT, `verify-${code}-admin.png`) }); |
| 105 | await p.evaluate(() => { try { document.getElementById('admin-close').click(); } catch (e) {} }); |
| 106 | await p.waitForTimeout(200); |
| 107 | } |
| 108 | // ── Switch while open: a surface on screen shows the new language ─── |
| 109 | // |
| 110 | // The rule: open the surface, switch, read it back WITHOUT closing it. |
| 111 | // |
| 112 | // What counts as "still in the old language" is decided from the locale FILES, |
| 113 | // not from anything the app says about itself — a string the outgoing table |
| 114 | // produces for some key, which the incoming table renders differently, and |
| 115 | // which the incoming table does not produce for ANY key. That last clause is |
| 116 | // what keeps a word two languages happen to share out of the result. |
| 117 | // |
| 118 | // Nothing here names a surface in its assertion, which is the point: the check |
| 119 | // sweeps whatever is on screen, so a panel added after this was written is |
| 120 | // covered by it as it stands. |
| 121 | |
| 122 | /// Strings only `from` produces. Text on screen that is one of these, after a |
| 123 | /// switch to `to`, is a surface that did not repaint. |
| 124 | const outgoingOf = (from, to) => { |
| 125 | const out = {}; |
| 126 | const held = new Set(Object.values(to)); |
| 127 | for (const k of Object.keys(from)) { |
| 128 | const v = from[k]; |
| 129 | if (typeof v !== 'string' || v.trim().length < 2) continue; |
| 130 | if (v === to[k] || held.has(v)) continue; |
| 131 | if (!(v in out)) out[v] = k; |
| 132 | } |
| 133 | return out; |
| 134 | }; |
| 135 | |
| 136 | /// What is showing, and should not be: the outgoing table's strings, and any |
| 137 | /// key rendered raw because the table had no entry for it (`t` returns the key, |
| 138 | /// so a repaint that reaches for a missing key prints `search.head` on screen |
| 139 | /// where a fresh mount printed nothing). |
| 140 | const SWEEP = ([sel, outgoing, keys]) => { |
| 141 | const roots = sel ? [...document.querySelectorAll(sel)] : [document.body]; |
| 142 | const keySet = new Set(keys); |
| 143 | const stale = [], raw = []; |
| 144 | const where = (n) => { |
| 145 | let e = n.nodeType === 1 ? n : n.parentElement; |
| 146 | const parts = []; |
| 147 | while (e && e !== document.body) { |
| 148 | if (e.id) { parts.unshift('#' + e.id); break; } |
| 149 | if (typeof e.className === 'string' && e.className.trim()) parts.unshift('.' + e.className.trim().split(/\s+/)[0]); |
| 150 | e = e.parentElement; |
| 151 | } |
| 152 | return parts.join(' ') || '(body)'; |
| 153 | }; |
| 154 | let shown = 0; |
| 155 | for (const root of roots) { |
| 156 | if (!root.getClientRects || !root.getClientRects().length) continue; |
| 157 | shown++; |
| 158 | const walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); |
| 159 | let n; |
| 160 | while ((n = walk.nextNode())) { |
| 161 | const v = (n.nodeValue || '').trim(); |
| 162 | if (!v) continue; |
| 163 | const e = n.parentElement; |
| 164 | if (!e || !e.getClientRects().length) continue; |
| 165 | if (outgoing[v]) stale.push(outgoing[v] + ' @' + where(n)); |
| 166 | else if (keySet.has(v)) raw.push(v + ' @' + where(n)); |
| 167 | } |
| 168 | // A control's spoken name is as much a string as its label, and one fixed |
| 169 | // at mount in the language of the day is the same defect unheard. |
| 170 | for (const a of ['title', 'aria-label', 'placeholder', 'alt', 'data-label']) { |
| 171 | const list = [...root.querySelectorAll('[' + a + ']')]; |
| 172 | if (root.hasAttribute(a)) list.push(root); |
| 173 | for (const e of list) { |
| 174 | if (!e.getClientRects().length) continue; |
| 175 | const v = (e.getAttribute(a) || '').trim(); |
| 176 | if (!v) continue; |
| 177 | if (outgoing[v]) stale.push(a + ' ' + outgoing[v] + ' @' + where(e)); |
| 178 | else if (keySet.has(v)) raw.push(a + ' ' + v + ' @' + where(e)); |
| 179 | } |
| 180 | } |
| 181 | } |
| 182 | return { shown, stale: [...new Set(stale)], raw: [...new Set(raw)] }; |
| 183 | }; |
| 184 | |
| 185 | { |
| 186 | const tables = Object.fromEntries(['en'].concat(codes).map(c => [c, loadTable(c + '.js')])); |
| 187 | const ring = ['en'].concat(codes); // the locales, in a cycle |
| 188 | const enKeys = Object.keys(en); |
| 189 | |
| 190 | /// Open `surface`, switch from `from` to `to`, and read it back where it |
| 191 | /// stands. Returns whether it was on screen at all, so a surface the layout |
| 192 | /// declined to seat is reported rather than counted as a pass. |
| 193 | const switchWhileOpen = async ({ name, sel, show }, from, to) => { |
| 194 | await p.evaluate(c => window.DaimondI18n.setLocale(c), from); |
| 195 | await p.waitForTimeout(250); |
| 196 | try { await show(); } catch (e) { /* a surface this build has no room for */ } |
| 197 | await p.waitForTimeout(700); |
| 198 | const out = outgoingOf(tables[from], tables[to]); |
| 199 | const before = await p.evaluate(SWEEP, [sel, out, enKeys]); |
| 200 | if (!before.shown) { console.log(' -- ' + name + ' — not on screen, nothing switched under it'); return false; } |
| 201 | // Whether the surface was showing anything that TELLS the two languages |
| 202 | // apart. If it was not, the switch below proves nothing about it, and |
| 203 | // saying "ok" would be the same kind of lie this half exists to stop. The |
| 204 | // whole-document ring after this catches such a surface anyway: it is |
| 205 | // stale in exactly one language, and every language takes a turn as the |
| 206 | // one being left. |
| 207 | if (!before.stale.length) { |
| 208 | console.log(' -- ' + name + ` — nothing on it distinguishes ${from} from ${to}; the ring below covers it`); |
| 209 | return false; |
| 210 | } |
| 211 | const mark = s.logs.length; |
| 212 | await p.evaluate(c => window.DaimondI18n.setLocale(c), to); |
| 213 | await p.waitForTimeout(700); |
| 214 | const after = await p.evaluate(SWEEP, [sel, out, enKeys]); |
| 215 | const gone = missingSince(mark); |
| 216 | check(after.stale.length === 0, |
| 217 | `${name}: open in ${from}, switched to ${to} — shows ${to}`, |
| 218 | after.stale.slice(0, 6).join(' ') || undefined); |
| 219 | check(after.raw.length === 0, |
| 220 | `${name}: the repaint asked for no key the table lacks`, |
| 221 | after.raw.slice(0, 6).join(' ') || undefined); |
| 222 | check(gone.length === 0, |
| 223 | `${name}: no missing-key warnings during the repaint`, |
| 224 | gone.slice(0, 4).join(' ') || undefined); |
| 225 | return true; |
| 226 | }; |
| 227 | |
| 228 | // The panels come from the layout engine, which reads them off the DOM — so |
| 229 | // this list is the app's, not a copy of it kept here to go stale. |
| 230 | const panels = await p.evaluate(() => window.DaimondPanels.panels().map(x => x.id)); |
| 231 | const surfaces = panels.map(id => ({ |
| 232 | name: 'panel ' + id, |
| 233 | sel: '#panel-' + id, |
| 234 | show: () => p.evaluate(i => window.DaimondPanels.show(i), id), |
| 235 | })).concat([ |
| 236 | { name: 'drawer home', sel: '#admin', show: () => p.evaluate(() => window.DaimondAdmin.home()) }, |
| 237 | { name: 'drawer models', sel: '#admin', show: () => p.evaluate(() => window.DaimondAdmin.settings('')) }, |
| 238 | { name: 'drawer credits', sel: '#admin', show: () => p.evaluate(() => window.DaimondAdmin.credits('')) }, |
| 239 | { name: 'drawer version', sel: '#admin', show: () => p.evaluate(() => window.DaimondAdmin.release()) }, |
| 240 | { name: 'drawer push', sel: '#admin', show: () => p.evaluate(() => window.DaimondAdmin.push()) }, |
| 241 | { |
| 242 | // A form is a question a caller is waiting on, so it is opened and |
| 243 | // answered rather than left standing. |
| 244 | name: 'drawer form', sel: '#admin', |
| 245 | show: () => p.evaluate(() => { |
| 246 | window.DaimondAdmin.form({ |
| 247 | title: window.DaimondI18n.t('drawer.admin'), |
| 248 | message: window.DaimondI18n.t('home.change_name'), |
| 249 | fields: [{ name: 'n', label: window.DaimondI18n.t('common.save'), value: '' }], |
| 250 | }); |
| 251 | }), |
| 252 | }, |
| 253 | ]); |
| 254 | |
| 255 | // Each surface takes a different pair off the ring, so the sweep covers every |
| 256 | // language it ships without running every surface against every one of them. |
| 257 | let swept = 0; |
| 258 | for (let i = 0; i < surfaces.length; i++) { |
| 259 | const from = ring[i % ring.length]; |
| 260 | const to = ring[(i + 1) % ring.length]; |
| 261 | if (await switchWhileOpen(surfaces[i], from, to)) swept++; |
| 262 | } |
| 263 | await p.evaluate(() => { try { window.DaimondAdmin.close(); } catch (e) {} }); |
| 264 | check(swept > 0, 'switch-while-open: surfaces were actually on screen for a switch', swept + ' swept'); |
| 265 | |
| 266 | // And the whole document, through every language in turn, with the drawer |
| 267 | // standing open the entire time — the exact shape of the report: a drawer |
| 268 | // opened once and a language changed under it, again and again. |
| 269 | await p.evaluate(() => window.DaimondAdmin.home()); |
| 270 | await p.waitForTimeout(500); |
| 271 | for (let i = 0; i < ring.length; i++) { |
| 272 | const from = ring[i], to = ring[(i + 1) % ring.length]; |
| 273 | await p.evaluate(c => window.DaimondI18n.setLocale(c), from); |
| 274 | await p.waitForTimeout(450); |
| 275 | await p.evaluate(c => window.DaimondI18n.setLocale(c), to); |
| 276 | await p.waitForTimeout(750); |
| 277 | const after = await p.evaluate(SWEEP, [null, outgoingOf(tables[from], tables[to]), enKeys]); |
| 278 | check(after.stale.length === 0, |
| 279 | `drawer left open, ${from} → ${to}: nothing on the page is still ${from}`, |
| 280 | after.stale.slice(0, 6).join(' ') || undefined); |
| 281 | } |
| 282 | await p.screenshot({ path: path.join(OUT, 'verify-switch-while-open.png') }); |
| 283 | await p.evaluate(() => { try { window.DaimondAdmin.close(); } catch (e) {} }); |
| 284 | } |
| 285 | |
| 286 | // ── Reload: a remembered language survives without the picker ─────── |
| 287 | // Every other check goes through setLocale, which loads the table itself. |
| 288 | // A RELOADED page only has the stored code -- the engine must fetch the |
| 289 | // table unprompted, and surfaces drawn before it arrives must repaint. |
| 290 | // This gap shipped once: every reload came back English until the picker |
| 291 | // was touched again, and no setLocale-based check could see it. |
| 292 | { |
| 293 | const frTable = loadTable('fr.js'); |
| 294 | const s2 = await open({ name: 'i18nreload' + Date.now() }); |
| 295 | const p2 = s2.page; |
| 296 | await p2.evaluate(() => window.DaimondI18n.setLocale('fr')); |
| 297 | await p2.waitForTimeout(400); |
| 298 | await p2.reload({ waitUntil: 'domcontentloaded' }); |
| 299 | await p2.waitForTimeout(2000); |
| 300 | await p2.evaluate(() => { |
| 301 | const pass = document.getElementById('id-pass'); |
| 302 | if (pass && pass.getClientRects().length) { |
| 303 | pass.value = 'testpass1234'; |
| 304 | pass.dispatchEvent(new Event('input', { bubbles: true })); |
| 305 | document.getElementById('id-primary').click(); |
| 306 | } |
| 307 | }); |
| 308 | await p2.waitForTimeout(2600); |
| 309 | const state = await p2.evaluate(() => ({ |
| 310 | tChatNew: window.DaimondI18n.t('chat.new'), |
| 311 | btn: (document.querySelector('.empty-new-session') || {}).textContent || '(absent)', |
| 312 | })); |
| 313 | check(state.tChatNew === frTable['chat.new'], |
| 314 | 'reload: the remembered locale table loads unprompted', state.tChatNew); |
| 315 | check(state.btn === frTable['chat.new'], |
| 316 | 'reload: surfaces drawn before the table arrived repaint', state.btn); |
| 317 | await s2.close(); |
| 318 | } |
| 319 | await s.close(); |
| 320 | |
| 321 | console.log(failures === 0 |
| 322 | ? '\ni18n: every locale is whole, and a surface open when the language changes shows the new one.' |
| 323 | : `\ni18n: ${failures} check(s) failed.`); |
| 324 | process.exit(failures ? 1 : 0); |