oxedyne/daimond/dev/verify_mobile.mjs
20.0 KiB, 1 run
created by r2519314175:535, 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_mobile.mjs — the phone shell: chat floor, left drawer, thing-sheet. |
| 2 | // |
| 3 | // Drives the real page at a phone viewport (390×844) and checks the paradigm: |
| 4 | // the rail opens as a left drawer, a stage guest rises as a bottom sheet over |
| 5 | // the chat (never taking a bar slot), the ask pill forwards to the one |
| 6 | // composer, closing the sheet returns the guest to the stage, and growing back |
| 7 | // to desktop reseats everything with the chat still whole. |
| 8 | // |
| 9 | // AND THE RULE THE PARADIGM RESTS ON, which nothing checked until 2026-08-12: |
| 10 | // EVERY PANEL A PHONE CAN BE ASKED FOR ARRIVES ON SCREEN. A phone panel needs |
| 11 | // either a seat in the bottom bar with a `body[data-mpanel="…"]` rule in |
| 12 | // `responsive.css`, or a place in `MOBILE_GUESTS` so it rises as a sheet. Graph |
| 13 | // and Pending had neither: `responsive.css:89` hides every panel and only four |
| 14 | // rules un-hide anything, so asking for either took the conversation off screen |
| 15 | // and put nothing in its place — a visible button, a blank screen, and no way |
| 16 | // back but the hamburger. Pending was the worse of the two, because a worker's |
| 17 | // consent question opens that panel itself. |
| 18 | // |
| 19 | // This file could not see it. It read `dataset.mpanel` twice and both times |
| 20 | // asserted it was `'ai'`, which is a fact about the floor and says nothing about |
| 21 | // what the user was shown. The sweep below asks the only question that matters: |
| 22 | // after asking for a panel, is that panel on the screen, and is anything? |
| 23 | // |
| 24 | // It is written over the PANEL SET, not a list, so the next panel added without |
| 25 | // a destination fails here rather than shipping. |
| 26 | // |
| 27 | // node dev/verify_mobile.mjs --break noguest # the sweep fails for graph and |
| 28 | // # pending: the state before the fix |
| 29 | // node dev/verify_mobile.mjs # and then, clean |
| 30 | import fs from 'node:fs'; |
| 31 | import path from 'node:path'; |
| 32 | import { fileURLToPath } from 'node:url'; |
| 33 | import { open } from './harness.mjs'; |
| 34 | |
| 35 | const WWW = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'www'); |
| 36 | |
| 37 | const BREAK = (() => { |
| 38 | const i = process.argv.indexOf('--break'); |
| 39 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 40 | })(); |
| 41 | |
| 42 | // Each break is a real edit to a real file, served in place of it through |
| 43 | // `page.route`, and is how that piece behaved before the fix. |
| 44 | const BREAKS = { |
| 45 | // `MOBILE_GUESTS` as it was: the panels added after the rule was written are |
| 46 | // not in it, so none of them has anywhere to go on a phone. |
| 47 | // |
| 48 | // MATCHED BY SHAPE AND NOT BY THE TABLE'S LITERAL TEXT, which is the state |
| 49 | // this break was found in on 2026-08-17: it pinned the four lines the table |
| 50 | // held when it was written, three panels had been added to it since, the |
| 51 | // anchor matched ZERO times, and `--break noguest` hard-exited 2 instead of |
| 52 | // going red. A break nobody can run is a check nobody has proved. The table |
| 53 | // is edited every time a panel is added, so the anchor must be the thing that |
| 54 | // does not change -- its opening line and its closing brace. |
| 55 | noguest: [{ |
| 56 | file: 'js/daimond.js', |
| 57 | re: /var MOBILE_GUESTS = \{[\s\S]*?\n\t\};/, |
| 58 | with: 'var MOBILE_GUESTS = { web: 1, doc: 1, msg: 1, compose: 1, tools: 1, ' |
| 59 | + 'spend: 1, term: 1, trash: 1 };', |
| 60 | }], |
| 61 | }; |
| 62 | |
| 63 | if (BREAK && !BREAKS[BREAK]) { |
| 64 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 65 | process.exit(2); |
| 66 | } |
| 67 | |
| 68 | /// The damaged source, or a hard stop. A break whose anchor is not there exactly |
| 69 | /// once broke nothing, and the run below would prove nothing. |
| 70 | /// |
| 71 | /// A spec anchors by `find` (literal) or by `re` (shape). The shape form is for |
| 72 | /// anchors inside something that is edited often -- a table of panels, say -- |
| 73 | /// where pinning the literal text makes the break go stale the next time |
| 74 | /// somebody adds a line to it, and stale means it aborts rather than fails. |
| 75 | function damaged(src, spec) { |
| 76 | if (spec.re) { |
| 77 | const n = (src.match(new RegExp(spec.re.source, spec.re.flags.includes('g') |
| 78 | ? spec.re.flags : spec.re.flags + 'g')) || []).length; |
| 79 | if (n !== 1) { |
| 80 | console.error(`break '${BREAK}': the shape ${spec.re} matches ${n} time(s) in ${spec.file}.`); |
| 81 | process.exit(2); |
| 82 | } |
| 83 | return src.replace(spec.re, spec.with); |
| 84 | } |
| 85 | const n = src.split(spec.find).length - 1; |
| 86 | if (n !== 1) { |
| 87 | console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}.`); |
| 88 | process.exit(2); |
| 89 | } |
| 90 | return src.replace(spec.find, spec.with); |
| 91 | } |
| 92 | |
| 93 | /// The damaged files, ONE BODY PER FILE. |
| 94 | /// |
| 95 | /// Every edit a break names for a file goes into the SAME body, in order, and |
| 96 | /// that one body is what the route serves. A `page.route` per edit spec does not |
| 97 | /// work and does not say so: Playwright hands a request to the LAST route |
| 98 | /// registered for its URL, so a two-edit break shipped only its second edit -- |
| 99 | /// and still went red, for half the reason it claims, with nothing to notice it. |
| 100 | function damagedFiles() { |
| 101 | const byFile = new Map(); |
| 102 | for (const spec of (BREAKS[BREAK] || [])) { |
| 103 | const src = byFile.has(spec.file) ? byFile.get(spec.file) |
| 104 | : fs.readFileSync(path.join(WWW, spec.file), 'utf8'); |
| 105 | byFile.set(spec.file, damaged(src, spec)); |
| 106 | } |
| 107 | return byFile; |
| 108 | } |
| 109 | |
| 110 | const routeBreaks = async (pg) => { |
| 111 | if (!BREAK) return; |
| 112 | for (const [file, body] of damagedFiles()) { |
| 113 | await pg.route('**/' + file, r => r.fulfill({ |
| 114 | status: 200, contentType: 'application/javascript', body, |
| 115 | })); |
| 116 | } |
| 117 | }; |
| 118 | |
| 119 | const ok = [], bad = []; |
| 120 | const check = (name, pass, detail) => { |
| 121 | (pass ? ok : bad).push(name); |
| 122 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 123 | }; |
| 124 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 125 | // A browser-only tab talking to no gateway account gets 401s on /api/* — that is |
| 126 | // the disconnected state, not a fault. Judge only real script/page errors. |
| 127 | const realErrs = () => s.errs.filter(e => |
| 128 | !/Failed to load resource|status of 401|status of 4\d\d/.test(e)); |
| 129 | const shot = async (p) => { try { await page.screenshot({ path: p, timeout: 8000 }); } catch (e) { console.log(' (shot skipped: ' + p + ')'); } }; |
| 130 | const raise = async (id) => { // force a clean open even if open-by-default |
| 131 | await page.evaluate((x) => { window.DaimondPanels.hide(x); window.DaimondPanels.show(x); }, id); |
| 132 | await sleep(450); |
| 133 | }; |
| 134 | |
| 135 | const s = await open({ signIn: true, connect: true, name: 'mobiletester', route: routeBreaks }); |
| 136 | const { page } = s; |
| 137 | |
| 138 | // Become a phone. matchMedia('(max-width:760px)') flips, and the shell takes over. |
| 139 | await page.setViewportSize({ width: 390, height: 844 }); |
| 140 | // Headless does not reliably advance CSS transitions, so a measured mid-flight |
| 141 | // value is meaningless; disable them so every assertion reads the settled state. |
| 142 | await page.addStyleTag({ content: '*,*::before,*::after{transition:none!important;animation:none!important}' }); |
| 143 | await sleep(400); |
| 144 | |
| 145 | // A chat must exist for the floor to be the conversation. |
| 146 | await page.evaluate(() => { try { document.getElementById('new-session-btn').click(); } catch (e) {} }); |
| 147 | await sleep(300); |
| 148 | await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find(x => /^Start$/.test(x.textContent.trim())); if (b) b.click(); }); |
| 149 | await sleep(500); |
| 150 | |
| 151 | // ── 1. Boot clean on a phone ─────────────────────────────── |
| 152 | check('boot: no console errors', realErrs().length === 0, realErrs().slice(0, 3).join(' | ')); |
| 153 | check('boot: shell present', await page.evaluate(() => !!window.DaimondSheet && !!window.DaimondShell)); |
| 154 | check('boot: hamburger visible', await page.evaluate(() => { |
| 155 | const b = document.getElementById('drawer-btn'); return b && getComputedStyle(b).display !== 'none'; |
| 156 | })); |
| 157 | // THE BAR IS THE CHIP ROW NOW, and this asked for the four hard-wired |
| 158 | // destinations it replaced -- Chat, Email, Files, Agents, four of the seventeen |
| 159 | // panels there are. The count is asserted UP rather than merely changed: what |
| 160 | // the footer holds must be the whole pinned row, and the four the old bar named |
| 161 | // must all still be in it, or "replaced the bar" would be a way of losing three |
| 162 | // of them quietly. |
| 163 | { |
| 164 | const bar = await page.evaluate(() => { |
| 165 | const row = document.getElementById('panel-tags'); |
| 166 | return { |
| 167 | inBar: !!(row && row.closest('#mnav')), |
| 168 | ids: row ? [...row.querySelectorAll('.ptag[data-panel]')].map(c => c.dataset.panel) : [], |
| 169 | labelled: row ? [...row.querySelectorAll('.ptag[data-panel]')].every(c => (c.textContent || '').trim().length > 0) : false, |
| 170 | scrolls: row ? row.scrollWidth > row.clientWidth : false, |
| 171 | more: row ? (row.dataset.more || '') : '', |
| 172 | }; |
| 173 | }); |
| 174 | check('boot: the footer is the panel chip row', bar.inBar); |
| 175 | check('boot: it holds more than the four the old bar named', |
| 176 | bar.ids.length > 4, bar.ids.length + ': ' + bar.ids.join(',')); |
| 177 | check('boot: and it still holds all four of them', |
| 178 | ['ai', 'mail', 'work', 'agents'].every(id => bar.ids.indexOf(id) !== -1), bar.ids.join(',')); |
| 179 | check('boot: every chip carries its word', bar.labelled); |
| 180 | check('boot: the strip scrolls, and says which way there is more of it', |
| 181 | bar.scrolls && /^(start|end|both)$/.test(bar.more), 'more=' + bar.more); |
| 182 | } |
| 183 | check('boot: floor is the chat', await page.evaluate(() => document.body.dataset.mpanel === 'ai')); |
| 184 | |
| 185 | // ── 1b. The strip's chips reach, and say where you are ───── |
| 186 | // A strip that scrolls is only useful if the chip you press comes to you: the |
| 187 | // last four panels are off the end of a 390px screen, and a chip you have to |
| 188 | // find again after every tap is a chip you stop using. |
| 189 | { |
| 190 | await page.evaluate(() => { const r = document.getElementById('panel-tags'); r.scrollLeft = 0; r.dispatchEvent(new Event('scroll')); }); |
| 191 | await sleep(250); |
| 192 | const before = await page.evaluate(() => { |
| 193 | const r = document.getElementById('panel-tags'); |
| 194 | const c = r.querySelector('.ptag[data-panel="social"]'); |
| 195 | return { left: Math.round(r.scrollLeft), off: c.getBoundingClientRect().right > window.innerWidth }; |
| 196 | }); |
| 197 | await page.evaluate(() => document.querySelector('#panel-tags .ptag[data-panel="social"]').click()); |
| 198 | await sleep(600); |
| 199 | const after = await page.evaluate(() => { |
| 200 | const r = document.getElementById('panel-tags'); |
| 201 | const c = r.querySelector('.ptag[data-panel="social"]'); |
| 202 | const b = c.getBoundingClientRect(); |
| 203 | return { left: Math.round(r.scrollLeft), onScreen: b.left >= 0 && b.right <= window.innerWidth, |
| 204 | lit: [...r.querySelectorAll('.ptag.on')].map(x => x.dataset.panel) }; |
| 205 | }); |
| 206 | check('strip: the last chip starts off the end of the screen', before.off); |
| 207 | check('strip: pressing it brings it into view', after.onScreen && after.left > before.left, |
| 208 | JSON.stringify(after)); |
| 209 | check('strip: and it is the only chip filled in — where you are, not what is open', |
| 210 | after.lit.length === 1 && after.lit[0] === 'social', after.lit.join(',')); |
| 211 | // Back to the floor, so the sections below start where they expect to. |
| 212 | await page.evaluate(() => document.querySelector('#panel-tags .ptag[data-panel="ai"]').click()); |
| 213 | await sleep(500); |
| 214 | } |
| 215 | |
| 216 | // ── 2. The drawer ────────────────────────────────────────── |
| 217 | await page.evaluate(() => document.getElementById('drawer-btn').click()); |
| 218 | await sleep(350); |
| 219 | check('drawer: opens on hamburger', await page.evaluate(() => document.body.classList.contains('drawer-open'))); |
| 220 | check('drawer: rail is on screen', await page.evaluate(() => { |
| 221 | const r = document.getElementById('panel-rail'); if (!r) return false; |
| 222 | const x = r.getBoundingClientRect().left; return x > -5; // slid in from the left |
| 223 | })); |
| 224 | check('drawer: scrim is catching taps', await page.evaluate(() => { |
| 225 | const sc = document.getElementById('scrim'); return sc && getComputedStyle(sc).pointerEvents === 'auto'; |
| 226 | })); |
| 227 | await page.evaluate(() => document.getElementById('scrim').click()); |
| 228 | await sleep(350); |
| 229 | check('drawer: scrim tap closes it', await page.evaluate(() => !document.body.classList.contains('drawer-open'))); |
| 230 | |
| 231 | // ── 3. A thing rises as a sheet ──────────────────────────── |
| 232 | await raise('web'); |
| 233 | check('sheet: opens for a stage guest', await page.evaluate(() => window.DaimondSheet.isOpen() && window.DaimondSheet.guest() === 'web')); |
| 234 | check('sheet: guest moved into the sheet', await page.evaluate(() => { |
| 235 | const el = document.getElementById('panel-web'); |
| 236 | return el && el.closest('#msheet') !== null; |
| 237 | })); |
| 238 | check('sheet: floor stays the chat', await page.evaluate(() => document.body.dataset.mpanel === 'ai')); |
| 239 | check('sheet: raised above peek', await page.evaluate(() => { |
| 240 | const h = document.getElementById('msheet').getBoundingClientRect().height; |
| 241 | return h > 120; // well above the ~56px peek |
| 242 | })); |
| 243 | check('sheet: ask pill on screen at half', await page.evaluate(() => { |
| 244 | const pill = document.getElementById('msheet-ask'); |
| 245 | const r = pill.getBoundingClientRect(); |
| 246 | return r.height > 0 && r.bottom <= window.innerHeight + 1 && r.top >= 0; |
| 247 | })); |
| 248 | check('sheet: web keeps an ask pill', await page.evaluate(() => |
| 249 | !document.getElementById('msheet-ask').classList.contains('hidden'))); |
| 250 | check('sheet: bar still reachable above sheet', await page.evaluate(() => { |
| 251 | const bar = document.getElementById('mnav'), sh = document.getElementById('msheet'); |
| 252 | const bz = +getComputedStyle(bar).zIndex || 0, sz = +getComputedStyle(sh).zIndex || 0; |
| 253 | return bz > sz; |
| 254 | })); |
| 255 | check('sheet: chat composer hidden while sheet up', await page.evaluate(() => { |
| 256 | const bar = document.querySelector('.ai .chat-input-bar'); |
| 257 | return bar && getComputedStyle(bar).visibility === 'hidden'; |
| 258 | })); |
| 259 | await shot('shots/mobile-sheet-web.png'); |
| 260 | |
| 261 | // ── 4. Tools sheet hides the ask pill ────────────────────── |
| 262 | await raise('tools'); |
| 263 | check('sheet: only one guest up at a time', await page.evaluate(() => |
| 264 | document.getElementById('panel-web').closest('#msheet') === null && |
| 265 | document.getElementById('panel-tools').closest('#msheet') !== null)); |
| 266 | check('sheet: tools has no ask pill', await page.evaluate(() => |
| 267 | document.getElementById('msheet-ask').classList.contains('hidden'))); |
| 268 | |
| 269 | // ── 5. Close returns the guest to the stage ──────────────── |
| 270 | await page.evaluate(() => document.getElementById('msheet-close').click()); |
| 271 | await sleep(400); |
| 272 | check('close: sheet down', await page.evaluate(() => !window.DaimondSheet.isOpen() && !document.body.classList.contains('sheet-open'))); |
| 273 | check('close: guest back in the stage', await page.evaluate(() => { |
| 274 | const el = document.getElementById('panel-tools'); |
| 275 | return el && el.closest('#stage') !== null && el.closest('#msheet') === null; |
| 276 | })); |
| 277 | check('close: engine marks it closed', await page.evaluate(() => !window.DaimondPanels.isOpen('tools'))); |
| 278 | |
| 279 | // ── 6. The ask pill forwards to the one composer ─────────── |
| 280 | await raise('web'); |
| 281 | const asked = await page.evaluate(async () => { |
| 282 | const before = document.querySelectorAll('.chat-msg-user').length; |
| 283 | document.getElementById('msheet-ask-input').value = 'what is this page'; |
| 284 | document.getElementById('msheet-ask-send').click(); |
| 285 | await new Promise(r => setTimeout(r, 600)); |
| 286 | const after = document.querySelectorAll('.chat-msg-user').length; |
| 287 | return { before, after, parkedH: document.getElementById('msheet').style.height }; |
| 288 | }); |
| 289 | check('ask: pill posts a user message to the chat', asked.after === asked.before + 1, JSON.stringify(asked)); |
| 290 | |
| 291 | // ── 6a. Every panel a phone can ask for arrives on screen ── |
| 292 | // |
| 293 | // The panel SET is read out of the page, so a panel added later is swept |
| 294 | // without anybody remembering to add it here. Two questions per panel, and they |
| 295 | // are different failures: the panel the user asked for is on screen, and SOME |
| 296 | // panel is — a blank main area is the defect that shipped, and a panel that is |
| 297 | // merely mis-seated is the one that would replace it. |
| 298 | // |
| 299 | // Everything else is closed first. A dock with no free seat refuses to open |
| 300 | // anything (`DaimondPanels.show` returns early), and a refusal is not the defect |
| 301 | // under test — it would redden this sweep for a reason that has nothing to do |
| 302 | // with whether the panel has a phone destination. |
| 303 | // The PANELS, not the chips that name them: `data-panel` is on both, so the |
| 304 | // element's own id is what says which is which. |
| 305 | const panelIds = await page.evaluate(() => |
| 306 | [...document.querySelectorAll('[data-panel]')] |
| 307 | .filter(e => e.id === 'panel-' + e.dataset.panel) |
| 308 | .map(e => e.dataset.panel) |
| 309 | .filter(id => id !== 'rail')); |
| 310 | check('phone: the page declares its panels', panelIds.length >= 10, panelIds.join(' ')); |
| 311 | |
| 312 | const missing = [], blank = []; |
| 313 | for (const id of panelIds) { |
| 314 | const seen = await page.evaluate(async (x) => { |
| 315 | const P = window.DaimondPanels; |
| 316 | [...document.querySelectorAll('[data-panel]')].forEach((e) => { |
| 317 | if (e.id !== 'panel-' + e.dataset.panel) return; |
| 318 | const p = e.dataset.panel; |
| 319 | if (p !== x && p !== 'rail' && p !== 'ai') { try { P.hide(p); } catch (e2) {} } |
| 320 | }); |
| 321 | try { P.hide(x); } catch (e2) {} |
| 322 | P.show(x); |
| 323 | await new Promise(r => setTimeout(r, 400)); |
| 324 | const el = document.getElementById('panel-' + x); |
| 325 | const b = el ? el.getBoundingClientRect() : { width: 0, height: 0 }; |
| 326 | // Anything at all in the main area with a real box. The bug shows here as |
| 327 | // nothing: `body[data-mpanel="graph"]` matches no un-hide rule, so every |
| 328 | // panel in `.main` keeps `display: none !important`. |
| 329 | // |
| 330 | // NOT `.rail`. `responsive.css:89` hides `.panel:not(.rail)`, so the rail |
| 331 | // keeps its box whatever happens and counting it made this a check that |
| 332 | // could not fail — it passed against the broken build, which is the one |
| 333 | // thing a check must never do. |
| 334 | const anyMain = [...document.querySelectorAll('.main .panel:not(.rail)')] |
| 335 | .some((p) => p.getBoundingClientRect().height > 20); |
| 336 | return { onScreen: b.width > 0 && b.height > 0, anyMain, |
| 337 | mpanel: document.body.dataset.mpanel, |
| 338 | guest: window.DaimondSheet ? window.DaimondSheet.guest() : null }; |
| 339 | }, id); |
| 340 | if (!seen.onScreen) missing.push(id + ' (mpanel=' + seen.mpanel + ', sheet=' + seen.guest + ')'); |
| 341 | if (!seen.anyMain) blank.push(id); |
| 342 | // The sheet is put away between panels so each one is asked of a clean shell. |
| 343 | await page.evaluate(() => { try { window.DaimondSheet.close(); } catch (e) {} }); |
| 344 | await sleep(250); |
| 345 | } |
| 346 | check('phone: every panel the user can ask for arrives on screen', |
| 347 | missing.length === 0, missing.length ? ('never appeared: ' + missing.join('; ')) : panelIds.length + ' swept'); |
| 348 | check('phone: and none of them leaves the main area blank', |
| 349 | blank.length === 0, blank.length ? ('blank screen for: ' + blank.join(', ')) : ''); |
| 350 | await shot('shots/mobile-panel-sweep.png'); |
| 351 | |
| 352 | // Back to the chat floor for the desktop half below. |
| 353 | await page.evaluate(() => { try { window.DaimondSheet.close(); } catch (e) {} window.DaimondPanels.show('ai'); }); |
| 354 | await sleep(350); |
| 355 | |
| 356 | // ── 7. Grow back to desktop: everything reseats ──────────── |
| 357 | await page.setViewportSize({ width: 1500, height: 950 }); |
| 358 | // A real browser fires `resize` on a viewport change; Playwright's |
| 359 | // setViewportSize does not always, so dispatch it as the browser would. |
| 360 | await page.evaluate(() => window.dispatchEvent(new Event('resize'))); |
| 361 | await sleep(600); |
| 362 | console.log(' dbg ' + JSON.stringify(await page.evaluate(() => ({ |
| 363 | innerW: window.innerWidth, |
| 364 | mqMatches: window.matchMedia('(max-width: 760px)').matches, |
| 365 | sheetOpen: document.body.classList.contains('sheet-open'), |
| 366 | guest: window.DaimondSheet.guest(), |
| 367 | webParent: (function () { const e = document.getElementById('panel-web'); return e && e.parentElement ? e.parentElement.id : null; })(), |
| 368 | webOpen: window.DaimondPanels.isOpen('web'), |
| 369 | })))); |
| 370 | check('desktop: sheet folded away', await page.evaluate(() => |
| 371 | !document.body.classList.contains('sheet-open') && !window.DaimondSheet.isOpen())); |
| 372 | check('desktop: rail back in the main column', await page.evaluate(() => { |
| 373 | const r = document.getElementById('panel-rail'); |
| 374 | return r && r.closest('#main') !== null && getComputedStyle(r).position !== 'fixed'; |
| 375 | })); |
| 376 | check('desktop: web guest reseated on the stage', await page.evaluate(() => { |
| 377 | const el = document.getElementById('panel-web'); |
| 378 | return el && el.closest('#stage') !== null; |
| 379 | })); |
| 380 | check('desktop: no new console errors', realErrs().length === 0, realErrs().slice(0, 3).join(' | ')); |
| 381 | |
| 382 | await shot('shots/mobile-desktop-after.png'); |
| 383 | |
| 384 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 385 | if (bad.length) console.log('FAILED: ' + bad.join(', ')); |
| 386 | await s.close(); |
| 387 | process.exit(bad.length ? 1 : 0); |