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 |
| 23 | import path from 'node:path'; |
| 24 | import os from 'node:os'; |
| 25 | import fs from 'node:fs'; |
| 26 | import http from 'node:http'; |
| 27 | import { pathToFileURL } from 'node:url'; |
| 28 | |
| 29 | const PW = path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs'); |
| 30 | const { chromium } = await import(pathToFileURL(PW).href); |
| 31 | const CHROME = `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`; |
| 32 | import { 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. |
| 38 | import './display.mjs'; |
| 39 | const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); // this checkout, not one developer's home |
| 40 | const 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. |
| 45 | const { extDev } = await import(pathToFileURL(`${ROOT}/dev/extdev.mjs`).href); |
| 46 | const EXT_DEV = await extDev(); |
| 47 | const EXTID = 'mpliijponglmmffjnonahhignkpkhmij'; |
| 48 | // Not /tmp -- see the SCRATCH note in harness.mjs. |
| 49 | const 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. |
| 52 | const APP = process.env.DAIMOND_APP || `http://localhost:${process.env.DAIMOND_PORT || 8777}`; |
| 53 | const SITE_PORT = Number(process.env.EXTI18N_PORT || 9123); |
| 54 | const SITE = `http://127.0.0.1:${SITE_PORT}`; |
| 55 | |
| 56 | const ok = [], bad = []; |
| 57 | const 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. |
| 67 | const 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 | |
| 72 | const table = {}; |
| 73 | for (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 | |
| 83 | const EN = table.en || {}; |
| 84 | const KEYS = Object.keys(EN); |
| 85 | check('the English table is the baseline and is not empty', KEYS.length > 0, `${KEYS.length} messages`); |
| 86 | |
| 87 | // ── Every locale, every key ────────────────────────────────────────── |
| 88 | for (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. |
| 115 | for (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 ────────────────────────────────── |
| 145 | const 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. |
| 176 | const 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 | }); |
| 186 | await new Promise((r) => site.listen(SITE_PORT, '127.0.0.1', r)); |
| 187 | |
| 188 | function 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 | } |
| 198 | async 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 | } |
| 202 | const msg = (code, key) => table[code][key].message; |
| 203 | /// The message with its `$HOST$` filled in, the way the window will show it. |
| 204 | const 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. |
| 209 | async 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 | |
| 218 | const PROFILE = path.join(SCRATCH, 'verify-ext-i18n'); |
| 219 | let b = await launch(PROFILE); |
| 220 | |
| 221 | try { |
| 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 | |
| 452 | console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed'); |
| 453 | process.exit(bad.length ? 1 : 0); |