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>
187 lines
7.3 KiB
JavaScript
187 lines
7.3 KiB
JavaScript
// 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 }
|