Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion MODULE.bazel.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 16 additions & 4 deletions docs/wasm_build.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions hands/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Hand list files used by dtest and WASM system tests.
exports_files(
[
"list1.txt",
],
visibility = ["//visibility:public"],
)
16 changes: 14 additions & 2 deletions library/tests/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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",
Expand Down
11 changes: 9 additions & 2 deletions library/tests/TestTimer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ void TestTimer::set_name(const string& s)
}


long clock_delta_to_ms(clock_t delta)
{
return static_cast<long>(
(1000.0 * static_cast<double>(delta)) /
static_cast<double>(CLOCKS_PER_SEC));
}


void TestTimer::start(const int number)
{
pending_hands_ = number;
Expand All @@ -75,8 +83,7 @@ void TestTimer::end()

duration<double, std::milli> d = user1 - user0_;
const long tuser = static_cast<long>(d.count());
const long tsys = static_cast<long>((1000 * (sys1 - sys0_)) /
static_cast<double>(CLOCKS_PER_SEC));
const long tsys = clock_delta_to_ms(sys1 - sys0_);

TestTimer::record(pending_hands_, tuser, tsys);
pending_hands_ = 0;
Expand Down
5 changes: 5 additions & 0 deletions library/tests/TestTimer.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions library/tests/test_timer_test.cpp
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
/// @file test_timer_test.cpp
/// @brief Unit tests for TestTimer batch min/max tracking and optional reporting.

#include <cstdint>
#include <ctime>
#include <gtest/gtest.h>
#include <iomanip>
#include <limits>
#include <sstream>
#include <string>

Expand All @@ -21,8 +24,73 @@ 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<std::uint64_t>(value) % kMod; // low 32 bits
if (bits > static_cast<std::uint64_t>(
std::numeric_limits<std::int32_t>::max())) {
return static_cast<std::int32_t>(
static_cast<std::int64_t>(bits) - static_cast<std::int64_t>(kMod));
}
return static_cast<std::int32_t>(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 =
wrap_i64_to_i32(1000 * static_cast<std::int64_t>(delta));
return static_cast<long>(prod / static_cast<double>(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<std::int32_t>::max();
return static_cast<clock_t>(
static_cast<std::int64_t>(kMaxI32) / 1000 + 1);
}

} // namespace

TEST(TestTimer, WrapI64ToI32UsesDefinedTwosComplement)
{
constexpr auto kMaxI32 = std::numeric_limits<std::int32_t>::max();
constexpr auto kMinI32 = std::numeric_limits<std::int32_t>::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<std::int64_t>(kMaxI32) + 1), kMinI32);
EXPECT_EQ(
wrap_i64_to_i32(static_cast<std::int64_t>(kMinI32) - 1),
kMaxI32);
// 1000 * ticks_that_overflow_i32_multiply()
EXPECT_EQ(
wrap_i64_to_i32(
1000 * static_cast<std::int64_t>(ticks_that_overflow_i32_multiply())),
-2147483296);
}

TEST(TestTimer, ClockDeltaToMsAvoids32BitOverflowForMultiSecondBatches)
{
const clock_t ticks = ticks_that_overflow_i32_multiply();
const long expected_ms = static_cast<long>(
(1000.0 * static_cast<double>(ticks)) /
static_cast<double>(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;
Expand Down
24 changes: 17 additions & 7 deletions specs/wasm-emscripten.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
capability: wasm-emscripten
owners: [wasm]
last-updated: 2026-07-18
last-updated: 2026-07-19
---

# WASM (Emscripten) Build
Expand All @@ -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
Expand All @@ -38,23 +42,29 @@ 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`.

## 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
Expand Down
38 changes: 36 additions & 2 deletions wasm/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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",
],
)
Expand Down
Loading
Loading