From 8ad86d8caa688f7598e55e5aa70efe65dd34044d Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Fri, 11 Sep 2026 08:49:22 +0200 Subject: [PATCH 1/3] Fix named void callbacks and gate the complete installed web starter --- .github/workflows/test.yml | 31 ++- docs/evidence/installed-starter-browser-v1.md | 58 ++++++ docs/starter.md | 6 + docs/windows-engine-plan.md | 7 +- native/web/splice_game.cjs | 17 +- scripts/ci-check.sh | 7 + tools/ci/fixed_step_smoke.py | 4 +- tools/ci/fixtures/fixed-step.ts | 13 ++ tools/ci/perry_wasm_console.cjs | 2 + tools/ci/setup_windows_wasm_pack.py | 53 ++++++ tools/ci/starter_browser_smoke.py | 177 ++++++++++++++++++ tools/ci/starter_monitor.js | 81 ++++++++ tools/ci/starter_package_smoke.py | 60 +++++- tools/ci/starter_web_run.py | 74 ++++++++ tools/ci/test_fixed_step_smoke.py | 2 +- tools/ci/test_starter_browser_smoke.py | 43 +++++ tools/ci/test_web_build.cjs | 20 +- 17 files changed, 646 insertions(+), 9 deletions(-) create mode 100644 docs/evidence/installed-starter-browser-v1.md create mode 100644 tools/ci/setup_windows_wasm_pack.py create mode 100644 tools/ci/starter_browser_smoke.py create mode 100644 tools/ci/starter_monitor.js create mode 100644 tools/ci/starter_web_run.py create mode 100644 tools/ci/test_starter_browser_smoke.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c8ba8bb..dbf6d26 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -282,7 +282,8 @@ jobs: ~/.cargo/registry ~/.cargo/git native/windows/target - key: ${{ runner.os }}-windows-crate-${{ hashFiles('native/windows/Cargo.lock') }} + native/web/target + key: ${{ runner.os }}-windows-crate-web-${{ hashFiles('native/windows/Cargo.lock', 'native/web/Cargo.lock') }} restore-keys: ${{ runner.os }}-windows-crate- - name: full / host-build @@ -338,6 +339,28 @@ jobs: if-no-files-found: error retention-days: 1 + - name: Prepare pinned Windows web build tool + shell: pwsh + run: | + python tools/ci/setup_windows_wasm_pack.py --out "$env:RUNNER_TEMP/bloom-wasm-pack" --github-env + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + rustup target add wasm32-unknown-unknown + + - name: Windows / installed starter web run + shell: pwsh + env: + CARGO_BUILD_JOBS: '2' + run: python tools/ci/starter_package_smoke.py --web --out target/ci/starter-web + + - name: Retain complete installed starter website + if: always() + uses: actions/upload-artifact@v4 + with: + name: bloom-starter-web-${{ github.run_id }} + path: target/ci/starter-web + if-no-files-found: error + retention-days: 1 + - name: Windows / installed native startup shell: pwsh env: @@ -523,6 +546,12 @@ jobs: name: bloom-compiled-web-game-${{ github.run_id }} path: target/ci/compiled-web-game + - name: Download installed starter website + uses: actions/download-artifact@v4 + with: + name: bloom-starter-web-${{ github.run_id }} + path: target/ci/starter-web + - name: web / browser-smoke run: ./scripts/ci-check.sh --web --component browser-smoke diff --git a/docs/evidence/installed-starter-browser-v1.md b/docs/evidence/installed-starter-browser-v1.md new file mode 100644 index 0000000..7ace764 --- /dev/null +++ b/docs/evidence/installed-starter-browser-v1.md @@ -0,0 +1,58 @@ +# Complete installed starter browser acceptance + +The minimal compiled-game fixture verifies rendering and lifecycle hooks, but +does not exercise the generated starter's asset read, named function callbacks +or text rendering. The new gate creates a project through the installed CLI, +then runs the installed `bloom run --web` command against its unchanged source. +The command compiles game and engine WASM, writes the asset manifest and starts +its development server. The preparer checks served HTML, manifest, asset and +engine bytes, including the WASM MIME type, then stops its owned process tree. +The complete generated website and every file's SHA-256 are retained. + +Windows CI uses the existing qualified Perry 0.5.1220 profile and a pinned +[official wasm-pack 0.15.0 archive](https://github.com/wasm-bindgen/wasm-pack/releases/tag/v0.15.0). +Archive SHA-256 is checked before selecting the executable from the tar file; +the tool's version and executable hash are recorded. The build adds no test +markers or alternate logic to the starter source. + +Hosted macOS Chrome loads this exact website through its production bootstrap. +The monitor observes actual asset fetch/read, text and rectangle FFI calls and +the real Perry callback dispatcher. It requests normal engine stop after eight +successful frames and requires exactly one cleanup. The screenshot must contain +the 800x450 viewport, the square's white interior at its actual interpolated +position, a populated text region and the expected black background. Text +coverage allows glyph antialiasing differences; the real draw call must contain +the exact text read from `assets/welcome.txt`. The existing startup-fault gate +remains required. This new gate does not replace the game's draw calls or load +a fake physics factory. + +## Failure found by executing the full starter + +The first real generated WASM run fails during named `init` callback dispatch: +Perry exports that void function without a WASM result, but its closure bridge +passes the returned JavaScript `undefined` into an i64 decoder. The decoder then +raises `Cannot mix BigInt and other types`. Native starter rendering passed; +compilation-only web evidence did not expose this startup failure. The raw +failed probe and the observed `undefined` return are retained. + +Bloom's production splicer now installs a compatibility bridge that preserves +an actual void return and delegates every boxed value to the existing decoder. +It does not suppress other decoder exceptions. Actual native/WASM lifecycle +checks now cover named functions for all five hooks. A recording-FFI probe runs +the real compiled starter and production scheduler, reads the asset text, draws +eight frames and cleans up once with the correction. That probe does not load +the engine renderer or claim browser/network acceptance. + +The corrected installed `bloom run --web` command passes locally in 64.531 +seconds, including game/engine compilation, serving the four required resources +with exact file bytes and the correct WASM MIME type, and controlled shutdown. +The resulting 11-file website includes the actual production compatibility +bridge. Running that exact generated boot script with the recording FFI also +passes all starter callbacks and one cleanup. Ten Python acceptance-control +tests and the repository contracts pass; the text/image verifier accepts the +previous actual native starter capture. Hosted browser rendering is pending. + +General Perry exception propagation, browser physics acceptance, the complete +canonical runtime matrix, visible native presentation and clean distribution +packaging remain separate. A browser warning about optional physics startup is +retained as observed; the starter itself does not exercise physics. diff --git a/docs/starter.md b/docs/starter.md index fd5a9b3..cab1300 100644 --- a/docs/starter.md +++ b/docs/starter.md @@ -81,3 +81,9 @@ The fixed-lifecycle follow-up repeats fresh installed default creation, native build and the bounded greeting/asset/cleanup run using the revised template, then completes its full web build. See [the lifecycle evidence](evidence/fixed-game-lifecycle-v1.md). The browser runtime still needs to qualify this complete starter. + +The [complete starter browser gate](evidence/installed-starter-browser-v1.md) +now runs the installed web command and retains its unchanged generated website +for hosted rendering, including the actual asset read, text draw and cleanup. +It also covers the production bridge for named void callbacks in Perry WASM. +Its hosted result is pending. diff --git a/docs/windows-engine-plan.md b/docs/windows-engine-plan.md index d550910..13828f7 100644 --- a/docs/windows-engine-plan.md +++ b/docs/windows-engine-plan.md @@ -19,7 +19,7 @@ first nine-scene Radeon evidence are in draft PR #154. Follow-up work starts at | #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. 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; the serial follow-up and #171 each pass all 22 hosted Tests jobs. The driver root cause remains open | | #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–#171 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–#172 remain drafts; no merge performed | ## Engine work retained in scope @@ -61,6 +61,11 @@ audit are saved in `tools/quality/out/windows-engine-plan/plan-requirements.json pass default project creation and setup-error controls in hosted CI. The full starter's assets/text browser rendering and canonical example runtime matrix remain open. All 20 native examples compile and link. + The [complete starter browser candidate](evidence/installed-starter-browser-v1.md) + now runs the installed web command, verifies its served files and retains the + unchanged site for hosted asset/text/render acceptance. Executing its real WASM + found a named-void-callback return conversion failure; the production bootstrap + bridge and expanded named-hook contracts pass locally. Hosted rendering is pending. 2. **Finish and qualify fixed lifecycle integration.** The [fixed lifecycle candidate](evidence/fixed-game-lifecycle-v1.md) passes pure native/WASM timing and hook-order contracts, plus exact installed rendering diff --git a/native/web/splice_game.cjs b/native/web/splice_game.cjs index b9095fb..281cfcc 100644 --- a/native/web/splice_game.cjs +++ b/native/web/splice_game.cjs @@ -27,6 +27,19 @@ const BLOOM_SHELL = ` `; +function installPerryVoidReturnCompatibility(runtime) { + // Perry 0.5.1220 exports named void functions with no WASM result, while + // its closure bridge always decodes the return as a NaN-boxed i64. Preserve + // an actual void result; keep the original decoder for every boxed value. + const decode = runtime.__bitsToJsValue; + if (typeof decode !== "function") { + throw new Error("Perry runtime has no value decoder; use the supported compiler version."); + } + runtime.__bitsToJsValue = function(bits) { + return bits === undefined ? undefined : decode(bits); + }; +} + function splice(html) { if (!html.includes(PERRY_ROOT)) { throw new Error("could not find perry-root in Perry HTML; compiler output format may have changed"); @@ -43,10 +56,10 @@ function splice(html) { } tail = tail.replace(BOOT_CALL, 'window.__bloomReady.then(() => bootPerryWasm("'); tail = tail.replace(BOOT_CATCH, '\")).catch('); - return head + tail; + return head + `(${installPerryVoidReturnCompatibility.toString()})(globalThis);\n` + tail; } -module.exports = { splice }; +module.exports = { splice, installPerryVoidReturnCompatibility }; if (require.main === module) { try { if (process.argv.length !== 4) throw new Error("Usage: splice_game.cjs "); diff --git a/scripts/ci-check.sh b/scripts/ci-check.sh index 3c7e458..1e3e198 100755 --- a/scripts/ci-check.sh +++ b/scripts/ci-check.sh @@ -289,6 +289,11 @@ run_component() { tools/ci/test_compiled_web_smoke.py \ tools/ci/fixed_step_smoke.py \ tools/ci/test_fixed_step_smoke.py \ + tools/ci/starter_package_smoke.py \ + tools/ci/starter_web_run.py \ + tools/ci/starter_browser_smoke.py \ + tools/ci/test_starter_browser_smoke.py \ + tools/ci/setup_windows_wasm_pack.py \ tools/ci/test_compile_examples.py "$python_cmd" -m unittest \ tools/quality/test_run.py \ @@ -303,6 +308,7 @@ run_component() { tools/ci/test_compile_examples.py \ tools/ci/test_compiled_web_smoke.py \ tools/ci/test_fixed_step_smoke.py \ + tools/ci/test_starter_browser_smoke.py \ -v hr "visual metric and fault-engine tests" cargo test --release --manifest-path tools/bloom-diff/Cargo.toml @@ -340,6 +346,7 @@ run_component() { "$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 + "$python_cmd" tools/ci/starter_browser_smoke.py ;; target-check) cross_crate="${BLOOM_CROSS_CRATE:-}" diff --git a/tools/ci/fixed_step_smoke.py b/tools/ci/fixed_step_smoke.py index 4e02919..7cf1ec9 100644 --- a/tools/ci/fixed_step_smoke.py +++ b/tools/ci/fixed_step_smoke.py @@ -24,6 +24,7 @@ "overflowRejected": True, "hugeAlpha": 0.9, "tinyBounded": True, "finiteContract": True, "sparseStarted": True, "sparseDraws": 1, "updateStopEvents": "UC", "invalidDriverRejected": True, "invalidCalls": 0, + "namedEvents": "IFUDC", } @@ -95,7 +96,8 @@ def run(name, command): report["source_sha256"] = { name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in ("src/core/fixed_step.ts", "src/core/game_lifecycle.ts", "src/core/numbers.ts", - "tools/ci/fixtures/fixed-step.ts", "tools/ci/perry_wasm_console.cjs") + "tools/ci/fixtures/fixed-step.ts", "tools/ci/perry_wasm_console.cjs", + "native/web/splice_game.cjs") } for mode in ("native", "wasm"): artifact = out / ("fixed-step.html" if mode == "wasm" else "fixed-step.exe" if os.name == "nt" else "fixed-step") diff --git a/tools/ci/fixtures/fixed-step.ts b/tools/ci/fixtures/fixed-step.ts index 1c2df18..b02e775 100644 --- a/tools/ci/fixtures/fixed-step.ts +++ b/tools/ci/fixtures/fixed-step.ts @@ -104,6 +104,18 @@ const invalidDriverRejected = !invalidDriver.initialize(); invalidDriver.frame(0.01, () => false); invalidDriver.dispose(); +let namedEvents = ''; +function namedInit(): void { namedEvents = namedEvents + 'I'; } +function namedFixed(_dt: number, _tick: number): void { namedEvents = namedEvents + 'F'; } +function namedUpdate(_dt: number): void { namedEvents = namedEvents + 'U'; } +function namedDraw(_alpha: number): void { namedEvents = namedEvents + 'D'; } +function namedCleanup(): void { namedEvents = namedEvents + 'C'; } +const namedDriver = new GameLifecycleDriver({ init: namedInit, fixedUpdate: namedFixed, + update: namedUpdate, draw: namedDraw, cleanup: namedCleanup }); +namedDriver.initialize(); +namedDriver.frame(1 / 60, () => false); +namedDriver.dispose(); + // Read each observation directly. The original object-literal JSON.stringify // report returned undefined in Perry WASM; preserve that separately from the // timing/lifecycle contract. Events contain only this fixture's fixed ASCII tags. @@ -142,4 +154,5 @@ result = result + ",\"sparseDraws\":" + sparseDraws; result = result + ",\"updateStopEvents\":\"" + updateStopEvents + "\""; result = result + ",\"invalidDriverRejected\":" + invalidDriverRejected; result = result + ",\"invalidCalls\":" + invalidCalls; +result = result + ",\"namedEvents\":\"" + namedEvents + "\""; console.log("BLOOM_FIXED_STEP_RESULT:" + result + "}"); diff --git a/tools/ci/perry_wasm_console.cjs b/tools/ci/perry_wasm_console.cjs index d12b639..7b250e0 100644 --- a/tools/ci/perry_wasm_console.cjs +++ b/tools/ci/perry_wasm_console.cjs @@ -5,6 +5,7 @@ // renderer, GPU, network or FFI stubs participate in the contract result. const fs = require("node:fs"); const vm = require("node:vm"); +const { installPerryVoidReturnCompatibility } = require("../../native/web/splice_game.cjs"); async function main() { if (process.argv.length !== 3) throw new Error("Usage: perry_wasm_console.cjs "); @@ -36,6 +37,7 @@ async function main() { context.window = context; context.self = context; vm.runInContext(scripts[0][1], context, { timeout: 5000 }); + vm.runInContext(`(${installPerryVoidReturnCompatibility.toString()})(globalThis);`, context, { timeout: 5000 }); const boot = context.bootPerryWasm; if (typeof boot !== "function") throw new Error("Perry runtime has no boot entry"); let completion; diff --git a/tools/ci/setup_windows_wasm_pack.py b/tools/ci/setup_windows_wasm_pack.py new file mode 100644 index 0000000..357dbfe --- /dev/null +++ b/tools/ci/setup_windows_wasm_pack.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Prepare the pinned official Windows wasm-pack binary for the starter gate.""" + +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import subprocess +import tarfile +import urllib.request + +VERSION = '0.15.0' +ARCHIVE = f'wasm-pack-v{VERSION}-x86_64-pc-windows-msvc.tar.gz' +URL = f'https://github.com/wasm-bindgen/wasm-pack/releases/download/v{VERSION}/{ARCHIVE}' +SHA256 = '518dc51180c7bc864699c9279b3bc99025bc109123e0249a34ba34d130a509bf' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--out', type=Path, required=True) + parser.add_argument('--github-env', action='store_true') + args = parser.parse_args() + out = args.out.resolve() + out.mkdir(parents=True, exist_ok=True) + archive = out / ARCHIVE + if not archive.exists() or hashlib.sha256(archive.read_bytes()).hexdigest() != SHA256: + with urllib.request.urlopen(URL, timeout=120) as response: + data = response.read() + if hashlib.sha256(data).hexdigest() != SHA256: + raise RuntimeError('official wasm-pack archive SHA-256 differs from the pinned release') + archive.write_bytes(data) + with tarfile.open(fileobj=io.BytesIO(archive.read_bytes()), mode='r:gz') as tar: + files = [member for member in tar.getmembers() if member.isfile() and Path(member.name).name == 'wasm-pack.exe'] + if len(files) != 1: + raise RuntimeError('official wasm-pack archive must contain exactly one executable') + data = tar.extractfile(files[0]).read() + executable = out / 'wasm-pack.exe' + executable.write_bytes(data) + version = subprocess.check_output([str(executable), '--version'], text=True).strip() + if version != f'wasm-pack {VERSION}': + raise RuntimeError(f'unexpected wasm-pack version: {version}') + receipt = dict(url=URL, archive_sha256=SHA256, executable_sha256=hashlib.sha256(data).hexdigest(), version=version) + (out / 'toolchain.json').write_text(json.dumps(receipt, indent=2) + '\n', encoding='utf-8') + if args.github_env: + with open(os.environ['GITHUB_ENV'], 'a', encoding='utf-8') as output: + output.write(f'BLOOM_WASM_PACK={executable}\n') + print(json.dumps(receipt)) + + +if __name__ == '__main__': + main() diff --git a/tools/ci/starter_browser_smoke.py b/tools/ci/starter_browser_smoke.py new file mode 100644 index 0000000..6f10cc7 --- /dev/null +++ b/tools/ci/starter_browser_smoke.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Render the complete, unchanged installed starter website in hosted Chrome.""" + +import argparse +import base64 +import hashlib +import http.server +import json +import math +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.compiled_web_smoke import evaluate +from tools.ci.web_smoke import QuietHandler, DevTools, browser_path, devtools_target, free_local_port +from tools.quality.khronos_materials import png_rgb + + +def validate_state(state): + if not isinstance(state, dict) or state.get('errors') or state.get('stopped') is not True: + raise RuntimeError(f'starter failed or did not stop normally: {state}') + for name, expected in [('frames', 8), ('cleanups', 1), ('registrations', 1)]: + if type(state.get(name)) is not int or state[name] != expected: + raise RuntimeError(f'starter {name} must be {expected}') + reads = state.get('reads', []) + if len(reads) != 1 or reads[0].get('path') != 'assets/welcome.txt' or reads[0].get('value', '').strip() != 'Hello, Bloom!': + raise RuntimeError('starter did not read the actual welcome asset') + if not any(item.get('path', '').endswith('/assets/welcome.txt') and item.get('status') == 200 for item in state.get('fetches', [])): + raise RuntimeError('starter did not fetch its welcome asset successfully') + texts, rects = state.get('texts', []), state.get('rects', []) + if len(texts) != 8 or len(rects) != 8: + raise RuntimeError('each starter frame must draw its text and square') + for text in texts: + if len(text) != 8 or text[0].strip() != 'Hello, Bloom!' or text[1:] != [24, 24, 24, 255, 255, 255, 255]: + raise RuntimeError('starter text draw differs from the shipped template') + for rect in rects: + if len(rect) != 8 or not isinstance(rect[0], (int, float)) or not math.isfinite(rect[0]) or not 288 <= rect[0] <= 448 or rect[1:] != [193, 64, 64, 255, 255, 255, 255]: + raise RuntimeError('starter square draw differs from the shipped template') + return rects[-1] + + +def check_pixels(width, height, pixels, rect): + if (width, height) != (800, 450) or len(pixels) != 800 * 450: + raise RuntimeError('starter capture must contain the complete 800x450 viewport') + x = rect[0] + text_pixels = 0 + for index, pixel in enumerate(pixels): + px, py = index % width, index // width + text_region = 24 <= px < 250 and 24 <= py < 56 + square_region = math.floor(x) - 1 <= px <= math.ceil(x + 64) + 1 and 192 <= py <= 258 + if math.ceil(x) + 1 <= px < math.floor(x + 64) - 1 and 194 <= py < 256: + if pixel != (255, 255, 255): + raise RuntimeError('starter square interior is missing or incorrect') + elif text_region: + if max(pixel) >= 32: text_pixels += 1 + elif not square_region and pixel != (0, 0, 0): + raise RuntimeError('starter background contains unexpected content') + if not 100 <= text_pixels <= 3000: + raise RuntimeError(f'starter text raster is missing or filled: {text_pixels} lit pixels') + return dict(width=width, height=height, text_pixels=text_pixels, square_x=x) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--browser') + parser.add_argument('--game', type=Path, default=ROOT / 'target/ci/starter-web') + parser.add_argument('--out', type=Path, default=ROOT / 'target/ci/web-smoke/starter') + parser.add_argument('--timeout', type=float, default=90) + args = parser.parse_args() + if not math.isfinite(args.timeout) or args.timeout <= 0: parser.error('timeout must be finite and positive') + out = args.out.resolve() + out.mkdir(parents=True, exist_ok=True) + report = dict(schema='bloom-starter-browser-v1', status='running', failures=[]) + def save(): (out / 'result.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + process = devtools = server = thread = None + parent = Path(tempfile.gettempdir()).resolve() + temporary = Path(tempfile.mkdtemp(prefix='bloom-starter-browser-', dir=parent)).resolve() + try: + prepared = json.loads((args.game / 'result.json').read_text(encoding='utf-8')) + head = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip() + if prepared.get('status') != 'pass' or prepared.get('source_commit') != head or not prepared.get('web', {}).get('source_unchanged'): + raise RuntimeError('starter artifact did not qualify the unchanged source at this checkout') + if prepared.get('web_command', {}).get('status') != 'pass': + raise RuntimeError('installed bloom run --web did not qualify its served website') + site = (args.game / 'site').resolve() + actual = {} + for current, dirs, files in os.walk(site, followlinks=False): + directory = Path(current) + if any((directory / name).is_symlink() or (directory / name).is_junction() for name in dirs + files): + raise RuntimeError('starter artifact contains an unexpected filesystem link') + for name in files: + file = directory / name + actual[file.relative_to(site).as_posix()] = hashlib.sha256(file.read_bytes()).hexdigest() + if actual != prepared['web']['files']: raise RuntimeError('starter website differs from its build receipt') + report['prepared'] = prepared + browser = browser_path(args.browser) + if browser is None: raise RuntimeError('Chrome/Chromium is required for full starter 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=900,600', f'--user-data-dir={temporary / "profile"}', 'about:blank'] + 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 starter page') + devtools = DevTools(target) + devtools.call('Page.enable') + devtools.call('Runtime.enable') + devtools.call('Emulation.setDeviceMetricsOverride', dict(width=800, height=450, deviceScaleFactor=1, mobile=False)) + devtools.call('Page.addScriptToEvaluateOnNewDocument', {'source': (ROOT / 'tools/ci/starter_monitor.js').read_text()}) + url = f'http://127.0.0.1:{server.server_port}/' + devtools.call('Page.navigate', {'url': url}) + started = time.monotonic() + state = None + while time.monotonic() - started < args.timeout: + state = evaluate(devtools, f'location.href === {json.dumps(url)} ? globalThis.__starterProbe || null : null') + if isinstance(state, dict) and (state.get('errors') or state.get('cleanups')): break + time.sleep(0.1) + report['state'] = state + report['duration_seconds'] = round(time.monotonic() - started, 3) + save() + rect = validate_state(state) + evaluate(devtools, 'new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))') + final_state = evaluate(devtools, 'globalThis.__starterProbe') + validate_state(final_state) + report['state'] = final_state + capture = devtools.call('Page.captureScreenshot', {'format': 'png', 'fromSurface': True}) + frame = out / 'starter.png' + frame.write_bytes(base64.b64decode(capture['data'])) + report['frame'] = {**check_pixels(*png_rgb(frame), rect), 'sha256': hashlib.sha256(frame.read_bytes()).hexdigest()} + report['adapter'] = evaluate(devtools, '(() => { const i = globalThis.__starterAdapter?.info; return i ? {vendor:i.vendor,architecture:i.architecture,device:i.device,description:i.description} : null; })()') + report['status'] = 'pass' + print('PASS: unchanged installed starter reads its asset, renders text/square, and cleans up once in Chrome') + return 0 + except (OSError, ValueError, RuntimeError, KeyError, TypeError, subprocess.SubprocessError) as error: + report['status'] = 'fail' + report['failures'].append(str(error)) + 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: {error}') + 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 != parent or temporary.is_symlink() or temporary.is_junction(): + raise RuntimeError('refusing cleanup outside owned starter browser profile') + shutil.rmtree(temporary) + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/ci/starter_monitor.js b/tools/ci/starter_monitor.js new file mode 100644 index 0000000..f37d8b9 --- /dev/null +++ b/tools/ci/starter_monitor.js @@ -0,0 +1,81 @@ +// Observe the unmodified starter through its real FFI and callback boundaries. +// The sole control action is a normal engine stop after eight successful frames. +(() => { + const state = globalThis.__starterProbe = { + frames: 0, cleanups: 0, registrations: 0, reads: [], texts: [], rects: [], + errors: [], logs: [], fetches: [], stopped: false, + }; + const describe = value => String(value?.stack || value).slice(0, 16384); + const error = value => { if (state.errors.length < 32) state.errors.push(describe(value)); }; + for (const level of ['error', 'warn', 'log']) { + const original = console[level]; + console[level] = (...args) => { + if (level === 'error') error(args.map(describe).join(' ')); + else if (state.logs.length < 100) state.logs.push(level + ': ' + args.map(describe).join(' ')); + original.apply(console, args); + }; + } + addEventListener('error', event => error(event.error || event.message)); + addEventListener('unhandledrejection', event => error(event.reason)); + const fetchOriginal = globalThis.fetch; + globalThis.fetch = async (...args) => { + const response = await fetchOriginal(...args); + const url = new URL(args[0]?.url || String(args[0]), location.href); + if (url.origin === location.origin && ['assets_manifest.json', 'assets/welcome.txt'].some(p => url.pathname.endsWith('/' + p))) { + state.fetches.push({ path: url.pathname, status: response.status }); + } + return response; + }; + if (globalThis.GPU) { + const request = GPU.prototype.requestAdapter; + GPU.prototype.requestAdapter = async function(...args) { + const adapter = await request.apply(this, args); + globalThis.__starterAdapter = adapter; + return adapter; + }; + } + let ffi; + Object.defineProperty(globalThis, '__ffiImports', { + configurable: true, + get: () => ffi, + set: value => { + const originalRead = value.bloom_read_file; + value.bloom_read_file = (...args) => { + const result = originalRead(...args); + if (state.reads.length < 32) state.reads.push({ path: String(args[0]), value: String(result).slice(0, 4096) }); + return result; + }; + for (const [name, destination] of [['bloom_draw_text', 'texts'], ['bloom_draw_rect', 'rects']]) { + const original = value[name]; + value[name] = (...args) => { + const result = original(...args); + if (state[destination].length < 120) state[destination].push(args.map(v => typeof v === 'bigint' ? Number(v) : v)); + return result; + }; + } + let updateHandle, cleanupHandle; + const register = value.bloom_run_game_with_cleanup; + value.bloom_run_game_with_cleanup = (update, cleanup) => { + state.registrations++; + updateHandle = update; + cleanupHandle = cleanup; + return register(update, cleanup); + }; + const call = globalThis.callWasmClosure; + if (typeof call !== 'function') throw new Error('Starter monitor requires the real Perry closure dispatcher'); + globalThis.callWasmClosure = (callback, ...args) => { + if (callback === cleanupHandle) state.cleanups++; + const result = call(callback, ...args); + if (callback === updateHandle) { + state.frames++; + if (state.frames === 8) { + state.stopped = true; + value.bloom_close_window(); + } + } + return result; + }; + ffi = value; + }, + }); +})(); diff --git a/tools/ci/starter_package_smoke.py b/tools/ci/starter_package_smoke.py index 02a4d31..0e053f5 100644 --- a/tools/ci/starter_package_smoke.py +++ b/tools/ci/starter_package_smoke.py @@ -2,9 +2,11 @@ """Require the packed starter CLI to create a project with the exact engine package.""" import argparse +import base64 import hashlib import json import os +import re from pathlib import Path import shutil import subprocess @@ -15,24 +17,26 @@ ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from tools.ci.native_package_smoke import npm_command +from tools.ci.starter_web_run import run_web def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--out", type=Path, default=ROOT / "target/ci/installed-starter") + parser.add_argument("--web", action="store_true", help="Build and retain the full installed starter website") args = parser.parse_args() out = args.out.resolve() out.mkdir(parents=True, exist_ok=True) report = {"schema": "bloom-installed-starter-v1", "status": "running", "commands": []} def save(): (out / "result.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") - def run(name, command, cwd, env=None, expected_error=None): + def run(name, command, cwd, env=None, expected_error=None, timeout=240): record = {"name": name, "command": command, "cwd": str(cwd)} report["commands"].append(record) save() start = time.monotonic() with (out / f"{name}.stdout.log").open("wb") as stdout, (out / f"{name}.stderr.log").open("wb") as stderr: - result = subprocess.run(command, cwd=cwd, env=env, stdout=stdout, stderr=stderr, timeout=240) + result = subprocess.run(command, cwd=cwd, env=env, stdout=stdout, stderr=stderr, timeout=timeout) record.update(exit_code=result.returncode, duration_seconds=round(time.monotonic() - start, 3)) save() stdout = (out / f"{name}.stdout.log").read_text(encoding="utf-8", errors="replace") @@ -67,6 +71,9 @@ def run(name, command, cwd, env=None, expected_error=None): source_files = ["tools/cli/bloom.cjs", "tools/cli/toolchain.cjs", "tools/cli/serve.cjs", "tools/cli/templates/main.ts", "tools/cli/templates/README.md", "tools/ci/setup_windows_perry.py"] + if args.web: + source_files += ['native/web/splice_game.cjs', 'native/web/game_loop.mjs', + 'src/core/index.ts', 'src/core/game_lifecycle.ts', 'src/core/fixed_step.ts'] report["installed_source_sha256"] = {} for name in source_files: # npm normalizes executable shebang line endings on Windows. @@ -82,6 +89,55 @@ def run(name, command, cwd, env=None, expected_error=None): run("unsupported-target", npm + ["exec", "--", "bloom", "build", "--target", "android"], project, expected_error="Unsupported starter target") missing_env = {**os.environ, "BLOOM_PERRY": str(temporary / "missing-perry.exe")} run("missing-compiler", npm + ["exec", "--", "bloom", "build", "--target", "web"], project, env=missing_env, expected_error="not found") + if args.web: + compiler = os.environ.get('BLOOM_PERRY') or shutil.which('perry') + wasm_pack = os.environ.get('BLOOM_WASM_PACK') or shutil.which('wasm-pack') + if not compiler or not wasm_pack: + raise RuntimeError('Perry and wasm-pack are required for installed starter web acceptance') + report['compiler_version'] = run('perry-version', [compiler, '--version'], project).strip() + report['wasm_pack_version'] = run('wasm-pack-version', [wasm_pack, '--version'], project).strip() + if report['compiler_version'] != 'perry 0.5.1220' or report['wasm_pack_version'] != 'wasm-pack 0.15.0': + raise RuntimeError('starter web gate requires Perry 0.5.1220 and wasm-pack 0.15.0') + report['compiler_sha256'] = hashlib.sha256(Path(compiler).read_bytes()).hexdigest() + web_env = os.environ.copy() + web_env['CARGO_TARGET_DIR'] = str(ROOT / 'native/web/target') + report['web_command'] = run_web(project, out, web_env) + log = (out / 'installed-web-run.stdout.log').read_text(encoding='utf-8', errors='replace') + (out / 'installed-web-run.stderr.log').read_text(encoding='utf-8', errors='replace') + if 'Could not resolve import' in log: + raise RuntimeError('starter web build contains unresolved Perry imports') + website = project / 'dist/web' + html = (website / 'index.html').read_text(encoding='utf-8') + encoded = re.search(r'window\.__perryWasmB64\s*=\s*"([A-Za-z0-9+/=]+)"', html) + if encoded is None: + raise RuntimeError('starter web build has no compiled game WASM') + game_wasm = base64.b64decode(encoded[1], validate=True) + (out / 'starter.game.wasm').write_bytes(game_wasm) + inspect = "const fs=require('node:fs');process.stdout.write(JSON.stringify(WebAssembly.Module.imports(new WebAssembly.Module(fs.readFileSync(process.argv[1])))));" + imports = json.loads(run('game-imports', ['node', '-e', inspect, str(out / 'starter.game.wasm')], project)) + required = {'bloom_init_window', 'bloom_run_game_with_cleanup', 'bloom_read_file', 'bloom_draw_text', 'bloom_draw_rect'} + actual = {item['name'] for item in imports if item['module'] == 'ffi'} + if required - actual: + raise RuntimeError(f'starter is missing required compiled imports: {sorted(required - actual)}') + if json.loads((website / 'assets_manifest.json').read_text()) != {'files': ['assets/welcome.txt']}: + raise RuntimeError('starter asset manifest differs from the shipped asset inventory') + if (website / 'assets/welcome.txt').read_text().strip() != 'Hello, Bloom!': + raise RuntimeError('starter text asset changed during the build') + if (project / 'main.ts').read_text(encoding='utf-8') != (ROOT / 'tools/cli/templates/main.ts').read_text(encoding='utf-8'): + raise RuntimeError('starter source was changed for browser acceptance') + # Retain the complete output of the actual installed build command. + # Reusing an output directory must never retain stale website files. + site = out / 'site' + if site.exists(): + raise RuntimeError('starter web evidence output already contains a website; choose a fresh --out') + shutil.copytree(website, site) + (out / 'starter.ts').write_bytes((project / 'main.ts').read_bytes()) + report['web'] = {'source_unchanged': True, 'game_wasm_sha256': hashlib.sha256(game_wasm).hexdigest(), + 'engine_ffi_imports': sorted(actual), 'files': {}} + for file in sorted(site.rglob('*')): + if file.is_symlink() or file.is_junction(): + raise RuntimeError('starter website unexpectedly contains a filesystem link') + if file.is_file(): + report['web']['files'][file.relative_to(site).as_posix()] = hashlib.sha256(file.read_bytes()).hexdigest() report["status"] = "pass" print("PASS: installed CLI creates an exact-package starter; setup failure controls rejected") return 0 diff --git a/tools/ci/starter_web_run.py b/tools/ci/starter_web_run.py new file mode 100644 index 0000000..1415ec1 --- /dev/null +++ b/tools/ci/starter_web_run.py @@ -0,0 +1,74 @@ +"""Run the installed public web command and verify the files it actually serves.""" + +import hashlib +import json +import os +from pathlib import Path +import shutil +import signal +import socket +import subprocess +import time +import urllib.error +import urllib.request + + +def run_web(project, out, env, timeout=1800): + with socket.socket() as listener: + listener.bind(('127.0.0.1', 0)) + port = listener.getsockname()[1] + cli = project / 'node_modules/@bloomengine/engine/tools/cli/bloom.cjs' + command = [shutil.which('node'), str(cli), 'run', '--web', '--port', str(port)] + record = dict(command=command, cwd=str(project), status='running', served_files={}) + def save(): (out / 'installed-web-run.json').write_text(json.dumps(record, indent=2) + '\n', encoding='utf-8') + save() + started = time.monotonic() + with (out / 'installed-web-run.stdout.log').open('wb') as stdout, (out / 'installed-web-run.stderr.log').open('wb') as stderr: + process = subprocess.Popen(command, cwd=project, env=env, stdout=stdout, stderr=stderr, + start_new_session=os.name != 'nt', + creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0) + try: + while time.monotonic() - started < timeout: + if process.poll() is not None: + raise RuntimeError(f'installed bloom run --web exited before serving: {process.returncode}') + try: + with urllib.request.urlopen(f'http://127.0.0.1:{port}/', timeout=1) as response: + if response.status == 200: + break + except (OSError, urllib.error.URLError): + pass + time.sleep(0.2) + else: + raise RuntimeError('installed bloom run --web did not serve before timeout') + for name in ('index.html', 'assets/welcome.txt', 'assets_manifest.json', 'pkg/bloom_web_bg.wasm'): + with urllib.request.urlopen(f'http://127.0.0.1:{port}/{name}', timeout=15) as response: + data = response.read() + mime = response.headers.get_content_type() + if data != (project / 'dist/web' / name).read_bytes(): + raise RuntimeError(f'installed server returned different bytes for {name}') + if name.endswith('.wasm') and mime != 'application/wasm': + raise RuntimeError('installed server did not serve the engine with the WASM MIME type') + record['served_files'][name] = dict(sha256=hashlib.sha256(data).hexdigest(), mime=mime) + record['status'] = 'pass' + record['duration_seconds'] = round(time.monotonic() - started, 3) + save() + return record + except Exception as error: + record.update(status='fail', error=str(error), duration_seconds=round(time.monotonic() - started, 3)) + save() + raise + finally: + # Terminate only this invocation's owned process tree; a failure may + # happen while its compiler child is still running. + if process.poll() is None: + if os.name == 'nt': + subprocess.run(['taskkill', '/PID', str(process.pid), '/T', '/F'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) + else: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + if os.name != 'nt': os.killpg(process.pid, signal.SIGKILL) + else: process.kill() + process.wait(timeout=5) diff --git a/tools/ci/test_fixed_step_smoke.py b/tools/ci/test_fixed_step_smoke.py index a06daa9..1c1a3e3 100644 --- a/tools/ci/test_fixed_step_smoke.py +++ b/tools/ci/test_fixed_step_smoke.py @@ -20,7 +20,7 @@ def test_wasm_harness_waits_for_boot_and_rejects_late_errors(self): for body, success in ((emit, True), (emit + emit, False), ('', False), (emit + 'console.error("late failure");', False), (emit + 'throw new Error("late boot failure");', False)): - page.write_text('' + page.write_text('' '', encoding='utf-8') result = subprocess.run(['node', str(helper), str(page)], capture_output=True, text=True, timeout=15) diff --git a/tools/ci/test_starter_browser_smoke.py b/tools/ci/test_starter_browser_smoke.py new file mode 100644 index 0000000..cd2809d --- /dev/null +++ b/tools/ci/test_starter_browser_smoke.py @@ -0,0 +1,43 @@ +import copy +import unittest + +from tools.ci.starter_browser_smoke import validate_state, check_pixels + + +def valid_state(): + return dict(frames=8, cleanups=1, registrations=1, stopped=True, errors=[], + reads=[dict(path='assets/welcome.txt', value='Hello, Bloom!\n')], + fetches=[dict(path='/assets/welcome.txt', status=200)], + texts=[['Hello, Bloom!\n', 24, 24, 24, 255, 255, 255, 255] for _ in range(8)], + rects=[[368, 193, 64, 64, 255, 255, 255, 255] for _ in range(8)]) + + +class StarterBrowserAcceptanceTests(unittest.TestCase): + def test_requires_real_asset_draw_progress_and_one_cleanup(self): + state = valid_state() + validate_state(state) + for change in (dict(frames=7), dict(cleanups=2), dict(registrations=True), dict(stopped=False), + dict(errors=['startup failed']), dict(reads=[]), dict(fetches=[]), dict(texts=[]), + dict(rects=[]), dict(reads=[dict(path='assets/welcome.txt', value='')])): + with self.subTest(change=change), self.assertRaises(RuntimeError): + validate_state({**state, **change}) + for field, position, value in [('texts', 0, 'wrong asset'), ('rects', 0, float('nan')), ('rects', 2, 0)]: + wrong = copy.deepcopy(state) + wrong[field][0][position] = value + with self.subTest(field=field), self.assertRaises(RuntimeError): validate_state(wrong) + + def test_pixels_reject_empty_missing_text_and_unexpected_background(self): + pixels = [(0, 0, 0)] * (800 * 450) + for y in range(193, 257): + for x in range(368, 432): pixels[y * 800 + x] = (255, 255, 255) + with self.assertRaises(RuntimeError): check_pixels(800, 450, pixels, [368]) + for x in range(24, 224): pixels[32 * 800 + x] = (200, 200, 200) + check_pixels(800, 450, pixels, [368]) + pixels[225 * 800 + 400] = (0, 0, 0) + with self.assertRaises(RuntimeError): check_pixels(800, 450, pixels, [368]) + pixels[225 * 800 + 400] = (255, 255, 255) + pixels[0] = (255, 255, 255) + with self.assertRaises(RuntimeError): check_pixels(800, 450, pixels, [368]) + + +if __name__ == '__main__': unittest.main() diff --git a/tools/ci/test_web_build.cjs b/tools/ci/test_web_build.cjs index 11fc834..5baa046 100644 --- a/tools/ci/test_web_build.cjs +++ b/tools/ci/test_web_build.cjs @@ -7,7 +7,25 @@ const path = require("node:path"); const { spawnSync } = require("node:child_process"); const { test } = require("node:test"); const { build, parseArgs, runTool } = require("../../native/web/build.cjs"); -const { splice } = require("../../native/web/splice_game.cjs"); +const { splice, installPerryVoidReturnCompatibility } = require("../../native/web/splice_game.cjs"); + +test("void callback compatibility preserves boxed values and unrelated decoder errors", () => { + const calls = []; + const runtime = { __bitsToJsValue(bits) { + calls.push(bits); + if (typeof bits !== "bigint") throw new TypeError("expected boxed i64"); + if (bits === 9n) throw new Error("decoder failure"); + return bits === 7n ? undefined : "decoded"; + } }; + installPerryVoidReturnCompatibility(runtime); + assert.equal(runtime.__bitsToJsValue(undefined), undefined); + assert.deepEqual(calls, []); + assert.equal(runtime.__bitsToJsValue(7n), undefined); + assert.equal(runtime.__bitsToJsValue(8n), "decoded"); + assert.throws(() => runtime.__bitsToJsValue(9n), /decoder failure/); + assert.throws(() => runtime.__bitsToJsValue(1), /expected boxed/); + assert.throws(() => installPerryVoidReturnCompatibility({}), /no value decoder/); +}); const perryHtml = `