From 98bb19c44e8961c7c7a54ef62491edb9132d01eb Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Sun, 19 Jul 2026 14:03:42 -0500 Subject: [PATCH 1/9] Wire //wasm:dtest_wasm and a Node bazel-run wrapper. Port the dtest harness under Emscripten for Node (NODERAWFS) so WASM can be exercised against the same hand lists as native, with //wasm:run_dtest_wasm for convenient bazel run. Co-authored-by: Cursor --- MODULE.bazel.lock | 2 +- docs/wasm_build.md | 20 ++++++-- hands/BUILD.bazel | 4 ++ library/tests/BUILD.bazel | 16 ++++++- specs/wasm-emscripten.md | 24 +++++++--- wasm/BUILD.bazel | 38 ++++++++++++++- wasm/run_dtest_wasm.py | 63 +++++++++++++++++++++++++ wasm/tests/test_run_dtest_wasm.py | 61 ++++++++++++++++++++++++ wasm/tests/test_wasm_examples_system.py | 19 ++++++++ 9 files changed, 231 insertions(+), 16 deletions(-) create mode 100644 hands/BUILD.bazel create mode 100644 wasm/run_dtest_wasm.py create mode 100644 wasm/tests/test_run_dtest_wasm.py diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 5f82f03c..eb582401 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -328,7 +328,7 @@ "@@emsdk+//:emscripten_deps.bzl%emscripten_deps": { "general": { "bzlTransitiveDigest": "F42/mZKtzkBQnTUD4cHY6FUWdyW5Gm0Z1zmvUVFI0NY=", - "usagesDigest": "lqS0hMGr6MqmX63BGZOskMFcqzqO53K0UlYFxuE3QSU=", + "usagesDigest": "wvQRffIp8pEJeKXankBKEQBAOCI8HTtmw9TvnW7/e1c=", "recordedInputs": [ "REPO_MAPPING:bazel_features+,bazel_features_globals bazel_features++version_extension+bazel_features_globals", "REPO_MAPPING:bazel_features+,bazel_features_version bazel_features++version_extension+bazel_features_version", diff --git a/docs/wasm_build.md b/docs/wasm_build.md index 561192fa..f4e37688 100644 --- a/docs/wasm_build.md +++ b/docs/wasm_build.md @@ -44,15 +44,27 @@ Outputs are under `bazel-bin/wasm/`: - `solve_board.js` / `solve_board.wasm` - `AnalysePlayBin.js` / `AnalysePlayBin.wasm` - `calc_dd_table_pbn.js` / `calc_dd_table_pbn.wasm` +- `dtest.js` / `dtest.wasm` (from `//wasm:dtest_wasm`) ## Available WASM targets -Rules in `wasm/BUILD.bazel` wrap native examples in `examples/`: +Rules in `wasm/BUILD.bazel` wrap native binaries: -- `solve_board_wasm` — solves a single board +- `solve_board_wasm` — solves a single board (`//examples:solve_board`) - `analyse_play_bin_wasm` — analyze play from binary format - `calc_dd_table_pbn_wasm` — double-dummy table from PBN +- `dtest_wasm` — the `dtest` hand-list harness for Node (`//library/tests:dtest`) +`dtest_wasm` is linked with `NODERAWFS` so Node can pass a real host path to +`-f` (for example `hands/list1.txt`). Use `-n 1` (WASM is single-threaded). + +```bash +bazel build //wasm:dtest_wasm +node bazel-bin/wasm/dtest.js -f hands/list1.txt -s solve -n 1 + +# Or via Bazel (forwards args after -- to dtest; cwd is your shell's directory): +bazel run //wasm:run_dtest_wasm -- -f hands/list1.txt -s solve -n 1 +``` ## How it works 1. **`wasm_cc_binary`** (from `@emsdk`) transitions its `cc_target` to `@emsdk//:platform_wasm` and sets `--cpu=wasm`. @@ -129,13 +141,13 @@ bazel test //wasm:all - **`//web:dds_mvp_wasm_system_test`** — builds `//web:dds_mvp_wasm`, runs `patch_mvp_wasm` / `gen_wasm_bin_js` / `verify_wasm_js`, then calls `dds_mvp_calc_table` via Node (`web/tests/dds_mvp_wasm_node.mjs`). - **`//web:dds_mvp_e2e_test`** — Playwright tests for `dds_mvp.html` over `file://` and HTTP (part-score deal table, validation error). Requires Node, network (Chromium download on first run), and `tags = ["no-sandbox"]`. -- **`//wasm:wasm_examples_system_test`** — runs `calc_dd_table_pbn.js` under Node and checks for `OK` on all three example hands. +- **`//wasm:wasm_examples_system_test`** — runs `calc_dd_table_pbn.js` under Node (expects `OK` on all three example hands) and `dtest.js` on `hands/list1.txt` (`-s solve -n 1`). The MVP link flags include `-sENVIRONMENT=web,node` so the same `.js` / `.wasm` artifacts work in the browser and in Node system tests. ## Development notes -- A reusable `cc_library` WASM artifact (not only example binaries) is not yet provided; today only `wasm_cc_binary` example targets are wired up. +- A reusable `cc_library` WASM artifact (not only CLI binaries) is not yet provided; today `wasm_cc_binary` wraps selected examples plus `dtest`. - The browser MVP lives under `web/`; see **Web browser (DDS MVP)** above and `//web:web_system_tests`. ## Next steps diff --git a/hands/BUILD.bazel b/hands/BUILD.bazel new file mode 100644 index 00000000..9fbf8658 --- /dev/null +++ b/hands/BUILD.bazel @@ -0,0 +1,4 @@ +# Hand list files used by dtest and WASM system tests. +exports_files([ + "list1.txt", +]) diff --git a/library/tests/BUILD.bazel b/library/tests/BUILD.bazel index 2509ff72..974ba161 100644 --- a/library/tests/BUILD.bazel +++ b/library/tests/BUILD.bazel @@ -1,5 +1,6 @@ load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_test") load("//:CPPVARIABLES.bzl", "DDS_CPPOPTS", "DDS_LINKOPTS", "DDS_LOCAL_DEFINES", "DDS_SCHEDULER_DEFINE") +load("//:wasm_compat.bzl", "WASM_LINKOPTS") # Test utility sources (glob used for convenience; excludes integration/context equivalence tests) filegroup( @@ -15,12 +16,23 @@ filegroup( ), ) -# Main test executable for manual invocation +# Emscripten flags when //library/tests:dtest is built under wasm_cc_binary. +# NODERAWFS lets Node read host hand-list paths; ENVIRONMENT=node matches dtest CLI use. +DTEST_LINKOPTS_WASM = select({ + "//:build_wasm": WASM_LINKOPTS + [ + "-sENVIRONMENT=node", + "-sNODERAWFS=1", + "-sEXIT_RUNTIME=1", + ], + "//conditions:default": [], +}) + +# Main test executable for manual invocation (also the cc_target for //wasm:dtest_wasm) cc_binary( name = "dtest", srcs = [":test_sources"], copts = DDS_CPPOPTS, - linkopts = DDS_LINKOPTS, + linkopts = DDS_LINKOPTS + DTEST_LINKOPTS_WASM, local_defines = DDS_LOCAL_DEFINES + DDS_SCHEDULER_DEFINE, deps = [ "//library/src:dds", diff --git a/specs/wasm-emscripten.md b/specs/wasm-emscripten.md index 697fba99..907e0ac1 100644 --- a/specs/wasm-emscripten.md +++ b/specs/wasm-emscripten.md @@ -1,7 +1,7 @@ --- capability: wasm-emscripten owners: [wasm] -last-updated: 2026-07-18 +last-updated: 2026-07-19 --- # WASM (Emscripten) Build @@ -22,11 +22,15 @@ core solver builds and runs correctly under Emscripten. > Per-target detail is in the BUILD file and `docs/wasm_build.md`; these are the > capability-wide facts. -- **A curated subset of examples is ported.** `wasm_cc_binary` wraps three +- **A curated subset of CLIs is ported.** `wasm_cc_binary` wraps three [examples-cli](examples-cli.md) binaries: `solve_board_wasm` (← `//examples:solve_board`), `analyse_play_bin_wasm` (← `//examples:AnalysePlayBin`), and `calc_dd_table_pbn_wasm` (← `//examples:calc_dd_table_pbn`), each emitting a - `.js` loader + `.wasm`. `all_examples_wasm` groups them. + `.js` loader + `.wasm`. `all_examples_wasm` groups them. Separately, + `dtest_wasm` (← `//library/tests:dtest`) ports the hand-list test harness for + Node (host file access via `NODERAWFS`); it is not part of `all_examples_wasm`. + `//wasm:run_dtest_wasm` is a `py_binary` that runs that module under Node + (`bazel run //wasm:run_dtest_wasm -- …`). - **The toolchain is hermetic and transition-driven.** `wasm_cc_binary` applies an Emscripten **platform transition** to the underlying `cc_binary` — no `--config=wasm` or `.bazelrc` profile is needed. The emsdk toolchain is @@ -38,16 +42,21 @@ core solver builds and runs correctly under Emscripten. from `WASM_LINKOPTS` ([build-system](build-system.md)) — notably an 8 MB stack, because DDS search recursion overflows Emscripten's 64 KB default. Example binaries also attach those flags via `EXAMPLES_LINKOPTS_WASM` in `examples/BUILD.bazel`. + `dtest` adds Node-oriented flags (`ENVIRONMENT=node`, `NODERAWFS`, + `EXIT_RUNTIME`) under the same `build_wasm` select. - **Correctness is checked two ways.** `calc_dd_table_pbn_test` is a native `cc_test` over the same example logic (fast feedback without a JS runtime); the - `wasm_examples_system_test` py_test actually runs the built - `calc_dd_table_pbn_wasm` module end to end. The `all` and `wasm_system_tests` + `wasm_examples_system_test` py_test runs `calc_dd_table_pbn_wasm` and + `dtest_wasm` under Node end to end. `run_dtest_wasm_test` covers the Node + runner helpers. The `all` and `wasm_system_tests` suites bundle these. ## Key entry points -- `wasm/BUILD.bazel` — the three `wasm_cc_binary` targets, `all_examples_wasm`, - `calc_dd_table_pbn_test`, `wasm_examples_system_test`, and the test suites. +- `wasm/BUILD.bazel` — the example `wasm_cc_binary` targets, `dtest_wasm`, + `run_dtest_wasm`, `all_examples_wasm`, `calc_dd_table_pbn_test`, + `wasm_examples_system_test`, and the test suites. +- `wasm/run_dtest_wasm.py` — `bazel run` entry that invokes `dtest.js` via Node. - `wasm/tests/test_wasm_examples_system.py` — the end-to-end runner. - Consumer guide: `docs/wasm_build.md`. Shared link flags: `WASM_LINKOPTS` in `wasm_compat.bzl`. @@ -55,6 +64,7 @@ core solver builds and runs correctly under Emscripten. ## Known gaps / non-goals - **Only three examples are ported**, not the full [examples-cli](examples-cli.md) set. + `dtest_wasm` is an additional harness port, not an example CLI. - **No threaded WASM** — single-thread only. - Browser wiring, the site, MVP post-build JS patches (`web/patch_mvp_wasm.py`), and JS/e2e tests belong to [web-mvp](web-mvp.md); this capability provides the example diff --git a/wasm/BUILD.bazel b/wasm/BUILD.bazel index b9d78014..24a078f4 100644 --- a/wasm/BUILD.bazel +++ b/wasm/BUILD.bazel @@ -1,6 +1,6 @@ load("@rules_cc//cc:defs.bzl", "cc_test") load("@emsdk//emscripten_toolchain:wasm_rules.bzl", "wasm_cc_binary") -load("@rules_python//python:defs.bzl", "py_test") +load("@rules_python//python:defs.bzl", "py_binary", "py_test") load("//:CPPVARIABLES.bzl", "DDS_CPPOPTS", "DDS_LOCAL_DEFINES") wasm_cc_binary( @@ -30,6 +30,35 @@ wasm_cc_binary( ], ) +# dtest harness under Node (reads host hand-list files via NODERAWFS). +wasm_cc_binary( + name = "dtest_wasm", + cc_target = "//library/tests:dtest", + outputs = [ + "dtest.js", + "dtest.wasm", + ], +) + +# `bazel run //wasm:run_dtest_wasm -- -f hands/list1.txt -s solve -n 1` +py_binary( + name = "run_dtest_wasm", + srcs = ["run_dtest_wasm.py"], + main = "run_dtest_wasm.py", + data = [":dtest_wasm"], +) + +py_test( + name = "run_dtest_wasm_test", + size = "small", + srcs = [ + "run_dtest_wasm.py", + "tests/test_run_dtest_wasm.py", + ], + main = "tests/test_run_dtest_wasm.py", + imports = ["."], +) + filegroup( name = "all_examples_wasm", srcs = [ @@ -60,13 +89,18 @@ py_test( timeout = "short", main = "tests/test_wasm_examples_system.py", srcs = ["tests/test_wasm_examples_system.py"], - data = [":calc_dd_table_pbn_wasm"], + data = [ + ":calc_dd_table_pbn_wasm", + ":dtest_wasm", + "//hands:list1.txt", + ], ) test_suite( name = "all", tests = [ ":calc_dd_table_pbn_test", + ":run_dtest_wasm_test", ":wasm_examples_system_test", ], ) diff --git a/wasm/run_dtest_wasm.py b/wasm/run_dtest_wasm.py new file mode 100644 index 00000000..808e80cb --- /dev/null +++ b/wasm/run_dtest_wasm.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Run //wasm:dtest_wasm under Node.js (for `bazel run //wasm:run_dtest_wasm`).""" +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + + +def _runfiles_root() -> Path | None: + for key in ("RUNFILES_DIR", "TEST_SRCDIR"): + if key in os.environ: + return Path(os.environ[key]) + return None + + +def rlocation(relpath: str) -> Path: + """Resolve a runfiles path such as ``wasm/dtest.js``.""" + name = Path(relpath).name + candidates: list[Path] = [] + + # Same Bazel package: data deps land beside this script under bazel run. + # Do not Path.resolve() — that follows the runfiles symlink into the + # source tree and loses the sibling .js/.wasm data deps. + candidates.append(Path(__file__).absolute().parent / name) + + root = _runfiles_root() + if root is not None: + candidates.append(root / relpath) + candidates.append(root / "_main" / relpath) + + for candidate in candidates: + if candidate.exists(): + return candidate + raise FileNotFoundError(relpath) + + +def build_node_command(*, js_path: str, argv: list[str]) -> list[str]: + return ["node", js_path, *argv] + + +def resolve_cwd(env: dict[str, str]) -> str | None: + # `bazel run` sets this to the directory where the user invoked Bazel, so + # relative -f paths like hands/list1.txt resolve under NODERAWFS. + return env.get("BUILD_WORKING_DIRECTORY") or None + + +def main(argv: list[str] | None = None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + if not shutil.which("node"): + print("error: node not found on PATH", file=sys.stderr) + return 127 + + js = rlocation("wasm/dtest.js") + cmd = build_node_command(js_path=str(js), argv=args) + cwd = resolve_cwd(dict(os.environ)) + return subprocess.call(cmd, cwd=cwd) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/wasm/tests/test_run_dtest_wasm.py b/wasm/tests/test_run_dtest_wasm.py new file mode 100644 index 00000000..8bcf7f62 --- /dev/null +++ b/wasm/tests/test_run_dtest_wasm.py @@ -0,0 +1,61 @@ +"""Unit tests for the dtest_wasm Node runner helpers.""" +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from run_dtest_wasm import build_node_command, resolve_cwd, rlocation + + +class RunDtestWasmTest(unittest.TestCase): + def test_build_node_command_prepends_node_and_js(self) -> None: + cmd = build_node_command( + js_path="/runfiles/wasm/dtest.js", + argv=["-f", "hands/list1.txt", "-s", "solve", "-n", "1"], + ) + self.assertEqual( + cmd, + [ + "node", + "/runfiles/wasm/dtest.js", + "-f", + "hands/list1.txt", + "-s", + "solve", + "-n", + "1", + ], + ) + + def test_build_node_command_allows_empty_args(self) -> None: + cmd = build_node_command(js_path="/runfiles/wasm/dtest.js", argv=[]) + self.assertEqual(cmd, ["node", "/runfiles/wasm/dtest.js"]) + + def test_resolve_cwd_prefers_build_working_directory(self) -> None: + cwd = resolve_cwd( + { + "BUILD_WORKING_DIRECTORY": "/Users/me/src/dds", + "PWD": "/tmp/runfiles", + } + ) + self.assertEqual(cwd, "/Users/me/src/dds") + + def test_resolve_cwd_falls_back_to_none(self) -> None: + self.assertIsNone(resolve_cwd({})) + + def test_rlocation_finds_sibling_data_dep(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + sibling = Path(tmp) / "dtest.js" + sibling.write_text("// stub\n", encoding="utf-8") + fake_file = Path(tmp) / "run_dtest_wasm.py" + fake_file.write_text("#\n", encoding="utf-8") + with mock.patch("run_dtest_wasm.__file__", str(fake_file)): + with mock.patch.dict("os.environ", {}, clear=True): + found = rlocation("wasm/dtest.js") + self.assertTrue(found.samefile(sibling)) + + +if __name__ == "__main__": + unittest.main() diff --git a/wasm/tests/test_wasm_examples_system.py b/wasm/tests/test_wasm_examples_system.py index bfd4634b..dc20a337 100644 --- a/wasm/tests/test_wasm_examples_system.py +++ b/wasm/tests/test_wasm_examples_system.py @@ -39,6 +39,25 @@ def test_calc_dd_table_pbn_wasm(self) -> None: self.assertIn(f"CalcDDtable, hand {hand}: OK", proc.stdout) self.assertNotIn("ERROR", proc.stdout) + def test_dtest_wasm_solve_list1(self) -> None: + js = rlocation("wasm/dtest.js") + hands = rlocation("hands/list1.txt") + proc = subprocess.run( + ["node", str(js), "-f", str(hands), "-s", "solve", "-n", "1"], + capture_output=True, + text=True, + check=False, + timeout=120, + cwd=str(js.parent), + ) + self.assertEqual( + proc.returncode, + 0, + msg=f"stdout:\n{proc.stdout}\nstderr:\n{proc.stderr}", + ) + self.assertIn("Number of hands", proc.stdout) + self.assertNotIn(" ERROR", proc.stdout) + if __name__ == "__main__": unittest.main() From 6ef51450747cdd08be162690bf6b61b939cbef6b Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 21 Jul 2026 21:27:11 -0500 Subject: [PATCH 2/9] bazel mod deps --lockfile_mode=update --- MODULE.bazel.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 6cff3358..b7816577 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -327,7 +327,7 @@ }, "@@emsdk+//:emscripten_deps.bzl%emscripten_deps": { "general": { - "bzlTransitiveDigest": "F42/mZKtzkBQnTUD4cHY6FUWdyW5Gm0Z1zmvUVFI0NY=", + "bzlTransitiveDigest": "CpCK8JdAd4sMkjDZLg/mBRxzNO6d3nEkAW2H4mMike4=", "usagesDigest": "wvQRffIp8pEJeKXankBKEQBAOCI8HTtmw9TvnW7/e1c=", "recordedInputs": [ "REPO_MAPPING:bazel_features+,bazel_features_globals bazel_features++version_extension+bazel_features_globals", From 83f95a0dbb656fc52b6e05b2fff1ae33c36714a4 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 22 Jul 2026 20:36:56 +0200 Subject: [PATCH 3/9] Fix TestTimer sys-time overflow on 32-bit (wasm) clocks. Integer `1000 * clock_delta` wraps for batches longer than ~2.15s, so convert via floating point and cover it with a unit test. Co-authored-by: Cursor --- library/tests/TestTimer.cpp | 11 +++++++++-- library/tests/TestTimer.hpp | 5 +++++ library/tests/test_timer_test.cpp | 25 +++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/library/tests/TestTimer.cpp b/library/tests/TestTimer.cpp index 1b2976da..b34eacd4 100644 --- a/library/tests/TestTimer.cpp +++ b/library/tests/TestTimer.cpp @@ -60,6 +60,14 @@ void TestTimer::set_name(const string& s) } +long clock_delta_to_ms(clock_t delta) +{ + return static_cast( + (1000.0 * static_cast(delta)) / + static_cast(CLOCKS_PER_SEC)); +} + + void TestTimer::start(const int number) { pending_hands_ = number; @@ -75,8 +83,7 @@ void TestTimer::end() duration d = user1 - user0_; const long tuser = static_cast(d.count()); - const long tsys = static_cast((1000 * (sys1 - sys0_)) / - static_cast(CLOCKS_PER_SEC)); + const long tsys = clock_delta_to_ms(sys1 - sys0_); TestTimer::record(pending_hands_, tuser, tsys); pending_hands_ = 0; diff --git a/library/tests/TestTimer.hpp b/library/tests/TestTimer.hpp index 0ad78347..5eb03be1 100644 --- a/library/tests/TestTimer.hpp +++ b/library/tests/TestTimer.hpp @@ -25,6 +25,11 @@ using std::chrono::time_point; /// of test execution. Useful for performance regression detection /// and identifying slow test hands. +/// Convert a `clock()` tick delta to milliseconds. +/// Uses floating-point so `1000 * ticks` cannot overflow 32-bit `long` +/// (wasm32 batches longer than ~2.15s when CLOCKS_PER_SEC is 1e6). +long clock_delta_to_ms(clock_t delta); + /// Timer for measuring test performance. /// Tracks both wall-clock (user) and CPU (system) time for test execution. class TestTimer diff --git a/library/tests/test_timer_test.cpp b/library/tests/test_timer_test.cpp index f6366454..d3d95fc5 100644 --- a/library/tests/test_timer_test.cpp +++ b/library/tests/test_timer_test.cpp @@ -1,6 +1,8 @@ /// @file test_timer_test.cpp /// @brief Unit tests for TestTimer batch min/max tracking and optional reporting. +#include +#include #include #include #include @@ -21,8 +23,31 @@ std::string capture_print_hands( return out.str(); } +/// What the old `1000 * delta` path produces when `long` is 32-bit (wasm32). +long wrapped_i32_clock_delta_to_ms(const clock_t delta) +{ + const auto prod = + static_cast(1000 * static_cast(delta)); + return static_cast(prod / static_cast(CLOCKS_PER_SEC)); +} + } // namespace +TEST(TestTimer, ClockDeltaToMsAvoids32BitOverflowForMultiSecondBatches) +{ + // ~3.478s of CLOCKS_PER_SEC ticks: integer `1000 * ticks` overflows int32. + const clock_t ticks = static_cast(3.478 * CLOCKS_PER_SEC); + const long expected_ms = static_cast( + (1000.0 * static_cast(ticks)) / + static_cast(CLOCKS_PER_SEC)); + const long wrapped_ms = wrapped_i32_clock_delta_to_ms(ticks); + + ASSERT_NE(wrapped_ms, expected_ms) + << "fixture requires a delta that wraps under 32-bit multiply"; + EXPECT_EQ(clock_delta_to_ms(ticks), expected_ms); + EXPECT_NE(clock_delta_to_ms(ticks), wrapped_ms); +} + TEST(TestTimer, RecordTracksMinAndMaxPerHandAcrossBatches) { TestTimer timer; From 51020b2dd3722f7ef504621cbb024802d1133715 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 22 Jul 2026 21:33:52 +0200 Subject: [PATCH 4/9] Make clock overflow fixture independent of CLOCKS_PER_SEC. Windows uses CLOCKS_PER_SEC=1000, so a fixed ~3.5s delta never wraps int32; pick the minimal tick count that does. Co-authored-by: Cursor --- library/tests/test_timer_test.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/library/tests/test_timer_test.cpp b/library/tests/test_timer_test.cpp index d3d95fc5..d27f3b7a 100644 --- a/library/tests/test_timer_test.cpp +++ b/library/tests/test_timer_test.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -31,12 +32,20 @@ long wrapped_i32_clock_delta_to_ms(const clock_t delta) return static_cast(prod / static_cast(CLOCKS_PER_SEC)); } +/// Smallest tick count where `1000 * ticks` no longer fits in int32. +/// Independent of CLOCKS_PER_SEC (1000 on Windows, 1e6 on POSIX/wasm). +clock_t ticks_that_overflow_i32_multiply() +{ + constexpr auto kMaxI32 = std::numeric_limits::max(); + return static_cast( + static_cast(kMaxI32) / 1000 + 1); +} + } // namespace TEST(TestTimer, ClockDeltaToMsAvoids32BitOverflowForMultiSecondBatches) { - // ~3.478s of CLOCKS_PER_SEC ticks: integer `1000 * ticks` overflows int32. - const clock_t ticks = static_cast(3.478 * CLOCKS_PER_SEC); + const clock_t ticks = ticks_that_overflow_i32_multiply(); const long expected_ms = static_cast( (1000.0 * static_cast(ticks)) / static_cast(CLOCKS_PER_SEC)); From 0dd682993ea1bf396b54251df988b5e53cd82d23 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 23 Jul 2026 07:47:23 +0200 Subject: [PATCH 5/9] Tighten WASM system-test ERROR checks to cover stderr. Co-authored-by: Cursor --- wasm/tests/test_wasm_examples_system.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wasm/tests/test_wasm_examples_system.py b/wasm/tests/test_wasm_examples_system.py index dc20a337..119f71ea 100644 --- a/wasm/tests/test_wasm_examples_system.py +++ b/wasm/tests/test_wasm_examples_system.py @@ -37,7 +37,7 @@ def test_calc_dd_table_pbn_wasm(self) -> None: self.assertEqual(proc.returncode, 0, msg=proc.stderr) for hand in (1, 2, 3): self.assertIn(f"CalcDDtable, hand {hand}: OK", proc.stdout) - self.assertNotIn("ERROR", proc.stdout) + self.assertNotIn("ERROR", proc.stdout + proc.stderr) def test_dtest_wasm_solve_list1(self) -> None: js = rlocation("wasm/dtest.js") @@ -56,7 +56,7 @@ def test_dtest_wasm_solve_list1(self) -> None: msg=f"stdout:\n{proc.stdout}\nstderr:\n{proc.stderr}", ) self.assertIn("Number of hands", proc.stdout) - self.assertNotIn(" ERROR", proc.stdout) + self.assertNotIn("ERROR", proc.stdout + proc.stderr) if __name__ == "__main__": From 523dd2696ac72b504bc6334a0b7fe2f8c8152004 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 23 Jul 2026 08:21:00 +0200 Subject: [PATCH 6/9] Define 32-bit clock wrap with explicit modulo mapping. Narrowing out-of-range values to int32 is implementation-defined; modulo 2^32 plus signed remapping keeps the overflow fixture portable. Co-authored-by: Cursor --- library/tests/test_timer_test.cpp | 36 ++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/library/tests/test_timer_test.cpp b/library/tests/test_timer_test.cpp index d27f3b7a..51e308bc 100644 --- a/library/tests/test_timer_test.cpp +++ b/library/tests/test_timer_test.cpp @@ -24,11 +24,26 @@ std::string capture_print_hands( return out.str(); } +/// Map a 64-bit value into signed int32 via explicit two's-complement wrap. +/// Avoids implementation-defined narrowing of out-of-range values to int32. +std::int32_t wrap_i64_to_i32(const std::int64_t value) +{ + constexpr auto kMod = std::uint64_t{1} << 32; + const auto bits = + static_cast(value) % kMod; // low 32 bits + if (bits > static_cast( + std::numeric_limits::max())) { + return static_cast( + static_cast(bits) - static_cast(kMod)); + } + return static_cast(bits); +} + /// What the old `1000 * delta` path produces when `long` is 32-bit (wasm32). long wrapped_i32_clock_delta_to_ms(const clock_t delta) { const auto prod = - static_cast(1000 * static_cast(delta)); + wrap_i64_to_i32(1000 * static_cast(delta)); return static_cast(prod / static_cast(CLOCKS_PER_SEC)); } @@ -43,6 +58,25 @@ clock_t ticks_that_overflow_i32_multiply() } // namespace +TEST(TestTimer, WrapI64ToI32UsesDefinedTwosComplement) +{ + constexpr auto kMaxI32 = std::numeric_limits::max(); + constexpr auto kMinI32 = std::numeric_limits::min(); + + EXPECT_EQ(wrap_i64_to_i32(0), 0); + EXPECT_EQ(wrap_i64_to_i32(kMaxI32), kMaxI32); + EXPECT_EQ(wrap_i64_to_i32(kMinI32), kMinI32); + EXPECT_EQ(wrap_i64_to_i32(static_cast(kMaxI32) + 1), kMinI32); + EXPECT_EQ( + wrap_i64_to_i32(static_cast(kMinI32) - 1), + kMaxI32); + // 1000 * ticks_that_overflow_i32_multiply() + EXPECT_EQ( + wrap_i64_to_i32( + 1000 * static_cast(ticks_that_overflow_i32_multiply())), + -2147483296); +} + TEST(TestTimer, ClockDeltaToMsAvoids32BitOverflowForMultiSecondBatches) { const clock_t ticks = ticks_that_overflow_i32_multiply(); From 5bafd4a0cec11c306259b96ea15391afb0266f47 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 23 Jul 2026 08:44:52 +0200 Subject: [PATCH 7/9] Mark //hands:list1.txt as publicly visible. Make the exports_files visibility explicit so cross-package deps (e.g. WASM system tests) stay clear and match other packages. Co-authored-by: Cursor --- hands/BUILD.bazel | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/hands/BUILD.bazel b/hands/BUILD.bazel index 9fbf8658..eedd3460 100644 --- a/hands/BUILD.bazel +++ b/hands/BUILD.bazel @@ -1,4 +1,7 @@ # Hand list files used by dtest and WASM system tests. -exports_files([ - "list1.txt", -]) +exports_files( + [ + "list1.txt", + ], + visibility = ["//visibility:public"], +) From 6cd9659d96cd8652fa4f98c9e6534f191c5488b8 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 23 Jul 2026 12:00:06 +0200 Subject: [PATCH 8/9] Resolve dtest.js via RUNFILES_MANIFEST_FILE when needed. Directory-based runfiles are unset on some platforms (notably Windows), so fall back to the manifest for bazel run //wasm:run_dtest_wasm. Co-authored-by: Cursor --- wasm/run_dtest_wasm.py | 36 ++++++++++++++++++++++++++ wasm/tests/test_run_dtest_wasm.py | 42 +++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/wasm/run_dtest_wasm.py b/wasm/run_dtest_wasm.py index 808e80cb..3f422a03 100644 --- a/wasm/run_dtest_wasm.py +++ b/wasm/run_dtest_wasm.py @@ -16,6 +16,37 @@ def _runfiles_root() -> Path | None: return None +def _rlocation_from_manifest(relpath: str) -> Path | None: + """Look up ``relpath`` in RUNFILES_MANIFEST_FILE (Windows-style runfiles).""" + manifest = os.environ.get("RUNFILES_MANIFEST_FILE") + if not manifest: + return None + keys = {relpath, f"_main/{relpath}"} + try: + with open(manifest, encoding="utf-8") as fh: + for line in fh: + line = line.rstrip("\n") + if not line or line.startswith("["): + # Skip empty lines and JSON/ndjson manifest entries. + continue + # Classic format: " " + # Escaped keys (paths with spaces) start with a leading space; + # we only need unescaped wasm/... keys here. + if line.startswith(" "): + continue + space = line.find(" ") + if space < 0: + continue + key, value = line[:space], line[space + 1 :] + if key in keys and value: + path = Path(value) + if path.exists(): + return path + except OSError: + return None + return None + + def rlocation(relpath: str) -> Path: """Resolve a runfiles path such as ``wasm/dtest.js``.""" name = Path(relpath).name @@ -34,6 +65,11 @@ def rlocation(relpath: str) -> Path: for candidate in candidates: if candidate.exists(): return candidate + + from_manifest = _rlocation_from_manifest(relpath) + if from_manifest is not None: + return from_manifest + raise FileNotFoundError(relpath) diff --git a/wasm/tests/test_run_dtest_wasm.py b/wasm/tests/test_run_dtest_wasm.py index 8bcf7f62..6564b1f3 100644 --- a/wasm/tests/test_run_dtest_wasm.py +++ b/wasm/tests/test_run_dtest_wasm.py @@ -56,6 +56,48 @@ def test_rlocation_finds_sibling_data_dep(self) -> None: found = rlocation("wasm/dtest.js") self.assertTrue(found.samefile(sibling)) + def test_rlocation_uses_runfiles_manifest_file(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + real = root / "actual_dtest.js" + real.write_text("// stub\n", encoding="utf-8") + manifest = root / "MANIFEST" + manifest.write_text( + f"_main/wasm/dtest.js {real}\n", + encoding="utf-8", + ) + # No sibling data dep beside the script (manifest-only platforms). + fake_script = root / "pkg" / "run_dtest_wasm.py" + fake_script.parent.mkdir() + fake_script.write_text("#\n", encoding="utf-8") + with mock.patch("run_dtest_wasm.__file__", str(fake_script)): + with mock.patch.dict( + "os.environ", + {"RUNFILES_MANIFEST_FILE": str(manifest)}, + clear=True, + ): + found = rlocation("wasm/dtest.js") + self.assertTrue(found.samefile(real)) + + def test_rlocation_manifest_accepts_unprefixed_key(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + real = root / "dtest.js" + real.write_text("// stub\n", encoding="utf-8") + manifest = root / "MANIFEST" + manifest.write_text(f"wasm/dtest.js {real}\n", encoding="utf-8") + fake_script = root / "pkg" / "run_dtest_wasm.py" + fake_script.parent.mkdir() + fake_script.write_text("#\n", encoding="utf-8") + with mock.patch("run_dtest_wasm.__file__", str(fake_script)): + with mock.patch.dict( + "os.environ", + {"RUNFILES_MANIFEST_FILE": str(manifest)}, + clear=True, + ): + found = rlocation("wasm/dtest.js") + self.assertTrue(found.samefile(real)) + if __name__ == "__main__": unittest.main() From 02570eb94c34f7f29adf605133e83bd68503d989 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 23 Jul 2026 11:00:16 +0100 Subject: [PATCH 9/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- wasm/run_dtest_wasm.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/wasm/run_dtest_wasm.py b/wasm/run_dtest_wasm.py index 808e80cb..ac8c17a9 100644 --- a/wasm/run_dtest_wasm.py +++ b/wasm/run_dtest_wasm.py @@ -31,6 +31,25 @@ def rlocation(relpath: str) -> Path: candidates.append(root / relpath) candidates.append(root / "_main" / relpath) + manifest = os.environ.get("RUNFILES_MANIFEST_FILE") + if manifest: + def manifest_lookup(key: str) -> Path | None: + try: + with open(manifest, encoding="utf-8") as f: + for line in f: + if line.startswith(key + " "): + _, path = line.rstrip("\n").split(" ", 1) + return Path(path) + except OSError: + return None + return None + + for key in (relpath, f"_main/{relpath}"): + resolved = manifest_lookup(key) + if resolved is not None: + candidates.append(resolved) + break + for candidate in candidates: if candidate.exists(): return candidate