From 4bb5fb65a9cdd35b5442273efa8c9bf0bb1a2130 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Mon, 1 Jun 2026 12:42:08 +0200 Subject: [PATCH 01/45] Cursor: Refactor using best practices so that the build can create wasm targets without breaking "bazel build --config=wasm //..." --- .bazelrc | 4 + .github/workflows/ci_wasm.yml | 4 +- BUILD.bazel | 2 + docs/wasm_build.md | 108 +++++----------- examples/BUILD.bazel | 118 ++++++------------ examples/wasm/BUILD.bazel | 28 +++++ library/src/BUILD.bazel | 10 -- library/src/heuristic_sorting/BUILD.bazel | 8 -- library/src/lookup_tables/BUILD.bazel | 8 -- library/src/moves/BUILD.bazel | 8 -- library/src/solver_context/BUILD.bazel | 8 -- library/src/system/BUILD.bazel | 8 -- library/src/trans_table/BUILD.bazel | 11 -- library/src/utility/BUILD.bazel | 9 -- library/tests/BUILD.bazel | 4 + library/tests/heuristic_sorting/BUILD.bazel | 9 +- library/tests/moves/BUILD.bazel | 3 +- .../regression/heuristic_sorting/BUILD.bazel | 3 + library/tests/solve_board/BUILD.bazel | 4 + library/tests/system/BUILD.bazel | 17 +++ library/tests/trans_table/BUILD.bazel | 5 +- library/tests/utility/BUILD.bazel | 5 + wasm/BUILD.bazel | 1 + wasm_compat.bzl | 15 +++ 24 files changed, 164 insertions(+), 236 deletions(-) create mode 100644 examples/wasm/BUILD.bazel create mode 100644 wasm/BUILD.bazel create mode 100644 wasm_compat.bzl diff --git a/.bazelrc b/.bazelrc index d0ae7bf8..f637e4eb 100644 --- a/.bazelrc +++ b/.bazelrc @@ -49,8 +49,12 @@ build:tsan --compilation_mode=dbg # WebAssembly (WASM) configuration # Usage: bazel build --config=wasm //... # Compiles using the hermetic emsdk toolchain downloaded automatically by Bazel. +# Targets tagged "native-only" (Python, tests, most examples) are skipped. build:wasm --platforms=@emsdk//:platform_wasm build:wasm --cpu=wasm build:wasm --cxxopt=-std=c++20 build:wasm --host_cxxopt=-std=c++20 build:wasm --compilation_mode=opt +build:wasm --build_tag_filters=-native-only +# Python bindings and C++ unit tests require the host toolchain. +build:wasm --deleted_packages=python,library/tests diff --git a/.github/workflows/ci_wasm.yml b/.github/workflows/ci_wasm.yml index 7b0d9fcb..516ee02c 100644 --- a/.github/workflows/ci_wasm.yml +++ b/.github/workflows/ci_wasm.yml @@ -22,8 +22,8 @@ jobs: # 3️⃣ Build WASM targets (hermetic emsdk toolchain downloaded by Bazel) - name: Build WASM targets - run: bazel build --config=wasm --verbose_failures //examples:all_examples_wasm + run: bazel build --config=wasm --verbose_failures //... # 4️⃣ Smoke test: run solve_board under Node.js — pass if it does not crash - name: Smoke test solve_board_wasm - run: node bazel-bin/examples/solve_board.js + run: node bazel-bin/examples/wasm/solve_board.js diff --git a/BUILD.bazel b/BUILD.bazel index 02ea9a23..75eb3a9a 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -121,6 +121,7 @@ EOF tags = [ "manual", "local", + "native-only", "no-remote", "no-remote-cache", ], @@ -134,6 +135,7 @@ cc_library( cc_library( name = "testable_dds", + tags = ["native-only"], deps = ["//library/src:testable_dds"], visibility = [ "//library/tests:__pkg__", diff --git a/docs/wasm_build.md b/docs/wasm_build.md index 9cfca6db..2d2a2157 100644 --- a/docs/wasm_build.md +++ b/docs/wasm_build.md @@ -13,107 +13,70 @@ The Emscripten SDK (emsdk) does NOT need to be manually installed. Bazel will au ## Building WASM Examples -### Build All Examples +### Build all WASM-compatible targets + +Native-only targets (Python bindings, C++ tests, most examples) are skipped automatically: ```bash -cd /workspaces/dds -bazel build //examples:all_examples_wasm +bazel build --config=wasm //... ``` -### Build Specific Example +### Build WASM examples only ```bash -bazel build //examples:solve_board_wasm +bazel build --config=wasm //examples/wasm:all_examples_wasm ``` -### Output Files +The alias `//examples:all_examples_wasm` points at the same filegroup. -the output files will be located in: -``` -bazel-bin/examples/ +### Build a specific example + +```bash +bazel build --config=wasm //examples/wasm:solve_board_wasm ``` -Depending on the target, you'll get: -- `target.js` - JavaScript bindings -- `target.wasm` - WebAssembly binary +### Output Files +Output files are under `bazel-bin/examples/wasm/` (wasm_cc_binary output layout): -## Available WASM Targets +- `solve_board.js` / `solve_board.wasm` +- `AnalysePlayBin.js` / `AnalysePlayBin.wasm` -The following example targets are available for WASM builds: - -### Implemented Examples -- `solve_board` - Solves a single board (produces .js and .wasm) +## Available WASM Targets -- `analyse_play_bin` - Analyze play from binary format +WASM rules live in `examples/wasm/BUILD.bazel` and wrap native `cc_binary` targets in `examples/`: +- `solve_board_wasm` — solves a single board +- `analyse_play_bin_wasm` — analyze play from binary format ## WASM Build Configuration -The WASM build is configured through: - -1. **BUILD.bazel** - Defines the WASM config_setting: - ```python - config_setting( - name = "build_wasm", - values = {"cpu": "wasm"}, - ) - ``` - -2. **CPPVARIABLES.bzl** - Specifies WASM-specific compiler flags: - - Optimization: `-O3 -flto` - - Exceptions: `-fexceptions` - - Define: `-D__WASM__` - -3. **.bazelrc** - Contains the `wasm` profile: - ``` - build:wasm --platforms=@emsdk//:platform_wasm - build:wasm --cpu=wasm - build:wasm --cxxopt=-std=c++20 - build:wasm --host_cxxopt=-std=c++20 - build:wasm --compilation_mode=opt - ``` - -## Running WASM Examples - -After building, you can run the examples: +1. **`BUILD.bazel`** — `//:build_wasm` matches `--cpu=wasm` from the `wasm` config +2. **`wasm_compat.bzl`** — shared Emscripten link flags (`WASM_LINKOPTS`) +3. **`CPPVARIABLES.bzl`** — WASM compiler flags and `__WASM__` define +4. **`.bazelrc`** — `wasm` config (platform, `--build_tag_filters=-native-only`) -### Node.js (requires Node.js installed) +Targets tagged `native-only` are excluded from `bazel build --config=wasm //...` (most examples, Doxygen). The `python` and `library/tests` packages are omitted entirely via `--deleted_packages`. -```bash -node bazel-bin/examples/solve_board.js -``` +## Running WASM Examples -### Web Browser (TODO) +### Node.js -For HTML targets, open the generated HTML file in a web browser: ```bash -# Copy the output files to a web-accessible location -cp bazel-bin/examples/solve_board.* /path/to/webserver/ - -# Then open in browser at http://localhost:8000/solve_board.html +node bazel-bin/examples/wasm/solve_board.js ``` ## Compilation Flags -The WASM build uses the following key flags: - | Flag | Purpose | |------|---------| | `-O3` | Aggressive optimization | | `-flto` | Link-time optimization | | `-fexceptions` | Enable C++ exceptions | -| `-D__WASM__` | Defines `__WASM__` preprocessor constant | +| `-D__WASM__` | Preprocessor constant for WASM builds | | `-sWASM=1` | Emscripten WASM output (link flag) | | `-sALLOW_MEMORY_GROWTH=1` | Allow heap growth at runtime | -| `-sINITIAL_MEMORY=268435456` | Configure 256MB initial memory | - -## C++ Standard - -The WASM build uses C++20 as specified in `.bazelrc`: -``` -build:wasm --cxxopt=-std=c++20 -``` +| `-sINITIAL_MEMORY=268435456` | 256MB initial memory | ## Related Documentation @@ -121,16 +84,3 @@ build:wasm --cxxopt=-std=c++20 - [Bazel Build System](https://bazel.build/docs) - [DDS C++ API](c++_interface.md) - [Build System Overview](BUILD_SYSTEM.md) - -## Next Steps - -To integrate WASM builds into CI/CD: -1. See `.github/workflows/ci_linux.yml` and `.github/workflows/ci_macos.yml` -2. Store WASM artifacts for download/release - -## Development Notes - -- The `__WASM__` preprocessor constant is added initially to bypass error, need to check again whether we can remove -- Some threading and platform-specific features are disabled for WASM -- The build flow for a reusable library wasm -- A good HTML example diff --git a/examples/BUILD.bazel b/examples/BUILD.bazel index e577e567..4e6f1000 100644 --- a/examples/BUILD.bazel +++ b/examples/BUILD.bazel @@ -1,60 +1,6 @@ load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_library") load("//:CPPVARIABLES.bzl", "DDS_CPPOPTS", "DDS_LINKOPTS", "DDS_LOCAL_DEFINES") -load("@emsdk//emscripten_toolchain:wasm_rules.bzl", "wasm_cc_binary") - -filegroup( - name = "solve_board_wasm_inputs", - srcs = [ - "hands.cpp", - "solve_board.cpp", - "//library/src:dds_wasm_sources", - "//library/src:testable_dds_headers", - "//library/src/heuristic_sorting:wasm_sources", - "//library/src/lookup_tables:wasm_sources", - "//library/src/moves:wasm_sources", - "//library/src/solver_context:wasm_sources", - "//library/src/system:wasm_sources", - "//library/src/trans_table:wasm_sources", - "//library/src/utility:wasm_sources", - ], -) - -filegroup( - name = "analyse_play_bin_wasm_inputs", - srcs = [ - "hands.cpp", - "analyse_play_bin.cpp", - "//library/src:dds_wasm_sources", - "//library/src:testable_dds_headers", - "//library/src/heuristic_sorting:wasm_sources", - "//library/src/lookup_tables:wasm_sources", - "//library/src/moves:wasm_sources", - "//library/src/solver_context:wasm_sources", - "//library/src/system:wasm_sources", - "//library/src/trans_table:wasm_sources", - "//library/src/utility:wasm_sources", - ], -) - -wasm_cc_binary( - name = "solve_board_wasm", - cc_target = ":solve_board", - outputs = [ - "solve_board.js", - "solve_board.wasm", - ], - tags = ["manual"], -) - -wasm_cc_binary( - name = "analyse_play_bin_wasm", - cc_target = ":AnalysePlayBin", - outputs = [ - "AnalysePlayBin.js", - "AnalysePlayBin.wasm", - ], - tags = ["manual"], -) +load("//:wasm_compat.bzl", "WASM_LINKOPTS") # Examples use legacy C-style code, so we need to suppress certain warnings EXAMPLES_CPPOPTS = DDS_CPPOPTS + select({ @@ -73,14 +19,9 @@ EXAMPLES_LOCAL_DEFINES = DDS_LOCAL_DEFINES + select({ "//conditions:default": [], }) -# WASM-specific link flags for em++ output (produces .html, .js, .wasm) +# Emscripten link flags for examples built as WASM (see //examples/wasm). EXAMPLES_LINKOPTS_WASM = select({ - "//:build_wasm": [ - "-sWASM=1", - "-fexceptions", - "-sALLOW_MEMORY_GROWTH=1", - "-sINITIAL_MEMORY=268435456", - ], + "//:build_wasm": WASM_LINKOPTS, "//conditions:default": [], }) @@ -102,8 +43,9 @@ cc_binary( name = "analyse_all_plays_bin", srcs = ["analyse_all_plays_bin.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, + tags = ["native-only"], deps = [ ":hands", "//library/src:dds", @@ -115,8 +57,9 @@ cc_binary( name = "analyse_all_plays_pbn", srcs = ["analyse_all_plays_pbn.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, + tags = ["native-only"], deps = [ ":hands", "//library/src:dds", @@ -130,6 +73,7 @@ cc_binary( copts = EXAMPLES_CPPOPTS, linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, local_defines = EXAMPLES_LOCAL_DEFINES, + visibility = ["//examples/wasm:__pkg__"], deps = [ ":hands", "//library/src:dds", @@ -141,8 +85,9 @@ cc_binary( name = "analyse_play_pbn", srcs = ["analyse_play_pbn.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, + tags = ["native-only"], deps = [ ":hands", "//library/src:dds", @@ -154,8 +99,9 @@ cc_binary( name = "calc_all_tables", srcs = ["calc_all_tables.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, + tags = ["native-only"], deps = [ ":hands", "//library/src:dds", @@ -167,8 +113,9 @@ cc_binary( name = "calc_all_tables_pbn", srcs = ["calc_all_tables_pbn.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, + tags = ["native-only"], deps = [ ":hands", "//library/src:dds", @@ -180,8 +127,9 @@ cc_binary( name = "calc_dd_table", srcs = ["calc_dd_table.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, + tags = ["native-only"], deps = [ ":hands", "//library/src:dds", @@ -193,8 +141,9 @@ cc_binary( name = "calc_dd_table_pbn", srcs = ["calc_dd_table_pbn.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, + tags = ["native-only"], deps = [ ":hands", "//library/src:dds", @@ -206,8 +155,9 @@ cc_binary( name = "dealer_par", srcs = ["dealer_par.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, + tags = ["native-only"], deps = [ ":hands", "//library/src:dds", @@ -219,8 +169,9 @@ cc_binary( name = "par", srcs = ["par.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, + tags = ["native-only"], deps = [ ":hands", "//library/src:dds", @@ -232,8 +183,9 @@ cc_binary( name = "solve_all_boards", srcs = ["solve_all_boards.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, + tags = ["native-only"], deps = [ ":hands", "//library/src:dds", @@ -247,6 +199,7 @@ cc_binary( copts = EXAMPLES_CPPOPTS, linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, local_defines = EXAMPLES_LOCAL_DEFINES, + visibility = ["//examples/wasm:__pkg__"], deps = [ ":hands", "//library/src:dds", @@ -258,8 +211,9 @@ cc_binary( name = "solve_board_pbn", srcs = ["solve_board_pbn.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, + tags = ["native-only"], deps = [ ":hands", "//library/src:dds", @@ -271,8 +225,9 @@ cc_binary( name = "migration_example", srcs = ["migration_example.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, + tags = ["native-only"], deps = [ "//library/src:dds", "//library/src/api:api_definitions", @@ -283,8 +238,9 @@ cc_binary( name = "calc_par_context_example", srcs = ["calc_par_context_example.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, + linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, + tags = ["native-only"], deps = [ ":hands", "//library/src:dds", @@ -293,9 +249,10 @@ cc_binary( ], ) -# Convenience target to build all examples +# Convenience target to build all native examples filegroup( name = "all_examples", + tags = ["native-only"], srcs = [ ":analyse_all_plays_bin", ":analyse_all_plays_pbn", @@ -315,11 +272,8 @@ filegroup( ], ) -filegroup( +# Backward-compatible alias; prefer //examples/wasm:all_examples_wasm. +alias( name = "all_examples_wasm", - srcs = [ - ":analyse_play_bin_wasm", - ":solve_board_wasm", - ], - tags = ["manual"], + actual = "//examples/wasm:all_examples_wasm", ) diff --git a/examples/wasm/BUILD.bazel b/examples/wasm/BUILD.bazel new file mode 100644 index 00000000..58ac4d7a --- /dev/null +++ b/examples/wasm/BUILD.bazel @@ -0,0 +1,28 @@ +load("@emsdk//emscripten_toolchain:wasm_rules.bzl", "wasm_cc_binary") + +wasm_cc_binary( + name = "solve_board_wasm", + cc_target = "//examples:solve_board", + outputs = [ + "solve_board.js", + "solve_board.wasm", + ], +) + +wasm_cc_binary( + name = "analyse_play_bin_wasm", + cc_target = "//examples:AnalysePlayBin", + outputs = [ + "AnalysePlayBin.js", + "AnalysePlayBin.wasm", + ], +) + +filegroup( + name = "all_examples_wasm", + srcs = [ + ":analyse_play_bin_wasm", + ":solve_board_wasm", + ], + visibility = ["//visibility:public"], +) diff --git a/library/src/BUILD.bazel b/library/src/BUILD.bazel index ca141ddd..730bc3b7 100644 --- a/library/src/BUILD.bazel +++ b/library/src/BUILD.bazel @@ -64,16 +64,6 @@ filegroup( ], ) -filegroup( - name = "dds_wasm_sources", - srcs = glob([ - "**/*.cpp", - ]), - visibility = [ - "//examples:__pkg__", - ], -) - cc_library( name = "testable_dds", srcs = ["//library/src:testable_dds_sources"], diff --git a/library/src/heuristic_sorting/BUILD.bazel b/library/src/heuristic_sorting/BUILD.bazel index e6e7ffb1..6ce9cdcd 100644 --- a/library/src/heuristic_sorting/BUILD.bazel +++ b/library/src/heuristic_sorting/BUILD.bazel @@ -44,12 +44,4 @@ cc_library( "//library/tests/heuristic_sorting:__pkg__", "//library/tests/regression/heuristic_sorting:__pkg__", ], -) - -filegroup( - name = "wasm_sources", - srcs = ["heuristic_sorting.cpp"], - visibility = [ - "//examples:__pkg__", - ], ) \ No newline at end of file diff --git a/library/src/lookup_tables/BUILD.bazel b/library/src/lookup_tables/BUILD.bazel index 2e35514c..76f9c93f 100644 --- a/library/src/lookup_tables/BUILD.bazel +++ b/library/src/lookup_tables/BUILD.bazel @@ -14,11 +14,3 @@ cc_library( linkopts = DDS_LINKOPTS, local_defines = DDS_LOCAL_DEFINES, ) - -filegroup( - name = "wasm_sources", - srcs = ["lookup_tables.cpp"], - visibility = [ - "//examples:__pkg__", - ], -) diff --git a/library/src/moves/BUILD.bazel b/library/src/moves/BUILD.bazel index 75d13fff..317f5645 100644 --- a/library/src/moves/BUILD.bazel +++ b/library/src/moves/BUILD.bazel @@ -35,12 +35,4 @@ cc_library( visibility = [ "//library/tests/moves:__pkg__", ], -) - -filegroup( - name = "wasm_sources", - srcs = glob(["*.cpp"]), - visibility = [ - "//examples:__pkg__", - ], ) \ No newline at end of file diff --git a/library/src/solver_context/BUILD.bazel b/library/src/solver_context/BUILD.bazel index d25dea9e..a566211b 100644 --- a/library/src/solver_context/BUILD.bazel +++ b/library/src/solver_context/BUILD.bazel @@ -51,11 +51,3 @@ cc_library( linkopts = DDS_LINKOPTS, local_defines = DDS_LOCAL_DEFINES + DDS_SCHEDULER_DEFINE + ["DDS_UTILITIES_STATS"], ) - -filegroup( - name = "wasm_sources", - srcs = ["solver_context.cpp"], - visibility = [ - "//examples:__pkg__", - ], -) diff --git a/library/src/system/BUILD.bazel b/library/src/system/BUILD.bazel index 8ef12ac3..fb05075a 100644 --- a/library/src/system/BUILD.bazel +++ b/library/src/system/BUILD.bazel @@ -59,11 +59,3 @@ cc_library( linkopts = DDS_LINKOPTS, local_defines = DDS_LOCAL_DEFINES + DDS_SCHEDULER_DEFINE + ["DDS_UTILITIES_STATS"], ) - -filegroup( - name = "wasm_sources", - srcs = glob(["*.cpp"]), - visibility = [ - "//examples:__pkg__", - ], -) diff --git a/library/src/trans_table/BUILD.bazel b/library/src/trans_table/BUILD.bazel index b3cf2f13..d9caef65 100644 --- a/library/src/trans_table/BUILD.bazel +++ b/library/src/trans_table/BUILD.bazel @@ -47,15 +47,4 @@ cc_library( visibility = [ "//library/tests/trans_table:__pkg__", ], -) - -filegroup( - name = "wasm_sources", - srcs = [ - "trans_table_l.cpp", - "trans_table_s.cpp", - ], - visibility = [ - "//examples:__pkg__", - ], ) \ No newline at end of file diff --git a/library/src/utility/BUILD.bazel b/library/src/utility/BUILD.bazel index 874ade9b..214f0e9f 100644 --- a/library/src/utility/BUILD.bazel +++ b/library/src/utility/BUILD.bazel @@ -19,12 +19,3 @@ cc_library( deps = [], ) -filegroup( - name = "wasm_sources", - srcs = ["constants.cpp"], - visibility = [ - "//examples:__pkg__", - ], -) - - diff --git a/library/tests/BUILD.bazel b/library/tests/BUILD.bazel index 0781deee..b994317d 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", "NATIVE_ONLY") # Test utility sources (glob used for convenience; excludes integration/context equivalence tests) filegroup( @@ -17,6 +18,7 @@ filegroup( # Main test executable for manual invocation cc_binary( name = "dtest", + target_compatible_with = NATIVE_ONLY, srcs = [":test_sources"], copts = DDS_CPPOPTS, linkopts = DDS_LINKOPTS, @@ -32,6 +34,7 @@ cc_binary( # Uses testable_dds to expose internal symbols for unit testing cc_test( name = "unit_tests", + target_compatible_with = NATIVE_ONLY, srcs = [":test_sources"], size = "small", copts = DDS_CPPOPTS, @@ -46,6 +49,7 @@ cc_test( # Standalone calc_par API test cc_test( name = "calc_par_test", + target_compatible_with = NATIVE_ONLY, srcs = ["calc_par_test.cpp"], size = "small", copts = DDS_CPPOPTS, diff --git a/library/tests/heuristic_sorting/BUILD.bazel b/library/tests/heuristic_sorting/BUILD.bazel index 80e0db94..09dbcfbf 100644 --- a/library/tests/heuristic_sorting/BUILD.bazel +++ b/library/tests/heuristic_sorting/BUILD.bazel @@ -1,8 +1,11 @@ load("//:CPPVARIABLES.bzl", "DDS_CPPOPTS", "DDS_LINKOPTS", "DDS_LOCAL_DEFINES") load("@rules_cc//cc:defs.bzl", "cc_library") load("@rules_cc//cc:defs.bzl", "cc_test", "cc_binary") +load("//:wasm_compat.bzl", "NATIVE_ONLY") + cc_test( + target_compatible_with = NATIVE_ONLY, name = "heuristic_sorting_test", size = "small", srcs = ["heuristic_sorting_test.cpp"], @@ -14,6 +17,7 @@ cc_test( ) cc_test( + target_compatible_with = NATIVE_ONLY, name = "merge_scratch_test", size = "small", srcs = ["merge_scratch_test.cpp"], @@ -27,6 +31,7 @@ cc_test( ) cc_test( + target_compatible_with = NATIVE_ONLY, name = "minimal_new_test", size = "small", srcs = ["minimal_new_test.cpp"], @@ -37,6 +42,7 @@ cc_test( ) cc_test( + target_compatible_with = NATIVE_ONLY, name = "minimal_weight_test", size = "small", srcs = ["minimal_weight_test.cpp"], @@ -49,6 +55,7 @@ cc_test( ) cc_test( + target_compatible_with = NATIVE_ONLY, name = "targeted_unit_tests", size = "small", srcs = [ @@ -63,4 +70,4 @@ cc_test( "//library/src/moves:moves", "@googletest//:gtest_main", ], -) +) \ No newline at end of file diff --git a/library/tests/moves/BUILD.bazel b/library/tests/moves/BUILD.bazel index 47939520..83290411 100644 --- a/library/tests/moves/BUILD.bazel +++ b/library/tests/moves/BUILD.bazel @@ -1,8 +1,9 @@ load("@rules_cc//cc:defs.bzl", "cc_test") +load("//:wasm_compat.bzl", "NATIVE_ONLY") -package(default_visibility = ["//visibility:public"]) cc_test( + target_compatible_with = NATIVE_ONLY, name = "moves_test", size = "small", srcs = ["moves_test.cpp"], diff --git a/library/tests/regression/heuristic_sorting/BUILD.bazel b/library/tests/regression/heuristic_sorting/BUILD.bazel index 2c7ef1c9..04be2ca8 100644 --- a/library/tests/regression/heuristic_sorting/BUILD.bazel +++ b/library/tests/regression/heuristic_sorting/BUILD.bazel @@ -1,8 +1,11 @@ load("@rules_cc//cc:defs.bzl", "cc_test") +load("//:wasm_compat.bzl", "NATIVE_ONLY") + # Regression tests for heuristic sorting behavior cc_test( + target_compatible_with = NATIVE_ONLY, name = "heuristic_sorting", size = "small", srcs = ["heuristic_sorting_test.cpp"], diff --git a/library/tests/solve_board/BUILD.bazel b/library/tests/solve_board/BUILD.bazel index 222909fd..8a672cf1 100644 --- a/library/tests/solve_board/BUILD.bazel +++ b/library/tests/solve_board/BUILD.bazel @@ -1,8 +1,11 @@ # Solve_board regression tests load("@rules_cc//cc:defs.bzl", "cc_test") +load("//:wasm_compat.bzl", "NATIVE_ONLY") + cc_test( + target_compatible_with = NATIVE_ONLY, name = "solve_board_test", size = "small", srcs = [ @@ -15,6 +18,7 @@ cc_test( ) cc_test( + target_compatible_with = NATIVE_ONLY, name = "analyse_play_consistency_test", size = "small", srcs = [ diff --git a/library/tests/system/BUILD.bazel b/library/tests/system/BUILD.bazel index 5570c184..62038783 100644 --- a/library/tests/system/BUILD.bazel +++ b/library/tests/system/BUILD.bazel @@ -1,6 +1,8 @@ load("@rules_cc//cc:defs.bzl", "cc_test") +load("//:wasm_compat.bzl", "NATIVE_ONLY") cc_test( + target_compatible_with = NATIVE_ONLY, name = "concurrency_validation_test", size = "small", srcs = ["concurrency_validation_test.cpp"], @@ -13,6 +15,7 @@ cc_test( ) cc_test( + target_compatible_with = NATIVE_ONLY, name = "configure_tt_api_test", size = "small", srcs = ["configure_tt_api_test.cpp"], @@ -24,6 +27,7 @@ cc_test( ) cc_test( + target_compatible_with = NATIVE_ONLY, name = "context_equivalence_test", size = "small", srcs = ["context_equivalence_test.cpp"], @@ -37,6 +41,7 @@ cc_test( # Same parity test, but with context TT ownership enabled to validate lazy TT path cc_test( + target_compatible_with = NATIVE_ONLY, name = "context_equivalence_test_ctx_tt", size = "small", srcs = ["context_equivalence_test.cpp"], @@ -51,6 +56,7 @@ cc_test( # New lifecycle test that validates SolverContext-managed TT behavior cc_test( + target_compatible_with = NATIVE_ONLY, name = "context_tt_facade_test", size = "small", srcs = ["context_tt_facade_test.cpp"], @@ -64,6 +70,7 @@ cc_test( # Utilities feature flags tests cc_test( + target_compatible_with = NATIVE_ONLY, name = "utilities_feature_flags_test", size = "small", srcs = ["utilities_feature_flags_test.cpp"], @@ -75,6 +82,7 @@ cc_test( ) cc_test( + target_compatible_with = NATIVE_ONLY, name = "utilities_feature_flags_test_with_log", size = "small", srcs = ["utilities_feature_flags_test_with_log.cpp"], @@ -86,6 +94,7 @@ cc_test( ) cc_test( + target_compatible_with = NATIVE_ONLY, name = "utilities_feature_flags_test_with_stats", size = "small", srcs = ["utilities_feature_flags_test_with_stats.cpp"], @@ -98,6 +107,7 @@ cc_test( # Utilities logging tests cc_test( + target_compatible_with = NATIVE_ONLY, name = "utilities_log_contains_test", size = "small", srcs = ["utilities_log_contains_test.cpp"], @@ -109,6 +119,7 @@ cc_test( ) cc_test( + target_compatible_with = NATIVE_ONLY, name = "utilities_log_ctx_ops_test", size = "small", srcs = ["utilities_log_ctx_ops_test.cpp"], @@ -120,6 +131,7 @@ cc_test( ) cc_test( + target_compatible_with = NATIVE_ONLY, name = "utilities_log_ctx_ops_test_with_define", size = "small", srcs = ["utilities_log_ctx_ops_test_with_define.cpp"], @@ -131,6 +143,7 @@ cc_test( ) cc_test( + target_compatible_with = NATIVE_ONLY, name = "utilities_log_test", size = "small", srcs = ["utilities_log_test.cpp"], @@ -143,6 +156,7 @@ cc_test( ) cc_test( + target_compatible_with = NATIVE_ONLY, name = "utilities_log_test_with_define", size = "small", srcs = ["utilities_log_test_with_define.cpp"], @@ -156,6 +170,7 @@ cc_test( # Utilities snapshot tests # DISABLED: Tests for unimplemented log_snapshot() feature cc_test( + target_compatible_with = NATIVE_ONLY, name = "utilities_log_snapshot_test", srcs = ["utilities_log_snapshot_test.cpp"], tags = ["manual"], @@ -168,6 +183,7 @@ cc_test( # Utilities stats tests cc_test( + target_compatible_with = NATIVE_ONLY, name = "utilities_stats_test", size = "small", srcs = ["utilities_stats_test.cpp"], @@ -181,6 +197,7 @@ cc_test( # Transposition table sharing tests # DISABLED: Tests for SolverContext TT sharing (incomplete API) cc_test( + target_compatible_with = NATIVE_ONLY, name = "tt_sharing_test", srcs = ["tt_sharing_test.cpp"], tags = ["manual"], diff --git a/library/tests/trans_table/BUILD.bazel b/library/tests/trans_table/BUILD.bazel index 56a4e0cb..0cc4b5af 100644 --- a/library/tests/trans_table/BUILD.bazel +++ b/library/tests/trans_table/BUILD.bazel @@ -1,4 +1,6 @@ load("@rules_cc//cc:defs.bzl", "cc_library", "cc_test") +load("//:wasm_compat.bzl", "NATIVE_ONLY") + cc_library( name = "test_utilities", @@ -19,6 +21,7 @@ cc_library( ) cc_test( + target_compatible_with = NATIVE_ONLY, name = "trans_table", size = "small", srcs = [ @@ -31,4 +34,4 @@ cc_test( ":test_utilities", "@googletest//:gtest_main", ], -) +) \ No newline at end of file diff --git a/library/tests/utility/BUILD.bazel b/library/tests/utility/BUILD.bazel index 5504c7c4..b5080fe6 100644 --- a/library/tests/utility/BUILD.bazel +++ b/library/tests/utility/BUILD.bazel @@ -1,8 +1,11 @@ load("@rules_cc//cc:defs.bzl", "cc_test") +load("//:wasm_compat.bzl", "NATIVE_ONLY") + # Utility tests: constants, lookup tables, and RNG determinism validation cc_test( + target_compatible_with = NATIVE_ONLY, name = "constants_test", size = "small", srcs = ["constants_test.cpp"], @@ -13,6 +16,7 @@ cc_test( ) cc_test( + target_compatible_with = NATIVE_ONLY, name = "lookup_tables_test", size = "small", srcs = ["lookup_tables_test.cpp"], @@ -24,6 +28,7 @@ cc_test( # Aggregate test suite combining constants and lookup tables tests cc_test( + target_compatible_with = NATIVE_ONLY, name = "utility_test_suite", size = "small", srcs = [ diff --git a/wasm/BUILD.bazel b/wasm/BUILD.bazel new file mode 100644 index 00000000..9bb72912 --- /dev/null +++ b/wasm/BUILD.bazel @@ -0,0 +1 @@ +# WASM helpers live in //:wasm_compat.bzl (no build targets in this package). diff --git a/wasm_compat.bzl b/wasm_compat.bzl new file mode 100644 index 00000000..1640ea55 --- /dev/null +++ b/wasm_compat.bzl @@ -0,0 +1,15 @@ +"""Shared attributes for native vs WebAssembly builds.""" + +# Mark targets incompatible when building with --config=wasm. +NATIVE_ONLY = select({ + "//:build_wasm": ["@platforms//:incompatible"], + "//conditions:default": [], +}) + +# Emscripten link flags for cc_binary targets wrapped by wasm_cc_binary. +WASM_LINKOPTS = [ + "-sWASM=1", + "-fexceptions", + "-sALLOW_MEMORY_GROWTH=1", + "-sINITIAL_MEMORY=268435456", +] From d0928c3da5d17cc31f91a6140cd4b4cbe2380aa8 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Mon, 1 Jun 2026 12:47:11 +0200 Subject: [PATCH 02/45] Cursor: get rid of the "--config=wasm" --- .bazelrc | 13 ------ .github/workflows/ci_wasm.yml | 2 +- BUILD.bazel | 5 +-- docs/wasm_build.md | 43 ++++++++----------- examples/BUILD.bazel | 14 ------ library/tests/BUILD.bazel | 4 -- library/tests/heuristic_sorting/BUILD.bazel | 6 --- library/tests/moves/BUILD.bazel | 2 - .../regression/heuristic_sorting/BUILD.bazel | 2 - library/tests/solve_board/BUILD.bazel | 3 -- library/tests/system/BUILD.bazel | 17 -------- library/tests/trans_table/BUILD.bazel | 2 - library/tests/utility/BUILD.bazel | 4 -- wasm_compat.bzl | 8 +--- 14 files changed, 21 insertions(+), 104 deletions(-) diff --git a/.bazelrc b/.bazelrc index f637e4eb..a0490fc5 100644 --- a/.bazelrc +++ b/.bazelrc @@ -45,16 +45,3 @@ build:tsan --linkopt=-fsanitize=thread build:tsan --strip=never build:tsan --features=dbg build:tsan --compilation_mode=dbg - -# WebAssembly (WASM) configuration -# Usage: bazel build --config=wasm //... -# Compiles using the hermetic emsdk toolchain downloaded automatically by Bazel. -# Targets tagged "native-only" (Python, tests, most examples) are skipped. -build:wasm --platforms=@emsdk//:platform_wasm -build:wasm --cpu=wasm -build:wasm --cxxopt=-std=c++20 -build:wasm --host_cxxopt=-std=c++20 -build:wasm --compilation_mode=opt -build:wasm --build_tag_filters=-native-only -# Python bindings and C++ unit tests require the host toolchain. -build:wasm --deleted_packages=python,library/tests diff --git a/.github/workflows/ci_wasm.yml b/.github/workflows/ci_wasm.yml index 516ee02c..114c5eab 100644 --- a/.github/workflows/ci_wasm.yml +++ b/.github/workflows/ci_wasm.yml @@ -22,7 +22,7 @@ jobs: # 3️⃣ Build WASM targets (hermetic emsdk toolchain downloaded by Bazel) - name: Build WASM targets - run: bazel build --config=wasm --verbose_failures //... + run: bazel build --verbose_failures //examples/wasm:all_examples_wasm # 4️⃣ Smoke test: run solve_board under Node.js — pass if it does not crash - name: Smoke test solve_board_wasm diff --git a/BUILD.bazel b/BUILD.bazel index 75eb3a9a..8a672088 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -72,8 +72,7 @@ config_setting( define_values = {"asan": "true"}, ) -# WebAssembly (WASM) build configuration -# Usage: bazel build --config=wasm //... +# Matches cc_* targets built under the wasm_cc_binary platform transition (cpu=wasm). config_setting( name = "build_wasm", values = {"cpu": "wasm"}, @@ -121,7 +120,6 @@ EOF tags = [ "manual", "local", - "native-only", "no-remote", "no-remote-cache", ], @@ -135,7 +133,6 @@ cc_library( cc_library( name = "testable_dds", - tags = ["native-only"], deps = ["//library/src:testable_dds"], visibility = [ "//library/tests:__pkg__", diff --git a/docs/wasm_build.md b/docs/wasm_build.md index 2d2a2157..15c5e780 100644 --- a/docs/wasm_build.md +++ b/docs/wasm_build.md @@ -1,6 +1,6 @@ # WebAssembly (WASM) Build Guide -This document explains how to build the DDS library and examples for WebAssembly using Bazel. +This document explains how to build DDS examples for WebAssembly using Bazel. ## Prerequisites @@ -9,22 +9,16 @@ This document explains how to build the DDS library and examples for WebAssembly Bazel 7.x or later is required. Install using your package manager or download from: https://bazel.build/install -The Emscripten SDK (emsdk) does NOT need to be manually installed. Bazel will automatically download, configure, and cache the appropriate hermetic Emscripten toolchain for your host platform as part of the build process. +The Emscripten SDK (emsdk) does NOT need to be manually installed. Bazel downloads and caches a hermetic Emscripten toolchain when you build a `wasm_cc_binary` target. ## Building WASM Examples -### Build all WASM-compatible targets +WASM targets use `wasm_cc_binary`, which applies an Emscripten **platform transition** to the underlying `cc_binary`. You do **not** need `--config=wasm` or any other `.bazelrc` profile. -Native-only targets (Python bindings, C++ tests, most examples) are skipped automatically: +### Build all WASM examples ```bash -bazel build --config=wasm //... -``` - -### Build WASM examples only - -```bash -bazel build --config=wasm //examples/wasm:all_examples_wasm +bazel build //examples/wasm:all_examples_wasm ``` The alias `//examples:all_examples_wasm` points at the same filegroup. @@ -32,33 +26,32 @@ The alias `//examples:all_examples_wasm` points at the same filegroup. ### Build a specific example ```bash -bazel build --config=wasm //examples/wasm:solve_board_wasm +bazel build //examples/wasm:solve_board_wasm ``` -### Output Files +### Output files -Output files are under `bazel-bin/examples/wasm/` (wasm_cc_binary output layout): +Outputs are under `bazel-bin/examples/wasm/`: - `solve_board.js` / `solve_board.wasm` - `AnalysePlayBin.js` / `AnalysePlayBin.wasm` -## Available WASM Targets +## Available WASM targets -WASM rules live in `examples/wasm/BUILD.bazel` and wrap native `cc_binary` targets in `examples/`: +Rules in `examples/wasm/BUILD.bazel` wrap native examples in `examples/`: - `solve_board_wasm` — solves a single board - `analyse_play_bin_wasm` — analyze play from binary format -## WASM Build Configuration +## How it works -1. **`BUILD.bazel`** — `//:build_wasm` matches `--cpu=wasm` from the `wasm` config -2. **`wasm_compat.bzl`** — shared Emscripten link flags (`WASM_LINKOPTS`) -3. **`CPPVARIABLES.bzl`** — WASM compiler flags and `__WASM__` define -4. **`.bazelrc`** — `wasm` config (platform, `--build_tag_filters=-native-only`) +1. **`wasm_cc_binary`** (from `@emsdk`) transitions its `cc_target` to `@emsdk//:platform_wasm` and sets `--cpu=wasm`. +2. **`//:build_wasm`** in the root `BUILD.bazel` matches that CPU for `select()` in `CPPVARIABLES.bzl` and example link flags. +3. **`wasm_compat.bzl`** — shared Emscripten link flags (`WASM_LINKOPTS`) on the WASM-capable `cc_binary` targets. -Targets tagged `native-only` are excluded from `bazel build --config=wasm //...` (most examples, Doxygen). The `python` and `library/tests` packages are omitted entirely via `--deleted_packages`. +Native builds (`bazel build //...`, `bazel test //library/tests/...`, Python bindings) are unchanged and use the host LLVM toolchain. -## Running WASM Examples +## Running WASM examples ### Node.js @@ -66,7 +59,7 @@ Targets tagged `native-only` are excluded from `bazel build --config=wasm //...` node bazel-bin/examples/wasm/solve_board.js ``` -## Compilation Flags +## Compilation flags | Flag | Purpose | |------|---------| @@ -78,7 +71,7 @@ node bazel-bin/examples/wasm/solve_board.js | `-sALLOW_MEMORY_GROWTH=1` | Allow heap growth at runtime | | `-sINITIAL_MEMORY=268435456` | 256MB initial memory | -## Related Documentation +## Related documentation - [Emscripten Documentation](https://emscripten.org/docs/) - [Bazel Build System](https://bazel.build/docs) diff --git a/examples/BUILD.bazel b/examples/BUILD.bazel index 4e6f1000..1ae72944 100644 --- a/examples/BUILD.bazel +++ b/examples/BUILD.bazel @@ -45,7 +45,6 @@ cc_binary( copts = EXAMPLES_CPPOPTS, linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, - tags = ["native-only"], deps = [ ":hands", "//library/src:dds", @@ -59,7 +58,6 @@ cc_binary( copts = EXAMPLES_CPPOPTS, linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, - tags = ["native-only"], deps = [ ":hands", "//library/src:dds", @@ -87,7 +85,6 @@ cc_binary( copts = EXAMPLES_CPPOPTS, linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, - tags = ["native-only"], deps = [ ":hands", "//library/src:dds", @@ -101,7 +98,6 @@ cc_binary( copts = EXAMPLES_CPPOPTS, linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, - tags = ["native-only"], deps = [ ":hands", "//library/src:dds", @@ -115,7 +111,6 @@ cc_binary( copts = EXAMPLES_CPPOPTS, linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, - tags = ["native-only"], deps = [ ":hands", "//library/src:dds", @@ -129,7 +124,6 @@ cc_binary( copts = EXAMPLES_CPPOPTS, linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, - tags = ["native-only"], deps = [ ":hands", "//library/src:dds", @@ -143,7 +137,6 @@ cc_binary( copts = EXAMPLES_CPPOPTS, linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, - tags = ["native-only"], deps = [ ":hands", "//library/src:dds", @@ -157,7 +150,6 @@ cc_binary( copts = EXAMPLES_CPPOPTS, linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, - tags = ["native-only"], deps = [ ":hands", "//library/src:dds", @@ -171,7 +163,6 @@ cc_binary( copts = EXAMPLES_CPPOPTS, linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, - tags = ["native-only"], deps = [ ":hands", "//library/src:dds", @@ -185,7 +176,6 @@ cc_binary( copts = EXAMPLES_CPPOPTS, linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, - tags = ["native-only"], deps = [ ":hands", "//library/src:dds", @@ -213,7 +203,6 @@ cc_binary( copts = EXAMPLES_CPPOPTS, linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, - tags = ["native-only"], deps = [ ":hands", "//library/src:dds", @@ -227,7 +216,6 @@ cc_binary( copts = EXAMPLES_CPPOPTS, linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, - tags = ["native-only"], deps = [ "//library/src:dds", "//library/src/api:api_definitions", @@ -240,7 +228,6 @@ cc_binary( copts = EXAMPLES_CPPOPTS, linkopts = DDS_LINKOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, - tags = ["native-only"], deps = [ ":hands", "//library/src:dds", @@ -252,7 +239,6 @@ cc_binary( # Convenience target to build all native examples filegroup( name = "all_examples", - tags = ["native-only"], srcs = [ ":analyse_all_plays_bin", ":analyse_all_plays_pbn", diff --git a/library/tests/BUILD.bazel b/library/tests/BUILD.bazel index b994317d..0781deee 100644 --- a/library/tests/BUILD.bazel +++ b/library/tests/BUILD.bazel @@ -1,6 +1,5 @@ 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", "NATIVE_ONLY") # Test utility sources (glob used for convenience; excludes integration/context equivalence tests) filegroup( @@ -18,7 +17,6 @@ filegroup( # Main test executable for manual invocation cc_binary( name = "dtest", - target_compatible_with = NATIVE_ONLY, srcs = [":test_sources"], copts = DDS_CPPOPTS, linkopts = DDS_LINKOPTS, @@ -34,7 +32,6 @@ cc_binary( # Uses testable_dds to expose internal symbols for unit testing cc_test( name = "unit_tests", - target_compatible_with = NATIVE_ONLY, srcs = [":test_sources"], size = "small", copts = DDS_CPPOPTS, @@ -49,7 +46,6 @@ cc_test( # Standalone calc_par API test cc_test( name = "calc_par_test", - target_compatible_with = NATIVE_ONLY, srcs = ["calc_par_test.cpp"], size = "small", copts = DDS_CPPOPTS, diff --git a/library/tests/heuristic_sorting/BUILD.bazel b/library/tests/heuristic_sorting/BUILD.bazel index 09dbcfbf..3f564dd7 100644 --- a/library/tests/heuristic_sorting/BUILD.bazel +++ b/library/tests/heuristic_sorting/BUILD.bazel @@ -1,11 +1,9 @@ load("//:CPPVARIABLES.bzl", "DDS_CPPOPTS", "DDS_LINKOPTS", "DDS_LOCAL_DEFINES") load("@rules_cc//cc:defs.bzl", "cc_library") load("@rules_cc//cc:defs.bzl", "cc_test", "cc_binary") -load("//:wasm_compat.bzl", "NATIVE_ONLY") cc_test( - target_compatible_with = NATIVE_ONLY, name = "heuristic_sorting_test", size = "small", srcs = ["heuristic_sorting_test.cpp"], @@ -17,7 +15,6 @@ cc_test( ) cc_test( - target_compatible_with = NATIVE_ONLY, name = "merge_scratch_test", size = "small", srcs = ["merge_scratch_test.cpp"], @@ -31,7 +28,6 @@ cc_test( ) cc_test( - target_compatible_with = NATIVE_ONLY, name = "minimal_new_test", size = "small", srcs = ["minimal_new_test.cpp"], @@ -42,7 +38,6 @@ cc_test( ) cc_test( - target_compatible_with = NATIVE_ONLY, name = "minimal_weight_test", size = "small", srcs = ["minimal_weight_test.cpp"], @@ -55,7 +50,6 @@ cc_test( ) cc_test( - target_compatible_with = NATIVE_ONLY, name = "targeted_unit_tests", size = "small", srcs = [ diff --git a/library/tests/moves/BUILD.bazel b/library/tests/moves/BUILD.bazel index 83290411..5eb94e20 100644 --- a/library/tests/moves/BUILD.bazel +++ b/library/tests/moves/BUILD.bazel @@ -1,9 +1,7 @@ load("@rules_cc//cc:defs.bzl", "cc_test") -load("//:wasm_compat.bzl", "NATIVE_ONLY") cc_test( - target_compatible_with = NATIVE_ONLY, name = "moves_test", size = "small", srcs = ["moves_test.cpp"], diff --git a/library/tests/regression/heuristic_sorting/BUILD.bazel b/library/tests/regression/heuristic_sorting/BUILD.bazel index 04be2ca8..0671095c 100644 --- a/library/tests/regression/heuristic_sorting/BUILD.bazel +++ b/library/tests/regression/heuristic_sorting/BUILD.bazel @@ -1,11 +1,9 @@ load("@rules_cc//cc:defs.bzl", "cc_test") -load("//:wasm_compat.bzl", "NATIVE_ONLY") # Regression tests for heuristic sorting behavior cc_test( - target_compatible_with = NATIVE_ONLY, name = "heuristic_sorting", size = "small", srcs = ["heuristic_sorting_test.cpp"], diff --git a/library/tests/solve_board/BUILD.bazel b/library/tests/solve_board/BUILD.bazel index 8a672cf1..e1d11fe1 100644 --- a/library/tests/solve_board/BUILD.bazel +++ b/library/tests/solve_board/BUILD.bazel @@ -1,11 +1,9 @@ # Solve_board regression tests load("@rules_cc//cc:defs.bzl", "cc_test") -load("//:wasm_compat.bzl", "NATIVE_ONLY") cc_test( - target_compatible_with = NATIVE_ONLY, name = "solve_board_test", size = "small", srcs = [ @@ -18,7 +16,6 @@ cc_test( ) cc_test( - target_compatible_with = NATIVE_ONLY, name = "analyse_play_consistency_test", size = "small", srcs = [ diff --git a/library/tests/system/BUILD.bazel b/library/tests/system/BUILD.bazel index 62038783..5570c184 100644 --- a/library/tests/system/BUILD.bazel +++ b/library/tests/system/BUILD.bazel @@ -1,8 +1,6 @@ load("@rules_cc//cc:defs.bzl", "cc_test") -load("//:wasm_compat.bzl", "NATIVE_ONLY") cc_test( - target_compatible_with = NATIVE_ONLY, name = "concurrency_validation_test", size = "small", srcs = ["concurrency_validation_test.cpp"], @@ -15,7 +13,6 @@ cc_test( ) cc_test( - target_compatible_with = NATIVE_ONLY, name = "configure_tt_api_test", size = "small", srcs = ["configure_tt_api_test.cpp"], @@ -27,7 +24,6 @@ cc_test( ) cc_test( - target_compatible_with = NATIVE_ONLY, name = "context_equivalence_test", size = "small", srcs = ["context_equivalence_test.cpp"], @@ -41,7 +37,6 @@ cc_test( # Same parity test, but with context TT ownership enabled to validate lazy TT path cc_test( - target_compatible_with = NATIVE_ONLY, name = "context_equivalence_test_ctx_tt", size = "small", srcs = ["context_equivalence_test.cpp"], @@ -56,7 +51,6 @@ cc_test( # New lifecycle test that validates SolverContext-managed TT behavior cc_test( - target_compatible_with = NATIVE_ONLY, name = "context_tt_facade_test", size = "small", srcs = ["context_tt_facade_test.cpp"], @@ -70,7 +64,6 @@ cc_test( # Utilities feature flags tests cc_test( - target_compatible_with = NATIVE_ONLY, name = "utilities_feature_flags_test", size = "small", srcs = ["utilities_feature_flags_test.cpp"], @@ -82,7 +75,6 @@ cc_test( ) cc_test( - target_compatible_with = NATIVE_ONLY, name = "utilities_feature_flags_test_with_log", size = "small", srcs = ["utilities_feature_flags_test_with_log.cpp"], @@ -94,7 +86,6 @@ cc_test( ) cc_test( - target_compatible_with = NATIVE_ONLY, name = "utilities_feature_flags_test_with_stats", size = "small", srcs = ["utilities_feature_flags_test_with_stats.cpp"], @@ -107,7 +98,6 @@ cc_test( # Utilities logging tests cc_test( - target_compatible_with = NATIVE_ONLY, name = "utilities_log_contains_test", size = "small", srcs = ["utilities_log_contains_test.cpp"], @@ -119,7 +109,6 @@ cc_test( ) cc_test( - target_compatible_with = NATIVE_ONLY, name = "utilities_log_ctx_ops_test", size = "small", srcs = ["utilities_log_ctx_ops_test.cpp"], @@ -131,7 +120,6 @@ cc_test( ) cc_test( - target_compatible_with = NATIVE_ONLY, name = "utilities_log_ctx_ops_test_with_define", size = "small", srcs = ["utilities_log_ctx_ops_test_with_define.cpp"], @@ -143,7 +131,6 @@ cc_test( ) cc_test( - target_compatible_with = NATIVE_ONLY, name = "utilities_log_test", size = "small", srcs = ["utilities_log_test.cpp"], @@ -156,7 +143,6 @@ cc_test( ) cc_test( - target_compatible_with = NATIVE_ONLY, name = "utilities_log_test_with_define", size = "small", srcs = ["utilities_log_test_with_define.cpp"], @@ -170,7 +156,6 @@ cc_test( # Utilities snapshot tests # DISABLED: Tests for unimplemented log_snapshot() feature cc_test( - target_compatible_with = NATIVE_ONLY, name = "utilities_log_snapshot_test", srcs = ["utilities_log_snapshot_test.cpp"], tags = ["manual"], @@ -183,7 +168,6 @@ cc_test( # Utilities stats tests cc_test( - target_compatible_with = NATIVE_ONLY, name = "utilities_stats_test", size = "small", srcs = ["utilities_stats_test.cpp"], @@ -197,7 +181,6 @@ cc_test( # Transposition table sharing tests # DISABLED: Tests for SolverContext TT sharing (incomplete API) cc_test( - target_compatible_with = NATIVE_ONLY, name = "tt_sharing_test", srcs = ["tt_sharing_test.cpp"], tags = ["manual"], diff --git a/library/tests/trans_table/BUILD.bazel b/library/tests/trans_table/BUILD.bazel index 0cc4b5af..b7374c98 100644 --- a/library/tests/trans_table/BUILD.bazel +++ b/library/tests/trans_table/BUILD.bazel @@ -1,5 +1,4 @@ load("@rules_cc//cc:defs.bzl", "cc_library", "cc_test") -load("//:wasm_compat.bzl", "NATIVE_ONLY") cc_library( @@ -21,7 +20,6 @@ cc_library( ) cc_test( - target_compatible_with = NATIVE_ONLY, name = "trans_table", size = "small", srcs = [ diff --git a/library/tests/utility/BUILD.bazel b/library/tests/utility/BUILD.bazel index b5080fe6..16101a17 100644 --- a/library/tests/utility/BUILD.bazel +++ b/library/tests/utility/BUILD.bazel @@ -1,11 +1,9 @@ load("@rules_cc//cc:defs.bzl", "cc_test") -load("//:wasm_compat.bzl", "NATIVE_ONLY") # Utility tests: constants, lookup tables, and RNG determinism validation cc_test( - target_compatible_with = NATIVE_ONLY, name = "constants_test", size = "small", srcs = ["constants_test.cpp"], @@ -16,7 +14,6 @@ cc_test( ) cc_test( - target_compatible_with = NATIVE_ONLY, name = "lookup_tables_test", size = "small", srcs = ["lookup_tables_test.cpp"], @@ -28,7 +25,6 @@ cc_test( # Aggregate test suite combining constants and lookup tables tests cc_test( - target_compatible_with = NATIVE_ONLY, name = "utility_test_suite", size = "small", srcs = [ diff --git a/wasm_compat.bzl b/wasm_compat.bzl index 1640ea55..dcec1543 100644 --- a/wasm_compat.bzl +++ b/wasm_compat.bzl @@ -1,10 +1,4 @@ -"""Shared attributes for native vs WebAssembly builds.""" - -# Mark targets incompatible when building with --config=wasm. -NATIVE_ONLY = select({ - "//:build_wasm": ["@platforms//:incompatible"], - "//conditions:default": [], -}) +"""Shared attributes for WebAssembly (Emscripten) builds.""" # Emscripten link flags for cc_binary targets wrapped by wasm_cc_binary. WASM_LINKOPTS = [ From 7348cd60631da99cb2d3c00535898a74423663cb Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Mon, 1 Jun 2026 14:15:09 +0200 Subject: [PATCH 03/45] Restore or update some comments that Cursor had decided were unnecessary. --- .github/workflows/ci_wasm.yml | 1 + docs/wasm_build.md | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/.github/workflows/ci_wasm.yml b/.github/workflows/ci_wasm.yml index 114c5eab..9e0e4616 100644 --- a/.github/workflows/ci_wasm.yml +++ b/.github/workflows/ci_wasm.yml @@ -27,3 +27,4 @@ jobs: # 4️⃣ Smoke test: run solve_board under Node.js — pass if it does not crash - name: Smoke test solve_board_wasm run: node bazel-bin/examples/wasm/solve_board.js +git push -u origin wasm-refactor \ No newline at end of file diff --git a/docs/wasm_build.md b/docs/wasm_build.md index 15c5e780..411785ec 100644 --- a/docs/wasm_build.md +++ b/docs/wasm_build.md @@ -59,6 +59,10 @@ Native builds (`bazel build //...`, `bazel test //library/tests/...`, Python bin node bazel-bin/examples/wasm/solve_board.js ``` +### Web browser (not yet supported) + +There is no checked-in HTML runner yet. To experiment manually, copy the built `.js` and `.wasm` files from `bazel-bin/examples/wasm/` to a static file server and load the `.js` from a page that provides the Emscripten `Module` hooks. + ## Compilation flags | Flag | Purpose | @@ -71,6 +75,33 @@ node bazel-bin/examples/wasm/solve_board.js | `-sALLOW_MEMORY_GROWTH=1` | Allow heap growth at runtime | | `-sINITIAL_MEMORY=268435456` | 256MB initial memory | +## C++ standard + +WASM example binaries are built as C++20. The Emscripten toolchain (via `wasm_cc_binary`) compiles the transitioned `cc_target`; project flags for that configuration come from `CPPVARIABLES.bzl` when `//:build_wasm` matches (`--cpu=wasm` set by the transition). + +Host-side Bazel actions still use the normal platform flags in `.bazelrc`: + +``` +build:macos --cxxopt=-std=c++20 +build:linux --cxxopt=-std=c++20 +``` + +There is no separate `build:wasm` profile in `.bazelrc`; WASM builds are selected by targeting `//examples/wasm:*`. + +## Development notes + +- The `__WASM__` preprocessor constant is defined for WASM builds (`CPPVARIABLES.bzl`). It was added to work around platform-specific code paths; revisit whether it can be narrowed or removed as WASM support matures. +- Some threading and platform-specific features are disabled or stubbed when `__WASM__` is set. +- A reusable `cc_library` WASM artifact (not only example binaries) is not yet provided; today only `wasm_cc_binary` example targets are wired up. +- Browser/HTML packaging (`.html` shell, assets) is not yet documented or automated; Node.js is the supported smoke-test runner for now. + +## Next steps + +To integrate WASM builds into CI/CD: + +1. See `.github/workflows/ci_wasm.yml` +2. Store WASM artifacts (`*.js`, `*.wasm`) for download or release as needed + ## Related documentation - [Emscripten Documentation](https://emscripten.org/docs/) From 1fb0e37a48989e3d363d1fbe6a8c34d516addfec Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Mon, 1 Jun 2026 16:39:09 +0200 Subject: [PATCH 04/45] Initial commit - tries to connect to a backend that no longer exists. --- web/dds_mvp.css | 27 +++++ web/dds_mvp.html | 121 +++++++++++++++++++++ web/dds_mvp.js | 276 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 424 insertions(+) create mode 100644 web/dds_mvp.css create mode 100644 web/dds_mvp.html create mode 100644 web/dds_mvp.js diff --git a/web/dds_mvp.css b/web/dds_mvp.css new file mode 100644 index 00000000..5810008e --- /dev/null +++ b/web/dds_mvp.css @@ -0,0 +1,27 @@ +/* Copyright 2020 Adam Wildavsky and the Bridge Hackathon contributors + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT +*/ + +.grid-container { + display: grid; + grid-template-columns: auto auto auto; + background-color: #2196F3; + padding: 10px; + max-width: 800px; + grid-template-columns: 1fr 1fr 1fr; +} + +.grid-item { + background-color: rgba(255, 255, 255, 0.8); + border: 0px solid rgba(0, 0, 0, 0.8); + padding: 20px; + font-size: 30px; + text-align: center; +} + +td { + text-align: center; +} \ No newline at end of file diff --git a/web/dds_mvp.html b/web/dds_mvp.html new file mode 100644 index 00000000..0806fe13 --- /dev/null +++ b/web/dds_mvp.html @@ -0,0 +1,121 @@ + + + + + + + + + DDS MVP + + + +

+ Enter a 52-card deal using only these pips:
+

+ + + + + + + +
+
+ +
+
+
+ ♠ 
+ ♥ 
+ ♦ 
+ ♣ 
+
+
+
+ ♠ 
+ ♥ 
+ ♦ 
+ ♣ 
+
+
+
+ ♠ 
+ ♥ 
+ ♦ 
+ ♣ 
+
+
+
+ ♠ 
+ ♥ 
+ ♦ 
+ ♣ 
+
+
+
+ +
+ + + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
♦ ♥ NT
N




E




S




W




+ + + + diff --git a/web/dds_mvp.js b/web/dds_mvp.js new file mode 100644 index 00000000..52cdb2ee --- /dev/null +++ b/web/dds_mvp.js @@ -0,0 +1,276 @@ +// Copyright 2020 Adam Wildavsky and the Bridge Hackathon contributors +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT + +// TODO: Add tests for the exported functions. + +// ESLint configuration +// https://eslint.org/demo +// ECMA Version: 2015 +// Environment: browser + +/* eslint-env es6 */ +/* exported fillFormWithGrandSlamTestData + fillFormWithEveryoneMakes3nTestData + fillFormWithPartScoreTestData + clearTestData + rotateClockwise + pageLoad + sendJSON + */ + +// It's also useful to pass the code through +// https://jshint.com/ and https://jslint.com/ + +"use strict"; + +const DIRECTIONS = ["north", "east", "south", "west"]; +const SUITS = ["spades", "hearts", "diamonds", "clubs"]; +const PIPS = "AKQJT98765432"; +const DENOMINATIONS = ["C", "D", "H", "S", "N"]; +const DIRECTION_LETTERS = ["N", "E", "S", "W"]; + +// TODO: Clean up our HTML rendering, perhaps using custom elements. +// See https://developers.google.com/web/fundamentals/web-components/customelements +const SUIT_SYMBOLS = { + "S" : "♠", + "H" : "", + "D" : "", + "C" : "♣" +}; + +function fillFormWithTestData(nesw) { + clear_results(); + + var holdings = []; + + for (const hand of nesw) { + for (const holding of hand.split(".")) { + holdings.push(holding); + } + } + + for (const element of hand_elements()) { + element.value = holdings.shift(); + } +} + +function fillFormWithGrandSlamTestData() { + fillFormWithTestData([ + "AKQJ.AKQJ.T98.T9", + "5432.5432.32.432", + "T98.T9.AKQJ.AKQJ", + "76.876.7654.8765" + ]); +} + +function fillFormWithEveryoneMakes3nTestData() { + fillFormWithTestData([ + "QT9.A8765432.KJ.", + "KJ..A8765432.QT9", + "A8765432.QT9..KJ", + ".KJ.QT9.A8765432" + ]); +} + +function fillFormWithPartScoreTestData() { + fillFormWithTestData([ + "AQ85.AK976.5.J87", + "JT.QJ5432.Q9.KQ9", + "972..JT863.A6432", + "K643.T8.AK742.T5" + ]); +} + +function * directions_and_suits() { + // Generator + + for (const direction of DIRECTIONS) { + for (const suit of SUITS) { + yield { "direction": direction, "suit": suit }; + } + } +} + +function * hand_elements() { + // Generator + + for (const ds of directions_and_suits()) { + var element_index = ds.direction + "_" + ds.suit; + var element = document.getElementById(element_index); + yield element; + } +} + +function clearTestData() { + clear_results(); + + for (const element of hand_elements()) { + element.value = ""; + } +} + +function rotateClockwise() { + clear_results(); + + var hands = []; + + for (const element of hand_elements()) { + hands.push(element.value); + } + + // rotate west to north, and so on + for (var i = 0; i < 4; i++) { + var west = hands.pop(); + hands.unshift(west); + } + + for (const element of hand_elements()) { + element.value = hands.shift(); + } +} + +function collectHands() { + var hands = {}; + + for (const ds of directions_and_suits()) { + var direction_letter = ds.direction.charAt(0).toUpperCase(); + + hands[direction_letter] = hands[direction_letter] || []; + + var suit_letter = ds.suit.charAt(0).toUpperCase(); + var element_index = ds.direction + "_" + ds.suit; + var holding = document.getElementById(element_index).value; + + for (const card of holding) { + hands[direction_letter].push(suit_letter + card.toUpperCase()); + } + } + + return hands; +} + +function inputIsValid(hands) { + var deck = []; + var duplicates = []; + + for (const direction in hands) { + const hand = hands[direction]; + + if (hand.length != 13) { + return "Please enter 13 cards per hand."; + } + + for (const card of hand) { + const pip = card.substring(1); + + if (!PIPS.includes(pip)) { + return "Please use only these pips: " + PIPS; + } + + if (deck[card]) { + if (deck[card] == 1) { + duplicates.push(card); + } + + deck[card]++; + } else { + deck[card] = 1; + } + } + } + + if (duplicates.length) { + var error_message = "Duplicated card"; + + if (duplicates.length > 1) { + error_message += "s"; + } + + error_message += ": "; + + for (const card of duplicates) { + const suit_letter = card.substring(0, 1); + const pip = card.substring(1); + const suit_symbol = SUIT_SYMBOLS[suit_letter]; + + error_message += suit_symbol; + error_message += pip; + error_message += " "; + } + + return error_message; + } + + return ""; +} + +function pageLoad() { + document.getElementById("valid-pips").innerHTML = PIPS; +} + +function clear_results() { + var result = document.getElementById("result"); + var result_table = document.getElementById("result-table"); + + result.innerHTML = ""; + + for (var row = 1; row <= 4; row++) { + for (var column = 1; column <= 5; column++) { + var cell = result_table.rows[row].cells[column]; + cell.innerHTML = ""; + } + } +} + +function sendJSON() { + const result = document.getElementById("result"); + const result_table = document.getElementById("result-table"); + + var hands = collectHands(); + + const error_message = inputIsValid(hands); + + if (error_message.length) { + clear_results(); + result.innerHTML = error_message; + return; + } + + var xhr = new XMLHttpRequest(); + + // For testing backend changes locally + // const URL = "http://localhost:5000/api/dds-table/"; + + // const URL = "https://dds.globalbridge.app/api/dds-table/"; + const URL = "https://dds.prod.globalbridge.app/api/dds-table/"; + + xhr.open("POST", URL, true); + + xhr.setRequestHeader("Content-Type", "application/json"); + + xhr.onreadystatechange = function () { + if (xhr.readyState === 4 && xhr.status === 200) { + const dd_table = JSON.parse(this.responseText); + + for (var row = 1; row <= 4; row++) { + for (var column = 1; column <= 5; column++) { + const cell = result_table.rows[row].cells[column]; + const denomination = DENOMINATIONS[column - 1]; + const direction = DIRECTION_LETTERS[row - 1]; + const tricks = dd_table[denomination][direction]; + cell.innerHTML = tricks; + } + } + } + }; + + clear_results(); + + var deal = { "hands": hands }; + var data = JSON.stringify(deal); + + xhr.send(data); +} \ No newline at end of file From bc917ec8e721488eb8a50ad2c6f499789f8ef39c Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Mon, 1 Jun 2026 16:54:00 +0200 Subject: [PATCH 05/45] Added a wasm target for calc_dd_table_pbn --- docs/wasm_build.md | 3 +++ examples/BUILD.bazel | 3 ++- examples/calc_dd_table_pbn.cpp | 2 +- examples/wasm/BUILD.bazel | 10 ++++++++++ library/src/system/system.cpp | 13 +++++++++++++ wasm_compat.bzl | 2 ++ 6 files changed, 31 insertions(+), 2 deletions(-) diff --git a/docs/wasm_build.md b/docs/wasm_build.md index 411785ec..2c232193 100644 --- a/docs/wasm_build.md +++ b/docs/wasm_build.md @@ -35,6 +35,7 @@ Outputs are under `bazel-bin/examples/wasm/`: - `solve_board.js` / `solve_board.wasm` - `AnalysePlayBin.js` / `AnalysePlayBin.wasm` +- `calc_dd_table_pbn.js` / `calc_dd_table_pbn.wasm` ## Available WASM targets @@ -42,6 +43,7 @@ Rules in `examples/wasm/BUILD.bazel` wrap native examples in `examples/`: - `solve_board_wasm` — solves a single board - `analyse_play_bin_wasm` — analyze play from binary format +- `calc_dd_table_pbn_wasm` — double-dummy table from PBN ## How it works @@ -74,6 +76,7 @@ There is no checked-in HTML runner yet. To experiment manually, copy the built ` | `-sWASM=1` | Emscripten WASM output (link flag) | | `-sALLOW_MEMORY_GROWTH=1` | Allow heap growth at runtime | | `-sINITIAL_MEMORY=268435456` | 256MB initial memory | +| `-sSTACK_SIZE=8388608` | 8MB stack (default 64KB is too small for DDS search) | ## C++ standard diff --git a/examples/BUILD.bazel b/examples/BUILD.bazel index 1ae72944..538c6a98 100644 --- a/examples/BUILD.bazel +++ b/examples/BUILD.bazel @@ -135,8 +135,9 @@ cc_binary( name = "calc_dd_table_pbn", srcs = ["calc_dd_table_pbn.cpp"], copts = EXAMPLES_CPPOPTS, - linkopts = DDS_LINKOPTS, + linkopts = DDS_LINKOPTS + EXAMPLES_LINKOPTS_WASM, local_defines = EXAMPLES_LOCAL_DEFINES, + visibility = ["//examples/wasm:__pkg__"], deps = [ ":hands", "//library/src:dds", diff --git a/examples/calc_dd_table_pbn.cpp b/examples/calc_dd_table_pbn.cpp index 31188dcb..7ca7fba7 100644 --- a/examples/calc_dd_table_pbn.cpp +++ b/examples/calc_dd_table_pbn.cpp @@ -27,7 +27,7 @@ auto main() -> int char line[80]; bool match; -#if defined(__linux) || defined(__APPLE__) +#if defined(__linux) || defined(__APPLE__) || defined(__WASM__) SetMaxThreads(0); #endif diff --git a/examples/wasm/BUILD.bazel b/examples/wasm/BUILD.bazel index 58ac4d7a..19752ed8 100644 --- a/examples/wasm/BUILD.bazel +++ b/examples/wasm/BUILD.bazel @@ -18,10 +18,20 @@ wasm_cc_binary( ], ) +wasm_cc_binary( + name = "calc_dd_table_pbn_wasm", + cc_target = "//examples:calc_dd_table_pbn", + outputs = [ + "calc_dd_table_pbn.js", + "calc_dd_table_pbn.wasm", + ], +) + filegroup( name = "all_examples_wasm", srcs = [ ":analyse_play_bin_wasm", + ":calc_dd_table_pbn_wasm", ":solve_board_wasm", ], visibility = ["//visibility:public"], diff --git a/library/src/system/system.cpp b/library/src/system/system.cpp index 81342817..3d0fc596 100644 --- a/library/src/system/system.cpp +++ b/library/src/system/system.cpp @@ -205,6 +205,14 @@ void System::get_hardware( kilobytes_free = 0; core_count = System::get_cores(); +#if defined(__EMSCRIPTEN__) + // sysconf/physical memory queries are unreliable under Emscripten; use a + // conservative default so SetResources allocates a usable transposition table. + kilobytes_free = 512ULL * 1024; + core_count = 1; + return; +#endif + #if defined(_WIN32) || defined(__CYGWIN__) // Using GlobalMemoryStatusEx instead of GlobalMemoryStatus // was suggested by Lorne Anderson. @@ -255,6 +263,11 @@ void System::get_hardware( core_count = sysconf(_SC_NPROCESSORS_ONLN); return; #endif + + if (kilobytes_free == 0) + { + kilobytes_free = 512ULL * 1024; + } } diff --git a/wasm_compat.bzl b/wasm_compat.bzl index dcec1543..c3fea9b7 100644 --- a/wasm_compat.bzl +++ b/wasm_compat.bzl @@ -6,4 +6,6 @@ WASM_LINKOPTS = [ "-fexceptions", "-sALLOW_MEMORY_GROWTH=1", "-sINITIAL_MEMORY=268435456", + # DDS search recursion needs more than Emscripten's 64KB default stack. + "-sSTACK_SIZE=8388608", ] From 6a41569f983c9f3d482dc947f5592989a5989488 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 00:24:03 +0200 Subject: [PATCH 06/45] Added a script to move wasm code into dds_mvp_wasm_bin.js to avoid CORS restrictions. --- docs/wasm_build.md | 14 ++++- web/BUILD.bazel | 38 ++++++++++++++ web/dds_mvp.css | 2 +- web/dds_mvp.html | 10 ++-- web/dds_mvp.js | 114 +++++++++++++++++++++++++++++++---------- web/dds_mvp_wasm.cpp | 65 +++++++++++++++++++++++ web/gen_wasm_bin_js.py | 37 +++++++++++++ web/patch_mvp_wasm.py | 37 +++++++++++++ web/update_wasm.sh | 14 +++++ web/verify_wasm_js.py | 35 +++++++++++++ 10 files changed, 332 insertions(+), 34 deletions(-) create mode 100644 web/BUILD.bazel create mode 100644 web/dds_mvp_wasm.cpp create mode 100755 web/gen_wasm_bin_js.py create mode 100755 web/patch_mvp_wasm.py create mode 100755 web/update_wasm.sh create mode 100755 web/verify_wasm_js.py diff --git a/docs/wasm_build.md b/docs/wasm_build.md index 2c232193..9e052149 100644 --- a/docs/wasm_build.md +++ b/docs/wasm_build.md @@ -61,9 +61,19 @@ Native builds (`bazel build //...`, `bazel test //library/tests/...`, Python bin node bazel-bin/examples/wasm/solve_board.js ``` -### Web browser (not yet supported) +### Web browser (DDS MVP) -There is no checked-in HTML runner yet. To experiment manually, copy the built `.js` and `.wasm` files from `bazel-bin/examples/wasm/` to a static file server and load the `.js` from a page that provides the Emscripten `Module` hooks. +The `web/` demo calls `CalcDDtablePBN` in the browser via `//web:dds_mvp_wasm`: + +```bash +./web/update_wasm.sh +python3 -m http.server 8080 --directory web +# open http://localhost:8080/dds_mvp.html +``` + +The MVP loads wasm from `dds_mvp_wasm_bin.js` (base64, no network fetch), so `file://` and HTTP both work. Run `./web/update_wasm.sh` to refresh `dds_mvp_wasm.{js,wasm,bin.js}`. + +For other experiments, copy built `.js` / `.wasm` files from `bazel-bin/examples/wasm/` to any static file server. ## Compilation flags diff --git a/web/BUILD.bazel b/web/BUILD.bazel new file mode 100644 index 00000000..23e9e7eb --- /dev/null +++ b/web/BUILD.bazel @@ -0,0 +1,38 @@ +load("@rules_cc//cc:defs.bzl", "cc_binary") +load("@emsdk//emscripten_toolchain:wasm_rules.bzl", "wasm_cc_binary") +load("//:CPPVARIABLES.bzl", "DDS_CPPOPTS", "DDS_LINKOPTS", "DDS_LOCAL_DEFINES") +load("//:wasm_compat.bzl", "WASM_LINKOPTS") + +# Emscripten flags for the browser MVP (modularized, no auto-run main). +WASM_MVP_LINKOPTS = WASM_LINKOPTS + [ + "-sMODULARIZE=1", + "-sEXPORT_NAME=createDdsModule", + "-sEXPORTED_FUNCTIONS=['_dds_mvp_calc_table','_malloc','_free']", + "-sEXPORTED_RUNTIME_METHODS=['ccall','getValue']", + "-sENVIRONMENT=web", +] + +cc_binary( + name = "dds_mvp_wasm_cc", + srcs = ["dds_mvp_wasm.cpp"], + copts = DDS_CPPOPTS, + linkopts = DDS_LINKOPTS + select({ + "//:build_wasm": WASM_MVP_LINKOPTS, + "//conditions:default": [], + }), + local_defines = DDS_LOCAL_DEFINES, + visibility = ["//web:__pkg__"], + deps = [ + "//library/src:dds", + "//library/src/api:api_definitions", + ], +) + +wasm_cc_binary( + name = "dds_mvp_wasm", + cc_target = ":dds_mvp_wasm_cc", + outputs = [ + "dds_mvp_wasm.js", + "dds_mvp_wasm.wasm", + ], +) diff --git a/web/dds_mvp.css b/web/dds_mvp.css index 5810008e..ba21e8b4 100644 --- a/web/dds_mvp.css +++ b/web/dds_mvp.css @@ -1,4 +1,4 @@ -/* Copyright 2020 Adam Wildavsky and the Bridge Hackathon contributors +/* Copyright 2020-2026 Adam Wildavsky Use of this source code is governed by an MIT-style license that can be found in the LICENSE file or at diff --git a/web/dds_mvp.html b/web/dds_mvp.html index 0806fe13..475d6db6 100644 --- a/web/dds_mvp.html +++ b/web/dds_mvp.html @@ -1,13 +1,15 @@ - @@ -116,6 +118,8 @@

+ + diff --git a/web/dds_mvp.js b/web/dds_mvp.js index 52cdb2ee..fbe789b3 100644 --- a/web/dds_mvp.js +++ b/web/dds_mvp.js @@ -1,4 +1,4 @@ -// Copyright 2020 Adam Wildavsky and the Bridge Hackathon contributors +// Copyright Copyright 2020-2026 Adam Wildavsky // // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file or at @@ -32,6 +32,10 @@ const PIPS = "AKQJT98765432"; const DENOMINATIONS = ["C", "D", "H", "S", "N"]; const DIRECTION_LETTERS = ["N", "E", "S", "W"]; +// DDS res_table strain index (S,H,D,C,N) to MVP table column key. +const DENOM_TO_STRAIN = { C: 3, D: 2, H: 1, S: 0, N: 4 }; +const DIR_TO_HAND = { N: 0, E: 1, S: 2, W: 3 }; + // TODO: Clean up our HTML rendering, perhaps using custom elements. // See https://developers.google.com/web/fundamentals/web-components/customelements const SUIT_SYMBOLS = { @@ -41,6 +45,50 @@ const SUIT_SYMBOLS = { "C" : "♣" }; +let ddsModulePromise = null; + +function loadDdsModule() { + if (typeof createDdsModule !== "function") { + return Promise.reject(new Error( + "WASM module not found. From the repo root run: ./web/update_wasm.sh" + )); + } + + if (!ddsModulePromise) { + if (typeof ddsMvpWasmBytes !== "function") { + return Promise.reject(new Error( + "WASM bytes not found. From the repo root run: ./web/update_wasm.sh" + )); + } + ddsModulePromise = createDdsModule({ + wasmBinary: ddsMvpWasmBytes() + }).then((module) => { + // MODULARIZE may return a Promise until the runtime is ready. + if (module != null && typeof module.then === "function") { + return module; + } + return module; + }); + } + + return ddsModulePromise; +} + +function handsToPbn(hands) { + const handOrder = ["N", "E", "S", "W"]; + const suitOrder = ["S", "H", "D", "C"]; + const handStrings = handOrder.map((direction) => { + return suitOrder.map((suit) => { + return hands[direction] + .filter((card) => card.charAt(0) === suit) + .map((card) => card.charAt(1)) + .sort((a, b) => PIPS.indexOf(a) - PIPS.indexOf(b)) + .join(""); + }).join("."); + }); + return "N:" + handStrings.join(" "); +} + function fillFormWithTestData(nesw) { clear_results(); @@ -225,7 +273,7 @@ function clear_results() { } } -function sendJSON() { +async function sendJSON() { const result = document.getElementById("result"); const result_table = document.getElementById("result-table"); @@ -238,39 +286,49 @@ function sendJSON() { result.innerHTML = error_message; return; } - - var xhr = new XMLHttpRequest(); - - // For testing backend changes locally - // const URL = "http://localhost:5000/api/dds-table/"; - - // const URL = "https://dds.globalbridge.app/api/dds-table/"; - const URL = "https://dds.prod.globalbridge.app/api/dds-table/"; - xhr.open("POST", URL, true); - - xhr.setRequestHeader("Content-Type", "application/json"); - - xhr.onreadystatechange = function () { - if (xhr.readyState === 4 && xhr.status === 200) { - const dd_table = JSON.parse(this.responseText); + clear_results(); + result.innerHTML = "Computing…"; + + try { + const module = await loadDdsModule(); + const pbn = handsToPbn(hands); + const outPtr = module._malloc(20 * 4); + + try { + const rc = module.ccall( + "dds_mvp_calc_table", + "number", + ["string", "number"], + [pbn, outPtr] + ); + + if (rc !== 1) { + result.innerHTML = "DDS error (code " + rc + ")."; + return; + } for (var row = 1; row <= 4; row++) { for (var column = 1; column <= 5; column++) { const cell = result_table.rows[row].cells[column]; const denomination = DENOMINATIONS[column - 1]; const direction = DIRECTION_LETTERS[row - 1]; - const tricks = dd_table[denomination][direction]; - cell.innerHTML = tricks; + const strain = DENOM_TO_STRAIN[denomination]; + const hand = DIR_TO_HAND[direction]; + const index = strain * 4 + hand; + cell.innerHTML = module.getValue( + outPtr + index * 4, + "i32" + ); } } - } - }; - - clear_results(); - - var deal = { "hands": hands }; - var data = JSON.stringify(deal); - xhr.send(data); -} \ No newline at end of file + result.innerHTML = ""; + } finally { + module._free(outPtr); + } + } catch (err) { + clear_results(); + result.innerHTML = err.message; + } +} diff --git a/web/dds_mvp_wasm.cpp b/web/dds_mvp_wasm.cpp new file mode 100644 index 00000000..dc3bdf9b --- /dev/null +++ b/web/dds_mvp_wasm.cpp @@ -0,0 +1,65 @@ +/* + Bridge Hackathon DDS MVP — browser entry point for CalcDDtablePBN. + + Copyright 2020-2026 Adam Wildavsky + Use of this source code is governed by the MIT license. +*/ + +#include + +#include + +#if defined(__EMSCRIPTEN__) +#include +#else +#define EMSCRIPTEN_KEEPALIVE +#endif + +namespace { + +void ensure_initialized() { +#if defined(__linux) || defined(__APPLE__) || defined(__WASM__) + SetMaxThreads(0); +#endif +} + +} // namespace + +extern "C" { + +// Fills out_table[20] with res_table[strain][hand] (strain 0..4 = S,H,D,C,N). +// Returns RETURN_NO_FAULT (1) on success, or a DDS error code. +EMSCRIPTEN_KEEPALIVE +auto dds_mvp_calc_table(const char* pbn, int* out_table) -> int { + if (pbn == nullptr || out_table == nullptr) { + return RETURN_UNKNOWN_FAULT; + } + + ensure_initialized(); + + DdTableDealPBN deal{}; + if (std::strlen(pbn) >= sizeof(deal.cards)) { + return RETURN_PBN_FAULT; + } + std::strcpy(deal.cards, pbn); + + DdTableResults table{}; + const int res = CalcDDtablePBN(deal, &table); + if (res != RETURN_NO_FAULT) { + return res; + } + + int k = 0; + for (int strain = 0; strain < DDS_STRAINS; ++strain) { + for (int hand = 0; hand < DDS_HANDS; ++hand) { + out_table[k++] = table.res_table[strain][hand]; + } + } + return RETURN_NO_FAULT; +} + +} // extern "C" + +#if !defined(__EMSCRIPTEN__) +auto main() -> int { return 0; } +#endif diff --git a/web/gen_wasm_bin_js.py b/web/gen_wasm_bin_js.py new file mode 100755 index 00000000..6d3b7cb6 --- /dev/null +++ b/web/gen_wasm_bin_js.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Emit dds_mvp_wasm_bin.js with base64-encoded wasm for file:// and HTTP.""" +from __future__ import annotations + +import base64 +import sys +from pathlib import Path + +HEADER = """\ +// Generated by web/gen_wasm_bin_js.py — do not edit. +"use strict"; + +function ddsMvpWasmBytes() { + const bin = atob(DDS_MVP_WASM_BASE64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) { + out[i] = bin.charCodeAt(i); + } + return out; +} +""" + + +def main() -> int: + wasm_path = Path(sys.argv[1]) + out_path = Path(sys.argv[2]) + b64 = base64.b64encode(wasm_path.read_bytes()).decode("ascii") + # Split base64 across lines to keep editors happy. + lines = [b64[i : i + 76] for i in range(0, len(b64), 76)] + body = 'const DDS_MVP_WASM_BASE64 =\n "' + '" +\n "'.join(lines) + '";\n\n' + out_path.write_text(HEADER + body, encoding="utf-8") + print(f"wrote {out_path} ({wasm_path.stat().st_size} byte wasm)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web/patch_mvp_wasm.py b/web/patch_mvp_wasm.py new file mode 100755 index 00000000..42d56cff --- /dev/null +++ b/web/patch_mvp_wasm.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Patch the single isFileURI line in Emscripten output. + +Do not use global substitution on SINGLE_FILE builds: the embedded wasm +bytes can contain arbitrary text that would corrupt the module. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +OLD = "var isFileURI = (filename) => filename.startsWith('file://');" +NEW = ( + "var isFileURI = (filename) => " + "(typeof filename === 'string' ? filename : (filename && filename.href) || '')" + ".startsWith('file://');" +) + + +def main() -> int: + path = Path(sys.argv[1]) + text = path.read_text(encoding="utf-8") + if NEW in text: + return 0 + count = text.count(OLD) + if count == 0: + print("patch_mvp_wasm: isFileURI line not found", file=sys.stderr) + return 1 + if count != 1: + print(f"patch_mvp_wasm: expected 1 match, got {count}", file=sys.stderr) + return 1 + path.write_text(text.replace(OLD, NEW, 1), encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web/update_wasm.sh b/web/update_wasm.sh new file mode 100755 index 00000000..38e5ee72 --- /dev/null +++ b/web/update_wasm.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Copy Bazel WASM artifacts next to dds_mvp.html for local static serving. +set -euo pipefail +cd "$(dirname "$0")/.." +bazel build //web:dds_mvp_wasm +bin="$(bazel info bazel-bin)/web" +cp -f "${bin}/dds_mvp_wasm.js" web/ +cp -f "${bin}/dds_mvp_wasm.wasm" web/ +chmod u+w web/dds_mvp_wasm.js +python3 web/gen_wasm_bin_js.py web/dds_mvp_wasm.wasm web/dds_mvp_wasm_bin.js +# Safe one-line fix for file:// (never global-replace inside the .js bundle). +python3 web/patch_mvp_wasm.py web/dds_mvp_wasm.js +python3 web/verify_wasm_js.py web/dds_mvp_wasm.wasm +echo "Updated web/dds_mvp_wasm.{js,wasm,bin.js}" diff --git a/web/verify_wasm_js.py b/web/verify_wasm_js.py new file mode 100755 index 00000000..3969629c --- /dev/null +++ b/web/verify_wasm_js.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Verify MVP wasm bytes compile under Node.""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +def main() -> int: + wasm_path = Path(sys.argv[1]) + wasm = wasm_path.read_bytes() + print(f"{wasm_path}: {len(wasm)} bytes, magic={wasm[:4]!r}") + + tmp = Path("/tmp/dds_mvp_test.wasm") + tmp.write_bytes(wasm) + proc = subprocess.run( + [ + "node", + "-e", + "const fs=require('fs');" + "const b=fs.readFileSync(process.argv[1]);" + "WebAssembly.compile(b).then(()=>console.log('compile OK')," + "e=>{console.error(e);process.exit(1);});", + str(tmp), + ], + capture_output=True, + text=True, + ) + print(proc.stdout.strip() or proc.stderr.strip()) + return proc.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) From 8d507def1b70b3dfc587924344d3ea54f0e523c4 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 00:27:45 +0200 Subject: [PATCH 07/45] Fixed stray text. --- .github/workflows/ci_wasm.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci_wasm.yml b/.github/workflows/ci_wasm.yml index 9e0e4616..b17b8459 100644 --- a/.github/workflows/ci_wasm.yml +++ b/.github/workflows/ci_wasm.yml @@ -26,5 +26,4 @@ jobs: # 4️⃣ Smoke test: run solve_board under Node.js — pass if it does not crash - name: Smoke test solve_board_wasm - run: node bazel-bin/examples/wasm/solve_board.js -git push -u origin wasm-refactor \ No newline at end of file + run: node bazel-bin/examples/wasm/solve_board.js \ No newline at end of file From 573de850ed23349a091dfc4e0661d308b69f5866 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 09:39:30 +0200 Subject: [PATCH 08/45] Eliminate the Windows unreachable-code warning (treated as error) --- library/src/system/system.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/library/src/system/system.cpp b/library/src/system/system.cpp index 3d0fc596..a2e5aec4 100644 --- a/library/src/system/system.cpp +++ b/library/src/system/system.cpp @@ -264,10 +264,12 @@ void System::get_hardware( return; #endif +#if !defined(_WIN32) && !defined(__CYGWIN__) && !defined(__APPLE__) && !defined(__linux__) if (kilobytes_free == 0) { kilobytes_free = 512ULL * 1024; } +#endif } From d7e362938074ff032d6c8c1b23fd16a0f3df42e9 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 09:51:23 +0200 Subject: [PATCH 09/45] =?UTF-8?q?Use=20platform=20constraints=20instead=20?= =?UTF-8?q?of=20deprecated=20cpu=20matching:=20from:=20values=20=3D=20{"cp?= =?UTF-8?q?u":=20"wasm"}=20to:=20constraint=5Fvalues=20=3D=20["@platforms/?= =?UTF-8?q?/cpu:wasm32"]=20This=20makes=20select()s=20in=20CPPVARIABLES.bz?= =?UTF-8?q?l=20choose=20the=20wasm=20branch=20consistently=20across=20host?= =?UTF-8?q?=20OSes=20(including=20Windows=20runners),=20so=20/std:c++20=20?= =?UTF-8?q?won=E2=80=99t=20leak=20into=20wasm=20compiles.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BUILD.bazel | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/BUILD.bazel b/BUILD.bazel index 8a672088..42f8ecdb 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -72,10 +72,14 @@ config_setting( define_values = {"asan": "true"}, ) -# Matches cc_* targets built under the wasm_cc_binary platform transition (cpu=wasm). +# Matches cc_* targets built under the wasm_cc_binary platform transition. +# Use platform constraints (not deprecated `cpu` select) so this works +# consistently across host OSes, including Windows. config_setting( name = "build_wasm", - values = {"cpu": "wasm"}, + constraint_values = [ + "@platforms//cpu:wasm32", + ], ) From 5726a9d46600c41e2465e24415723618cb804c68 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 10:21:40 +0200 Subject: [PATCH 10/45] build_windows now also requires cpu = x64_windows debug_build_windows now requires: cpu = x64_windows compilation_mode = dbg That prevents Windows-native flags from applying to wasm transition builds (cpu=wasm), including //web:dds_mvp_wasm_cc. --- BUILD.bazel | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/BUILD.bazel b/BUILD.bazel index 42f8ecdb..eb754cd5 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -33,6 +33,9 @@ config_setting( constraint_values = [ "@platforms//os:windows", ], + values = { + "cpu": "x64_windows", + }, ) config_setting( @@ -40,7 +43,10 @@ config_setting( constraint_values = [ "@platforms//os:windows", ], - values = {"compilation_mode": "dbg"}, + values = { + "cpu": "x64_windows", + "compilation_mode": "dbg", + }, ) config_setting( From fb9848093260fdd22dd81f0b85a96df4fa3b487e Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 10:31:18 +0200 Subject: [PATCH 11/45] Removed remaining global source of MSVC flags. .bazelrc had: build:windows --cxxopt=/std:c++20 build:windows --host_cxxopt=/std:c++20 Those options are too broad and can bleed into wasm builds on Windows hosts, which is exactly why emcc saw /std:c++20. Removed those two lines and left Windows C++ standard selection to CPPVARIABLES.bzl (//:build_windows selects), which keeps native Windows builds on C++20 without contaminating wasm transitions. --- .bazelrc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.bazelrc b/.bazelrc index a0490fc5..5b87a333 100644 --- a/.bazelrc +++ b/.bazelrc @@ -7,8 +7,8 @@ build:macos --cxxopt=-std=c++20 build:macos --host_cxxopt=-std=c++20 build:linux --cxxopt=-std=c++20 build:linux --host_cxxopt=-std=c++20 -build:windows --cxxopt=/std:c++20 -build:windows --host_cxxopt=/std:c++20 +# Keep Windows C++ standard selection in CPPVARIABLES.bzl selects. +# Avoid global /std flags here so wasm transitions on Windows don't inherit MSVC opts. # Performance optimization flags build:opt --compilation_mode=opt From 166868b1447e4d844435e4a8314532208aabd7e2 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 10:41:03 +0200 Subject: [PATCH 12/45] =?UTF-8?q?Didn=E2=80=99t=20set=20copts,=20so=20it?= =?UTF-8?q?=20fell=20back=20to=20MSVC=E2=80=99s=20default=20language=20mod?= =?UTF-8?q?e=20(too=20old=20for=20designated=20initializers),=20causing=20?= =?UTF-8?q?error=20C7555.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed by: loading DDS_CPPOPTS from CPPVARIABLES.bzl and applying copts = DDS_CPPOPTS to both tests in that file: solve_board_test and analyse_play_consistency_test --- library/tests/solve_board/BUILD.bazel | 3 +++ 1 file changed, 3 insertions(+) diff --git a/library/tests/solve_board/BUILD.bazel b/library/tests/solve_board/BUILD.bazel index e1d11fe1..a8ec48b8 100644 --- a/library/tests/solve_board/BUILD.bazel +++ b/library/tests/solve_board/BUILD.bazel @@ -1,6 +1,7 @@ # Solve_board regression tests load("@rules_cc//cc:defs.bzl", "cc_test") +load("//:CPPVARIABLES.bzl", "DDS_CPPOPTS") cc_test( @@ -13,6 +14,7 @@ cc_test( "//library/src:testable_dds", "@googletest//:gtest_main", ], + copts = DDS_CPPOPTS, ) cc_test( @@ -25,4 +27,5 @@ cc_test( "//library/src:testable_dds", "@googletest//:gtest_main", ], + copts = DDS_CPPOPTS, ) From 66e9583598c20b918bfe34af30b06be9e250602f Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 10:54:16 +0200 Subject: [PATCH 13/45] Fixed missing include issue exposed by Emscripten/libc++. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit library/src/init.cpp uses stringstream (and i/o manipulators like setw, left, right) in GetDDSInfo, but didn’t include the required standard headers directly. Added #include #include to library/src/init.cpp. That should resolve the CI wasm compile error: implicit instantiation of undefined template 'std::basic_stringstream' --- library/src/init.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/library/src/init.cpp b/library/src/init.cpp index 5b003041..27243514 100644 --- a/library/src/init.cpp +++ b/library/src/init.cpp @@ -9,6 +9,8 @@ #include #include +#include +#include #include #include "init.hpp" From 6f3440308dd9af25e9e177709c7ae0a084a44097 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 11:10:21 +0200 Subject: [PATCH 14/45] =?UTF-8?q?std::jthread=20isn=E2=80=99t=20available?= =?UTF-8?q?=20in=20the=20Emscripten=20libc++=20used=20on=20the=20Windows?= =?UTF-8?q?=20CI=20path.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed library/src/solve_board.cpp to use std::thread + explicit join() instead of std::jthread in solve_all_boards_n(). Before: std::vector After: std::vector and a join loop --- library/src/solve_board.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/library/src/solve_board.cpp b/library/src/solve_board.cpp index 30a69e3a..69156d7a 100644 --- a/library/src/solve_board.cpp +++ b/library/src/solve_board.cpp @@ -78,10 +78,14 @@ auto solve_all_boards_n( START_BLOCK_TIMER; { - std::vector threads; + // Avoid std::jthread here: Emscripten's libc++ on Windows does not + // provide it yet, while std::thread is widely available. + std::vector threads; threads.reserve(static_cast(nthreads)); for (int i = 0; i < nthreads; ++i) threads.emplace_back(worker); + for (auto& t : threads) + t.join(); } END_BLOCK_TIMER; From 3c0deea968a8fc90c02cfb73bd8b2cfac5dc7c63 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 11:42:34 +0200 Subject: [PATCH 15/45] West/East/South inputs now labeled with the correct direction. --- web/dds_mvp.html | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/web/dds_mvp.html b/web/dds_mvp.html index 475d6db6..45b7ced7 100644 --- a/web/dds_mvp.html +++ b/web/dds_mvp.html @@ -43,24 +43,24 @@

- ♠ 
- ♥ 
- ♦ 
- ♣ 
+ ♠ 
+ ♥ 
+ ♦ 
+ ♣ 
- ♠ 
- ♥ 
- ♦ 
- ♣ 
+ ♠ 
+ ♥ 
+ ♦ 
+ ♣ 
- ♠ 
- ♥ 
- ♦ 
- ♣ 
+ ♠ 
+ ♥ 
+ ♦ 
+ ♣ 
From 417bf432d3a8b8ba49f1e3de1a512a4b4b78a0c5 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 11:44:37 +0200 Subject: [PATCH 16/45] Ignore files that will be generated by update_wasm.sh --- web/.gitignore | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 web/.gitignore diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 00000000..cdefec86 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,5 @@ +# Generated by update_wasm.sh + +dds_mvp_wasm.js +dds_mvp_wasm_bin.js +dds_mvp_wasm.wasm \ No newline at end of file From 9d75ee51380aafc85ca8c8b5baae694802595e23 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 11:46:56 +0200 Subject: [PATCH 17/45] =?UTF-8?q?Added=20recovery=20for=20failed=20initial?= =?UTF-8?q?ization:=20loadDdsModule()=20now=20clears=20ddsModulePromise=20?= =?UTF-8?q?inside=20.catch(...)=20before=20rethrowing,=20so=20later=20call?= =?UTF-8?q?s=20can=20retry.=20Removed=20the=20no-op=20.then((module)=20=3D?= =?UTF-8?q?>=20...)=20block=20entirely.=20Now=20a=20transient=20createDdsM?= =?UTF-8?q?odule(...)=20failure=20won=E2=80=99t=20permanently=20poison=20f?= =?UTF-8?q?uture=20attempts.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/dds_mvp.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/web/dds_mvp.js b/web/dds_mvp.js index fbe789b3..5be39359 100644 --- a/web/dds_mvp.js +++ b/web/dds_mvp.js @@ -62,12 +62,10 @@ function loadDdsModule() { } ddsModulePromise = createDdsModule({ wasmBinary: ddsMvpWasmBytes() - }).then((module) => { - // MODULARIZE may return a Promise until the runtime is ready. - if (module != null && typeof module.then === "function") { - return module; - } - return module; + }).catch((error) => { + // Allow retry after transient initialization failures. + ddsModulePromise = null; + throw error; }); } From 3b8fa7cd9ce989cfa3fd1b2566b6857fcf7b0e9d Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 11:53:33 +0200 Subject: [PATCH 18/45] Removed unnecessary ensure_initialized(). Rely on DDS's default initialization. --- web/dds_mvp_wasm.cpp | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/web/dds_mvp_wasm.cpp b/web/dds_mvp_wasm.cpp index dc3bdf9b..1e44c8f4 100644 --- a/web/dds_mvp_wasm.cpp +++ b/web/dds_mvp_wasm.cpp @@ -15,16 +15,6 @@ #define EMSCRIPTEN_KEEPALIVE #endif -namespace { - -void ensure_initialized() { -#if defined(__linux) || defined(__APPLE__) || defined(__WASM__) - SetMaxThreads(0); -#endif -} - -} // namespace - extern "C" { // Fills out_table[20] with res_table[strain][hand] (strain 0..4 = S,H,D,C,N). @@ -35,8 +25,6 @@ auto dds_mvp_calc_table(const char* pbn, int* out_table) -> int { return RETURN_UNKNOWN_FAULT; } - ensure_initialized(); - DdTableDealPBN deal{}; if (std::strlen(pbn) >= sizeof(deal.cards)) { return RETURN_PBN_FAULT; From 4360e1dc9d32c47051651cc86d6b9edf92363738 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 12:11:36 +0200 Subject: [PATCH 19/45] Replaced std::strcpy with std::memcpy to avoid static-analyzer warnings. --- web/dds_mvp_wasm.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/web/dds_mvp_wasm.cpp b/web/dds_mvp_wasm.cpp index 1e44c8f4..6f3b4b26 100644 --- a/web/dds_mvp_wasm.cpp +++ b/web/dds_mvp_wasm.cpp @@ -1,5 +1,5 @@ /* - Bridge Hackathon DDS MVP — browser entry point for CalcDDtablePBN. + Browser entry point for CalcDDtablePBN. Copyright 2020-2026 Adam Wildavsky Use of this source code is governed by the MIT license. @@ -26,10 +26,11 @@ auto dds_mvp_calc_table(const char* pbn, int* out_table) -> int { } DdTableDealPBN deal{}; - if (std::strlen(pbn) >= sizeof(deal.cards)) { + const size_t pbn_len = std::strlen(pbn); + if (pbn_len >= sizeof(deal.cards)) { return RETURN_PBN_FAULT; } - std::strcpy(deal.cards, pbn); + std::memcpy(deal.cards, pbn, pbn_len + 1); DdTableResults table{}; const int res = CalcDDtablePBN(deal, &table); From c960116d54efaf3a57139633de4f7078ca08e779 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 12:14:35 +0200 Subject: [PATCH 20/45] Replaced "..." with … for proper rendering. --- web/dds_mvp.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/dds_mvp.js b/web/dds_mvp.js index 5be39359..af0e0e4f 100644 --- a/web/dds_mvp.js +++ b/web/dds_mvp.js @@ -286,7 +286,7 @@ async function sendJSON() { } clear_results(); - result.innerHTML = "Computing…"; + result.innerHTML = "Computing…"; // horizontal ellipsis try { const module = await loadDdsModule(); From cf07c5237d075b74db7cd0116b0426ae9f6baff0 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 12:27:58 +0200 Subject: [PATCH 21/45] Data structure tweeks suggested by Copilot. --- web/dds_mvp.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/dds_mvp.js b/web/dds_mvp.js index af0e0e4f..5d505a4b 100644 --- a/web/dds_mvp.js +++ b/web/dds_mvp.js @@ -199,10 +199,10 @@ function collectHands() { } function inputIsValid(hands) { - var deck = []; - var duplicates = []; + const deck = {}; + const duplicates = []; - for (const direction in hands) { + for (const direction of Object.keys(hands)) { const hand = hands[direction]; if (hand.length != 13) { From 6fa7a8100493642311dce8b0e2843900399b6cd7 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 13:00:49 +0200 Subject: [PATCH 22/45] Attenpt to deal with possible changes due to Emscripten version updates. --- MODULE.bazel | 2 ++ docs/wasm_build.md | 10 ++++++- web/patch_mvp_wasm.py | 63 ++++++++++++++++++++++++++++++++++--------- 3 files changed, 62 insertions(+), 13 deletions(-) diff --git a/MODULE.bazel b/MODULE.bazel index cf4cdcb9..7328a0c6 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -9,6 +9,8 @@ bazel_dep(name = "googletest", version = "1.17.0.bcr.2") bazel_dep(name = "pybind11_bazel", version = "3.0.1") bazel_dep(name = "rules_python", version = "2.0.1") bazel_dep(name = "toolchains_llvm", version = "1.7.0") +# WASM builds (//examples/wasm:*, //web:dds_mvp_wasm). If you bump this version, +# rebuild and run web/update_wasm.sh; see docs/wasm_build.md and patch_mvp_wasm.py. bazel_dep(name = "emsdk", version = "5.0.7") llvm = use_extension("@toolchains_llvm//toolchain/extensions:llvm.bzl", "llvm") diff --git a/docs/wasm_build.md b/docs/wasm_build.md index 9e052149..302bbe33 100644 --- a/docs/wasm_build.md +++ b/docs/wasm_build.md @@ -11,6 +11,14 @@ https://bazel.build/install The Emscripten SDK (emsdk) does NOT need to be manually installed. Bazel downloads and caches a hermetic Emscripten toolchain when you build a `wasm_cc_binary` target. +### Emscripten / emsdk version + +WASM builds use the Bazel Central Registry package pinned in `MODULE.bazel`: + +- `bazel_dep(name = "emsdk", version = "5.0.7")` (Emscripten 3.1.x toolchain) + +If you upgrade `emsdk`, rebuild WASM targets and the web MVP (`./web/update_wasm.sh`). The post-build script `web/patch_mvp_wasm.py` patches one generated `isFileURI` helper for browser/file URL safety; if Emscripten changes that line, update the regex in that script and this note. + ## Building WASM Examples WASM targets use `wasm_cc_binary`, which applies an Emscripten **platform transition** to the underlying `cc_binary`. You do **not** need `--config=wasm` or any other `.bazelrc` profile. @@ -71,7 +79,7 @@ python3 -m http.server 8080 --directory web # open http://localhost:8080/dds_mvp.html ``` -The MVP loads wasm from `dds_mvp_wasm_bin.js` (base64, no network fetch), so `file://` and HTTP both work. Run `./web/update_wasm.sh` to refresh `dds_mvp_wasm.{js,wasm,bin.js}`. +The MVP loads wasm from `dds_mvp_wasm_bin.js` (base64, no network fetch), so `file://` and HTTP both work. Run `./web/update_wasm.sh` to refresh `dds_mvp_wasm.{js,wasm,bin.js}` (includes a small post-process step for Emscripten `isFileURI`; see **Emscripten / emsdk version** above). For other experiments, copy built `.js` / `.wasm` files from `bazel-bin/examples/wasm/` to any static file server. diff --git a/web/patch_mvp_wasm.py b/web/patch_mvp_wasm.py index 42d56cff..285b976b 100755 --- a/web/patch_mvp_wasm.py +++ b/web/patch_mvp_wasm.py @@ -3,33 +3,72 @@ Do not use global substitution on SINGLE_FILE builds: the embedded wasm bytes can contain arbitrary text that would corrupt the module. + +The Emscripten line we patch is generated by rules_emscripten / emsdk. When +upgrading `bazel_dep(name = "emsdk", ...)` in MODULE.bazel, rebuild +`//web:dds_mvp_wasm` and run this script; if the regex no longer matches, +update UNPATCHED_IS_FILE_URI below and document the new version in +docs/wasm_build.md. """ from __future__ import annotations +import re import sys from pathlib import Path -OLD = "var isFileURI = (filename) => filename.startsWith('file://');" -NEW = ( - "var isFileURI = (filename) => " - "(typeof filename === 'string' ? filename : (filename && filename.href) || '')" - ".startsWith('file://');" +# Keep in sync with bazel_dep(name = "emsdk", version = "...") in MODULE.bazel. +EXPECTED_EMSDK_BAZEL_DEP_VERSION = "5.0.7" + +# Emscripten ~3.1.x (emsdk 5.0.7): var isFileURI = (filename) => filename.startsWith('file://'); +UNPATCHED_IS_FILE_URI = re.compile( + r"var\s+isFileURI\s*=\s*\(\s*(\w+)\s*\)\s*=>\s*" + r"\1\.startsWith\(\s*['\"]file://['\"]\s*\)\s*;", ) +# Already patched by a previous run of this script. +PATCHED_IS_FILE_URI = re.compile( + r"var\s+isFileURI\s*=\s*\(\s*(\w+)\s*\)\s*=>\s*" + r"\(\s*typeof\s+\1\s*===\s*['\"]string['\"]", +) + + +def patched_line(param: str) -> str: + return ( + f"var isFileURI = ({param}) => " + f"(typeof {param} === 'string' ? {param} : ({param} && {param}.href) || '')" + f".startsWith('file://');" + ) + def main() -> int: path = Path(sys.argv[1]) text = path.read_text(encoding="utf-8") - if NEW in text: + + if PATCHED_IS_FILE_URI.search(text): return 0 - count = text.count(OLD) - if count == 0: - print("patch_mvp_wasm: isFileURI line not found", file=sys.stderr) + + matches = list(UNPATCHED_IS_FILE_URI.finditer(text)) + if not matches: + print( + "patch_mvp_wasm: isFileURI declaration not found " + f"(expected Emscripten output from emsdk BCR {EXPECTED_EMSDK_BAZEL_DEP_VERSION}).\n" + "After upgrading emsdk, rebuild //web:dds_mvp_wasm and update " + "UNPATCHED_IS_FILE_URI in web/patch_mvp_wasm.py if the generator changed.", + file=sys.stderr, + ) return 1 - if count != 1: - print(f"patch_mvp_wasm: expected 1 match, got {count}", file=sys.stderr) + if len(matches) != 1: + print( + f"patch_mvp_wasm: expected 1 isFileURI declaration, found {len(matches)}", + file=sys.stderr, + ) return 1 - path.write_text(text.replace(OLD, NEW, 1), encoding="utf-8") + + match = matches[0] + param = match.group(1) + replacement = patched_line(param) + updated = text[: match.start()] + replacement + text[match.end() :] + path.write_text(updated, encoding="utf-8") return 0 From 4946a4394ee6b1b56d9597f4af952b3d9d572296 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 13:04:18 +0200 Subject: [PATCH 23/45] Added argv checks to all three scripts. Each now prints a clear usage: line and exits with code 2 when arguments are missing. --- web/gen_wasm_bin_js.py | 9 +++++++++ web/patch_mvp_wasm.py | 9 +++++++++ web/verify_wasm_js.py | 9 +++++++++ 3 files changed, 27 insertions(+) diff --git a/web/gen_wasm_bin_js.py b/web/gen_wasm_bin_js.py index 6d3b7cb6..9a438918 100755 --- a/web/gen_wasm_bin_js.py +++ b/web/gen_wasm_bin_js.py @@ -21,7 +21,16 @@ """ +def usage() -> None: + name = Path(sys.argv[0]).name + print(f"usage: {name} WASM_FILE OUTPUT_JS", file=sys.stderr) + + def main() -> int: + if len(sys.argv) != 3: + usage() + return 2 + wasm_path = Path(sys.argv[1]) out_path = Path(sys.argv[2]) b64 = base64.b64encode(wasm_path.read_bytes()).decode("ascii") diff --git a/web/patch_mvp_wasm.py b/web/patch_mvp_wasm.py index 285b976b..5ff86faf 100755 --- a/web/patch_mvp_wasm.py +++ b/web/patch_mvp_wasm.py @@ -40,7 +40,16 @@ def patched_line(param: str) -> str: ) +def usage() -> None: + name = Path(sys.argv[0]).name + print(f"usage: {name} EMSCRIPTEN_JS", file=sys.stderr) + + def main() -> int: + if len(sys.argv) != 2: + usage() + return 2 + path = Path(sys.argv[1]) text = path.read_text(encoding="utf-8") diff --git a/web/verify_wasm_js.py b/web/verify_wasm_js.py index 3969629c..79f82f53 100755 --- a/web/verify_wasm_js.py +++ b/web/verify_wasm_js.py @@ -7,7 +7,16 @@ from pathlib import Path +def usage() -> None: + name = Path(sys.argv[0]).name + print(f"usage: {name} WASM_FILE", file=sys.stderr) + + def main() -> int: + if len(sys.argv) != 2: + usage() + return 2 + wasm_path = Path(sys.argv[1]) wasm = wasm_path.read_bytes() print(f"{wasm_path}: {len(wasm)} bytes, magic={wasm[:4]!r}") From 9997a40f3802012ee548504c1eda6c3a87305399 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 13:11:53 +0200 Subject: [PATCH 24/45] Removed #ifdef guard on fallback code, to deal with Copilot unreachable code warning. --- library/src/system/system.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/library/src/system/system.cpp b/library/src/system/system.cpp index a2e5aec4..77a61558 100644 --- a/library/src/system/system.cpp +++ b/library/src/system/system.cpp @@ -264,12 +264,11 @@ void System::get_hardware( return; #endif -#if !defined(_WIN32) && !defined(__CYGWIN__) && !defined(__APPLE__) && !defined(__linux__) + // Fallback if no platform is detected if (kilobytes_free == 0) { kilobytes_free = 512ULL * 1024; } -#endif } From 9919ae556f944a8b239e93f3f819ac22762f9ca7 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 13:27:28 +0200 Subject: [PATCH 25/45] Added unit tests. --- .github/workflows/ci_wasm.yml | 5 +- examples/BUILD.bazel | 2 + examples/wasm/BUILD.bazel | 20 ++++ examples/wasm/calc_dd_table_pbn_test.cpp | 28 ++++++ web/BUILD.bazel | 40 +++++++- web/dds_mvp_wasm.cpp | 2 +- web/dds_mvp_wasm_test.cpp | 53 ++++++++++ web/gen_wasm_bin_js.py | 14 ++- web/patch_mvp_wasm.py | 32 +++--- web/tests/test_wasm_scripts.py | 118 +++++++++++++++++++++++ web/verify_wasm_js.py | 7 ++ 11 files changed, 301 insertions(+), 20 deletions(-) create mode 100644 examples/wasm/calc_dd_table_pbn_test.cpp create mode 100644 web/dds_mvp_wasm_test.cpp create mode 100644 web/tests/test_wasm_scripts.py diff --git a/.github/workflows/ci_wasm.yml b/.github/workflows/ci_wasm.yml index b17b8459..e702e21f 100644 --- a/.github/workflows/ci_wasm.yml +++ b/.github/workflows/ci_wasm.yml @@ -20,7 +20,10 @@ jobs: with: bazelisk-version: "1.29.0" - # 3️⃣ Build WASM targets (hermetic emsdk toolchain downloaded by Bazel) + # 3️⃣ Unit tests and WASM builds (hermetic emsdk toolchain downloaded by Bazel) + - name: Test web helpers and CalcDDtablePBN + run: bazel test --verbose_failures //web:web_tests //examples/wasm:calc_dd_table_pbn_test + - name: Build WASM targets run: bazel build --verbose_failures //examples/wasm:all_examples_wasm diff --git a/examples/BUILD.bazel b/examples/BUILD.bazel index 538c6a98..8b0b67d3 100644 --- a/examples/BUILD.bazel +++ b/examples/BUILD.bazel @@ -30,8 +30,10 @@ cc_library( name = "hands", srcs = ["hands.cpp"], hdrs = ["hands.hpp"], + includes = ["."], copts = EXAMPLES_CPPOPTS, local_defines = EXAMPLES_LOCAL_DEFINES, + visibility = ["//examples:__subpackages__"], deps = [ "//library/src:dds", "//library/src/api:api_definitions", diff --git a/examples/wasm/BUILD.bazel b/examples/wasm/BUILD.bazel index 19752ed8..a8d44f8a 100644 --- a/examples/wasm/BUILD.bazel +++ b/examples/wasm/BUILD.bazel @@ -1,4 +1,6 @@ +load("@rules_cc//cc:defs.bzl", "cc_test") load("@emsdk//emscripten_toolchain:wasm_rules.bzl", "wasm_cc_binary") +load("//:CPPVARIABLES.bzl", "DDS_CPPOPTS") wasm_cc_binary( name = "solve_board_wasm", @@ -36,3 +38,21 @@ filegroup( ], visibility = ["//visibility:public"], ) + +cc_test( + name = "calc_dd_table_pbn_test", + size = "small", + srcs = ["calc_dd_table_pbn_test.cpp"], + copts = DDS_CPPOPTS, + deps = [ + "//examples:hands", + "//library/src:dds", + "//library/src/api:api_definitions", + "@googletest//:gtest_main", + ], +) + +test_suite( + name = "all", + tests = [":calc_dd_table_pbn_test"], +) diff --git a/examples/wasm/calc_dd_table_pbn_test.cpp b/examples/wasm/calc_dd_table_pbn_test.cpp new file mode 100644 index 00000000..a0e5013b --- /dev/null +++ b/examples/wasm/calc_dd_table_pbn_test.cpp @@ -0,0 +1,28 @@ +/* + Regression tests for CalcDDtablePBN (WASM example target). + + Copyright 2020-2026 Adam Wildavsky + Use of this source code is governed by the MIT license. +*/ + +#include + +#include + +#include +#include "hands.hpp" + +TEST(CalcDdTablePbnWasmTest, MatchesReferenceTables) { +#if defined(__linux) || defined(__APPLE__) + SetMaxThreads(0); +#endif + + for (int handno = 0; handno < 3; ++handno) { + DdTableDealPBN deal{}; + std::strcpy(deal.cards, pbn_hands_[handno]); + + DdTableResults table{}; + ASSERT_EQ(CalcDDtablePBN(deal, &table), RETURN_NO_FAULT) << "hand " << handno; + EXPECT_TRUE(compare_table(&table, handno)) << "hand " << handno; + } +} diff --git a/web/BUILD.bazel b/web/BUILD.bazel index 23e9e7eb..a03b8f9f 100644 --- a/web/BUILD.bazel +++ b/web/BUILD.bazel @@ -1,5 +1,6 @@ -load("@rules_cc//cc:defs.bzl", "cc_binary") +load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_test") load("@emsdk//emscripten_toolchain:wasm_rules.bzl", "wasm_cc_binary") +load("@rules_python//python:defs.bzl", "py_test") load("//:CPPVARIABLES.bzl", "DDS_CPPOPTS", "DDS_LINKOPTS", "DDS_LOCAL_DEFINES") load("//:wasm_compat.bzl", "WASM_LINKOPTS") @@ -36,3 +37,40 @@ wasm_cc_binary( "dds_mvp_wasm.wasm", ], ) + +cc_test( + name = "dds_mvp_wasm_test", + size = "small", + srcs = [ + "dds_mvp_wasm.cpp", + "dds_mvp_wasm_test.cpp", + ], + copts = DDS_CPPOPTS, + linkopts = DDS_LINKOPTS, + local_defines = DDS_LOCAL_DEFINES + ["DDS_MVP_WASM_NO_MAIN"], + deps = [ + "//library/src:dds", + "//library/src/api:api_definitions", + "@googletest//:gtest_main", + ], +) + +py_test( + name = "wasm_scripts_test", + size = "small", + main = "tests/test_wasm_scripts.py", + srcs = ["tests/test_wasm_scripts.py"], + data = [ + "gen_wasm_bin_js.py", + "patch_mvp_wasm.py", + "verify_wasm_js.py", + ], +) + +test_suite( + name = "web_tests", + tests = [ + ":dds_mvp_wasm_test", + ":wasm_scripts_test", + ], +) diff --git a/web/dds_mvp_wasm.cpp b/web/dds_mvp_wasm.cpp index 6f3b4b26..87676ed1 100644 --- a/web/dds_mvp_wasm.cpp +++ b/web/dds_mvp_wasm.cpp @@ -49,6 +49,6 @@ auto dds_mvp_calc_table(const char* pbn, int* out_table) -> int { } // extern "C" -#if !defined(__EMSCRIPTEN__) +#if !defined(__EMSCRIPTEN__) && !defined(DDS_MVP_WASM_NO_MAIN) auto main() -> int { return 0; } #endif diff --git a/web/dds_mvp_wasm_test.cpp b/web/dds_mvp_wasm_test.cpp new file mode 100644 index 00000000..35bc17ce --- /dev/null +++ b/web/dds_mvp_wasm_test.cpp @@ -0,0 +1,53 @@ +/* + Unit tests for the browser MVP WASM bridge (dds_mvp_calc_table). + + Copyright 2020-2026 Adam Wildavsky + Use of this source code is governed by the MIT license. +*/ + +#include +#include + +#include + +#include + +extern "C" int dds_mvp_calc_table(const char* pbn, int* out_table); + +namespace { + +constexpr int kExpectedHand0[20] = { + 5, 8, 5, 8, 6, 6, 6, 6, 5, 7, 5, 7, 7, 5, 7, 5, 6, 6, 6, 6, +}; + +constexpr char kPbnHand0[] = + "N:QJ6.K652.J85.T98 873.J97.AT764.Q4 K5.T83.KQ9.A7652 AT942.AQ4.32.KJ3"; + +} // namespace + +TEST(DdsMvpWasmTest, RejectsNullPointers) { + int out[20]{}; + EXPECT_EQ(dds_mvp_calc_table(nullptr, out), RETURN_UNKNOWN_FAULT); + EXPECT_EQ(dds_mvp_calc_table(kPbnHand0, nullptr), RETURN_UNKNOWN_FAULT); +} + +TEST(DdsMvpWasmTest, RejectsPbnTooLong) { + int out[20]{}; + const std::string too_long(80, 'A'); + EXPECT_EQ(dds_mvp_calc_table(too_long.c_str(), out), RETURN_PBN_FAULT); +} + +TEST(DdsMvpWasmTest, RejectsInvalidPbn) { + int out[20]{}; + const int res = dds_mvp_calc_table("not-a-valid-pbn", out); + EXPECT_NE(res, RETURN_NO_FAULT); + EXPECT_LT(res, RETURN_NO_FAULT); +} + +TEST(DdsMvpWasmTest, FillsFlatStrainHandTable) { + int out[20]{}; + ASSERT_EQ(dds_mvp_calc_table(kPbnHand0, out), RETURN_NO_FAULT); + for (int i = 0; i < 20; ++i) { + EXPECT_EQ(out[i], kExpectedHand0[i]) << "index " << i; + } +} diff --git a/web/gen_wasm_bin_js.py b/web/gen_wasm_bin_js.py index 9a438918..b611e07c 100755 --- a/web/gen_wasm_bin_js.py +++ b/web/gen_wasm_bin_js.py @@ -26,6 +26,14 @@ def usage() -> None: print(f"usage: {name} WASM_FILE OUTPUT_JS", file=sys.stderr) +def make_bin_js(wasm_bytes: bytes) -> str: + """Return the contents of dds_mvp_wasm_bin.js for the given wasm module.""" + b64 = base64.b64encode(wasm_bytes).decode("ascii") + lines = [b64[i : i + 76] for i in range(0, len(b64), 76)] + body = 'const DDS_MVP_WASM_BASE64 =\n "' + '" +\n "'.join(lines) + '";\n\n' + return HEADER + body + + def main() -> int: if len(sys.argv) != 3: usage() @@ -33,11 +41,7 @@ def main() -> int: wasm_path = Path(sys.argv[1]) out_path = Path(sys.argv[2]) - b64 = base64.b64encode(wasm_path.read_bytes()).decode("ascii") - # Split base64 across lines to keep editors happy. - lines = [b64[i : i + 76] for i in range(0, len(b64), 76)] - body = 'const DDS_MVP_WASM_BASE64 =\n "' + '" +\n "'.join(lines) + '";\n\n' - out_path.write_text(HEADER + body, encoding="utf-8") + out_path.write_text(make_bin_js(wasm_path.read_bytes()), encoding="utf-8") print(f"wrote {out_path} ({wasm_path.stat().st_size} byte wasm)") return 0 diff --git a/web/patch_mvp_wasm.py b/web/patch_mvp_wasm.py index 5ff86faf..9c397678 100755 --- a/web/patch_mvp_wasm.py +++ b/web/patch_mvp_wasm.py @@ -45,16 +45,10 @@ def usage() -> None: print(f"usage: {name} EMSCRIPTEN_JS", file=sys.stderr) -def main() -> int: - if len(sys.argv) != 2: - usage() - return 2 - - path = Path(sys.argv[1]) - text = path.read_text(encoding="utf-8") - +def patch_text(text: str) -> tuple[str, int]: + """Return patched text and an exit code (0 success, 1 error).""" if PATCHED_IS_FILE_URI.search(text): - return 0 + return text, 0 matches = list(UNPATCHED_IS_FILE_URI.finditer(text)) if not matches: @@ -65,19 +59,33 @@ def main() -> int: "UNPATCHED_IS_FILE_URI in web/patch_mvp_wasm.py if the generator changed.", file=sys.stderr, ) - return 1 + return text, 1 if len(matches) != 1: print( f"patch_mvp_wasm: expected 1 isFileURI declaration, found {len(matches)}", file=sys.stderr, ) - return 1 + return text, 1 match = matches[0] param = match.group(1) replacement = patched_line(param) updated = text[: match.start()] + replacement + text[match.end() :] - path.write_text(updated, encoding="utf-8") + return updated, 0 + + +def main() -> int: + if len(sys.argv) != 2: + usage() + return 2 + + path = Path(sys.argv[1]) + text = path.read_text(encoding="utf-8") + updated, code = patch_text(text) + if code != 0: + return code + if updated != text: + path.write_text(updated, encoding="utf-8") return 0 diff --git a/web/tests/test_wasm_scripts.py b/web/tests/test_wasm_scripts.py new file mode 100644 index 00000000..34a188ef --- /dev/null +++ b/web/tests/test_wasm_scripts.py @@ -0,0 +1,118 @@ +import base64 +import importlib.util +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +WEB_ROOT = Path(__file__).resolve().parents[1] + + +def _load_module(name: str): + path = WEB_ROOT / f"{name}.py" + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod + + +patch_mvp_wasm = _load_module("patch_mvp_wasm") +gen_wasm_bin_js = _load_module("gen_wasm_bin_js") +verify_wasm_js = _load_module("verify_wasm_js") + +UNPATCHED_LINE = ( + "var isFileURI = (filename) => filename.startsWith('file://');\n" +) + + +class PatchMvpWasmTest(unittest.TestCase): + def test_patched_line_checks_type(self) -> None: + line = patch_mvp_wasm.patched_line("filename") + self.assertIn("typeof filename", line) + self.assertIn(".startsWith('file://')", line) + + def test_patch_text_replaces_unpatched(self) -> None: + updated, code = patch_mvp_wasm.patch_text(UNPATCHED_LINE) + self.assertEqual(code, 0) + self.assertNotEqual(updated, UNPATCHED_LINE) + self.assertRegex(updated, patch_mvp_wasm.PATCHED_IS_FILE_URI) + + def test_patch_text_idempotent(self) -> None: + once, _ = patch_mvp_wasm.patch_text(UNPATCHED_LINE) + again, code = patch_mvp_wasm.patch_text(once) + self.assertEqual(code, 0) + self.assertEqual(again, once) + + def test_patch_text_missing_declaration(self) -> None: + _, code = patch_mvp_wasm.patch_text("// no isFileURI here\n") + self.assertEqual(code, 1) + + def test_cli_writes_file(self) -> None: + with tempfile.NamedTemporaryFile(mode="w", suffix=".js", delete=False) as tmp: + tmp.write(UNPATCHED_LINE) + path = Path(tmp.name) + self.addCleanup(path.unlink, missing_ok=True) + proc = subprocess.run( + [sys.executable, str(WEB_ROOT / "patch_mvp_wasm.py"), str(path)], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(proc.returncode, 0) + self.assertRegex(path.read_text(encoding="utf-8"), patch_mvp_wasm.PATCHED_IS_FILE_URI) + + +class GenWasmBinJsTest(unittest.TestCase): + def test_make_bin_js_roundtrip(self) -> None: + wasm = b"\x00asm\x01\x00\x00\x00" + b"\xab" * 80 + js = gen_wasm_bin_js.make_bin_js(wasm) + self.assertIn("ddsMvpWasmBytes", js) + self.assertIn("DDS_MVP_WASM_BASE64", js) + b64 = base64.b64encode(wasm).decode("ascii") + self.assertIn(b64[:40], js.replace("\n", "").replace(" ", "")) + + def test_cli_writes_output(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + wasm_path = Path(tmpdir) / "tiny.wasm" + out_path = Path(tmpdir) / "dds_mvp_wasm_bin.js" + wasm_path.write_bytes(b"\x00asm\x01\x00\x00\x00") + proc = subprocess.run( + [ + sys.executable, + str(WEB_ROOT / "gen_wasm_bin_js.py"), + str(wasm_path), + str(out_path), + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(proc.returncode, 0) + self.assertTrue(out_path.is_file()) + self.assertIn("DDS_MVP_WASM_BASE64", out_path.read_text(encoding="utf-8")) + + +class VerifyWasmJsTest(unittest.TestCase): + def test_wasm_magic_ok(self) -> None: + self.assertTrue(verify_wasm_js.wasm_magic_ok(b"\x00asm\x01\x00\x00\x00")) + self.assertFalse(verify_wasm_js.wasm_magic_ok(b"bogus")) + self.assertFalse(verify_wasm_js.wasm_magic_ok(b"\x00as")) + + def test_cli_rejects_bad_magic(self) -> None: + with tempfile.NamedTemporaryFile(suffix=".wasm", delete=False) as tmp: + tmp.write(b"not-wasm") + path = Path(tmp.name) + self.addCleanup(path.unlink, missing_ok=True) + proc = subprocess.run( + [sys.executable, str(WEB_ROOT / "verify_wasm_js.py"), str(path)], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(proc.returncode, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/verify_wasm_js.py b/web/verify_wasm_js.py index 79f82f53..89bf0419 100755 --- a/web/verify_wasm_js.py +++ b/web/verify_wasm_js.py @@ -12,6 +12,10 @@ def usage() -> None: print(f"usage: {name} WASM_FILE", file=sys.stderr) +def wasm_magic_ok(wasm: bytes) -> bool: + return len(wasm) >= 4 and wasm[:4] == b"\x00asm" + + def main() -> int: if len(sys.argv) != 2: usage() @@ -19,6 +23,9 @@ def main() -> int: wasm_path = Path(sys.argv[1]) wasm = wasm_path.read_bytes() + if not wasm_magic_ok(wasm): + print(f"{wasm_path}: invalid wasm magic (got {wasm[:4]!r})", file=sys.stderr) + return 1 print(f"{wasm_path}: {len(wasm)} bytes, magic={wasm[:4]!r}") tmp = Path("/tmp/dds_mvp_test.wasm") From 5a2bf956a34a3501b785ac8958e1dfbf8ea4274d Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 13:35:24 +0200 Subject: [PATCH 26/45] Added system tests for the web component. --- .github/workflows/ci_wasm.yml | 4 +- docs/wasm_build.md | 16 ++- examples/wasm/BUILD.bazel | 19 +++- .../wasm/tests/test_wasm_examples_system.py | 44 ++++++++ web/BUILD.bazel | 21 +++- web/tests/dds_mvp_wasm_node.mjs | 71 ++++++++++++ web/tests/test_wasm_system.py | 104 ++++++++++++++++++ 7 files changed, 274 insertions(+), 5 deletions(-) create mode 100644 examples/wasm/tests/test_wasm_examples_system.py create mode 100644 web/tests/dds_mvp_wasm_node.mjs create mode 100644 web/tests/test_wasm_system.py diff --git a/.github/workflows/ci_wasm.yml b/.github/workflows/ci_wasm.yml index e702e21f..c43959b0 100644 --- a/.github/workflows/ci_wasm.yml +++ b/.github/workflows/ci_wasm.yml @@ -21,8 +21,8 @@ jobs: bazelisk-version: "1.29.0" # 3️⃣ Unit tests and WASM builds (hermetic emsdk toolchain downloaded by Bazel) - - name: Test web helpers and CalcDDtablePBN - run: bazel test --verbose_failures //web:web_tests //examples/wasm:calc_dd_table_pbn_test + - name: Test web helpers, system tests, and CalcDDtablePBN + run: bazel test --verbose_failures //web:web_tests //web:web_system_tests //examples/wasm:all - name: Build WASM targets run: bazel build --verbose_failures //examples/wasm:all_examples_wasm diff --git a/docs/wasm_build.md b/docs/wasm_build.md index 302bbe33..ab2fbbce 100644 --- a/docs/wasm_build.md +++ b/docs/wasm_build.md @@ -109,12 +109,26 @@ build:linux --cxxopt=-std=c++20 There is no separate `build:wasm` profile in `.bazelrc`; WASM builds are selected by targeting `//examples/wasm:*`. +## Tests + +Unit and system tests (Node.js required for system tests; skipped if `node` is not on `PATH`): + +```bash +bazel test //web:web_tests //web:web_system_tests +bazel test //examples/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`). +- **`//examples/wasm:wasm_examples_system_test`** — runs `calc_dd_table_pbn.js` under Node and checks for `OK` on all three example hands. + +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 - The `__WASM__` preprocessor constant is defined for WASM builds (`CPPVARIABLES.bzl`). It was added to work around platform-specific code paths; revisit whether it can be narrowed or removed as WASM support matures. - Some threading and platform-specific features are disabled or stubbed when `__WASM__` is set. - A reusable `cc_library` WASM artifact (not only example binaries) is not yet provided; today only `wasm_cc_binary` example targets are wired up. -- Browser/HTML packaging (`.html` shell, assets) is not yet documented or automated; Node.js is the supported smoke-test runner for now. +- The browser MVP lives under `web/`; see **Web browser (DDS MVP)** above and `//web:web_system_tests`. ## Next steps diff --git a/examples/wasm/BUILD.bazel b/examples/wasm/BUILD.bazel index a8d44f8a..1fa2f9b0 100644 --- a/examples/wasm/BUILD.bazel +++ b/examples/wasm/BUILD.bazel @@ -1,5 +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("//:CPPVARIABLES.bzl", "DDS_CPPOPTS") wasm_cc_binary( @@ -52,7 +53,23 @@ cc_test( ], ) +py_test( + name = "wasm_examples_system_test", + size = "medium", + main = "tests/test_wasm_examples_system.py", + srcs = ["tests/test_wasm_examples_system.py"], + data = [":calc_dd_table_pbn_wasm"], +) + test_suite( name = "all", - tests = [":calc_dd_table_pbn_test"], + tests = [ + ":calc_dd_table_pbn_test", + ":wasm_examples_system_test", + ], +) + +test_suite( + name = "wasm_system_tests", + tests = [":wasm_examples_system_test"], ) diff --git a/examples/wasm/tests/test_wasm_examples_system.py b/examples/wasm/tests/test_wasm_examples_system.py new file mode 100644 index 00000000..c16fc794 --- /dev/null +++ b/examples/wasm/tests/test_wasm_examples_system.py @@ -0,0 +1,44 @@ +"""System tests: run WASM example binaries under Node.js.""" +from __future__ import annotations + +import os +import shutil +import subprocess +import unittest +from pathlib import Path + + +def _runfiles_root() -> Path: + for key in ("RUNFILES_DIR", "TEST_SRCDIR"): + if key in os.environ: + return Path(os.environ[key]) + raise RuntimeError("not running under Bazel test") + + +def rlocation(relpath: str) -> Path: + root = _runfiles_root() + for candidate in (root / relpath, root / "_main" / relpath): + if candidate.exists(): + return candidate + raise FileNotFoundError(relpath) + + +@unittest.skipUnless(shutil.which("node"), "node not found") +class WasmExamplesSystemTest(unittest.TestCase): + def test_calc_dd_table_pbn_wasm(self) -> None: + js = rlocation("examples/wasm/calc_dd_table_pbn.js") + proc = subprocess.run( + ["node", str(js)], + capture_output=True, + text=True, + check=False, + timeout=120, + ) + 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) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/BUILD.bazel b/web/BUILD.bazel index a03b8f9f..afecd2dd 100644 --- a/web/BUILD.bazel +++ b/web/BUILD.bazel @@ -10,7 +10,7 @@ WASM_MVP_LINKOPTS = WASM_LINKOPTS + [ "-sEXPORT_NAME=createDdsModule", "-sEXPORTED_FUNCTIONS=['_dds_mvp_calc_table','_malloc','_free']", "-sEXPORTED_RUNTIME_METHODS=['ccall','getValue']", - "-sENVIRONMENT=web", + "-sENVIRONMENT=web,node", ] cc_binary( @@ -67,6 +67,20 @@ py_test( ], ) +py_test( + name = "dds_mvp_wasm_system_test", + size = "medium", + main = "tests/test_wasm_system.py", + srcs = ["tests/test_wasm_system.py"], + data = [ + ":dds_mvp_wasm", + "gen_wasm_bin_js.py", + "patch_mvp_wasm.py", + "tests/dds_mvp_wasm_node.mjs", + "verify_wasm_js.py", + ], +) + test_suite( name = "web_tests", tests = [ @@ -74,3 +88,8 @@ test_suite( ":wasm_scripts_test", ], ) + +test_suite( + name = "web_system_tests", + tests = [":dds_mvp_wasm_system_test"], +) diff --git a/web/tests/dds_mvp_wasm_node.mjs b/web/tests/dds_mvp_wasm_node.mjs new file mode 100644 index 00000000..dff6b0f9 --- /dev/null +++ b/web/tests/dds_mvp_wasm_node.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +/** + * System smoke test: load Emscripten MVP module and call dds_mvp_calc_table. + * + * Usage: node dds_mvp_wasm_node.mjs EMSCRIPTEN_JS WASM_FILE [PBN] + */ +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +const EXPECTED_DEFAULT = [ + 5, 8, 5, 8, 6, 6, 6, 6, 5, 7, 5, 7, 7, 5, 7, 5, 6, 6, 6, 6, +]; + +const DEFAULT_PBN = + "N:QJ6.K652.J85.T98 873.J97.AT764.Q4 K5.T83.KQ9.A7652 AT942.AQ4.32.KJ3"; + +function fail(message) { + console.error(message); + process.exit(1); +} + +async function main() { + const [jsPath, wasmPath, pbnArg] = process.argv.slice(2); + if (!jsPath || !wasmPath) { + fail("usage: node dds_mvp_wasm_node.mjs EMSCRIPTEN_JS WASM_FILE [PBN]"); + } + + const wasmBinary = fs.readFileSync(wasmPath); + const createDdsModule = require(path.resolve(jsPath)); + if (typeof createDdsModule !== "function") { + fail("createDdsModule is not a function"); + } + + const module = await createDdsModule({ wasmBinary }); + const pbn = pbnArg ?? DEFAULT_PBN; + const outPtr = module._malloc(20 * 4); + try { + const rc = module.ccall( + "dds_mvp_calc_table", + "number", + ["string", "number"], + [pbn, outPtr], + ); + if (rc !== 1) { + fail(`dds_mvp_calc_table returned ${rc}`); + } + + const out = []; + for (let i = 0; i < 20; i++) { + out.push(module.getValue(outPtr + i * 4, "i32")); + } + + for (let i = 0; i < EXPECTED_DEFAULT.length; i++) { + if (out[i] !== EXPECTED_DEFAULT[i]) { + fail(`table[${i}] = ${out[i]}, expected ${EXPECTED_DEFAULT[i]}`); + } + } + + console.log("dds_mvp_wasm_node: OK"); + } finally { + module._free(outPtr); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/web/tests/test_wasm_system.py b/web/tests/test_wasm_system.py new file mode 100644 index 00000000..32c20fdb --- /dev/null +++ b/web/tests/test_wasm_system.py @@ -0,0 +1,104 @@ +"""End-to-end system tests for the web MVP WASM build and Node smoke harness.""" +from __future__ import annotations + +import importlib.util +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +WEB_ROOT = Path(__file__).resolve().parents[1] +TESTS_ROOT = Path(__file__).resolve().parent + + +def _runfiles_root() -> Path: + for key in ("RUNFILES_DIR", "TEST_SRCDIR"): + if key in os.environ: + return Path(os.environ[key]) + raise RuntimeError("not running under Bazel test") + + +def rlocation(relpath: str) -> Path: + root = _runfiles_root() + for candidate in (root / relpath, root / "_main" / relpath): + if candidate.exists(): + return candidate + raise FileNotFoundError(relpath) + + +def _load_module(name: str): + path = WEB_ROOT / f"{name}.py" + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod + + +def _require_node() -> str: + node = shutil.which("node") + if not node: + raise unittest.SkipTest("node not found") + return node + + +@unittest.skipUnless(shutil.which("node"), "node not found") +class DdsMvpWasmSystemTest(unittest.TestCase): + def test_update_pipeline_and_node_smoke(self) -> None: + node = _require_node() + js_src = rlocation("web/dds_mvp_wasm.js") + wasm_src = rlocation("web/dds_mvp_wasm.wasm") + + patch_mvp_wasm = _load_module("patch_mvp_wasm") + gen_wasm_bin_js = _load_module("gen_wasm_bin_js") + verify_wasm_js = _load_module("verify_wasm_js") + + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + js_path = tmp / "dds_mvp_wasm.js" + wasm_path = tmp / "dds_mvp_wasm.wasm" + bin_js_path = tmp / "dds_mvp_wasm_bin.js" + + shutil.copyfile(js_src, js_path) + shutil.copyfile(wasm_src, wasm_path) + js_path.chmod(0o644) + wasm_path.chmod(0o644) + + updated, code = patch_mvp_wasm.patch_text(js_path.read_text(encoding="utf-8")) + self.assertEqual(code, 0) + js_path.write_text(updated, encoding="utf-8") + + again, code2 = patch_mvp_wasm.patch_text(js_path.read_text(encoding="utf-8")) + self.assertEqual(code2, 0) + self.assertEqual(again, updated) + + bin_js_path.write_text( + gen_wasm_bin_js.make_bin_js(wasm_path.read_bytes()), + encoding="utf-8", + ) + self.assertTrue(verify_wasm_js.wasm_magic_ok(wasm_path.read_bytes())) + self.assertIn("ddsMvpWasmBytes", bin_js_path.read_text(encoding="utf-8")) + + proc = subprocess.run( + [ + node, + str(TESTS_ROOT / "dds_mvp_wasm_node.mjs"), + str(js_path), + str(wasm_path), + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual( + proc.returncode, + 0, + msg=proc.stdout + proc.stderr, + ) + self.assertIn("dds_mvp_wasm_node: OK", proc.stdout) + + +if __name__ == "__main__": + unittest.main() From bcb8d1938c72cc0b3dbbc81adbdd444c0ad2e2d9 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 13:57:07 +0200 Subject: [PATCH 27/45] Added end-to-end tests for web. --- .github/workflows/ci_wasm.yml | 2 +- MODULE.bazel | 8 + MODULE.bazel.lock | 497 +++++++++++++++++++++++++++++++++- docs/wasm_build.md | 3 +- web/BUILD.bazel | 53 +++- web/requirements.in | 1 + web/requirements_lock.txt | 14 + web/tests/mvp_site.py | 65 +++++ web/tests/test_mvp_e2e.py | 212 +++++++++++++++ web/tests/test_wasm_system.py | 66 +---- 10 files changed, 856 insertions(+), 65 deletions(-) create mode 100644 web/requirements.in create mode 100644 web/requirements_lock.txt create mode 100644 web/tests/mvp_site.py create mode 100644 web/tests/test_mvp_e2e.py diff --git a/.github/workflows/ci_wasm.yml b/.github/workflows/ci_wasm.yml index c43959b0..397285c8 100644 --- a/.github/workflows/ci_wasm.yml +++ b/.github/workflows/ci_wasm.yml @@ -21,7 +21,7 @@ jobs: bazelisk-version: "1.29.0" # 3️⃣ Unit tests and WASM builds (hermetic emsdk toolchain downloaded by Bazel) - - name: Test web helpers, system tests, and CalcDDtablePBN + - name: Test web helpers, system tests, e2e, and CalcDDtablePBN run: bazel test --verbose_failures //web:web_tests //web:web_system_tests //examples/wasm:all - name: Build WASM targets diff --git a/MODULE.bazel b/MODULE.bazel index 7328a0c6..e9766b27 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -32,5 +32,13 @@ python.toolchain( ) use_repo(python, "python_3_14") +pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip") +pip.parse( + hub_name = "pypi", + python_version = "3.14", + requirements_lock = "//web:requirements_lock.txt", +) +use_repo(pip, "pypi") + register_toolchains("@llvm_toolchain//:all") register_toolchains("@emsdk//:all") \ No newline at end of file diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 54b461c3..800cc995 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -549,5 +549,500 @@ } } }, - "facts": {} + "facts": { + "@@rules_python+//python/extensions:pip.bzl%pip": { + "dist_hashes": { + "https://pypi.org/simple": { + "backports-tarfile": { + "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz": "d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", + "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl": "77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34" + }, + "certifi": { + "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz": "47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", + "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl": "0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de" + }, + "cffi": { + "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", + "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl": "3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", + "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl": "5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", + "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl": "b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", + "https://files.pythonhosted.org/packages/15/12/a7a79bd0df4c3bff744b2d7e52cc1b68d5e7e427b384252c42366dc1ecbc/cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165", + "https://files.pythonhosted.org/packages/1f/74/cc4096ce66f5939042ae094e2e96f53426a979864aa1f96a621ad128be27/cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63", + "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl": "cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", + "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl": "7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", + "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl": "737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", + "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl": "c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", + "https://files.pythonhosted.org/packages/2b/e7/7c769804eb75e4c4b35e658dba01de1640a351a9653c3d49ca89d16ccc91/cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl": "89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322", + "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl": "7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", + "https://files.pythonhosted.org/packages/32/f2/81b63e288295928739d715d00952c8c6034cb6c6a516b17d37e0c8be5600/cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl": "cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f", + "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl": "b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", + "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl": "6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", + "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl": "19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", + "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl": "81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", + "https://files.pythonhosted.org/packages/3d/de/38d9726324e127f727b4ecc376bc85e505bfe61ef130eaf3f290c6847dd4/cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl": "de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7", + "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl": "9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", + "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl": "087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", + "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl": "a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", + "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", + "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl": "8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", + "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl": "45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", + "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl": "00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", + "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", + "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl": "2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", + "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", + "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl": "d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", + "https://files.pythonhosted.org/packages/54/8f/a1e836f82d8e32a97e6b29cc8f641779181ac7363734f12df27db803ebda/cffi-2.0.0-cp39-cp39-win_amd64.whl": "b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9", + "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl": "c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", + "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl": "e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", + "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", + "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl": "da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", + "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl": "9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", + "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl": "fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", + "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl": "0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", + "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl": "4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", + "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl": "c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", + "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", + "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", + "https://files.pythonhosted.org/packages/9b/13/c92e36358fbcc39cf0962e83223c9522154ee8630e1df7c0b3a39a8124e2/cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c", + "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl": "f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", + "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl": "dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", + "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl": "9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", + "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl": "1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", + "https://files.pythonhosted.org/packages/a3/ad/5c51c1c7600bdd7ed9a24a203ec255dccdd0ebf4527f7b922a0bde2fb6ed/cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534", + "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", + "https://files.pythonhosted.org/packages/aa/d9/6218d78f920dcd7507fc16a766b5ef8f3b913cc7aa938e7fc80b9978d089/cffi-2.0.0-cp39-cp39-win32.whl": "2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a", + "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl": "94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", + "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl": "0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", + "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl": "66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", + "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", + "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", + "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", + "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl": "2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", + "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", + "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl": "203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", + "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl": "d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", + "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", + "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl": "d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", + "https://files.pythonhosted.org/packages/c0/cc/08ed5a43f2996a16b462f64a7055c6e962803534924b9b2f1371d8c00b7b/cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl": "fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf", + "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", + "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl": "38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", + "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl": "256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", + "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl": "dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", + "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", + "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", + "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", + "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", + "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl": "b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", + "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", + "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl": "8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", + "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", + "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl": "6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", + "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl": "1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", + "https://files.pythonhosted.org/packages/e8/be/f6424d1dc46b1091ffcc8964fa7c0ab0cd36839dd2761b49c90481a6ba1b/cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl": "0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2", + "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl": "6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", + "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz": "44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", + "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl": "74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", + "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", + "https://files.pythonhosted.org/packages/f7/e0/dda537c2309817edf60109e39265f24f24aa7f050767e22c98c53fe7f48b/cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl": "1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65", + "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl": "da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", + "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe" + }, + "charset-normalizer": { + "https://files.pythonhosted.org/packages/00/bd/ef9c88464b126fa176f4ef4a317ad9b6f4d30b2cffbc43386062367c3e2c/charset_normalizer-3.4.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9", + "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl": "ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", + "https://files.pythonhosted.org/packages/04/9a/914d294daa4809c57667b77470533e65def9c0be1ef8b4c1183a99170e9d/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7", + "https://files.pythonhosted.org/packages/05/35/bb59b1cd012d7196fc81c2f5879113971efc226a63812c9cf7f89fe97c40/charset_normalizer-3.4.3-cp38-cp38-win_amd64.whl": "5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40", + "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl": "42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", + "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl": "30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", + "https://files.pythonhosted.org/packages/0c/52/8b0c6c3e53f7e546a5e49b9edb876f379725914e1130297f3b423c7b71c5/charset_normalizer-3.4.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b", + "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", + "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", + "https://files.pythonhosted.org/packages/1a/79/ae516e678d6e32df2e7e740a7be51dc80b700e2697cb70054a0f1ac2c955/charset_normalizer-3.4.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b", + "https://files.pythonhosted.org/packages/20/30/5f64fe3981677fe63fa987b80e6c01042eb5ff653ff7cec1b7bd9268e54e/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl": "2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312", + "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", + "https://files.pythonhosted.org/packages/22/82/63a45bfc36f73efe46731a3a71cb84e2112f7e0b049507025ce477f0f052/charset_normalizer-3.4.3-cp38-cp38-macosx_10_9_universal2.whl": "0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c", + "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl": "d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", + "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl": "1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", + "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl": "cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", + "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl": "78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", + "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", + "https://files.pythonhosted.org/packages/39/c6/99271dc37243a4f925b09090493fb96c9333d7992c6187f5cfe5312008d2/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e", + "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl": "86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", + "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", + "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl": "4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", + "https://files.pythonhosted.org/packages/45/8c/dcef87cfc2b3f002a6478f38906f9040302c68aebe21468090e39cde1445/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl": "88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34", + "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl": "939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", + "https://files.pythonhosted.org/packages/50/10/c117806094d2c956ba88958dab680574019abc0c02bcf57b32287afca544/charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_x86_64.whl": "a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b", + "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl": "fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", + "https://files.pythonhosted.org/packages/59/c0/a74f3bd167d311365e7973990243f32c35e7a94e45103125275b9e6c479f/charset_normalizer-3.4.3-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4", + "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", + "https://files.pythonhosted.org/packages/61/c5/dc3ba772489c453621ffc27e8978a98fe7e41a93e787e5e5bde797f1dddb/charset_normalizer-3.4.3-cp38-cp38-win32.whl": "ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557", + "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl": "96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", + "https://files.pythonhosted.org/packages/63/86/9cbd533bd37883d467fcd1bd491b3547a3532d0fbb46de2b99feeebf185e/charset_normalizer-3.4.3-cp39-cp39-win32.whl": "16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432", + "https://files.pythonhosted.org/packages/64/d1/f9d141c893ef5d4243bc75c130e95af8fd4bc355beff06e9b1e941daad6e/charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_ppc64le.whl": "5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a", + "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl": "6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", + "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", + "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl": "14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", + "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl": "18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", + "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", + "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl": "d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", + "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl": "fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", + "https://files.pythonhosted.org/packages/7a/03/cbb6fac9d3e57f7e07ce062712ee80d80a5ab46614684078461917426279/charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_aarch64.whl": "d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb", + "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", + "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl": "53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", + "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", + "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl": "b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", + "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", + "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz": "6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", + "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", + "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", + "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", + "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl": "ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", + "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl": "3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", + "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl": "fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", + "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl": "cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", + "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", + "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl": "6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", + "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl": "02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", + "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl": "027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", + "https://files.pythonhosted.org/packages/b0/a8/6f5bcf1bcf63cb45625f7c5cadca026121ff8a6c8a3256d8d8cd59302663/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7", + "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl": "c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", + "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl": "320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", + "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl": "6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", + "https://files.pythonhosted.org/packages/c2/ca/9a0983dd5c8e9733565cf3db4df2b0a2e9a82659fd8aa2a868ac6e4a991f/charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl": "70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05", + "https://files.pythonhosted.org/packages/c4/72/d3d0e9592f4e504f9dea08b8db270821c909558c353dc3b457ed2509f2fb/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl": "1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19", + "https://files.pythonhosted.org/packages/c5/35/9c99739250742375167bc1b1319cd1cec2bf67438a70d84b2e1ec4c9daa3/charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_s390x.whl": "b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942", + "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", + "https://files.pythonhosted.org/packages/ce/d6/7e805c8e5c46ff9729c49950acc4ee0aeb55efb8b3a56687658ad10c3216/charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl": "d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca", + "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", + "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl": "fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", + "https://files.pythonhosted.org/packages/e1/ef/dd08b2cac9284fd59e70f7d97382c33a3d0a926e45b15fc21b3308324ffd/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl": "511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc", + "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl": "c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", + "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", + "https://files.pythonhosted.org/packages/e4/69/132eab043356bba06eb333cc2cc60c6340857d0a2e4ca6dc2b51312886b3/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99", + "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl": "e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", + "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl": "73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", + "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl": "c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", + "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", + "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl": "31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", + "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl": "bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", + "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018" + }, + "cryptography": { + "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl": "2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", + "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl": "760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", + "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl": "02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", + "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl": "8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", + "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl": "7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", + "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl": "3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", + "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl": "69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", + "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl": "aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", + "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", + "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl": "6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", + "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl": "79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", + "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl": "2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", + "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl": "ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", + "https://files.pythonhosted.org/packages/2e/84/7ccff00ced5bac74b775ce0beb7d1be4e8637536b522b5df9b73ada42da2/cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl": "2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", + "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl": "8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", + "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl": "22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", + "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl": "6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", + "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl": "64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", + "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", + "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl": "d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", + "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl": "b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", + "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", + "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl": "380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", + "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl": "b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", + "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl": "341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", + "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl": "c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", + "https://files.pythonhosted.org/packages/9e/c5/e1594c4eec66a567c3ac4400008108a415808be2ce13dcb9a9045c92f1a0/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl": "90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", + "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz": "27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", + "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", + "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl": "bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", + "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl": "4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", + "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl": "8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", + "https://files.pythonhosted.org/packages/bc/1f/4c926f50df7749f000f20eede0c896769509895e2648db5da0ed55db711d/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl": "a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", + "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl": "6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", + "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl": "12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", + "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl": "7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", + "https://files.pythonhosted.org/packages/c6/65/707be3ffbd5f786028665c3223e86e11c4cda86023adbc56bd72b1b6bab5/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl": "12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", + "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl": "c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", + "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl": "97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", + "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl": "67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", + "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl": "3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", + "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl": "456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", + "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl": "cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", + "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl": "9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", + "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", + "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl": "d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", + "https://files.pythonhosted.org/packages/f3/6d/73557ed0ef7d73d04d9aba745d2c8e95218213687ee5e76b7d236a5030fc/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl": "50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", + "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", + "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl": "063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d" + }, + "docutils": { + "https://files.pythonhosted.org/packages/4a/c0/89fe6215b443b919cb98a5002e107cb5026854ed1ccb6b5833e0768419d1/docutils-0.22.2.tar.gz": "9fdb771707c8784c8f2728b67cb2c691305933d68137ef95a75db5f4dfbc213d", + "https://files.pythonhosted.org/packages/66/dd/f95350e853a4468ec37478414fc04ae2d61dad7a947b3015c3dcc51a09b9/docutils-0.22.2-py3-none-any.whl": "b0e98d679283fc3bb0ead8a5da7f501baa632654e7056e9c5846842213d674d8" + }, + "greenlet": { + "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl": "9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", + "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl": "b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", + "https://files.pythonhosted.org/packages/0f/05/85a511e68ee109aff0aa00b4b497806091dd2d82ce209e49c6e801bd5d92/greenlet-3.5.1-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl": "c3d35f87c7253b715d13d679e0783d845910144f282cb939fe1ba4ac8616269c", + "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl": "d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", + "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl": "7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", + "https://files.pythonhosted.org/packages/1a/c6/50e520283a9f19388a7326b05f9e8637e566003475eacaadad04f558c68d/greenlet-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl": "ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071", + "https://files.pythonhosted.org/packages/1d/21/117c8710abb7f146d804a124c07eb5964a60b90d02b72452885aecc18efa/greenlet-3.5.1-cp310-cp310-macosx_11_0_universal2.whl": "7eacb17a9d41538a2bc4912eba5ef13823c83cb69e4d141d0813debe7163187f", + "https://files.pythonhosted.org/packages/21/1c/13abd1f4860d987fa5e1170a01930d6e6cd40d328de487a3c9fdaff0ffd0/greenlet-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl": "d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c", + "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl": "cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", + "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl": "7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", + "https://files.pythonhosted.org/packages/26/9a/4ba4c2bc9d9df5f41bb8943fb7bb11e440352e6b9c2e36716b6e85f8b82d/greenlet-3.5.1-cp310-cp310-manylinux_2_39_riscv64.whl": "001775efe7b8e758861294c7a27c28af87f3f3f1c20468a2bc618c45b346c061", + "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl": "51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", + "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl": "8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", + "https://files.pythonhosted.org/packages/2c/f2/8fd452fd81adb9ec79c8275c1375702ab0fd6bee4952da12eaa09b9508d8/greenlet-3.5.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl": "6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360", + "https://files.pythonhosted.org/packages/2e/19/60df45065b2981ff894fdd51e7c99a3a4b107412822b083d88d5d528f663/greenlet-3.5.1-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl": "00929c98ec525fd9bf075875d8c5f6a983a90906cdf78a66e6de2d8e466c2a19", + "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl": "3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", + "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl": "7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", + "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl": "3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", + "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl": "110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", + "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl": "ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", + "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl": "add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", + "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl": "50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", + "https://files.pythonhosted.org/packages/42/3c/ff890b466eaba2b0f5e6bdfff025f8c75f41b8ffdc3dbc3d24ad261e764a/greenlet-3.5.1-cp311-cp311-macosx_11_0_universal2.whl": "73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f", + "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl": "a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", + "https://files.pythonhosted.org/packages/46/1c/43b8203cf10f4292c9e3d270e9e5f5ade79115a0a0ca5ea6f1be5f8915a7/greenlet-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl": "9d59e840387076a51016777a9328b3f2c427c6f9208a6e958bad251be50a648d", + "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl": "7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", + "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl": "e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", + "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl": "5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", + "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl": "2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", + "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl": "45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", + "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl": "a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", + "https://files.pythonhosted.org/packages/5d/14/ad1f9fc9b82384c010212464a3702bd911f95dab2f1180bc6fbcfb1f958c/greenlet-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl": "ed8cdb691169715a9a492844a83246f090182247d1a5031dc78a403f68ba1e97", + "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl": "2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", + "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl": "80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", + "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl": "c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", + "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl": "1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", + "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl": "a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", + "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl": "f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", + "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz": "5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", + "https://files.pythonhosted.org/packages/6d/e1/f89a21d58d308298e6f275f13a1b472ed96c680b601a371b08be6a725989/greenlet-3.5.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl": "a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33", + "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl": "d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", + "https://files.pythonhosted.org/packages/75/de/af6cef182862d2ccd6975440d21c9058a77c3f9b469abf94e322dfd2e0e3/greenlet-3.5.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl": "8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563", + "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl": "ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", + "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl": "2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", + "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl": "a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", + "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl": "c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", + "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl": "89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", + "https://files.pythonhosted.org/packages/81/0e/5e5457be3d256918f6a4756f073548a3f0190836e2cc94aa6d0d617a940b/greenlet-3.5.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl": "a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2", + "https://files.pythonhosted.org/packages/89/b8/8b83d18ae07c46c019617f35afd7b47aab7f9b4fbb12fc637d681e10bdd8/greenlet-3.5.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl": "540dae7b956209af4d70a3be35927b4055f617763771e5e84a5255bea934d2f5", + "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl": "8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", + "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl": "d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", + "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl": "ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", + "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl": "ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", + "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl": "17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", + "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl": "e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", + "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl": "4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", + "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl": "3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", + "https://files.pythonhosted.org/packages/ac/6e/0344b1e99f58f71715456e46492101fd2daa408957b8186ade0a4b515da7/greenlet-3.5.1-cp310-cp310-win_amd64.whl": "b9152fca4a6466e114aaec745ae61cba739903a109754a9d4e1262f01e9259b1", + "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl": "1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", + "https://files.pythonhosted.org/packages/b9/f7/6762a56fa5f6c2295c449c6524e10ce481e381c994cc44d9d03aef0700fb/greenlet-3.5.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl": "e5cc9606aa5f4e0bde0d3bd502b44f743864c3ffa5cfa1011b1e30f5aa02366f", + "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl": "92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", + "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl": "fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", + "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl": "cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", + "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl": "6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", + "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl": "a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", + "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl": "975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", + "https://files.pythonhosted.org/packages/d9/a9/a3c2fa886c5b94863fb0e61b3bc14610b7aa94cf4f17f8741b11708305fc/greenlet-3.5.1-cp311-cp311-win_arm64.whl": "cc6ab7e555c8a112ad3a76e368e86e12a2754bcae1652a5602e133ec7b635523", + "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl": "10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", + "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl": "e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", + "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl": "9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", + "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl": "de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", + "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl": "089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", + "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl": "a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", + "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl": "5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", + "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl": "67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", + "https://files.pythonhosted.org/packages/ec/bc/c318aa9f3ffc77320fddcee3d892be957b42e2ff947198d9450b004f3a38/greenlet-3.5.1-cp311-cp311-manylinux_2_39_riscv64.whl": "017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747", + "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl": "dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", + "https://files.pythonhosted.org/packages/f5/56/5f332b7705545eac2dc01b4e9254d24a793f2656d55d5cc6b94ee59d22ae/greenlet-3.5.1-cp311-cp311-win_amd64.whl": "88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e", + "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl": "111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64" + }, + "idna": { + "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl": "946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", + "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz": "12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9" + }, + "importlib-metadata": { + "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl": "e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", + "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz": "d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000" + }, + "jaraco-classes": { + "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz": "47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", + "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl": "f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790" + }, + "jaraco-context": { + "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz": "9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", + "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl": "f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4" + }, + "jaraco-functools": { + "https://files.pythonhosted.org/packages/b4/09/726f168acad366b11e420df31bf1c702a54d373a83f968d94141a8c3fde0/jaraco_functools-4.3.0-py3-none-any.whl": "227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8", + "https://files.pythonhosted.org/packages/f7/ed/1aa2d585304ec07262e1a83a9889880701079dde796ac7b1d1826f40c63d/jaraco_functools-4.3.0.tar.gz": "cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294" + }, + "jeepney": { + "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz": "cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", + "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl": "97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683" + }, + "keyring": { + "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz": "0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", + "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl": "552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd" + }, + "markdown-it-py": { + "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz": "cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", + "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl": "87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147" + }, + "mdurl": { + "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl": "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", + "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz": "bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" + }, + "more-itertools": { + "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl": "52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", + "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz": "f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd" + }, + "nh3": { + "https://files.pythonhosted.org/packages/0c/e0/cf1543e798ba86d838952e8be4cb8d18e22999be2a24b112a671f1c04fd6/nh3-0.3.0-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl": "ec6cfdd2e0399cb79ba4dcffb2332b94d9696c52272ff9d48a630c5dca5e325a", + "https://files.pythonhosted.org/packages/10/71/2fb1834c10fab6d9291d62c95192ea2f4c7518bd32ad6c46aab5d095cb87/nh3-0.3.0-cp313-cp313t-musllinux_1_2_i686.whl": "0649464ac8eee018644aacbc103874ccbfac80e3035643c3acaab4287e36e7f5", + "https://files.pythonhosted.org/packages/23/1e/80a8c517655dd40bb13363fc4d9e66b2f13245763faab1a20f1df67165a7/nh3-0.3.0-cp313-cp313t-win_amd64.whl": "423201bbdf3164a9e09aa01e540adbb94c9962cc177d5b1cbb385f5e1e79216e", + "https://files.pythonhosted.org/packages/2f/d6/f1c6e091cbe8700401c736c2bc3980c46dca770a2cf6a3b48a175114058e/nh3-0.3.0-cp313-cp313t-win32.whl": "7275fdffaab10cc5801bf026e3c089d8de40a997afc9e41b981f7ac48c5aa7d5", + "https://files.pythonhosted.org/packages/33/c1/8f8ccc2492a000b6156dce68a43253fcff8b4ce70ab4216d08f90a2ac998/nh3-0.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl": "1adeb1062a1c2974bc75b8d1ecb014c5fd4daf2df646bbe2831f7c23659793f9", + "https://files.pythonhosted.org/packages/39/2c/6394301428b2017a9d5644af25f487fa557d06bc8a491769accec7524d9a/nh3-0.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "f416c35efee3e6a6c9ab7716d9e57aa0a49981be915963a82697952cba1353e1", + "https://files.pythonhosted.org/packages/4c/3c/cba7b26ccc0ef150c81646478aa32f9c9535234f54845603c838a1dc955c/nh3-0.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl": "80fe20171c6da69c7978ecba33b638e951b85fb92059259edd285ff108b82a6d", + "https://files.pythonhosted.org/packages/4e/9a/344b9f9c4bd1c2413a397f38ee6a3d5db30f1a507d4976e046226f12b297/nh3-0.3.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl": "37d3003d98dedca6cd762bf88f2e70b67f05100f6b949ffe540e189cc06887f9", + "https://files.pythonhosted.org/packages/5b/76/3165e84e5266d146d967a6cc784ff2fbf6ddd00985a55ec006b72bc39d5d/nh3-0.3.0-cp38-abi3-win_arm64.whl": "d97d3efd61404af7e5721a0e74d81cdbfc6e5f97e11e731bb6d090e30a7b62b2", + "https://files.pythonhosted.org/packages/5c/86/a96b1453c107b815f9ab8fac5412407c33cc5c7580a4daf57aabeb41b774/nh3-0.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl": "ce5e7185599f89b0e391e2f29cc12dc2e206167380cea49b33beda4891be2fe1", + "https://files.pythonhosted.org/packages/63/da/c5fd472b700ba37d2df630a9e0d8cc156033551ceb8b4c49cc8a5f606b68/nh3-0.3.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl": "ba0caa8aa184196daa6e574d997a33867d6d10234018012d35f86d46024a2a95", + "https://files.pythonhosted.org/packages/66/3f/cd37f76c8ca277b02a84aa20d7bd60fbac85b4e2cbdae77cb759b22de58b/nh3-0.3.0-cp38-abi3-musllinux_1_2_aarch64.whl": "634e34e6162e0408e14fb61d5e69dbaea32f59e847cfcfa41b66100a6b796f62", + "https://files.pythonhosted.org/packages/6a/1b/b15bd1ce201a1a610aeb44afd478d55ac018b4475920a3118ffd806e2483/nh3-0.3.0-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl": "e9e6a7e4d38f7e8dda9edd1433af5170c597336c1a74b4693c5cb75ab2b30f2a", + "https://files.pythonhosted.org/packages/8c/ae/324b165d904dc1672eee5f5661c0a68d4bab5b59fbb07afb6d8d19a30b45/nh3-0.3.0-cp38-abi3-win_amd64.whl": "bae63772408fd63ad836ec569a7c8f444dd32863d0c67f6e0b25ebbd606afa95", + "https://files.pythonhosted.org/packages/8f/14/079670fb2e848c4ba2476c5a7a2d1319826053f4f0368f61fca9bb4227ae/nh3-0.3.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl": "7852f038a054e0096dac12b8141191e02e93e0b4608c4b993ec7d4ffafea4e49", + "https://files.pythonhosted.org/packages/97/03/03f79f7e5178eb1ad5083af84faff471e866801beb980cc72943a4397368/nh3-0.3.0-cp38-abi3-musllinux_1_2_i686.whl": "c7a32a7f0d89f7d30cb8f4a84bdbd56d1eb88b78a2434534f62c71dac538c450", + "https://files.pythonhosted.org/packages/97/33/11e7273b663839626f714cb68f6eb49899da5a0d9b6bc47b41fe870259c2/nh3-0.3.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl": "389d93d59b8214d51c400fb5b07866c2a4f79e4e14b071ad66c92184fec3a392", + "https://files.pythonhosted.org/packages/9a/e0/af86d2a974c87a4ba7f19bc3b44a8eaa3da480de264138fec82fe17b340b/nh3-0.3.0-cp313-cp313t-win_arm64.whl": "16f8670201f7e8e0e05ed1a590eb84bfa51b01a69dd5caf1d3ea57733de6a52f", + "https://files.pythonhosted.org/packages/a3/e5/ac7fc565f5d8bce7f979d1afd68e8cb415020d62fa6507133281c7d49f91/nh3-0.3.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl": "af5aa8127f62bbf03d68f67a956627b1bd0469703a35b3dad28d0c1195e6c7fb", + "https://files.pythonhosted.org/packages/ad/7f/7c6b8358cf1222921747844ab0eef81129e9970b952fcb814df417159fb9/nh3-0.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "7c915060a2c8131bef6a29f78debc29ba40859b6dbe2362ef9e5fd44f11487c2", + "https://files.pythonhosted.org/packages/b4/11/340b7a551916a4b2b68c54799d710f86cf3838a4abaad8e74d35360343bb/nh3-0.3.0-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl": "a537ece1bf513e5a88d8cff8a872e12fe8d0f42ef71dd15a5e7520fecd191bbb", + "https://files.pythonhosted.org/packages/c3/a4/96cff0977357f60f06ec4368c4c7a7a26cccfe7c9fcd54f5378bf0428fd3/nh3-0.3.0.tar.gz": "d8ba24cb31525492ea71b6aac11a4adac91d828aadeff7c4586541bf5dc34d2f", + "https://files.pythonhosted.org/packages/c9/50/76936ec021fe1f3270c03278b8af5f2079038116b5d0bfe8538ffe699d69/nh3-0.3.0-cp38-abi3-win32.whl": "6d68fa277b4a3cf04e5c4b84dd0c6149ff7d56c12b3e3fab304c525b850f613d", + "https://files.pythonhosted.org/packages/ce/55/1974bcc16884a397ee699cebd3914e1f59be64ab305533347ca2d983756f/nh3-0.3.0-cp38-abi3-musllinux_1_2_x86_64.whl": "3f1b4f8a264a0c86ea01da0d0c390fe295ea0bcacc52c2103aca286f6884f518", + "https://files.pythonhosted.org/packages/ee/db/7aa11b44bae4e7474feb1201d8dee04fabe5651c7cb51409ebda94a4ed67/nh3-0.3.0-cp38-abi3-musllinux_1_2_armv7l.whl": "b0612ccf5de8a480cf08f047b08f9d3fecc12e63d2ee91769cb19d7290614c23", + "https://files.pythonhosted.org/packages/f3/ba/59e204d90727c25b253856e456ea61265ca810cda8ee802c35f3fadaab00/nh3-0.3.0-cp313-cp313t-musllinux_1_2_armv7l.whl": "e90883f9f85288f423c77b3f5a6f4486375636f25f793165112679a7b6363b35" + }, + "pkginfo": { + "https://files.pythonhosted.org/packages/24/03/e26bf3d6453b7fda5bd2b84029a426553bb373d6277ef6b5ac8863421f87/pkginfo-1.12.1.2.tar.gz": "5cd957824ac36f140260964eba3c6be6442a8359b8c48f4adf90210f33a04b7b", + "https://files.pythonhosted.org/packages/fa/3d/f4f2ba829efb54b6cd2d91349c7463316a9cc55a43fc980447416c88540f/pkginfo-1.12.1.2-py3-none-any.whl": "c783ac885519cab2c34927ccfa6bf64b5a704d7c69afaea583dd9b7afe969343" + }, + "playwright": { + "https://files.pythonhosted.org/packages/1e/62/a20240605485ca99365a8b72ed95e0b4c5739a13fb986353f72d8d3f1d27/playwright-1.52.0-py3-none-macosx_10_13_x86_64.whl": "19b2cb9d4794062008a635a99bd135b03ebb782d460f96534a91cb583f549512", + "https://files.pythonhosted.org/packages/4e/e9/0661d343ed55860bcfb8934ce10e9597fc953358773ece507b22b0f35c57/playwright-1.52.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl": "4173e453c43180acc60fd77ffe1ebee8d0efbfd9986c03267007b9c3845415af", + "https://files.pythonhosted.org/packages/51/f3/cca2aa84eb28ea7d5b85d16caa92d62d18b6e83636e3d67957daca1ee4c7/playwright-1.52.0-py3-none-win_amd64.whl": "dcbf75101eba3066b7521c6519de58721ea44379eb17a0dafa94f9f1b17f59e4", + "https://files.pythonhosted.org/packages/73/c6/8e27af9798f81465b299741ef57064c6ec1a31128ed297406469907dc5a4/playwright-1.52.0-py3-none-manylinux1_x86_64.whl": "d010124d24a321e0489a8c0d38a3971a7ca7656becea7656c9376bfea7f916d4", + "https://files.pythonhosted.org/packages/7a/81/a850dbc6bc2e1bd6cc87341e59c253269602352de83d34b00ea38cf410ee/playwright-1.52.0-py3-none-win32.whl": "cd0bdf92df99db6237a99f828e80a6a50db6180ef8d5352fc9495df2c92f9971", + "https://files.pythonhosted.org/packages/a2/ff/eee8532cff4b3d768768152e8c4f30d3caa80f2969bf3143f4371d377b74/playwright-1.52.0-py3-none-macosx_11_0_universal2.whl": "7223960b7dd7ddeec1ba378c302d1d09733b8dac438f492e9854c85d3ca7144f", + "https://files.pythonhosted.org/packages/b5/4f/71a8a873e8c3c3e2d3ec03a578e546f6875be8a76214d90219f752f827cd/playwright-1.52.0-py3-none-win_arm64.whl": "9d0085b8de513de5fb50669f8e6677f0252ef95a9a1d2d23ccee9638e71e65cb", + "https://files.pythonhosted.org/packages/dc/23/57ff081663b3061a2a3f0e111713046f705da2595f2f384488a76e4db732/playwright-1.52.0-py3-none-macosx_11_0_arm64.whl": "0797c0479cbdc99607412a3c486a3a2ec9ddc77ac461259fd2878c975bcbb94a" + }, + "pycparser": { + "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl": "e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", + "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz": "78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2" + }, + "pyee": { + "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz": "0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", + "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl": "af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228" + }, + "pygments": { + "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz": "636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", + "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl": "86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" + }, + "pywin32-ctypes": { + "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz": "d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", + "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl": "8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8" + }, + "readme-renderer": { + "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz": "8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1", + "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl": "2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151" + }, + "requests": { + "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz": "c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", + "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl": "3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b" + }, + "requests-toolbelt": { + "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl": "cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", + "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz": "7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6" + }, + "rfc3986": { + "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz": "97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", + "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl": "50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd" + }, + "rich": { + "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl": "536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", + "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz": "e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8" + }, + "secretstorage": { + "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz": "2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", + "https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl": "f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99" + }, + "twine": { + "https://files.pythonhosted.org/packages/5d/ec/00f9d5fd040ae29867355e559a94e9a8429225a0284a3f5f091a3878bfc0/twine-5.1.1-py3-none-any.whl": "215dbe7b4b94c2c50a7315c0275d2258399280fbb7d04182c7e55e24b5f93997", + "https://files.pythonhosted.org/packages/77/68/bd982e5e949ef8334e6f7dcf76ae40922a8750aa2e347291ae1477a4782b/twine-5.1.1.tar.gz": "9aa0825139c02b3434d913545c7b847a21c835e11597f5255842d457da2322db" + }, + "typing-extensions": { + "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl": "f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", + "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz": "0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466" + }, + "urllib3": { + "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl": "bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", + "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz": "1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed" + }, + "zipp": { + "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl": "071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", + "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz": "a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166" + } + } + }, + "fact_version": "v1", + "index_urls": { + "https://pypi.org/simple/": { + "backports_tarfile": "/simple/backports-tarfile/", + "certifi": "/simple/certifi/", + "cffi": "/simple/cffi/", + "charset_normalizer": "/simple/charset-normalizer/", + "cryptography": "/simple/cryptography/", + "docutils": "/simple/docutils/", + "greenlet": "/simple/greenlet/", + "idna": "/simple/idna/", + "importlib_metadata": "/simple/importlib-metadata/", + "jaraco_classes": "/simple/jaraco-classes/", + "jaraco_context": "/simple/jaraco-context/", + "jaraco_functools": "/simple/jaraco-functools/", + "jeepney": "/simple/jeepney/", + "keyring": "/simple/keyring/", + "markdown_it_py": "/simple/markdown-it-py/", + "mdurl": "/simple/mdurl/", + "more_itertools": "/simple/more-itertools/", + "nh3": "/simple/nh3/", + "pkginfo": "/simple/pkginfo/", + "playwright": "/simple/playwright/", + "pycparser": "/simple/pycparser/", + "pyee": "/simple/pyee/", + "pygments": "/simple/pygments/", + "pywin32_ctypes": "/simple/pywin32-ctypes/", + "readme_renderer": "/simple/readme-renderer/", + "requests": "/simple/requests/", + "requests_toolbelt": "/simple/requests-toolbelt/", + "rfc3986": "/simple/rfc3986/", + "rich": "/simple/rich/", + "secretstorage": "/simple/secretstorage/", + "twine": "/simple/twine/", + "typing_extensions": "/simple/typing-extensions/", + "urllib3": "/simple/urllib3/", + "zipp": "/simple/zipp/" + } + } + } + } } diff --git a/docs/wasm_build.md b/docs/wasm_build.md index ab2fbbce..1ddcdcd5 100644 --- a/docs/wasm_build.md +++ b/docs/wasm_build.md @@ -114,11 +114,12 @@ There is no separate `build:wasm` profile in `.bazelrc`; WASM builds are selecte Unit and system tests (Node.js required for system tests; skipped if `node` is not on `PATH`): ```bash -bazel test //web:web_tests //web:web_system_tests +bazel test //web:web_tests //web:web_system_tests //web:web_e2e_tests bazel test //examples/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"]`. - **`//examples/wasm:wasm_examples_system_test`** — runs `calc_dd_table_pbn.js` under Node and checks for `OK` on all three example hands. The MVP link flags include `-sENVIRONMENT=web,node` so the same `.js` / `.wasm` artifacts work in the browser and in Node system tests. diff --git a/web/BUILD.bazel b/web/BUILD.bazel index afecd2dd..6a439057 100644 --- a/web/BUILD.bazel +++ b/web/BUILD.bazel @@ -1,6 +1,6 @@ load("@rules_cc//cc:defs.bzl", "cc_binary", "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_library", "py_test") load("//:CPPVARIABLES.bzl", "DDS_CPPOPTS", "DDS_LINKOPTS", "DDS_LOCAL_DEFINES") load("//:wasm_compat.bzl", "WASM_LINKOPTS") @@ -55,6 +55,13 @@ cc_test( ], ) +py_library( + name = "mvp_site", + srcs = ["tests/mvp_site.py"], + imports = ["tests"], + visibility = ["//web:__pkg__"], +) + py_test( name = "wasm_scripts_test", size = "small", @@ -67,18 +74,50 @@ py_test( ], ) +# Stages wasm artifacts and runs Node smoke (~1–2s; wasm build is a separate analysis action). py_test( name = "dds_mvp_wasm_system_test", - size = "medium", + size = "small", + timeout = "short", main = "tests/test_wasm_system.py", srcs = ["tests/test_wasm_system.py"], data = [ ":dds_mvp_wasm", + "dds_mvp.css", + "dds_mvp.html", + "dds_mvp.js", "gen_wasm_bin_js.py", "patch_mvp_wasm.py", "tests/dds_mvp_wasm_node.mjs", "verify_wasm_js.py", ], + deps = [":mvp_site"], +) + +# Playwright usually finishes in ~30s; first run may download Chromium (requires-network). +py_test( + name = "dds_mvp_e2e_test", + size = "small", + timeout = "short", + main = "tests/test_mvp_e2e.py", + srcs = ["tests/test_mvp_e2e.py"], + data = [ + ":dds_mvp_wasm", + "dds_mvp.css", + "dds_mvp.html", + "dds_mvp.js", + "gen_wasm_bin_js.py", + "patch_mvp_wasm.py", + ], + deps = [ + ":mvp_site", + "@pypi//playwright", + ], + tags = [ + "e2e", + "no-sandbox", + "requires-network", + ], ) test_suite( @@ -91,5 +130,13 @@ test_suite( test_suite( name = "web_system_tests", - tests = [":dds_mvp_wasm_system_test"], + tests = [ + ":dds_mvp_wasm_system_test", + ":dds_mvp_e2e_test", + ], +) + +test_suite( + name = "web_e2e_tests", + tests = [":dds_mvp_e2e_test"], ) diff --git a/web/requirements.in b/web/requirements.in new file mode 100644 index 00000000..2e874d60 --- /dev/null +++ b/web/requirements.in @@ -0,0 +1 @@ +playwright==1.52.0 diff --git a/web/requirements_lock.txt b/web/requirements_lock.txt new file mode 100644 index 00000000..299bd567 --- /dev/null +++ b/web/requirements_lock.txt @@ -0,0 +1,14 @@ +# +# This file is autogenerated by pip-compile with Python 3.13 +# by the following command: +# +# pip-compile --output-file=web/requirements_lock.txt web/requirements.in +# +greenlet==3.5.1 + # via playwright +playwright==1.52.0 + # via -r web/requirements.in +pyee==13.0.1 + # via playwright +typing-extensions==4.15.0 + # via pyee diff --git a/web/tests/mvp_site.py b/web/tests/mvp_site.py new file mode 100644 index 00000000..1d67b98e --- /dev/null +++ b/web/tests/mvp_site.py @@ -0,0 +1,65 @@ +"""Stage a self-contained DDS MVP site directory for tests.""" +from __future__ import annotations + +import importlib.util +import os +import shutil +from pathlib import Path + +STATIC_FILES = ("dds_mvp.html", "dds_mvp.css", "dds_mvp.js") + + +def runfiles_root() -> Path: + for key in ("RUNFILES_DIR", "TEST_SRCDIR"): + if key in os.environ: + return Path(os.environ[key]) + raise RuntimeError("not running under Bazel test") + + +def rlocation(relpath: str) -> Path: + root = runfiles_root() + for candidate in (root / relpath, root / "_main" / relpath): + if candidate.exists(): + return candidate + raise FileNotFoundError(relpath) + + +def _load_module(web_root: Path, name: str): + path = web_root / f"{name}.py" + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod + + +def stage_mvp_site(dest: Path) -> Path: + """Copy HTML/JS/CSS and patched wasm artifacts into dest. Returns dest.""" + dest.mkdir(parents=True, exist_ok=True) + web_root = rlocation("web") + + for name in STATIC_FILES: + shutil.copyfile(web_root / name, dest / name) + + js_src = rlocation("web/dds_mvp_wasm.js") + wasm_src = rlocation("web/dds_mvp_wasm.wasm") + js_path = dest / "dds_mvp_wasm.js" + wasm_path = dest / "dds_mvp_wasm.wasm" + shutil.copyfile(js_src, js_path) + shutil.copyfile(wasm_src, wasm_path) + js_path.chmod(0o644) + wasm_path.chmod(0o644) + + patch_mvp_wasm = _load_module(web_root, "patch_mvp_wasm") + gen_wasm_bin_js = _load_module(web_root, "gen_wasm_bin_js") + + updated, code = patch_mvp_wasm.patch_text(js_path.read_text(encoding="utf-8")) + if code != 0: + raise RuntimeError("patch_mvp_wasm failed") + js_path.write_text(updated, encoding="utf-8") + + (dest / "dds_mvp_wasm_bin.js").write_text( + gen_wasm_bin_js.make_bin_js(wasm_path.read_bytes()), + encoding="utf-8", + ) + return dest diff --git a/web/tests/test_mvp_e2e.py b/web/tests/test_mvp_e2e.py new file mode 100644 index 00000000..be352255 --- /dev/null +++ b/web/tests/test_mvp_e2e.py @@ -0,0 +1,212 @@ +"""End-to-end browser tests for web/dds_mvp.html (file:// and HTTP).""" +from __future__ import annotations + +import http.server +import os +import shutil +import subprocess +import sys +import tempfile +import threading +import unittest +from pathlib import Path + +from mvp_site import stage_mvp_site + +try: + from playwright.sync_api import sync_playwright +except ImportError: + sync_playwright = None # type: ignore[misc, assignment] + +# res_table[strain][hand], strains S,H,D,C,N — from CalcDDtablePBN on part-score test deal. +PART_SCORE_TABLE = [ + 8, 5, 7, 5, 6, 6, 6, 7, 7, 6, 7, 6, 9, 4, 8, 4, 7, 6, 6, 6, +] + +STRAIN_INDEX = {"S": 0, "H": 1, "D": 2, "C": 3, "N": 4} +HAND_INDEX = {"N": 0, "E": 1, "S": 2, "W": 3} +TABLE_ROW = {"N": 1, "E": 2, "S": 3, "W": 4} +TABLE_COL = {"C": 1, "D": 2, "H": 3, "S": 4, "N": 5} + + +def _expected_tricks(direction: str, denomination: str) -> int: + return PART_SCORE_TABLE[ + STRAIN_INDEX[denomination] * 4 + HAND_INDEX[direction] + ] + + +def _ensure_playwright_chromium(browsers_dir: Path) -> None: + env = os.environ.copy() + env["PLAYWRIGHT_BROWSERS_PATH"] = str(browsers_dir) + proc = subprocess.run( + [sys.executable, "-m", "playwright", "install", "chromium"], + env=env, + capture_output=True, + text=True, + check=False, + ) + if proc.returncode != 0: + raise unittest.SkipTest( + "playwright install chromium failed " + f"(exit {proc.returncode}): {proc.stderr or proc.stdout}" + ) + + +def _make_http_handler(directory: Path) -> type[http.server.SimpleHTTPRequestHandler]: + root = str(directory) + + class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, request, client_address, server) -> None: + super().__init__(request, client_address, server, directory=root) + + return Handler + + +class _HttpSite: + def __init__(self, directory: Path) -> None: + self._directory = directory + self._httpd: http.server.ThreadingHTTPServer | None = None + self._thread: threading.Thread | None = None + self.url = "" + + def __enter__(self) -> _HttpSite: + self._httpd = http.server.ThreadingHTTPServer( + ("127.0.0.1", 0), + _make_http_handler(self._directory), + ) + port = self._httpd.server_address[1] + self.url = f"http://127.0.0.1:{port}/dds_mvp.html" + self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True) + self._thread.start() + return self + + def __exit__(self, *args: object) -> None: + if self._httpd: + self._httpd.shutdown() + if self._thread: + self._thread.join(timeout=5) + + +@unittest.skipIf(sync_playwright is None, "playwright not installed") +class DdsMvpHtmlE2eTest(unittest.TestCase): + browsers_dir: Path + site_dir: Path + + @classmethod + def setUpClass(cls) -> None: + if not shutil.which("node"): + raise unittest.SkipTest("node not found (wasm sanity)") + cls.browsers_dir = Path(tempfile.mkdtemp(prefix="pw-browsers-")) + _ensure_playwright_chromium(cls.browsers_dir) + cls.site_dir = Path(tempfile.mkdtemp(prefix="dds-mvp-site-")) + stage_mvp_site(cls.site_dir) + + @classmethod + def tearDownClass(cls) -> None: + shutil.rmtree(cls.browsers_dir, ignore_errors=True) + shutil.rmtree(cls.site_dir, ignore_errors=True) + + def setUp(self) -> None: + os.environ["PLAYWRIGHT_BROWSERS_PATH"] = str(self.browsers_dir) + self._pw = sync_playwright().start() + self._browser = self._pw.chromium.launch(headless=True) + + def tearDown(self) -> None: + self._browser.close() + self._pw.stop() + + def _open_page(self, url: str): + page = self._browser.new_page() + errors: list[str] = [] + page.on("pageerror", lambda exc: errors.append(str(exc))) + page.goto(url, wait_until="load") + page.wait_for_function( + "() => typeof createDdsModule === 'function' && typeof ddsMvpWasmBytes === 'function'" + ) + return page, errors + + def _fill_part_score_deal(self, page) -> None: + page.get_by_role("button", name="Part-score test deal").click() + + def _run_double_dummy(self, page) -> None: + page.get_by_role("button", name="Double-dummy it!").click() + page.wait_for_function( + """() => { + const cell = document.getElementById('result-table').rows[1].cells[1]; + return cell && /^\\d+$/.test(cell.textContent.trim()); + }""", + timeout=120_000, + ) + + def _read_table_cell(self, page, direction: str, denomination: str) -> str: + row = TABLE_ROW[direction] + col = TABLE_COL[denomination] + return page.evaluate( + f"""() => {{ + return document.getElementById('result-table') + .rows[{row}].cells[{col}].textContent.trim(); + }}""" + ) + + def _assert_part_score_table(self, page) -> None: + for direction in ("N", "E", "S", "W"): + for denomination in ("C", "D", "H", "S", "N"): + expected = str(_expected_tricks(direction, denomination)) + actual = self._read_table_cell(page, direction, denomination) + self.assertEqual( + actual, + expected, + msg=f"{direction}/{denomination}", + ) + result_text = page.locator("#result").inner_text() + self.assertEqual(result_text.strip(), "") + + def test_page_load_shows_valid_pips(self) -> None: + page, errors = self._open_page(self.site_dir.joinpath("dds_mvp.html").as_uri()) + try: + pips = page.locator("#valid-pips").inner_text() + self.assertIn("A", pips) + self.assertIn("2", pips) + self.assertEqual(errors, []) + finally: + page.close() + + def test_file_url_part_score_table(self) -> None: + url = self.site_dir.joinpath("dds_mvp.html").as_uri() + page, errors = self._open_page(url) + try: + self._fill_part_score_deal(page) + self._run_double_dummy(page) + self._assert_part_score_table(page) + self.assertEqual(errors, []) + finally: + page.close() + + def test_http_part_score_table(self) -> None: + with _HttpSite(self.site_dir) as site: + page, errors = self._open_page(site.url) + try: + self._fill_part_score_deal(page) + self._run_double_dummy(page) + self._assert_part_score_table(page) + self.assertEqual(errors, []) + finally: + page.close() + + def test_validation_error_on_incomplete_deal(self) -> None: + page, errors = self._open_page(self.site_dir.joinpath("dds_mvp.html").as_uri()) + try: + page.get_by_role("button", name="Clear entries").click() + page.get_by_role("button", name="Double-dummy it!").click() + page.wait_for_function( + """() => document.getElementById('result').textContent.includes('13 cards')""" + ) + message = page.locator("#result").inner_text() + self.assertIn("13 cards", message) + self.assertEqual(errors, []) + finally: + page.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/web/tests/test_wasm_system.py b/web/tests/test_wasm_system.py index 32c20fdb..ab9bb9a3 100644 --- a/web/tests/test_wasm_system.py +++ b/web/tests/test_wasm_system.py @@ -1,40 +1,14 @@ """End-to-end system tests for the web MVP WASM build and Node smoke harness.""" from __future__ import annotations -import importlib.util -import os import shutil import subprocess -import tempfile import unittest from pathlib import Path -WEB_ROOT = Path(__file__).resolve().parents[1] -TESTS_ROOT = Path(__file__).resolve().parent - - -def _runfiles_root() -> Path: - for key in ("RUNFILES_DIR", "TEST_SRCDIR"): - if key in os.environ: - return Path(os.environ[key]) - raise RuntimeError("not running under Bazel test") - +from mvp_site import stage_mvp_site -def rlocation(relpath: str) -> Path: - root = _runfiles_root() - for candidate in (root / relpath, root / "_main" / relpath): - if candidate.exists(): - return candidate - raise FileNotFoundError(relpath) - - -def _load_module(name: str): - path = WEB_ROOT / f"{name}.py" - spec = importlib.util.spec_from_file_location(name, path) - mod = importlib.util.module_from_spec(spec) - assert spec.loader is not None - spec.loader.exec_module(mod) - return mod +TESTS_ROOT = Path(__file__).resolve().parent def _require_node() -> str: @@ -47,46 +21,20 @@ def _require_node() -> str: @unittest.skipUnless(shutil.which("node"), "node not found") class DdsMvpWasmSystemTest(unittest.TestCase): def test_update_pipeline_and_node_smoke(self) -> None: - node = _require_node() - js_src = rlocation("web/dds_mvp_wasm.js") - wasm_src = rlocation("web/dds_mvp_wasm.wasm") + import tempfile - patch_mvp_wasm = _load_module("patch_mvp_wasm") - gen_wasm_bin_js = _load_module("gen_wasm_bin_js") - verify_wasm_js = _load_module("verify_wasm_js") + node = _require_node() with tempfile.TemporaryDirectory() as tmpdir: tmp = Path(tmpdir) - js_path = tmp / "dds_mvp_wasm.js" - wasm_path = tmp / "dds_mvp_wasm.wasm" - bin_js_path = tmp / "dds_mvp_wasm_bin.js" - - shutil.copyfile(js_src, js_path) - shutil.copyfile(wasm_src, wasm_path) - js_path.chmod(0o644) - wasm_path.chmod(0o644) - - updated, code = patch_mvp_wasm.patch_text(js_path.read_text(encoding="utf-8")) - self.assertEqual(code, 0) - js_path.write_text(updated, encoding="utf-8") - - again, code2 = patch_mvp_wasm.patch_text(js_path.read_text(encoding="utf-8")) - self.assertEqual(code2, 0) - self.assertEqual(again, updated) - - bin_js_path.write_text( - gen_wasm_bin_js.make_bin_js(wasm_path.read_bytes()), - encoding="utf-8", - ) - self.assertTrue(verify_wasm_js.wasm_magic_ok(wasm_path.read_bytes())) - self.assertIn("ddsMvpWasmBytes", bin_js_path.read_text(encoding="utf-8")) + stage_mvp_site(tmp) proc = subprocess.run( [ node, str(TESTS_ROOT / "dds_mvp_wasm_node.mjs"), - str(js_path), - str(wasm_path), + str(tmp / "dds_mvp_wasm.js"), + str(tmp / "dds_mvp_wasm.wasm"), ], capture_output=True, text=True, From 31796e1512cb88819eecb88bed6830c5b512af8d Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 14:05:33 +0200 Subject: [PATCH 28/45] Better fix for Windows unreachable code warning. --- library/src/system/system.cpp | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/library/src/system/system.cpp b/library/src/system/system.cpp index 77a61558..3f732e71 100644 --- a/library/src/system/system.cpp +++ b/library/src/system/system.cpp @@ -210,10 +210,7 @@ void System::get_hardware( // conservative default so SetResources allocates a usable transposition table. kilobytes_free = 512ULL * 1024; core_count = 1; - return; -#endif - -#if defined(_WIN32) || defined(__CYGWIN__) +#elif defined(_WIN32) || defined(__CYGWIN__) // Using GlobalMemoryStatusEx instead of GlobalMemoryStatus // was suggested by Lorne Anderson. MEMORYSTATUSEX statex; @@ -225,10 +222,7 @@ void System::get_hardware( SYSTEM_INFO sysinfo; GetSystemInfo(&sysinfo); core_count = static_cast(sysinfo.dwNumberOfProcessors); - return; -#endif - -#ifdef __APPLE__ +#elif defined(__APPLE__) // The code for Mac OS X was suggested by Matthew Kidd. // This is physical memory, rather than "free" memory as below @@ -248,10 +242,7 @@ void System::get_hardware( } core_count = sysconf(_SC_NPROCESSORS_ONLN); - return; -#endif - -#ifdef __linux__ +#elif defined(__linux__) // Use half of the physical memory long pages = sysconf (_SC_PHYS_PAGES); long pagesize = sysconf (_SC_PAGESIZE); @@ -261,14 +252,13 @@ void System::get_hardware( kilobytes_free = 1024 * 1024; // guess 1GB core_count = sysconf(_SC_NPROCESSORS_ONLN); - return; -#endif - +#else // Fallback if no platform is detected if (kilobytes_free == 0) { kilobytes_free = 512ULL * 1024; } +#endif } From 7f0c276397ffd6c23c6a063b3416e8b3679fe482 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 16:18:19 +0200 Subject: [PATCH 29/45] Change the table order to North, East, South, West. --- web/dds_mvp.css | 55 ++++++++++++++++++++++++++++++++++++++++++++---- web/dds_mvp.html | 30 ++++++++++++++------------ 2 files changed, 67 insertions(+), 18 deletions(-) diff --git a/web/dds_mvp.css b/web/dds_mvp.css index ba21e8b4..0fca2241 100644 --- a/web/dds_mvp.css +++ b/web/dds_mvp.css @@ -5,13 +5,56 @@ https://opensource.org/licenses/MIT */ -.grid-container { - display: grid; - grid-template-columns: auto auto auto; +.grid-outer { background-color: #2196F3; padding: 10px; max-width: 800px; +} + +.grid-container { + display: grid; + grid-template-areas: + "nw north ne" + "west center east" + "sw south se"; grid-template-columns: 1fr 1fr 1fr; + background-color: #2196F3; +} + +.hand-north { + grid-area: north; +} + +.hand-east { + grid-area: east; +} + +.hand-south { + grid-area: south; +} + +.hand-west { + grid-area: west; +} + +.grid-filler-nw { + grid-area: nw; +} + +.grid-filler-ne { + grid-area: ne; +} + +.grid-filler-center { + grid-area: center; +} + +.grid-filler-sw { + grid-area: sw; +} + +.grid-filler-se { + grid-area: se; } .grid-item { @@ -22,6 +65,10 @@ text-align: center; } +.grid-filler { + padding: 20px; +} + td { text-align: center; -} \ No newline at end of file +} diff --git a/web/dds_mvp.html b/web/dds_mvp.html index 45b7ced7..db8a11c0 100644 --- a/web/dds_mvp.html +++ b/web/dds_mvp.html @@ -33,36 +33,38 @@



+
-
-
+
♠ 
♥ 
♦ 
♣ 
-
-
- ♠ 
- ♥ 
- ♦ 
- ♣ 
-
-
-
+
♠ 
♥ 
♦ 
♣ 
-
-
+
♠ 
♥ 
♦ 
♣ 
-
+
+ ♠ 
+ ♥ 
+ ♦ 
+ ♣ 
+
+ + + + + +

From 8571a4525ac57e14c7439637c6781bbddd98acd5 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 16:25:52 +0200 Subject: [PATCH 30/45] Added a wrapper script to clean up the wasm build artifacts. --- clean.sh | 11 +++++++++++ docs/wasm_build.md | 8 ++++++++ web/clean_wasm.sh | 6 ++++++ 3 files changed, 25 insertions(+) create mode 100755 clean.sh create mode 100755 web/clean_wasm.sh diff --git a/clean.sh b/clean.sh new file mode 100755 index 00000000..83bfd4c1 --- /dev/null +++ b/clean.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Run Bazel clean, then remove web/ WASM copies (not part of bazel-out). +# Usage: ./clean.sh [--expunge] [other bazel clean flags...] +set -euo pipefail +cd "$(dirname "$0")" +if command -v bazelisk >/dev/null 2>&1; then + bazelisk clean "$@" +else + bazel clean "$@" +fi +./web/clean_wasm.sh diff --git a/docs/wasm_build.md b/docs/wasm_build.md index 1ddcdcd5..ec03b958 100644 --- a/docs/wasm_build.md +++ b/docs/wasm_build.md @@ -81,6 +81,14 @@ python3 -m http.server 8080 --directory web The MVP loads wasm from `dds_mvp_wasm_bin.js` (base64, no network fetch), so `file://` and HTTP both work. Run `./web/update_wasm.sh` to refresh `dds_mvp_wasm.{js,wasm,bin.js}` (includes a small post-process step for Emscripten `isFileURI`; see **Emscripten / emsdk version** above). +`bazel clean` does not delete those copied files under `web/` (they live outside `bazel-out`). Use either: + +```bash +./clean.sh # bazel clean + remove web/dds_mvp_wasm.* +./clean.sh --expunge +./web/clean_wasm.sh # web artifacts only +``` + For other experiments, copy built `.js` / `.wasm` files from `bazel-bin/examples/wasm/` to any static file server. ## Compilation flags diff --git a/web/clean_wasm.sh b/web/clean_wasm.sh new file mode 100755 index 00000000..778212bc --- /dev/null +++ b/web/clean_wasm.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Remove WASM artifacts copied into web/ by update_wasm.sh. +set -euo pipefail +cd "$(dirname "$0")" +rm -f dds_mvp_wasm.js dds_mvp_wasm.wasm dds_mvp_wasm_bin.js +echo "Removed web/dds_mvp_wasm.{js,wasm,bin.js} (if present)" From 18afc69d2f32718e716f603aa17c838b4eb59e54 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 2 Jun 2026 17:02:08 +0200 Subject: [PATCH 31/45] Include hashes for playright and its dependencies. --- BUILD.bazel | 10 ++-- docs/wasm_build.md | 4 +- web/requirements.in | 1 + web/requirements_lock.txt | 101 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 102 insertions(+), 14 deletions(-) diff --git a/BUILD.bazel b/BUILD.bazel index eb754cd5..50327287 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -32,21 +32,17 @@ config_setting( name = "build_windows", constraint_values = [ "@platforms//os:windows", + "@platforms//cpu:x86_64", ], - values = { - "cpu": "x64_windows", - }, ) config_setting( name = "debug_build_windows", constraint_values = [ "@platforms//os:windows", + "@platforms//cpu:x86_64", ], - values = { - "cpu": "x64_windows", - "compilation_mode": "dbg", - }, + values = {"compilation_mode": "dbg"}, ) config_setting( diff --git a/docs/wasm_build.md b/docs/wasm_build.md index ec03b958..fb170d5d 100644 --- a/docs/wasm_build.md +++ b/docs/wasm_build.md @@ -56,7 +56,7 @@ Rules in `examples/wasm/BUILD.bazel` wrap native examples in `examples/`: ## How it works 1. **`wasm_cc_binary`** (from `@emsdk`) transitions its `cc_target` to `@emsdk//:platform_wasm` and sets `--cpu=wasm`. -2. **`//:build_wasm`** in the root `BUILD.bazel` matches that CPU for `select()` in `CPPVARIABLES.bzl` and example link flags. +2. **`//:build_wasm`** in the root `BUILD.bazel` matches `@platforms//cpu:wasm32` for `select()` in `CPPVARIABLES.bzl` and example link flags. 3. **`wasm_compat.bzl`** — shared Emscripten link flags (`WASM_LINKOPTS`) on the WASM-capable `cc_binary` targets. Native builds (`bazel build //...`, `bazel test //library/tests/...`, Python bindings) are unchanged and use the host LLVM toolchain. @@ -106,7 +106,7 @@ For other experiments, copy built `.js` / `.wasm` files from `bazel-bin/examples ## C++ standard -WASM example binaries are built as C++20. The Emscripten toolchain (via `wasm_cc_binary`) compiles the transitioned `cc_target`; project flags for that configuration come from `CPPVARIABLES.bzl` when `//:build_wasm` matches (`--cpu=wasm` set by the transition). +WASM example binaries are built as C++20. The Emscripten toolchain (via `wasm_cc_binary`) compiles the transitioned `cc_target`; project flags for that configuration come from `CPPVARIABLES.bzl` when `//:build_wasm` matches (the Emscripten platform transition sets `wasm32`). Host-side Bazel actions still use the normal platform flags in `.bazelrc`: diff --git a/web/requirements.in b/web/requirements.in index 2e874d60..54e825c7 100644 --- a/web/requirements.in +++ b/web/requirements.in @@ -1 +1,2 @@ +# Regenerate: pip-compile --generate-hashes -o web/requirements_lock.txt web/requirements.in playwright==1.52.0 diff --git a/web/requirements_lock.txt b/web/requirements_lock.txt index 299bd567..1091dbb5 100644 --- a/web/requirements_lock.txt +++ b/web/requirements_lock.txt @@ -2,13 +2,104 @@ # This file is autogenerated by pip-compile with Python 3.13 # by the following command: # -# pip-compile --output-file=web/requirements_lock.txt web/requirements.in +# pip-compile --generate-hashes --output-file=web/requirements_lock.txt web/requirements.in # -greenlet==3.5.1 +greenlet==3.5.1 \ + --hash=sha256:001775efe7b8e758861294c7a27c28af87f3f3f1c20468a2bc618c45b346c061 \ + --hash=sha256:00929c98ec525fd9bf075875d8c5f6a983a90906cdf78a66e6de2d8e466c2a19 \ + --hash=sha256:017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747 \ + --hash=sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1 \ + --hash=sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10 \ + --hash=sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0 \ + --hash=sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9 \ + --hash=sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64 \ + --hash=sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659 \ + --hash=sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a \ + --hash=sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541 \ + --hash=sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e \ + --hash=sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c \ + --hash=sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d \ + --hash=sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b \ + --hash=sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986 \ + --hash=sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78 \ + --hash=sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2 \ + --hash=sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a \ + --hash=sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc \ + --hash=sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b \ + --hash=sha256:540dae7b956209af4d70a3be35927b4055f617763771e5e84a5255bea934d2f5 \ + --hash=sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829 \ + --hash=sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea \ + --hash=sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436 \ + --hash=sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c \ + --hash=sha256:6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360 \ + --hash=sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f \ + --hash=sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244 \ + --hash=sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283 \ + --hash=sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54 \ + --hash=sha256:7eacb17a9d41538a2bc4912eba5ef13823c83cb69e4d141d0813debe7163187f \ + --hash=sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2 \ + --hash=sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188 \ + --hash=sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e \ + --hash=sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249 \ + --hash=sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3 \ + --hash=sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563 \ + --hash=sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de \ + --hash=sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6 \ + --hash=sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368 \ + --hash=sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26 \ + --hash=sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de \ + --hash=sha256:9d59e840387076a51016777a9328b3f2c427c6f9208a6e958bad251be50a648d \ + --hash=sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2 \ + --hash=sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e \ + --hash=sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33 \ + --hash=sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d \ + --hash=sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c \ + --hash=sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd \ + --hash=sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207 \ + --hash=sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed \ + --hash=sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b \ + --hash=sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62 \ + --hash=sha256:b9152fca4a6466e114aaec745ae61cba739903a109754a9d4e1262f01e9259b1 \ + --hash=sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0 \ + --hash=sha256:c3d35f87c7253b715d13d679e0783d845910144f282cb939fe1ba4ac8616269c \ + --hash=sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823 \ + --hash=sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab \ + --hash=sha256:cc6ab7e555c8a112ad3a76e368e86e12a2754bcae1652a5602e133ec7b635523 \ + --hash=sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd \ + --hash=sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c \ + --hash=sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce \ + --hash=sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c \ + --hash=sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07 \ + --hash=sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135 \ + --hash=sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e \ + --hash=sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071 \ + --hash=sha256:e5cc9606aa5f4e0bde0d3bd502b44f743864c3ffa5cfa1011b1e30f5aa02366f \ + --hash=sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5 \ + --hash=sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee \ + --hash=sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e \ + --hash=sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f \ + --hash=sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad \ + --hash=sha256:ed8cdb691169715a9a492844a83246f090182247d1a5031dc78a403f68ba1e97 \ + --hash=sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc \ + --hash=sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e \ + --hash=sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2 \ + --hash=sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed # via playwright -playwright==1.52.0 +playwright==1.52.0 \ + --hash=sha256:0797c0479cbdc99607412a3c486a3a2ec9ddc77ac461259fd2878c975bcbb94a \ + --hash=sha256:19b2cb9d4794062008a635a99bd135b03ebb782d460f96534a91cb583f549512 \ + --hash=sha256:4173e453c43180acc60fd77ffe1ebee8d0efbfd9986c03267007b9c3845415af \ + --hash=sha256:7223960b7dd7ddeec1ba378c302d1d09733b8dac438f492e9854c85d3ca7144f \ + --hash=sha256:9d0085b8de513de5fb50669f8e6677f0252ef95a9a1d2d23ccee9638e71e65cb \ + --hash=sha256:cd0bdf92df99db6237a99f828e80a6a50db6180ef8d5352fc9495df2c92f9971 \ + --hash=sha256:d010124d24a321e0489a8c0d38a3971a7ca7656becea7656c9376bfea7f916d4 \ + --hash=sha256:dcbf75101eba3066b7521c6519de58721ea44379eb17a0dafa94f9f1b17f59e4 # via -r web/requirements.in -pyee==13.0.1 +pyee==13.0.1 \ + --hash=sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8 \ + --hash=sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228 # via playwright -typing-extensions==4.15.0 +typing-extensions==4.15.0 \ + --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ + --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 # via pyee From 7cc1985558458bf33ab55efbfd3dfc5cfc83a154 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 3 Jun 2026 07:33:25 +0100 Subject: [PATCH 32/45] Removed redundant conditional check Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- library/src/system/system.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/library/src/system/system.cpp b/library/src/system/system.cpp index 3f732e71..90042696 100644 --- a/library/src/system/system.cpp +++ b/library/src/system/system.cpp @@ -254,10 +254,8 @@ void System::get_hardware( core_count = sysconf(_SC_NPROCESSORS_ONLN); #else // Fallback if no platform is detected - if (kilobytes_free == 0) - { - kilobytes_free = 512ULL * 1024; - } + kilobytes_free = 512ULL * 1024; +#endif #endif } From 0f62d34a67ee825cf6bc2980264856f75303e7c7 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 3 Jun 2026 08:38:55 +0200 Subject: [PATCH 33/45] Removed extra #endif --- library/src/system/system.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/library/src/system/system.cpp b/library/src/system/system.cpp index 90042696..b6965cf3 100644 --- a/library/src/system/system.cpp +++ b/library/src/system/system.cpp @@ -256,7 +256,6 @@ void System::get_hardware( // Fallback if no platform is detected kilobytes_free = 512ULL * 1024; #endif -#endif } From 7edff0521123a5908b055c0cfba76ceb6913a03a Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 3 Jun 2026 08:42:52 +0200 Subject: [PATCH 34/45] Don't write to /tmp - not portable. --- web/tests/test_wasm_scripts.py | 34 ++++++++++++++++++++++++++++++++++ web/verify_wasm_js.py | 25 ++++++++++++++----------- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/web/tests/test_wasm_scripts.py b/web/tests/test_wasm_scripts.py index 34a188ef..575aa99e 100644 --- a/web/tests/test_wasm_scripts.py +++ b/web/tests/test_wasm_scripts.py @@ -1,5 +1,7 @@ import base64 import importlib.util +import os +import shutil import subprocess import sys import tempfile @@ -113,6 +115,38 @@ def test_cli_rejects_bad_magic(self) -> None: ) self.assertEqual(proc.returncode, 1) + def test_cli_missing_node(self) -> None: + with tempfile.NamedTemporaryFile(suffix=".wasm", delete=False) as tmp: + tmp.write(b"\x00asm\x01\x00\x00\x00") + path = Path(tmp.name) + self.addCleanup(path.unlink, missing_ok=True) + env = os.environ.copy() + env["PATH"] = "" + proc = subprocess.run( + [sys.executable, str(WEB_ROOT / "verify_wasm_js.py"), str(path)], + capture_output=True, + text=True, + check=False, + env=env, + ) + self.assertEqual(proc.returncode, 127) + self.assertIn("node not found", proc.stderr) + + @unittest.skipUnless(shutil.which("node"), "node not found") + def test_cli_compiles_valid_wasm(self) -> None: + with tempfile.NamedTemporaryFile(suffix=".wasm", delete=False) as tmp: + tmp.write(b"\x00asm\x01\x00\x00\x00") + path = Path(tmp.name) + self.addCleanup(path.unlink, missing_ok=True) + proc = subprocess.run( + [sys.executable, str(WEB_ROOT / "verify_wasm_js.py"), str(path)], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(proc.returncode, 0, msg=proc.stdout + proc.stderr) + self.assertIn("compile OK", proc.stdout) + if __name__ == "__main__": unittest.main() diff --git a/web/verify_wasm_js.py b/web/verify_wasm_js.py index 89bf0419..585403c6 100755 --- a/web/verify_wasm_js.py +++ b/web/verify_wasm_js.py @@ -2,10 +2,18 @@ """Verify MVP wasm bytes compile under Node.""" from __future__ import annotations +import shutil import subprocess import sys from pathlib import Path +NODE_COMPILE_SNIPPET = ( + "const fs=require('fs');" + "const b=fs.readFileSync(process.argv[1]);" + "WebAssembly.compile(b).then(()=>console.log('compile OK')," + "e=>{console.error(e);process.exit(1);});" +) + def usage() -> None: name = Path(sys.argv[0]).name @@ -28,18 +36,13 @@ def main() -> int: return 1 print(f"{wasm_path}: {len(wasm)} bytes, magic={wasm[:4]!r}") - tmp = Path("/tmp/dds_mvp_test.wasm") - tmp.write_bytes(wasm) + node = shutil.which("node") + if not node: + print("verify_wasm_js: node not found in PATH", file=sys.stderr) + return 127 + proc = subprocess.run( - [ - "node", - "-e", - "const fs=require('fs');" - "const b=fs.readFileSync(process.argv[1]);" - "WebAssembly.compile(b).then(()=>console.log('compile OK')," - "e=>{console.error(e);process.exit(1);});", - str(tmp), - ], + [node, "-e", NODE_COMPILE_SNIPPET, str(wasm_path)], capture_output=True, text=True, ) From 50f6863deb6d6f90f7b113ab4461b86652941102 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 3 Jun 2026 08:48:58 +0200 Subject: [PATCH 35/45] Fix typo in comment. --- web/dds_mvp.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/dds_mvp.js b/web/dds_mvp.js index 5d505a4b..8311c9ab 100644 --- a/web/dds_mvp.js +++ b/web/dds_mvp.js @@ -1,4 +1,4 @@ -// Copyright Copyright 2020-2026 Adam Wildavsky +// Copyright 2020-2026 Adam Wildavsky // // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file or at From dfd41b18c8fca048599ae05c1e698b4befda8863 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 3 Jun 2026 08:50:26 +0200 Subject: [PATCH 36/45] Updated brace style for consistency. --- web/dds_mvp_wasm.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/web/dds_mvp_wasm.cpp b/web/dds_mvp_wasm.cpp index 87676ed1..34634509 100644 --- a/web/dds_mvp_wasm.cpp +++ b/web/dds_mvp_wasm.cpp @@ -20,7 +20,8 @@ extern "C" { // Fills out_table[20] with res_table[strain][hand] (strain 0..4 = S,H,D,C,N). // Returns RETURN_NO_FAULT (1) on success, or a DDS error code. EMSCRIPTEN_KEEPALIVE -auto dds_mvp_calc_table(const char* pbn, int* out_table) -> int { +auto dds_mvp_calc_table(const char* pbn, int* out_table) -> int +{ if (pbn == nullptr || out_table == nullptr) { return RETURN_UNKNOWN_FAULT; } @@ -50,5 +51,8 @@ auto dds_mvp_calc_table(const char* pbn, int* out_table) -> int { } // extern "C" #if !defined(__EMSCRIPTEN__) && !defined(DDS_MVP_WASM_NO_MAIN) -auto main() -> int { return 0; } +auto main() -> int +{ + return 0; +} #endif From 44b59a78e5eef81f131876ff77484072863ee964 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 3 Jun 2026 08:52:22 +0200 Subject: [PATCH 37/45] Updated the sendJSON catch block so it no longer assumes an Error: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Error — still uses err.message null / undefined — shows "Unknown error" Anything else — uses String(err) (numbers, strings, etc.) without throwing again Normal failures from loadDdsModule() (rejected promises with Error) behave as before; odd throws from Emscripten or elsewhere get a visible message instead of masking the failure. --- web/dds_mvp.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/dds_mvp.js b/web/dds_mvp.js index 8311c9ab..abdeb066 100644 --- a/web/dds_mvp.js +++ b/web/dds_mvp.js @@ -327,6 +327,10 @@ async function sendJSON() { } } catch (err) { clear_results(); - result.innerHTML = err.message; + result.innerHTML = err instanceof Error + ? err.message + : err == null + ? "Unknown error" + : String(err); } } From 7653b58b500156243fdccc18ea5dfe2aa8da828a Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 3 Jun 2026 08:56:40 +0200 Subject: [PATCH 38/45] =?UTF-8?q?Aligned=20the=20lock=20file=20with=20Baze?= =?UTF-8?q?l=E2=80=99s=20Python=20toolchain=20by=20regenerating=20web/requ?= =?UTF-8?q?irements=5Flock.txt=20with=20Python=203.14=20(via=20bazel=20run?= =?UTF-8?q?=20@python=5F3=5F14//:python3).=20The=20header=20now=20says=203?= =?UTF-8?q?.14,=20matching=20pip.parse=20in=20MODULE.bazel.=20Package=20pi?= =?UTF-8?q?ns=20and=20hashes=20are=20unchanged=20=E2=80=94=20only=20the=20?= =?UTF-8?q?version=20comment=20differed=20between=203.13=20and=203.14=20fo?= =?UTF-8?q?r=20this=20dependency=20set.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated web/requirements.in with the exact bazel run commands so the lock is not regenerated accidentally with system Python 3.13. --- web/requirements.in | 5 ++++- web/requirements_lock.txt | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/web/requirements.in b/web/requirements.in index 54e825c7..54342e99 100644 --- a/web/requirements.in +++ b/web/requirements.in @@ -1,2 +1,5 @@ -# Regenerate: pip-compile --generate-hashes -o web/requirements_lock.txt web/requirements.in +# Regenerate (must match MODULE.bazel python_version, currently 3.14): +# bazel run @python_3_14//:python3 -- -m pip install pip-tools +# bazel run @python_3_14//:python3 -- -m piptools compile --generate-hashes \ +# --output-file=web/requirements_lock.txt web/requirements.in playwright==1.52.0 diff --git a/web/requirements_lock.txt b/web/requirements_lock.txt index 1091dbb5..eabbd22f 100644 --- a/web/requirements_lock.txt +++ b/web/requirements_lock.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.13 +# This file is autogenerated by pip-compile with Python 3.14 # by the following command: # # pip-compile --generate-hashes --output-file=web/requirements_lock.txt web/requirements.in From a6345ce60cef7b596d59b3475b791e9e1da45836 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 3 Jun 2026 09:03:33 +0200 Subject: [PATCH 39/45] examples/wasm/calc_dd_table_pbn_test.cpp only gated SetMaxThreads(0) on __linux__ / __APPLE__, while the matching example examples/calc_dd_table_pbn.cpp and other WASM examples use __WASM__ as well. On WASM builds, __WASM__ is set via DDS_LOCAL_DEFINES in CPPVARIABLES.bzl, but the test target did not pass those defines and the preprocessor guard did not include WASM. Changes: Extended the guard to || defined(__WASM__), matching calc_dd_table_pbn.cpp. Set local_defines = DDS_LOCAL_DEFINES on //examples/wasm:calc_dd_table_pbn_test so __WASM__ is actually defined when that target is built under the WASM configuration. --- examples/wasm/BUILD.bazel | 3 ++- examples/wasm/calc_dd_table_pbn_test.cpp | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/wasm/BUILD.bazel b/examples/wasm/BUILD.bazel index 1fa2f9b0..83af1bf5 100644 --- a/examples/wasm/BUILD.bazel +++ b/examples/wasm/BUILD.bazel @@ -1,7 +1,7 @@ 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("//:CPPVARIABLES.bzl", "DDS_CPPOPTS") +load("//:CPPVARIABLES.bzl", "DDS_CPPOPTS", "DDS_LOCAL_DEFINES") wasm_cc_binary( name = "solve_board_wasm", @@ -45,6 +45,7 @@ cc_test( size = "small", srcs = ["calc_dd_table_pbn_test.cpp"], copts = DDS_CPPOPTS, + local_defines = DDS_LOCAL_DEFINES, deps = [ "//examples:hands", "//library/src:dds", diff --git a/examples/wasm/calc_dd_table_pbn_test.cpp b/examples/wasm/calc_dd_table_pbn_test.cpp index a0e5013b..01a900c9 100644 --- a/examples/wasm/calc_dd_table_pbn_test.cpp +++ b/examples/wasm/calc_dd_table_pbn_test.cpp @@ -13,7 +13,7 @@ #include "hands.hpp" TEST(CalcDdTablePbnWasmTest, MatchesReferenceTables) { -#if defined(__linux) || defined(__APPLE__) +#if defined(__linux) || defined(__APPLE__) || defined(__WASM__) SetMaxThreads(0); #endif From 08342f1380daa2c2b95cb69c2808f3383ec14f21 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 3 Jun 2026 21:57:36 +0100 Subject: [PATCH 40/45] Replace Assert with if and raise Avoid using assert to validate importlib loader availability in test helpers. assert statements can be stripped with python -O, and spec_from_file_location() can return None, leading to confusing AttributeErrors. Prefer an explicit runtime check and raise ImportError with a clear message. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- web/tests/test_wasm_scripts.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/tests/test_wasm_scripts.py b/web/tests/test_wasm_scripts.py index 575aa99e..327cfeb9 100644 --- a/web/tests/test_wasm_scripts.py +++ b/web/tests/test_wasm_scripts.py @@ -14,8 +14,9 @@ def _load_module(name: str): path = WEB_ROOT / f"{name}.py" spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise ImportError(f"Unable to load module {name} from {path}") mod = importlib.util.module_from_spec(spec) - assert spec.loader is not None spec.loader.exec_module(mod) return mod From 163cb690d7869a9c54a444dd4097370fa354d35f Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 3 Jun 2026 23:01:59 +0200 Subject: [PATCH 41/45] DdsMvpHtmlE2eTest was using 2-space indentation for the class and its methods while the rest of the file used 4 spaces. The whole class body is now indented with 4 spaces per level, consistent with the module-level helpers above it. --- web/tests/test_mvp_e2e.py | 222 +++++++++++++++++++------------------- 1 file changed, 111 insertions(+), 111 deletions(-) diff --git a/web/tests/test_mvp_e2e.py b/web/tests/test_mvp_e2e.py index be352255..aa7c6101 100644 --- a/web/tests/test_mvp_e2e.py +++ b/web/tests/test_mvp_e2e.py @@ -89,123 +89,123 @@ def __exit__(self, *args: object) -> None: @unittest.skipIf(sync_playwright is None, "playwright not installed") class DdsMvpHtmlE2eTest(unittest.TestCase): - browsers_dir: Path - site_dir: Path - - @classmethod - def setUpClass(cls) -> None: - if not shutil.which("node"): - raise unittest.SkipTest("node not found (wasm sanity)") - cls.browsers_dir = Path(tempfile.mkdtemp(prefix="pw-browsers-")) - _ensure_playwright_chromium(cls.browsers_dir) - cls.site_dir = Path(tempfile.mkdtemp(prefix="dds-mvp-site-")) - stage_mvp_site(cls.site_dir) - - @classmethod - def tearDownClass(cls) -> None: - shutil.rmtree(cls.browsers_dir, ignore_errors=True) - shutil.rmtree(cls.site_dir, ignore_errors=True) - - def setUp(self) -> None: - os.environ["PLAYWRIGHT_BROWSERS_PATH"] = str(self.browsers_dir) - self._pw = sync_playwright().start() - self._browser = self._pw.chromium.launch(headless=True) - - def tearDown(self) -> None: - self._browser.close() - self._pw.stop() - - def _open_page(self, url: str): - page = self._browser.new_page() - errors: list[str] = [] - page.on("pageerror", lambda exc: errors.append(str(exc))) - page.goto(url, wait_until="load") - page.wait_for_function( - "() => typeof createDdsModule === 'function' && typeof ddsMvpWasmBytes === 'function'" - ) - return page, errors - - def _fill_part_score_deal(self, page) -> None: - page.get_by_role("button", name="Part-score test deal").click() - - def _run_double_dummy(self, page) -> None: - page.get_by_role("button", name="Double-dummy it!").click() - page.wait_for_function( - """() => { + browsers_dir: Path + site_dir: Path + + @classmethod + def setUpClass(cls) -> None: + if not shutil.which("node"): + raise unittest.SkipTest("node not found (wasm sanity)") + cls.browsers_dir = Path(tempfile.mkdtemp(prefix="pw-browsers-")) + _ensure_playwright_chromium(cls.browsers_dir) + cls.site_dir = Path(tempfile.mkdtemp(prefix="dds-mvp-site-")) + stage_mvp_site(cls.site_dir) + + @classmethod + def tearDownClass(cls) -> None: + shutil.rmtree(cls.browsers_dir, ignore_errors=True) + shutil.rmtree(cls.site_dir, ignore_errors=True) + + def setUp(self) -> None: + os.environ["PLAYWRIGHT_BROWSERS_PATH"] = str(self.browsers_dir) + self._pw = sync_playwright().start() + self._browser = self._pw.chromium.launch(headless=True) + + def tearDown(self) -> None: + self._browser.close() + self._pw.stop() + + def _open_page(self, url: str): + page = self._browser.new_page() + errors: list[str] = [] + page.on("pageerror", lambda exc: errors.append(str(exc))) + page.goto(url, wait_until="load") + page.wait_for_function( + "() => typeof createDdsModule === 'function' && typeof ddsMvpWasmBytes === 'function'" + ) + return page, errors + + def _fill_part_score_deal(self, page) -> None: + page.get_by_role("button", name="Part-score test deal").click() + + def _run_double_dummy(self, page) -> None: + page.get_by_role("button", name="Double-dummy it!").click() + page.wait_for_function( + """() => { const cell = document.getElementById('result-table').rows[1].cells[1]; return cell && /^\\d+$/.test(cell.textContent.trim()); }""", - timeout=120_000, - ) - - def _read_table_cell(self, page, direction: str, denomination: str) -> str: - row = TABLE_ROW[direction] - col = TABLE_COL[denomination] - return page.evaluate( - f"""() => {{ + timeout=120_000, + ) + + def _read_table_cell(self, page, direction: str, denomination: str) -> str: + row = TABLE_ROW[direction] + col = TABLE_COL[denomination] + return page.evaluate( + f"""() => {{ return document.getElementById('result-table') .rows[{row}].cells[{col}].textContent.trim(); }}""" - ) - - def _assert_part_score_table(self, page) -> None: - for direction in ("N", "E", "S", "W"): - for denomination in ("C", "D", "H", "S", "N"): - expected = str(_expected_tricks(direction, denomination)) - actual = self._read_table_cell(page, direction, denomination) - self.assertEqual( - actual, - expected, - msg=f"{direction}/{denomination}", - ) - result_text = page.locator("#result").inner_text() - self.assertEqual(result_text.strip(), "") - - def test_page_load_shows_valid_pips(self) -> None: - page, errors = self._open_page(self.site_dir.joinpath("dds_mvp.html").as_uri()) - try: - pips = page.locator("#valid-pips").inner_text() - self.assertIn("A", pips) - self.assertIn("2", pips) - self.assertEqual(errors, []) - finally: - page.close() - - def test_file_url_part_score_table(self) -> None: - url = self.site_dir.joinpath("dds_mvp.html").as_uri() - page, errors = self._open_page(url) - try: - self._fill_part_score_deal(page) - self._run_double_dummy(page) - self._assert_part_score_table(page) - self.assertEqual(errors, []) - finally: - page.close() - - def test_http_part_score_table(self) -> None: - with _HttpSite(self.site_dir) as site: - page, errors = self._open_page(site.url) - try: - self._fill_part_score_deal(page) - self._run_double_dummy(page) - self._assert_part_score_table(page) - self.assertEqual(errors, []) - finally: - page.close() - - def test_validation_error_on_incomplete_deal(self) -> None: - page, errors = self._open_page(self.site_dir.joinpath("dds_mvp.html").as_uri()) - try: - page.get_by_role("button", name="Clear entries").click() - page.get_by_role("button", name="Double-dummy it!").click() - page.wait_for_function( - """() => document.getElementById('result').textContent.includes('13 cards')""" - ) - message = page.locator("#result").inner_text() - self.assertIn("13 cards", message) - self.assertEqual(errors, []) - finally: - page.close() + ) + + def _assert_part_score_table(self, page) -> None: + for direction in ("N", "E", "S", "W"): + for denomination in ("C", "D", "H", "S", "N"): + expected = str(_expected_tricks(direction, denomination)) + actual = self._read_table_cell(page, direction, denomination) + self.assertEqual( + actual, + expected, + msg=f"{direction}/{denomination}", + ) + result_text = page.locator("#result").inner_text() + self.assertEqual(result_text.strip(), "") + + def test_page_load_shows_valid_pips(self) -> None: + page, errors = self._open_page(self.site_dir.joinpath("dds_mvp.html").as_uri()) + try: + pips = page.locator("#valid-pips").inner_text() + self.assertIn("A", pips) + self.assertIn("2", pips) + self.assertEqual(errors, []) + finally: + page.close() + + def test_file_url_part_score_table(self) -> None: + url = self.site_dir.joinpath("dds_mvp.html").as_uri() + page, errors = self._open_page(url) + try: + self._fill_part_score_deal(page) + self._run_double_dummy(page) + self._assert_part_score_table(page) + self.assertEqual(errors, []) + finally: + page.close() + + def test_http_part_score_table(self) -> None: + with _HttpSite(self.site_dir) as site: + page, errors = self._open_page(site.url) + try: + self._fill_part_score_deal(page) + self._run_double_dummy(page) + self._assert_part_score_table(page) + self.assertEqual(errors, []) + finally: + page.close() + + def test_validation_error_on_incomplete_deal(self) -> None: + page, errors = self._open_page(self.site_dir.joinpath("dds_mvp.html").as_uri()) + try: + page.get_by_role("button", name="Clear entries").click() + page.get_by_role("button", name="Double-dummy it!").click() + page.wait_for_function( + """() => document.getElementById('result').textContent.includes('13 cards')""" + ) + message = page.locator("#result").inner_text() + self.assertIn("13 cards", message) + self.assertEqual(errors, []) + finally: + page.close() if __name__ == "__main__": From 80fb1e98be8eea08775ff1da61d2c85a0ecce04c Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 3 Jun 2026 23:05:49 +0200 Subject: [PATCH 42/45] Updated so thread creation is exception-safe. If emplace_back throws after some workers have started, the catch block joins every joinable thread in threads before rethrowing, so destructors never hit std::terminate() on still-running std::thread objects. The normal path still joins all threads after the loop completes. --- library/src/solve_board.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/library/src/solve_board.cpp b/library/src/solve_board.cpp index 69156d7a..82d76b8d 100644 --- a/library/src/solve_board.cpp +++ b/library/src/solve_board.cpp @@ -82,8 +82,15 @@ auto solve_all_boards_n( // provide it yet, while std::thread is widely available. std::vector threads; threads.reserve(static_cast(nthreads)); - for (int i = 0; i < nthreads; ++i) - threads.emplace_back(worker); + try { + for (int i = 0; i < nthreads; ++i) + threads.emplace_back(worker); + } catch (...) { + for (auto& t : threads) + if (t.joinable()) + t.join(); + throw; + } for (auto& t : threads) t.join(); } From 4b26af98640480c5e75e3fd7ab30bbc244b8cdce Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 4 Jun 2026 11:31:59 +0200 Subject: [PATCH 43/45] Updated comments to more clearly state the purpose of the file and requirements_lock.txt. --- web/requirements.in | 16 +++++++++++++++- web/requirements_lock.txt | 2 +- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/web/requirements.in b/web/requirements.in index 54342e99..b10630c0 100644 --- a/web/requirements.in +++ b/web/requirements.in @@ -1,5 +1,19 @@ -# Regenerate (must match MODULE.bazel python_version, currently 3.14): +# This file is a manifest used to regenerate requirements_lock.txt. +# Regenerate the lock with the same Python version as MODULE.bazel (currently 3.14). +# requirements_lock is used to ensure a consistent set of pinned Python dependencies +# for Bazel across builds on different machines and in different environments. +# For now, that means playwright and its deps. +# playwright is used for the end-to-end tests in web/tests/test_mvp_e2e.py + +# Normal Bazel builds consume requirements_lock.txt; they do not regenerate it. +# To update requirements_lock.txt after editing this file: +# # bazel run @python_3_14//:python3 -- -m pip install pip-tools # bazel run @python_3_14//:python3 -- -m piptools compile --generate-hashes \ # --output-file=web/requirements_lock.txt web/requirements.in +# +# Todo: Consider adding a 'compile_pip_requirements' rules_python target that +# would be invoked by 'bazel run … --update'. If done, though, requirements_lock.txt +# should remain in the repo so that it stays versioned. + playwright==1.52.0 diff --git a/web/requirements_lock.txt b/web/requirements_lock.txt index eabbd22f..3ca40195 100644 --- a/web/requirements_lock.txt +++ b/web/requirements_lock.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.14 # by the following command: # -# pip-compile --generate-hashes --output-file=web/requirements_lock.txt web/requirements.in +# g --generate-hashes --output-file=web/requirements_lock.txt web/requirements.in # greenlet==3.5.1 \ --hash=sha256:001775efe7b8e758861294c7a27c28af87f3f3f1c20468a2bc618c45b346c061 \ From b2cab792fba6168f47fb74c88b66183570bbc023 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 4 Jun 2026 12:13:32 +0200 Subject: [PATCH 44/45] Excluded the time-consuming e2e tests from blaze test /... --- .bazelrc | 5 +++++ docs/wasm_build.md | 2 ++ 2 files changed, 7 insertions(+) diff --git a/.bazelrc b/.bazelrc index 5b87a333..b62438e4 100644 --- a/.bazelrc +++ b/.bazelrc @@ -1,5 +1,10 @@ # Bazel configuration for DDS project +# Skip Playwright browser tests on `bazel test //...` (network, Chromium, no-sandbox). +# Run e2e explicitly: bazel test --test_tag_filters=e2e //web:web_e2e_tests +# Run every test: bazel test --test_tag_filters= /... +test --test_tag_filters=-e2e + common --enable_platform_specific_config # Platform-specific C++ standard flags diff --git a/docs/wasm_build.md b/docs/wasm_build.md index fb170d5d..f071beea 100644 --- a/docs/wasm_build.md +++ b/docs/wasm_build.md @@ -126,6 +126,8 @@ bazel test //web:web_tests //web:web_system_tests //web:web_e2e_tests bazel test //examples/wasm:all ``` +`bazel test //...` skips targets tagged `e2e` by default (see `.bazelrc`). Run Playwright tests explicitly, e.g. `bazel test //web:web_e2e_tests` or `bazel test --test_tag_filters=e2e //web:dds_mvp_e2e_test`. To run all tests, including the Playwright tests: `bazel test --test_tag_filters= /...` + - **`//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"]`. - **`//examples/wasm:wasm_examples_system_test`** — runs `calc_dd_table_pbn.js` under Node and checks for `OK` on all three example hands. From bf78d55fae5585c3306271e0e46917aa2d8b456b Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 4 Jun 2026 12:15:29 +0200 Subject: [PATCH 45/45] Corrected the size of wasm_examples_system_test to 'small'. --- examples/wasm/BUILD.bazel | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/wasm/BUILD.bazel b/examples/wasm/BUILD.bazel index 83af1bf5..b9d78014 100644 --- a/examples/wasm/BUILD.bazel +++ b/examples/wasm/BUILD.bazel @@ -56,7 +56,8 @@ cc_test( py_test( name = "wasm_examples_system_test", - size = "medium", + size = "small", + timeout = "short", main = "tests/test_wasm_examples_system.py", srcs = ["tests/test_wasm_examples_system.py"], data = [":calc_dd_table_pbn_wasm"],