Oregami
Repositories/oxedyne/daimond

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.
33import fs from 'node:fs';
34import path from 'node:path';
35import os from 'node:os';
36import { fileURLToPath } from 'node:url';
37import { open } from './harness.mjs';
38
39const HERE = path.dirname(fileURLToPath(import.meta.url));
40const I18NDIR = path.join(HERE, '..', 'www', 'i18n');
41const OUT = path.join(os.homedir(), '.cache/daimond/i18n-shots');
42fs.mkdirSync(OUT, { recursive: true });
43
44let failures = 0;
45const 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 ─────────────────────
51const 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};
58const multiset = (s, re) => (String(s).match(re) || []).sort().join(',');
59
60const en = loadTable('en.js');
61const codes = fs.readdirSync(I18NDIR)
62 .filter(f => f.endsWith('.js') && f !== 'en.js')
63 .map(f => f.slice(0, -3));
64check(en && Object.keys(en).length > 0, 'en.js loads', Object.keys(en || {}).length + ' keys');
65check(codes.length >= 7, 'all locale files present', codes.join(' '));
66
67for (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.
83const s = await open({ name: 'i18n' + Date.now() });
84const p = s.page;
85const 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
89for (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.
124const 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).
140const 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}
319await s.close();
320
321console.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.`);
324process.exit(failures ? 1 : 0);