From 5cce54a5251ef12ffcb51f540208a96c758fd7ef Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Fri, 11 Sep 2026 04:48:52 +0200 Subject: [PATCH 1/2] Make the installed web build command portable and preserve failures --- .github/workflows/test.yml | 14 ++ README.md | 2 + docs/evidence/windows-portable-web-cli-v1.md | 54 ++++++ docs/web-target.md | 30 +++- docs/windows-engine-plan.md | 5 +- native/web/build.cjs | 162 +++++++++++++++++ native/web/build.sh | 172 +------------------ native/web/splice_game.cjs | 58 +++++++ native/web/splice_game.py | 89 +--------- package.json | 7 +- scripts/ci-check.sh | 1 + tools/ci/check-installed-web-cli.ps1 | 89 ++++++++++ tools/ci/test_web_build.cjs | 148 ++++++++++++++++ tools/validate-docs.js | 21 +-- 14 files changed, 575 insertions(+), 277 deletions(-) create mode 100644 docs/evidence/windows-portable-web-cli-v1.md create mode 100644 native/web/build.cjs create mode 100644 native/web/splice_game.cjs create mode 100644 tools/ci/check-installed-web-cli.ps1 create mode 100644 tools/ci/test_web_build.cjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 35660b17..f2916b3a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -239,6 +239,20 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable + - name: Windows / installed web command + shell: pwsh + run: ./tools/ci/check-installed-web-cli.ps1 + + - name: Retain installed command evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: windows-installed-web-cli + path: | + target/ci/installed-web-cli/result.json + target/ci/installed-web-cli/*/*.log + if-no-files-found: error + - name: Set up MSVC environment # Populates LIB / INCLUDE / PATH so cmake (which builds the Jolt C++ # shim via bloom-shared's build.rs) can find the Windows SDK. Without diff --git a/README.md b/README.md index 63f1c860..c91663cf 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ You'll also need: - **Perry** — the TypeScript AOT compiler that turns your game into a native binary or WASM module. It also drives the engine's native build. - **Rust toolchain** ([rustup.rs](https://rustup.rs)) — Perry invokes Cargo to compile the engine's platform crate the first time you build for each target. - For web builds only: [wasm-pack](https://crates.io/crates/wasm-pack) (`cargo install wasm-pack`). +- The packaged `bloom-web` command uses Node.js 18 or newer. See the + [web build guide](docs/web-target.md) for tool paths and Windows serving commands. ## Quick Start diff --git a/docs/evidence/windows-portable-web-cli-v1.md b/docs/evidence/windows-portable-web-cli-v1.md new file mode 100644 index 00000000..7b06c99b --- /dev/null +++ b/docs/evidence/windows-portable-web-cli-v1.md @@ -0,0 +1,54 @@ +# Portable installed web command + +The actual npm package installed successfully in a clean Windows project, but +`npm exec -- bloom-web --help` failed because its generated command shim tried +to run `/bin/bash`. Invoking the script directly through Git Bash in repository +checks did not exercise that installed entry point. + +The old build also piped `wasm-pack` into `tail` without `pipefail`, allowing a +failed engine compile to appear successful and reuse stale `pkg/` output. + +## Change + +The package now exposes a Node entry point. It passes executable arguments +directly, resolves game/output paths from the caller, compiles in a fresh +temporary directory and assembles the distribution only after successful +compilation and artifact checks. Missing tools, nonzero compiler exits, missing +outputs and incompatible Perry HTML stop the build. The optional optimizer may +be absent; a present or explicitly configured optimizer must succeed. + +The existing shell and Python entry points delegate to the same Node build and +splicer implementations. Bash and Python are no longer package build +dependencies. Node 18 or newer is declared. The web guide distinguishes this +upcoming branch from the stable 0.4.16 npm command. + +Nine regression checks cover the real subprocess exit boundary, stale/missing +artifacts, spaces and assets, repeated builds, optimizer failures and Perry +bootstrap validation. Their fake compiler outputs are orchestration fixtures, +not evidence of engine compilation or rendering. Repository contracts run them; +the Windows build job additionally packs and installs the package in a clean +project and invokes npm's actual installed command. + +## Local verification + +- All nine regression checks and the complete contracts component pass. +- The candidate package packs and installs with pinned Jolt dependency 0.4.1. + Lifecycle scripts are disabled; both package manifests have no lifecycle scripts. +- The installed `npm exec -- bloom-web --help` passes on Windows. +- The installed command compiles a bounded TypeScript probe using Perry + 0.5.1220, builds the engine with wasm-pack and assembles a distribution in a + path containing spaces. The full build passes in 107.140 seconds. Its engine + WASM is 7,839,672 bytes; the asset copy and gated HTML bootstrap are checked. +- The Windows CI pack/install script also passes locally. An initial probe + exposed Windows PowerShell's UTF-8 BOM in a generated package manifest; the + script now writes that manifest without a BOM and retains native exit codes + independently of stderr. + +Commands, exact candidate patch, package and artifact hashes, full build logs, +and install receipts are retained under +`tools/quality/out/windows-engine-plan/portable-web-cli/`. + +Hosted validation is pending. The local browser connection is unavailable, so +no browser frame or runtime startup result is claimed here. Native startup, +the one-command starter, shared lifecycle, wider example runtime matrix and +packaged shader-runtime acceptance remain open under #142, #74 and #145. diff --git a/docs/web-target.md b/docs/web-target.md index d1e26040..8a0d2b94 100644 --- a/docs/web-target.md +++ b/docs/web-target.md @@ -19,14 +19,15 @@ Game.ts ─(perry --target wasm)──> game WASM (game logic, base64-embedded Browser: + WebGPU + Web Audio + DOM Events ``` -Both game logic and rendering run in WebAssembly. A thin JS glue layer (`native/web/bloom_glue.js`, spliced into Perry's self-contained HTML by `splice_game.py`) bridges the two modules, handles DOM events, asset fetching, and audio output. +Both game logic and rendering run in WebAssembly. A thin JS glue layer (`native/web/bloom_glue.js`, spliced into Perry's self-contained HTML by `splice_game.cjs`) bridges the two modules, handles DOM events, asset fetching, and audio output. ## Building ### Prerequisites +- Node.js 18 or newer, npm and a Rust toolchain - [wasm-pack](https://crates.io/crates/wasm-pack): `cargo install wasm-pack` -- [Perry compiler](https://github.com/PerryTS/perry): built from source +- [Perry compiler](https://github.com/PerryTS/perry) on PATH - wasm-opt (optional): `cargo install wasm-opt` ### Quick Build @@ -36,10 +37,24 @@ npm exec -- bloom-web path/to/game/main.ts --output dist/web ``` This runs: -1. `wasm-pack build` to compile `native/web/` → `pkg/bloom_web_bg.wasm` + `pkg/bloom_web.js` bindings -2. `wasm-opt -Oz` for binary size optimization (if installed) -3. `perry main.ts --target wasm` to compile game TypeScript → WASM -4. Assembles output directory at `dist/web/` +1. `perry compile main.ts --target wasm` to compile game TypeScript and prepare its engine bootstrap +2. `wasm-pack build` to compile `native/web/` into fresh WASM and JavaScript bindings +3. `wasm-opt -Oz` for binary size optimization (if installed) +4. Assembles the fresh artifacts in `dist/web/` + +The packaged command runs directly through Node on Windows, macOS and Linux. +This portable entry point is part of the upcoming 0.5 branch; npm 0.4.16 still +uses the earlier Bash entry point. +It does not require Bash or Python. Paths resolve from the caller's directory, +and paths containing spaces are passed directly to the tools. The existing +`native/web/build.sh` entry point delegates to the same command. + +Use `--help` without a compiler installed. Missing tools and nonzero compiler +exits stop the build before assembly; an old `pkg/` cannot satisfy a failed +build. Set `BLOOM_PERRY`, `BLOOM_WASM_PACK` or `BLOOM_WASM_OPT` to a tool's +executable path when it is not on PATH. These values are paths, not shell +commands or argument strings. An explicitly configured optimizer is required; +an absent default `wasm-opt` is optional. ### Serve Locally @@ -49,6 +64,9 @@ python3 -m http.server 8080 # Open http://localhost:8080 ``` +On Windows, use `python -m http.server 8080` if Python is installed as `python`. +Python is only an example HTTP server, not a build dependency. + ## Game Loop Browsers cannot run blocking `while` loops. Use `runGame()` instead: diff --git a/docs/windows-engine-plan.md b/docs/windows-engine-plan.md index 38d57290..0ffaee91 100644 --- a/docs/windows-engine-plan.md +++ b/docs/windows-engine-plan.md @@ -86,7 +86,10 @@ audit are saved in `tools/quality/out/windows-engine-plan/plan-requirements.json 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. + 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. + Real starter rendering and native startup 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 diff --git a/native/web/build.cjs b/native/web/build.cjs new file mode 100644 index 00000000..cc2b31bb --- /dev/null +++ b/native/web/build.cjs @@ -0,0 +1,162 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const { splice } = require("./splice_game.cjs"); + +const HELP = `Usage: bloom-web [game.ts] [--output dist/web] + +Build the engine and optional Perry game for the browser. +Requires Rust, wasm-pack and (for a game) Perry on PATH. +Optional: wasm-opt for additional size optimization. +Set BLOOM_PERRY, BLOOM_WASM_PACK or BLOOM_WASM_OPT to an executable path. +`; + +class BuildError extends Error { + constructor(message, exitCode = 1) { + super(message); + this.exitCode = exitCode; + } +} + +function parseArgs(args, cwd = process.cwd()) { + let game; + let output = "dist/web"; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === "--help" || arg === "-h") return { help: true }; + if (arg === "--output") { + if (!args[i + 1] || args[i + 1].startsWith("--")) { + throw new BuildError("--output requires a directory", 2); + } + output = args[++i]; + } else if (arg.startsWith("--output=")) { + output = arg.slice("--output=".length); + if (!output) throw new BuildError("--output requires a directory", 2); + } else if (arg.startsWith("-")) { + throw new BuildError(`unknown option: ${arg}`, 2); + } else if (game !== undefined) { + throw new BuildError("only one game entry file may be supplied", 2); + } else { + game = arg; + } + } + return { + game: game === undefined ? undefined : path.resolve(cwd, game), + output: path.resolve(cwd, output), + }; +} + +function runTool(program, args, { execute = spawnSync, cwd, optional = false } = {}) { + const result = execute(program, args, { + cwd, stdio: "inherit", shell: false, windowsHide: true, + }); + if (result.error) { + if (optional && result.error.code === "ENOENT") return false; + const detail = result.error.code === "ENOENT" + ? "executable not found; install it or set its BLOOM_* executable path" + : result.error.message; + throw new BuildError(`${program}: ${detail}`); + } + if (result.status !== 0) { + throw new BuildError( + `${program} failed (${result.signal ? `signal ${result.signal}` : `exit ${result.status}`})`, + Number.isInteger(result.status) && result.status > 0 ? result.status : 1, + ); + } + return true; +} + +function requireFile(file, label) { + if (!fs.existsSync(file) || !fs.statSync(file).isFile() || fs.statSync(file).size === 0) { + throw new BuildError(`${label} did not produce a non-empty file: ${file}`); + } +} + +function build(options, { execute = spawnSync, webDir = __dirname, env = process.env } = {}) { + if (options.game) requireFile(options.game, "game entry"); + const wasmPack = env.BLOOM_WASM_PACK || "wasm-pack"; + const perry = env.BLOOM_PERRY || "perry"; + const wasmOpt = env.BLOOM_WASM_OPT || "wasm-opt"; + const run = (program, args, extra = {}) => runTool(program, args, { execute, ...extra }); + + // Check required tools before compiling either module. Help needs no toolchain. + run(wasmPack, ["--version"]); + if (options.game) run(perry, ["--version"]); + + const temporaryRoot = path.resolve(os.tmpdir()); + const temporary = fs.mkdtempSync(path.join(temporaryRoot, "bloom-web-")); + try { + const pkg = path.join(temporary, "pkg"); + const index = path.join(temporary, "index.html"); + if (options.game) { + console.log("Compiling game with Perry..."); + const gameHtml = path.join(temporary, "game.html"); + run(perry, ["compile", options.game, "--target", "wasm", "-o", gameHtml], { + cwd: path.dirname(options.game), + }); + requireFile(gameHtml, "Perry"); + fs.writeFileSync(index, splice(fs.readFileSync(gameHtml, "utf8")), "utf8"); + } else { + fs.copyFileSync(path.join(webDir, "index.html"), index); + } + + console.log("Building engine with wasm-pack..."); + run(wasmPack, ["build", "--target", "web", "--out-dir", pkg, "--no-typescript"], { + cwd: webDir, + }); + const wasm = path.join(pkg, "bloom_web_bg.wasm"); + requireFile(wasm, "wasm-pack"); + requireFile(path.join(pkg, "bloom_web.js"), "wasm-pack"); + + const optimized = path.join(temporary, "optimized.wasm"); + if (run(wasmOpt, ["-Oz", wasm, "-o", optimized], { optional: !env.BLOOM_WASM_OPT })) { + requireFile(optimized, "wasm-opt"); + fs.renameSync(optimized, wasm); + } else { + console.log("Skipping optional wasm-opt (not installed)."); + } + + // Build in a unique staging directory. Failed commands cannot reuse an old + // pkg/ or overwrite the caller's previous successful distribution. + fs.mkdirSync(options.output, { recursive: true }); + fs.cpSync(pkg, path.join(options.output, "pkg"), { recursive: true }); + for (const file of ["bloom_glue.js", "jolt_bridge.js"]) { + fs.copyFileSync(path.join(webDir, file), path.join(options.output, file)); + } + if (options.game) { + const assets = path.join(path.dirname(options.game), "assets"); + if (fs.existsSync(assets)) { + fs.cpSync(assets, path.join(options.output, "assets"), { recursive: true }); + } + } + fs.copyFileSync(index, path.join(options.output, "index.html")); + console.log(`Build complete: ${options.output}`); + console.log(`Engine WASM: ${fs.statSync(wasm).size} bytes`); + console.log("Serve this directory over HTTP; see docs/web-target.md."); + } finally { + // Only remove the directory this invocation created under the temp root. + if (path.dirname(path.resolve(temporary)) !== temporaryRoot) { + throw new BuildError("temporary build directory escaped its root"); + } + fs.rmSync(temporary, { recursive: true, force: true }); + } +} + +function main(args = process.argv.slice(2)) { + try { + const options = parseArgs(args); + if (options.help) console.log(HELP); + else build(options); + return 0; + } catch (error) { + console.error(`bloom-web: ${error.message}`); + return error.exitCode || 1; + } +} + +module.exports = { BuildError, parseArgs, runTool, build, main }; +if (require.main === module) process.exitCode = main(); diff --git a/native/web/build.sh b/native/web/build.sh index d24aa986..8b211ee9 100755 --- a/native/web/build.sh +++ b/native/web/build.sh @@ -1,171 +1,3 @@ #!/bin/bash -# Build Bloom Engine for Web -# -# Usage: -# bloom-web [game.ts] [--output dist/] -# -# Steps: -# 1. Build bloom_web.wasm via wasm-pack -# 2. Compile game TypeScript via perry --target wasm (if provided) -# 3. Assemble output directory with all files needed to serve -# -# Prerequisites: -# - wasm-pack: cargo install wasm-pack -# - perry: ../../../perry/perry/target/release/perry (or in PATH) -# - wasm-opt (optional): for binary size optimization - -set -e - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -ENGINE_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" -CALLER_DIR="$(pwd)" -WEB_CRATE="$SCRIPT_DIR" -OUTPUT_DIR="" -GAME_FILE="" - -while [ "$#" -gt 0 ]; do - case "$1" in - --output) - if [ "$#" -lt 2 ]; then - echo "ERROR: --output requires a directory" - exit 2 - fi - OUTPUT_DIR="$2" - shift 2 - ;; - --output=*) - OUTPUT_DIR="${1#*=}" - shift - ;; - -h|--help) - echo "Usage: bloom-web [game.ts] [--output dist/]" - exit 0 - ;; - -*) - echo "ERROR: unknown option: $1" - exit 2 - ;; - *) - if [ -n "$GAME_FILE" ]; then - echo "ERROR: only one game entry file may be supplied" - exit 2 - fi - GAME_FILE="$1" - shift - ;; - esac -done - -if [ -z "$OUTPUT_DIR" ]; then - OUTPUT_DIR="$CALLER_DIR/dist/web" -elif [ "${OUTPUT_DIR#/}" = "$OUTPUT_DIR" ]; then - OUTPUT_DIR="$CALLER_DIR/$OUTPUT_DIR" -fi - -# Resolve the game file to an absolute path NOW, while still in the caller's -# working directory — the build cd's into the web crate before compiling, so a -# relative path like `examples/pong/main.ts` would otherwise no longer resolve. -if [ -n "$GAME_FILE" ]; then - if [ -f "$GAME_FILE" ]; then - GAME_FILE="$(cd "$(dirname "$GAME_FILE")" && pwd)/$(basename "$GAME_FILE")" - else - echo "ERROR: game file not found: $GAME_FILE" - exit 1 - fi -fi - -echo "=== Bloom Web Build ===" -echo "" - -# 1. Build Bloom WASM via wasm-pack -echo "[1/4] Building bloom_web.wasm..." -cd "$WEB_CRATE" -wasm-pack build --target web --out-dir pkg --no-typescript 2>&1 | tail -3 -echo " Output: $WEB_CRATE/pkg/" - -# 2. Optimize WASM binary (if wasm-opt is available) -if command -v wasm-opt &> /dev/null; then - echo "[2/4] Optimizing WASM with wasm-opt..." - WASM_FILE="$WEB_CRATE/pkg/bloom_web_bg.wasm" - ORIG_SIZE=$(wc -c < "$WASM_FILE") - wasm-opt -Oz "$WASM_FILE" -o "$WASM_FILE.opt" - mv "$WASM_FILE.opt" "$WASM_FILE" - OPT_SIZE=$(wc -c < "$WASM_FILE") - echo " Optimized: $((ORIG_SIZE / 1024))KB → $((OPT_SIZE / 1024))KB" -else - echo "[2/4] Skipping wasm-opt (not installed). Install with: cargo install wasm-opt" -fi - -# 3. Compile game (if provided) -PERRY_HTML="" -if [ -n "$GAME_FILE" ] && [ -f "$GAME_FILE" ]; then - echo "[3/4] Compiling game: $GAME_FILE" - - # Find perry compiler - PERRY="" - if command -v perry &> /dev/null; then - PERRY="perry" - elif [ -f "$ENGINE_DIR/../../perry/perry/target/release/perry" ]; then - PERRY="$ENGINE_DIR/../../perry/perry/target/release/perry" - else - echo " ERROR: perry compiler not found. Install it or add to PATH." - exit 1 - fi - - # Perry emits a self-contained HTML carrying the game WASM (base64) plus its - # full runtime bridge (the ~280 `rt`-namespace host functions + NaN-boxing + - # closure dispatch). build.sh later splices the Bloom engine bootstrap into it. - # Use a temp dir (portable across GNU/BSD mktemp) so cleanup is a single rm. - PERRY_TMP="$(mktemp -d)" - PERRY_HTML="$PERRY_TMP/game.html" - $PERRY "$GAME_FILE" --target wasm -o "$PERRY_HTML" - echo " Game compiled to WASM ($PERRY_HTML)" -else - echo "[3/4] No game file specified, skipping game compilation" -fi - -# 4. Assemble output directory -echo "[4/4] Assembling output..." -mkdir -p "$OUTPUT_DIR" - -# Copy Bloom WASM package -cp -r "$WEB_CRATE/pkg" "$OUTPUT_DIR/pkg" - -# Engine bootstrap + Jolt bridge are needed by both the game and engine-only pages. -cp "$WEB_CRATE/bloom_glue.js" "$OUTPUT_DIR/bloom_glue.js" -cp "$WEB_CRATE/jolt_bridge.js" "$OUTPUT_DIR/jolt_bridge.js" - -if [ -n "$PERRY_HTML" ]; then - # Game build: splice the Bloom bootstrap into Perry's HTML and gate the game's - # bootPerryWasm() call on engine readiness → dist/web/index.html. - python3 "$WEB_CRATE/splice_game.py" "$PERRY_HTML" "$OUTPUT_DIR/index.html" - rm -rf "$PERRY_TMP" - echo " Spliced game + engine into index.html" -else - # No game: ship the engine-only standalone page. - cp "$WEB_CRATE/index.html" "$OUTPUT_DIR/index.html" - echo " Copied engine-only index.html (no game compiled)" -fi - -# Copy game assets (if game directory has an assets/ folder) -if [ -n "$GAME_FILE" ]; then - GAME_DIR="$(dirname "$(realpath "$GAME_FILE")")" - if [ -d "$GAME_DIR/assets" ]; then - cp -r "$GAME_DIR/assets" "$OUTPUT_DIR/assets" - echo " Copied assets/ directory" - fi -fi - -# Calculate total size -TOTAL_SIZE=$(du -sh "$OUTPUT_DIR" | cut -f1) -WASM_SIZE=$(wc -c < "$OUTPUT_DIR/pkg/bloom_web_bg.wasm" 2>/dev/null || echo "0") - -echo "" -echo "=== Build Complete ===" -echo " Output: $OUTPUT_DIR" -echo " WASM size: $((WASM_SIZE / 1024))KB" -echo " Total size: $TOTAL_SIZE" -echo "" -echo "To serve locally:" -echo " cd $OUTPUT_DIR && python3 -m http.server 8080" -echo " Open http://localhost:8080" +# Compatibility entry point for existing checkout-based build commands. +exec node "$(dirname "$0")/build.cjs" "$@" diff --git a/native/web/splice_game.cjs b/native/web/splice_game.cjs new file mode 100644 index 00000000..b9095fbf --- /dev/null +++ b/native/web/splice_game.cjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +"use strict"; + +// Gate Perry's game entry on the Bloom engine and FFI bridge being ready. +const fs = require("node:fs"); +const PERRY_ROOT = "
"; +const BOOT_MARKER = "window.__perryWasmB64"; +const BOOT_CALL = "bootPerryWasm(\""; +const BOOT_CATCH = "\").catch("; +const BLOOM_SHELL = ` + +
Loading Bloom Engine...
+ + + + +`; + +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"); + } + if (!html.includes(BOOT_MARKER)) { + throw new Error("could not find the Perry boot block; compiler output format may have changed"); + } + html = html.replace(PERRY_ROOT, PERRY_ROOT + BLOOM_SHELL); + const index = html.lastIndexOf(BOOT_MARKER); + const head = html.slice(0, index); + let tail = html.slice(index); + if (!tail.includes(BOOT_CALL) || !tail.includes(BOOT_CATCH)) { + throw new Error("unexpected Perry boot block shape; compiler output format may have changed"); + } + tail = tail.replace(BOOT_CALL, 'window.__bloomReady.then(() => bootPerryWasm("'); + tail = tail.replace(BOOT_CATCH, '\")).catch('); + return head + tail; +} + +module.exports = { splice }; +if (require.main === module) { + try { + if (process.argv.length !== 4) throw new Error("Usage: splice_game.cjs "); + fs.writeFileSync(process.argv[3], splice(fs.readFileSync(process.argv[2], "utf8")), "utf8"); + } catch (error) { + console.error(`splice_game: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/native/web/splice_game.py b/native/web/splice_game.py index 1b4e60d1..5896ce6f 100644 --- a/native/web/splice_game.py +++ b/native/web/splice_game.py @@ -1,85 +1,10 @@ #!/usr/bin/env python3 -"""Splice the Bloom engine bootstrap into Perry's self-contained WASM HTML. - -Perry emits a complete HTML page: a classic - -''' - - -def splice(html: str) -> str: - if PERRY_ROOT not in html: - raise SystemExit( - 'splice_game.py: could not find perry-root div in Perry HTML — ' - 'output format may have changed; aborting so the build fails loudly.' - ) - if BOOT_MARKER not in html: - raise SystemExit( - 'splice_game.py: could not find the bootPerryWasm boot block — ' - 'output format may have changed; aborting.' - ) - - # 1. Inject the Bloom shell right after Perry's root div. - html = html.replace(PERRY_ROOT, PERRY_ROOT + BLOOM_SHELL, 1) - - # 2. Gate the boot call. Operate only on the tail beginning at the boot - # marker so we never touch a coincidental `bootPerryWasm("` / `").catch(` - # inside the large runtime script (the definition reads - # `bootPerryWasm(wasmBase64`, not `bootPerryWasm("`). - idx = html.rindex(BOOT_MARKER) - head, tail = html[:idx], html[idx:] - if BOOT_CALL not in tail or BOOT_CATCH not in tail: - raise SystemExit('splice_game.py: unexpected boot block shape; aborting.') - tail = tail.replace(BOOT_CALL, 'window.__bloomReady.then(() => bootPerryWasm("', 1) - tail = tail.replace(BOOT_CATCH, '")).catch(', 1) - return head + tail - - -def main() -> None: - if len(sys.argv) != 3: - raise SystemExit('Usage: splice_game.py ') - with open(sys.argv[1], 'r', encoding='utf-8') as f: - html = f.read() - with open(sys.argv[2], 'w', encoding='utf-8') as f: - f.write(splice(html)) - - -if __name__ == '__main__': - main() +if __name__ == "__main__": + raise SystemExit(subprocess.call([ + "node", str(pathlib.Path(__file__).with_suffix(".cjs")), *sys.argv[1:] + ])) diff --git a/package.json b/package.json index 1cb96953..8d55e7ca 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "src/index.ts", "types": "src/index.ts", "bin": { - "bloom-web": "native/web/build.sh" + "bloom-web": "native/web/build.cjs" }, "exports": { ".": "./src/index.ts", @@ -78,11 +78,16 @@ "native/web/Cargo.lock", "native/web/src/**", "native/web/build.sh", + "native/web/build.cjs", + "native/web/splice_game.cjs", "native/web/splice_game.py", "native/web/index.html", "native/web/bloom_glue.js", "native/web/jolt_bridge.js" ], + "engines": { + "node": ">=18" + }, "dependencies": { "@bloomengine/jolt-prebuilt": "0.4.1" }, diff --git a/scripts/ci-check.sh b/scripts/ci-check.sh index 852869c8..9ea8d031 100755 --- a/scripts/ci-check.sh +++ b/scripts/ci-check.sh @@ -230,6 +230,7 @@ run_component() { node tools/validate-ffi.js hr "documentation and package contracts" node tools/validate-docs.js + node --test tools/ci/test_web_build.cjs hr "file-size ratchet" node tools/check-file-lines.js ;; diff --git a/tools/ci/check-installed-web-cli.ps1 b/tools/ci/check-installed-web-cli.ps1 new file mode 100644 index 00000000..ced9a807 --- /dev/null +++ b/tools/ci/check-installed-web-cli.ps1 @@ -0,0 +1,89 @@ +param([string]$OutDir = "target/ci/installed-web-cli") + +$ErrorActionPreference = "Stop" +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "../..")).Path +$evidenceDir = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $OutDir)) +New-Item -ItemType Directory -Force -Path $evidenceDir | Out-Null +$runDir = Join-Path $evidenceDir ([Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $runDir | Out-Null +$temporaryRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd([System.IO.Path]::DirectorySeparatorChar) +$workDir = Join-Path $temporaryRoot ("bloom-installed-cli-" + [Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $workDir | Out-Null +$records = [System.Collections.Generic.List[object]]::new() +$report = [ordered]@{ + schema = "bloom-installed-web-cli-v1" + status = "running" + commands = $records + runtime_rendering_verified = $false +} +$reportPath = Join-Path $evidenceDir "result.json" + +function Save-Report { + [System.IO.File]::WriteAllText($reportPath, ($report | ConvertTo-Json -Depth 8), [System.Text.UTF8Encoding]::new($false)) +} + +function Invoke-Checked([string]$Tool, [string[]]$ToolArguments, [string]$Name) { + $logPath = Join-Path $runDir "$Name.log" + $errorLogPath = Join-Path $runDir "$Name.stderr.log" + $started = Get-Date + $previousPreference = $ErrorActionPreference + try { + # Windows PowerShell wraps native stderr in ErrorRecords. Preserve the + # tool's actual exit status and keep stderr out of npm's JSON stdout. + $ErrorActionPreference = "Continue" + $output = & $Tool @ToolArguments 2> $errorLogPath | Out-String + $code = $LASTEXITCODE + } finally { $ErrorActionPreference = $previousPreference } + $output | Set-Content -Encoding utf8 $logPath + $records.Add([ordered]@{ + name = $Name + command = @($Tool) + $ToolArguments + cwd = (Get-Location).Path + exit_code = $code + duration_seconds = [Math]::Round(((Get-Date) - $started).TotalSeconds, 3) + log = $logPath + stderr_log = $errorLogPath + }) + Save-Report + if ($code -ne 0) { throw "$Name failed with exit $code; see $logPath" } + return $output +} + +Save-Report +Push-Location $repoRoot +try { + $npmCommand = (Get-Command npm -ErrorAction Stop).Source + $nodeCommand = (Get-Command node -ErrorAction Stop).Source + Invoke-Checked $nodeCommand @("--test", "tools/ci/test_web_build.cjs") "regressions" | Out-Null + $packedText = Invoke-Checked $npmCommand @("pack", "--json", "--ignore-scripts", "--pack-destination", $workDir) "pack" + $packed = @($packedText | ConvertFrom-Json)[0] + $archive = Join-Path $workDir $packed.filename + $report.archive_sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $archive).Hash.ToLowerInvariant() + $project = Join-Path $workDir "clean project with spaces" + New-Item -ItemType Directory -Path $project | Out-Null + $manifest = @{ name = "bloom-installed-cli-smoke"; version = "1.0.0"; private = $true } | ConvertTo-Json + [System.IO.File]::WriteAllText((Join-Path $project "package.json"), $manifest, [System.Text.UTF8Encoding]::new($false)) + Push-Location $project + try { + Invoke-Checked $npmCommand @("install", "--ignore-scripts", "--no-audit", "--no-fund", $archive) "install" | Out-Null + $help = Invoke-Checked $npmCommand @("exec", "--", "bloom-web", "--help") "installed-help" + if (-not $help.Contains("--output")) { throw "installed command did not print its usage" } + } finally { Pop-Location } + $report.status = "pass" + Write-Output "PASS: packed and installed bloom-web command runs on Windows; rendering is a separate check." +} catch { + $report.status = "fail" + $report.error = $_.Exception.Message + throw +} finally { + Pop-Location + Save-Report + # Dependency packages stay out of CI evidence. Remove only this invocation's + # checked temporary directory, never a computed repository/output ancestor. + $resolvedWork = (Resolve-Path -LiteralPath $workDir).Path + if ([System.IO.Path]::GetDirectoryName($resolvedWork) -ne $temporaryRoot -or + ((Get-Item -LiteralPath $resolvedWork).Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + throw "refusing cleanup outside the owned temporary directory: $resolvedWork" + } + Remove-Item -LiteralPath $resolvedWork -Recurse -Force +} diff --git a/tools/ci/test_web_build.cjs b/tools/ci/test_web_build.cjs new file mode 100644 index 00000000..214686b4 --- /dev/null +++ b/tools/ci/test_web_build.cjs @@ -0,0 +1,148 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +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 perryHtml = `
`; + +function fixture(t, behavior = {}) { + const temporaryRoot = path.resolve(os.tmpdir()); + const root = fs.mkdtempSync(path.join(temporaryRoot, "bloom-web-test-")); + t.after(() => { + assert.equal(path.dirname(path.resolve(root)), temporaryRoot); + fs.rmSync(root, { recursive: true, force: true }); + }); + const gameDir = path.join(root, "game with spaces & Unicode-é"); + const webDir = path.join(root, "engine"); + const output = path.join(root, "output with spaces"); + fs.mkdirSync(path.join(gameDir, "assets"), { recursive: true }); + fs.mkdirSync(webDir); + fs.writeFileSync(path.join(gameDir, "main.ts"), "// fixture game"); + fs.writeFileSync(path.join(gameDir, "assets", "hello.txt"), "fixture asset"); + for (const name of ["bloom_glue.js", "jolt_bridge.js", "index.html"]) { + fs.writeFileSync(path.join(webDir, name), `fixture ${name}`); + } + const fakeTool = path.join(root, "fixture-tool.cjs"); + fs.writeFileSync(fakeTool, ` + const fs = require("node:fs"); + const path = require("node:path"); + const [tool, ...args] = process.argv.slice(2); + const behavior = ${JSON.stringify(behavior)}; + if (args[0] === "--version") process.exit(0); + if (tool === behavior.failTool) process.exit(23); + if (tool === "perry") { + fs.writeFileSync(args[args.indexOf("-o") + 1], ${JSON.stringify(perryHtml)}); + } else if (tool === "wasm-pack") { + const pkg = args[args.indexOf("--out-dir") + 1]; + fs.mkdirSync(pkg); + if (!behavior.omitWasm) fs.writeFileSync(path.join(pkg, "bloom_web_bg.wasm"), "fixture wasm"); + fs.writeFileSync(path.join(pkg, "bloom_web.js"), "fixture bindings"); + } else if (tool === "wasm-opt") { + fs.copyFileSync(args[1], args[args.indexOf("-o") + 1]); + } + `); + const calls = []; + const execute = (tool, args, options) => { + calls.push({ tool, args, options }); + if (tool === "wasm-opt" && behavior.missingOptimizer) { + return { error: Object.assign(new Error("not installed"), { code: "ENOENT" }) }; + } + // These are subprocess fixtures, not engine/compiler acceptance evidence. + // Real process exits exercise the failure boundary on Windows and Unix. + return spawnSync(process.execPath, [fakeTool, tool, ...args], options); + }; + return { + root, gameDir, webDir, output, calls, + options: { game: path.join(gameDir, "main.ts"), output }, + dependencies: { execute, webDir, env: {} }, + }; +} + +test("CLI help works without tools; malformed arguments fail before a build", () => { + const cli = path.resolve(__dirname, "../../native/web/build.cjs"); + const help = spawnSync(process.execPath, [cli, "--help"], { + encoding: "utf8", env: { ...process.env, PATH: "" }, windowsHide: true, + }); + assert.equal(help.status, 0, help.stderr); + assert.match(help.stdout, /--output/); + for (const args of [["--output"], ["--output="], ["--unknown"], ["a.ts", "b.ts"]]) { + assert.throws(() => parseArgs(args), (error) => error.exitCode === 2); + } +}); + +test("compiler exit code is retained", () => { + assert.throws( + () => runTool(process.execPath, ["-e", "process.exit(23)"]), + (error) => error.exitCode === 23 && /exit 23/.test(error.message), + ); +}); + +test("failed wasm-pack cannot reuse old output or assemble a false success", (t) => { + const f = fixture(t, { failTool: "wasm-pack" }); + fs.mkdirSync(path.join(f.output, "pkg"), { recursive: true }); + const previous = path.join(f.output, "pkg", "bloom_web_bg.wasm"); + fs.writeFileSync(previous, "previous distribution"); + assert.throws(() => build(f.options, f.dependencies), (error) => error.exitCode === 23); + assert.equal(fs.readFileSync(previous, "utf8"), "previous distribution"); + assert.equal(fs.existsSync(path.join(f.output, "index.html")), false); + assert.equal(f.calls.some((call) => call.tool === "wasm-opt"), false); +}); + +test("a zero exit with missing artifacts still fails", (t) => { + const f = fixture(t, { omitWasm: true }); + assert.throws(() => build(f.options, f.dependencies), /did not produce a non-empty file/); + assert.equal(fs.existsSync(f.output), false); +}); + +test("fresh assembly supports spaces, assets and repeated builds without pkg nesting", (t) => { + const f = fixture(t, { missingOptimizer: true }); + build(f.options, f.dependencies); + build(f.options, f.dependencies); + const call = f.calls.find((item) => item.tool === "perry" && item.args[0] === "compile"); + assert.equal(call.args[1], f.options.game); + assert.equal(call.options.cwd, f.gameDir); + assert.equal(call.options.shell, false); + assert.equal(fs.readFileSync(path.join(f.output, "assets", "hello.txt"), "utf8"), "fixture asset"); + assert.equal(fs.readFileSync(path.join(f.output, "pkg", "bloom_web_bg.wasm"), "utf8"), "fixture wasm"); + assert.equal(fs.existsSync(path.join(f.output, "pkg", "pkg")), false); + assert.match(fs.readFileSync(path.join(f.output, "index.html"), "utf8"), /__bloomReady.then/); +}); + +test("an installed optimizer failure stops assembly", (t) => { + const f = fixture(t, { failTool: "wasm-opt" }); + assert.throws(() => build(f.options, f.dependencies), (error) => error.exitCode === 23); + assert.equal(fs.existsSync(f.output), false); +}); + +test("explicitly configured optimizer is required", (t) => { + const f = fixture(t, { missingOptimizer: true }); + f.dependencies.env.BLOOM_WASM_OPT = "wasm-opt"; + assert.throws(() => build(f.options, f.dependencies), /executable not found/); + assert.equal(fs.existsSync(f.output), false); +}); + +test("engine-only builds do not require Perry", (t) => { + const f = fixture(t); + build({ output: f.output }, f.dependencies); + assert.equal(f.calls.some((call) => call.tool === "perry"), false); + assert.equal(fs.readFileSync(path.join(f.output, "index.html"), "utf8"), "fixture index.html"); +}); + +test("unsupported Perry output aborts instead of publishing an ungated game", () => { + assert.throws(() => splice("different format"), /perry-root/); + assert.throws(() => splice('
'), /boot block/); + assert.throws(() => splice('
window.__perryWasmB64'), /boot block shape/); + const html = splice(perryHtml); + assert.match(html, /window.__bloomReady.then\(\(\) => bootPerryWasm\("AA=="\)\).catch/); + assert.match(html, /function bootPerryWasm\(wasmBase64\)/); +}); diff --git a/tools/validate-docs.js b/tools/validate-docs.js index 14f91440..34ffa022 100644 --- a/tools/validate-docs.js +++ b/tools/validate-docs.js @@ -97,7 +97,7 @@ if (!ultraDocs || ultraDocs !== ultraSource) { } const packageJson = JSON.parse(read("package.json")); -if (packageJson.bin?.["bloom-web"] !== "native/web/build.sh") { +if (packageJson.bin?.["bloom-web"] !== "native/web/build.cjs") { fail("package.json does not expose the bloom-web command"); } @@ -116,6 +116,8 @@ if (pack.status !== 0) { } for (const required of [ "native/web/build.sh", + "native/web/build.cjs", + "native/web/splice_game.cjs", "native/web/splice_game.py", "crates/bloom-geometry-format/Cargo.toml", "crates/bloom-scene-format/Cargo.toml", @@ -127,22 +129,7 @@ if (pack.status !== 0) { } } -function bashExecutable() { - if (process.env.BLOOM_BASH) return process.env.BLOOM_BASH; - if (process.platform === "win32") { - // Git for Windows normally exposes git.exe through cmd/, while its Bash - // executable is deliberately absent from PATH. Use that same installation. - const gitPaths = spawnSync("where.exe", ["git"], { encoding: "utf8" }); - for (const gitPath of (gitPaths.stdout || "").trim().split(/\r?\n/)) { - if (!gitPath) continue; - const candidate = path.resolve(path.dirname(gitPath), "..", "bin", "bash.exe"); - if (fs.existsSync(candidate)) return candidate; - } - } - return "bash"; -} - -const help = spawnSync(bashExecutable(), ["native/web/build.sh", "--help"], { +const help = spawnSync(process.execPath, [packageJson.bin["bloom-web"], "--help"], { cwd: root, encoding: "utf8", }); From f30ebd0067b4dba9d4bb25c5e0ac9cb366a06c0b Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Fri, 11 Sep 2026 05:01:13 +0200 Subject: [PATCH 2/2] Await directory copies on Windows and record Node version --- docs/evidence/windows-portable-web-cli-v1.md | 12 ++++++++- native/web/build.cjs | 14 ++++++----- tools/ci/check-installed-web-cli.ps1 | 1 + tools/ci/test_web_build.cjs | 26 ++++++++++---------- 4 files changed, 33 insertions(+), 20 deletions(-) diff --git a/docs/evidence/windows-portable-web-cli-v1.md b/docs/evidence/windows-portable-web-cli-v1.md index 7b06c99b..2d41ebb2 100644 --- a/docs/evidence/windows-portable-web-cli-v1.md +++ b/docs/evidence/windows-portable-web-cli-v1.md @@ -48,7 +48,17 @@ Commands, exact candidate patch, package and artifact hashes, full build logs, and install receipts are retained under `tools/quality/out/windows-engine-plan/portable-web-cli/`. -Hosted validation is pending. The local browser connection is unavailable, so +The first hosted run passes the Linux contracts but its Windows regression +process exits with 3221226505 during directory assembly. The runner image uses +Node 22.23.2; local checks use Node 24.21.0. Node tracks a matching Unicode-path +failure in its synchronous directory copy implementation +([upstream report](https://github.com/nodejs/node/issues/59636)). This is a likely +match, not a captured native stack proving the same root cause. The command now +awaits asynchronous directory copies, and the same nine local checks plus the +actual Windows pack/install command pass. The original hosted failure is retained. +Full installed-build and hosted revalidation of this follow-up are pending. + +The local browser connection is unavailable, so no browser frame or runtime startup result is claimed here. Native startup, the one-command starter, shared lifecycle, wider example runtime matrix and packaged shader-runtime acceptance remain open under #142, #74 and #145. diff --git a/native/web/build.cjs b/native/web/build.cjs index cc2b31bb..538ff6f0 100644 --- a/native/web/build.cjs +++ b/native/web/build.cjs @@ -76,7 +76,7 @@ function requireFile(file, label) { } } -function build(options, { execute = spawnSync, webDir = __dirname, env = process.env } = {}) { +async function build(options, { execute = spawnSync, webDir = __dirname, env = process.env } = {}) { if (options.game) requireFile(options.game, "game entry"); const wasmPack = env.BLOOM_WASM_PACK || "wasm-pack"; const perry = env.BLOOM_PERRY || "perry"; @@ -123,14 +123,16 @@ function build(options, { execute = spawnSync, webDir = __dirname, env = process // Build in a unique staging directory. Failed commands cannot reuse an old // pkg/ or overwrite the caller's previous successful distribution. fs.mkdirSync(options.output, { recursive: true }); - fs.cpSync(pkg, path.join(options.output, "pkg"), { recursive: true }); + // Use the asynchronous copy implementation: Node's synchronous native + // directory-copy path has Windows failures with Unicode paths (#59636). + await fs.promises.cp(pkg, path.join(options.output, "pkg"), { recursive: true }); for (const file of ["bloom_glue.js", "jolt_bridge.js"]) { fs.copyFileSync(path.join(webDir, file), path.join(options.output, file)); } if (options.game) { const assets = path.join(path.dirname(options.game), "assets"); if (fs.existsSync(assets)) { - fs.cpSync(assets, path.join(options.output, "assets"), { recursive: true }); + await fs.promises.cp(assets, path.join(options.output, "assets"), { recursive: true }); } } fs.copyFileSync(index, path.join(options.output, "index.html")); @@ -146,11 +148,11 @@ function build(options, { execute = spawnSync, webDir = __dirname, env = process } } -function main(args = process.argv.slice(2)) { +async function main(args = process.argv.slice(2)) { try { const options = parseArgs(args); if (options.help) console.log(HELP); - else build(options); + else await build(options); return 0; } catch (error) { console.error(`bloom-web: ${error.message}`); @@ -159,4 +161,4 @@ function main(args = process.argv.slice(2)) { } module.exports = { BuildError, parseArgs, runTool, build, main }; -if (require.main === module) process.exitCode = main(); +if (require.main === module) main().then((code) => { process.exitCode = code; }); diff --git a/tools/ci/check-installed-web-cli.ps1 b/tools/ci/check-installed-web-cli.ps1 index ced9a807..6d4ae1e3 100644 --- a/tools/ci/check-installed-web-cli.ps1 +++ b/tools/ci/check-installed-web-cli.ps1 @@ -54,6 +54,7 @@ Push-Location $repoRoot try { $npmCommand = (Get-Command npm -ErrorAction Stop).Source $nodeCommand = (Get-Command node -ErrorAction Stop).Source + $report.node_version = (Invoke-Checked $nodeCommand @("--version") "node-version").Trim() Invoke-Checked $nodeCommand @("--test", "tools/ci/test_web_build.cjs") "regressions" | Out-Null $packedText = Invoke-Checked $npmCommand @("pack", "--json", "--ignore-scripts", "--pack-destination", $workDir) "pack" $packed = @($packedText | ConvertFrom-Json)[0] diff --git a/tools/ci/test_web_build.cjs b/tools/ci/test_web_build.cjs index 214686b4..3f1423a8 100644 --- a/tools/ci/test_web_build.cjs +++ b/tools/ci/test_web_build.cjs @@ -87,27 +87,27 @@ test("compiler exit code is retained", () => { ); }); -test("failed wasm-pack cannot reuse old output or assemble a false success", (t) => { +test("failed wasm-pack cannot reuse old output or assemble a false success", async (t) => { const f = fixture(t, { failTool: "wasm-pack" }); fs.mkdirSync(path.join(f.output, "pkg"), { recursive: true }); const previous = path.join(f.output, "pkg", "bloom_web_bg.wasm"); fs.writeFileSync(previous, "previous distribution"); - assert.throws(() => build(f.options, f.dependencies), (error) => error.exitCode === 23); + await assert.rejects(() => build(f.options, f.dependencies), (error) => error.exitCode === 23); assert.equal(fs.readFileSync(previous, "utf8"), "previous distribution"); assert.equal(fs.existsSync(path.join(f.output, "index.html")), false); assert.equal(f.calls.some((call) => call.tool === "wasm-opt"), false); }); -test("a zero exit with missing artifacts still fails", (t) => { +test("a zero exit with missing artifacts still fails", async (t) => { const f = fixture(t, { omitWasm: true }); - assert.throws(() => build(f.options, f.dependencies), /did not produce a non-empty file/); + await assert.rejects(() => build(f.options, f.dependencies), /did not produce a non-empty file/); assert.equal(fs.existsSync(f.output), false); }); -test("fresh assembly supports spaces, assets and repeated builds without pkg nesting", (t) => { +test("fresh assembly supports spaces, assets and repeated builds without pkg nesting", async (t) => { const f = fixture(t, { missingOptimizer: true }); - build(f.options, f.dependencies); - build(f.options, f.dependencies); + await build(f.options, f.dependencies); + await build(f.options, f.dependencies); const call = f.calls.find((item) => item.tool === "perry" && item.args[0] === "compile"); assert.equal(call.args[1], f.options.game); assert.equal(call.options.cwd, f.gameDir); @@ -118,22 +118,22 @@ test("fresh assembly supports spaces, assets and repeated builds without pkg nes assert.match(fs.readFileSync(path.join(f.output, "index.html"), "utf8"), /__bloomReady.then/); }); -test("an installed optimizer failure stops assembly", (t) => { +test("an installed optimizer failure stops assembly", async (t) => { const f = fixture(t, { failTool: "wasm-opt" }); - assert.throws(() => build(f.options, f.dependencies), (error) => error.exitCode === 23); + await assert.rejects(() => build(f.options, f.dependencies), (error) => error.exitCode === 23); assert.equal(fs.existsSync(f.output), false); }); -test("explicitly configured optimizer is required", (t) => { +test("explicitly configured optimizer is required", async (t) => { const f = fixture(t, { missingOptimizer: true }); f.dependencies.env.BLOOM_WASM_OPT = "wasm-opt"; - assert.throws(() => build(f.options, f.dependencies), /executable not found/); + await assert.rejects(() => build(f.options, f.dependencies), /executable not found/); assert.equal(fs.existsSync(f.output), false); }); -test("engine-only builds do not require Perry", (t) => { +test("engine-only builds do not require Perry", async (t) => { const f = fixture(t); - build({ output: f.output }, f.dependencies); + await build({ output: f.output }, f.dependencies); assert.equal(f.calls.some((call) => call.tool === "perry"), false); assert.equal(fs.readFileSync(path.join(f.output, "index.html"), "utf8"), "fixture index.html"); });