// Multi-device E2E (puppeteer-core + system Chrome device emulation). Opt-in: SP_E2E=1 (needs Chrome, ~2 min). // // SP_E2E=1 node --test test/ui/devices.e2e.test.js // // Devices (landscape): iPad (1180×820, touch), iPhone 14 (844×390, touch), Pixel 7 (915×412, touch), a 1366×768 touch // laptop (touch + mouse) and the smallest supported phone (640×360). For each: the real title page (index.html) — on // phones and the iPad also title → lobby → room with taps against the real server — and the in-match mock // (public/dev/game-mock.html) — screenshots → test/e2e/out/device--.png — and checks: // * no page overflow, every visible HUD control fully inside the viewport, the rotate hint hidden in landscape; // * feature classes (sp-touch / sp-coarse) from feature detection, touch-action on the field and the page; // * touch: tap targets of the small HUD controls are ≥ 40 px (hit-area probe with elementFromPoint), the 🔍▶▶ pen // button works with taps, two-tap buy works with taps, a long press on a shop card opens its detail (no purchase); // * safe-area insets (a notch on the left) move the HUD, not the field; // plus graceful degradation: WebGL disabled → the DOM view with a notice, local-client art missing → CSS look-alikes, // portrait → the rotate hint. import { test, describe, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { existsSync, mkdirSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const OUT = path.join(ROOT, 'test/e2e/out'); const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME); const IOS_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'; const IPAD_UA = 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'; const ANDROID_UA = 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36'; const DEVICES = { ipad: { viewport: { width: 1180, height: 820, deviceScaleFactor: 2, isMobile: true, hasTouch: true, isLandscape: true }, userAgent: IPAD_UA, coarse: true }, iphone14: { viewport: { width: 844, height: 390, deviceScaleFactor: 3, isMobile: true, hasTouch: true, isLandscape: true }, userAgent: IOS_UA, coarse: true }, pixel7: { viewport: { width: 915, height: 412, deviceScaleFactor: 2.625, isMobile: true, hasTouch: true, isLandscape: true }, userAgent: ANDROID_UA, coarse: true }, 'laptop-touch': { viewport: { width: 1366, height: 768, deviceScaleFactor: 1, isMobile: false, hasTouch: true, isLandscape: true }, userAgent: null, coarse: true }, 'phone-min': { viewport: { width: 640, height: 360, deviceScaleFactor: 2, isMobile: true, hasTouch: true, isLandscape: true }, userAgent: ANDROID_UA, coarse: true }, }; /** Small HUD controls that must have a ≥ 40 px hit area on touch screens. */ const HUD_TARGETS = ['.gtop__exit', '.gtop__iconbtn', '.enemybtn', '.readybtn', '.toolbtn', '.ewheel__btn', '.gm__gear']; describe('multi-device (Chrome device emulation)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => { let srv; let browser; let base; before(async () => { const { startServer } = await import('../../server/index.js'); const puppeteer = (await import('puppeteer-core')).default; srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true }); base = `http://127.0.0.1:${srv.port}`; browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] }); mkdirSync(OUT, { recursive: true }); }); after(async () => { await browser?.close(); await srv?.close(); }); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function open(dev, url, { render = 'engine', intercept = null, b = browser, ctx = null } = {}) { const page = await (ctx || b).newPage(); const d = DEVICES[dev]; await page.emulate({ viewport: d.viewport, userAgent: d.userAgent || await b.userAgent() }); const problems = []; const local = (u) => u.startsWith(base); page.on('console', (m) => { if (m.type() === 'error' && !/Failed to load resource/.test(m.text())) problems.push(`console: ${m.text()}`); }); page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`)); page.on('requestfailed', (r) => { if (local(r.url()) && !intercept) problems.push(`requestfailed: ${r.url()}`); }); if (intercept) { await page.setRequestInterception(true); page.on('request', (r) => (intercept(r.url()) ? r.respond({ status: 404, body: 'gone' }) : r.continue())); } const full = url.startsWith('/') ? `${base}${url}` : `${base}/dev/game-mock.html?shot=1&render=${render}&${url}`; await page.goto(full, { waitUntil: 'networkidle0' }); await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)'), { timeout: 20000 }); await sleep(url.startsWith('/') ? 600 : 1600); return { page, problems }; } /** No scrollbars, every visible control inside the viewport. */ async function layoutProblems(page, scope = 'body') { return page.evaluate((sel) => { const out = []; const se = document.scrollingElement || document.documentElement; if (se.scrollWidth > innerWidth + 1) out.push(`page overflows horizontally (${se.scrollWidth} > ${innerWidth})`); if (se.scrollHeight > innerHeight + 1) out.push(`page overflows vertically (${se.scrollHeight} > ${innerHeight})`); const root = document.querySelector(sel) || document.body; for (const el of root.querySelectorAll('button, input, [role="button"]')) { const r = el.getBoundingClientRect(); if (r.width < 2 || r.height < 2) continue; let hidden = false; for (let a = el; a && a !== document.body; a = a.parentElement) { const cs = getComputedStyle(a); if (cs.visibility === 'hidden' || cs.display === 'none' || Number(cs.opacity) < 0.05) { hidden = true; break; } } if (hidden || el.closest('.mockbar')) continue; // content of a scroller (strategy grid, result list …) may scroll; an element parked fully off-screen is hidden let scroller = false; for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) { const cs = getComputedStyle(a); if (/(auto|scroll)/.test(cs.overflowX + cs.overflowY)) { scroller = true; break; } } if (scroller) continue; if (r.right <= 0 || r.bottom <= 0 || r.left >= innerWidth || r.top >= innerHeight) continue; if (r.left < -1 || r.top < -1 || r.right > innerWidth + 1 || r.bottom > innerHeight + 1) { out.push(`clipped: ${el.className || el.tagName} ${(el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 12)} [${Math.round(r.left)},${Math.round(r.top)},${Math.round(r.right)},${Math.round(r.bottom)}]`); } } return out; }, scope); } /** Effective hit box of the first visible match of each selector (probe outward from the centre). */ async function hitBoxes(page, sels) { return page.evaluate((list) => list.map((sel) => { const el = [...document.querySelectorAll(sel)].find((x) => x.getBoundingClientRect().width > 1); if (!el) return { sel, missing: true }; const r = el.getBoundingClientRect(); const cx = r.left + r.width / 2, cy = r.top + r.height / 2; const inside = (x, y) => { const t = document.elementFromPoint(x, y); return !!t && (t === el || el.contains(t)); }; const reach = (dx, dy) => { let d = 0; while (d < 30 && inside(cx + dx * (d + 1), cy + dy * (d + 1))) d++; return d; }; return { sel, w: reach(-1, 0) + reach(1, 0) + 1, h: reach(0, -1) + reach(0, 1) + 1 }; }), sels); } for (const dev of Object.keys(DEVICES)) { test(`${dev}: title page — fits, touch classes, no zoom gestures, no errors`, async () => { const { page, problems } = await open(dev, '/'); await page.waitForSelector('.title-screen', { timeout: 10000 }); await page.screenshot({ path: path.join(OUT, `device-${dev}-title.png`) }); const st = await page.evaluate(() => ({ cls: document.documentElement.className, rotate: getComputedStyle(document.querySelector('.rotate-hint')).display, touchAction: getComputedStyle(document.documentElement).touchAction, focus: document.activeElement?.tagName, })); assert.match(st.cls, /\bsp-touch\b/, 'touch detected'); assert.match(st.cls, /\bsp-coarse\b/, 'coarse pointer detected'); assert.equal(st.rotate, 'none', 'no rotate hint in landscape'); assert.equal(st.touchAction, 'pan-x pan-y', 'no pinch / double-tap zoom'); assert.notEqual(st.focus, 'INPUT', 'no autofocus on touch screens (it would pop the keyboard)'); assert.deepEqual(await layoutProblems(page), []); assert.deepEqual(problems, []); await page.close(); }); test(`${dev}: match HUD (prep / combat / strategy draft) fits; ≥ 40 px touch targets`, async () => { for (const [name, q] of [['prep', 'phase=PREP'], ['combat', 'phase=COMBAT'], ['draft', 'phase=BAND_DRAFT'], ['briefing', 'phase=INFO_CHECK'], ['sp', 'phase=SP_DRAFT&variant=bounty'], ['result', 'phase=RESULT']]) { const { page, problems } = await open(dev, q); await page.screenshot({ path: path.join(OUT, `device-${dev}-${name}.png`) }); assert.deepEqual(await layoutProblems(page), [], `${dev} ${name}`); if (name === 'prep') { assert.equal(await page.$eval('.gm__field canvas:last-of-type', (el) => getComputedStyle(el).touchAction).catch(() => 'none'), 'none', 'the field owns its gestures'); for (const b of await hitBoxes(page, HUD_TARGETS)) { if (b.missing) continue; assert.ok(b.w >= 34 && b.h >= 34 && Math.max(b.w, b.h) >= 40, `${dev}: ${b.sel} hit box ${b.w}×${b.h}`); } } assert.deepEqual(problems, [], `${dev} ${name}`); await page.close(); } }); } for (const dev of ['iphone14', 'ipad', 'laptop-touch']) { test(`${dev}: touch — tap the pen button and back, two-tap buy, long-press = detail`, async () => { const { page, problems } = await open(dev, 'phase=PREP'); await page.waitForFunction(() => !!globalThis.__SP_VIEW__, { timeout: 15000 }); const tap = async (sel) => { const r = await page.$eval(sel, (el) => { const b = el.getBoundingClientRect(); return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; }); await page.touchscreen.tap(r.x, r.y); await sleep(350); }; await tap('.enemybtn'); await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen', { timeout: 3000 }); await sleep(400); await page.screenshot({ path: path.join(OUT, `device-${dev}-pen.png`) }); await tap('.gtop__iconbtn'); await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'prep', { timeout: 3000 }); assert.equal(await page.$('.tooltip.is-shown'), null, 'a tap never leaves a tooltip behind'); // two taps buy const funds0 = await page.evaluate(() => globalThis.__MOCK__.S().priv.funds); await tap('.shopbar__cards .scard:not(.scard--sold):not(.is-disabled)'); assert.ok(await page.$('.scard.is-armed'), 'first tap arms 确认购买'); await tap('.scard.is-armed'); const funds1 = await page.evaluate(() => globalThis.__MOCK__.S().priv.funds); assert.ok(funds1 < funds0, 'second tap buys'); // long press on a card: its detail, no purchase / arming await page.keyboard.press('Escape'); await sleep(200); const card = await page.$eval('.shopbar__cards .scard:not(.scard--sold):not(.is-disabled):not(.is-armed)', (el) => { const b = el.getBoundingClientRect(); return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; }); await page.touchscreen.touchStart(card.x, card.y); await sleep(800); await page.touchscreen.touchEnd(); await sleep(400); assert.ok(await page.$('.dpanel'), 'long press opens the detail card'); assert.equal(await page.evaluate(() => globalThis.__MOCK__.S().priv.funds), funds1, 'long press never buys'); assert.deepEqual(problems, []); await page.close(); }); } for (const dev of ['iphone14', 'phone-min', 'pixel7', 'ipad']) { test(`${dev}: 交流 pager — every emote cell, dot and ‹ › is hit at its centre; taps send / change the page`, async () => { const { page, problems } = await open(dev, 'phase=PREP'); await page.evaluate(() => localStorage.removeItem('sp.pref.emoteTheme')); const tapAt = async (p) => { await page.touchscreen.tap(p.x, p.y); await sleep(350); }; const openWheel = async () => { const b = await page.$eval('.ewheel__btn', (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }); await tapAt(b); await page.waitForSelector('.ewheel__panel'); await sleep(450); // the panel's pop-in animation }; await openWheel(); const hits = await page.evaluate(() => { const own = (el, sel) => { const r = el.getBoundingClientRect(); const t = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); return t?.closest(sel) === el ? null : `${el.getAttribute('aria-label')} → ${t ? `${t.tagName}.${t.className} ${t.getAttribute('aria-label') || ''}` : 'nothing'}`; }; return [...document.querySelectorAll('.ewheel__item')].map((el) => own(el, '.ewheel__item')) .concat([...document.querySelectorAll('.ewheel__dot')].map((el) => own(el, '.ewheel__dot'))) .concat([...document.querySelectorAll('.ewheel__nav')].map((el) => own(el, '.ewheel__nav'))) .filter(Boolean); }); assert.deepEqual(hits, [], `${dev}: controls covered by a neighbour's hit area`); // each dot opens its own page — the last one first: dot 1 used to act as ‹ from the last page const dots = await page.$$eval('.ewheel__dot', (els) => els.map((el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; })); for (const i of [dots.length - 1, ...dots.keys()]) { await tapAt(dots[i]); assert.equal(await page.$$eval('.ewheel__dot', (els) => els.findIndex((d) => d.classList.contains('is-on'))), i, `${dev}: dot ${i + 1}`); } await tapAt(dots[0]); // the bottom corners of page 1 send (they sat under the enlarged ‹ / › hit areas) for (const idx of [3, 5]) { if (!(await page.$('.ewheel__panel'))) await openWheel(); const c = await page.$$eval('.ewheel__item', (els, k) => { const r = els[k].getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, page: document.querySelector('.ewheel__dot.is-on')?.getAttribute('aria-label') }; }, idx); assert.match(c.page, /1\/6/, 'page 1'); await tapAt(c); assert.equal(await page.$('.ewheel__panel'), null, `${dev}: a tap on emote #${idx + 1} sends it (the panel closes)`); await sleep(1100); // chatCD } assert.deepEqual(problems, []); await page.close(); }); } for (const dev of ['phone-min', 'iphone14']) { test(`${dev}: the left detail card leaves the corner buttons (⚙ 📖 ⛶) usable`, async () => { const { page, problems } = await open(dev, 'phase=PREP'); const card = await page.$eval('.shopbar__cards .scard:not(.scard--sold):not(.is-disabled)', (el) => { const b = el.getBoundingClientRect(); return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; }); await page.touchscreen.tap(card.x, card.y); await page.waitForSelector('.dpanel:not(.dpanel--right)', { timeout: 3000 }); await sleep(400); const covered = await page.evaluate(() => [...document.querySelectorAll('.gm__corner .gm__gear')].map((el) => { const r = el.getBoundingClientRect(); const t = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); return t && el.contains(t) ? null : `${el.getAttribute('aria-label') || el.className} under ${t?.className}`; }).filter(Boolean)); await page.screenshot({ path: path.join(OUT, `device-${dev}-detail-corner.png`) }); assert.deepEqual(covered, []); assert.deepEqual(problems, []); await page.close(); }); } for (const dev of ['iphone14', 'phone-min', 'ipad']) { test(`${dev}: title → lobby → room with taps (the on-screen flow before a match fits and responds)`, async () => { // a fresh profile: another device's saved session (same origin) would resume straight into its room const ctx = await browser.createBrowserContext(); const { page, problems } = await open(dev, '/', { ctx }); const tapText = async (sel, text) => { const r = await page.evaluate((s, t) => { const el = [...document.querySelectorAll(s)].find((x) => !t || x.textContent.includes(t)); if (!el) return null; const b = el.getBoundingClientRect(); return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; }, sel, text); assert.ok(r, `${sel} ${text || ''}`); await page.touchscreen.tap(r.x, r.y); await sleep(350); }; await tapText('.title-login input'); await page.keyboard.type('凯尔希'); await tapText('.title-login button', '开始'); await page.waitForSelector('.lobby-screen', { timeout: 15000 }).catch(async (err) => { await page.screenshot({ path: path.join(OUT, `device-${dev}-lobby-FAILED.png`) }); throw err; }); await sleep(400); await page.screenshot({ path: path.join(OUT, `device-${dev}-lobby.png`) }); assert.deepEqual(await layoutProblems(page), [], `${dev} lobby`); await tapText('.mode-card', '同盟模拟'); await tapText('.create-box button', '创建同盟'); await page.waitForSelector('.room-bar__right', { timeout: 15000 }); await sleep(500); await page.screenshot({ path: path.join(OUT, `device-${dev}-room.png`) }); assert.deepEqual(await layoutProblems(page), [], `${dev} room`); assert.deepEqual(problems, []); await page.close(); await ctx.close(); }); } test('safe-area insets (notch on the left): the HUD moves in, the field stays full-bleed', async () => { const { page, problems } = await open('iphone14', 'phase=PREP'); await page.addStyleTag({ content: ':root { --sa-l: 47px !important; --sa-r: 0px !important; --sa-b: 21px !important; }' }); await sleep(300); const r = await page.evaluate(() => { const box = (s) => { const b = document.querySelector(s)?.getBoundingClientRect(); return b ? { left: b.left, bottom: b.bottom, right: b.right } : null; }; return { hud: box('.gm__hud'), field: box('.gm__field'), exit: box('.gtop__exit'), corner: box('.gm__corner') }; }); assert.equal(Math.round(r.hud.left), 47); assert.equal(Math.round(r.field.left), 0, 'the board stays full-bleed'); assert.ok(r.exit.left >= 47, `exit button clear of the notch (${r.exit.left})`); assert.ok(r.corner.bottom <= 390 - 21 + 0.5, 'corner buttons clear of the home indicator'); await page.screenshot({ path: path.join(OUT, 'device-iphone14-notch.png') }); // review regression: a top inset moves the in-match toasts under the top bar once (not twice: margin + top) await page.addStyleTag({ content: ':root { --sa-t: 20px !important; }' }); await page.evaluate(() => import('/js/ui/toasts.js').then((m) => m.toast('整备区已满', 'warn'))); await page.waitForSelector('.toast'); const t = await page.evaluate(() => ({ host: document.querySelector('.toast-host').getBoundingClientRect().top, rem: parseFloat(getComputedStyle(document.documentElement).fontSize), })); assert.ok(Math.abs(t.host - (1.34 * t.rem + 20)) <= 1.5, `toast host at ${t.host} (expected ${1.34 * t.rem + 20})`); assert.deepEqual(problems, []); await page.close(); }); test('交流 button: the label never runs into the robot icon (desktop sizes, phones, CJK 12 px minimum font size)', async () => { const puppeteer = (await import('puppeteer-core')).default; const { mkdtempSync, mkdirSync: mk, writeFileSync, rmSync } = await import('node:fs'); const os = await import('node:os'); const prof = mkdtempSync(path.join(os.tmpdir(), 'sp-minfont-')); mk(path.join(prof, 'Default'), { recursive: true }); // Chrome / Edge in Chinese locales clamp text to 12 px (webkit.webprefs.minimum_font_size) writeFileSync(path.join(prof, 'Default', 'Preferences'), JSON.stringify({ webkit: { webprefs: { minimum_font_size: 12, minimum_logical_font_size: 12 } } })); const b12 = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'], userDataDir: prof }); try { for (const b of [browser, b12]) { for (const [w, h, touch] of [[1280, 720], [1366, 657], [1920, 1080], [2560, 1440], [844, 390, true], [640, 360, true]]) { const page = await b.newPage(); await page.emulate({ viewport: { width: w, height: h, deviceScaleFactor: 1, isMobile: !!touch, hasTouch: !!touch, isLandscape: true }, userAgent: await b.userAgent() }); await page.goto(`${base}/dev/game-mock.html?shot=1&render=fallback&phase=PREP`, { waitUntil: 'networkidle0' }); await page.waitForSelector('.ewheel__btn'); await sleep(300); const m = await page.$eval('.ewheel__btn', (btn) => { const r = btn.getBoundingClientRect(); const lab = btn.querySelector('.ewheel__label'); const range = document.createRange(); range.selectNodeContents(lab); const t = range.getBoundingClientRect(); const icon = btn.querySelector('.icon, svg'); const ir = icon ? icon.getBoundingClientRect() : null; return { sprite: btn.classList.contains('has-sprite'), l: r.left, w: r.width, right: r.right, tl: t.left, tr: t.right, iconRight: ir ? ir.right : null, fs: parseFloat(getComputedStyle(lab).fontSize) }; }); const where = `${b === b12 ? 'min-font 12 ' : ''}${w}×${h}`; // sprite: the robot + chevron end at x = 79/151 of the button; CSS fallback: after the icon const iconEnd = m.sprite ? m.l + m.w * (80 / 151) : m.iconRight; assert.ok(m.tl >= iconEnd - 0.5, `${where}: label starts at ${m.tl.toFixed(1)} < icon end ${iconEnd.toFixed(1)} (font ${m.fs}px)`); assert.ok(m.tr <= m.right + 0.5, `${where}: label spills out of the button`); await page.close(); } } } finally { await b12.close(); try { rmSync(prof, { recursive: true, force: true }); } catch { /* ignore */ } } }); test('portrait phone: the rotate hint covers the page', async () => { const page = await browser.newPage(); await page.emulate({ viewport: { width: 390, height: 844, deviceScaleFactor: 3, isMobile: true, hasTouch: true }, userAgent: IOS_UA }); await page.goto(`${base}/`, { waitUntil: 'networkidle0' }); await sleep(400); assert.equal(await page.$eval('.rotate-hint', (el) => getComputedStyle(el).display), 'grid'); await page.screenshot({ path: path.join(OUT, 'device-portrait-rotate.png') }); await page.close(); // review regression: a narrow desktop window (half a 1080p screen, portrait monitor) cannot be "rotated" — no hint const desk = await browser.newPage(); await desk.setViewport({ width: 900, height: 1000 }); await desk.goto(`${base}/`, { waitUntil: 'networkidle0' }); await desk.waitForSelector('.title-screen', { timeout: 10000 }); assert.equal(await desk.$eval('.rotate-hint', (el) => getComputedStyle(el).display), 'none', 'desktop keeps the game'); await desk.close(); }); test('graceful degradation: no WebGL → the DOM view (with a notice); no local-client art → CSS look-alikes', async () => { const puppeteer = (await import('puppeteer-core')).default; const nogl = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--disable-webgl', '--disable-webgl2', '--disable-3d-apis'] }); try { const { page, problems } = await open('ipad', 'phase=PREP', { b: nogl }); await page.waitForFunction(() => globalThis.__SP_VIEW__?.kind === 'fallback', { timeout: 20000 }); await page.waitForSelector('.toast', { timeout: 5000 }); assert.match(await page.$eval('.toast-host', (el) => el.textContent), /简化视图/); await page.click('.enemybtn'); await page.waitForSelector('.ff-pen__enemy', { timeout: 3000 }); await page.screenshot({ path: path.join(OUT, 'device-ipad-nowebgl-pen.png') }); assert.deepEqual(problems, []); await page.close(); } finally { await nogl.close(); } const { page, problems } = await open('iphone14', 'phase=PREP', { intercept: (u) => /\/data\/local-assets\.json|\/assets\/local\//.test(u) }); const st = await page.evaluate(() => ({ chk: document.querySelector('.enemybtn')?.classList.contains('has-sprite'), emo: document.querySelector('.ewheel__btn')?.classList.contains('has-sprite'), label: document.querySelector('.ewheel__label')?.textContent, })); assert.deepEqual(st, { chk: false, emo: false, label: '交流' }, 'CSS look-alikes'); await page.screenshot({ path: path.join(OUT, 'device-iphone14-noart.png') }); assert.deepEqual(await layoutProblems(page), []); assert.deepEqual(problems, []); await page.close(); }); });