// Self-contained browser QA. The temporary HTTP source is shut down for the // offline test: WebKit's emulated offline mode can fail before SW dispatch. const { chromium, webkit } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const zlib = require('node:zlib'); const http = require('node:http'); const output = path.join(__dirname, '.qa'); fs.mkdirSync(output, {recursive:true}); async function openSettings(page, side = 'left') { const button = page.locator('.paw.' + side); const box = await button.boundingBox(); await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down(); await page.waitForTimeout(2150); await page.mouse.up(); await page.waitForFunction(() => document.querySelector('#settings').open); } async function verify(browserType, name, launchOptions) { const assets = new Set(['index.html','game.js','style.css','manifest.webmanifest','sw.js','icon.svg','apple-touch-icon.png','icon-512.png']); const types = {'.html':'text/html','.js':'application/javascript','.css':'text/css','.svg':'image/svg+xml','.png':'image/png','.webmanifest':'application/manifest+json'}; const source = http.createServer((request, response) => { const file = new URL(request.url, 'http://local').pathname.slice(1) || 'index.html'; if (!assets.has(file)) { response.writeHead(404); return response.end(); } response.setHeader('Content-Type', types[path.extname(file)] || 'application/octet-stream'); response.end(fs.readFileSync(path.join(__dirname,file))); }); await new Promise(resolve => source.listen(0, '127.0.0.1', resolve)); const base = `http://127.0.0.1:${source.address().port}/`; const stopSource = async () => { source.closeAllConnections(); await new Promise(resolve => source.close(resolve)); }; const browser = await browserType.launch({headless:true, ...launchOptions}); const context = await browser.newContext({viewport:{width:1024,height:768},deviceScaleFactor:2,hasTouch:true}); const page = await context.newPage(); const errors = [], external = []; context.on('page', p => p.on('pageerror', e => errors.push(e.message))); page.on('pageerror', e => errors.push(e.message)); context.on('request', r => {if(!r.url().startsWith(new URL(base).origin)) external.push(r.url());}); try { await page.goto(base); await page.waitForFunction(() => document.querySelector('#offline').textContent.includes('已准备好'), {timeout:30000}); await page.waitForFunction(() => !!navigator.serviceWorker.controller); await page.locator('.paw.left').click(); assert.equal(await page.locator('#settings').evaluate(e => e.open), false, 'single tap must not open settings'); await openSettings(page); for (const scene of ['laser', 'feather', 'fish']) { await page.locator(`[data-scene=${scene}]`).click(); assert.equal(await page.locator('body').getAttribute('data-mode'), scene); assert.equal(await page.locator(`[data-scene=${scene}]`).getAttribute('aria-pressed'), 'true'); } await page.locator('#sound').click(); assert.equal(await page.locator('#sound').getAttribute('aria-checked'), 'false'); await page.locator('#resume').click(); await page.setViewportSize({width:390,height:844}); await page.touchscreen.tap(195, 300); assert.equal(await page.evaluate(() => document.documentElement.scrollHeight > innerHeight || document.documentElement.scrollWidth > innerWidth), false, 'portrait must not scroll'); await page.screenshot({path:path.join(output, name+'-portrait.png')}); await openSettings(page, 'right'); await page.locator('[data-scene=feather]').click(); await page.locator('#resume').click(); await page.reload(); assert.equal(await page.locator('body').getAttribute('data-mode'), 'feather', 'mode preference persists'); assert.equal(await page.locator('#sound').getAttribute('aria-checked'), 'false', 'sound preference persists'); await stopSource(); await page.close(); const offlinePage = await context.newPage(); await offlinePage.goto(base, {waitUntil:'load'}); assert.equal(await offlinePage.title(), '猫爪游乐场 · PawPlay'); assert.equal(await offlinePage.locator('body').getAttribute('data-mode'), 'feather'); await openSettings(offlinePage); await offlinePage.locator('[data-scene=laser]').click(); await offlinePage.locator('#resume').click(); await offlinePage.setViewportSize({width:844,height:390}); assert.equal(await offlinePage.evaluate(() => document.documentElement.scrollHeight > innerHeight || document.documentElement.scrollWidth > innerWidth), false, 'landscape must not scroll'); await offlinePage.screenshot({path:path.join(output, name+'-offline-landscape.png')}); if (name === 'chromium') { const cdp = await context.newCDPSession(offlinePage); await offlinePage.evaluate(() => {window.touchPointers=[];document.querySelector('#pond').addEventListener('pointerdown',e=>window.touchPointers.push(e.pointerId));}); await cdp.send('Input.dispatchTouchEvent', {type:'touchStart',touchPoints:[{x:250,y:150,id:1},{x:500,y:200,id:2}]}); await cdp.send('Input.dispatchTouchEvent', {type:'touchMove',touchPoints:[{x:270,y:170,id:1},{x:520,y:210,id:2}]}); await cdp.send('Input.dispatchTouchEvent', {type:'touchEnd',touchPoints:[]}); assert.equal(await offlinePage.evaluate(() => new Set(window.touchPointers).size), 2, 'two simultaneous touches reach the canvas'); } assert.deepEqual(errors, [], 'no page errors'); assert.deepEqual(external, [], 'no external requests'); return {browser:name,offline_reopen:true,menu_hold:true,short_tap_guard:true,three_modes:true,portrait_landscape_no_scroll:true,preferences:true,multi_touch:name==='chromium'?'two simultaneous pointers passed':'single touch passed; simultaneous touch not exercised',errors}; } finally { await browser.close(); if(source.listening) await stopSource(); } } (async () => { const files = ['index.html','style.css','game.js','sw.js','manifest.webmanifest','icon.svg','apple-touch-icon.png','icon-512.png']; const sizes = files.map(name => {const b=fs.readFileSync(path.join(__dirname,name));return {name,bytes:b.length,gzip_bytes:zlib.gzipSync(b).length};}); const total = sizes.reduce((n,f)=>n+f.bytes,0), gzip=sizes.reduce((n,f)=>n+f.gzip_bytes,0); assert(total < 500*1024); assert(gzip < 200*1024); const results = [await verify(chromium,'chromium',{executablePath:process.env.CHROME_BIN || '/usr/bin/google-chrome'})]; results.push(await verify(webkit,'webkit', process.env.WEBKIT_BIN ? {executablePath:process.env.WEBKIT_BIN} : {})); const result = {checked_at:new Date().toISOString(),sizes,total_bytes:total,gzip_bytes:gzip,results,limits:'Desktop browser engines only. No iPad hardware, standalone-mode, Guided Access, screen-lock, or real-cat validation.'}; fs.writeFileSync(path.join(output,'result.json'),JSON.stringify(result,null,2)+'\n'); console.log(JSON.stringify(result,null,2)); })().catch(e=>{console.error(e);process.exitCode=1;});