feat(course): audit fixes and twelve extensions (Modules 7b, 12, 13)

Audit pass:
- Frame rule counts frames since the last move, so every step lasts
  exactly movesEvery(score) frames, also right after a speed-up
- clampTime + MAX_GAP_MS: a hidden tab no longer replays the whole gap
- Pong Restart keeps held keys
- Fact corrections (mulberry32 output coverage, Haskell IO, uniform)
- New fixture cases from a mutation analysis

Extensions (all three tracks, shared fixtures, course text):
- Turn queue (Exercise 10.6), persisted high score (10.7),
  replay viewer (10.8), play() end-to-end helper (9.5)
- Optional State (4.7) and optional Module 7b, stack-safe IO
- Module 12, property-based testing with generators, shrinking,
  invariants and a model-based oracle
- Module 13, Conway's Life with a Click event
- JS type checking via jsconfig, JSDoc and types.d.ts
- Maintainer tools: scripts/mutate.mjs, scripts/typecheck-js.sh,
  Playwright end-to-end suite (e2e/, scripts/e2e.sh)

Docs: docs/contract.md is the canonical contract; specs and build log
under docs/superpowers.

Verified: JS 728, Scala 689, F# 691 unit tests; stubs and progression
M01-M13 and M07b; 176 end-to-end tests on Chromium and WebKit.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Fable 5.2 committed 2026-11-11 12:05:29 +01:00
1 parent 876f5aabb1
commit edd57f0148
253 files changed
+21753 -503

No files matched your search

+186
View File
@@ -0,0 +1,186 @@
// The test fixture every spec uses: one page of one track, with the fixed seed, the paused fake clock and the drawing
// recorder installed, a Model fed exactly the inputs the page received, and a check that compares the two.
import { test as base, expect } from '@playwright/test'
import { fixSeed, SEED } from './seed.mjs'
import { FakeClock } from './clock.mjs'
import { recordDrawing, framesFrom, canvasColors, pageShape, expectedShape } from './drawing.mjs'
import { Model, programs } from './oracle.mjs'
/** The tracks under test: E2E_TRACKS=js,scala,fsharp (scripts/e2e.sh sets it from --tracks). */
export const TRACKS = (process.env.E2E_TRACKS ?? 'js,scala,fsharp').split(',').filter(Boolean)
// Playwright's key names for the DOM `key` values the programs listen to.
const KEY_NAMES = { ' ': 'Space' }
const keyName = (key) => KEY_NAMES[key] ?? key
const describeShape = (s) => (s ? JSON.stringify(s) : 'nothing')
export class App {
constructor(page, track, name, { seed = SEED, best = 0 } = {}) {
this.page = page
this.track = track
this.name = name
this.seed = seed
this.best = best
this.clock = new FakeClock(page)
this.colors = new Map()
this.errors = []
page.on('pageerror', (e) => this.errors.push(`page error: ${e.message}`))
page.on('console', (m) => { if (m.type() === 'error') this.errors.push(`console error: ${m.text()}`) })
}
get url() {
return `/${this.track}/${programs[this.name].page}`
}
/** Install the helpers and open the page; nothing is drawn until start(). */
async open() {
await fixSeed(this.page, this.seed)
await recordDrawing(this.page)
await this.clock.install()
await this.page.goto(this.url)
}
/** Run the clock to the page's first frame and set up a fresh Model with `best` as the stored best. */
async start({ best = this.best } = {}) {
this.best = best
this.model = new Model(this.name, { seed: this.seed, best })
this.seen = 0 // page frames already compared with the model
this.lastPageFrame = null
const first = await this.clock.start(async () => {
const [f] = await framesFrom(this.page, 0)
return f ? f.at : null
})
this.model.feed(first)
return this
}
/** Reload the page (its storage stays), then start again with `best` as the best the page should load. */
async reload({ best } = {}) {
await this.page.reload()
return this.start({ best })
}
get state() {
return this.model.state
}
async advance(ms) {
this.model.feed(await this.clock.advance(ms))
}
/** Advance one animation frame at a time until `done(state)` holds in the model (checked by peeking ahead), and
* stop on the first frame where it does. Returns the fake ms that passed. */
async advanceUntil(done, limitMs = 60000) {
const from = this.clock.now
const ms = (() => {
for (let n = 1; n * 16 <= limitMs; n++) {
if (done(this.model.peek(this.clock.timesBetween(from, from + n * 16)))) return n * 16
}
throw new Error(`advanceUntil: the model never got there within ${limitMs} ms`)
})()
await this.advance(ms)
return ms
}
async jump(ms) {
this.model.feed(await this.clock.jump(ms))
}
async press(key) {
await this.page.keyboard.press(keyName(key))
this.model.feed([{ tag: 'KeyDown', key }, { tag: 'KeyUp', key }])
}
async down(key) {
await this.page.keyboard.down(keyName(key))
this.model.feed([{ tag: 'KeyDown', key }])
}
async up(key) {
await this.page.keyboard.up(keyName(key))
this.model.feed([{ tag: 'KeyUp', key }])
}
/**
* A real mouse click on canvas pixel (px, py), wherever and however large the page shows the canvas: the window
* position is the middle of that pixel, through the canvas's bounding box, border and CSS scale. The model is fed a
* Click at (px, py), so the page's own conversion from window to canvas coordinates is what is under test.
*/
async click(px, py) {
const box = await this.page.locator('canvas').evaluate((c) => {
const r = c.getBoundingClientRect()
return { left: r.left + c.clientLeft, top: r.top + c.clientTop, sx: c.clientWidth / c.width, sy: c.clientHeight / c.height }
})
await this.page.mouse.click(box.left + (px + 0.5) * box.sx, box.top + (py + 0.5) * box.sy)
this.model.feed([{ tag: 'Click', x: px, y: py }])
}
async color(c) {
if (!this.colors.has(c)) this.colors.set(c, (await canvasColors(this.page, [c]))[0])
return this.colors.get(c)
}
/**
* The check every test makes: every frame the page has drawn since the last check equals, shape by shape and in
* order, what the model drew for the corresponding input; and the page has drawn exactly as many frames as the
* model. Returns the page's last frame (shapes normalised for comparison).
*/
async expectInSync(label = '') {
const fresh = await framesFrom(this.page, this.seen)
const modelFrames = this.model.frames
const colors = [...new Set(modelFrames.slice(this.seen).flatMap((f) => f.shapes.map((s) => s.color)))]
await Promise.all(colors.map((c) => this.color(c)))
const color = (c) => this.colors.get(c)
const where = `${this.track} ${this.name}${label ? `, ${label}` : ''}`
for (const [i, frame] of fresh.entries()) {
const n = this.seen + i
const want = modelFrames[n]
const got = frame.shapes.map(pageShape)
const t = frame.at - this.clock.t0
if (!want) {
throw new Error(`${where}: the page drew frame #${n} (at ${t} ms) but the model drew only ${modelFrames.length} frames. ` +
`Page frame: ${got.length} shapes, first ${describeShape(got[0])}`)
}
const exp = want.shapes.map((s) => expectedShape(s, color))
const j = exp.findIndex((s, k) => JSON.stringify(s) !== JSON.stringify(got[k]))
if (j >= 0 || exp.length !== got.length) {
const k = j >= 0 ? j : Math.min(exp.length, got.length)
throw new Error(`${where}: frame #${n} (page at ${t} ms; model input ${JSON.stringify(want.input)}) differs at shape ${k}:\n` +
` page drew: ${describeShape(got[k])}\n model says: ${describeShape(exp[k])}\n` +
` (page ${got.length} shapes, model ${exp.length})`)
}
}
this.seen += fresh.length
if (fresh.length > 0) this.lastPageFrame = fresh[fresh.length - 1].shapes.map(pageShape)
expect(this.seen, `${where}: frames drawn by the page vs by the model`).toBe(modelFrames.length)
return this.lastPageFrame
}
/** The texts of the page's last checked frame. */
texts() {
return (this.lastPageFrame ?? []).filter((s) => s.tag === 'Text').map((s) => s.text)
}
/** Model shapes normalised the way the page's are, for direct comparisons with a scene built in the test. */
async expected(scene) {
await Promise.all(scene.map((s) => this.color(s.color)))
return scene.map((s) => expectedShape(s, (c) => this.colors.get(c)))
}
}
/** `app(track, name, options)` opens a page and returns its App; the test fails if the page logged any error. */
export const test = base.extend({
app: async ({ page }, use) => {
const apps = []
await use(async (track, name, options) => {
const app = new App(page, track, name, options)
apps.push(app)
await app.open()
return app
})
for (const app of apps) expect(app.errors, `${app.track} ${app.name}: page and console errors`).toEqual([])
},
})
export { expect }
+91
View File
@@ -0,0 +1,91 @@
// Technique 2: a fake clock.
// Playwright's page.clock replaces requestAnimationFrame, performance.now, Date and the timers in the page. Paused,
// it moves only when a test calls runFor or fastForward, so the game sees exactly the time the test chooses.
//
// How fake time becomes the model's `Time t` events (worked out from Playwright 1.65's clock and the three glues):
// - The fake clock counts integer milliseconds ("ticks"). A requestAnimationFrame callback is due at the next
// multiple of 16 ticks after the request (`16 - ticks % 16` later) and receives the tick count as its timestamp.
// - Each track's glue (js/solutions/browser.js, scala/web/BrowserPlatform.scala, fsharp/web/BrowserPlatform.fs)
// remembers the timestamp T0 of its first callback and feeds `Time(T - T0)` for every callback at tick T, and asks
// for the next frame inside the callback (so the next one is due 16 ticks later). Scala and F# truncate to a whole
// number; fake timestamps are whole numbers already, so all three see the same values.
// - Hence: the first frame is Time 0, and from a moment at tick N, `runFor(ms)` fires one Time(T - T0) for every
// multiple of 16 T in (N, N + ms], in order; `fastForward(ms)` fires the single pending frame once, at N + ms
// (Time(N + ms - T0)) — that is how a test makes one long frame, like a tab that was hidden.
// - Keys and clicks sent between two clock calls reach the page after every frame up to the current tick, so the
// model is fed them in that order.
// This class mirrors that arithmetic and returns, for each move of the clock, the exact list of Time events the page
// received; the oracle is fed those. The technique test (tests/techniques.spec.mjs) checks the mapping against the
// timestamps of the recorded frames on every track, so a change in Playwright's clock shows up there first.
/** Interval of Playwright's fake requestAnimationFrame, in ms. */
export const FRAME = 16
export class FakeClock {
constructor(page) {
this.page = page
this.now = null // current tick, known after start()
this.t0 = null // tick of the page's first animation frame
}
/** Install a paused clock for every document the page loads from now on. Call before page.goto. */
async install() {
await this.page.clock.install({ time: new Date('2026-01-01T00:00:00Z') })
await this.page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'))
}
/** The tick the page's clock shows now. */
ticks() {
return this.page.evaluate(() => performance.now())
}
/**
* Step the clock one frame at a time until the page has drawn its first frame (`drawn()` returns that frame's tick,
* or null while nothing is drawn). Before that, the page may still be loading its script or the replay viewer its
* recording; frames that fire before the program asked for one do not exist for it. Returns [Time 0].
*/
async start(drawn, tries = 200) {
this.now = await this.ticks()
for (let i = 0; i < tries; i++) {
const at = await drawn()
if (at !== null) {
if (this.now !== at) throw new Error(`fake clock: expected to stop on the first frame (tick ${at}), but it shows ${this.now}`)
this.t0 = at
return [{ tag: 'Time', t: 0 }]
}
// Up to the next multiple of 16, where a frame fires if the program has asked for one.
const step = FRAME - (this.now % FRAME)
await this.page.clock.runFor(step)
this.now += step
}
throw new Error('the page drew nothing within 200 animation frames of fake time')
}
/** The Time events the page receives while the clock runs from tick `from` to tick `to` frame by frame. */
timesBetween(from, to) {
const first = Math.floor(from / FRAME) + 1
const last = Math.floor(to / FRAME)
return Array.from({ length: Math.max(0, last - first + 1) }, (_, i) => ({ tag: 'Time', t: (first + i) * FRAME - this.t0 }))
}
/** Let `ms` of fake time pass, one animation frame every 16 ms. Returns the Time events the page received. */
async advance(ms) {
const events = this.timesBetween(this.now, this.now + ms)
await this.page.clock.runFor(ms)
this.now += ms
return events
}
/** Advance to the next tick that is a multiple of 16 after `this.now + ms` (inclusive), so a frame fires there. */
async advanceToFrameAfter(ms) {
const target = Math.ceil((this.now + ms) / FRAME) * FRAME
return this.advance(target - this.now)
}
/** Jump `ms` ahead in one go: the pending animation frame fires once, at the end. Returns that one Time event. */
async jump(ms) {
await this.page.clock.fastForward(ms)
this.now += ms
return [{ tag: 'Time', t: this.now - this.t0 }]
}
}
+67
View File
@@ -0,0 +1,67 @@
// Technique 3: recorded drawing.
// The glue of every track draws a Rect shape as `fillStyle = color; fillRect(x, y, w, h)` and a Text shape as
// `fillStyle = color; font = "<size>px monospace"; textAlign = align; fillText(text, x, y)`. This init script wraps
// fillRect and fillText on CanvasRenderingContext2D.prototype before any page script runs and records each call with
// the fill style, font and alignment in force at the moment of the call, read back from the context (so colours and
// fonts are recorded the way the canvas reports them, e.g. "#7cb342" and "16px monospace").
//
// Frames. A frame is everything drawn in one go: in all three runtimes one input (an animation frame, a key, a click)
// is fed to the program and every drawing it produces is performed synchronously before the callback returns. So the
// first call after a quiet spell opens a frame (stamped with the fake `performance.now()` of that moment) and a
// queued microtask, which runs as soon as the callback has returned, closes it. One frame of the page therefore
// corresponds to the drawings the model produces for one input; if an input produces several drawings (several game
// frames due in one animation frame), they are all in that one page frame, in order.
/** Runs in the page. */
const recorderScript = () => {
const frames = []
let open = null
const current = () => {
if (open === null) {
open = { at: performance.now(), shapes: [] }
frames.push(open)
queueMicrotask(() => { open = null })
}
return open
}
const proto = CanvasRenderingContext2D.prototype
const fillRect = proto.fillRect
const fillText = proto.fillText
proto.fillRect = function (x, y, w, h) {
current().shapes.push({ tag: 'Rect', x, y, w, h, color: this.fillStyle })
return fillRect.call(this, x, y, w, h)
}
proto.fillText = function (text, x, y, ...rest) {
current().shapes.push({ tag: 'Text', x, y, text: String(text), color: this.fillStyle, font: this.font, align: this.textAlign })
return fillText.call(this, text, x, y, ...rest)
}
Object.defineProperty(window, '__e2eFrames', { value: frames })
}
/** Install the recorder for every document the page loads from now on. */
export const recordDrawing = (page) => page.addInitScript(recorderScript)
/** The frames recorded in the current document from index `from` on: [{at, shapes}]. */
export const framesFrom = (page, from = 0) => page.evaluate((i) => (window.__e2eFrames ?? []).slice(i), from)
/** How many frames the current document has recorded. */
export const frameCount = (page) => page.evaluate(() => (window.__e2eFrames ?? []).length)
/** Ask the page's canvas how it reports each colour (fillStyle normalises "#ABC", names and so on). */
export const canvasColors = (page, colors) => page.evaluate((cs) => {
const ctx = document.createElement('canvas').getContext('2d')
return cs.map((c) => { ctx.fillStyle = '#000000'; ctx.fillStyle = c; return ctx.fillStyle })
}, colors)
/** A recorded Text shape, with its font split into size and family, so it can be compared with a model shape. */
export const pageShape = (s) => {
if (s.tag !== 'Text') return s
const m = /^\s*(?:\S+\s+)*?(\d+(?:\.\d+)?)px\s+(.+?)\s*$/.exec(s.font)
return { tag: 'Text', x: s.x, y: s.y, text: s.text, color: s.color, size: m ? Number(m[1]) : s.font, family: m ? m[2] : s.font, align: s.align }
}
/** A model shape as the page should have recorded it; `color` maps a model colour to the canvas's spelling. */
export const expectedShape = (s, color) =>
s.tag === 'Rect'
? { tag: 'Rect', x: s.x, y: s.y, w: s.w, h: s.h, color: color(s.color) }
: { tag: 'Text', x: s.x, y: s.y, text: s.text, color: color(s.color), size: s.size, family: 'monospace', align: s.align }
+106
View File
@@ -0,0 +1,106 @@
// The oracle: the pure JS solutions, run in Node, say what a page must draw for a given seed, stored best and list of
// inputs. Nothing here touches a browser.
//
// A program (`main`, `pongMain`, `replayMain(recording)`, `lifeMain`) is an IO that yields a stream; `trace` answers
// its RandomSeed and LoadBest ops with the test's seed and best, exactly as the course's `play(main, seed, events,
// best)` does (js/solutions/app.js). The Model then steps that stream one input at a time and traces each IO it emits,
// so it knows, per input, which shapes are drawn and which scores are saved. Flattened, its output equals `play`'s
// (tests/techniques.spec.mjs checks that); grouped per input, it lines up with the page's recorded frames.
//
// Next to the program, the Model steps a stream of the program's *states*, built from the same pieces as the program
// (e.g. `clampTime(sessions(seed, best, speedActions), MAX_GAP_MS)` for Snake). It is used only to plan inputs and to
// state assertions in game terms ("the head moved one cell"); every judgement of the page is made on its drawing.
import { readFileSync } from 'node:fs'
import { trace } from '../../js/solutions/io.js'
import { clampTime } from '../../js/solutions/stream.js'
import { main, play, sessions, sessionView, speedActions, MAX_GAP_MS, FRAME_MS } from '../../js/solutions/app.js'
import { movesEvery } from '../../js/solutions/game.js'
import { newGame } from '../../js/solutions/spawn.js'
import { pongMain, pongStates, pongActions, FRAME_MS_PONG } from '../../js/solutions/pong/app.js'
import { pongView } from '../../js/solutions/pong/view.js'
import { replayMain, viewerStates, replayActions, viewerView, frames as replayFrames, REPLAY_MS } from '../../js/solutions/replay.js'
import { lifeMain, lifeStates, lifeActions, GEN_MS } from '../../js/solutions/life/app.js'
import { lifeView } from '../../js/solutions/life/view.js'
import { LCELL, LW, LH } from '../../js/solutions/life/model.js'
export const recording = JSON.parse(readFileSync(new URL('../../fixtures/replay-sample.json', import.meta.url), 'utf8'))
/** The four pages of a track, the program each runs, and the matching stream of states. */
export const programs = {
snake: {
page: 'index.html',
main: () => main,
states: (seed, best) => clampTime(sessions(seed, best, speedActions), MAX_GAP_MS),
},
pong: {
page: 'pong.html',
main: () => pongMain,
states: (seed) => clampTime(pongStates(seed, pongActions), MAX_GAP_MS),
},
replay: {
page: 'replay.html',
main: () => replayMain(recording),
states: () => clampTime(viewerStates(replayFrames(recording).length - 1, replayActions), MAX_GAP_MS),
},
life: {
page: 'life.html',
main: () => lifeMain,
states: (seed) => clampTime(lifeStates(seed, lifeActions), MAX_GAP_MS),
},
}
export class Model {
constructor(name, { seed, best = 0 }) {
const program = programs[name]
this.name = name
this.seed = seed
this.best = best
const traced = trace(program.main(), seed, best)
this.startOps = traced.ops // RandomSeed, LoadBest: what the program asks before it starts
this.stream = traced.result
this.stateStream = program.states(seed, best)
this.state = null // latest state the program emitted
this.inputs = [] // every input fed so far
this.frames = [] // per input that drew anything: {input, shapes}
this.saves = [] // scores passed to SaveBest, in order
}
/** Feed inputs, in order, as the page received them. */
feed(inputs) {
for (const input of inputs) {
const [ios, next] = this.stream.step(input)
this.stream = next
const ops = ios.flatMap((io) => trace(io, this.seed, this.best).ops)
const shapes = ops.filter((op) => op.tag === 'Draw').map((op) => op.shape)
this.saves.push(...ops.filter((op) => op.tag === 'SaveBest').map((op) => op.score))
if (shapes.length > 0) this.frames.push({ input, shapes })
const [states, nextStates] = this.stateStream.step(input)
this.stateStream = nextStates
if (states.length > 0) this.state = states[states.length - 1]
this.inputs.push(input)
}
return this
}
/** What the state would be after these inputs, without feeding them. Streams are values, so this is free. */
peek(inputs) {
const [states] = inputs.reduce(([acc, s], input) => {
const [out, next] = s.step(input)
return [out.length > 0 ? out[out.length - 1] : acc, next]
}, [this.state, this.stateStream])
return states
}
/** The course's own one-equality run of the program over every input fed so far. */
play() {
return play(programs[this.name].main(), this.seed, this.inputs, this.best)
}
lastFrame() {
return this.frames.length === 0 ? null : this.frames[this.frames.length - 1]
}
}
// Re-exported for tests that state expectations directly in model terms.
export { sessionView, newGame, movesEvery, pongView, viewerView, replayFrames, lifeView }
export { FRAME_MS, MAX_GAP_MS, FRAME_MS_PONG, REPLAY_MS, GEN_MS, LCELL, LW, LH }
+32
View File
@@ -0,0 +1,32 @@
// Technique 1: a fixed seed.
// Every track's glue asks the browser for its seed the same way: it makes a Uint32Array of length 1 and hands it to
// crypto.getRandomValues (JS: `new Uint32Array(1)`; Scala: `new Uint32Array(1)`; F#: `Array.zeroCreate<uint32> 1`,
// which Fable compiles to a Uint32Array). This init script replaces `getRandomValues` before any page script runs,
// writes the seed into such an array, and logs every call so a test can prove the page asked exactly once, in exactly
// that shape. Any other request (none of the pages makes one) falls through to the real generator and is logged too.
/** The seed every test uses unless it says otherwise. Above 2^31 on purpose: a track that read the seed as a signed
* 32-bit number would start a different game and every comparison with the model would fail. */
export const SEED = 0xdeadbeef
/** Runs in the page (Playwright serialises it), so it may use only its argument. */
const seedScript = (seed) => {
const real = Crypto.prototype.getRandomValues
const calls = []
Object.defineProperty(window, '__e2eSeedCalls', { value: calls })
Crypto.prototype.getRandomValues = function (array) {
const type = array?.constructor?.name
calls.push({ type, length: array?.length })
if (type === 'Uint32Array' && array.length === 1) {
array[0] = seed
return array
}
return real.call(this, array)
}
}
/** Install the fixed seed for every document the page loads from now on. */
export const fixSeed = (page, seed = SEED) => page.addInitScript(seedScript, seed)
/** The calls the current document made to crypto.getRandomValues: [{type, length}]. */
export const seedCalls = (page) => page.evaluate(() => window.__e2eSeedCalls ?? null)