// 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 }