diff --git a/packages/melonjs/tests/audio-backend.spec.js b/packages/melonjs/tests/audio-backend.spec.js index 9cd5b807c..c8a053d6e 100644 --- a/packages/melonjs/tests/audio-backend.spec.js +++ b/packages/melonjs/tests/audio-backend.spec.js @@ -1115,14 +1115,19 @@ describe("audio backend", () => { { once: true }, ); document.dispatchEvent(new Event("click")); - await tick(); - // the hardware is running: a generated tone would be heard here - expect(ctx.state).toBe("running"); - // and so must the engine be, with the clip actually started. The - // fixture is ten milliseconds long, so `playing()` is already false - // again by now: the "play" event is the record that it started - expect(audioEngine.state).toBe("running"); - expect(played).toHaveBeenCalledTimes(1); + // waited for rather than given a fixed time, which a full parallel + // run can outlast (#1817): an engine wedged as in the bug never + // gets there at all + await vi.waitFor(() => { + // the hardware is running: a generated tone would be heard here + expect(ctx.state).toBe("running"); + // and so must the engine be, with the clip actually started. + // The fixture is ten milliseconds long, so `playing()` is + // already false again by now: the "play" event is the record + // that it started + expect(audioEngine.state).toBe("running"); + expect(played).toHaveBeenCalledTimes(1); + }); expect(played.mock.calls[0][0]).toBe(id); delete ctx.state; ctx.suspend = savedSuspend; diff --git a/packages/melonjs/tests/drawmesh_bench.spec.js b/packages/melonjs/tests/drawmesh_bench.spec.js index 8cf3a6317..9a5b068f2 100644 --- a/packages/melonjs/tests/drawmesh_bench.spec.js +++ b/packages/melonjs/tests/drawmesh_bench.spec.js @@ -233,7 +233,9 @@ describe("drawMesh benchmark (baseline for #1468)", () => { ); }; - it("baseline cost across mesh counts", (ctx) => { + // Run on demand, and given the time it takes under software GL: + // BENCH=1 npx vitest run --config packages/melonjs/vitest.config.ts tests/drawmesh_bench.spec.js + it("baseline cost across mesh counts", { timeout: 300000 }, (ctx) => { // `measure()` below logs and returns when there is no WebGL, which // reports as a PASS — a benchmark that never ran then looks exactly // like one that did. Decide it once, visibly, up front. @@ -241,12 +243,18 @@ describe("drawMesh benchmark (baseline for #1468)", () => { ctx.skip("Canvas renderer — drawMesh benchmark not applicable"); return; } - // Skipped on CI, visibly rather than by quietly measuring nothing. - // The runner is a GPU-less container rasterizing through SwiftShader's - // Subzero JIT on four shared vCPUs, so these figures describe that - // environment and not the engine. The work is also heavy enough to - // starve the shared GPU process for minutes, which is what made the - // suite flaky. Run it locally to compare against #1507's numbers. + // Skipped unless asked for (`BENCH=1`), visibly rather than by quietly + // measuring nothing. On CI, the runner is a GPU-less container + // rasterizing through SwiftShader's Subzero JIT on four shared vCPUs, + // so these figures describe that environment and not the engine. The + // work is also heavy enough to starve the shared GPU process for + // minutes: in a full run, on CI or on a machine without a GPU, it + // timed out itself and the specs beside it (#1817). Run it on its own + // to compare against #1507's numbers. + if (import.meta.env.VITE_BENCH === "") { + ctx.skip("benchmark — run it with BENCH=1"); + return; + } if (import.meta.env.VITE_CI !== "") { ctx.skip("CI — a software-rasterized benchmark measures the runner"); return; diff --git a/packages/melonjs/tests/text-ink-clipping.spec.js b/packages/melonjs/tests/text-ink-clipping.spec.js index 4a41c5744..01e1dc223 100644 --- a/packages/melonjs/tests/text-ink-clipping.spec.js +++ b/packages/melonjs/tests/text-ink-clipping.spec.js @@ -102,16 +102,23 @@ describe("Text — the glyph is baked whole, not clipped", () => { return m.actualBoundingBoxAscent + m.actualBoundingBoxDescent + stroke; }; - it("bakes the whole glyph, stroke included", () => { - // The oracle is the ink box the browser reports plus the stroke, which - // no metric covers. Anything less means the bake cut the glyph off. - const text = stroked(); - + /** + * The glyph's ink is as tall as the ink box the browser reports, plus the + * stroke, which no metric covers: anything less means the bake cut it + * off. Within a row: the box is measured on the outline, and how the + * face's hinting rasterizes it can paint one row less (#1817: 58 rows for + * a reported 59 on one machine, with nothing cut). + * @param text - a Text whose offscreen canvas has been baked + */ + const expectFullHeight = (text) => { const { top, bottom } = inkRows(text); - expect(bottom - top + 1).toBeGreaterThanOrEqual( - Math.floor(expectedInk(text)), + Math.floor(expectedInk(text)) - 1, ); + }; + + it("bakes the whole glyph, stroke included", () => { + expectFullHeight(stroked()); }); it("keeps the ink inside the canvas at the bottom too", () => { @@ -126,12 +133,12 @@ describe("Text — the glyph is baked whole, not clipped", () => { // the other half of the bug: a face whose glyphs rise above the em box // overshoots with no stroke involved at all const text = stroked({ lineWidth: 0, size: 64 }); - - const { top, bottom } = inkRows(text); - - expect(bottom - top + 1).toBeGreaterThanOrEqual( - Math.floor(expectedInk(text)), - ); + expectFullHeight(text); + // and with nothing on the canvas's first or last row, where a cut + // would leave it + const { top, bottom, height } = inkRows(text); + expect(top).toBeGreaterThan(0); + expect(bottom).toBeLessThan(height - 1); }); describe("the reported layout box is unchanged", () => { diff --git a/packages/melonjs/tests/transition-second-change.spec.js b/packages/melonjs/tests/transition-second-change.spec.js index 78054eb63..adbef426d 100644 --- a/packages/melonjs/tests/transition-second-change.spec.js +++ b/packages/melonjs/tests/transition-second-change.spec.js @@ -1,4 +1,13 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; import { Application, boot, @@ -24,9 +33,74 @@ import { */ const DURATION = 60; -// Generous: a covering transition advances on animation frames, and under a -// full parallel run the browser can go seconds without giving this page one. -const WAIT = { timeout: 15000 }; +const FRAME = 1000 / 60; +// how long, in real time, to keep running frames for something to happen: a +// WebGL renderer can hold a switch while its shaders build +const LIMIT = 20000; + +/* + * The game loop is run here, one frame at a time, rather than by the + * browser: `requestAnimationFrame` only keeps the engine's frame callback, + * and `frame()` calls it with a clock moved on by 1/60 s. Under a full + * parallel run the browser can go seconds without giving this page an + * animation frame, which timed these tests out (#1817); the engine's own + * loop, its timers and deferred switches run as they do in a game. + */ +let nextFrame = null; +let clock = 0; + +/** run one frame of the game loop, if it is running, then what it deferred */ +const frame = async () => { + const callback = nextFrame; + nextFrame = null; + if (callback !== null) { + clock += FRAME; + callback(clock); + } + // a turn of the event loop, for the timers it queued (a state switch) + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); +}; + +/** run frames for `ms` of game time */ +const runFor = async (ms) => { + for (let t = 0; t < ms; t += FRAME) { + await frame(); + } +}; + +/** run frames until `check` passes, or fail with its last error */ +const runUntil = async (check) => { + const end = globalThis.performance.now() + LIMIT; + while (globalThis.performance.now() < end) { + try { + check(); + return; + } catch { + await frame(); + } + } + check(); +}; + +beforeAll(() => { + clock = globalThis.performance.now(); + vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation( + (callback) => { + nextFrame = callback; + return 1; + }, + ); + vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation(() => { + nextFrame = null; + }); +}); + +afterAll(() => { + vi.restoreAllMocks(); +}); + const covers = { fade: FadeEffect, mask: MaskEffect, @@ -71,15 +145,13 @@ describe.each([ * camera effect at all, the transition's own or any other */ const settlesOn = async (id) => { - await vi.waitFor(() => { + await runUntil(() => { expect(state.isCurrent(id)).toBe(true); expect(app.viewport.cameraEffects).toHaveLength(0); - }, WAIT); + }); // and stays so: a cover left behind is persistent, and outlives // the reveal that would otherwise be the last effect to go - await new Promise((resolve) => { - setTimeout(resolve, DURATION * 3); - }); + await runFor(DURATION * 3); expect(state.isCurrent(id)).toBe(true); expect(app.viewport.cameraEffects).toHaveLength(0); }; @@ -92,6 +164,10 @@ describe.each([ start = newState("start"); state.setTransition(start, false); state.change(start, true); + // the loop running, its warm-up over + await runUntil(() => { + expect(nextFrame).not.toBeNull(); + }); resets.length = 0; state.transition( type, @@ -122,9 +198,7 @@ describe.each([ const a = newState("a"); const b = newState("b"); state.change(a); - await new Promise((resolve) => { - setTimeout(resolve, DURATION / 2); - }); + await runFor(DURATION / 2); expect(state.isCurrent(a)).toBe(false); state.change(b); expect(effects()).toHaveLength(1); @@ -162,9 +236,9 @@ describe.each([ const a = newState("a"); const b = newState("b"); state.change(a); - await vi.waitFor(() => { + await runUntil(() => { expect(state.isCurrent(a)).toBe(true); - }, WAIT); + }); // during a's reveal: a change of its own, with a cover of its own state.change(b); await settlesOn(b); @@ -289,9 +363,9 @@ describe("a transition left covering by a destroyed application", () => { try { // not taken by the dead transition: switched, directly state.change(b); - await vi.waitFor(() => { + await runUntil(() => { expect(state.isCurrent(b)).toBe(true); - }, WAIT); + }); // and with no reveal of the dead transition's added to this one expect( app.viewport.cameraEffects.filter((fx) => { diff --git a/packages/melonjs/vitest.config.ts b/packages/melonjs/vitest.config.ts index ef579555c..a4b58d428 100644 --- a/packages/melonjs/vitest.config.ts +++ b/packages/melonjs/vitest.config.ts @@ -100,6 +100,11 @@ export default defineConfig(() => // contributor's machine that genuinely has no GL). define: { "import.meta.env.VITE_CI": JSON.stringify(process.env.CI ?? ""), + // Benchmarks run only when asked for, with `BENCH=1`: they measure + // rather than test, and the heavy ones tie up the browser's shared + // GPU process long enough to time out the specs running beside + // them (#1817). + "import.meta.env.VITE_BENCH": JSON.stringify(process.env.BENCH ?? ""), }, publicDir: resolve(__dirname, "tests/public"), plugins: [