diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 86baf11..9db07f5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -81,6 +81,9 @@ jobs: # physical DX12/DXC qualification remains a separate requirement. "WGPU_BACKEND=dx12" >> $env:GITHUB_ENV "WGPU_DX12_COMPILER=fxc" >> $env:GITHUB_ENV + # A later FXC run also crashed under concurrent unit tests. Keep + # every test and assertion, but serialize this hosted Windows lane. + "RUST_TEST_THREADS=1" >> $env:GITHUB_ENV - name: quick / shared-tests # Retain adapter identities and capability skips even when Rust reports @@ -297,6 +300,19 @@ jobs: CARGO_BUILD_JOBS: '2' run: python tools/ci/setup_windows_perry.py --out "$env:RUNNER_TEMP/bloom-perry" --github-env + - name: Compile real browser startup fixture + shell: pwsh + run: python tools/ci/compile_web_game.py + + - name: Retain compiled browser fixture + uses: actions/upload-artifact@v4 + with: + name: bloom-compiled-web-game-${{ github.run_id }} + path: target/ci/compiled-web-game + overwrite: true + if-no-files-found: error + retention-days: 1 + - name: Windows / installed native startup shell: pwsh env: @@ -461,17 +477,27 @@ jobs: # image, so validate the exact Linux-built package in macOS Chrome, which has # a working hosted WebGPU presentation path. browser-smoke: - needs: build-web + needs: [build-web, build-windows] runs-on: macos-14 steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Download qualified web package uses: actions/download-artifact@v4 with: name: bloom-web-package-${{ github.run_id }} path: native/web/pkg + - name: Download compiled browser fixture + uses: actions/download-artifact@v4 + with: + name: bloom-compiled-web-game-${{ github.run_id }} + path: target/ci/compiled-web-game + - name: web / browser-smoke run: ./scripts/ci-check.sh --web --component browser-smoke diff --git a/docs/evidence/compiled-web-startup-v1.md b/docs/evidence/compiled-web-startup-v1.md new file mode 100644 index 0000000..8bdf5cd --- /dev/null +++ b/docs/evidence/compiled-web-startup-v1.md @@ -0,0 +1,70 @@ +# Compiled-game browser startup acceptance + +The existing browser gate calls the engine WASM directly from JavaScript. It +qualifies rendering and temporal reconstruction but cannot catch a trap in +Perry's game entry, FFI calls or closure dispatch as reported in #74. + +The new gate compiles a real TypeScript game and an intentional startup-failure +control with the pinned Perry 0.5.1220 toolchain already prepared by Windows CI. +Both pages use the production splicer and bootstrap. Their compiler, entry, +embedded game WASM and assembled HTML hashes are retained. The browser job uses +the Linux-built engine package and the compiled pages at the same source commit +in hosted macOS Chrome, which has a working WebGPU presentation path. + +The game initializes the window, selects direct-2D mode, draws a white square +on black for eight frames, requests a stop and records exactly one cleanup. +Acceptance requires the actual game progress and cleanup markers, no script +errors, and all 16,384 expected pixels in a 128x128 browser screenshot. The +monitor observes errors and adapter identity; it does not draw the test image +or call the game's update itself. + +The failure control records entry through a real compiled `writeFile` call. +The monitor then injects a `WebAssembly.RuntimeError` at that FFI boundary. +It must produce that specific script error without successful frame or cleanup markers. +An unrelated infrastructure failure cannot satisfy that control. Unit controls +also reject missing progress, duplicate cleanup and an error-free fault marker. + +The browser job now waits for both the engine build and the Windows fixture +compiler. This reuses the already qualified compiler instead of introducing a +second platform compiler/runtime download. Existing JavaScript-driven renderer +and temporal checks remain required and unchanged. + +Both actual pages compile and splice locally, and acceptance-state controls +pass. Local browser execution is unavailable in this session; hosted execution +is the required rendering proof. No compiled-game browser result is claimed +until that check passes. Browser physics, the complete example runtime matrix, +visible native presentation, fixed-update lifecycle, one-command creation and +named hardware qualification remain separate. + +The initial hosted Windows shared-test job at `1c147ae` exits with an access +violation despite the explicit FXC setting. That process failure is retained; +the earlier compiler workaround does not eliminate the instability. The next +CI attempt serializes the Windows Rust test harness, retaining all tests and +assertions. A local serial DX12/FXC library run at the same source passes 489 +tests with one existing ignored test in 264.59 test seconds (333.515 seconds +including compilation). Selectable helpers use WARP; other helpers can use the +Radeon adapter. This local result does not prove every test on WARP or identify +the hosted crash's cause. The hosted serial result is still required. + +## Initial browser failure and corrected compilation + +The first hosted browser attempt passes the existing JavaScript renderer check +but times out after 90 seconds with no compiled-game frame or cleanup marker. +Its compiler had warned that both engine imports were unresolved, yet exited +zero and emitted a 10,870-byte WASM module. The original local compilation had +the same warning. Those outputs are invalid game qualification, despite their +valid WASM headers. The new preparer creates a project with an explicit local +engine dependency, verifies it resolves to the exact checkout, rejects unresolved +import warnings and requires the intended engine FFI imports in the actual WASM. + +The corrected local fixture compiles six modules and includes 159 engine imports. +A Node VM probe executes the generated Perry runtime and game WASM against a +recording FFI, verifying eight callbacks, one cleanup and the fault control. +It does not load Bloom's renderer or replace hosted browser acceptance. + +That probe also found Perry 0.5.1220's plain TypeScript throw only sets internal +exception state in this fixture and continues into game initialization. The +control now explicitly injects a runtime error at the compiled FFI call, which +the actual Perry boot promise rejects. General Perry exception propagation +remains a separate limitation; this change does not claim to fix it. Failure +diagnostics now include startup logs, DOM status, FFI count and a screenshot. diff --git a/docs/windows-engine-plan.md b/docs/windows-engine-plan.md index f9fcc8f..e6d6edb 100644 --- a/docs/windows-engine-plan.md +++ b/docs/windows-engine-plan.md @@ -17,9 +17,9 @@ first nine-scene Radeon evidence are in draft PR #154. Follow-up work starts at | #127 Vulkan PT correctness | Three deterministic progressive and motion runs, both negative controls, finite intermediates, reset/lighting/rigid-motion checks, retained report | Canonical hardware gate, all four focused temporal tests, and CPU reference sanity check pass on Radeon/Vulkan; [report](evidence/issue-127-windows-vulkan-v1.md) and [raw evidence](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-155-windows-vulkan-20260910) published | | #128 Windows image discrepancies | Identify the first incorrect stage or document a reviewed backend-specific baseline decision; rerun the full strict corpus and reproducibility checks | Cutout and surface corrections pass all nine Radeon images. At #159 source `d610d6a`, full runs 2 and 3 pass every configured check and reproduce 257 artifacts byte-identically with matching metadata and timing differences inside existing noise bounds. Earlier invalid runs retain their failures; named hardware acceptance remains separate | | #135 / #149 temporal reconstruction | Enforced motion/producer/quality-preset corpus, representative scenes, fractional/native and frozen A/B timing, memory/resize checks, platform evidence | Device/resource, stationary SSGI, and profiler fixes are retained. The surface correction passes original HD startup limits and 154,720 analytic receiver checks on Vulkan, DX12, and hosted Metal; 93 local goldens pass, including lighting recovery. The full Radeon corpus passes twice. Wider representative scenes, frozen A/B performance, memory/resize, and platform acceptance remain open | -| #140 integration gates | Same required local/hosted lanes pass on exact source; release package startup and all-example evidence | #160 fixes silent Windows CI non-execution and MSVC PATH ordering. #161 passes the actual native engine build and all 20 native links locally and in hosted CI. #162 fixes the focused DX12 failures; #163 fixes camera-history reset. #164 passes all 22 hosted Tests jobs using an explicit FXC Windows lane. The underlying WARP/DXIL crash remains open. A separate layered-material correction passes the full local FXC shared suite and all 93 DXC/Vulkan goldens. Release startup/install acceptance remains open | +| #140 integration gates | Same required local/hosted lanes pass on exact source; release package startup and all-example evidence | #160 fixes silent Windows CI non-execution and MSVC PATH ordering. #161 passes the actual native engine build and all 20 native links locally and in hosted CI. #162 fixes the focused DX12 failures; #163 fixes camera-history reset. #164 passes all 22 hosted Tests jobs using an explicit FXC Windows lane. The underlying WARP/DXIL crash remains open. A separate layered-material correction passes the full local FXC shared suite and all 93 DXC/Vulkan goldens. Fresh installed headless scene/direct-2D rendering and cleanup pass through #169. Visible presentation and release packaging remain open. #170's initial Windows shared job again crashes despite FXC; serial mitigation awaits hosted qualification | | #138 capability fallback | Actual constrained-adapter startup and relevant forced-tier corpus, truthful capability outputs | Existing implementation/evidence preserved; physical constrained-limit acceptance still needs proof | -| PR integration | Reviewable changes, passing required checks, full issue evidence, merge-ready rendering branch | #147 and the stacked fixes #154–#166 remain drafts; no merge performed | +| PR integration | Reviewable changes, passing required checks, full issue evidence, merge-ready rendering branch | #147 and the stacked fixes #154–#170 remain drafts; no merge performed | ## Engine work retained in scope @@ -54,74 +54,41 @@ audit are saved in `tools/quality/out/windows-engine-plan/plan-requirements.json ## Current next steps -1. Complete hosted Windows test and build execution. The - [CI and example correction](evidence/windows-example-ci-v1.md) explicitly - selects Bash, restores the MSVC linker ahead of Git's tools, and requires - execution-summary artifacts. The hosted native build passes at `636b69a`. - Draft #162 corrects the two focused DX12 failures and passes the complete - Vulkan shared component. Hosted Windows still hits an access violation. - The expanded local DX12 goldens expose a [camera-history reset defect](evidence/windows-camera-history-v1.md); - its read guard passes exact fresh/reset comparisons after eight and 40 - history frames on Vulkan and DX12. Both complete local shared components - now pass, including all 93 goldens. Hosted #163 also passes all 93 macOS - goldens and the expanded cut check; its [evidence is published](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-camera-history-20260911). - The Windows library crash reproduces locally inside WARP's DXIL shader - optimizer during concurrent traversal tests. An [explicit FXC hosted lane](evidence/windows-warp-compiler-v1.md) - and consistent test compiler selection pass all 22 hosted Tests jobs at #164, - with [published evidence](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-warp-compiler-20260911). - Hosted Windows goldens skip its CPU adapter; complete physical Radeon DX12/DXC - and Vulkan shared runs pass all 93 goldens. The expanded physical FXC run - exposed four layered-material compiler failures. A [level-zero LUT correction](evidence/windows-fxc-layered-lut-v1.md) - restores the complete local FXC shared component, including all 93 goldens; - all 93 DXC and Vulkan regression goldens also pass. Hosted checks for that - correction pass all 22 Tests jobs at #165, with [published evidence](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-fxc-layered-20260911). The underlying WARP/DXIL - defect remains open. -2. Finish all-example native linking, real starter/example startup, and clean - Windows installation. The [native example gate](evidence/windows-example-gate-v1.md) - passes all 20 links locally using Perry 0.5.1220 and one matching source-built - runtime profile. It adds required full-lane Windows PR compilation and - rejects missing or stale executable outputs. Hosted example validation also passes at `59244b9`; actual startup and clean - package installation remain required. Packing and installing the actual - package succeeds in a clean project, but its installed `bloom-web --help` - command fails on Windows because npm's shim tries `/bin/bash`. A bounded - starter compiles to WASM from that installed package; runtime rendering is - not yet proven. The next implementation work is portable command execution - and native/browser startup acceptance. The [portable web command](evidence/windows-portable-web-cli-v1.md) - now passes its clean installed help command, nine failure/assembly regression - checks, and a complete installed Perry-plus-engine WASM build on Windows. - Its asynchronous-copy follow-up also passes the hosted Windows pack/install - check and a fresh local installed web build. All 22 hosted Tests jobs pass - at #166, with [published evidence](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-portable-web-20260911). - The [native package correction](evidence/windows-installed-native-v1.md) - fixes Jolt directory lookup and redundant final-link metadata. A diagnostic - installed fixture simulates Jolt and renders an exact frame on DX12 and - Vulkan. The complete fresh-package checker also passes both backends with - all 16,384 pixels matching and no CMake fallback. All 22 hosted Tests jobs at - #167 pass, including installed startup, with [published evidence](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-installed-native-20260911). - The [direct-frame capture correction](evidence/windows-direct-frame-capture-v1.md) - also passes the installed physics/image fixture in both scene and direct-2D - modes on DX12 and Vulkan. A render-target regression checks capture deferral - and fresh output pixels. All 22 hosted Tests jobs at #168 pass, including - both installed rendering modes. The [game-loop cleanup](game-loop.md) adds an optional - final callback on native and web and moves Pong onto the shared loop with - edge-triggered pause input. [Native cleanup and Pong replay evidence](evidence/windows-game-cleanup-v1.md) - also verifies the corrected palette names and all 20 example links. The same - Pong source completes the real web build; its browser frame remains unproven. - Browser starter - rendering, visible native presentation, shared lifecycle, general long-path - support remain open. -3. Complete the wider temporal/geometry, performance, memory, resize, and - capability corpus. The - [HD surface correction](evidence/windows-ssgi-surface-v1.md) and two valid - full Radeon runs are [published with #159](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-ssgi-surface-20260911). - All nine images pass; reproducibility retains 257 byte-identical artifacts. - The original 16-frame HD startup limits pass on Vulkan, DX12, and hosted - Metal. Earlier host-load failures remain invalid timing windows. Hosted - Metal lacks timestamp queries and cannot qualify GPU timing. -4. Complete API generation, streaming, components, runtime UI, and packaging - against the full issue requirements above, then prepare the draft stack for - review and integration. These outcomes include both implementation work and - acceptance evidence; they do not imply that every subsystem is absent. +1. **Finish real compiled-game browser acceptance (#74/#142).** + The [compiled-game gate](evidence/compiled-web-startup-v1.md) catches a gap + in the earlier JavaScript-driven renderer check: Perry returned success for + unresolved imports and emitted a game without the engine calls. The corrected + preparer installs the exact checkout as a dependency, rejects unresolved + imports and inspects the actual WASM import table. A local recording-FFI probe + passes callback/cleanup and explicit startup-fault controls; hosted rendering + must still produce the exact frame. Perry's plain throw propagation remains + a separate limitation found during this work. +2. **Qualify the Windows CI mitigation (#140).** + All 22 Tests jobs passed through #169. #170's initial shared-library job then + hit an access violation despite FXC. The next attempt serializes the Windows + harness while retaining every assertion. The local serial library passes + 489 tests with one existing ignored test; that does not establish the crash's + cause or qualify every helper on WARP. Physical Radeon DX12/DXC and Vulkan + image evidence remains distinct from hosted software rendering. +3. **Complete the starter and example experience (#142/#145).** + The installed web command works on Windows. Fresh native packages render + exact scene/direct-2D frames and simulate Jolt locally and in hosted CI. + [Shared cleanup, corrected example palettes and Pong pause replay](evidence/windows-game-cleanup-v1.md) + are [published at #169](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-starter-lifecycle-20260911). + All 20 canonical native examples link. Project creation/build/run commands, + all-example web/runtime acceptance, fixed updates, visible native presentation, + packaged DXC/DXIL and general Windows long-path support remain incomplete. +4. **Complete wider graphics and performance acceptance.** + [Two strict full Radeon runs at #159](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-ssgi-surface-20260911) + pass all nine images and reproduce 257 artifacts byte-identically. Rerun + current-source qualification after integration, and complete representative + temporal/geometry scenes, fractional/native and frozen A/B timing, memory, + resize and constrained-adapter checks. Hosted Metal without timestamp queries + cannot qualify GPU timing. Named discrete hardware acceptance stays open. +5. **Finish API generation, streaming, components, runtime UI and packaging**, then + prepare the draft stack for review and integration. The full issue requirements + in the table above govern completion; each subsystem already has some code. -Local work continues on the Radeon 760M. RTX-specific, physical constrained -adapter, and other unavailable hardware acceptance remains explicitly open. +Local work continues on the Radeon 760M. An RTX 4080 is not a prerequisite for +this implementation work. RTX-specific and physical constrained-adapter evidence +remain explicitly unperformed. No draft PR has been merged or npm package released. diff --git a/scripts/ci-check.sh b/scripts/ci-check.sh index def0a66..340cc48 100755 --- a/scripts/ci-check.sh +++ b/scripts/ci-check.sh @@ -283,6 +283,9 @@ run_component() { tools/ci/test_web_smoke.py \ tools/ci/compile_examples.py \ tools/ci/setup_windows_perry.py \ + tools/ci/compile_web_game.py \ + tools/ci/compiled_web_smoke.py \ + tools/ci/test_compiled_web_smoke.py \ tools/ci/test_compile_examples.py "$python_cmd" -m unittest \ tools/quality/test_run.py \ @@ -295,6 +298,7 @@ run_component() { tools/quality/test_vsm_motion_corpus.py \ tools/ci/test_web_smoke.py \ tools/ci/test_compile_examples.py \ + tools/ci/test_compiled_web_smoke.py \ -v hr "visual metric and fault-engine tests" cargo test --release --manifest-path tools/bloom-diff/Cargo.toml @@ -330,6 +334,8 @@ run_component() { browser-smoke) hr "Bloom WebGPU real-browser known-frame smoke" "$python_cmd" tools/ci/web_smoke.py + hr "Perry compiled-game startup, frame, cleanup and failure control" + "$python_cmd" tools/ci/compiled_web_smoke.py ;; target-check) cross_crate="${BLOOM_CROSS_CRATE:-}" diff --git a/tools/ci/compile_web_game.py b/tools/ci/compile_web_game.py new file mode 100644 index 0000000..eb10168 --- /dev/null +++ b/tools/ci/compile_web_game.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Compile actual Perry startup and failure-control pages for browser acceptance.""" + +import argparse +import base64 +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import time + +ROOT = Path(__file__).resolve().parents[2] +VERSION = "perry 0.5.1220" +sys.path.insert(0, str(ROOT)) +from tools.ci.native_package_smoke import npm_command + + +def validate_compilation(log, imports): + if "Could not resolve import" in log: + raise RuntimeError("Perry could not resolve a game import; a zero compiler exit is insufficient") + required = {"bloom_init_window", "bloom_set_target_fps", "bloom_set_direct_2d_mode", + "bloom_run_game_with_cleanup", "bloom_write_file", "bloom_draw_rect"} + actual = {item["name"] for item in imports if item["module"] == "ffi"} + missing = required - actual + if missing: + raise RuntimeError(f"compiled game is missing required engine FFI imports: {sorted(missing)}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--perry", default=os.environ.get("BLOOM_PERRY") or shutil.which("perry")) + parser.add_argument("--out", type=Path, default=ROOT / "target/ci/compiled-web-game") + args = parser.parse_args() + if not args.perry: + parser.error("Perry 0.5.1220 is required; prepare the pinned example toolchain") + out = args.out.resolve() + out.mkdir(parents=True, exist_ok=True) + entries = ROOT / "target/ci/compiled-web-entries" + entries.mkdir(parents=True, exist_ok=True) + fixture = (ROOT / "tools/ci/fixtures/compiled-web.ts").read_text(encoding="utf-8") + marker = "const BLOOM_SMOKE_FAIL_STARTUP = false;" + assert fixture.count(marker) == 1 + report = {"schema": "bloom-compiled-web-game-v1", "status": "running", "commands": [], "pages": [], + "source_commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip()} + + def save(): + (out / "result.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + + def run(name, command): + start = time.monotonic() + with (out / f"{name}.log").open("wb") as log: + result = subprocess.run(command, cwd=ROOT, stdout=log, stderr=subprocess.STDOUT, timeout=120) + report["commands"].append({"name": name, "command": command, "exit_code": result.returncode, + "duration_seconds": round(time.monotonic() - start, 3)}) + save() + if result.returncode: + raise RuntimeError(f"{name} exited {result.returncode}; see {out / (name + '.log')}") + + save() + try: + # Perry does not resolve package self-references from an arbitrary + # source folder. Install an explicit local dependency for this project. + manifest = {"name": "bloom-compiled-web-fixture", "private": True, + "dependencies": {"@bloomengine/engine": "file:" + os.path.relpath(ROOT, entries).replace(os.sep, "/")}, + "perry": {"allow": {"nativeLibrary": ["@bloomengine/engine", "@bloomengine/engine/*"]}}} + (entries / "package.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + run("fixture-install", npm_command() + ["install", "--prefix", str(entries), "--ignore-scripts", "--no-audit", "--no-fund", "--package-lock=false", "--install-links=false"]) + dependency = entries / "node_modules/@bloomengine/engine" + if dependency.resolve() != ROOT.resolve(): + raise RuntimeError("compiled fixture dependency must resolve to this exact engine checkout") + run("perry-version", [args.perry, "--version"]) + report["compiler_version"] = (out / "perry-version.log").read_text(encoding="utf-8").strip() + if report["compiler_version"] != VERSION: + raise RuntimeError(f"expected {VERSION}; found {report['compiler_version']}") + report["compiler_sha256"] = hashlib.sha256(Path(args.perry).read_bytes()).hexdigest() + for name, fails in [("game", False), ("trap-control", True)]: + source = fixture.replace(marker, "const BLOOM_SMOKE_FAIL_STARTUP = true;") if fails else fixture + entry = entries / f"{name}.ts" + entry.write_text(source, encoding="utf-8", newline="\n") + (out / f"{name}.ts").write_bytes(entry.read_bytes()) + raw, page = out / f"{name}.perry.html", out / f"{name}.html" + raw.unlink(missing_ok=True) + page.unlink(missing_ok=True) + run(name + "-compile", [args.perry, "compile", str(entry), "--target", "wasm", "-o", str(raw)]) + html = raw.read_text(encoding="utf-8") + match = re.search(r'window\.__perryWasmB64\s*=\s*"([A-Za-z0-9+/=]+)"', html) + if match is None: + raise RuntimeError(f"{name}: Perry output has no embedded game WASM") + wasm = base64.b64decode(match[1], validate=True) + if not wasm.startswith(b"\0asm"): + raise RuntimeError(f"{name}: invalid game WASM header") + wasm_path = out / f"{name}.game.wasm" + wasm_path.write_bytes(wasm) + inspect = "const fs=require('node:fs');process.stdout.write(JSON.stringify(WebAssembly.Module.imports(new WebAssembly.Module(fs.readFileSync(process.argv[1])))));" + run(name + "-imports", ["node", "-e", inspect, str(wasm_path)]) + imports = json.loads((out / f"{name}-imports.log").read_text(encoding="utf-8")) + validate_compilation((out / f"{name}-compile.log").read_text(encoding="utf-8", errors="replace"), imports) + run(name + "-splice", ["node", str(ROOT / "native/web/splice_game.cjs"), str(raw), str(page)]) + report["pages"].append({"name": name, "entry_sha256": hashlib.sha256(entry.read_bytes()).hexdigest(), + "game_wasm_sha256": hashlib.sha256(wasm).hexdigest(), "game_wasm_bytes": len(wasm), + "html_sha256": hashlib.sha256(page.read_bytes()).hexdigest(), + "engine_ffi_imports": sorted(item["name"] for item in imports if item["module"] == "ffi"), + "expected_startup_failure": fails}) + report["status"] = "pass" + print("PASS: compiled and spliced actual Perry startup and failure-control pages") + return 0 + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as exc: + report.update(status="fail", error=str(exc)) + print(f"FAIL: {exc}") + return 1 + finally: + save() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/ci/compiled_web_smoke.py b/tools/ci/compiled_web_smoke.py new file mode 100644 index 0000000..c7e093f --- /dev/null +++ b/tools/ci/compiled_web_smoke.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""CI acceptance for a real Perry-compiled game, its frame, cleanup and trap control.""" + +import argparse +import base64 +import hashlib +import http.server +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import threading +import time + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +from tools.ci.native_package_smoke import check_frame +from tools.ci.web_smoke import QuietHandler, DevTools, browser_path, devtools_target, free_local_port + +MONITOR = """ +(() => { +globalThis.__compiledGameErrors = []; +globalThis.__compiledGameLog = []; +const describe = (value) => String(value?.stack || value).slice(0, 16384); +const record = (value) => { if (__compiledGameErrors.length < 32) __compiledGameErrors.push(describe(value)); }; +const originalError = console.error; +console.error = (...args) => { record(args.map(describe).join(' ')); originalError.apply(console, args); }; +for (const level of ['log', 'warn']) { + const original = console[level]; + console[level] = (...args) => { + if (__compiledGameLog.length < 100) __compiledGameLog.push(level + ': ' + args.map(describe).join(' ')); + original.apply(console, args); + }; +} +addEventListener('error', (event) => record(event.error || event.message || 'script load error')); +addEventListener('unhandledrejection', (event) => record(event.reason)); +globalThis.__joltFactory = async () => { throw new Error('physics omitted in compiled render smoke'); }; +// Inject a startup trap at a real compiled-game FFI call. The original write +// records entry into the control before the exception crosses back into WASM. +let ffiImports; +Object.defineProperty(globalThis, '__ffiImports', { + configurable: true, + get: () => ffiImports, + set: (value) => { + const write = value.bloom_write_file; + if (typeof write === 'function') { + value.bloom_write_file = (...args) => { + const result = write(...args); + if (args[0] === 'compiled-web-expected-fault') { + throw new WebAssembly.RuntimeError('BLOOM_EXPECTED_STARTUP_FAILURE'); + } + return result; + }; + } + ffiImports = value; + }, +}); +if (globalThis.GPU) { + const request = GPU.prototype.requestAdapter; + GPU.prototype.requestAdapter = async function(...args) { + const adapter = await request.apply(this, args); + globalThis.__compiledGameAdapter = adapter; + return adapter; + }; +} +})(); +""" + + +def evaluate(devtools, expression): + result = devtools.call("Runtime.evaluate", {"expression": expression, "returnByValue": True, "awaitPromise": True}) + if result.get("exceptionDetails"): + raise RuntimeError(f"browser evaluation failed: {result['exceptionDetails']}") + return result.get("result", {}).get("value") + + +def validate_state(name, state): + if name == "game": + if state["errors"] or state["frames"] != "8" or state["cleanups"] != "1": + raise RuntimeError(f"compiled game failed startup/frame/cleanup acceptance: {state}") + elif state["expectedFault"] != "BLOOM_EXPECTED_STARTUP_FAILURE" or not any("RuntimeError: BLOOM_EXPECTED_STARTUP_FAILURE" in error for error in state["errors"]) or state["frames"] is not None or state["cleanups"] is not None: + raise RuntimeError(f"intentional compiled startup failure was not rejected: {state}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--browser") + parser.add_argument("--game", type=Path, default=ROOT / "target/ci/compiled-web-game") + parser.add_argument("--out", type=Path, default=ROOT / "target/ci/web-smoke/compiled-game") + parser.add_argument("--timeout", type=float, default=90) + args = parser.parse_args() + if args.timeout <= 0: + parser.error("timeout must be positive") + out = args.out.resolve() + out.mkdir(parents=True, exist_ok=True) + (out / "compiled-game.png").unlink(missing_ok=True) + report = {"schema": "bloom-compiled-web-smoke-v1", "status": "running", "cases": [], "failures": []} + def save(): + (out / "result.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + save() + process = devtools = server = thread = None + temp_parent = Path(tempfile.gettempdir()).resolve() + temporary = Path(tempfile.mkdtemp(prefix="bloom-compiled-web-", dir=temp_parent)).resolve() + try: + compiler_report = json.loads((args.game / "result.json").read_text(encoding="utf-8")) + if compiler_report.get("status") != "pass" or compiler_report.get("compiler_version") != "perry 0.5.1220": + raise RuntimeError("compiled-game artifact did not pass with the required Perry version") + current_source = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip() + if compiler_report.get("source_commit") != current_source: + raise RuntimeError("compiled game and browser engine checkout have different source commits") + report["compiled_game"] = compiler_report + engine_package = ROOT / "native/web/pkg" + engine_wasm = engine_package / "bloom_web_bg.wasm" + report["engine_wasm_sha256"] = hashlib.sha256(engine_wasm.read_bytes()).hexdigest() + site = temporary / "site" + site.mkdir() + shutil.copytree(engine_package, site / "pkg") + for name in ("bloom_glue.js", "game_loop.mjs", "jolt_bridge.js"): + shutil.copyfile(ROOT / "native/web" / name, site / name) + for name in ("game", "trap-control"): + page = args.game / f"{name}.html" + identity = next(p for p in compiler_report["pages"] if p["name"] == name) + if hashlib.sha256(page.read_bytes()).hexdigest() != identity["html_sha256"]: + raise RuntimeError(f"{name}: compiled page hash differs from its compiler receipt") + shutil.copyfile(page, site / page.name) + browser = browser_path(args.browser) + if browser is None: + raise RuntimeError("Chrome/Chromium is required for compiled-game acceptance") + handler = lambda *a, **kw: QuietHandler(*a, directory=str(site), **kw) + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + debug_port = free_local_port() + command = [browser, "--headless=new", "--no-sandbox", "--disable-dev-shm-usage", + "--disable-gpu-sandbox", "--enable-unsafe-webgpu", "--ignore-gpu-blocklist", + "--remote-allow-origins=*", f"--remote-debugging-port={debug_port}", + "--window-size=320,240", f"--user-data-dir={temporary / 'profile'}", "about:blank"] + if sys.platform.startswith("linux"): + command[1:1] = ["--use-webgpu-adapter=swiftshader", "--use-gpu-in-tests", "--enable-accelerated-2d-canvas"] + report["browser_command"] = command + with (out / "browser.stdout.log").open("wb") as stdout, (out / "browser.stderr.log").open("wb") as stderr: + process = subprocess.Popen(command, stdout=stdout, stderr=stderr) + target = devtools_target(debug_port, "about:blank", time.monotonic() + args.timeout) + if target is None: + raise RuntimeError("browser did not expose the owned test page") + devtools = DevTools(target) + devtools.call("Page.enable") + devtools.call("Runtime.enable") + devtools.call("Emulation.setDeviceMetricsOverride", {"width": 128, "height": 128, "deviceScaleFactor": 1, "mobile": False}) + devtools.call("Page.addScriptToEvaluateOnNewDocument", {"source": MONITOR}) + for name in ("game", "trap-control"): + if name != "game": + evaluate(devtools, "localStorage.clear()") + url = f"http://127.0.0.1:{server.server_port}/{name}.html" + devtools.call("Page.navigate", {"url": url}) + started = time.monotonic() + state = None + while time.monotonic() - started < args.timeout: + state = evaluate(devtools, "(() => { if (location.href !== " + json.dumps(url) + + " || !Array.isArray(globalThis.__compiledGameErrors)) return null; return ({" + """ + frames: localStorage.getItem('bloom_fs:compiled-web-frames'), + cleanups: localStorage.getItem('bloom_fs:compiled-web-cleanups'), + expectedFault: localStorage.getItem('bloom_fs:compiled-web-expected-fault'), + errors: globalThis.__compiledGameErrors || [], + logs: globalThis.__compiledGameLog || [], + loading: document.getElementById('loading')?.textContent || null, + rootText: document.getElementById('perry-root')?.textContent?.slice(0, 4096) || null, + ffiCount: Object.keys(globalThis.__ffiImports || {}).length, + }); })()""") + if isinstance(state, dict) and (state["errors"] or state["cleanups"] is not None): + break + time.sleep(0.1) + if not isinstance(state, dict): + raise RuntimeError(f"{name}: no game state before timeout") + case = {"name": name, "state": state, "duration_seconds": round(time.monotonic() - started, 3)} + report["cases"].append(case) + save() + validate_state(name, state) + if name == "game": + evaluate(devtools, "new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))") + capture = devtools.call("Page.captureScreenshot", {"format": "png", "fromSurface": True}) + frame = out / "compiled-game.png" + frame.write_bytes(base64.b64decode(capture["data"])) + case["frame"] = check_frame(frame) + case["adapter"] = evaluate(devtools, "(() => { const i = globalThis.__compiledGameAdapter?.info; return i ? { vendor:i.vendor, architecture:i.architecture, device:i.device, description:i.description } : null; })()") + case["status"] = "pass" + save() + report["status"] = "pass" + print("PASS: actual Perry game rendered its exact frame and cleaned up; startup trap control rejected") + return 0 + except (OSError, ValueError, RuntimeError, KeyError, TypeError, StopIteration, subprocess.SubprocessError) as exc: + report["status"] = "fail" + report["failures"].append(str(exc)) + if devtools is not None: + try: + capture = devtools.call("Page.captureScreenshot", {"format": "png", "fromSurface": True}) + (out / "failure.png").write_bytes(base64.b64decode(capture["data"])) + except (OSError, ValueError, RuntimeError, KeyError) as capture_error: + report["failure_capture_error"] = str(capture_error) + print(f"FAIL: {exc}") + return 1 + finally: + if devtools is not None: + devtools.close() + if process is not None: + process.terminate() + try: process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + if server is not None: + server.shutdown() + server.server_close() + if thread is not None: + thread.join(timeout=2) + save() + if temporary.parent != temp_parent or temporary.is_symlink() or temporary.is_junction(): + raise RuntimeError(f"refusing cleanup outside owned temporary directory: {temporary}") + shutil.rmtree(temporary) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/ci/fixtures/compiled-web.ts b/tools/ci/fixtures/compiled-web.ts new file mode 100644 index 0000000..24e1819 --- /dev/null +++ b/tools/ci/fixtures/compiled-web.ts @@ -0,0 +1,31 @@ +import { + initWindow, runGame, clearBackground, closeWindow, setDirect2DMode, + setTargetFPS, writeFile, +} from "@bloomengine/engine/core"; +import { drawRect } from "@bloomengine/engine/shapes"; + +const BLOOM_SMOKE_FAIL_STARTUP = false; +if (BLOOM_SMOKE_FAIL_STARTUP) { + // The browser acceptance monitor records this real FFI call and injects a + // WebAssembly.RuntimeError across its return boundary. This is a fault + // injection control; the normal game never writes this marker. + writeFile("compiled-web-expected-fault", "BLOOM_EXPECTED_STARTUP_FAILURE"); +} + +initWindow(128, 128, "Bloom compiled web startup"); +setTargetFPS(60); +setDirect2DMode(true); +let frames = 0; +let cleanups = 0; +runGame((_dt) => { + clearBackground({ r: 0, g: 0, b: 0, a: 255 }); + drawRect(32, 32, 64, 64, { r: 255, g: 255, b: 255, a: 255 }); + frames = frames + 1; + if (frames === 8) { + writeFile("compiled-web-frames", frames.toString()); + closeWindow(); + } +}, () => { + cleanups = cleanups + 1; + writeFile("compiled-web-cleanups", cleanups.toString()); +}); diff --git a/tools/ci/test_compiled_web_smoke.py b/tools/ci/test_compiled_web_smoke.py new file mode 100644 index 0000000..853d543 --- /dev/null +++ b/tools/ci/test_compiled_web_smoke.py @@ -0,0 +1,34 @@ +import unittest + +from tools.ci.compiled_web_smoke import validate_state +from tools.ci.compile_web_game import validate_compilation + + +class CompiledGameAcceptanceTests(unittest.TestCase): + def test_unresolved_imports_and_empty_game_cannot_pass_compilation(self): + imports = [{"module": "ffi", "name": name} for name in + ["bloom_init_window", "bloom_set_target_fps", "bloom_set_direct_2d_mode", + "bloom_run_game_with_cleanup", "bloom_write_file", "bloom_draw_rect"]] + validate_compilation("Generating WebAssembly", imports) + with self.assertRaisesRegex(RuntimeError, "could not resolve"): + validate_compilation("Warning: Could not resolve import '@bloomengine/engine/core'", imports) + with self.assertRaisesRegex(RuntimeError, "missing required"): + validate_compilation("Generating WebAssembly", []) + + def test_success_requires_render_progress_and_exactly_one_cleanup(self): + state = {"frames": "8", "cleanups": "1", "expectedFault": None, "errors": []} + validate_state("game", state) + for change in ({"frames": None}, {"cleanups": None}, {"cleanups": "2"}, {"errors": ["WASM Error: unreachable"]}): + with self.subTest(change=change), self.assertRaises(RuntimeError): + validate_state("game", {**state, **change}) + + def test_failure_control_requires_entering_its_fault_and_an_actual_error(self): + state = {"frames": None, "cleanups": None, "expectedFault": "BLOOM_EXPECTED_STARTUP_FAILURE", "errors": ["RuntimeError: BLOOM_EXPECTED_STARTUP_FAILURE"]} + validate_state("trap-control", state) + for change in ({"expectedFault": None}, {"errors": []}, {"errors": ["unrelated error"]}, {"frames": "8"}, {"cleanups": "1"}): + with self.subTest(change=change), self.assertRaises(RuntimeError): + validate_state("trap-control", {**state, **change}) + + +if __name__ == "__main__": + unittest.main()