Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_ext_i18n.mjs

21.2 KiB, 1 run

created by r2519314175:405, 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_ext_i18n.mjs — Daimond Hands in the eight languages the app speaks.
2//
3// The extension is where the user is asked to GRANT a page to an agent. A
4// reader who cannot read that window is being asked to trust something at the
5// exact moment they cannot understand the question, so this file is mostly
6// about that one window.
7//
8// Two halves.
9//
10// The tables, read from disk: every locale carries every key, no key is empty,
11// the placeholders line up, the product nouns are left alone, and the source
12// asks for nothing that is not there.
13//
14// The extension, in a real browser: the manifest's own `description` really is
15// served from `_locales` (nothing but `_locales` can do that); the grant window
16// really does draw itself in the language the APP chose rather than the one
17// Chrome is running in; the answer still reaches the broker and still refuses
18// in English, which is the daimon's language and not the user's; and a locale
19// we do not ship falls back to English rather than to a blank window.
20//
21// Needs dev/serve.mjs (DAIMOND_PORT, default 8777). Run it headed, under xvfb:
22// xvfb-run -a node dev/verify_ext_i18n.mjs
23import path from 'node:path';
24import os from 'node:os';
25import fs from 'node:fs';
26import http from 'node:http';
27import { pathToFileURL } from 'node:url';
28
29const PW = path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs');
30const { chromium } = await import(pathToFileURL(PW).href);
31const CHROME = `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`;
32import { fileURLToPath } from 'node:url';
33// Chromium's ozone platform is chosen by autodetection and prefers Wayland whenever
34// `WAYLAND_DISPLAY` is set -- which it is in every rc session on argonaut -- so a headed
35// run under `xvfb-run` still went to the compositor and opened a window on the owner's
36// desktop. Importing this strips the two variables from `process.env`, which is all a
37// launcher that spreads `process.env` needs. See dev/display.mjs.
38import './display.mjs';
39const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); // this checkout, not one developer's home
40const EXT = `${ROOT}/ext`;
41// The SHIPPED manifest names one origin, and it is not this dev server, so the
42// BROWSER is given the generated dev build -- the same files, linked, with the
43// loopback origins added back. Everything read from disk below is still read
44// from the source. See dev/extdev.mjs.
45const { extDev } = await import(pathToFileURL(`${ROOT}/dev/extdev.mjs`).href);
46const EXT_DEV = await extDev();
47const EXTID = 'mpliijponglmmffjnonahhignkpkhmij';
48// Not /tmp -- see the SCRATCH note in harness.mjs.
49const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond');
50// The world's dev server -- see dev/world.sh. Kept inline rather than imported,
51// so this stays standalone and does not load the harness.
52const APP = process.env.DAIMOND_APP || `http://localhost:${process.env.DAIMOND_PORT || 8777}`;
53const SITE_PORT = Number(process.env.EXTI18N_PORT || 9123);
54const SITE = `http://127.0.0.1:${SITE_PORT}`;
55
56const ok = [], bad = [];
57const check = (name, pass, detail) => {
58 (pass ? ok : bad).push(name);
59 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
60};
61
62// ── The locales we ship ──────────────────────────────────────────────
63//
64// App locale code -> Chrome `_locales` directory name. Chrome's directories are
65// its own: an underscore, and its own region spellings. `pt-BR` becomes `pt_BR`
66// and `zh-Hans` becomes `zh_CN`, because `_locales` has no script-tag form.
67const DIRS = {
68 'en': 'en', 'de': 'de', 'es': 'es', 'fr': 'fr',
69 'ja': 'ja', 'ko': 'ko', 'pt-BR': 'pt_BR', 'zh-Hans': 'zh_CN',
70};
71
72const table = {};
73for (const [code, dir] of Object.entries(DIRS)) {
74 const p = `${EXT}/_locales/${dir}/messages.json`;
75 try {
76 table[code] = JSON.parse(fs.readFileSync(p, 'utf8'));
77 } catch (e) {
78 table[code] = null;
79 check(`${code} has a table at _locales/${dir}/`, false, String(e.message));
80 }
81}
82
83const EN = table.en || {};
84const KEYS = Object.keys(EN);
85check('the English table is the baseline and is not empty', KEYS.length > 0, `${KEYS.length} messages`);
86
87// ── Every locale, every key ──────────────────────────────────────────
88for (const code of Object.keys(DIRS)) {
89 const tbl = table[code];
90 if (!tbl) continue;
91 const missing = KEYS.filter((k) => !tbl[k]);
92 const extra = Object.keys(tbl).filter((k) => !EN[k]);
93 const empty = KEYS.filter((k) => tbl[k] && !String(tbl[k].message || '').trim());
94 check(`${code}: every key is present`, missing.length === 0, missing.join(', '));
95 check(`${code}: no key is empty`, empty.length === 0, empty.join(', '));
96 check(`${code}: no key the baseline does not have`, extra.length === 0, extra.join(', '));
97
98 // A `$HOST$` that survives in one language and is dropped in another is a
99 // window that names the site to some users and not to others.
100 const phBad = KEYS.filter((k) => {
101 if (!tbl[k]) return false;
102 const wantsHost = /\$HOST\$/.test(EN[k].message);
103 const hasHost = /\$HOST\$/.test(tbl[k].message);
104 const declared = !!(tbl[k].placeholders && tbl[k].placeholders.host);
105 return wantsHost !== hasHost || wantsHost !== declared;
106 });
107 check(`${code}: the $HOST$ placeholder is kept and declared`, phBad.length === 0, phBad.join(', '));
108}
109
110// ── Do not translate ─────────────────────────────────────────────────
111//
112// "Daimond" is a product noun, "Daimond Hands" is the extension's name, and a
113// user must not meet two words for one thing. Wherever the English says them,
114// every other language says them too, verbatim.
115for (const noun of ['Daimond Hands', 'Daimond', 'Chrome']) {
116 const owed = KEYS.filter((k) => EN[k].message.includes(noun));
117 const broken = [];
118 for (const code of Object.keys(DIRS)) {
119 if (!table[code] || code === 'en') continue;
120 for (const k of owed) {
121 const m = table[code][k] && table[code][k].message;
122 if (m && !m.includes(noun)) broken.push(`${code}/${k}`);
123 }
124 }
125 check(`"${noun}" is left untranslated everywhere`, broken.length === 0,
126 `${owed.length} keys carry it; broken: ${broken.join(', ')}`);
127}
128
129// ── Register ─────────────────────────────────────────────────────────
130//
131// Calm, plain, concrete. The app has no exclamation marks in it and neither
132// should the window that asks for a permission.
133{
134 const shouty = [];
135 for (const code of Object.keys(DIRS)) {
136 if (!table[code]) continue;
137 for (const k of Object.keys(table[code])) {
138 if (/[!!]/.test(table[code][k].message)) shouty.push(`${code}/${k}`);
139 }
140 }
141 check('no exclamation marks anywhere', shouty.length === 0, shouty.join(', '));
142}
143
144// ── The source and the tables agree ──────────────────────────────────
145const SRC = ['manifest.json', 'i18n.js', 'background.js', 'hand.js', 'content.js', 'popup.js', 'popup.html', 'grant.js', 'grant.html', 'announce.js']
146 .map((f) => fs.readFileSync(`${EXT}/${f}`, 'utf8')).join('\n');
147
148{
149 // Asked for but not shipped: the surface would print the key itself.
150 const asked = new Set();
151 for (const m of SRC.matchAll(/\b[tT]\(\s*'([a-z0-9_]+)'/g)) asked.add(m[1]);
152 for (const m of SRC.matchAll(/data-i18n(?:-title)?="([a-z0-9_]+)"/g)) asked.add(m[1]);
153 for (const m of SRC.matchAll(/__MSG_([a-z0-9_]+)__/g)) asked.add(m[1]);
154 const unknown = [...asked].filter((k) => !EN[k]);
155 check('every key the source asks for exists', unknown.length === 0, unknown.join(', '));
156 check('the source really does ask for them', asked.size > 10, `${asked.size} keys used`);
157
158 // Shipped but never asked for: a translator's work nobody will ever read.
159 const dead = KEYS.filter((k) => !SRC.includes(k));
160 check('no key is shipped that the source never uses', dead.length === 0, dead.join(', '));
161}
162
163// ── The manifest ─────────────────────────────────────────────────────
164{
165 const man = JSON.parse(fs.readFileSync(`${EXT}/manifest.json`, 'utf8'));
166 check('the manifest declares a default_locale', man.default_locale === 'en', String(man.default_locale));
167 check('the manifest description comes from _locales', man.description === '__MSG_ext_desc__', man.description);
168 // The name is a product noun. Routing it through _locales would only invite
169 // somebody to translate it.
170 check('the manifest name is the untranslated product noun', man.name === 'Daimond Hands', man.name);
171}
172
173// ── The extension, in a real browser ─────────────────────────────────
174
175/// A tiny site of our own, so nothing here depends on another test's server.
176const site = http.createServer((req, res) => {
177 const body = req.url.startsWith('/login')
178 ? '<!doctype html><title>Sign in</title><main><h1>Sign in</h1>'
179 + '<form action="/in" method="post"><input name="u" placeholder="User" type="text">'
180 + '<input name="p" placeholder="Password" type="password">'
181 + '<button type="submit">Log in</button></form></main>'
182 : '<!doctype html><title>Home</title><main><h1>Home</h1><p>An ordinary page.</p></main>';
183 res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
184 res.end(body);
185});
186await new Promise((r) => site.listen(SITE_PORT, '127.0.0.1', r));
187
188function launch(profile) {
189 fs.rmSync(profile, { recursive: true, force: true });
190 fs.mkdirSync(profile, { recursive: true });
191 return chromium.launchPersistentContext(profile, {
192 executablePath: CHROME, headless: false,
193 args: ['--no-sandbox', '--disable-dev-shm-usage',
194 `--disable-extensions-except=${EXT_DEV}`, `--load-extension=${EXT_DEV}`],
195 viewport: { width: 1280, height: 900 },
196 });
197}
198async function waitSW(b) {
199 for (let i = 0; i < 80 && !b.serviceWorkers().length; i++) await new Promise((r) => setTimeout(r, 100));
200 return b.serviceWorkers()[0];
201}
202const msg = (code, key) => table[code][key].message;
203/// The message with its `$HOST$` filled in, the way the window will show it.
204const msgHost = (code, key, host) => msg(code, key).replace(/\$HOST\$/g, host);
205
206/// Put a language into the app the way a user does, and let announce.js carry
207/// it across. Reloading is what makes it deterministic: the content script
208/// reads the choice at document_start.
209async function speak(page, code) {
210 await page.evaluate((c) => {
211 if (c) localStorage.setItem('daimond-locale', c);
212 else localStorage.removeItem('daimond-locale');
213 }, code);
214 await page.reload({ waitUntil: 'domcontentloaded' });
215 await page.waitForTimeout(350);
216}
217
218const PROFILE = path.join(SCRATCH, 'verify-ext-i18n');
219let b = await launch(PROFILE);
220
221try {
222 const sw = await waitSW(b);
223 check('the extension service worker started', !!sw);
224
225 // Only `_locales` can reach the manifest. If this is the literal
226 // `__MSG_ext_desc__`, the whole mechanism is not wired up.
227 const desc = sw ? await sw.evaluate(() => chrome.runtime.getManifest().description) : '';
228 check('the manifest description is served from _locales', desc === msg('en', 'ext_desc'), desc.slice(0, 60));
229
230 const app = await b.newPage();
231 await app.goto(APP + '/', { waitUntil: 'domcontentloaded' });
232 await app.waitForTimeout(400);
233
234 const grant = await b.newPage();
235 const HOST = 'example.com';
236 const grantUrl = `chrome-extension://${EXTID}/grant.html?nonce=t&kind=site&host=${HOST}`
237 + `&pattern=${encodeURIComponent('*://*.' + HOST + '/*')}`;
238
239 // The grant window, once per language.
240 for (const code of Object.keys(DIRS)) {
241 await speak(app, code);
242 await grant.goto(grantUrl, { waitUntil: 'domcontentloaded' });
243 await grant.waitForTimeout(250);
244 const got = await grant.evaluate(() => ({
245 head: document.getElementById('head').textContent,
246 scope: document.getElementById('scope').textContent,
247 body: document.getElementById('body').textContent,
248 fine: document.getElementById('fine').textContent,
249 allow: document.getElementById('allow').textContent,
250 deny: document.getElementById('deny').textContent,
251 lang: document.documentElement.lang,
252 }));
253 const want = {
254 head: msg(code, 'grant_site_head'),
255 scope: msgHost(code, 'grant_site_scope', HOST),
256 body: msg(code, 'grant_site_body'),
257 fine: msg(code, 'grant_site_fine'),
258 allow: msg(code, 'grant_site_allow'),
259 deny: msg(code, 'grant_deny'),
260 };
261 const wrong = Object.keys(want).filter((k) => got[k] !== want[k]);
262 check(`the grant window speaks ${code}`, wrong.length === 0,
263 wrong.map((k) => `${k}: ${JSON.stringify(got[k])}`).join(' | '));
264 check(`the grant window says it is in ${code}`, got.lang === code, got.lang);
265 }
266
267 // The other question, in one language, so the mirror branch is covered too.
268 {
269 await speak(app, 'ja');
270 await grant.goto(`chrome-extension://${EXTID}/grant.html?nonce=t&kind=mirror`
271 + `&pattern=${encodeURIComponent('<all_urls>')}`, { waitUntil: 'domcontentloaded' });
272 await grant.waitForTimeout(250);
273 const got = await grant.evaluate(() => ({
274 head: document.getElementById('head').textContent,
275 allow: document.getElementById('allow').textContent,
276 hostShown: !document.getElementById('host').hidden,
277 }));
278 check('the live-view question speaks ja too',
279 got.head === msg('ja', 'grant_mirror_head') && got.allow === msg('ja', 'grant_mirror_allow'),
280 JSON.stringify(got));
281 check('the live-view question still names no host', !got.hostShown);
282 }
283
284 // A language we do not ship falls back to English, not to a blank window.
285 for (const [what, code] of [['a locale we do not ship', 'zh-Hant'], ['no choice at all', '']]) {
286 await speak(app, code);
287 await grant.goto(grantUrl, { waitUntil: 'domcontentloaded' });
288 await grant.waitForTimeout(250);
289 const got = await grant.evaluate(() => ({
290 head: document.getElementById('head').textContent,
291 allow: document.getElementById('allow').textContent,
292 scope: document.getElementById('scope').textContent,
293 }));
294 check(`${what} falls back to English`,
295 got.head === msg('en', 'grant_site_head') && got.allow === msg('en', 'grant_site_allow'),
296 JSON.stringify(got).slice(0, 160));
297 // The fallback runs through chrome.i18n rather than through our own
298 // table, and that is the path where a `$HOST$` left standing would tell
299 // the user their site is called "$HOST$".
300 check(`${what} still fills in the host`,
301 got.scope === msgHost('en', 'grant_site_scope', HOST), JSON.stringify(got.scope));
302 }
303
304 // The popup, in one language.
305 {
306 await speak(app, 'de');
307 const pop = await b.newPage();
308 await pop.goto(`chrome-extension://${EXTID}/popup.html`, { waitUntil: 'domcontentloaded' });
309 await pop.waitForTimeout(500);
310 const got = await pop.evaluate(() => ({
311 head: document.querySelector('h2').textContent,
312 mode: document.getElementById('mode').textContent,
313 none: document.querySelector('#granted li') ? document.querySelector('#granted li').textContent : '',
314 }));
315 check('the popup speaks de',
316 got.head === msg('de', 'popup_granted_head')
317 && got.mode.startsWith(msg('de', 'popup_mode_idle'))
318 && got.none === msg('de', 'popup_none'),
319 JSON.stringify(got));
320 await pop.close();
321 }
322 await grant.close();
323
324 // ── The whole round trip: ask, answer, refuse ────────────────────
325 //
326 // The window the broker itself opens, in the app's language; the click that
327 // answers it; and the refusal that comes back — which is addressed to the
328 // model, not to the user, and so stays English however the window was
329 // written.
330 await speak(app, 'ja');
331 const opened = new Promise((resolve) => {
332 const on = (p) => { if (p.url().includes('grant.html')) { b.off('page', on); resolve(p); } };
333 b.on('page', on);
334 });
335 const answer = app.evaluate(({ extId, url }) => new Promise((resolve) => {
336 chrome.runtime.sendMessage(extId, { cmd: 'open', url }, (r) =>
337 resolve(r || { ok: false, error: chrome.runtime.lastError && chrome.runtime.lastError.message }));
338 }), { extId: EXTID, url: SITE + '/' });
339
340 const win = await Promise.race([opened, new Promise((r) => setTimeout(() => r(null), 15000))]);
341 check('asking to open an un-approved site opens the grant window', !!win);
342 if (win) {
343 await win.waitForTimeout(400);
344 const shown = await win.evaluate(() => ({
345 head: document.getElementById('head').textContent,
346 host: document.getElementById('host').textContent,
347 deny: document.getElementById('deny').textContent,
348 }));
349 check('that window is in ja, and names the host',
350 shown.head === msg('ja', 'grant_site_head') && shown.deny === msg('ja', 'grant_deny')
351 && shown.host === '127.0.0.1',
352 JSON.stringify(shown));
353 await win.click('#deny');
354 }
355 const r = await Promise.race([answer, new Promise((res) => setTimeout(() => res({ ok: false, error: 'TIMEOUT' }), 20000))]);
356 check('the answer reaches the broker and the site is refused', r && r.ok === false, JSON.stringify(r).slice(0, 160));
357 check('the refusal to the model is still English',
358 !!(r && /The user declined/.test(r.error || '')), (r && r.error || '').slice(0, 120));
359
360 await b.close();
361
362 // ── The wheel, and the button that gives it back ─────────────────
363 //
364 // Chrome's own permission bubble cannot be clicked under automation, so the
365 // host grant is seeded into the profile — the user having said Allow once.
366 // Everything after it is real: a login page takes the wheel, the reason the
367 // app prints back is in the user's language, and so is the button in the tab.
368 const P2 = path.join(SCRATCH, 'verify-ext-i18n-granted');
369 b = await launch(P2);
370 await waitSW(b);
371 await b.close();
372 const prefsPath = path.join(P2, 'Default', 'Preferences');
373 const prefs = JSON.parse(fs.readFileSync(prefsPath, 'utf8'));
374 for (const k of ['granted_permissions', 'active_permissions']) {
375 prefs.extensions.settings[EXTID][k].explicit_host = ['*://*/*', '<all_urls>'];
376 }
377 fs.writeFileSync(prefsPath, JSON.stringify(prefs));
378
379 b = await chromium.launchPersistentContext(P2, {
380 executablePath: CHROME, headless: false,
381 args: ['--no-sandbox', '--disable-dev-shm-usage',
382 `--disable-extensions-except=${EXT_DEV}`, `--load-extension=${EXT_DEV}`],
383 viewport: { width: 1280, height: 900 },
384 });
385 await waitSW(b);
386 const app2 = await b.newPage();
387 await app2.goto(APP + '/', { waitUntil: 'domcontentloaded' });
388 await app2.waitForTimeout(400);
389 await speak(app2, 'ja');
390
391 const send = (cmd, extra) => app2.evaluate(({ extId, cmd, extra }) => new Promise((resolve) => {
392 chrome.runtime.sendMessage(extId, Object.assign({ cmd }, extra || {}), (r) =>
393 resolve(r || { ok: false, error: chrome.runtime.lastError && chrome.runtime.lastError.message }));
394 }), { extId: EXTID, cmd, extra });
395
396 const o = await send('open', { url: SITE + '/login' });
397 check('a granted site opens without asking again', !!(o && o.ok), JSON.stringify(o).slice(0, 140));
398 await app2.waitForTimeout(2000);
399 const st = await send('status');
400 check('a login page takes the wheel', !!(st && st.mode === 'user'), JSON.stringify(st).slice(0, 140));
401 check('the reason the app prints back is in ja',
402 !!(st && st.reason === msg('ja', 'reason_password')),
403 JSON.stringify(st && st.reason));
404
405 const tab = b.pages().find((p) => p.url().startsWith(SITE));
406 // The host element is all the page can see: the button lives in a CLOSED
407 // shadow root, which is the point of it. Reading the label therefore needs
408 // the debugger rather than script — `pierce` is the only way in, and that
409 // nothing else can do this is the property being relied on.
410 let planted = false, label = '';
411 if (tab) {
412 for (let i = 0; i < 30 && !planted; i++) {
413 planted = await tab.evaluate(() =>
414 [...document.querySelectorAll('div')].some((d) =>
415 d.style.position === 'fixed' && d.style.zIndex === '2147483647')
416 ).catch(() => false);
417 if (!planted) await tab.waitForTimeout(200);
418 }
419 const leak = await tab.evaluate(() => {
420 for (const d of document.querySelectorAll('div')) if (d.shadowRoot) return true;
421 return false;
422 }).catch(() => false);
423 check('the page cannot read the overlay for itself', !leak);
424 try {
425 const cdp = await tab.context().newCDPSession(tab);
426 const { root } = await cdp.send('DOM.getDocument', { depth: -1, pierce: true });
427 // Only what is INSIDE a shadow root counts: the page's own headings
428 // are text too, and finding one of those would prove nothing.
429 const text = (n) => {
430 if (n.nodeName === '#text') return (n.nodeValue || '').trim();
431 return (n.children || []).map(text).join('').trim();
432 };
433 // `closed` only: every <input> on the page carries a user-agent shadow
434 // root of its own, and its placeholder is not what is being checked.
435 const hunt = (n) => {
436 for (const sr of (n.shadowRoots || [])) {
437 if (sr.shadowRootType === 'closed') label = label || text(sr);
438 }
439 for (const c of (n.children || [])) hunt(c);
440 };
441 hunt(root);
442 await cdp.detach().catch(() => {});
443 } catch (e) { /* no debugger; the plant check still stands */ }
444 }
445 check('the resume button is planted in the managed tab', planted);
446 check('the resume button is labelled in ja', label === msg('ja', 'resume_button'), JSON.stringify(label));
447} finally {
448 await b.close().catch(() => {});
449 await new Promise((r) => site.close(r));
450}
451
452console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed');
453process.exit(bad.length ? 1 : 0);