Skip to content

fix(editor): time out a wedged play-realm prepare instead of hanging … #596

fix(editor): time out a wedged play-realm prepare instead of hanging …

fix(editor): time out a wedged play-realm prepare instead of hanging … #596

Workflow file for this run

name: Build
# Push-gated CI: every master push must keep the architecture invariants green —
# boundary guards, full-component ABI static_asserts (compiled by the web build),
# the C++ doctest harnesses, and both TS suites. workflow_dispatch stays for
# re-runs and branch experiments.
on:
push:
branches: [master]
workflow_dispatch:
concurrency:
group: build-${{ github.ref }}
cancel-in-progress: true
jobs:
build-emscripten:
name: Web (Emscripten) + C++ tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'pnpm'
- name: Setup Emscripten
uses: mymindstorm/setup-emsdk@v14
with:
version: 5.0.0
- name: Setup ccache
uses: hendrikmuhs/ccache-action@v1.2.20
with:
key: ${{ runner.os }}-emscripten
max-size: 500M
- name: Install dependencies
run: pnpm install
- name: Build Web target
run: node build-tools/cli.js build -t web --no-sync
- name: Build Playable target
run: node build-tools/cli.js build -t playable --no-sync
- name: Build WeChat target
run: node build-tools/cli.js build -t wechat --no-sync
- name: Verify build outputs
run: |
test -f build/wasm/web/esengine.js && echo "✓ Web JS"
test -f build/wasm/web/esengine.wasm && echo "✓ Web WASM"
test -f build/wasm/playable/esengine.single.js && echo "✓ Playable JS"
test -f build/wasm/wechat/esengine.wxgame.js && echo "✓ WeChat JS"
test -f build/wasm/wechat/esengine.wxgame.wasm && echo "✓ WeChat WASM"
- name: Build C++ test harnesses
run: |
emcmake cmake -S . -B build/wasm/web-tests -DCMAKE_BUILD_TYPE=Release -DES_BUILD_WEB=ON -DES_BUILD_TESTS=ON
cmake --build build/wasm/web-tests -j "$(nproc)" --target \
test_ecs test_events test_particle test_tilemap test_sparse_set \
test_resource_pool test_shader_variant test_log_format test_registry_safety \
test_shader_device test_draw_params test_texture_fb test_buffer_device \
test_compressed_format test_batch_builder test_boundary_span test_webgpu_device \
webgpu_engine_bringup
# The root package.json is ESM, so the emscripten-emitted .js must run as .cjs.
- name: Run C++ test harnesses
run: |
for t in test_ecs test_events test_particle test_tilemap test_sparse_set \
test_resource_pool test_shader_variant test_log_format test_registry_safety \
test_shader_device test_draw_params test_texture_fb test_buffer_device \
test_compressed_format test_batch_builder test_boundary_span test_webgpu_device; do
cp "build/wasm/web-tests/bin/$t.js" "build/wasm/web-tests/bin/$t.cjs"
echo "=== $t"
node "build/wasm/web-tests/bin/$t.cjs"
done
# No --continue-on-error: a side-module compile break must FAIL the gate.
# It used to be swallowed here, so a physics-module error lived on master
# for days and only surfaced when a release ran `build -t all` for real.
- name: Build remaining targets (spine, physics)
run: node build-tools/cli.js build -t all --no-sync
- name: Upload web wasm artifact
uses: actions/upload-artifact@v4
with:
name: wasm-web
path: build/wasm/web/
retention-days: 3
test:
name: Tests (SDK + Desktop)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'pnpm'
- name: Check GL boundary (no gl* outside GLDevice)
run: node tools/check-gl-boundary.mjs
- name: Check render-command boundary (DrawCommands built only by BatchBuilder)
run: node tools/check-draw-command-boundary.mjs
- name: Install dependencies
run: pnpm install
# Filtered so the SDK is checked by ITS OWN pinned TypeScript (the workspace
# carries two majors — desktop is on 6.x); a bare root `pnpm exec tsc`
# resolves an arbitrary compiler. Mirrors the repo's `verify` gate exactly.
- name: Type-check SDK
run: pnpm --filter ./sdk exec tsc --noEmit
- name: Check API surface (snapshot + release-tag policy)
run: node tools/api-surface.mjs --check
- name: Build SDK
run: pnpm --filter ./sdk build
- name: Type-check Examples
run: node build-tools/cli.js check-examples
- name: Run SDK tests
run: pnpm --filter ./sdk exec vitest run
- name: Type-check Desktop
run: pnpm --filter @estella/editor exec tsc --noEmit
- name: Run Desktop tests
run: pnpm --filter @estella/editor exec vitest run
# Headless pixel verification: real scenes rendered by the editor's electron
# host under xvfb (SwiftShader WebGL), asserted pixel-by-pixel.
verify-render:
name: Render verify (headless)
runs-on: ubuntu-latest
needs: build-emscripten
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'pnpm'
- name: Download web wasm artifact
uses: actions/download-artifact@v4
with:
name: wasm-web
path: build/wasm/web/
- name: Install dependencies
run: pnpm install
- name: Build SDK
run: pnpm --filter ./sdk build
- name: Stage wasm for the editor host
run: |
mkdir -p desktop/public/wasm
cp build/wasm/web/* desktop/public/wasm/
- name: Build editor renderer bundle
working-directory: desktop
run: pnpm exec vite build
# Twin coverage guard: every fixture .esshader must carry its WGSL twin
# (hand-written, generated, or switch-skipped) — a twin-less shader is
# WebGPU-dead by design and gen-shader-twins --check exits 1 on it.
- name: Check shader twins
run: node tools/gen-shader-twins.mjs --check $(find desktop/public/scenes -name '*.esshader')
# Pipeline smoke: strip a fixture's twins into a temp copy and require the
# vendored converters (glslang wasm + naga WASI wasm) to produce one —
# exercises the whole GLSL→SPIR-V→WGSL chain on every CI run.
- name: Shader-twin pipeline smoke
run: |
tmp=$(mktemp -d)
sed '/#pragma vertex wgsl/,$d' desktop/public/scenes/mat-tint/tint.esshader > "$tmp/smoke.esshader"
if node tools/gen-shader-twins.mjs --check "$tmp/smoke.esshader"; then
echo "expected would-generate (exit 1) for a twin-less shader"; exit 1
fi
- name: Run headless verify scenes
working-directory: desktop
env:
# CI runners can't use the SUID sandbox helper (not root-owned in
# node_modules); rendering verification doesn't need it.
ELECTRON_DISABLE_SANDBOX: '1'
run: |
run_scene() {
echo "=== $1"
shift
env "$@" xvfb-run -a pnpm exec electron scripts/headless-verify.mjs
}
run_scene sprite-default
# Editor grid on/off pixel diff — guards the custom-draw reflected
# material path (drawMeshWithMaterial + MaterialConstants UBO).
run_scene editor-grid ESTELLA_VERIFY_GRID=64 ESTELLA_VERIFY_STEPS=3
run_scene mesh2d \
ESTELLA_VERIFY_SCENE=/scenes/mesh2d.esscene \
ESTELLA_VERIFY_MANIFEST=/scenes/mesh2d.textures.json \
ESTELLA_VERIFY_W=256 ESTELLA_VERIFY_H=256 ESTELLA_VERIFY_STEPS=4 \
'ESTELLA_VERIFY_EXPECT=[{"x":0.30,"y":0.556,"rgb":[255,0,0],"tol":40},{"x":0.70,"y":0.556,"rgb":[0,255,0],"tol":40},{"x":0.30,"y":0.40,"rgb":[255,0,0],"tol":40}]'
run_scene parallax-shape \
ESTELLA_VERIFY_SCENE=/scenes/parallax-shape.esscene \
ESTELLA_VERIFY_MANIFEST=/scenes/parallax-shape.textures.json \
ESTELLA_VERIFY_STEPS=4 \
'ESTELLA_VERIFY_EXPECT=[{"x":0.5,"y":0.5,"rgb":[0,255,0],"tol":40},{"x":0.25,"y":0.5,"rgb":[255,0,0],"tol":40}]'
run_scene tilemap-flip \
ESTELLA_VERIFY_SCENE=/scenes/tilemap-flip.esscene \
ESTELLA_VERIFY_MANIFEST=/scenes/tilemap-flip.textures.json \
ESTELLA_VERIFY_W=256 ESTELLA_VERIFY_H=256 ESTELLA_VERIFY_STEPS=2 \
'ESTELLA_VERIFY_EXPECT=[{"x":0.40,"y":0.36,"rgb":[255,0,0]},{"x":0.60,"y":0.36,"rgb":[0,255,0]},{"x":0.40,"y":0.58,"rgb":[0,0,255]},{"x":0.60,"y":0.58,"rgb":[255,255,0]}]'
# Tiled tile (GID) object: an object-layer object with a gid renders as a
# positioned Sprite (bottom-left anchor, tileset UV) — the green tile centered.
run_scene tilemap-gidobj \
ESTELLA_VERIFY_SCENE=/scenes/tilemap-gidobj.esscene \
ESTELLA_VERIFY_MANIFEST=/scenes/tilemap-gidobj.textures.json \
ESTELLA_VERIFY_W=256 ESTELLA_VERIFY_H=256 ESTELLA_VERIFY_STEPS=10 \
'ESTELLA_VERIFY_EXPECT=[{"x":0.5,"y":0.5,"rgb":[0,255,0],"tol":40},{"x":0.5,"y":0.12,"rgb":[0,0,0],"tol":30}]'
run_scene ui-mask \
ESTELLA_VERIFY_SCENE=/scenes/ui-list-mask.esscene \
ESTELLA_VERIFY_MANIFEST=/scenes/ui-list-mask.textures.json \
ESTELLA_VERIFY_W=800 ESTELLA_VERIFY_H=600 \
'ESTELLA_VERIFY_EXPECT=[{"x":0.5,"y":0.5,"rgb":[255,0,0],"tol":60},{"x":0.9,"y":0.5,"rgb":[0,0,255],"tol":60},{"x":0.5,"y":0.85,"rgb":[0,0,255],"tol":60},{"x":0.5,"y":0.15,"rgb":[0,0,255],"tol":60}]'
# Y-sort: same scene twice — mask on flips the overlap to the lower
# (higher-Y-key) entity; mask off preserves submission order. Both
# asserted so neither path can silently regress.
run_scene ysort-on \
ESTELLA_VERIFY_YSORT=1 \
ESTELLA_VERIFY_SCENE=/scenes/ysort.esscene \
ESTELLA_VERIFY_MANIFEST=/scenes/ysort.textures.json \
ESTELLA_VERIFY_W=256 ESTELLA_VERIFY_H=256 ESTELLA_VERIFY_STEPS=2 \
'ESTELLA_VERIFY_EXPECT=[{"x":0.5,"y":0.5,"rgb":[0,255,0],"tol":30},{"x":0.5,"y":0.25,"rgb":[255,0,0],"tol":30},{"x":0.5,"y":0.75,"rgb":[0,255,0],"tol":30}]'
run_scene ysort-off \
ESTELLA_VERIFY_SCENE=/scenes/ysort.esscene \
ESTELLA_VERIFY_MANIFEST=/scenes/ysort.textures.json \
ESTELLA_VERIFY_W=256 ESTELLA_VERIFY_H=256 ESTELLA_VERIFY_STEPS=2 \
'ESTELLA_VERIFY_EXPECT=[{"x":0.5,"y":0.5,"rgb":[255,0,0],"tol":30}]'
# Motion trail: move a TrailRenderer entity across the frame in play mode,
# asserting the ribbon rasterizes as a green band (head/mid/tail) with black
# above it — the CPU-ribbon-through-the-batch-face path.
run_scene trail \
ESTELLA_VERIFY_PLAY=1 \
ESTELLA_VERIFY_SCENE=/scenes/trail.esscene \
ESTELLA_VERIFY_MANIFEST=/scenes/trail.textures.json \
ESTELLA_VERIFY_W=256 ESTELLA_VERIFY_H=256 \
'ESTELLA_VERIFY_TRAIL={"from":[-200,0],"to":[200,0],"steps":20}' \
'ESTELLA_VERIFY_EXPECT=[{"x":0.80,"y":0.5,"rgb":[0,255,0],"tol":50},{"x":0.5,"y":0.5,"rgb":[0,255,0],"tol":50},{"x":0.25,"y":0.5,"rgb":[0,255,0],"tol":60},{"x":0.5,"y":0.15,"rgb":[0,0,0],"tol":30}]'
# Linear-light pipeline (colorSpace: 'linear'). The lit falloff moves 52
# to 125 (perceptually-uniform light); the mid-gray texture must ROUND-TRIP
# exactly (sRGB store → hw decode → blit encode = identity) — a missing
# sRGB upload variant would read ~187, so tol 6 is a hard discriminator.
run_scene linear-lit \
ESTELLA_VERIFY_COLORSPACE=linear \
ESTELLA_VERIFY_SCENE=/scenes/mat-lit-point.esscene \
ESTELLA_VERIFY_MANIFEST=/scenes/mat-lit-point.textures.json \
ESTELLA_VERIFY_W=256 ESTELLA_VERIFY_H=256 ESTELLA_VERIFY_STEPS=2 \
'ESTELLA_VERIFY_EXPECT=[{"x":0.5,"y":0.5,"rgb":[0,254,0],"tol":25},{"x":0.75,"y":0.5,"rgb":[0,125,0],"tol":25}]'
run_scene linear-midtone \
ESTELLA_VERIFY_COLORSPACE=linear \
ESTELLA_VERIFY_SCENE=/scenes/srgb-midtone.esscene \
ESTELLA_VERIFY_MANIFEST=/scenes/srgb-midtone.textures.json \
ESTELLA_VERIFY_W=256 ESTELLA_VERIFY_H=256 ESTELLA_VERIFY_STEPS=2 \
'ESTELLA_VERIFY_EXPECT=[{"x":0.5,"y":0.5,"rgb":[128,128,128],"tol":6}]'
# Post-process chain guards — bloom's first CI pixel coverage. The gamma
# scene pins the LDR multi-pass chain; the linear scene is the HDR
# discriminator: threshold 1.5 sits above anything an 8-bit chain can
# store, so glow OUTSIDE the sprite (x=0.64) proves over-range light
# survived the float intermediates — the same scene blooms NOTHING in
# gamma mode.
run_scene bloom-ldr \
ESTELLA_VERIFY_PLAY=1 \
ESTELLA_VERIFY_SCENE=/scenes/bloom-ldr.esscene \
ESTELLA_VERIFY_MANIFEST=/scenes/bloom-ldr.textures.json \
ESTELLA_VERIFY_W=256 ESTELLA_VERIFY_H=256 ESTELLA_VERIFY_STEPS=10 \
'ESTELLA_VERIFY_EXPECT=[{"x":0.61,"y":0.5,"rgb":[0,130,0],"tol":35},{"x":0.70,"y":0.5,"rgb":[0,32,0],"tol":25},{"x":0.85,"y":0.5,"rgb":[0,0,0],"tol":12}]'
run_scene hdr-bloom \
ESTELLA_VERIFY_PLAY=1 ESTELLA_VERIFY_COLORSPACE=linear \
ESTELLA_VERIFY_SCENE=/scenes/hdr-bloom.esscene \
ESTELLA_VERIFY_MANIFEST=/scenes/hdr-bloom.textures.json \
ESTELLA_VERIFY_W=256 ESTELLA_VERIFY_H=256 ESTELLA_VERIFY_STEPS=10 \
'ESTELLA_VERIFY_EXPECT=[{"x":0.5,"y":0.5,"rgb":[0,255,0],"tol":25},{"x":0.64,"y":0.5,"rgb":[0,255,0],"tol":35},{"x":0.85,"y":0.5,"rgb":[0,0,0],"tol":12}]'
# The SAME pixel assertions on the WebGPU backend — Dawn renders through the
# bundled SwiftShader Vulkan (--use-webgpu-adapter=swiftshader), so a GPU-less
# runner verifies the second backend for real: WGSL twins, the reflected
# material path, the editor grid, and the async readback (material preview).
- name: Run headless verify scenes (WebGPU / SwiftShader)
working-directory: desktop
env:
ELECTRON_DISABLE_SANDBOX: '1'
ESTELLA_VERIFY_BACKEND: webgpu
ESTELLA_VERIFY_WEBGPU_ADAPTER: swiftshader
run: |
run_scene() {
echo "=== $1"
shift
env "$@" xvfb-run -a pnpm exec electron scripts/headless-verify.mjs
}
run_scene webgpu-sprite-grid ESTELLA_VERIFY_GRID=64 ESTELLA_VERIFY_STEPS=3
run_scene webgpu-parallax-shape \
ESTELLA_VERIFY_SCENE=/scenes/parallax-shape.esscene \
ESTELLA_VERIFY_MANIFEST=/scenes/parallax-shape.textures.json \
ESTELLA_VERIFY_STEPS=4 \
'ESTELLA_VERIFY_EXPECT=[{"x":0.5,"y":0.5,"rgb":[0,255,0],"tol":40},{"x":0.25,"y":0.5,"rgb":[255,0,0],"tol":40}]'
# Tolerances tightened after the readback fix (force-color-profile=srgb):
# capturePage was display-referred, so wide-gamut machines read a fake
# "R/B bleed" here. Deterministic now — GL and WebGPU read identical.
run_scene webgpu-mat-lit-point \
ESTELLA_VERIFY_SCENE=/scenes/mat-lit-point.esscene \
ESTELLA_VERIFY_MANIFEST=/scenes/mat-lit-point.textures.json \
ESTELLA_VERIFY_W=256 ESTELLA_VERIFY_H=256 ESTELLA_VERIFY_STEPS=2 \
'ESTELLA_VERIFY_EXPECT=[{"x":0.5,"y":0.5,"rgb":[0,253,0],"tol":25},{"x":0.75,"y":0.5,"rgb":[0,52,0],"tol":25}]'
run_scene webgpu-mat-tint-preview \
ESTELLA_VERIFY_SCENE=/scenes/mat-tint.esscene \
ESTELLA_VERIFY_MANIFEST=/scenes/mat-tint.textures.json \
ESTELLA_VERIFY_W=256 ESTELLA_VERIFY_H=256 ESTELLA_VERIFY_STEPS=2 \
'ESTELLA_VERIFY_EXPECT=[{"x":0.5,"y":0.5,"rgb":[255,0,0],"tol":40}]' \
'ESTELLA_VERIFY_PREVIEW={"w":64,"h":64,"rgb":[255,0,0],"tol":45}'
run_scene webgpu-ysort \
ESTELLA_VERIFY_YSORT=1 \
ESTELLA_VERIFY_SCENE=/scenes/ysort.esscene \
ESTELLA_VERIFY_MANIFEST=/scenes/ysort.textures.json \
ESTELLA_VERIFY_W=256 ESTELLA_VERIFY_H=256 ESTELLA_VERIFY_STEPS=2 \
'ESTELLA_VERIFY_EXPECT=[{"x":0.5,"y":0.5,"rgb":[0,255,0],"tol":30},{"x":0.5,"y":0.25,"rgb":[255,0,0],"tol":30},{"x":0.5,"y":0.75,"rgb":[0,255,0],"tol":30}]'
run_scene webgpu-trail \
ESTELLA_VERIFY_PLAY=1 \
ESTELLA_VERIFY_SCENE=/scenes/trail.esscene \
ESTELLA_VERIFY_MANIFEST=/scenes/trail.textures.json \
ESTELLA_VERIFY_W=256 ESTELLA_VERIFY_H=256 \
'ESTELLA_VERIFY_TRAIL={"from":[-200,0],"to":[200,0],"steps":20}' \
'ESTELLA_VERIFY_EXPECT=[{"x":0.80,"y":0.5,"rgb":[0,255,0],"tol":50},{"x":0.5,"y":0.5,"rgb":[0,255,0],"tol":50},{"x":0.25,"y":0.5,"rgb":[0,255,0],"tol":60},{"x":0.5,"y":0.15,"rgb":[0,0,0],"tol":30}]'
# Linear pipeline on the second backend — same expectations as the GL
# linear scenes (backends are each other's gold). The mid-gray round-trip
# additionally proves the ENGINE-SIDE sRGB upload promotion: on WebGPU
# every texture uploads through rm_createTexture, so a missing SRGB8A8
# promotion would read ~187 here while GL (raw-GL upload path) stayed 128.
run_scene webgpu-linear-lit \
ESTELLA_VERIFY_COLORSPACE=linear \
ESTELLA_VERIFY_SCENE=/scenes/mat-lit-point.esscene \
ESTELLA_VERIFY_MANIFEST=/scenes/mat-lit-point.textures.json \
ESTELLA_VERIFY_W=256 ESTELLA_VERIFY_H=256 ESTELLA_VERIFY_STEPS=2 \
'ESTELLA_VERIFY_EXPECT=[{"x":0.5,"y":0.5,"rgb":[0,254,0],"tol":25},{"x":0.75,"y":0.5,"rgb":[0,125,0],"tol":25}]'
run_scene webgpu-linear-midtone \
ESTELLA_VERIFY_COLORSPACE=linear \
ESTELLA_VERIFY_SCENE=/scenes/srgb-midtone.esscene \
ESTELLA_VERIFY_MANIFEST=/scenes/srgb-midtone.textures.json \
ESTELLA_VERIFY_W=256 ESTELLA_VERIFY_H=256 ESTELLA_VERIFY_STEPS=2 \
'ESTELLA_VERIFY_EXPECT=[{"x":0.5,"y":0.5,"rgb":[128,128,128],"tol":6}]'
# Bloom on the second backend — measured byte-identical to GL after the
# bind-group eviction fix (stale-id aliasing silently zeroed the WGSL
# chain's params); these scenes keep that regression impossible to miss.
run_scene webgpu-bloom-ldr \
ESTELLA_VERIFY_PLAY=1 \
ESTELLA_VERIFY_SCENE=/scenes/bloom-ldr.esscene \
ESTELLA_VERIFY_MANIFEST=/scenes/bloom-ldr.textures.json \
ESTELLA_VERIFY_W=256 ESTELLA_VERIFY_H=256 ESTELLA_VERIFY_STEPS=10 \
'ESTELLA_VERIFY_EXPECT=[{"x":0.61,"y":0.5,"rgb":[0,130,0],"tol":35},{"x":0.70,"y":0.5,"rgb":[0,32,0],"tol":25},{"x":0.85,"y":0.5,"rgb":[0,0,0],"tol":12}]'
run_scene webgpu-hdr-bloom \
ESTELLA_VERIFY_PLAY=1 ESTELLA_VERIFY_COLORSPACE=linear \
ESTELLA_VERIFY_SCENE=/scenes/hdr-bloom.esscene \
ESTELLA_VERIFY_MANIFEST=/scenes/hdr-bloom.textures.json \
ESTELLA_VERIFY_W=256 ESTELLA_VERIFY_H=256 ESTELLA_VERIFY_STEPS=10 \
'ESTELLA_VERIFY_EXPECT=[{"x":0.5,"y":0.5,"rgb":[0,255,0],"tol":25},{"x":0.64,"y":0.5,"rgb":[0,255,0],"tol":35},{"x":0.85,"y":0.5,"rgb":[0,0,0],"tol":12}]'