9.6 KiB
Testing: the loop, and how the suites are built
The build / test loop
- Fast:
cd crates && cargo check --workspace --all-targets,cargo test --workspace,cargo clippy --workspace --all-targets. cargo doc -p xrpl-wasm-vm --no-depsis part of the loop, not a nicety.lib.rscarriesdeny(rustdoc::broken_intra_doc_links), and neithercargo testnorclippychecks doc links. Caveat: it does not cover private modules, which are not documented by default — a dead link insideabi.rspasses silently (this is how aVmState::scratchlink survived theout_bufferrename). Add--document-private-itemsto check those, and grep after renaming a field.lib.rsalso carriesforbid(unsafe_code),deny(unreachable_pub)anddenyon four clippy cast lints, so an unargued cast fails the build rather than warning.- Full C++↔Rust: normal CMake build, then
./xrpl_tests --gtest_filter='WasmVMTest.*:*Call.*'. - Guest-linkability (needs
rustup target add wasm32-unknown-unknown):cargo check -p xrpl-host-functions --target wasm32-unknown-unknown. Only the ABI crate —xrpl-wasm-vmis host-side and pulls in wasmi, andcrates/hello_worldcannot be checked for that target at all because it depends oncxx→link-cplusplus, which wants a C++ toolchain for the target. - VCS is jj (
jj st,jj log), not raw git, for local work.
Two build gotchas that cost an afternoon each
- A stale build directory fails to link with
duplicate symbol '_rust_eh_personality'. The conanwasmipackage ships a Ruststd, and so does our staticlib.b7059deb9fdropped the conan requirement but leftfind_package(wasmi REQUIRED)andwasmi::wasmiin the CMake, both now removed; a build folder generated before that still hasbuild/generators/wasmi-*.cmake, so re-runconan install .. --output-folder . --build missing --settings build_type=Debugand delete them. Note this was two differently compiledstds — two staticlibs from this workspace are fine, andxrpldalready linksrs_hello_worldalongsidexrpl_wasm_vm_ffi. cargo teston the bridge crate links only because nothing in the tests reaches a C++ shim. Theextern "C++"symbols exist only in the CMake build, and the test binary links because-dead_stripdrops what no test path reaches. Verified: forcing a reference (let f: fn(&ffi::HostContext) -> _ = ...) fails withUndefined symbols: _rs$wasm_vm$cxxbridge1$…. So keep those tests on pure logic — the status map, the panic guard, the wire conversions — and put anything that needs a host in the gtest.check_escrowis the exception: it takes noHostContext, so its tests call the real bridge function, hand-writing the two modules they need as bytes (the eight-byte header is a valid module) rather than reaching for an assembler this crate does not have.
The Rust tests
- They come in two kinds, and the split is forced. A wasmi
Callerexists only during a host call, so everything inabi.rsthat takes one cannot be reached from a unit test. Unit tests insrc/cover what needs no live instance (wire conversions, budget arithmetic, the limits); guest-memory policy lives intests/, running real modules against a configurable fake host. - Those integration tests write modules as WAT text and assemble it themselves —
watis a[dev-dependencies]entry andsupport::assembleits only caller, so the assembler never enters the library.runtakes binaries; there is norun_wat. tests/support/mod.rsholds the fake host and the import declarations.Answerseparates what the host writes from what length it reports, which is what makes the over-cap and buffer-fit rules testable without values that large existing.
How the C++ tests are built
src/tests/libxrpl/tx/wasm/, in the xrpl_tests gtest binary. Four decisions, each of which
had an obvious cheaper alternative that was worse.
Modules are WebAssembly text, assembled at run time. Checked-in hex blobs do not scale
past one module — every host function needs its own, with its own import signature — and they
are unreviewable. So compile_wat comes over cxx from crates/xrpl-wasm-testkit, a crate
of its own that nothing in libxrpl or xrpld links.
That separation is the whole point and is worth not undoing. The engine pins
wasmi = { default-features = false } because wasmi's wat feature makes Module::new
accept text as readily as binary, which would make a transaction's validity a build flag
(finding A5 in history.md). Putting compile_wat on the production bridge would
link an assembler into the shipped node even though nothing called it; a cargo feature would
make the test and production binaries differ. A separate crate makes "no assembler in the
node" a property of the link graph. WasmVMTest.TextFormatModuleIsRejected then feeds the
engine the very text the rest of the suite assembles, so the guest-side half of A5 is pinned
too.
The host is a StrictMock. MockHostFunctions mocks only the methods the ABI declares;
the ~60 others keep HostFunctions' Unimplemented default, so a contract reaching past the
ABI fails the way production would. What this buys over a hand-written fake is assertions on
what the host was asked — that a guest i32 became the right SField, that two borrowed
regions and a flag all arrived, that an i64 survived as INT64_MIN.
Strict rather than nice, because these modules import exactly what they mean to exercise: a
host call no test asked for means the engine reached for something on its own, which is worth
a failure rather than a warning. The cost is one line in the fixture —
EXPECT_CALL(host_, checkSelf()).WillRepeatedly(Return(true)), since runEscrowWasm asks
every run whether the host is clean. Verified by mutation: giving escrow_finish an
unstubbed host call fails the test under Strict and passes silently under NiceMock.
One trap worth knowing even so: gmock's default action for std::expected<T, E> is a
successful T{}, so a method with an EXPECT_CALL but no action would answer 0 and a
test could pass on an answer nobody chose. The mock's constructor therefore ON_CALLs every
method to the base class's std::unexpected(Unimplemented).
Two levels of fixture. WasmTest holds the mock, a capturing journal sink and run(wat, gas, entryPoint). HostCallTest adds a wat() the derived fixture supplies and
hostAnswer(), so a per-function test says only what the host was asked and what came back.
Then one fixture per host function — LedgerSqnCall, CurrentLedgerObjFieldCall,
Sha512HalfCall, TraceCall, TraceNumCall — because the module is that function's shared
setup. WasmVMTest keeps what belongs to the engine rather than to any function.
PreflightTest deliberately derives from testing::Test, not from WasmTest, and holds
no mock: preflightEscrowWasm takes no host, and a fixture that supplied one would hide the
signature that is the point. That is why assembleWat is a free function in WasmFixture.h
rather than a WasmTest member. PreflightTest.ScreeningAgreesWithARun is the one test
there that does build a host — it puts the same modules through runEscrowWasm so the two
entry points do not have to be trusted to agree.
The journal is captured, not sent to a null sink.
WasmVMTest.ThrowingHostFunctionBecomesInternal asserts the exception text and that the log
names getLedgerSqn; without that, an exception silently swallowed with no log would pass, and
HostContext::guarded's source_location would be untested.
Two properties are pinned from the guest's side rather than asserted about internals:
LedgerSqnCall.BufferTooSmallIsRefusedWholeNotTruncated has the contract report whether
anything reached its memory, which is "a refused value reaches it in no part" as a contract
can observe it; and WasmVMTest.SoftHostErrorCodesCrossUnchanged walks all 18 soft
HostFunctionError codes, because the C++ and Rust error enums are two hand-maintained lists
of the same numbers that have already drifted once — -11 is OutOfTransferLimit in C++ and
Decoding in the Rust ABI; see "the two error enums have already drifted" in
open-questions.md.
Mutation-checked: making a too-large value write a truncated prefix, and pointing the sha512 input matcher at bytes the guest does not send, each fail exactly one test and nothing else.
Naming. Subject-first, no leading article — ContractReturnValueReachesCaller, not
AContractsReturnValueReachesTheCaller. That is the house style in src/tests/libxrpl
(BuilderThrowsOnWrongEntryType, OptionalFieldsReturnNullopt).
The old C++ suites, and why they are not the parity oracle yet
src/test/app/Wasm_test.cpp and HostFuncImpl_test.cpp are entirely inside /* */ and
compile to nothing, as is src/libxrpl/tx/wasm/WasmiVM.cpp.
Wasm_test.cpp asserts exact gas numbers (e.g. 29'502), which makes it the best gas-parity
oracle available — but its fixtures cannot run on this engine. They import from module
env, not host_lib (kLedgerSqnWasmHex decodes to
... 03 656e76 0a 6c6467725f696e646578 ...), and their target_features include sign-ext,
multivalue and reference-types, which this engine disables. The deleted C++ engine ignored
the import module name entirely — wasm_importtype_module is commented out at its
WasmiVM.cpp:428. Reviving it as an oracle means recompiling those fixtures with
-Wl,--import-module=host_lib and the engine's feature set. The gtest carries its own
WAT-derived modules for that reason.