Files
2026-11-03 16:45:50 +08:00

103 lines
7.0 KiB
JavaScript

// 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;});