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