From 8028bb2fc04df7a166804d3f9555f0ab902caec4 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 28 Oct 2026 23:53:09 +0100 Subject: [PATCH] feat(js): m09 input, app, runtime, browser, main, index.html and smoke test Co-Authored-By: Claude Sonnet 5.5 --- fixtures/m09-input.json | 160 ++++++++++++++++++++++++++++++++++++ js/index.html | 22 +++++ js/solutions/app.js | 24 ++++++ js/solutions/browser.js | 36 ++++++++ js/solutions/input.js | 12 +++ js/solutions/main.js | 5 ++ js/solutions/runtime.js | 31 +++++++ js/src/app.js | 26 ++++++ js/src/browser.js | 10 +++ js/src/input.js | 8 ++ js/src/main.js | 5 ++ js/src/runtime.js | 24 ++++++ js/test/m09-app.test.js | 50 +++++++++++ js/test/m09-input.test.js | 8 ++ js/test/m09-runtime.test.js | 113 +++++++++++++++++++++++++ scripts/smoke.mjs | 88 ++++++++++++++++++++ 16 files changed, 622 insertions(+) create mode 100644 fixtures/m09-input.json create mode 100644 js/index.html create mode 100644 js/solutions/app.js create mode 100644 js/solutions/browser.js create mode 100644 js/solutions/input.js create mode 100644 js/solutions/main.js create mode 100644 js/solutions/runtime.js create mode 100644 js/src/app.js create mode 100644 js/src/browser.js create mode 100644 js/src/input.js create mode 100644 js/src/main.js create mode 100644 js/src/runtime.js create mode 100644 js/test/m09-app.test.js create mode 100644 js/test/m09-input.test.js create mode 100644 js/test/m09-runtime.test.js create mode 100644 scripts/smoke.mjs diff --git a/fixtures/m09-input.json b/fixtures/m09-input.json new file mode 100644 index 0000000..172037e --- /dev/null +++ b/fixtures/m09-input.json @@ -0,0 +1,160 @@ +{ + "function": "keyToAction", + "cases": [ + { + "name": "ArrowUp-turns-up", + "args": [ + "ArrowUp" + ], + "expected": { + "tag": "Turn", + "dir": "Up" + } + }, + { + "name": "w-turns-up", + "args": [ + "w" + ], + "expected": { + "tag": "Turn", + "dir": "Up" + } + }, + { + "name": "W-turns-up", + "args": [ + "W" + ], + "expected": { + "tag": "Turn", + "dir": "Up" + } + }, + { + "name": "ArrowDown-turns-down", + "args": [ + "ArrowDown" + ], + "expected": { + "tag": "Turn", + "dir": "Down" + } + }, + { + "name": "s-turns-down", + "args": [ + "s" + ], + "expected": { + "tag": "Turn", + "dir": "Down" + } + }, + { + "name": "S-turns-down", + "args": [ + "S" + ], + "expected": { + "tag": "Turn", + "dir": "Down" + } + }, + { + "name": "ArrowLeft-turns-left", + "args": [ + "ArrowLeft" + ], + "expected": { + "tag": "Turn", + "dir": "Left" + } + }, + { + "name": "a-turns-left", + "args": [ + "a" + ], + "expected": { + "tag": "Turn", + "dir": "Left" + } + }, + { + "name": "A-turns-left", + "args": [ + "A" + ], + "expected": { + "tag": "Turn", + "dir": "Left" + } + }, + { + "name": "ArrowRight-turns-right", + "args": [ + "ArrowRight" + ], + "expected": { + "tag": "Turn", + "dir": "Right" + } + }, + { + "name": "d-turns-right", + "args": [ + "d" + ], + "expected": { + "tag": "Turn", + "dir": "Right" + } + }, + { + "name": "D-turns-right", + "args": [ + "D" + ], + "expected": { + "tag": "Turn", + "dir": "Right" + } + }, + { + "name": "x-is-ignored", + "args": [ + "x" + ], + "expected": null + }, + { + "name": "Enter-is-ignored", + "args": [ + "Enter" + ], + "expected": null + }, + { + "name": "empty-string-is-ignored", + "args": [ + "" + ], + "expected": null + }, + { + "name": "space-is-ignored (until m10)", + "args": [ + " " + ], + "expected": null + }, + { + "name": "r-is-ignored (until m10)", + "args": [ + "r" + ], + "expected": null + } + ] +} diff --git a/js/index.html b/js/index.html new file mode 100644 index 0000000..42e7e5f --- /dev/null +++ b/js/index.html @@ -0,0 +1,22 @@ + + + + + + Pure Snake + + + + +

Pure Snake

+ +

Arrows / WASD to move. Space to pause, R to restart (after module 10).

+ + + diff --git a/js/solutions/app.js b/js/solutions/app.js new file mode 100644 index 0000000..46e3fe2 --- /dev/null +++ b/js/solutions/app.js @@ -0,0 +1,24 @@ +import { Action } from './model.js' +import { update } from './game.js' +import { newGame } from './spawn.js' +import { view } from './view.js' +import { map as mapIO, randomSeed, drawScene } from './io.js' +import { keyDowns, interval, map, filterMap, merge, scan, startWith } from './stream.js' +import { keyToAction } from './input.js' + +export const TICK_MS = 150 + +// Everything that can happen to the game: a Tick every TICK_MS, and a Turn for each steering key. +export const classicActions = merge( + map(interval(TICK_MS), () => Action.Tick), + filterMap(keyDowns, keyToAction)) + +// Actions folded into game states. startWith puts the initial state first so it is drawn right away. +export const states = (seed, actions) => + startWith(scan(actions, update, newGame(seed)), newGame(seed)) + +export const render = (state) => drawScene(view(state)) + +// The whole program as a value: get a seed, then turn the stream of states into a stream of drawings. +// Nothing runs here; runtime.js interprets it. +export const main = mapIO(randomSeed, (seed) => map(states(seed, classicActions), render)) diff --git a/js/solutions/browser.js b/js/solutions/browser.js new file mode 100644 index 0000000..f82bf3b --- /dev/null +++ b/js/solutions/browser.js @@ -0,0 +1,36 @@ +// Browser glue: turns a canvas and a window into a Platform. Impure by nature; not part of the exercises' logic. + +const PREVENT = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' ']) + +export const browserPlatform = (canvas, win) => { + const ctx = canvas.getContext('2d') + // Keys that scroll the page should only steer the game. + const onKey = (cb) => (e) => { + if (PREVENT.has(e.key)) e.preventDefault() + cb(e.key) + } + return { + fillRect: (x, y, w, h, color) => { + ctx.fillStyle = color + ctx.fillRect(x, y, w, h) + }, + fillText: (x, y, text, color, size, align) => { + ctx.fillStyle = color + ctx.font = `${size}px monospace` + ctx.textAlign = align + ctx.fillText(text, x, y) + }, + randomSeed: () => crypto.getRandomValues(new Uint32Array(1))[0], + // t is the number of milliseconds since the first frame. + startClock: (onTime) => { + const frame = (start) => (now) => { + const t0 = start ?? now + onTime(now - t0) + win.requestAnimationFrame(frame(t0)) + } + win.requestAnimationFrame(frame(undefined)) + }, + onKeyDown: (cb) => win.addEventListener('keydown', onKey(cb)), + onKeyUp: (cb) => win.addEventListener('keyup', onKey(cb)), + } +} diff --git a/js/solutions/input.js b/js/solutions/input.js new file mode 100644 index 0000000..711cf09 --- /dev/null +++ b/js/solutions/input.js @@ -0,0 +1,12 @@ +import { Action } from './model.js' + +// Key name (as in KeyboardEvent.key) -> direction. A lookup table keeps keyToAction a one-liner. +const directions = { + ArrowUp: 'Up', w: 'Up', W: 'Up', + ArrowDown: 'Down', s: 'Down', S: 'Down', + ArrowLeft: 'Left', a: 'Left', A: 'Left', + ArrowRight: 'Right', d: 'Right', D: 'Right', +} + +export const keyToAction = (key) => + Object.hasOwn(directions, key) ? Action.Turn(directions[key]) : null diff --git a/js/solutions/main.js b/js/solutions/main.js new file mode 100644 index 0000000..526de7b --- /dev/null +++ b/js/solutions/main.js @@ -0,0 +1,5 @@ +import { runApp } from './runtime.js' +import { browserPlatform } from './browser.js' +import { main } from './app.js' + +runApp(main, browserPlatform(document.getElementById('game'), window)) diff --git a/js/solutions/runtime.js b/js/solutions/runtime.js new file mode 100644 index 0000000..58d9f02 --- /dev/null +++ b/js/solutions/runtime.js @@ -0,0 +1,31 @@ +// The single impure place in the course (with browser.js and main.js): everything else only builds values. +// Here we interpret IO descriptions against a Platform and hold the one mutable variable, the current stream. +// Platform = { fillRect, fillText, randomSeed, startClock, onKeyDown, onKeyUp } + +// Perform one op on the platform and return its result. +const perform = (op, platform) => { + if (op.tag === 'RandomSeed') return platform.randomSeed() + const s = op.shape + return s.tag === 'Rect' + ? platform.fillRect(s.x, s.y, s.w, s.h, s.color) + : platform.fillText(s.x, s.y, s.text, s.color, s.size, s.align) +} + +// A loop, not recursion: a scene is hundreds of ops and the stack must not grow with them. +export const runIO = (io, platform) => { + let current = io + while (current.tag === 'Effect') current = current.k(perform(current.op, platform)) + return current.value +} + +export const runApp = (main, platform) => { + let stream = runIO(main, platform) + const feed = (input) => { + const [ios, next] = stream.step(input) + stream = next + ios.forEach((io) => runIO(io, platform)) + } + platform.onKeyDown((key) => feed({ tag: 'KeyDown', key })) + platform.onKeyUp((key) => feed({ tag: 'KeyUp', key })) + platform.startClock((t) => feed({ tag: 'Time', t })) +} diff --git a/js/src/app.js b/js/src/app.js new file mode 100644 index 0000000..09d81e6 --- /dev/null +++ b/js/src/app.js @@ -0,0 +1,26 @@ +import { todo } from './todo.js' +import { pure } from './io.js' + +/** Milliseconds between game steps. */ +export const TICK_MS = 150 + +/** + * Stream of everything that can happen to the game: `Action.Tick` every TICK_MS (from `interval`), + * merged with the `Action` for each key press that `keyToAction` recognises (from `keyDowns`). + */ +export const classicActions = { step: () => todo('m09') } + +/** + * Fold a stream of actions into a stream of game states: start from `newGame(seed)`, apply `update` to each action + * (scan), and emit the initial state first (startWith) so there is something to draw before anything happens. + */ +export const states = (seed, actions) => todo('m09') + +/** The drawing of one state, as an IO: `drawScene(view(state))`. */ +export const render = (state) => todo('m09') + +/** + * The whole program as a value, `IO>>`: ask for a random seed, then turn the stream of + * states (built from `classicActions`) into a stream of drawings (`render`). Nothing runs here. + */ +export const main = pure({ step: () => todo('m09') }) diff --git a/js/src/browser.js b/js/src/browser.js new file mode 100644 index 0000000..4ee6028 --- /dev/null +++ b/js/src/browser.js @@ -0,0 +1,10 @@ +import { todo } from './todo.js' + +/** + * Build a Platform from a canvas and a window (see runtime.js). + * - fillRect / fillText draw on the canvas 2D context (font `${size}px monospace`, textAlign from `align`). + * - startClock calls `onTime(t)` on every animation frame (requestAnimationFrame), `t` = ms since the first frame. + * - onKeyDown / onKeyUp listen on `win`; call `preventDefault()` for arrow keys and space so the page does not scroll. + * - randomSeed uses `crypto.getRandomValues(new Uint32Array(1))[0]`. + */ +export const browserPlatform = (canvas, win) => todo('m09') diff --git a/js/src/input.js b/js/src/input.js new file mode 100644 index 0000000..1cf2a37 --- /dev/null +++ b/js/src/input.js @@ -0,0 +1,8 @@ +import { todo } from './todo.js' + +/** + * Translate a keyboard key name (as in `KeyboardEvent.key`) into the game action it triggers, or `null`. + * `ArrowUp`/`w`/`W` -> `Action.Turn('Up')`, `ArrowDown`/`s`/`S` -> Down, `ArrowLeft`/`a`/`A` -> Left, + * `ArrowRight`/`d`/`D` -> Right. Any other key -> `null`. + */ +export const keyToAction = (key) => todo('m09') diff --git a/js/src/main.js b/js/src/main.js new file mode 100644 index 0000000..526de7b --- /dev/null +++ b/js/src/main.js @@ -0,0 +1,5 @@ +import { runApp } from './runtime.js' +import { browserPlatform } from './browser.js' +import { main } from './app.js' + +runApp(main, browserPlatform(document.getElementById('game'), window)) diff --git a/js/src/runtime.js b/js/src/runtime.js new file mode 100644 index 0000000..f17e380 --- /dev/null +++ b/js/src/runtime.js @@ -0,0 +1,24 @@ +import { todo } from './todo.js' + +/** + * The single impure place in the course (with browser.js and main.js): everything else only builds values. + * A Platform is `{ fillRect(x,y,w,h,color), fillText(x,y,text,color,size,align), randomSeed() -> number, + * startClock(onTime: t => void), onKeyDown(cb: key => void), onKeyUp(cb: key => void) }`. + * Here `let`, loops and mutation are allowed. Keep it tiny. + */ + +/** + * Interpret an IO against a platform and return its final value. + * `Draw` of a Rect -> `platform.fillRect(x, y, w, h, color)`; of a Text -> `platform.fillText(x, y, text, color, size, align)`; + * `RandomSeed` -> `platform.randomSeed()`. The result of each op is passed to `io.k`. + * Must be a loop, not recursion: a long sequence of draws must not overflow the stack. + */ +export const runIO = (io, platform) => todo('m09') + +/** + * Run the program: `runIO(main)` gives the stream; keep the current stream in the one mutable variable. + * Register `platform.onKeyDown` / `onKeyUp` / `startClock` so that each event steps the stream with the matching + * input (`{tag:'KeyDown', key}`, `{tag:'KeyUp', key}`, `{tag:'Time', t}`), stores the next stream, + * and runs every IO the step emitted, in order. + */ +export const runApp = (main, platform) => todo('m09') diff --git a/js/test/m09-app.test.js b/js/test/m09-app.test.js new file mode 100644 index 0000000..81f9ed0 --- /dev/null +++ b/js/test/m09-app.test.js @@ -0,0 +1,50 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { states, classicActions, render, main, TICK_MS } from '../src/app.js' +import { run } from '../src/stream.js' +import { trace } from '../src/io.js' +import { view } from '../src/view.js' +import { newGame } from '../src/spawn.js' +import { Action } from '../src/model.js' +import { freeze } from './helpers.js' + +const time = (t) => ({ tag: 'Time', t }) +const down = (key) => ({ tag: 'KeyDown', key }) + +test('m09 the game ticks every 150 ms', () => assert.equal(TICK_MS, 150)) + +test('m09 classicActions turns Tick into actions on the clock and keys into Turn actions', () => + assert.deepStrictEqual( + run(classicActions, freeze([time(150), down('ArrowUp'), down('x'), time(300)])), + [Action.Tick, Action.Turn('Up'), Action.Tick])) + +test('m09 states starts with the new game, then yields one state per action', () => { + const out = run(states(42, classicActions), freeze([time(150), down('ArrowUp'), time(300)])) + assert.equal(out.length, 4) + assert.deepStrictEqual(out[0], newGame(42)) + assert.deepStrictEqual(out.map((s) => s.snake[0]), [ + { x: 10, y: 10 }, { x: 11, y: 10 }, { x: 11, y: 10 }, { x: 11, y: 9 }, + ]) + assert.deepStrictEqual(out.map((s) => s.nextDir), ['Right', 'Right', 'Up', 'Up']) + assert.deepStrictEqual(out.map((s) => s.dir), ['Right', 'Right', 'Right', 'Up']) +}) + +test('m09 render draws the whole view of the state', () => + assert.deepStrictEqual(trace(render(newGame(42)), 0).ops, view(newGame(42)).map((shape) => ({ tag: 'Draw', shape })))) + +test('m09 main asks for a random seed, then builds the stream from it', () => { + const { ops, result: stream } = trace(main, 42) + assert.deepStrictEqual(ops, [{ tag: 'RandomSeed' }]) + const [outs] = stream.step(time(0)) + assert.equal(outs.length, 1) + assert.deepStrictEqual(trace(outs[0], 0).ops, trace(render(newGame(42)), 0).ops) +}) + +test('m09 main with seed 42 draws the new game first, then the ticked game after 150 ms', () => { + const { result: stream } = trace(main, 42) + const [first, next] = stream.step(time(0)) + const [second] = next.step(time(150)) + assert.deepStrictEqual(trace(first[0], 0).ops.map((o) => o.shape), view(newGame(42))) + assert.equal(second.length, 1) + assert.notDeepEqual(trace(second[0], 0).ops, trace(first[0], 0).ops) +}) diff --git a/js/test/m09-input.test.js b/js/test/m09-input.test.js new file mode 100644 index 0000000..f3bc78f --- /dev/null +++ b/js/test/m09-input.test.js @@ -0,0 +1,8 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { keyToAction } from '../src/input.js' +import { loadFixture } from './helpers.js' + +const fx = loadFixture('m09-input') +fx.cases.forEach((c) => + test(`m09 ${fx.function}: ${c.name}`, () => assert.deepStrictEqual(keyToAction(...c.args), c.expected))) diff --git a/js/test/m09-runtime.test.js b/js/test/m09-runtime.test.js new file mode 100644 index 0000000..7a8e495 --- /dev/null +++ b/js/test/m09-runtime.test.js @@ -0,0 +1,113 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { runIO, runApp } from '../src/runtime.js' +import { pure, draw, randomSeed, map, sequence } from '../src/io.js' +import { main } from '../src/app.js' +import { view } from '../src/view.js' +import { newGame, tick } from '../src/spawn.js' + +// A fake platform: records every call and lets the test fire events by hand. +const fakePlatform = (seed = 42) => { + const calls = [] + const handlers = {} + return { + calls, + fillRect: (...args) => calls.push(['fillRect', ...args]), + fillText: (...args) => calls.push(['fillText', ...args]), + randomSeed: () => { calls.push(['randomSeed']); return seed }, + startClock: (cb) => { handlers.time = cb }, + onKeyDown: (cb) => { handlers.down = cb }, + onKeyUp: (cb) => { handlers.up = cb }, + fireTime: (t) => handlers.time(t), + fireKey: (k) => handlers.down(k), + fireKeyUp: (k) => handlers.up(k), + } +} + +// What the platform should have been asked to draw for a scene. +const expectedCalls = (scene) => + scene.map((s) => + s.tag === 'Rect' + ? ['fillRect', s.x, s.y, s.w, s.h, s.color] + : ['fillText', s.x, s.y, s.text, s.color, s.size, s.align]) + +const rect = { tag: 'Rect', x: 1, y: 2, w: 3, h: 4, color: '#abcdef' } +const text = { tag: 'Text', x: 5, y: 6, text: 'hi', color: '#fff', size: 16, align: 'center' } + +test('m09 runIO of a pure value returns it without touching the platform', () => { + const p = fakePlatform() + assert.equal(runIO(pure(7), p), 7) + assert.deepStrictEqual(p.calls, []) +}) + +test('m09 runIO maps Draw Rect to fillRect(x, y, w, h, color)', () => { + const p = fakePlatform() + runIO(draw(rect), p) + assert.deepStrictEqual(p.calls, [['fillRect', 1, 2, 3, 4, '#abcdef']]) +}) + +test('m09 runIO maps Draw Text to fillText(x, y, text, color, size, align)', () => { + const p = fakePlatform() + runIO(draw(text), p) + assert.deepStrictEqual(p.calls, [['fillText', 5, 6, 'hi', '#fff', 16, 'center']]) +}) + +test('m09 runIO answers RandomSeed with platform.randomSeed() and continues with it', () => { + const p = fakePlatform(99) + assert.equal(runIO(map(randomSeed, (s) => s + 1), p), 100) + assert.deepStrictEqual(p.calls, [['randomSeed']]) +}) + +test('m09 runIO performs effects in order', () => { + const p = fakePlatform() + runIO(sequence([draw(rect), draw(text), draw(rect)]), p) + assert.deepStrictEqual(p.calls.map((c) => c[0]), ['fillRect', 'fillText', 'fillRect']) +}) + +test('m09 runIO handles 100000 effects without overflowing the stack', () => { + const p = fakePlatform() + const n = 100_000 + runIO(sequence(Array.from({ length: n }, () => draw(rect))), p) + assert.equal(p.calls.length, n) +}) + +test('m09 runApp asks the platform for a seed once, at start, and draws nothing before the first input', () => { + const p = fakePlatform() + runApp(main, p) + assert.deepStrictEqual(p.calls, [['randomSeed']]) +}) + +test('m09 runApp draws the initial scene on the first clock tick, then the ticked scene after 150 ms', () => { + const p = fakePlatform(42) + runApp(main, p) + p.calls.length = 0 + p.fireTime(150) + assert.deepStrictEqual(p.calls, [ + ...expectedCalls(view(newGame(42))), + ...expectedCalls(view(tick(newGame(42)))), + ]) +}) + +test('m09 runApp feeds key presses to the game', () => { + const p = fakePlatform(42) + runApp(main, p) + p.fireTime(0) + p.calls.length = 0 + p.fireKey('ArrowUp') + p.fireTime(150) + const turned = { ...newGame(42), nextDir: 'Up' } + assert.deepStrictEqual(p.calls, [ + ...expectedCalls(view(turned)), + ...expectedCalls(view(tick(turned))), + ]) +}) + +test('m09 runApp ignores key releases and unmapped keys (no redraw)', () => { + const p = fakePlatform(42) + runApp(main, p) + p.fireTime(0) + p.calls.length = 0 + p.fireKeyUp('ArrowUp') + p.fireKey('x') + assert.deepStrictEqual(p.calls, []) +}) diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs new file mode 100644 index 0000000..534d1a9 --- /dev/null +++ b/scripts/smoke.mjs @@ -0,0 +1,88 @@ +// Headless smoke test: the page loads, throws no errors, and the canvas has painted something. +// Zero dependencies: drives google-chrome over the DevTools protocol with Node's global WebSocket. +// Usage: node scripts/smoke.mjs exit 0 = painted and clean, 1 = problem (reason printed) +import { spawn } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { createServer } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const url = process.argv[2] +if (!url) { console.error('usage: smoke.mjs '); process.exit(2) } +const chromePath = process.env.CHROME ?? '/usr/bin/google-chrome' +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +const freePort = () => new Promise((resolve, reject) => { + const s = createServer() + s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)) }) + s.on('error', reject) +}) + +const dir = mkdtempSync(join(tmpdir(), 'smoke-')) +const port = await freePort() +const chrome = spawn(chromePath, [ + '--headless=new', `--remote-debugging-port=${port}`, `--user-data-dir=${dir}`, + '--no-sandbox', '--disable-gpu', '--no-first-run', 'about:blank', +], { stdio: 'ignore' }) +const cleanup = () => { chrome.kill('SIGKILL'); rmSync(dir, { recursive: true, force: true }) } +const finish = (code, msg) => { if (msg) console.log(msg); cleanup(); process.exit(code) } +process.on('SIGINT', () => finish(1, 'interrupted')) +const timer = setTimeout(() => finish(1, 'FAIL: timed out'), 30000) + +try { + // Wait for the debugging endpoint, then find the page target. + let target + for (let i = 0; i < 100 && !target; i++) { + try { + const list = await (await fetch(`http://127.0.0.1:${port}/json`)).json() + target = list.find((t) => t.type === 'page') + } catch { await sleep(100) } + } + if (!target) finish(1, 'FAIL: could not connect to Chrome') + + const ws = new WebSocket(target.webSocketDebuggerUrl) + await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject }) + const errors = [] + const pending = new Map() + let id = 0 + const send = (method, params = {}) => new Promise((resolve) => { + pending.set(++id, resolve) + ws.send(JSON.stringify({ id, method, params })) + }) + ws.onmessage = ({ data }) => { + const m = JSON.parse(data) + if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result ?? m.error); pending.delete(m.id) } + else if (m.method === 'Runtime.exceptionThrown') { + const d = m.params.exceptionDetails + errors.push(`exception: ${d.exception?.description ?? d.text}`) + } else if (m.method === 'Runtime.consoleAPICalled' && m.params.type === 'error') { + errors.push(`console.error: ${m.params.args.map((a) => a.value ?? a.description).join(' ')}`) + } else if (m.method === 'Log.entryAdded' && m.params.entry.level === 'error') { + errors.push(`log: ${m.params.entry.text} ${m.params.entry.url ?? ''}`) + } + } + await send('Runtime.enable') + await send('Log.enable') + await send('Page.enable') + await send('Page.navigate', { url }) + await sleep(1000) + + // Does the canvas hold at least one pixel that is not the #111111 background (and not transparent)? + const check = `(() => { + const c = document.querySelector('canvas') + if (!c) return 'no canvas' + const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data + for (let i = 0; i < d.length; i += 4) + if (d[i + 3] !== 0 && (d[i] !== 0x11 || d[i + 1] !== 0x11 || d[i + 2] !== 0x11)) return 'painted' + return 'blank' + })()` + const res = await send('Runtime.evaluate', { expression: check, returnByValue: true }) + const state = res.result?.value ?? `evaluation failed: ${JSON.stringify(res)}` + + const problems = [...(state === 'painted' ? [] : [`canvas: ${state}`]), ...errors] + clearTimeout(timer) + if (problems.length) finish(1, `FAIL ${url}\n ${problems.join('\n ')}`) + finish(0, `OK ${url}: canvas painted, no errors`) +} catch (e) { + finish(1, `FAIL: ${e.message}`) +}