Add lightweight offline web game for tablets
This commit is contained in:
+102
@@ -0,0 +1,102 @@
|
||||
// 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;});
|
||||
Reference in New Issue
Block a user