diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e97a1ed76..024280a07 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -63,7 +63,44 @@ jobs: key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: ${{ runner.os }}-pnpm-store- - run: pnpm i - - run: pnpm test + # Vitest 4.1.11 runs each worker as one long-lived orchestrator page and + # dispatches every spec file over a single websocket. Its ws close handler + # destroys the session and rejects the pending `createTesters`, so if that + # socket drops the whole run dies — the in-page client does try to + # reconnect, but the session is already gone. Underneath, the browser + # intermittently severs a renderer's network-service channels: every + # websocket for that page dies at once, the server sees a graceful 1001 + # while the page sees 1006, and no process crashes. + # + # Measured at 4 deaths in 21 local runs, and it cost three attempts to + # land a PR that changed nothing but Markdown. Not our code: no test ever + # fails, and the spec it names is just whichever was in flight. Launching + # the full Chromium build fixes it locally (20 runs clean) but does + # nothing here (0 of 4), so this retries instead. + # + # The condition is deliberately narrow — the page must have been lost AND + # no test may have failed. A real failure prints "N failed" in the summary + # and exits without a retry. + - name: Test (retries once if vitest loses its browser page) + # the container has no default bash, so GitHub would run this under + # dash, which has no `pipefail` — and without it the exit status of + # `pnpm test` is masked by `tee` + shell: bash + run: | + set -o pipefail + for attempt in 1 2; do + if pnpm test 2>&1 | tee "test-$attempt.log"; then + exit 0 + fi + if ! grep -q "Browser connection was closed" "test-$attempt.log" \ + || grep -qE "Test Files.*[0-9]+ failed" "test-$attempt.log"; then + echo "::error::test failed for a real reason, not the known browser flake" + exit 1 + fi + echo "::warning::vitest lost its browser page (upstream flake) — attempt $attempt" + done + echo "::error::vitest lost its browser page on both attempts" + exit 1 - run: pnpm -F @melonjs/matter-adapter test - run: pnpm -F @melonjs/planck-adapter test - run: pnpm -F @melonjs/debug-plugin test diff --git a/packages/melonjs/tests/texture.spec.js b/packages/melonjs/tests/texture.spec.js index 7853482a1..b8aa30a9f 100644 --- a/packages/melonjs/tests/texture.spec.js +++ b/packages/melonjs/tests/texture.spec.js @@ -6,6 +6,7 @@ import { Sprite, TextureAtlas, video, + WebGLRenderer, } from "../src/index.js"; import Renderer from "../src/video/renderer.js"; @@ -287,8 +288,18 @@ describe("Texture", () => { if (compositor) { expect(flushed).toBe(true); - expect(compositor.boundTextures.length).toEqual(0); - expect(compositor.currentTextureUnit).toEqual(-1); + // `boundTextures` / `currentTextureUnit` are WebGL texture-unit + // bookkeeping and exist on no WebGPU batcher, which binds per + // draw instead. The app is built with `video.AUTO`, so which + // backend answers depends on the machine — headless SwiftShader + // lands on WebGL, a real GPU on WebGPU. The cache assertions + // above are backend-agnostic and run either way; only these two + // are gated, and on the renderer rather than on the field being + // present, so a WebGL regression that drops them still fails. + if (app.renderer instanceof WebGLRenderer) { + expect(compositor.boundTextures.length).toEqual(0); + expect(compositor.currentTextureUnit).toEqual(-1); + } // restore original compositor.flush = originalFlush; }