oxedyne/daimond/dev/shot_qc.mjs
19.0 KiB, 1 run
created by r2519314175:175, 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 | // shot_qc.mjs — the surfaces `dev/sweep.mjs` cannot reach, photographed and measured. |
| 2 | // |
| 3 | // The sweep walks the panels and the three `.pop` overlays, and that is most of |
| 4 | // the app but not the part the user complained about: *"button and chip |
| 5 | // misalignment and spacing problems in dialogs in the rail"*. A dialog in the |
| 6 | // rail is not a panel. It is the Admin drawer on one of its six views, the New |
| 7 | // Diamond box, the tag editor, the settings modal — each behind a click the |
| 8 | // sweep does not make, and two of them behind a Diamond that has to exist first. |
| 9 | // So this seeds a real workspace and then opens them one at a time. |
| 10 | // |
| 11 | // It also opens the two overlays the sweep reports as "did not open": the panel |
| 12 | // gallery, whose opener `#panel-more` exists only when the chip row overflows, |
| 13 | // and the permission-mode pop, whose opener lives in a chat head that is not |
| 14 | // there until a chat is. |
| 15 | // |
| 16 | // Measuring, not only photographing: a row of controls meant to be equal is |
| 17 | // checked for being equal, to the pixel, because 16px out of 62 is obvious on |
| 18 | // screen and invisible in a list of rectangles nobody compares. |
| 19 | // |
| 20 | // node dev/shot_qc.mjs --out ~/.cache/daimond/qc |
| 21 | // node dev/shot_qc.mjs --only appearance,admin-models |
| 22 | // node dev/shot_qc.mjs --cells sharp-dark-1500,warm-light-380 |
| 23 | import fs from 'node:fs'; |
| 24 | import path from 'node:path'; |
| 25 | import { open, errors, scratch } from './harness.mjs'; |
| 26 | |
| 27 | const argv = process.argv.slice(2); |
| 28 | const value = (n, d) => { const i = argv.indexOf(n); return (i >= 0 && argv[i + 1]) ? argv[i + 1] : d; }; |
| 29 | const list = (n) => value(n, '').split(',').map((x) => x.trim()).filter(Boolean); |
| 30 | |
| 31 | const OUT = value('--out', scratch('qc')); |
| 32 | const ONLY = list('--only'); |
| 33 | const CELLS_WANTED = list('--cells'); |
| 34 | fs.mkdirSync(OUT, { recursive: true }); |
| 35 | |
| 36 | const ALL_CELLS = [ |
| 37 | { tag: 'sharp-dark-1500', skin: 'sharp', theme: 'dark', w: 1500, h: 950 }, |
| 38 | { tag: 'warm-light-1500', skin: 'warm', theme: 'light', w: 1500, h: 950 }, |
| 39 | { tag: 'sharp-light-1500', skin: 'sharp', theme: 'light', w: 1500, h: 950 }, |
| 40 | { tag: 'warm-dark-1500', skin: 'warm', theme: 'dark', w: 1500, h: 950 }, |
| 41 | { tag: 'sharp-dark-380', skin: 'sharp', theme: 'dark', w: 380, h: 780 }, |
| 42 | { tag: 'warm-light-380', skin: 'warm', theme: 'light', w: 380, h: 780 }, |
| 43 | ]; |
| 44 | const CELLS = CELLS_WANTED.length ? ALL_CELLS.filter((c) => CELLS_WANTED.includes(c.tag)) : ALL_CELLS; |
| 45 | |
| 46 | const s = await open({ name: 'qc', connect: true }); |
| 47 | const { page } = s; |
| 48 | const pause = (ms) => page.waitForTimeout(ms); |
| 49 | |
| 50 | /// Put the app back to rest: nothing modal, nothing popped, no drawer. |
| 51 | async function quieten() { |
| 52 | await page.evaluate(() => { |
| 53 | document.querySelectorAll('.pop:not([hidden])').forEach((el) => { el.hidden = true; }); |
| 54 | const pal = document.getElementById('palette'); if (pal) pal.hidden = true; |
| 55 | document.querySelectorAll('.modal').forEach((el) => { el.style.display = 'none'; }); |
| 56 | document.querySelectorAll('.dlg-scrim, .scrim').forEach((el) => el.remove()); |
| 57 | const close = document.getElementById('admin-close'); |
| 58 | if (close && close.getClientRects().length) close.click(); |
| 59 | }).catch(() => {}); |
| 60 | await pause(180); |
| 61 | } |
| 62 | |
| 63 | // ── Seeding ──────────────────────────────────────────────────────────── |
| 64 | // |
| 65 | // Three Diamonds, one of them carrying enough tags to overflow its tile, and a |
| 66 | // chat with a turn in it. Every rail dialog worth photographing needs at least |
| 67 | // one of the three to exist, and the tag row needs the tags. |
| 68 | async function seed() { |
| 69 | const drawerClose = page.locator('#admin-close'); |
| 70 | if (await drawerClose.isVisible().catch(() => false)) { await drawerClose.click({ force: true }); await pause(200); } |
| 71 | for (const name of ['Ship a CSV parser', 'Mum birthday plan', 'Rust compiler notes']) { |
| 72 | await page.click('#new-diamond-btn', { force: true }); |
| 73 | await page.waitForSelector('.dlg-input', { timeout: 10000 }); |
| 74 | await page.fill('.dlg-input', name); |
| 75 | await page.click('.dlg-ok', { force: true }); |
| 76 | await pause(700); |
| 77 | } |
| 78 | // Tags on the first Diamond, through the editor a person uses. Five of them, |
| 79 | // because a tile shows two and then "+n" — and the "+n" is a chip whose |
| 80 | // alignment against the two beside it is exactly the kind of thing this |
| 81 | // sweep is for. |
| 82 | await page.$$eval('.diamond-box', (els) => els[0] && els[0].click()); |
| 83 | await pause(700); |
| 84 | await page.evaluate(() => { |
| 85 | const b = [...document.querySelectorAll('.crystal-act')].find((x) => /tag/i.test(x.textContent || '')); |
| 86 | if (b) b.click(); |
| 87 | }); |
| 88 | await page.waitForSelector('.tag-input', { timeout: 8000 }).catch(() => {}); |
| 89 | for (const tag of ['rust', 'parser', 'work in progress', 'reading', 'deep']) { |
| 90 | await page.fill('.tag-input', tag).catch(() => {}); |
| 91 | await page.keyboard.press('Enter'); |
| 92 | await pause(450); |
| 93 | } |
| 94 | await page.reload({ waitUntil: 'domcontentloaded' }); |
| 95 | await pause(1500); |
| 96 | // The reload lands on the passphrase gate again. |
| 97 | const pass = await page.$('#id-pass'); |
| 98 | if (pass && await pass.isVisible()) { |
| 99 | await page.fill('#id-pass', 'testpass1234'); |
| 100 | await page.evaluate(() => document.getElementById('id-primary').click()); |
| 101 | await page.waitForSelector('#identity-modal', { state: 'hidden', timeout: 15000 }).catch(() => {}); |
| 102 | await pause(800); |
| 103 | } |
| 104 | // A chat, so the chat head — and the permission chip in it — exists. |
| 105 | await quieten(); |
| 106 | await page.click('#new-session-btn', { force: true }).catch(() => {}); |
| 107 | await pause(500); |
| 108 | const start = page.locator('.tile-start').first(); |
| 109 | if (await start.count()) await start.click({ force: true }); |
| 110 | await page.waitForSelector('#chat-input', { state: 'visible', timeout: 10000 }).catch(() => {}); |
| 111 | await pause(400); |
| 112 | } |
| 113 | |
| 114 | // ── The surfaces ─────────────────────────────────────────────────────── |
| 115 | // |
| 116 | // `sel` is what gets photographed; `open` is what has to happen first. A |
| 117 | // surface that will not open on a cell is reported, not silently dropped: an |
| 118 | // absent picture reads as "clean" to whoever reads the directory. |
| 119 | const SURFACES = [ |
| 120 | { id: 'rail', sel: '#panel-rail', mobileDrawer: true, |
| 121 | open: async () => { await quieten(); await page.evaluate(() => DaimondPanels.show('rail')); } }, |
| 122 | |
| 123 | { id: 'dlg-newdiamond', sel: '.dlg-card', |
| 124 | open: async () => { |
| 125 | await quieten(); |
| 126 | await page.evaluate(() => DaimondPanels.show('rail')); |
| 127 | await page.click('#new-diamond-btn', { force: true }); |
| 128 | await page.waitForSelector('.dlg-input', { timeout: 6000 }); |
| 129 | }, |
| 130 | after: async () => { await page.keyboard.press('Escape'); await pause(200); } }, |
| 131 | |
| 132 | { id: 'crystal', sel: '.crystal-bar', |
| 133 | open: async () => { |
| 134 | await quieten(); |
| 135 | await page.evaluate(() => DaimondPanels.show('rail')); |
| 136 | await page.$$eval('.diamond-box', (els) => els[0] && els[0].click()); |
| 137 | await pause(600); |
| 138 | } }, |
| 139 | |
| 140 | { id: 'tag-editor', sel: '.tag-editor', |
| 141 | open: async () => { |
| 142 | await quieten(); |
| 143 | await page.evaluate(() => DaimondPanels.show('rail')); |
| 144 | await page.$$eval('.diamond-box', (els) => els[0] && els[0].click()); |
| 145 | await pause(600); |
| 146 | await page.evaluate(() => { |
| 147 | const b = [...document.querySelectorAll('.crystal-act')] |
| 148 | .find((x) => /tag/i.test(x.textContent || '')); |
| 149 | if (b) b.click(); |
| 150 | }); |
| 151 | await page.waitForSelector('.tag-editor', { timeout: 6000 }); |
| 152 | } }, |
| 153 | |
| 154 | { id: 'tag-pool', sel: '#diamond-filter', |
| 155 | open: async () => { |
| 156 | await quieten(); |
| 157 | await page.evaluate(() => DaimondPanels.show('rail')); |
| 158 | await page.click('#diamond-filter .tagf-toggle', { force: true }).catch(() => {}); |
| 159 | await pause(350); |
| 160 | } }, |
| 161 | |
| 162 | { id: 'admin-home', sel: '#admin-body', admin: 'settings-btn' }, |
| 163 | { id: 'admin-models', sel: '#admin-body', admin: 'astat-model' }, |
| 164 | { id: 'admin-account', sel: '#admin-body', admin: 'astat-account' }, |
| 165 | { id: 'admin-tools', sel: '#admin-body', admin: 'astat-tools' }, |
| 166 | { id: 'admin-hand', sel: '#admin-body', admin: 'astat-hand' }, |
| 167 | { id: 'admin-release', sel: '#admin-body', admin: 'astat-release' }, |
| 168 | { id: 'admin-pro', sel: '#admin-body', admin: 'astat-pro' }, |
| 169 | |
| 170 | { id: 'admin-models-form', sel: '#admin-body', |
| 171 | open: async () => { |
| 172 | await quieten(); |
| 173 | await page.evaluate(() => DaimondPanels.show('rail')); |
| 174 | await page.click('#astat-model', { force: true }); |
| 175 | await pause(450); |
| 176 | await page.click('#models-add', { force: true }).catch(() => {}); |
| 177 | await pause(350); |
| 178 | } }, |
| 179 | |
| 180 | { id: 'settings-modal', sel: '#settings-modal .modal-card', |
| 181 | open: async () => { |
| 182 | await quieten(); |
| 183 | await page.evaluate(() => { |
| 184 | const m = document.getElementById('settings-modal'); |
| 185 | if (m) m.style.display = ''; |
| 186 | }); |
| 187 | await pause(300); |
| 188 | } }, |
| 189 | |
| 190 | { id: 'appearance', sel: '#settings-menu', |
| 191 | open: async () => { |
| 192 | await quieten(); |
| 193 | await page.click('#settings-menu-btn', { force: true }); |
| 194 | await pause(320); |
| 195 | }, |
| 196 | after: async () => { await page.keyboard.press('Escape'); await pause(150); } }, |
| 197 | |
| 198 | { id: 'gallery', sel: '#panel-gallery', |
| 199 | open: async (cell) => { |
| 200 | await quieten(); |
| 201 | // `#panel-more` is drawn only when the chip row cannot hold every chip, |
| 202 | // which at 1500px it always can. Narrow the window until the row |
| 203 | // overflows — that IS the state the gallery exists for, so it is the |
| 204 | // honest one to photograph, not a reach past the UI. |
| 205 | await page.setViewportSize({ width: Math.min(cell.w, 1000), height: cell.h }); |
| 206 | await pause(400); |
| 207 | const more = await page.$('#panel-more'); |
| 208 | if (!more) return false; |
| 209 | await more.click({ force: true }); |
| 210 | await pause(320); |
| 211 | return true; |
| 212 | }, |
| 213 | after: async (cell) => { |
| 214 | await page.keyboard.press('Escape'); |
| 215 | await page.setViewportSize({ width: cell.w, height: cell.h }); |
| 216 | await pause(250); |
| 217 | } }, |
| 218 | |
| 219 | { id: 'handmode', sel: '#hand-mode-pop', |
| 220 | open: async () => { |
| 221 | await quieten(); |
| 222 | await page.evaluate(() => DaimondPanels.show('ai')); |
| 223 | await pause(300); |
| 224 | const chip = await page.$('#hand-mode-chip'); |
| 225 | if (!chip) return false; |
| 226 | await chip.click({ force: true }); |
| 227 | await pause(320); |
| 228 | return true; |
| 229 | }, |
| 230 | after: async () => { await page.keyboard.press('Escape'); await pause(150); } }, |
| 231 | |
| 232 | { id: 'palette', sel: '.pal-box', |
| 233 | open: async () => { |
| 234 | await quieten(); |
| 235 | await page.keyboard.press('Control+k'); |
| 236 | await pause(350); |
| 237 | const ok = await page.$eval('#palette', (el) => !el.hidden).catch(() => false); |
| 238 | if (!ok) return false; |
| 239 | await page.fill('.pal-input', 'sp').catch(() => {}); |
| 240 | await pause(250); |
| 241 | return true; |
| 242 | }, |
| 243 | after: async () => { await page.keyboard.press('Escape'); await pause(150); } }, |
| 244 | ]; |
| 245 | |
| 246 | // ── Measurement: rows that claim to be even ──────────────────────────── |
| 247 | // |
| 248 | // Photographs catch what is grossly wrong. These catch what is a few pixels |
| 249 | // wrong, which is the class the user has twice had to point out. |
| 250 | const EVENNESS = function () { |
| 251 | const out = []; |
| 252 | const round = (n) => Math.round(n * 100) / 100; |
| 253 | // A row of siblings meant to read as one control. If they are unequal, say |
| 254 | // by how much, and name the narrowest and the widest so the fix is obvious. |
| 255 | const ROWS = [ |
| 256 | { what: '.seg (dock tiling / spacing)', sel: '.seg', child: 'button' }, |
| 257 | { what: 'mobile tab bar', sel: '#mnav', child: 'button' }, |
| 258 | { what: 'chat head chips', sel: '.chat-head-right', child: 'button' }, |
| 259 | { what: 'crystal actions', sel: '.crystal-bar', child: 'button' }, |
| 260 | { what: 'dialog actions', sel: '.dlg-actions', child: 'button' }, |
| 261 | { what: 'mode ladder rungs', sel: '#hand-mode-pop', child: '.mode-row, label' }, |
| 262 | ]; |
| 263 | for (const r of ROWS) { |
| 264 | document.querySelectorAll(r.sel).forEach((row, n) => { |
| 265 | if (row.getClientRects().length === 0) return; |
| 266 | const kids = [...row.querySelectorAll(r.child)] |
| 267 | .filter((k) => k.getClientRects().length && k.parentElement === row); |
| 268 | if (kids.length < 2) return; |
| 269 | const ws = kids.map((k) => k.getBoundingClientRect().width); |
| 270 | const hs = kids.map((k) => k.getBoundingClientRect().height); |
| 271 | const lo = Math.min(...ws), hi = Math.max(...ws); |
| 272 | // One row, or two? A wrapped row's widths are meant to differ. |
| 273 | const tops = new Set(kids.map((k) => Math.round(k.getBoundingClientRect().top))); |
| 274 | out.push({ |
| 275 | row: `${r.what}${n ? ` [${n}]` : ''}`, |
| 276 | n: kids.length, |
| 277 | lines: tops.size, |
| 278 | widths: ws.map(round), |
| 279 | heights: hs.map(round), |
| 280 | spread: round(hi - lo), |
| 281 | narrowest: kids[ws.indexOf(lo)].textContent.trim().slice(0, 16), |
| 282 | widest: kids[ws.indexOf(hi)].textContent.trim().slice(0, 16), |
| 283 | }); |
| 284 | }); |
| 285 | } |
| 286 | // Anything wrapping onto a second line inside a header that means to be one |
| 287 | // line. A header that wraps leaves a void beside its title, which is the |
| 288 | // mobile-sheet complaint exactly. |
| 289 | for (const sel of ['.msheet-grab', '.panel-head', '.chead', '.railhead', '.admin-drawer-head', '.pptw-head']) { |
| 290 | document.querySelectorAll(sel).forEach((h) => { |
| 291 | if (h.getClientRects().length === 0) return; |
| 292 | const kids = [...h.children].filter((k) => k.getClientRects().length); |
| 293 | if (kids.length < 2) return; |
| 294 | // Not "the tops differ" — in any header a 22px button and a 16px |
| 295 | // heading sit at different tops by design, and testing for that |
| 296 | // reports every header in the app. A header has WRAPPED when the |
| 297 | // vertical spread exceeds the tallest child, which no single line |
| 298 | // can do. |
| 299 | const rs = kids.map((k) => k.getBoundingClientRect()); |
| 300 | const spreadY = Math.max(...rs.map((r) => r.top)) - Math.min(...rs.map((r) => r.top)); |
| 301 | const tallest = Math.max(...rs.map((r) => r.height)); |
| 302 | if (spreadY > tallest * 0.8) { |
| 303 | out.push({ |
| 304 | row: `WRAPPED HEADER ${sel}`, |
| 305 | n: kids.length, |
| 306 | lines: 2, |
| 307 | spreadY: round(spreadY), |
| 308 | tallest: round(tallest), |
| 309 | widths: rs.map((r) => round(r.width)), |
| 310 | heights: rs.map((r) => round(r.height)), |
| 311 | text: h.textContent.trim().replace(/\s+/g, ' ').slice(0, 40), |
| 312 | }); |
| 313 | } |
| 314 | }); |
| 315 | } |
| 316 | // The two rail lists, side by side: a Diamond tile and a chat tile that do |
| 317 | // not start at the same x read as a wobble down the whole rail. |
| 318 | const dl = document.querySelector('#diamond-list'); |
| 319 | const sl = document.querySelector('#session-list'); |
| 320 | if (dl && sl && dl.getClientRects().length && sl.getClientRects().length) { |
| 321 | const d = dl.querySelector('.session-box'), c = sl.querySelector('.session-box'); |
| 322 | out.push({ |
| 323 | row: 'RAIL TILE INSET', |
| 324 | diamondList: getComputedStyle(dl).padding, |
| 325 | sessionList: getComputedStyle(sl).padding, |
| 326 | diamondTileLeft: d ? round(d.getBoundingClientRect().left) : null, |
| 327 | chatTileLeft: c ? round(c.getBoundingClientRect().left) : null, |
| 328 | diamondTileRight: d ? round(d.getBoundingClientRect().right) : null, |
| 329 | chatTileRight: c ? round(c.getBoundingClientRect().right) : null, |
| 330 | }); |
| 331 | } |
| 332 | return out; |
| 333 | }; |
| 334 | |
| 335 | // ── Run ──────────────────────────────────────────────────────────────── |
| 336 | const findings = []; |
| 337 | let shots = 0, misses = 0; |
| 338 | |
| 339 | async function snap(label, sel) { |
| 340 | const file = path.join(OUT, label.replace(/[^\w.-]+/g, '_') + '.png'); |
| 341 | const el = await page.$(sel); |
| 342 | if (!el) return null; |
| 343 | try { |
| 344 | await el.screenshot({ path: file, timeout: 8000, animations: 'disabled' }); |
| 345 | } catch (e) { |
| 346 | try { |
| 347 | const box = await el.boundingBox(); |
| 348 | const vp = page.viewportSize(); |
| 349 | if (!box) throw e; |
| 350 | const clip = { |
| 351 | x: Math.max(0, box.x), y: Math.max(0, box.y), |
| 352 | width: Math.min(box.width, vp.width - Math.max(0, box.x)), |
| 353 | height: Math.min(box.height, vp.height - Math.max(0, box.y)), |
| 354 | }; |
| 355 | await page.screenshot({ path: file, clip, timeout: 8000, animations: 'disabled' }); |
| 356 | } catch (e2) { console.log(` MISS ${label} — ${(e2.message || e2).toString().split('\n')[0]}`); misses++; return null; } |
| 357 | } |
| 358 | // The trap this file exists under: `shot()` swallows a failure, so the run |
| 359 | // looks clean and the directory is empty. Confirm the bytes. |
| 360 | if (!fs.existsSync(file) || fs.statSync(file).size < 200) { |
| 361 | console.log(` MISS ${label} — no bytes on disk`); misses++; return null; |
| 362 | } |
| 363 | shots++; |
| 364 | return file; |
| 365 | } |
| 366 | |
| 367 | try { |
| 368 | await page.waitForFunction(() => !!(window.DaimondPanels && DaimondPanels.panels), null, { timeout: 20000 }); |
| 369 | await seed(); |
| 370 | console.log(`seeded; sweeping ${SURFACES.length} surfaces over ${CELLS.length} cells into ${OUT}\n`); |
| 371 | |
| 372 | for (const cell of CELLS) { |
| 373 | console.log(`── ${cell.tag}`); |
| 374 | await page.setViewportSize({ width: cell.w, height: cell.h }); |
| 375 | await page.evaluate((c) => { |
| 376 | document.documentElement.setAttribute('data-skin', c.skin); |
| 377 | document.documentElement.setAttribute('data-theme', c.theme); |
| 378 | document.documentElement.setAttribute('data-tone', c.theme === 'light' ? 'light' : 'dark'); |
| 379 | document.documentElement.setAttribute('data-ink', c.theme === 'light' ? 'dark' : 'light'); |
| 380 | }, cell); |
| 381 | await pause(300); |
| 382 | |
| 383 | for (const surf of SURFACES) { |
| 384 | if (ONLY.length && !ONLY.includes(surf.id)) continue; |
| 385 | try { |
| 386 | // Below the breakpoint the rail is behind the hamburger. Photograph |
| 387 | // the chat instead of the rail and the picture is mislabelled, which |
| 388 | // is worse than no picture. |
| 389 | if (cell.w < 700 && (surf.mobileDrawer || /^(rail|tag-|crystal|admin-|dlg-)/.test(surf.id))) { |
| 390 | await quieten(); |
| 391 | const btn = await page.$('#drawer-btn'); |
| 392 | if (btn) { await btn.click({ force: true }); await pause(400); } |
| 393 | } |
| 394 | let ok = true; |
| 395 | if (surf.admin) { |
| 396 | await quieten(); |
| 397 | if (cell.w >= 700) await page.evaluate(() => DaimondPanels.show('rail')); |
| 398 | const b = await page.$('#' + surf.admin); |
| 399 | if (!b || !(await b.isVisible())) ok = false; |
| 400 | else { await b.click({ force: true }); await pause(450); } |
| 401 | } else if (surf.open) { |
| 402 | const r = await surf.open(cell); |
| 403 | if (r === false) ok = false; |
| 404 | } |
| 405 | if (!ok) { console.log(` skip ${surf.id} — no opener on this cell`); continue; } |
| 406 | |
| 407 | const shown = await page.$eval(surf.sel, (el) => { |
| 408 | const r = el.getBoundingClientRect(); |
| 409 | return r.width > 4 && r.height > 4; |
| 410 | }).catch(() => false); |
| 411 | if (!shown) { console.log(` skip ${surf.id} — did not show`); continue; } |
| 412 | |
| 413 | const file = await snap(`${cell.tag}--${surf.id}`, surf.sel); |
| 414 | const rows = await page.evaluate(EVENNESS); |
| 415 | findings.push({ cell: cell.tag, surface: surf.id, shot: file, rows }); |
| 416 | // Only the rows that MEAN to be even are faulted for unevenness. |
| 417 | // The crystal bar's three buttons are sized to their words and are |
| 418 | // meant to be; the segmented controls and the tab bar are not. |
| 419 | const EVEN = /\.seg|tab bar|dialog actions|ladder/; |
| 420 | const bad = rows.filter((r) => (EVEN.test(r.row) && r.spread > 1 && r.lines === 1) |
| 421 | || /WRAPPED/.test(r.row)); |
| 422 | console.log(` ${bad.length ? 'LOOK' : ' ok '} ${surf.id}${bad.length ? ' ' + bad.map((b) => b.row + (b.spread ? ` spread ${b.spread}px` : '')).join('; ') : ''}`); |
| 423 | if (surf.after) await surf.after(cell); |
| 424 | } catch (e) { |
| 425 | console.log(` ERR ${surf.id} — ${(e.message || String(e)).split('\n')[0]}`); |
| 426 | } |
| 427 | } |
| 428 | } |
| 429 | |
| 430 | const jsonPath = path.join(OUT, 'qc.json'); |
| 431 | fs.writeFileSync(jsonPath, JSON.stringify(findings, null, '\t')); |
| 432 | console.log(`\n${shots} shots, ${misses} missed, in ${OUT}\n${jsonPath}`); |
| 433 | const errs = errors(s).filter((e) => !/502|Bad Gateway/.test(e)); |
| 434 | if (errs.length) { |
| 435 | console.log(`\n${errs.length} console error(s):`); |
| 436 | for (const e of errs.slice(0, 10)) console.log(' ' + String(e).split('\n')[0]); |
| 437 | } |
| 438 | } finally { |
| 439 | await s.close(); |
| 440 | } |