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}`)
+}