oxedyne/daimond/dev/verify_wordmark.mjs
23.0 KiB, 1 run
created by r2519314175:815, 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_wordmark.mjs -- the brand lockup, everywhere it is drawn, at every |
| 2 | // width the app is drawn at. |
| 3 | // |
| 4 | // The wordmark was redrawn wider (223.7x84.273 -> 373.76x84.273: 2.65:1 became |
| 5 | // 4.44:1) and every placement is height-constrained with `width: auto`, so every |
| 6 | // one of them got 67% wider on the spot. This walks the five surfaces that draw |
| 7 | // it -- the top bar, the unlock card, the About dialog, the guide header and the |
| 8 | // landing header/footer -- and asks four questions of each: |
| 9 | // |
| 10 | // 1. THE ARTWORK IS NOT DISTORTED. The rendered width divided by the rendered |
| 11 | // height must equal the ratio in the painted file's OWN viewBox, read off |
| 12 | // disk, to within a pixel. This is the check that catches a |
| 13 | // `preserveAspectRatio: none` or a fixed `width` "fix" -- including one a |
| 14 | // later session adds in good faith. |
| 15 | // 2. NOTHING IS GUILLOTINED. The row that holds the lockup does not scroll |
| 16 | // (`.topbar` is `overflow: hidden`, so a row that does not fit loses the |
| 17 | // last control off the right edge in silence). |
| 18 | // 3. NOTHING OVERLAPS. The lockup's box does not intersect any other box in |
| 19 | // its row. |
| 20 | // 4. THE PAGE DOES NOT SCROLL SIDEWAYS. |
| 21 | // |
| 22 | // Which file is painted is read from the computed `content`, not from `src`: on |
| 23 | // a phone the top bar swaps the lockup for the mark alone with `content: url()`, |
| 24 | // and `naturalWidth` keeps reporting the file in the `src` attribute after that. |
| 25 | // So the expected ratio comes from whichever file the engine is actually |
| 26 | // painting, parsed from that file's viewBox. |
| 27 | // |
| 28 | // node dev/verify_wordmark.mjs # the walk |
| 29 | // node dev/verify_wordmark.mjs --selftest # prove all four checks go red |
| 30 | // |
| 31 | // Needs a world: `bash dev/world.sh N --up` then `eval "$(bash dev/world.sh N --env)"`. |
| 32 | delete process.env.DISPLAY; // A forwarded DISPLAY means no compositor frames and every wait hangs. |
| 33 | import os from 'node:os'; |
| 34 | import fs from 'node:fs'; |
| 35 | import path from 'node:path'; |
| 36 | import { fileURLToPath, pathToFileURL } from 'node:url'; |
| 37 | |
| 38 | process.env.PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = '1'; |
| 39 | const PW = process.env.DAIMOND_PW || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs'); |
| 40 | const { chromium, webkit } = await import(pathToFileURL(PW).href); |
| 41 | const CHROME = process.env.DAIMOND_CHROME || path.join(os.homedir(), '.cache/ms-playwright/chromium-1229/chrome-linux64/chrome'); |
| 42 | |
| 43 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 44 | const ROOT = path.dirname(HERE); |
| 45 | const APP = process.env.DAIMOND_APP || `http://localhost:${process.env.DAIMOND_PORT || 8777}`; |
| 46 | const LANDING = pathToFileURL(path.join(ROOT, 'landing')).href; |
| 47 | const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond'); |
| 48 | const SHOTS = path.join(HERE, 'shots', 'wordmark'); |
| 49 | const PASS = 'testpass1234'; |
| 50 | const SELFTEST = process.argv.includes('--selftest'); |
| 51 | |
| 52 | fs.mkdirSync(SHOTS, { recursive: true }); |
| 53 | |
| 54 | // ── The artwork's own proportions, off disk ────────────────────── |
| 55 | // Keyed by file name, because that is all the browser tells us about what it |
| 56 | // painted. Both asset directories ship the same three files; a mismatch between |
| 57 | // the two copies is itself a finding, so they are compared rather than merged. |
| 58 | const ASSETS = ['daimond_word.svg', 'daimond_word_dark.svg', 'daimond_mark.svg']; |
| 59 | const RATIO = {}; |
| 60 | for (const dir of ['www/assets', 'landing/assets']) { |
| 61 | for (const f of ASSETS) { |
| 62 | const p = path.join(ROOT, dir, f); |
| 63 | if (!fs.existsSync(p)) continue; |
| 64 | const m = fs.readFileSync(p, 'utf8').match(/viewBox="([\d.\s-]+)"/); |
| 65 | if (!m) throw new Error(`${dir}/${f}: no viewBox`); |
| 66 | const [, , w, h] = m[1].trim().split(/\s+/).map(Number); |
| 67 | const r = w / h; |
| 68 | if (RATIO[f] !== undefined && Math.abs(RATIO[f] - r) > 1e-6) { |
| 69 | throw new Error(`${f}: www and landing copies disagree (${RATIO[f]} vs ${r})`); |
| 70 | } |
| 71 | RATIO[f] = r; |
| 72 | } |
| 73 | } |
| 74 | console.log('artwork ratios (from each file\'s viewBox):'); |
| 75 | for (const [f, r] of Object.entries(RATIO)) console.log(` ${f.padEnd(24)} ${r.toFixed(4)}:1`); |
| 76 | |
| 77 | let pass = 0, fail = 0, ENG = 'chromium'; |
| 78 | const fails = []; |
| 79 | const check = (ok, what, detail) => { |
| 80 | if (ok) { pass++; return true; } |
| 81 | fail++; fails.push(`${what} -- ${detail}`); |
| 82 | console.log(` FAIL ${what}\n ${detail}`); |
| 83 | return false; |
| 84 | }; |
| 85 | |
| 86 | // ── The measurement, in the page ───────────────────────────────── |
| 87 | // Returns one record per VISIBLE mark matching `markSel`, plus the row it sits |
| 88 | // in and every other box in that row. Nothing is asserted here: the caller |
| 89 | // decides, so the same measurement serves the walk and the self-test. |
| 90 | const MEASURE = (arg) => { |
| 91 | const { markSel, rowSel } = arg; |
| 92 | const de = document.documentElement; |
| 93 | const vis = (el) => { |
| 94 | const s = getComputedStyle(el); |
| 95 | if (s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) === 0) return false; |
| 96 | const r = el.getBoundingClientRect(); |
| 97 | return r.width > 0 && r.height > 0; |
| 98 | }; |
| 99 | const box = (el) => { const r = el.getBoundingClientRect(); return { |
| 100 | x: +r.x.toFixed(2), y: +r.y.toFixed(2), w: +r.width.toFixed(2), h: +r.height.toFixed(2), |
| 101 | right: +r.right.toFixed(2), bottom: +r.bottom.toFixed(2) }; }; |
| 102 | const tag = (el) => el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') |
| 103 | + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/\s+/).join('.') : ''); |
| 104 | |
| 105 | const row = document.querySelector(rowSel); |
| 106 | const marks = [...document.querySelectorAll(markSel)].filter(vis); |
| 107 | const out = marks.map((el) => { |
| 108 | // What is actually painted: `content` wins over `src`, and after a |
| 109 | // `content: url()` swap `currentSrc` still names the src file. |
| 110 | const c = getComputedStyle(el).content; |
| 111 | const m = c && c.match(/url\(["']?([^"')]+)["']?\)/); |
| 112 | const file = (m ? m[1] : (el.currentSrc || el.src || '')).split('/').pop().split('?')[0]; |
| 113 | // The chain from the mark up to the row's direct child, so a sibling |
| 114 | // check compares the mark against boxes that are not its own ancestors. |
| 115 | let branch = el; |
| 116 | while (branch && branch.parentElement !== row && branch.parentElement) branch = branch.parentElement; |
| 117 | return { sel: tag(el), file, box: box(el), branch: branch ? tag(branch) : null, |
| 118 | pa: el.getAttribute('preserveAspectRatio') || null }; |
| 119 | }); |
| 120 | |
| 121 | // Every other box in the row, for the overlap test. Only direct children: |
| 122 | // a descendant of the mark's own branch is not a neighbour. |
| 123 | let others = [], rowBox = null, rowClip = 0, rowPad = null, culprit = null; |
| 124 | if (row) { |
| 125 | rowBox = box(row); |
| 126 | rowClip = row.scrollWidth - row.clientWidth; |
| 127 | const cs = getComputedStyle(row); |
| 128 | rowPad = { l: parseFloat(cs.paddingLeft) || 0, r: parseFloat(cs.paddingRight) || 0 }; |
| 129 | others = [...row.children].filter(vis).map((k) => ({ sel: tag(k), box: box(k) })); |
| 130 | // When the row does overrun, name the box that reaches furthest right, so |
| 131 | // the reader is told WHAT is cut off rather than only that something is. |
| 132 | if (rowClip > 0) { |
| 133 | const edge = rowBox.right - rowPad.r; |
| 134 | for (const el of row.querySelectorAll('*')) { |
| 135 | if (!vis(el)) continue; |
| 136 | const b = box(el); |
| 137 | if (b.right > edge + 0.5 && (!culprit || b.right > culprit.box.right)) culprit = { sel: tag(el), box: b }; |
| 138 | } |
| 139 | } |
| 140 | } |
| 141 | return { marks: out, others, rowBox, rowClip, rowPad, culprit, rowFound: !!row, |
| 142 | hScroll: de.scrollWidth - de.clientWidth, vw: window.innerWidth }; |
| 143 | }; |
| 144 | |
| 145 | // ── One surface, at one width, in one palette ──────────────────── |
| 146 | async function audit(page, { label, markSel, rowSel, minMarks = 1, shot = null, clipRow = false }) { |
| 147 | const m = await page.evaluate(MEASURE, { markSel, rowSel }); |
| 148 | check(m.rowFound, `${label}: the row ${rowSel} exists`, 'selector matched nothing'); |
| 149 | check(m.marks.length >= minMarks, `${label}: ${markSel} is on screen`, |
| 150 | `${m.marks.length} visible, expected at least ${minMarks}`); |
| 151 | |
| 152 | // 4. The page does not scroll sideways. |
| 153 | check(m.hScroll <= 1, `${label}: the page has no horizontal overflow`, |
| 154 | `document scrollWidth exceeds clientWidth by ${m.hScroll}px at vw ${m.vw}`); |
| 155 | |
| 156 | // 2. The row is not clipping its own contents. Asserted only where the row |
| 157 | // really is a guillotine -- `.topbar` is `overflow: hidden`, so a row that |
| 158 | // does not fit loses the control at its end and says nothing. Elsewhere an |
| 159 | // overrun is reported as a NOTE naming the culprit: the unlock card has a |
| 160 | // standing one at 320 (the passphrase input's intrinsic width) that has |
| 161 | // nothing to do with the lockup, and a check that fails on it would be a |
| 162 | // check nobody reads. |
| 163 | if (clipRow) { |
| 164 | check(m.rowClip <= 0, `${label}: nothing is guillotined out of ${rowSel}`, |
| 165 | `row content overruns its box by ${m.rowClip}px` |
| 166 | + (m.culprit ? `; furthest right is ${m.culprit.sel} at ${m.culprit.box.right}` : '')); |
| 167 | } else if (m.rowClip > 0) { |
| 168 | console.log(` note ${label}: ${rowSel} overruns by ${m.rowClip}px` |
| 169 | + (m.culprit ? ` -- ${m.culprit.sel} reaches ${m.culprit.box.right}` : '')); |
| 170 | } |
| 171 | |
| 172 | for (const mk of m.marks) { |
| 173 | const expect = RATIO[mk.file]; |
| 174 | // 1. Undistorted. A pixel of tolerance on the WIDTH, not on the ratio: |
| 175 | // a ratio tolerance is meaningless at 20px and forgiving at 160px. |
| 176 | if (check(expect !== undefined, `${label}: ${mk.sel} paints a known asset`, |
| 177 | `painted ${JSON.stringify(mk.file)}, which is not one of ${ASSETS.join(', ')}`)) { |
| 178 | const want = mk.box.h * expect; |
| 179 | check(Math.abs(mk.box.w - want) <= 1, `${label}: ${mk.sel} is undistorted`, |
| 180 | `${mk.file} is ${expect.toFixed(4)}:1, so ${mk.box.h}px tall wants ${want.toFixed(2)}px wide; ` |
| 181 | + `rendered ${mk.box.w}px (${(mk.box.w - want).toFixed(2)}px out, drawn ratio ${(mk.box.w / mk.box.h).toFixed(4)}:1)`); |
| 182 | check(!mk.pa || mk.pa === 'xMidYMid meet', `${label}: ${mk.sel} keeps the default preserveAspectRatio`, |
| 183 | `preserveAspectRatio="${mk.pa}"`); |
| 184 | } |
| 185 | // 3. Inside its row's content box, and clear of every neighbour. |
| 186 | if (m.rowBox) { |
| 187 | const left = m.rowBox.x + m.rowPad.l, right = m.rowBox.right - m.rowPad.r; |
| 188 | check(mk.box.x >= left - 1 && mk.box.right <= right + 1, |
| 189 | `${label}: ${mk.sel} sits inside ${rowSel}`, |
| 190 | `mark spans ${mk.box.x}..${mk.box.right}, row content box is ${left.toFixed(2)}..${right.toFixed(2)}`); |
| 191 | } |
| 192 | for (const o of m.others) { |
| 193 | if (o.sel === mk.branch) continue; // the mark's own branch of the row |
| 194 | const hit = mk.box.x < o.box.right - 0.5 && o.box.x < mk.box.right - 0.5 |
| 195 | && mk.box.y < o.box.bottom - 0.5 && o.box.y < mk.box.bottom - 0.5; |
| 196 | check(!hit, `${label}: ${mk.sel} does not overlap ${o.sel}`, |
| 197 | `mark ${mk.box.x}..${mk.box.right} x ${mk.box.y}..${mk.box.bottom}, ` |
| 198 | + `neighbour ${o.box.x}..${o.box.right} x ${o.box.y}..${o.box.bottom}`); |
| 199 | } |
| 200 | } |
| 201 | // The crop is the ROW, not the page: a full-page shot of a 1500px desktop |
| 202 | // hides a two-pixel collision in the brand. Named per engine, or the WebKit |
| 203 | // pass silently overwrites the Chromium one and half the evidence is lost. |
| 204 | if (shot) { |
| 205 | await page.locator(shot).first().screenshot({ path: path.join(SHOTS, `${ENG}-${label.replace(/[^\w.-]+/g, '_')}.png`) }) |
| 206 | .catch((e) => console.log(` (no shot for ${label}: ${e.message})`)); |
| 207 | } |
| 208 | return m; |
| 209 | } |
| 210 | |
| 211 | // ── Drive the app to a signed-in state ─────────────────────────── |
| 212 | async function signIn(page, name) { |
| 213 | await page.waitForSelector('#id-primary', { timeout: 20000 }); |
| 214 | const nb = await page.$('#id-name'); |
| 215 | if (nb && await nb.isVisible() && await nb.isEditable()) await nb.fill(name); |
| 216 | await page.fill('#id-pass', PASS); |
| 217 | const c2 = await page.$('#id-pass2'); if (c2 && await c2.isVisible()) await c2.fill(PASS); |
| 218 | const wr = await page.$('#id-wrote'); if (wr && await wr.isVisible() && !(await wr.isChecked())) await wr.check({ force: true }); |
| 219 | await page.evaluate(() => document.getElementById('id-primary').click()); |
| 220 | await page.waitForSelector('#identity-modal', { state: 'hidden', timeout: 20000 }); |
| 221 | await page.waitForTimeout(500); |
| 222 | } |
| 223 | |
| 224 | /// The app publishes its palettes as a service; drive that rather than pinning |
| 225 | /// the attribute, so `data-ink` and the src swap happen the way they do live. |
| 226 | const setTheme = (page, t) => page.evaluate((x) => { |
| 227 | if (window.DaimondTheme) window.DaimondTheme.set(x); |
| 228 | else document.documentElement.setAttribute('data-theme', x); |
| 229 | }, t); |
| 230 | const setSkin = (page, s) => page.evaluate((x) => { |
| 231 | if (window.DaimondSkin) window.DaimondSkin.set(x); |
| 232 | else document.documentElement.setAttribute('data-skin', x); |
| 233 | }, s); |
| 234 | |
| 235 | // Desktop and the phone the brief names, plus the widths where the row was found |
| 236 | // to break: 320 (SE 1st gen), 390 (iPhone 12-15), 435 and 470 (the band the |
| 237 | // mobile shell drew the desktop-height lockup at). |
| 238 | const WIDTHS = [1500, 768, 470, 435, 430, 390, 360, 320]; |
| 239 | const PALETTES = [['dark', 'light lettering on a dark ground'], ['light', 'dark lettering on a light ground']]; |
| 240 | |
| 241 | const ENGINES = [{ name: 'chromium', type: chromium, opts: { executablePath: CHROME } }]; |
| 242 | if (!SELFTEST) ENGINES.push({ name: 'webkit', type: webkit, opts: {} }); |
| 243 | |
| 244 | for (const eng of ENGINES) { |
| 245 | ENG = eng.name; |
| 246 | let ctx; |
| 247 | try { |
| 248 | ctx = await eng.type.launchPersistentContext(path.join(SCRATCH, `wordmark-${eng.name}-${process.pid}`), { |
| 249 | ...eng.opts, headless: true, viewport: { width: 1500, height: 950 }, |
| 250 | }); |
| 251 | } catch (e) { console.log(`\n(${eng.name} will not launch here: ${e.message.split('\n')[0]})`); continue; } |
| 252 | const page = ctx.pages()[0] || await ctx.newPage(); |
| 253 | |
| 254 | // ── The app ────────────────────────────────────────────────── |
| 255 | console.log(`\n=== ${eng.name}: the app ===`); |
| 256 | await page.goto(APP, { waitUntil: 'domcontentloaded' }); |
| 257 | |
| 258 | // Signed out first: the unlock card is the first screen a returning user meets. |
| 259 | for (const w of WIDTHS) { |
| 260 | await page.setViewportSize({ width: w, height: 860 }); |
| 261 | await page.waitForTimeout(150); |
| 262 | for (const [theme, say] of PALETTES) { |
| 263 | await setTheme(page, theme); |
| 264 | await page.waitForTimeout(120); |
| 265 | await audit(page, { label: `unlock ${w} ${theme}`, markSel: '#identity-modal .login-logo', |
| 266 | rowSel: '#identity-modal .modal-card', shot: '#identity-modal .modal-card' }); |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | await page.setViewportSize({ width: 1500, height: 950 }); |
| 271 | await setTheme(page, 'dark'); |
| 272 | await signIn(page, 'wm' + eng.name); |
| 273 | |
| 274 | for (const w of WIDTHS) { |
| 275 | await page.setViewportSize({ width: w, height: 860 }); |
| 276 | await page.waitForTimeout(200); |
| 277 | for (const [theme] of PALETTES) { |
| 278 | for (const skin of ['sharp', 'warm']) { |
| 279 | await setTheme(page, theme); |
| 280 | await setSkin(page, skin); |
| 281 | await page.waitForTimeout(140); |
| 282 | const bar = await audit(page, { label: `topbar ${w} ${theme} ${skin}`, markSel: '.topbar .brand-wordmark', |
| 283 | rowSel: '.topbar', shot: '.topbar', clipRow: true }); |
| 284 | // WHICH artwork the bar shows is a decision, not a detail: a phone |
| 285 | // gets the mark alone, because the 4.44:1 lockup does not fit |
| 286 | // beside the burger and five controls at any legible height. |
| 287 | // Pinned here so putting the word back on a phone fails loudly |
| 288 | // rather than amputating the appearance menu again. |
| 289 | for (const mk of bar.marks) { |
| 290 | const ok = w <= 430 ? mk.file === 'daimond_mark.svg' : /^daimond_word(_dark)?\.svg$/.test(mk.file); |
| 291 | check(ok, `topbar ${w} ${theme} ${skin}: the bar shows ${w <= 430 ? 'the mark alone' : 'the full lockup'}`, |
| 292 | `painted ${mk.file}`); |
| 293 | } |
| 294 | } |
| 295 | } |
| 296 | await setSkin(page, 'sharp'); |
| 297 | // The About dialog wears the same class for its light/dark swap, so a |
| 298 | // rule written for the bar reaches it unless it is scoped. |
| 299 | for (const [theme] of PALETTES) { |
| 300 | await setTheme(page, theme); |
| 301 | await page.evaluate(() => { const b = document.getElementById('about-btn'); if (b) b.click(); }); |
| 302 | await page.waitForTimeout(450); |
| 303 | const ab = await audit(page, { label: `about ${w} ${theme}`, markSel: '.about-word', |
| 304 | rowSel: '.about-card', shot: '.about-card' }); |
| 305 | // `.about-word` asks for 46px and has room for it at every width. It |
| 306 | // was not getting it: the top bar's phone rules were written unscoped, |
| 307 | // so About drew its signature at 20-24px on every phone -- against its |
| 308 | // own rule and against the comment beside it. Assert the height the |
| 309 | // dialog states, so the leak cannot come back unnoticed. |
| 310 | for (const mk of ab.marks) { |
| 311 | check(Math.abs(mk.box.h - 46) < 0.5, `about ${w} ${theme}: the dialog lockup is 46px tall`, |
| 312 | `drawn at ${mk.box.h}px -- a top-bar rule is reaching into the dialog`); |
| 313 | check(mk.file !== 'daimond_mark.svg', `about ${w} ${theme}: the dialog shows the full lockup`, |
| 314 | `painted ${mk.file} -- the bar's phone swap has leaked in`); |
| 315 | } |
| 316 | await page.keyboard.press('Escape'); |
| 317 | await page.waitForTimeout(250); |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | // ── The guide, and the landing site ────────────────────────── |
| 322 | console.log(`\n=== ${eng.name}: guide and landing ===`); |
| 323 | const pages = [ |
| 324 | { label: 'guide-en', url: `${APP}/guide/index.html`, mark: '.wordmark', row: '.site-head-inner' }, |
| 325 | { label: 'guide-de', url: `${APP}/guide/de/interface.html`, mark: '.wordmark', row: '.site-head-inner' }, |
| 326 | { label: 'guide-ja', url: `${APP}/guide/ja/index.html`, mark: '.wordmark', row: '.site-head-inner' }, |
| 327 | { label: 'landing', url: `${LANDING}/index.html`, mark: '.brand-word', row: '.header-inner' }, |
| 328 | { label: 'terms', url: `${LANDING}/terms.html`, mark: '.brand-word', row: '.header-inner' }, |
| 329 | { label: 'privacy', url: `${LANDING}/privacy.html`, mark: '.brand-word', row: '.header-inner' }, |
| 330 | ]; |
| 331 | for (const p of pages) { |
| 332 | for (const w of [1500, 430, 390, 360, 320]) { |
| 333 | await page.setViewportSize({ width: w, height: 900 }); |
| 334 | for (const [theme] of PALETTES) { |
| 335 | // These are static pages with no theme service: the ink axis is |
| 336 | // the attribute, exactly as their own stylesheets read it. |
| 337 | await page.emulateMedia({ colorScheme: theme }); |
| 338 | await page.goto(p.url, { waitUntil: 'domcontentloaded' }); |
| 339 | await page.evaluate((t) => document.documentElement.setAttribute('data-ink', t === 'dark' ? 'light' : 'dark'), theme); |
| 340 | await page.waitForTimeout(200); |
| 341 | await audit(page, { label: `${p.label} ${w} ${theme}`, markSel: p.mark, row: p.row, |
| 342 | rowSel: p.row, minMarks: 1, shot: p.row }); |
| 343 | } |
| 344 | } |
| 345 | } |
| 346 | await page.emulateMedia({ colorScheme: null }); |
| 347 | |
| 348 | // ── Self-test: every check, proved red ─────────────────────── |
| 349 | if (SELFTEST) { |
| 350 | console.log('\n=== self-test: each check, against code broken on purpose ==='); |
| 351 | const before = fail; |
| 352 | const expectRed = async (what, breakIt, restore, opts) => { |
| 353 | const b0 = fail; |
| 354 | await page.evaluate(breakIt); |
| 355 | await page.waitForTimeout(200); |
| 356 | await audit(page, opts); |
| 357 | const went = fail - b0; |
| 358 | await page.evaluate(restore); |
| 359 | await page.waitForTimeout(200); |
| 360 | console.log(` ${went > 0 ? 'RED (good)' : 'STAYED GREEN -- THE CHECK IS INERT'} ${what} (+${went} failures)`); |
| 361 | return went > 0; |
| 362 | }; |
| 363 | await page.goto(APP, { waitUntil: 'domcontentloaded' }); |
| 364 | await page.setViewportSize({ width: 390, height: 860 }); |
| 365 | await page.waitForTimeout(300); |
| 366 | await signIn(page, 'wmself'); |
| 367 | await page.setViewportSize({ width: 390, height: 860 }); |
| 368 | await page.waitForTimeout(400); |
| 369 | |
| 370 | const results = []; |
| 371 | const STYLE = (css) => { const s = document.createElement('style'); s.id = 'wm-break'; s.textContent = css; document.head.appendChild(s); }; |
| 372 | const UNSTYLE = () => { const s = document.getElementById('wm-break'); if (s) s.remove(); }; |
| 373 | const barOpts = { label: 'SELFTEST topbar', markSel: '.topbar .brand-wordmark', rowSel: '.topbar', clipRow: true }; |
| 374 | |
| 375 | // 1a. A fixed width -- the classic "just make it fit" fix. |
| 376 | results.push(['undistorted vs a fixed width', |
| 377 | await expectRed('a fixed width on the lockup', |
| 378 | () => { const s = document.createElement('style'); s.id = 'wm-break'; |
| 379 | s.textContent = '.topbar .brand-wordmark { width: 60px !important; height: 24px !important; content: none !important; }'; |
| 380 | document.head.appendChild(s); }, |
| 381 | UNSTYLE, barOpts)]); |
| 382 | // 1b. preserveAspectRatio="none" on the artwork itself. |
| 383 | results.push(['undistorted vs preserveAspectRatio="none"', |
| 384 | await expectRed('preserveAspectRatio="none" on the lockup', |
| 385 | () => { for (const i of document.querySelectorAll('.topbar .brand-wordmark')) i.setAttribute('preserveAspectRatio', 'none'); }, |
| 386 | () => { for (const i of document.querySelectorAll('.topbar .brand-wordmark')) i.removeAttribute('preserveAspectRatio'); }, |
| 387 | barOpts)]); |
| 388 | // 2. The lockup at its desktop height on a phone -- the break this |
| 389 | // session was sent to fix, reproduced exactly. |
| 390 | results.push(['guillotine vs the desktop-height lockup on a phone', |
| 391 | await expectRed('the full lockup at 36px in a 390px bar', |
| 392 | () => { const s = document.createElement('style'); s.id = 'wm-break'; |
| 393 | s.textContent = '.topbar .brand-wordmark { content: none !important; height: 36px !important; }'; |
| 394 | document.head.appendChild(s); }, |
| 395 | UNSTYLE, barOpts)]); |
| 396 | // 3. An overlap, with no clipping to give it away. |
| 397 | results.push(['overlap vs a lockup dragged across its neighbours', |
| 398 | await expectRed('the lockup translated over the controls', |
| 399 | () => { const s = document.createElement('style'); s.id = 'wm-break'; |
| 400 | s.textContent = '.topbar .brand-group { position: relative; left: 120px; }'; |
| 401 | document.head.appendChild(s); }, |
| 402 | UNSTYLE, barOpts)]); |
| 403 | // 5. The mark painted from a file nobody ships. |
| 404 | results.push(['unknown asset', |
| 405 | await expectRed('a lockup pointed at splash.svg', |
| 406 | () => { const s = document.createElement('style'); s.id = 'wm-break'; |
| 407 | s.textContent = '.topbar .brand-wordmark { content: url("assets/splash.svg") !important; }'; |
| 408 | document.head.appendChild(s); }, |
| 409 | UNSTYLE, barOpts)]); |
| 410 | |
| 411 | // 4. Horizontal page overflow -- proved on the GUIDE, not on the app. |
| 412 | // The app's mobile shell pins `html, body { overflow: hidden }`, so no |
| 413 | // child can make the document scroll there and the check is structurally |
| 414 | // inert on the app below 760px. It is live on the guide and the landing |
| 415 | // site, which are ordinary scrolling documents, and that is where it has |
| 416 | // to be proved. |
| 417 | await page.setViewportSize({ width: 360, height: 900 }); |
| 418 | await page.goto(`${APP}/guide/index.html`, { waitUntil: 'domcontentloaded' }); |
| 419 | await page.waitForTimeout(250); |
| 420 | results.push(['page overflow vs an oversized child (on the guide)', |
| 421 | await expectRed('a 3000px element in the guide page', |
| 422 | () => { const d = document.createElement('div'); d.id = 'wm-wide'; |
| 423 | d.style.cssText = 'width:3000px;height:4px;background:red'; document.body.appendChild(d); }, |
| 424 | () => { const d = document.getElementById('wm-wide'); if (d) d.remove(); }, |
| 425 | { label: 'SELFTEST guide', markSel: '.wordmark', rowSel: '.site-head-inner' })]); |
| 426 | |
| 427 | // The self-test's own failures are the point, so they are not the run's. |
| 428 | fail = before; fails.length = before; |
| 429 | const inert = results.filter(([, red]) => !red); |
| 430 | console.log(`\nself-test: ${results.length - inert.length}/${results.length} checks proved red`); |
| 431 | if (inert.length) { |
| 432 | for (const [what] of inert) console.log(` INERT: ${what}`); |
| 433 | process.exitCode = 1; |
| 434 | } |
| 435 | } |
| 436 | |
| 437 | await ctx.close(); |
| 438 | } |
| 439 | |
| 440 | console.log(`\n${pass} passed, ${fail} failed`); |
| 441 | if (fails.length) { console.log('\nfailures:'); for (const f of fails) console.log(' ' + f); } |
| 442 | console.log(`shots: ${SHOTS}`); |
| 443 | if (fail) process.exitCode = 1; |