runner.js (79987B)
1 // Playwright browser smoke tests for KGames. 2 // Run with: node tests/browser/runner.js 3 // Requires: npm install (playwright) 4 5 const { chromium } = require('playwright'); 6 const serve = require('../serve.js'); 7 8 async function main() { 9 const { server, url } = await serve(8766); 10 const browser = await chromium.launch(); 11 let pass = 0; 12 let fail = 0; 13 14 async function test(name, fn, opts) { 15 // opts.hasTouch: open the page in a touch-capable context so ElementHandle.tap() 16 // sends real touch events (pointerdown -> pointerup/touchend), like a phone. 17 const context = opts && opts.hasTouch ? await browser.newContext({ hasTouch: true }) : null; 18 const page = context ? await context.newPage() : await browser.newPage(); 19 // Speed up fire-truck tests 20 await page.addInitScript(() => { 21 window.GAME_SPEED_MULTIPLIER = 8.0; 22 window.__TEST_MODE__ = true; 23 }); 24 const errors = []; 25 page.on('console', m => { if (m.type() === 'error') errors.push('[console] ' + m.text()); }); 26 page.on('pageerror', e => { errors.push('[pageerror] ' + e.message); }); 27 try { 28 await fn(page, url, errors); 29 console.log(` ✓ ${name}`); 30 pass++; 31 } catch (e) { 32 console.error(` ✗ ${name}`); 33 console.error(` ${e.message}`); 34 fail++; 35 } finally { 36 await page.close(); 37 if (context) await context.close(); 38 } 39 } 40 41 console.log('\nBrowser tests:'); 42 43 // ── Portal ──────────────────────────────────────────────────────────────── 44 45 await test('portal loads and contains all game tiles', async (page, url) => { 46 await page.goto(url, { waitUntil: 'domcontentloaded' }); 47 const letterFind = await page.$('a[href*="letter-find"]'); 48 const fireTruck = await page.$('a[href*="fire-truck"]'); 49 const animalLetter = await page.$('a[href*="animal-letter"]'); 50 const animalSpell = await page.$('a[href*="animal-spell"]'); 51 const work = await page.$('a[href*="work"]'); 52 const storyStamp = await page.$('a[href*="story-stamp"]'); 53 if (!letterFind) throw new Error('Letter Find tile not found'); 54 if (!storyStamp) throw new Error('Story Stamp tile not found'); 55 if (!fireTruck) throw new Error('Fire Truck tile not found'); 56 if (!animalLetter) throw new Error('Animal Letter tile not found'); 57 if (!animalSpell) throw new Error('Animal Spell tile not found'); 58 if (!work) throw new Error('Work tile not found'); 59 }); 60 61 // ── Letter Find ─────────────────────────────────────────────────────────── 62 63 await test('letter-find: loads, no JS errors, canvas exists', async (page, url, errors) => { 64 await page.goto(`${url}/games/letter-find/`, { waitUntil: 'domcontentloaded' }); 65 await page.waitForTimeout(1800); 66 if (errors.length) throw new Error(errors[0]); 67 const canvas = await page.$('canvas'); 68 if (!canvas) throw new Error('No canvas element'); 69 }); 70 71 await test('letter-find: canvas has multiple distinct colors (actually renders)', async (page, url) => { 72 await page.goto(`${url}/games/letter-find/`, { waitUntil: 'domcontentloaded' }); 73 await page.waitForTimeout(1800); 74 const colorCount = await page.evaluate(() => { 75 const cv = document.querySelector('canvas'); 76 if (!cv) return 0; 77 const seen = new Set(); 78 // Phaser uses WebGL by default 79 const gl = cv.getContext('webgl2') || cv.getContext('webgl'); 80 if (gl) { 81 const pixels = new Uint8Array(cv.width * cv.height * 4); 82 gl.readPixels(0, 0, cv.width, cv.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels); 83 for (let i = 0; i < pixels.length; i += 32) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`); 84 } else { 85 const ctx = cv.getContext('2d'); 86 const data = ctx.getImageData(0, 0, cv.width, cv.height).data; 87 for (let i = 0; i < data.length; i += 32) seen.add(`${data[i]},${data[i+1]},${data[i+2]}`); 88 } 89 return seen.size; 90 }); 91 if (colorCount < 4) throw new Error(`Only ${colorCount} colors found — canvas may be blank`); 92 }); 93 94 // ── Animal Letter ───────────────────────────────────────────────────────── 95 96 await test('animal-letter: loads, no JS errors, canvas exists', async (page, url, errors) => { 97 await page.goto(`${url}/games/animal-letter/`, { waitUntil: 'domcontentloaded' }); 98 await page.waitForTimeout(2000); 99 if (errors.length) throw new Error(errors[0]); 100 const canvas = await page.$('canvas'); 101 if (!canvas) throw new Error('No canvas element'); 102 }); 103 104 await test('animal-letter: canvas renders multiple colors', async (page, url) => { 105 await page.goto(`${url}/games/animal-letter/`, { waitUntil: 'domcontentloaded' }); 106 await page.waitForTimeout(2000); 107 const colorCount = await page.evaluate(() => { 108 const cv = document.querySelector('canvas'); 109 if (!cv) return 0; 110 const seen = new Set(); 111 const gl = cv.getContext('webgl2') || cv.getContext('webgl'); 112 if (gl) { 113 const pixels = new Uint8Array(cv.width * cv.height * 4); 114 gl.readPixels(0, 0, cv.width, cv.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels); 115 for (let i = 0; i < pixels.length; i += 32) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`); 116 } else { 117 const ctx = cv.getContext('2d'); 118 const data = ctx.getImageData(0, 0, cv.width, cv.height).data; 119 for (let i = 0; i < data.length; i += 32) seen.add(`${data[i]},${data[i+1]},${data[i+2]}`); 120 } 121 return seen.size; 122 }); 123 if (colorCount < 4) throw new Error(`Only ${colorCount} colors found — canvas may be blank`); 124 }); 125 126 await test('animal-letter: correct key press advances score', async (page, url) => { 127 await page.goto(`${url}/games/animal-letter/`, { waitUntil: 'domcontentloaded' }); 128 await page.waitForTimeout(2000); 129 const before = await page.evaluate(() => window.__AL_SCENE__ && window.__AL_SCENE__.lettersFound); 130 const letter = await page.evaluate(() => window.__AL_SCENE__ && window.__AL_SCENE__.currentAnimal && window.__AL_SCENE__.currentAnimal.letter); 131 if (!letter) throw new Error('Scene not ready'); 132 await page.keyboard.press(letter); 133 await page.waitForTimeout(300); 134 const after = await page.evaluate(() => window.__AL_SCENE__.lettersFound); 135 if (after !== (before || 0) + 1) throw new Error(`Score did not advance: ${before} -> ${after}`); 136 }); 137 138 // ── Animal Spell ────────────────────────────────────────────────────────── 139 140 await test('animal-spell: loads, no JS errors, canvas exists', async (page, url, errors) => { 141 await page.goto(`${url}/games/animal-spell/`, { waitUntil: 'domcontentloaded' }); 142 await page.waitForTimeout(2000); 143 if (errors.length) throw new Error(errors[0]); 144 const canvas = await page.$('canvas'); 145 if (!canvas) throw new Error('No canvas element'); 146 }); 147 148 await test('animal-spell: canvas renders multiple colors', async (page, url) => { 149 await page.goto(`${url}/games/animal-spell/`, { waitUntil: 'domcontentloaded' }); 150 await page.waitForTimeout(2000); 151 const colorCount = await page.evaluate(() => { 152 const cv = document.querySelector('canvas'); 153 if (!cv) return 0; 154 const seen = new Set(); 155 const gl = cv.getContext('webgl2') || cv.getContext('webgl'); 156 if (gl) { 157 const pixels = new Uint8Array(cv.width * cv.height * 4); 158 gl.readPixels(0, 0, cv.width, cv.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels); 159 for (let i = 0; i < pixels.length; i += 32) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`); 160 } else { 161 const ctx = cv.getContext('2d'); 162 const data = ctx.getImageData(0, 0, cv.width, cv.height).data; 163 for (let i = 0; i < data.length; i += 32) seen.add(`${data[i]},${data[i+1]},${data[i+2]}`); 164 } 165 return seen.size; 166 }); 167 if (colorCount < 4) throw new Error(`Only ${colorCount} colors found — canvas may be blank`); 168 }); 169 170 await test('animal-spell: correct key press advances letter index', async (page, url) => { 171 await page.goto(`${url}/games/animal-spell/`, { waitUntil: 'domcontentloaded' }); 172 await page.waitForTimeout(2000); 173 const before = await page.evaluate(() => window.__AS_SCENE__ && window.__AS_SCENE__.letterIndex); 174 const letter = await page.evaluate(() => { 175 const s = window.__AS_SCENE__; 176 return s && s.wordLetters && s.wordLetters[0]; 177 }); 178 if (!letter) throw new Error('Scene not ready'); 179 await page.keyboard.press(letter); 180 await page.waitForTimeout(400); 181 const after = await page.evaluate(() => window.__AS_SCENE__.letterIndex); 182 if (after !== (before || 0) + 1) throw new Error(`Letter index did not advance: ${before} -> ${after}`); 183 }); 184 185 // ── Work ────────────────────────────────────────────────────────────────── 186 187 await test('work: loads, no JS errors, canvas exists', async (page, url, errors) => { 188 await page.goto(`${url}/games/work/`, { waitUntil: 'domcontentloaded' }); 189 await page.waitForTimeout(1500); 190 if (errors.length) throw new Error(errors[0]); 191 const canvas = await page.$('canvas'); 192 if (!canvas) throw new Error('No canvas element'); 193 }); 194 195 await test('work: story credit is hidden until a story is playing', async (page, url) => { 196 await page.goto(`${url}/games/work/`, { waitUntil: 'domcontentloaded' }); 197 await page.waitForTimeout(1200); 198 if (await page.isVisible('#story-credits')) { 199 throw new Error('Story credit should be hidden outside Stories mode'); 200 } 201 // Enter Stories mode and check the credit matches the story actually chosen. 202 const expected = await page.evaluate(() => { 203 const sc = window.__WORK_SCENE__; 204 sc.enterMode('madlib'); 205 return sc.madTemplate.source; 206 }); 207 await page.waitForTimeout(300); 208 if (!(await page.isVisible('#story-credits'))) { 209 throw new Error('Story credit should be visible in Stories mode'); 210 } 211 const shown = await page.$$eval('#story-credits a', (as) => 212 as.map(a => ({ href: a.getAttribute('href'), text: a.textContent }))); 213 if (shown.length !== 1) throw new Error(`Expected 1 credit link, got ${shown.length}`); 214 if (shown[0].href !== expected.url || shown[0].text !== expected.title) { 215 throw new Error(`Credit does not match active story: ${JSON.stringify(shown[0])}`); 216 } 217 const body = await page.textContent('#story-credits'); 218 if (!body.includes(expected.author)) throw new Error('Credit missing author'); 219 // Visible in the DOM is not enough: the page never scrolls, so the footer 220 // must actually sit inside the viewport (regression: #game-container's 221 // default min-height:auto pushed it below the fold). 222 const box = await page.evaluate(() => { 223 const r = document.getElementById('story-credits').getBoundingClientRect(); 224 return { top: r.top, bottom: r.bottom, innerH: window.innerHeight }; 225 }); 226 if (box.bottom > box.innerH + 1 || box.top >= box.innerH) { 227 throw new Error(`Story credit is off-screen: ${JSON.stringify(box)}`); 228 } 229 // Leaving Stories mode hides it again. 230 await page.evaluate(() => window.__WORK_SCENE__.enterMode('idle')); 231 await page.waitForTimeout(200); 232 if (await page.isVisible('#story-credits')) { 233 throw new Error('Story credit should hide again on leaving Stories mode'); 234 } 235 }); 236 237 await test('work: canvas renders multiple colors', async (page, url) => { 238 await page.goto(`${url}/games/work/`, { waitUntil: 'domcontentloaded' }); 239 await page.waitForTimeout(1500); 240 const colorCount = await page.evaluate(() => { 241 const cv = document.querySelector('canvas'); 242 if (!cv) return 0; 243 const seen = new Set(); 244 const gl = cv.getContext('webgl2') || cv.getContext('webgl'); 245 if (gl) { 246 const pixels = new Uint8Array(cv.width * cv.height * 4); 247 gl.readPixels(0, 0, cv.width, cv.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels); 248 for (let i = 0; i < pixels.length; i += 32) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`); 249 } else { 250 const ctx = cv.getContext('2d'); 251 const data = ctx.getImageData(0, 0, cv.width, cv.height).data; 252 for (let i = 0; i < data.length; i += 32) seen.add(`${data[i]},${data[i+1]},${data[i+2]}`); 253 } 254 return seen.size; 255 }); 256 if (colorCount < 4) throw new Error(`Only ${colorCount} colors found — canvas may be blank`); 257 }); 258 259 await test('work: name round captures typed name on Enter', async (page, url) => { 260 await page.goto(`${url}/games/work/`, { waitUntil: 'domcontentloaded' }); 261 await page.waitForTimeout(1000); 262 await page.evaluate(() => window.__WORK_SCENE__.enterMode('name')); 263 await page.waitForFunction(() => window.__WORK_SCENE__.acceptInput === true, null, { timeout: 5000 }); 264 // delay so key-holds don't overlap; 0-delay typing triggers OS-style 265 // auto-repeat in Chromium and garbles the buffer (e.g. "AABCCC"). 266 await page.keyboard.type('ABC', { delay: 40 }); 267 await page.keyboard.press('Enter'); 268 await page.waitForTimeout(200); 269 const name = await page.evaluate(() => window.__WORK_SCENE__.playerName); 270 if (name !== 'ABC') throw new Error(`Expected playerName "ABC", got "${name}"`); 271 }); 272 273 await test('work: speed round auto-stops when name matches', async (page, url) => { 274 await page.goto(`${url}/games/work/`, { waitUntil: 'domcontentloaded' }); 275 await page.waitForTimeout(1000); 276 await page.evaluate(() => { 277 const s = window.__WORK_SCENE__; 278 s.enterMode('name'); 279 s.playerName = 'Sam'; 280 s._nameRace(); 281 }); 282 await page.waitForFunction(() => window.__WORK_SCENE__.acceptInput === true, null, { timeout: 5000 }); 283 await page.keyboard.type('Sam', { delay: 40 }); 284 await page.waitForFunction(() => window.__WORK_SCENE__.nameState === 'result', null, { timeout: 5000 }); 285 const t = await page.evaluate(() => window.__WORK_SCENE__.lastTimeMs); 286 if (!(t >= 0)) throw new Error(`Expected a finite lastTimeMs, got ${t}`); 287 }); 288 289 // ── Fire Truck ──────────────────────────────────────────────────────────── 290 291 await test('fire-truck: loads, no JS errors, canvas exists', async (page, url, errors) => { 292 await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); 293 await page.waitForTimeout(1800); 294 if (errors.length) throw new Error(errors[0]); 295 const canvas = await page.$('canvas'); 296 if (!canvas) throw new Error('No canvas element'); 297 }); 298 299 await test('fire-truck: canvas shows city colors', async (page, url) => { 300 await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); 301 await page.waitForTimeout(1800); 302 const colorCount = await page.evaluate(() => { 303 const cv = document.querySelector('canvas'); 304 if (!cv) return 0; 305 const seen = new Set(); 306 const gl = cv.getContext('webgl2') || cv.getContext('webgl'); 307 if (gl) { 308 const pixels = new Uint8Array(cv.width * cv.height * 4); 309 gl.readPixels(0, 0, cv.width, cv.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels); 310 for (let i = 0; i < pixels.length; i += 48) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`); 311 } else { 312 const ctx = cv.getContext('2d'); 313 const data = ctx.getImageData(0, 0, cv.width, cv.height).data; 314 for (let i = 0; i < data.length; i += 48) seen.add(`${data[i]},${data[i+1]},${data[i+2]}`); 315 } 316 return seen.size; 317 }); 318 if (colorCount < 6) throw new Error(`Only ${colorCount} colors found — city may not be rendering`); 319 }); 320 321 await test('fire-truck: truck starts moving automatically', async (page, url) => { 322 await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); 323 // debugDistancePx is cumulative from spawn. Whether the truck is still 324 // driving or has already reached its first intersection (waiting), it can 325 // only have got there by auto-driving — so a nonzero total proves motion. 326 // (Comparing two late samples is racy: at 8x test speed both can land in 327 // the same stationary 'waiting' state, giving a false zero delta.) 328 await page.waitForTimeout(3000); 329 const end = await page.evaluate(() => ({ 330 distance: window.__FT_SCENE__.debugDistancePx || 0, 331 prompt: window.__FT_SCENE__.promptDir, 332 state: window.__FT_SCENE__.state, 333 })); 334 if (end.distance < 120) { 335 throw new Error(`Truck advanced only ${end.distance.toFixed(1)} path pixels from spawn (state ${end.state}/${end.prompt || 'none'})`); 336 } 337 }); 338 339 await test('fire-truck: wrong arrow stops the truck', async (page, url) => { 340 await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); 341 await page.evaluate(() => { window.__FT_SCENE__.truck.setPosition(window.__FT_SCENE__.segmentEnd.x - 500, window.__FT_SCENE__.segmentEnd.y); }); 342 await page.waitForFunction(() => window.__FT_SCENE__ && !!window.__FT_SCENE__.promptDir, null, { timeout: 25000 }); 343 // Freeze the truck so it doesn't cross the intersection while playwright is IPCing 344 await page.evaluate(() => { window.__FT_SCENE__.targetSpeed = 0; window.__FT_SCENE__.speed = 0; window.__FT_SCENE__.lastStepTime = performance.now(); }); 345 const promptDir = await page.evaluate(() => window.__FT_SCENE__.promptDir); 346 const wrongKey = promptDir === 'left' ? 'ArrowRight' : promptDir === 'right' ? 'ArrowLeft' : 'ArrowLeft'; 347 await page.keyboard.press(wrongKey); 348 await page.waitForFunction(() => window.__FT_SCENE__.state === 'stopped', null, { timeout: 5000 }); 349 const state = await page.evaluate(() => ({ state: window.__FT_SCENE__.state, failVisible: window.__FT_SCENE__.failVisible })); 350 if (state.state !== 'stopped') throw new Error(`Expected stopped, got ${state.state}`); 351 if (!state.failVisible) throw new Error('Expected failVisible to be true'); 352 }); 353 354 await test('fire-truck: correct arrow after failure resumes movement', async (page, url) => { 355 await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); 356 await page.evaluate(() => { window.__FT_SCENE__.truck.setPosition(window.__FT_SCENE__.segmentEnd.x - 500, window.__FT_SCENE__.segmentEnd.y); }); 357 await page.waitForFunction(() => window.__FT_SCENE__ && !!window.__FT_SCENE__.promptDir, null, { timeout: 25000 }); 358 await page.evaluate(() => { window.__FT_SCENE__.targetSpeed = 0; window.__FT_SCENE__.speed = 0; window.__FT_SCENE__.lastStepTime = performance.now(); }); 359 const promptDir = await page.evaluate(() => window.__FT_SCENE__.promptDir); 360 const wrongKey = promptDir === 'left' ? 'ArrowRight' : promptDir === 'right' ? 'ArrowLeft' : 'ArrowLeft'; 361 const correctKey = promptDir === 'left' ? 'ArrowLeft' : promptDir === 'right' ? 'ArrowRight' : 'ArrowUp'; 362 await page.keyboard.press(wrongKey); 363 await page.waitForFunction(() => window.__FT_SCENE__.state === 'stopped', null, { timeout: 5000 }); 364 await page.keyboard.press(correctKey); 365 try { 366 await page.waitForFunction(() => window.__FT_SCENE__.state === 'driving', null, { timeout: 5000 }); 367 } catch (e) { 368 const dbg = await page.evaluate(() => ({ state: window.__FT_SCENE__.state, promptDir: window.__FT_SCENE__.promptDir, resolved: window.__FT_SCENE__.promptResolved })); 369 throw new Error(`Timeout driving. State: ${JSON.stringify(dbg)}`); 370 } 371 const state = await page.evaluate(() => ({ state: window.__FT_SCENE__.state })); 372 if (state.state === 'stopped') throw new Error('Truck did not resume'); 373 }); 374 375 await test('fire-truck: island debug hooks and route bounds', async (page, url) => { 376 await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); 377 await page.waitForTimeout(1800); 378 const info = await page.evaluate(() => { 379 const s = window.__FT_SCENE__; 380 return { 381 width: s.island.width, 382 height: s.island.height, 383 cell00: s.cellAt(0, 0).type, 384 cell11: s.cellAt(1, 1).type, 385 cell22: s.cellAt(2, 2).type, 386 routeOutOfBounds: s.route.some(step => step.x < 2 || step.x > 47 || step.y < 2 || step.y > 47 || step.approachX < 2 || step.approachX > 47 || step.approachY < 2 || step.approachY > 47 || step.exitX < 2 || step.exitX > 47 || step.exitY < 2 || step.exitY > 47), 387 hasFourWayOnIsland: s.island.roads.some(c => s.cellAt(c.x, c.y).meta.kind === 'four'), 388 }; 389 }); 390 if (info.width !== 50) throw new Error(`Expected width 50, got ${info.width}`); 391 if (info.height !== 50) throw new Error(`Expected height 50, got ${info.height}`); 392 if (info.cell00 !== 'water') throw new Error(`Expected cell00 water, got ${info.cell00}`); 393 if (info.cell11 !== 'beach') throw new Error(`Expected cell11 beach, got ${info.cell11}`); 394 if (info.cell22 !== 'road') throw new Error(`Expected cell22 road, got ${info.cell22}`); 395 if (info.routeOutOfBounds) throw new Error('Route step out of bounds'); 396 if (!info.hasFourWayOnIsland) throw new Error('No four-way intersection on island'); 397 }); 398 399 await test('fire-truck: no input leads to waiting state (not stopped)', async (page, url) => { 400 await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); 401 await page.evaluate(() => { window.__FT_SCENE__.truck.setPosition(window.__FT_SCENE__.segmentEnd.x - 500, window.__FT_SCENE__.segmentEnd.y); }); 402 await page.waitForFunction(() => window.__FT_SCENE__ && !!window.__FT_SCENE__.promptDir, null, { timeout: 25000 }); 403 await page.evaluate(() => { window.__FT_SCENE__.targetSpeed = 0; window.__FT_SCENE__.speed = 0; window.__FT_SCENE__.lastStepTime = performance.now(); }); 404 // Move truck up to the stop line without resolving. Place it just short of 405 // segmentEnd *along the real approach vector* (the segment may run in any 406 // direction) so it lands inside STOP_LINE_DIST regardless of orientation. 407 await page.evaluate(() => { 408 const s = window.__FT_SCENE__; 409 const dx = s.segmentEnd.x - s.truck.x, dy = s.segmentEnd.y - s.truck.y; 410 const d = Math.hypot(dx, dy) || 1; 411 const short = 30; // well inside STOP_LINE_DIST (~216px) 412 s.truck.setPosition(s.segmentEnd.x - (dx / d) * short, s.segmentEnd.y - (dy / d) * short); 413 s.step(0.016); 414 }); 415 const state = await page.evaluate(() => window.__FT_SCENE__.state); 416 if (state !== 'waiting') throw new Error(`Expected waiting, got ${state}`); 417 }); 418 419 await test('fire-truck: correct key from waiting resumes driving', async (page, url) => { 420 await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); 421 await page.evaluate(() => { window.__FT_SCENE__.truck.setPosition(window.__FT_SCENE__.segmentEnd.x - 500, window.__FT_SCENE__.segmentEnd.y); }); 422 await page.waitForFunction(() => window.__FT_SCENE__ && !!window.__FT_SCENE__.promptDir, null, { timeout: 25000 }); 423 await page.evaluate(() => { window.__FT_SCENE__.targetSpeed = 0; window.__FT_SCENE__.speed = 0; window.__FT_SCENE__.lastStepTime = performance.now(); }); 424 const promptDir = await page.evaluate(() => window.__FT_SCENE__.promptDir); 425 const correctKey = promptDir === 'left' ? 'ArrowLeft' : promptDir === 'right' ? 'ArrowRight' : 'ArrowUp'; 426 // Put into waiting: place just short of segmentEnd along the real approach 427 // vector so it reaches the stop line whatever direction the segment runs. 428 await page.evaluate(() => { 429 const s = window.__FT_SCENE__; 430 const dx = s.segmentEnd.x - s.truck.x, dy = s.segmentEnd.y - s.truck.y; 431 const d = Math.hypot(dx, dy) || 1; 432 const short = 30; 433 s.truck.setPosition(s.segmentEnd.x - (dx / d) * short, s.segmentEnd.y - (dy / d) * short); 434 s.step(0.016); 435 }); 436 await page.keyboard.press(correctKey); 437 try { 438 await page.waitForFunction(() => window.__FT_SCENE__.state === 'driving', null, { timeout: 5000 }); 439 } catch (e) { 440 const dbg = await page.evaluate(() => ({ state: window.__FT_SCENE__.state, promptDir: window.__FT_SCENE__.promptDir, resolved: window.__FT_SCENE__.promptResolved })); 441 throw new Error(`Timeout driving. State: ${JSON.stringify(dbg)}`); 442 } 443 }); 444 445 await test('fire-truck: manual mode drives only while a key is held', async (page, url) => { 446 await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); 447 await page.waitForFunction(() => !!window.__FT_SCENE__ && !!window.__FT_SCENE__.truck, null, { timeout: 25000 }); 448 await page.evaluate(() => window.__FT_SCENE__.setControlMode('manual')); 449 const before = await page.evaluate(() => window.__FT_SCENE__.debugDistancePx || 0); 450 // Pick any direction with a road exit from the anchored cell. 451 const key = await page.evaluate(() => { 452 const s = window.__FT_SCENE__; 453 const map = { N: 'ArrowUp', E: 'ArrowRight', S: 'ArrowDown', W: 'ArrowLeft' }; 454 for (const c of ['N', 'E', 'S', 'W']) if (s.manualCell.exits[c]) return map[c]; 455 return null; 456 }); 457 if (!key) throw new Error('Manual anchor cell has no road exits'); 458 await page.keyboard.down(key); 459 await page.waitForFunction( 460 (b) => (window.__FT_SCENE__.debugDistancePx || 0) > b + 50, 461 before, 462 { timeout: 5000 } 463 ); 464 await page.keyboard.up(key); 465 // Truck brakes to a stop and then stays put (unless the held drive already 466 // reached the fire and launched the minigame, which also means it moved). 467 await page.waitForFunction( 468 () => window.__FT_SCENE__.speed === 0 || window.__FT_SCENE__.state === 'minigame', 469 null, 470 { timeout: 5000 } 471 ); 472 const stopped = await page.evaluate(() => window.__FT_SCENE__.debugDistancePx); 473 await page.waitForTimeout(400); 474 const later = await page.evaluate(() => window.__FT_SCENE__.debugDistancePx); 475 if (later - stopped > 1) throw new Error(`Truck kept moving after key release (${(later - stopped).toFixed(1)}px)`); 476 }); 477 478 await test('fire-truck: manual mode flags wrong-way driving', async (page, url) => { 479 await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); 480 await page.waitForFunction(() => !!window.__FT_SCENE__ && !!window.__FT_SCENE__.truck, null, { timeout: 25000 }); 481 const ok = await page.evaluate(() => { 482 const s = window.__FT_SCENE__; 483 s.setControlMode('manual'); 484 if (!s.fireDistances || !s.manualCell) return false; 485 // Simulate arriving on a cell farther from the fire than the last one. 486 const grid = s.island.grid; 487 const here = s.manualCell; 488 let worse = null; 489 for (const c of ['N', 'E', 'S', 'W']) { 490 if (!here.exits[c]) continue; 491 const n = grid[here.y + window.FireTruckLib.DIRS[c].dy][here.x + window.FireTruckLib.DIRS[c].dx]; 492 if (s.fireDistances.get(n) > s.fireDistances.get(here)) { worse = n; break; } 493 } 494 if (!worse) return false; 495 s.manualTarget = worse; 496 s._manualArrive(); 497 return s.failVisible && s.statusText.text.includes('Wrong way'); 498 }); 499 if (!ok) throw new Error('Wrong-way feedback did not trigger'); 500 }); 501 502 await test('fire-truck: fire destination is set after load', async (page, url) => { 503 await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); 504 await page.waitForTimeout(1800); 505 const info = await page.evaluate(() => { 506 const s = window.__FT_SCENE__; 507 return { 508 hasFireCell: !!s.fireCell, 509 hasFireBuildingCell: !!s.fireBuildingCell, 510 fireIsRoad: s.fireCell ? s.cellAt(s.fireCell.x, s.fireCell.y).type === 'road' : false, 511 buildingIsBuilding: s.fireBuildingCell ? s.cellAt(s.fireBuildingCell.x, s.fireBuildingCell.y).type === 'building' : false, 512 adjacent: s.fireCell && s.fireBuildingCell ? Math.abs(s.fireCell.x - s.fireBuildingCell.x) + Math.abs(s.fireCell.y - s.fireBuildingCell.y) === 1 : false, 513 }; 514 }); 515 if (!info.hasFireCell) throw new Error('fireCell not set'); 516 if (!info.hasFireBuildingCell) throw new Error('fireBuildingCell not set'); 517 if (!info.fireIsRoad) throw new Error('fireCell is not a road'); 518 if (!info.buildingIsBuilding) throw new Error('fireBuildingCell is not a building'); 519 if (!info.adjacent) throw new Error('fireCell and fireBuildingCell are not adjacent'); 520 }); 521 522 await test('fire-truck: reaching the fire launches the hose minigame', async (page, url, errors) => { 523 await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); 524 await page.waitForTimeout(1800); 525 // Teleport the truck onto fireCell so the fire trigger fires 526 await page.evaluate(() => { 527 const s = window.__FT_SCENE__; 528 if (s.fireCell) { 529 s.state = 'driving'; 530 s.speed = 0; 531 s.targetSpeed = 0; 532 s.truck.setPosition(s.fireCell.x * s.cellSize, s.fireCell.y * s.cellSize); 533 s.step(0.016); 534 } 535 }); 536 await page.waitForFunction( 537 () => window.__FT_HOSE_SCENE__ && window.__FT_HOSE_SCENE__.fires && window.__FT_HOSE_SCENE__.fires.length > 0, 538 null, { timeout: 5000 }); 539 if (errors.length) throw new Error(errors[0]); 540 const state = await page.evaluate(() => window.__FT_SCENE__.state); 541 if (state !== 'minigame') throw new Error(`drive scene should be in minigame state, got: ${state}`); 542 // Aim at the first burning window and hold SPACE → quench progress rises 543 await page.evaluate(() => { 544 const h = window.__FT_HOSE_SCENE__; 545 const f = h.fires[0]; 546 h.aimX = f.cx; 547 h.aimY = f.cy; 548 }); 549 await page.keyboard.down('Space'); 550 await page.waitForTimeout(500); 551 const progress = await page.evaluate(() => window.__FT_HOSE_SCENE__.fires[0].progress); 552 await page.keyboard.up('Space'); 553 if (!(progress > 0)) throw new Error(`Quench progress did not accumulate: ${progress}`); 554 }); 555 556 await test('fire-truck: quenching every window returns to driving with counter +1', async (page, url, errors) => { 557 await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); 558 await page.waitForTimeout(1800); 559 await page.evaluate(() => { 560 const s = window.__FT_SCENE__; 561 if (s.fireCell) { 562 s.state = 'driving'; 563 s.speed = 0; 564 s.targetSpeed = 0; 565 s.truck.setPosition(s.fireCell.x * s.cellSize, s.fireCell.y * s.cellSize); 566 s.step(0.016); 567 } 568 }); 569 await page.waitForFunction( 570 () => window.__FT_HOSE_SCENE__ && window.__FT_HOSE_SCENE__.fires && window.__FT_HOSE_SCENE__.fires.length > 0, 571 null, { timeout: 5000 }); 572 await page.evaluate(() => { 573 const h = window.__FT_HOSE_SCENE__; 574 for (const f of h.fires) h._quenchWindow(f); 575 }); 576 // Celebration plays (~2.6 s), then the drive scene resumes and re-routes (~0.6 s) 577 await page.waitForFunction( 578 () => window.__FT_SCENE__.firesExtinguished === 1 && window.__FT_SCENE__.state === 'driving', 579 null, { timeout: 10000 }); 580 if (errors.length) throw new Error(errors[0]); 581 const info = await page.evaluate(() => ({ 582 hoseGone: !window.__FT_HOSE_SCENE__, 583 hasNextFire: !!window.__FT_SCENE__.fireCell, 584 })); 585 if (!info.hoseGone) throw new Error('FireHoseScene still active after completion'); 586 if (!info.hasNextFire) throw new Error('next fire destination was not set after minigame'); 587 }); 588 589 await test('fire-truck: correct arrow clears prompt immediately', async (page, url) => { 590 await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); 591 await page.waitForTimeout(1800); 592 // Teleport near segmentEnd so prompt triggers, then press the correct arrow 593 await page.evaluate(() => { 594 const s = window.__FT_SCENE__; 595 const end = s.segmentEnd; 596 s.state = 'driving'; 597 s.speed = 0; 598 s.targetSpeed = 0; 599 s.truck.setPosition(end.x - 260, end.y); 600 s.step(0.016); 601 }); 602 await page.waitForFunction(() => !!window.__FT_SCENE__.promptDir, null, { timeout: 5000 }); 603 const correctKey = await page.evaluate(() => { 604 const dir = window.__FT_SCENE__.promptDir; 605 return dir === 'left' ? 'ArrowLeft' : dir === 'right' ? 'ArrowRight' : 'ArrowUp'; 606 }); 607 await page.keyboard.press(correctKey); 608 // promptDir must be null on the very next evaluate (no round-trip delay) 609 const afterPromptDir = await page.evaluate(() => window.__FT_SCENE__.promptDir); 610 if (afterPromptDir !== null) throw new Error(`promptDir should be null immediately, got: ${afterPromptDir}`); 611 const promptText = await page.evaluate(() => window.__FT_SCENE__.promptText.text); 612 if (promptText !== '') throw new Error(`promptText should be empty, got: "${promptText}"`); 613 }); 614 615 await test('fire-truck: fire distance setting is honored at startup', async (page, url) => { 616 await page.addInitScript(() => { window.GAME_FIRE_DISTANCE = 25; }); 617 await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); 618 await page.waitForTimeout(1800); 619 const result = await page.evaluate(() => { 620 const s = window.__FT_SCENE__; 621 if (!s.fireCell) return { ok: false, reason: 'no fireCell' }; 622 const fromCell = s.island.grid[s.island.routeStart.y][s.island.routeStart.x]; 623 const FireTruckLib = window.FireTruckLib; 624 const dists = FireTruckLib.bfsRoadDistances(s.island.grid, fromCell); 625 const d = dists.get(s.island.grid[s.fireCell.y][s.fireCell.x]); 626 return { ok: d != null && d >= 20 && d <= 30, d }; 627 }); 628 if (!result.ok) throw new Error(`Fire distance ${result.d} not in [20,30] for targetDistance=25`); 629 }); 630 631 await test('fire-truck: endgame scene renders, exposes __FT_END_SCENE__, no errors', async (page, url, errors) => { 632 await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); 633 await page.waitForTimeout(1800); 634 if (errors.length) throw new Error(errors[0]); 635 await page.evaluate(() => { window.__FT_SCENE__.scene.start('FireTruckEndScene'); }); 636 await page.waitForTimeout(1200); 637 if (errors.length) throw new Error(errors[0]); 638 const hasEndScene = await page.evaluate(() => !!window.__FT_END_SCENE__); 639 if (!hasEndScene) throw new Error('__FT_END_SCENE__ not exposed after scene start'); 640 const colorCount = await page.evaluate(() => { 641 const cv = document.querySelector('canvas'); 642 if (!cv) return 0; 643 const seen = new Set(); 644 const sampleW = 200, sampleH = 200; 645 const gl = cv.getContext('webgl2') || cv.getContext('webgl'); 646 if (gl) { 647 const pixels = new Uint8Array(sampleW * sampleH * 4); 648 gl.readPixels(0, 0, sampleW, sampleH, gl.RGBA, gl.UNSIGNED_BYTE, pixels); 649 for (let i = 0; i < pixels.length; i += 16) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`); 650 } else { 651 // Phaser falls back to the Canvas (2D) renderer in headless Chromium. 652 const ctx = cv.getContext('2d'); 653 const w = Math.min(cv.width, sampleW), h = Math.min(cv.height, sampleH); 654 const data = ctx.getImageData(0, 0, w, h).data; 655 for (let i = 0; i < data.length; i += 16) seen.add(`${data[i]},${data[i+1]},${data[i+2]}`); 656 } 657 return seen.size; 658 }); 659 if (colorCount < 5) throw new Error(`Endgame scene rendered only ${colorCount} distinct colors — may be blank`); 660 }); 661 662 await test('fire-truck: play again button restarts FireTruckScene with reset counter', async (page, url, errors) => { 663 await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); 664 await page.waitForTimeout(1800); 665 await page.evaluate(() => { window.__FT_SCENE__.scene.start('FireTruckEndScene'); }); 666 await page.waitForTimeout(1200); 667 if (errors.length) throw new Error(errors[0]); 668 const hasEnd = await page.evaluate(() => !!window.__FT_END_SCENE__); 669 if (!hasEnd) throw new Error('FireTruckEndScene did not start'); 670 const W = await page.evaluate(() => window.innerWidth); 671 const H = await page.evaluate(() => window.innerHeight); 672 await page.mouse.click(W / 2, H * 0.91); 673 await page.waitForTimeout(1400); 674 if (errors.length) throw new Error(errors[0]); 675 const result = await page.evaluate(() => { 676 const s = window.__FT_SCENE__; 677 return { hasScene: !!s, counter: s ? s.firesExtinguished : -1 }; 678 }); 679 if (!result.hasScene) throw new Error('FireTruckScene not restarted after Play Again'); 680 if (result.counter !== 0) throw new Error(`firesExtinguished should be 0 after restart, got ${result.counter}`); 681 }); 682 683 await test('fire-truck: ambient traffic spawns and stays on roads', async (page, url) => { 684 await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); 685 await page.waitForTimeout(1800); 686 const info = await page.evaluate(() => { 687 const s = window.__FT_SCENE__; 688 const movers = [ 689 ...(s.trafficCars || []), ...(s.buses || []), 690 ...(s.bicyclists || []), ...(s.pedestrians || []), 691 ]; 692 const allRoad = movers.every(m => 693 s.cellAt(m.from.x, m.from.y).type === 'road' && 694 s.cellAt(m.to.x, m.to.y).type === 'road'); 695 return { 696 cars: (s.trafficCars || []).length, 697 buses: (s.buses || []).length, 698 bikes: (s.bicyclists || []).length, 699 peds: (s.pedestrians || []).length, 700 allRoad, 701 ambientReady: s.ambientReady, 702 }; 703 }); 704 if (!info.ambientReady) throw new Error('ambient systems not ready'); 705 if (info.cars === 0) throw new Error('no traffic cars spawned'); 706 if (info.buses === 0) throw new Error('no buses spawned'); 707 if (info.bikes === 0) throw new Error('no bicyclists spawned'); 708 if (info.peds < 20) throw new Error(`expected a bustling crowd, got ${info.peds} pedestrians`); 709 if (!info.allRoad) throw new Error('an ambient mover left the road graph'); 710 }); 711 712 // ── On-screen touch controls ──────────────────────────────────────────────── 713 714 // Helper: returns an ElementHandle for the touch key whose label === text. 715 async function touchKey(page, text) { 716 const h = await page.evaluateHandle( 717 (t) => [...document.querySelectorAll('#kg-touch .kg-key')].find(b => b.textContent === t), 718 text 719 ); 720 const el = h.asElement(); 721 if (!el) throw new Error(`On-screen key "${text}" not found`); 722 return el; 723 } 724 725 await test('touch: controls hidden by default on non-touch browser', async (page, url) => { 726 await page.goto(`${url}/games/letter-find/`, { waitUntil: 'domcontentloaded' }); 727 await page.waitForTimeout(800); 728 const bar = await page.$('#kg-touch'); 729 if (bar) throw new Error('Touch bar should not render on a non-touch device'); 730 }); 731 732 await test('touch: ?kbd=1 forces the on-screen keyboard to render', async (page, url) => { 733 await page.goto(`${url}/games/letter-find/?kbd=1`, { waitUntil: 'domcontentloaded' }); 734 await page.waitForSelector('#kg-touch', { timeout: 5000 }); 735 const keys = await page.$$eval('#kg-touch .kg-key', els => els.length); 736 if (keys !== 26) throw new Error(`Expected 26 alphabet keys, got ${keys}`); 737 }); 738 739 await test('touch: letter-find on-screen key advances score', async (page, url) => { 740 await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; }); 741 await page.goto(`${url}/games/letter-find/`, { waitUntil: 'domcontentloaded' }); 742 await page.waitForSelector('#kg-touch'); 743 await page.waitForFunction(() => window.__LF_SCENE__ && window.__LF_SCENE__.accepting, null, { timeout: 5000 }); 744 const target = await page.evaluate(() => window.__LF_SCENE__.targetChar); 745 const before = await page.evaluate(() => window.__LF_SCENE__.lettersFound); 746 const key = await touchKey(page, target); 747 await key.click(); 748 await page.waitForTimeout(300); 749 const after = await page.evaluate(() => window.__LF_SCENE__.lettersFound); 750 if (after !== before + 1) throw new Error(`Touch key did not advance score: ${before} -> ${after}`); 751 }); 752 753 await test('touch: animal-spell on-screen key advances letter index', async (page, url) => { 754 await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; }); 755 await page.goto(`${url}/games/animal-spell/`, { waitUntil: 'domcontentloaded' }); 756 await page.waitForSelector('#kg-touch'); 757 await page.waitForFunction(() => window.__AS_SCENE__ && window.__AS_SCENE__.accepting, null, { timeout: 6000 }); 758 const letter = await page.evaluate(() => window.__AS_SCENE__.wordLetters[0]); 759 const before = await page.evaluate(() => window.__AS_SCENE__.letterIndex); 760 const key = await touchKey(page, letter); 761 await key.click(); 762 await page.waitForTimeout(400); 763 const after = await page.evaluate(() => window.__AS_SCENE__.letterIndex); 764 if (after !== before + 1) throw new Error(`Touch key did not advance letter index: ${before} -> ${after}`); 765 }); 766 767 await test('touch: work text keyboard types name and submits on Enter', async (page, url) => { 768 await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; }); 769 await page.goto(`${url}/games/work/`, { waitUntil: 'domcontentloaded' }); 770 await page.waitForSelector('#kg-touch'); 771 await page.evaluate(() => window.__WORK_SCENE__.enterMode('name')); 772 await page.waitForFunction(() => window.__WORK_SCENE__.acceptInput === true, null, { timeout: 5000 }); 773 for (const ch of ['A', 'B', 'C']) { 774 const k = await touchKey(page, ch); 775 await k.click(); 776 await page.waitForTimeout(50); 777 } 778 const enter = await touchKey(page, '⏎'); 779 await enter.click(); 780 await page.waitForTimeout(200); 781 const name = await page.evaluate(() => window.__WORK_SCENE__.playerName); 782 if (name !== 'ABC') throw new Error(`Expected playerName "ABC" via touch keyboard, got "${name}"`); 783 }); 784 785 await test('touch: work keyboard has Space and Backspace keys', async (page, url) => { 786 await page.goto(`${url}/games/work/?kbd=1`, { waitUntil: 'domcontentloaded' }); 787 await page.waitForSelector('#kg-touch'); 788 const labels = await page.$$eval('#kg-touch .kg-key', els => els.map(e => e.textContent)); 789 if (!labels.includes('space')) throw new Error('Space key missing from work keyboard'); 790 if (!labels.includes('⌫')) throw new Error('Backspace key missing from work keyboard'); 791 }); 792 793 await test('touch: fire-truck drive pad clears prompt on correct arrow', async (page, url) => { 794 await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; }); 795 await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); 796 await page.waitForSelector('#kg-touch .kg-key-arrow'); 797 await page.evaluate(() => { 798 const s = window.__FT_SCENE__; 799 const end = s.segmentEnd; 800 s.state = 'driving'; s.speed = 0; s.targetSpeed = 0; 801 s.truck.setPosition(end.x - 260, end.y); 802 s.step(0.016); 803 }); 804 await page.waitForFunction(() => !!window.__FT_SCENE__.promptDir, null, { timeout: 6000 }); 805 const dir = await page.evaluate(() => window.__FT_SCENE__.promptDir); 806 const label = dir === 'left' ? '◀' : dir === 'right' ? '▶' : '▲'; 807 const arrow = await touchKey(page, label); 808 await arrow.click(); 809 const after = await page.evaluate(() => window.__FT_SCENE__.promptDir); 810 if (after !== null) throw new Error(`promptDir should be null after touch arrow, got ${after}`); 811 }); 812 813 await test('touch: fire-truck spray button accumulates extinguish (press-hold)', async (page, url) => { 814 await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; }); 815 await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); 816 await page.waitForSelector('#kg-touch'); 817 await page.evaluate(() => { 818 const s = window.__FT_SCENE__; 819 if (s.fireCell) { 820 s.state = 'driving'; s.speed = 0; s.targetSpeed = 0; 821 s.truck.setPosition(s.fireCell.x * s.cellSize, s.fireCell.y * s.cellSize); 822 s.step(0.016); 823 } 824 }); 825 await page.waitForFunction( 826 () => window.__FT_HOSE_SCENE__ && window.__FT_HOSE_SCENE__.fires && window.__FT_HOSE_SCENE__.fires.length > 0, 827 null, { timeout: 6000 }); 828 await page.evaluate(() => { 829 const h = window.__FT_HOSE_SCENE__; 830 const f = h.fires[0]; 831 h.aimX = f.cx; 832 h.aimY = f.cy; 833 }); 834 const spray = await touchKey(page, '💦 Spray'); 835 const box = await spray.boundingBox(); 836 await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); 837 await page.mouse.down(); 838 await page.waitForTimeout(400); 839 const after = await page.evaluate(() => window.__FT_HOSE_SCENE__.fires[0].progress); 840 await page.mouse.up(); 841 if (after <= 0) throw new Error(`Spray hold did not accumulate quench progress: ${after}`); 842 }); 843 844 845 // ── Story Stamp (DOM game, no canvas) ───────────────────────────────────── 846 847 const SS_URL = `${url}/games/story-stamp/`; 848 const ssState = (page) => page.evaluate(() => window.__SS_GAME__ && window.__SS_GAME__.state); 849 const ssWait = (page, st, ms) => page.waitForFunction((s) => window.__SS_GAME__ && window.__SS_GAME__.state === s, st, { timeout: ms || 8000 }) 850 .catch(async () => { throw new Error(`state never became ${st} (now ${await ssState(page)})`); }); 851 async function ssOpen(page, opts) { 852 await page.goto(SS_URL + ((opts && opts.query) || ''), { waitUntil: 'domcontentloaded' }); 853 await page.waitForFunction(() => !!window.__SS_GAME__); 854 } 855 // Start a profile and call the first patron in the queue: lands on the request slip. 856 async function ssQuickStart(page, name, pick) { 857 await page.evaluate((n) => { window.__SS_GAME__.quickStart(n); }, name || 'Tester'); 858 await ssWait(page, 'queue'); 859 if (pick != null) await page.keyboard.press(String(pick + 1)); else await page.keyboard.press('Enter'); 860 await ssWait(page, 'read', 15000); 861 } 862 // Serve the current patron end to end: type the letters, sound it out, pick the book. 863 async function ssServe(page) { 864 const o = await page.evaluate(() => ({ graphemes: window.__SS_GAME__.order.graphemes, word: window.__SS_GAME__.order.word.w, mode: window.__SS_GAME__.order.mode })); 865 await page.keyboard.type(o.graphemes.join(''), { delay: 20 }); 866 await ssWait(page, 'blend'); 867 await page.keyboard.press('Space'); 868 await page.waitForFunction(() => ['choose', 'stamp', 'serve', 'arrive', 'queue', 'dayend'].includes(window.__SS_GAME__.state), null, { timeout: 15000 }); 869 if ((await ssState(page)) === 'choose') { 870 const idx = await page.evaluate((w) => window.__SS_GAME__.order.cards.findIndex((c) => c.w === w), o.word); 871 await page.keyboard.press(String(idx + 1)); 872 } 873 await page.waitForFunction(() => ['queue', 'dayend'].includes(window.__SS_GAME__.state), null, { timeout: 15000 }); 874 return o; 875 } 876 877 await test('story-stamp: loads, no JS errors, library scene + profile screen render', async (page, url, errors) => { 878 await ssOpen(page); 879 await page.waitForTimeout(400); 880 if (errors.length) throw new Error(errors[0]); 881 if (!(await page.$('.ss-root .ss-desk'))) throw new Error('scene not built'); 882 if ((await ssState(page)) !== 'profiles') throw new Error('should start on the profile screen'); 883 if (!(await page.isVisible('.ss-overlay'))) throw new Error('profile overlay hidden'); 884 const imgs = await page.evaluate(() => Array.from(document.images).filter((i) => i.src.includes('/assets/img/')).map((i) => i.complete && i.naturalWidth > 0)); 885 if (!imgs.length || imgs.includes(false)) throw new Error('scene pictures did not load'); 886 }); 887 888 await test('story-stamp: keyboard alone creates a librarian and a queue of patrons with topics appears', async (page, url, errors) => { 889 await ssOpen(page); 890 await page.keyboard.press('Enter'); // "New librarian" 891 if ((await ssState(page)) !== 'avatar') throw new Error('avatar step not reached'); 892 await page.keyboard.press('ArrowRight'); 893 await page.keyboard.press('Enter'); 894 await page.keyboard.type('ada'); 895 await page.keyboard.press('Enter'); 896 await ssWait(page, 'queue'); 897 const info = await page.evaluate(() => { 898 const g = window.__SS_GAME__; 899 return { name: g.profile.name, patrons: document.querySelectorAll('.ss-patron').length, n: g.queue.length, 900 topics: new Set(g.queue.map((q) => q.topic.id)).size, signs: document.querySelectorAll('.ss-sign-card').length, 901 overlay: !document.querySelector('.ss-overlay').hidden && getComputedStyle(document.querySelector('.ss-overlay')).display !== 'none', 902 stored: JSON.parse(localStorage.getItem('kg-story-stamp-v1')).profiles.length }; 903 }); 904 if (info.name !== 'Ada') throw new Error(`name not captured: ${JSON.stringify(info)}`); 905 if (info.n < 2 || info.patrons !== info.n || info.signs !== info.n) throw new Error(`queue wrong: ${JSON.stringify(info)}`); 906 if (info.topics !== info.n) throw new Error('every patron should want a different topic'); 907 if (info.overlay) throw new Error('profile overlay should be gone once the desk opens'); 908 if (info.stored !== 1) throw new Error('profile not saved to localStorage'); 909 // Choosing a patron by arrow + Enter brings the request slip. 910 await page.keyboard.press('ArrowRight'); 911 const sel = await page.evaluate(() => window.__SS_GAME__.sel); 912 if (sel !== 1) throw new Error('arrow did not move the selection'); 913 await page.keyboard.press('Enter'); 914 await ssWait(page, 'read', 15000); 915 const o = await page.evaluate(() => ({ boxes: document.querySelectorAll('.ss-box').length, n: window.__SS_GAME__.order.graphemes.length, 916 mode: window.__SS_GAME__.order.mode, topic: window.__SS_GAME__.order.topic.id, t: window.__SS_GAME__.order.word.t, cust: document.querySelector('.ss-cust-name').textContent, 917 counted: window.__SS_GAME__.profile.topics })); 918 if (o.boxes !== o.n || o.n < 2) throw new Error(`sound boxes wrong: ${JSON.stringify(o)}`); 919 if (o.mode !== 'read') throw new Error('first book must be read mode'); 920 if (o.topic !== 'any' && !o.t.includes(o.topic)) throw new Error('book does not match the topic asked for'); 921 if (!o.cust) throw new Error('no patron name shown'); 922 if (!Object.keys(o.counted).length) throw new Error('topic choice not recorded'); 923 if (errors.length) throw new Error(errors[0]); 924 }); 925 926 await test('story-stamp: stamping the letters plays each sound; reading it out never plays the whole word', async (page, url, errors) => { 927 await ssOpen(page); 928 await ssQuickStart(page); 929 const o = await page.evaluate(() => ({ graphemes: window.__SS_GAME__.order.graphemes, units: window.__SS_GAME__.order.units, word: window.__SS_GAME__.order.word.w })); 930 await page.evaluate(() => { window.__SS_GAME__.audio.log.length = 0; }); 931 await page.keyboard.type(o.graphemes.join(''), { delay: 20 }); 932 await ssWait(page, 'blend'); 933 const typed = await page.evaluate(() => ({ stamped: document.querySelectorAll('.ss-box.stamped').length, done: document.querySelectorAll('.ss-box.done').length, 934 played: window.__SS_GAME__.audio.log.filter((e) => e.kind === 'phoneme').map((e) => e.id), words: window.__SS_GAME__.audio.log.filter((e) => e.kind === 'word').length })); 935 if (typed.stamped !== o.graphemes.length || typed.done !== o.graphemes.length) throw new Error(`boxes not all stamped: ${JSON.stringify(typed)}`); 936 const expect = [].concat(...o.units); 937 if (JSON.stringify(typed.played) !== JSON.stringify(expect)) throw new Error(`phonemes played ${typed.played} != ${expect}`); 938 if (typed.words) throw new Error('the word must not be played while typing'); 939 await page.evaluate(() => { window.__SS_GAME__.audio.log.length = 0; }); 940 await page.keyboard.press('Space'); 941 await ssWait(page, 'choose', 15000); 942 // Clips are logged when scheduled; order them by when they actually sound. 943 const blend = (await page.evaluate(() => window.__SS_GAME__.audio.log)).filter((e) => e.kind !== 'speak').sort((a, b) => a.at - b.at); 944 const ph = blend.filter((e) => e.kind === 'phoneme'); 945 if (ph.length !== expect.length * 3) throw new Error(`sounding out should play every sound 3 times, played ${ph.length}`); 946 if (blend.some((e) => e.kind === 'word')) throw new Error('the whole word must never be played: the child has to say it'); 947 const first = ph.slice(0, expect.length), last = ph.slice(-expect.length); 948 const held = first.some((e, i) => e.len > last[i].len); 949 if (!held) throw new Error('first pass should hold sounds longer than the last'); 950 if (!(await page.evaluate(() => document.querySelector('.ss-boxes').classList.contains('fused')))) throw new Error('boxes did not fuse'); 951 const cards = await page.evaluate(() => document.querySelectorAll('.ss-book').length); 952 if (cards !== 3) throw new Error(`expected 3 books on the cart, got ${cards}`); 953 const idx = await page.evaluate((w) => window.__SS_GAME__.order.cards.findIndex((c) => c.w === w), o.word); 954 await page.keyboard.press(String(idx + 1)); 955 await page.waitForFunction(() => document.querySelector('.ss-book.stamped'), null, { timeout: 8000 }).catch(() => { throw new Error('book never got stamped'); }); 956 await ssWait(page, 'queue', 15000); 957 const rec = await page.evaluate((w) => window.__SS_GAME__.profile.words[w], o.word); 958 if (!rec || rec.streak !== 1) throw new Error(`word not recorded as first-try: ${JSON.stringify(rec)}`); 959 if (errors.length) throw new Error(errors[0]); 960 }); 961 962 await test('story-stamp: a wrong key gets a re-demonstration, never a fail state', async (page, url) => { 963 await ssOpen(page); 964 await ssQuickStart(page); 965 const o = await page.evaluate(() => ({ graphemes: window.__SS_GAME__.order.graphemes, first: window.__SS_GAME__.order.units[0] })); 966 const wrong = 'abcdefghijklmnopqrstuvwxyz'.split('').find((c) => c !== o.graphemes[0][0]); 967 await page.evaluate(() => { window.__SS_GAME__.audio.log.length = 0; }); 968 await page.keyboard.press(wrong); 969 await page.waitForFunction(() => window.__SS_GAME__.busy === false, null, { timeout: 5000 }); 970 const after = await page.evaluate(() => ({ idx: window.__SS_GAME__.order.idx, missed: window.__SS_GAME__.order.missed, state: window.__SS_GAME__.state, 971 log: window.__SS_GAME__.audio.log.map((e) => e.kind + ':' + e.id) })); 972 if (after.idx !== 0 || after.state !== 'read') throw new Error(`wrong key changed progress: ${JSON.stringify(after)}`); 973 if (!after.missed) throw new Error('miss not recorded'); 974 if (!after.log.includes('phoneme:' + o.first[0])) throw new Error(`expected sound re-demonstrated, log: ${after.log}`); 975 await page.keyboard.type(o.graphemes.join(''), { delay: 20 }); 976 await ssWait(page, 'blend'); 977 }); 978 979 await test('story-stamp: a wrong book is corrected by re-reading; the third miss gives the word away', async (page, url) => { 980 await ssOpen(page); 981 await ssQuickStart(page); 982 const o = await page.evaluate(() => ({ graphemes: window.__SS_GAME__.order.graphemes, word: window.__SS_GAME__.order.word.w })); 983 await page.keyboard.type(o.graphemes.join(''), { delay: 20 }); 984 await ssWait(page, 'blend'); 985 await page.keyboard.press('Space'); 986 await ssWait(page, 'choose', 15000); 987 const wrongIdx = await page.evaluate((w) => window.__SS_GAME__.order.cards.findIndex((c) => c.w !== w), o.word); 988 await page.evaluate(() => { window.__SS_GAME__.audio.log.length = 0; }); 989 await page.keyboard.press(String(wrongIdx + 1)); 990 await page.waitForFunction(() => window.__SS_GAME__.state === 'choose' && window.__SS_GAME__.busy === false, null, { timeout: 8000 }); 991 const log = await page.evaluate(() => window.__SS_GAME__.audio.log.map((e) => e.kind + ':' + e.id)); 992 if (log.some((e) => e === 'word:' + o.word)) throw new Error('the word must not be given away on the first miss'); 993 if (log.filter((e) => e.startsWith('phoneme:')).length < o.graphemes.length) throw new Error('sounds should be re-read after a miss'); 994 // Second miss: a wrong book is taken away. Third miss: the word is said and the book pointed out. 995 let again = await page.evaluate((w) => window.__SS_GAME__.order.cards.findIndex((c) => c.w !== w), o.word); 996 await page.keyboard.press(String(again + 1)); 997 await page.waitForFunction(() => window.__SS_GAME__.state === 'choose' && window.__SS_GAME__.busy === false, null, { timeout: 8000 }); 998 const two = await page.evaluate(() => window.__SS_GAME__.order.cards.length); 999 if (two !== 2) throw new Error(`expected 2 books after the second miss, got ${two}`); 1000 again = await page.evaluate((w) => window.__SS_GAME__.order.cards.findIndex((c) => c.w !== w), o.word); 1001 await page.evaluate(() => { window.__SS_GAME__.audio.log.length = 0; }); 1002 await page.keyboard.press(String(again + 1)); 1003 await page.waitForFunction(() => window.__SS_GAME__.state === 'choose' && window.__SS_GAME__.busy === false, null, { timeout: 8000 }); 1004 const help = await page.evaluate((w) => ({ said: window.__SS_GAME__.audio.log.some((e) => e.kind === 'word' && e.id === w), pointed: !!document.querySelector('.ss-book.point') }), o.word); 1005 if (!help.said || !help.pointed) throw new Error(`third miss should give the word and point at the book: ${JSON.stringify(help)}`); 1006 const rightIdx = await page.evaluate((w) => window.__SS_GAME__.order.cards.findIndex((c) => c.w === w), o.word); 1007 await page.keyboard.press(String(rightIdx + 1)); 1008 await ssWait(page, 'queue', 15000); 1009 const rec = await page.evaluate((w) => window.__SS_GAME__.profile.words[w], o.word); 1010 if (rec.streak !== 0 || rec.lapses !== 1) throw new Error(`mix-up should count as a lapse: ${JSON.stringify(rec)}`); 1011 }); 1012 1013 await test('story-stamp: six books make a day, then the library reopens', async (page, url, errors) => { 1014 await ssOpen(page); 1015 await ssQuickStart(page); 1016 const words = []; 1017 for (let i = 0; i < 6; i++) { 1018 words.push((await ssServe(page)).word); 1019 const st = await ssState(page); 1020 if (i < 5) { 1021 if (st !== 'queue') throw new Error(`book ${i + 1}: expected the queue, got ${st}`); 1022 await page.keyboard.press('Enter'); 1023 await ssWait(page, 'read', 15000); 1024 } 1025 } 1026 if ((await ssState(page)) !== 'dayend') throw new Error('day did not end after six books'); 1027 const dots = await page.evaluate(() => document.querySelectorAll('.ss-day span.on').length); 1028 if (dots !== 6) throw new Error(`day progress shows ${dots}/6`); 1029 await page.keyboard.press('Enter'); 1030 await ssWait(page, 'queue', 15000); 1031 const p = await page.evaluate(() => ({ orders: window.__SS_GAME__.profile.orders, introduced: window.__SS_GAME__.profile.introduced.length })); 1032 if (p.orders !== 6) throw new Error(`profile.orders = ${p.orders}`); 1033 if (p.introduced < 7) throw new Error('six clean books should have introduced a new sound'); 1034 if (errors.length) throw new Error(errors[0]); 1035 }); 1036 1037 await test('story-stamp: a return says the word, hides the letters, and is typed by ear', async (page, url) => { 1038 await ssOpen(page); 1039 await page.evaluate(() => { 1040 const g = window.__SS_GAME__, SS = window.SS, now = Date.now(); 1041 const p = SS.newProfile('Pro', SS.AVATARS[1], now - 10 * SS.DAY, 'pro'); 1042 ['tap', 'pin', 'pan', 'ant', 'snap', 'pants'].forEach((w) => { SS.recordOrder(p, w, true, now - 9 * SS.DAY); SS.recordOrder(p, w, true, now - 8 * SS.DAY); }); 1043 g.store.profiles.push(p); g.save(); 1044 window.__SS_FORCE_MODE__ = 'return'; 1045 g.quickStart('Pro'); 1046 }); 1047 await ssWait(page, 'queue'); 1048 const idx = await page.evaluate(() => window.__SS_GAME__.queue.findIndex((q) => q.mode === 'return')); 1049 if (idx < 0) throw new Error('no return in the queue'); 1050 await page.keyboard.press(String(idx + 1)); 1051 await ssWait(page, 'read', 15000); 1052 const o = await page.evaluate(() => ({ mode: window.__SS_GAME__.order.mode, graphemes: window.__SS_GAME__.order.graphemes, word: window.__SS_GAME__.order.word.w, 1053 hidden: document.querySelectorAll('.ss-box.hidden-letter').length, pic: !!document.querySelector('.ss-bubble img'), 1054 said: window.__SS_GAME__.audio.log.some((e) => e.kind === 'word' && e.id === window.__SS_GAME__.order.word.w) })); 1055 if (o.mode !== 'return') throw new Error('mode not forced to return'); 1056 if (o.hidden !== o.graphemes.length) throw new Error('letters should be hidden in return mode'); 1057 if (!o.pic) throw new Error('patron should show the book'); 1058 if (!o.said) throw new Error('the word should have been played before typing'); 1059 await page.keyboard.type(o.graphemes.join(''), { delay: 20 }); 1060 await ssWait(page, 'blend'); 1061 await page.keyboard.press('Space'); 1062 await ssWait(page, 'queue', 15000); // no book choice for a return: straight back to the shelf 1063 const rec = await page.evaluate((w) => window.__SS_GAME__.profile.words[w], o.word); 1064 if (rec.reps !== 3) throw new Error(`review not recorded: ${JSON.stringify(rec)}`); 1065 }); 1066 1067 await test('story-stamp: progress survives a reload and due words return as regulars', async (page, url) => { 1068 await ssOpen(page); 1069 await ssQuickStart(page, 'Keep'); 1070 const first = await ssServe(page); 1071 await page.goto(SS_URL, { waitUntil: 'domcontentloaded' }); 1072 await page.waitForFunction(() => !!window.__SS_GAME__); 1073 await page.keyboard.press('Enter'); // last librarian is preselected 1074 await ssWait(page, 'queue'); 1075 const back = await page.evaluate(() => ({ name: window.__SS_GAME__.profile.name, words: Object.keys(window.__SS_GAME__.profile.words) })); 1076 if (back.name !== 'Keep' || !back.words.includes(first.word)) throw new Error(`progress lost: ${JSON.stringify(back)}`); 1077 // Jump two days ahead: the served word is due and its regular comes back. 1078 const review = await page.evaluate((w) => { 1079 const g = window.__SS_GAME__, SS = window.SS; 1080 window.__SS_NOW__ = Date.now() + 2 * SS.DAY; 1081 // pickNext never serves the immediately-previous word, and after exactly 1082 // one book that is the word we want back. Stand in for "some other book 1083 // has been served since" so the review itself is what's under test. 1084 g.profile.lastWord = null; 1085 let queued = false; 1086 for (let seed = 1; seed <= 12 && !queued; seed++) { 1087 const q = SS.buildQueue(g.profile, g.now(), SS.mulberry32(seed), 3); 1088 queued = q.some((e) => e.word.w === w && e.review && e.patron.id === SS.patronFor(w).id); 1089 } 1090 return { due: SS.dueWords(g.profile, g.now()).includes(w), queued }; 1091 }, first.word); 1092 if (!review.due) throw new Error('served word should be due for review two days later'); 1093 if (!review.queued) throw new Error('the due word should come back with its regular'); 1094 }); 1095 1096 await test('story-stamp: menu and shelf open from Backspace and close again', async (page, url) => { 1097 await ssOpen(page); 1098 await ssQuickStart(page); 1099 await page.keyboard.press('Backspace'); 1100 if ((await ssState(page)) !== 'menu') throw new Error('menu did not open'); 1101 await page.keyboard.press('Enter'); // My shelf 1102 if ((await ssState(page)) !== 'shelf') throw new Error('shelf did not open'); 1103 if (!(await page.evaluate(() => document.querySelector('.ss-panel').textContent.includes('No books yet')))) throw new Error('empty shelf message missing'); 1104 await page.keyboard.press('Backspace'); 1105 if ((await ssState(page)) !== 'read') throw new Error('shelf did not close back to the desk'); 1106 await page.keyboard.press('Backspace'); 1107 await page.keyboard.press('ArrowDown'); 1108 await page.keyboard.press('Enter'); // Change librarian 1109 if ((await ssState(page)) !== 'profiles') throw new Error('change librarian did not return to profiles'); 1110 }); 1111 1112 await test('story-stamp: a librarian can be removed from the profile screen', async (page, url) => { 1113 await ssOpen(page); 1114 await ssQuickStart(page, 'Gone'); 1115 await page.keyboard.press('Backspace'); await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter'); 1116 await page.keyboard.press('x'); 1117 if ((await ssState(page)) !== 'confirmDelete') throw new Error('no confirmation step'); 1118 await page.keyboard.press('y'); 1119 const left = await page.evaluate(() => window.__SS_GAME__.store.profiles.length); 1120 if (left !== 0) throw new Error(`profile not removed: ${left} left`); 1121 }); 1122 1123 await test('story-stamp: phone portrait keeps the desk, queue and slip inside the screen', async (page, url) => { 1124 await page.setViewportSize({ width: 390, height: 780 }); 1125 await ssOpen(page); 1126 await ssQuickStart(page); 1127 const box = await page.evaluate(() => { 1128 const r = (sel) => { const b = document.querySelector(sel).getBoundingClientRect(); return { l: b.left, r: b.right, t: b.top, b: b.bottom }; }; 1129 return { w: innerWidth, h: innerHeight, slip: r('.ss-slip'), desk: r('.ss-desk'), cust: r('.ss-customer') }; 1130 }); 1131 for (const k of ['slip', 'desk', 'cust']) { 1132 const b = box[k]; 1133 if (b.l < -2 || b.r > box.w + 2 || b.t < 0 || b.b > box.h + 2) throw new Error(`${k} is off screen: ${JSON.stringify(b)} in ${box.w}x${box.h}`); 1134 } 1135 }); 1136 1137 1138 await test('touch: story-stamp on-screen keyboard types a sound and has arrow keys', async (page, url) => { 1139 await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; }); 1140 await page.goto(`${url}/games/story-stamp/`, { waitUntil: 'domcontentloaded' }); 1141 await page.waitForSelector('#kg-touch'); 1142 await page.waitForFunction(() => !!window.__SS_GAME__); 1143 for (const label of ['◀', '▲', '▼', '▶', '⏎']) await touchKey(page, label); 1144 await page.evaluate(() => { window.__SS_GAME__.quickStart('Tap'); }); 1145 await page.waitForFunction(() => window.__SS_GAME__.state === 'queue', null, { timeout: 8000 }); 1146 await (await touchKey(page, '⏎')).tap(); 1147 await page.waitForFunction(() => window.__SS_GAME__.state === 'read', null, { timeout: 15000 }); 1148 const first = await page.evaluate(() => window.__SS_GAME__.order.graphemes[0][0].toUpperCase()); 1149 const key = await touchKey(page, first); 1150 await key.tap(); 1151 await page.waitForTimeout(200); 1152 const idx = await page.evaluate(() => window.__SS_GAME__.order.idx + window.__SS_GAME__.order.buffer.length); 1153 if (idx < 1) throw new Error('on-screen key did not register'); 1154 }, { hasTouch: true }); 1155 1156 // ── Page layout ─────────────────────────────────────────────────────────── 1157 1158 // Every shell is a fixed-height, non-scrolling flex column. If the canvas 1159 // refuses to shrink (flex items default to min-height:auto), whatever sits 1160 // below it is pushed past the fold with no way to scroll to it. 1161 const SHELLS = ['letter-find', 'fire-truck', 'animal-letter', 'animal-spell', 'work', 'story-stamp']; 1162 for (const slug of SHELLS) { 1163 await test(`${slug}: page does not overflow the viewport`, async (page, url) => { 1164 await page.setViewportSize({ width: 1000, height: 700 }); 1165 await page.goto(`${url}/games/${slug}/`, { waitUntil: 'domcontentloaded' }); 1166 await page.waitForTimeout(1200); 1167 const m = await page.evaluate(() => ({ 1168 scrollH: document.documentElement.scrollHeight, 1169 innerH: window.innerHeight, 1170 })); 1171 if (m.scrollH > m.innerH + 1) { 1172 throw new Error(`Page overflows viewport: ${JSON.stringify(m)}`); 1173 } 1174 }); 1175 1176 await test(`${slug}: touch bar fits on screen`, async (page, url) => { 1177 await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; }); 1178 await page.setViewportSize({ width: 390, height: 700 }); 1179 await page.goto(`${url}/games/${slug}/`, { waitUntil: 'domcontentloaded' }); 1180 await page.waitForSelector('#kg-touch'); 1181 await page.waitForTimeout(600); 1182 const m = await page.evaluate(() => { 1183 const r = document.getElementById('kg-touch').getBoundingClientRect(); 1184 return { top: r.top, bottom: r.bottom, innerH: window.innerHeight }; 1185 }); 1186 if (m.bottom > m.innerH + 1 || m.top >= m.innerH) { 1187 throw new Error(`Touch bar is off-screen: ${JSON.stringify(m)}`); 1188 } 1189 }); 1190 } 1191 1192 1193 // ── Audio unlock on phones ────────────────────────────────────────────────── 1194 // 1195 // Headless Chromium runs with autoplay allowed and, even under the real 1196 // policy, quietly auto-resumes a blocked AudioContext on the next gesture. 1197 // Safari does neither: a context created or resumed outside an activating 1198 // event (keydown / pointerup / touchend / click — NOT a touch pointerdown) 1199 // stays suspended forever. Emulate that at the Web Audio API level so the 1200 // test fails the way an iPhone does. 1201 function emulateSafariAutoplayPolicy() { 1202 const ACTIVATING = ['pointerup', 'touchend', 'click', 'keydown', 'mousedown', 'mouseup']; 1203 let inGesture = false; 1204 ACTIVATING.forEach((t) => window.addEventListener(t, (e) => { 1205 if (!e.isTrusted) return; 1206 inGesture = true; 1207 setTimeout(() => { inGesture = false; }, 0); 1208 }, true)); 1209 const unlocked = new WeakSet(); 1210 const stateDesc = Object.getOwnPropertyDescriptor(BaseAudioContext.prototype, 'state'); 1211 Object.defineProperty(BaseAudioContext.prototype, 'state', { 1212 configurable: true, 1213 get() { 1214 const real = stateDesc.get.call(this); 1215 return real === 'running' && !unlocked.has(this) ? 'suspended' : real; 1216 }, 1217 }); 1218 const origResume = AudioContext.prototype.resume; 1219 AudioContext.prototype.resume = function () { 1220 if (!inGesture) return new Promise(() => {}); // pending forever, like WebKit 1221 unlocked.add(this); 1222 return origResume.call(this); 1223 }; 1224 window.__SAFARI_AUDIO_EMULATED__ = true; 1225 } 1226 1227 async function fireTruckAudioState(page) { 1228 return page.evaluate(() => { 1229 const s = window.__FT_SCENE__; 1230 return { 1231 inited: s._audioInited, 1232 ready: s._audioReady, 1233 ctxState: s.audioCtx ? s.audioCtx.state : 'none', 1234 toneState: Tone.getContext().state, 1235 music: !!s.bgGain, 1236 transport: Tone.Transport.state, 1237 sirenOn: s.sirenOn, 1238 sirenOsc: !!s.sirenOsc, 1239 musicOn: s.musicOn, 1240 }; 1241 }); 1242 } 1243 1244 await test('audio: emulated Safari policy really blocks contexts created on touch pointerdown', async (page, url) => { 1245 await page.addInitScript(emulateSafariAutoplayPolicy); 1246 await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; }); 1247 await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); 1248 await page.waitForSelector('#kg-touch'); 1249 await page.waitForFunction(() => !!window.__FT_SCENE__); 1250 // Synthetic key events (what the touch bar dispatches) are not trusted, so 1251 // audio started from them must stay suspended under the emulation. 1252 await page.evaluate(() => { 1253 window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', bubbles: true })); 1254 window.dispatchEvent(new KeyboardEvent('keyup', { key: 's', code: 'KeyS', bubbles: true })); 1255 }); 1256 await page.waitForTimeout(300); 1257 const st = await fireTruckAudioState(page); 1258 if (!st.inited) throw new Error('initAudio did not run on synthetic keydown'); 1259 if (st.ready || st.ctxState === 'running' || st.toneState === 'running') { 1260 throw new Error(`Emulation is not blocking audio: ${JSON.stringify(st)}`); 1261 } 1262 if (!st.sirenOn) throw new Error('siren toggle did not register'); 1263 }, { hasTouch: true }); 1264 1265 await test('audio: fire-truck music + siren start from a touch-bar tap under Safari policy', async (page, url) => { 1266 await page.addInitScript(emulateSafariAutoplayPolicy); 1267 await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; }); 1268 await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); 1269 await page.waitForSelector('#kg-touch'); 1270 await page.waitForFunction(() => !!window.__FT_SCENE__); 1271 await page.waitForTimeout(300); 1272 // Nothing may be playing before the player asks for it. 1273 const idle = await fireTruckAudioState(page); 1274 if (idle.musicOn || idle.music || idle.sirenOn) { 1275 throw new Error(`audio should start off: ${JSON.stringify(idle)}`); 1276 } 1277 const siren = await touchKey(page, '🔊 Siren'); 1278 await siren.tap(); // touchstart/pointerdown (key fires here) -> pointerup/touchend (activation) 1279 await page.waitForFunction(() => window.__FT_SCENE__._audioReady === true, null, { timeout: 4000 }) 1280 .catch(async () => { throw new Error(`audio never became ready: ${JSON.stringify(await fireTruckAudioState(page))}`); }); 1281 const music = await touchKey(page, '🎵 Music'); 1282 await music.tap(); 1283 await page.waitForFunction(() => window.__FT_SCENE__._musicStarted === true, null, { timeout: 4000 }) 1284 .catch(async () => { throw new Error(`music never started: ${JSON.stringify(await fireTruckAudioState(page))}`); }); 1285 const st = await fireTruckAudioState(page); 1286 if (st.ctxState !== 'running') throw new Error(`SFX context not running: ${JSON.stringify(st)}`); 1287 if (st.toneState !== 'running') throw new Error(`Tone context not running: ${JSON.stringify(st)}`); 1288 if (!st.music) throw new Error(`music not initialised: ${JSON.stringify(st)}`); 1289 if (st.transport !== 'started') throw new Error(`Tone.Transport not started: ${JSON.stringify(st)}`); 1290 if (!st.sirenOn || !st.sirenOsc) throw new Error(`siren not sounding after tap: ${JSON.stringify(st)}`); 1291 // A second music tap silences it again. 1292 await music.tap(); 1293 await page.waitForTimeout(100); 1294 const quiet = await fireTruckAudioState(page); 1295 if (quiet.musicOn || quiet.transport === 'started') { 1296 throw new Error(`music did not stop on second tap: ${JSON.stringify(quiet)}`); 1297 } 1298 await music.tap(); 1299 await page.waitForTimeout(100); 1300 // A second tap turns the siren off again. 1301 await siren.tap(); 1302 await page.waitForTimeout(100); 1303 const off = await fireTruckAudioState(page); 1304 if (off.sirenOn || off.sirenOsc) throw new Error(`siren did not stop on second tap: ${JSON.stringify(off)}`); 1305 }, { hasTouch: true }); 1306 1307 await test('audio: fire-truck music is off until M, then starts under Safari policy', async (page, url) => { 1308 await page.addInitScript(emulateSafariAutoplayPolicy); 1309 await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); 1310 await page.waitForFunction(() => !!window.__FT_SCENE__); 1311 await page.waitForTimeout(300); 1312 // Driving around must not start music on its own. 1313 await page.keyboard.press('ArrowUp'); 1314 await page.waitForTimeout(300); 1315 const before = await fireTruckAudioState(page); 1316 if (before.musicOn || before.music || before.transport === 'started') { 1317 throw new Error(`music started without being asked: ${JSON.stringify(before)}`); 1318 } 1319 await page.keyboard.press('m'); 1320 await page.waitForFunction(() => window.__FT_SCENE__._musicStarted === true, null, { timeout: 4000 }) 1321 .catch(async () => { throw new Error(`music never started after M: ${JSON.stringify(await fireTruckAudioState(page))}`); }); 1322 const st = await fireTruckAudioState(page); 1323 if (!st.musicOn || !st.music || st.transport !== 'started') throw new Error(`music not running: ${JSON.stringify(st)}`); 1324 // M again turns it back off. 1325 await page.keyboard.press('m'); 1326 await page.waitForTimeout(100); 1327 const off = await fireTruckAudioState(page); 1328 if (off.musicOn || off.transport === 'started') throw new Error(`M did not stop music: ${JSON.stringify(off)}`); 1329 }); 1330 1331 await test('audio: fire-truck settings checkbox starts music from its click', async (page, url) => { 1332 await page.addInitScript(emulateSafariAutoplayPolicy); 1333 await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); 1334 await page.waitForFunction(() => !!window.__FT_SCENE__); 1335 await page.waitForTimeout(300); 1336 const checked = await page.$eval('#music-toggle', (el) => el.checked); 1337 if (checked) throw new Error('music checkbox should start unchecked'); 1338 await page.click('#settings-btn'); 1339 await page.click('#music-toggle'); 1340 await page.waitForFunction(() => window.__FT_SCENE__._musicStarted === true, null, { timeout: 4000 }) 1341 .catch(async () => { throw new Error(`music never started from checkbox: ${JSON.stringify(await fireTruckAudioState(page))}`); }); 1342 }); 1343 1344 await test('audio: letter-find music starts from a touch-bar tap under Safari policy', async (page, url) => { 1345 await page.addInitScript(emulateSafariAutoplayPolicy); 1346 await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; }); 1347 await page.goto(`${url}/games/letter-find/`, { waitUntil: 'domcontentloaded' }); 1348 await page.waitForSelector('#kg-touch'); 1349 await page.waitForFunction(() => !!window.__LF_SCENE__); 1350 await page.waitForTimeout(500); 1351 const key = await touchKey(page, 'A'); 1352 await key.tap(); 1353 await page.waitForFunction(() => Tone.Transport.state === 'started', null, { timeout: 4000 }) 1354 .catch(() => { throw new Error('Tone.Transport never started from a touch tap'); }); 1355 const toneState = await page.evaluate(() => Tone.getContext().state); 1356 if (toneState !== 'running') throw new Error(`Tone context is ${toneState}`); 1357 }, { hasTouch: true }); 1358 1359 1360 await test('audio: story-stamp audio context unlocks from a physical key under Safari policy', async (page, url) => { 1361 await page.addInitScript(emulateSafariAutoplayPolicy); 1362 await page.goto(`${url}/games/story-stamp/`, { waitUntil: 'domcontentloaded' }); 1363 await page.waitForFunction(() => !!window.__SS_GAME__); 1364 await page.evaluate(() => { window.__SS_GAME__.quickStart('Aud'); }); 1365 await page.waitForFunction(() => window.__SS_GAME__.state === 'queue', null, { timeout: 8000 }); 1366 const before = await page.evaluate(() => ({ ready: window.__SS_GAME__.audio.ready, state: window.__SS_GAME__.audio.ctx && window.__SS_GAME__.audio.ctx.state })); 1367 if (before.ready || before.state === 'running') throw new Error(`context should be blocked before a gesture: ${JSON.stringify(before)}`); 1368 await page.keyboard.press('Space'); // a trusted key press = activation 1369 await page.waitForFunction(() => window.__SS_GAME__.audio.ready === true, null, { timeout: 4000 }) 1370 .catch(() => { throw new Error('audio never became ready from a key press'); }); 1371 const decoded = () => Object.keys(window.__SS_GAME__.audio.buffers).filter((k) => k.startsWith('ph:') && window.__SS_GAME__.audio.buffers[k]).length; 1372 await page.waitForFunction(decoded.toString().replace('() =>', '() => 30 <='), null, { timeout: 8000 }) 1373 .catch(async () => { throw new Error(`only ${await page.evaluate(decoded)} phoneme clips decoded`); }); 1374 }); 1375 1376 // ── Summary ─────────────────────────────────────────────────────────────── 1377 1378 await browser.close(); 1379 server.close(); 1380 1381 const total = pass + fail; 1382 console.log(`\n${total} tests: ${pass} passed, ${fail} failed.\n`); 1383 if (fail > 0) process.exit(1); 1384 } 1385 1386 main().catch(e => { console.error(e); process.exit(1); });