Oregami
Repositories/oxedyne/daimond

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)"`.
32delete process.env.DISPLAY; // A forwarded DISPLAY means no compositor frames and every wait hangs.
33import os from 'node:os';
34import fs from 'node:fs';
35import path from 'node:path';
36import { fileURLToPath, pathToFileURL } from 'node:url';
37
38process.env.PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = '1';
39const PW = process.env.DAIMOND_PW || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs');
40const { chromium, webkit } = await import(pathToFileURL(PW).href);
41const CHROME = process.env.DAIMOND_CHROME || path.join(os.homedir(), '.cache/ms-playwright/chromium-1229/chrome-linux64/chrome');
42
43const HERE = path.dirname(fileURLToPath(import.meta.url));
44const ROOT = path.dirname(HERE);
45const APP = process.env.DAIMOND_APP || `http://localhost:${process.env.DAIMOND_PORT || 8777}`;
46const LANDING = pathToFileURL(path.join(ROOT, 'landing')).href;
47const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond');
48const SHOTS = path.join(HERE, 'shots', 'wordmark');
49const PASS = 'testpass1234';
50const SELFTEST = process.argv.includes('--selftest');
51
52fs.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.
58const ASSETS = ['daimond_word.svg', 'daimond_word_dark.svg', 'daimond_mark.svg'];
59const RATIO = {};
60for (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}
74console.log('artwork ratios (from each file\'s viewBox):');
75for (const [f, r] of Object.entries(RATIO)) console.log(` ${f.padEnd(24)} ${r.toFixed(4)}:1`);
76
77let pass = 0, fail = 0, ENG = 'chromium';
78const fails = [];
79const 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.
90const 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 ────────────────────
146async 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 ───────────────────────────
212async 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.
226const setTheme = (page, t) => page.evaluate((x) => {
227 if (window.DaimondTheme) window.DaimondTheme.set(x);
228 else document.documentElement.setAttribute('data-theme', x);
229}, t);
230const 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).
238const WIDTHS = [1500, 768, 470, 435, 430, 390, 360, 320];
239const PALETTES = [['dark', 'light lettering on a dark ground'], ['light', 'dark lettering on a light ground']];
240
241const ENGINES = [{ name: 'chromium', type: chromium, opts: { executablePath: CHROME } }];
242if (!SELFTEST) ENGINES.push({ name: 'webkit', type: webkit, opts: {} });
243
244for (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
440console.log(`\n${pass} passed, ${fail} failed`);
441if (fails.length) { console.log('\nfailures:'); for (const f of fails) console.log(' ' + f); }
442console.log(`shots: ${SHOTS}`);
443if (fail) process.exitCode = 1;