Repository navigation
test: property-based fuzz testing for js->zig->js roundtrip #41
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
nazarhussain
wants to merge
17
commits into
nh/bigint-exact-conversions
Choose a base branch
from
nh/fuzz-testing
base: nh/bigint-exact-conversions
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
a86ab02
chore(deps): add fast-check and test:fuzz script
nazarhussain 0cc0e58
chore(tests): scaffold fuzz_numeric addon module
nazarhussain 21af8ad
test(fuzz): add rtNumberF64 round-trip fuzz target
nazarhussain e578734
test(fuzz): add Number i32/u32/i64 round-trip properties
nazarhussain 2c7a8be
test(fuzz): add BigInt i64/u64 round-trip properties
nazarhussain 209b47b
test(fuzz): add lossless-flag introspection for toI64/toU64
nazarhussain 1005609
test(fuzz): add rtBigIntI128 round-trip target
nazarhussain 57bcdde
test(fuzz): add BigInt word-level round-trip
nazarhussain 72f6e4b
ci: add fuzz tests job
nazarhussain 4b67883
docs(fuzz): document seeds workflow and dev loop
nazarhussain 0d80fa4
docs(fuzz): correct seeds README — drop unimplemented __special reviver
nazarhussain 5d0c2a3
chore: restructure oracle code
nazarhussain 594a4ad
test(fuzz): cover exact and LowBits BigInt conversions
nazarhussain a032b18
test: add uint8array fuzz tests
nazarhussain 3689e3b
chore: restructure tests files
nazarhussain 3f2ee8c
chore: rename fuzz tests script
nazarhussain 17080db
test(fuzz): note getValueBigintWords overflow in rtBigIntWords
nazarhussain File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| const std = @import("std"); | ||
| const zbuild = @import("zbuild"); | ||
|
|
||
| pub fn build(b: *std.Build) !void { | ||
| @setEvalBranchQuota(200_000); | ||
| const manifest = @import("build.zig.zon"); | ||
| _ = try zbuild.configureBuild(b, manifest, .{}); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| .{ | ||
| .name = .zapi_tests, | ||
| .version = "0.0.0", | ||
| .fingerprint = 0xd21dc10774fb3dc7, | ||
| .minimum_zig_version = "0.16.0", | ||
| .paths = .{ | ||
| "build.zig", | ||
| "build.zig.zon", | ||
| "fuzz", | ||
| }, | ||
| .dependencies = .{ | ||
| // Repository builds use --fork so the tests consume the local | ||
| // checkout while preserving the URL/hash dependency boundary. | ||
| .zapi = .{ | ||
| .url = "https://github.com/ChainSafe/zapi/archive/b934988f8595e5a5fdc497b7212631004f800116.tar.gz", | ||
| .hash = "zapi-3.1.0-rIqzUfHFBADg6qo5AXJzbfHgs6LqpwgVKYal4qCG4XSW", | ||
| }, | ||
| .zbuild = .{ | ||
| .url = "git+https://github.com/chainsafe/zbuild.git#f7d5f19de09f2808e54acf599605f9e18c9bd804", | ||
| .hash = "zbuild-0.4.0-XJFav-1nAgDmfVY1nVSIIzbkcbuHZj7yMb7Fv8yvznPO", | ||
| }, | ||
| }, | ||
| .modules = .{ | ||
| .test_fuzz_numeric = .{ | ||
| .root_source_file = "fuzz/mod.zig", | ||
| .imports = .{.zapi}, | ||
| .link_libc = true, | ||
| }, | ||
| }, | ||
| .libraries = .{ | ||
| .test_fuzz_numeric = .{ | ||
| .root_module = .test_fuzz_numeric, | ||
| .linkage = .dynamic, | ||
| .linker_allow_shlib_undefined = true, | ||
| .dest_sub_path = "test_fuzz_numeric.node", | ||
| }, | ||
| }, | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| # fuzz | ||
|
|
||
| Property-based fuzz tests for zapi's Number, BigInt, and selected binary conversion surfaces. Driven from JS through the `test_fuzz_numeric.node` addon (built by `zig build`) using vitest + fast-check. | ||
|
|
||
| See `docs/superpowers/specs/2026-05-28-fuzz-testing-design.md` for design rationale. | ||
|
|
||
| ## Running | ||
|
|
||
| ```bash | ||
| # Full run (10 000 cases per property) | ||
| pnpm test:fuzz:round_trip | ||
|
|
||
| # CI-equivalent (1 000 cases per property) | ||
| FUZZ_RUNS=1000 pnpm test:fuzz:round_trip | ||
|
|
||
| # Single property | ||
| pnpm vitest run tests/fuzz/bigint.fuzz.test.ts -t "rtBigIntI128" | ||
| ``` | ||
|
|
||
| ## When a property fails | ||
|
|
||
| fast-check shrinks the counterexample and prints something like: | ||
|
|
||
| ``` | ||
| Property failed after 47 tests | ||
| Counterexample: 170141183460469231731687303715884105728n | ||
| Shrunk 14 times to: 170141183460469231731687303715884105728n | ||
| Seed: 1234567890, Path: 0:0:1 | ||
| ``` | ||
|
|
||
| To reproduce locally, paste the seed into a temporary `it.only`: | ||
|
|
||
| ```ts | ||
| it.only("repro", () => { | ||
| fc.assert( | ||
| fc.property(bigIntArbI128, (b) => ... ), | ||
| { seed: 1234567890, path: "0:0:1", numRuns: 1 }, | ||
| ); | ||
| }); | ||
| ``` | ||
|
|
||
| Once fixed, **persist the counterexample** so it runs forever: | ||
|
|
||
| 1. Create or open `tests/fuzz/seeds/<target>.json`. | ||
| 2. Append the counterexample to the JSON array. For bigints, use: | ||
| ```json | ||
| { "__bigint": "170141183460469231731687303715884105728" } | ||
| ``` | ||
| For numbers, use a JSON number directly. For `Uint8Array`, use a JSON array of byte values, e.g. `[0, 255, 1]`. NaN and ±Infinity aren't representable in JSON and are not currently supported as persisted seeds — those values are already in `edgeNumbers` and exercised on every run, so persisting them as regression cases adds nothing. If a future fuzz target needs persisted special-number seeds, extend `loadSeeds` and the relevant `revive*` helper at that time. | ||
| 3. Commit with the fix. | ||
|
|
||
| ## Adding a new fuzz target | ||
|
|
||
| 1. Add the round-trip export to `mod.zig`. | ||
| 2. Add the oracle to `oracle.test.ts` and run it against every entry in the relevant edge list. | ||
| 3. Add the fast-check property to the relevant `*.fuzz.test.ts` file. | ||
| 4. Update `tests/utils/edge.ts` if the target needs new edge values. | ||
| 5. Run `pnpm test:fuzz:round_trip` and watch it pass (or find a bug). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| import { describe, it } from "vitest"; | ||
| import fc from "fast-check"; | ||
| import { edgeBigInts } from "../utils/edge.ts"; | ||
| import { | ||
| fitsBigIntI128, | ||
| fitsBigIntI64, | ||
| fitsBigIntU64, | ||
| oracleBigIntI128, | ||
| oracleBigIntI128LowBits, | ||
| oracleLosslessI64, | ||
| oracleLosslessU64, | ||
| } from "../utils/oracles.ts"; | ||
| import { FUZZ_RUNS, loadSeeds, mod, reviveBigInt } from "../utils/support.ts"; | ||
|
|
||
| const bigIntArbI64 = fc.oneof( | ||
| { arbitrary: fc.bigInt({ min: -(2n ** 65n), max: 2n ** 65n }), weight: 4 }, | ||
| { arbitrary: fc.constantFrom(...edgeBigInts), weight: 1 }, | ||
| ); | ||
|
|
||
| describe("rtBigIntI64", () => { | ||
| it("is exact in [-2^63, 2^63) and throws outside that range", () => { | ||
| fc.assert( | ||
| fc.property(bigIntArbI64, (b) => { | ||
| if (fitsBigIntI64(b)) return mod.rtBigIntI64(b) === b; | ||
|
|
||
| try { | ||
| mod.rtBigIntI64(b); | ||
| return false; | ||
| } catch { | ||
| return true; | ||
| } | ||
| }), | ||
| { | ||
| numRuns: FUZZ_RUNS, | ||
| examples: loadSeeds<bigint>("rtBigIntI64", reviveBigInt), | ||
| }, | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe("rtBigIntU64", () => { | ||
| it("is exact in [0, 2^64) and throws outside that range", () => { | ||
| fc.assert( | ||
| fc.property(bigIntArbI64, (b) => { | ||
| if (fitsBigIntU64(b)) return mod.rtBigIntU64(b) === b; | ||
|
|
||
| try { | ||
| mod.rtBigIntU64(b); | ||
| return false; | ||
| } catch { | ||
| return true; | ||
| } | ||
| }), | ||
| { | ||
| numRuns: FUZZ_RUNS, | ||
| examples: loadSeeds<bigint>("rtBigIntU64", reviveBigInt), | ||
| }, | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe("losslessI64", () => { | ||
| it("lossless ⇔ b ∈ [-2^63, 2^63) and value matches asIntN", () => { | ||
| fc.assert( | ||
| fc.property(bigIntArbI64, (b) => { | ||
| const { value, lossless } = mod.losslessI64(b); | ||
| const expected = oracleLosslessI64(b); | ||
| return value === expected.value && lossless === expected.lossless; | ||
| }), | ||
| { | ||
| numRuns: FUZZ_RUNS, | ||
| examples: loadSeeds<bigint>("losslessI64", reviveBigInt), | ||
| }, | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe("losslessU64", () => { | ||
| it("lossless ⇔ b ∈ [0, 2^64) and value matches asUintN", () => { | ||
| fc.assert( | ||
| fc.property(bigIntArbI64, (b) => { | ||
| const { value, lossless } = mod.losslessU64(b); | ||
| const expected = oracleLosslessU64(b); | ||
| return value === expected.value && lossless === expected.lossless; | ||
| }), | ||
| { | ||
| numRuns: FUZZ_RUNS, | ||
| examples: loadSeeds<bigint>("losslessU64", reviveBigInt), | ||
| }, | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| const bigIntArbI128 = fc.oneof( | ||
| { arbitrary: fc.bigInt({ min: -(2n ** 129n), max: 2n ** 129n }), weight: 4 }, | ||
| { arbitrary: fc.constantFrom(...edgeBigInts), weight: 1 }, | ||
| ); | ||
|
|
||
| describe("rtBigIntI128", () => { | ||
| it("is exact in [-2^127, 2^127) and throws outside that range", () => { | ||
| fc.assert( | ||
| fc.property(bigIntArbI128, (b) => { | ||
| if (fitsBigIntI128(b)) return mod.rtBigIntI128(b) === oracleBigIntI128(b); | ||
|
|
||
| try { | ||
| mod.rtBigIntI128(b); | ||
| return false; | ||
| } catch { | ||
| return true; | ||
| } | ||
| }), | ||
| { | ||
| numRuns: FUZZ_RUNS, | ||
| examples: loadSeeds<bigint>("rtBigIntI128", reviveBigInt), | ||
| }, | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe("rtBigIntI128LowBits", () => { | ||
| it("matches BigInt.asIntN(128, ·)", () => { | ||
| fc.assert( | ||
| fc.property( | ||
| bigIntArbI128, | ||
| (b) => mod.rtBigIntI128LowBits(b) === oracleBigIntI128LowBits(b), | ||
| ), | ||
| { | ||
| numRuns: FUZZ_RUNS, | ||
| examples: loadSeeds<bigint>("rtBigIntI128LowBits", reviveBigInt), | ||
| }, | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| const bigIntArbWords = fc.oneof( | ||
| // Keep within the addon's 64-word cap (4096 bits). | ||
| { | ||
| arbitrary: fc.bigInt({ min: -(2n ** 4000n), max: 2n ** 4000n }), | ||
| weight: 4, | ||
| }, | ||
| { arbitrary: fc.constantFrom(...edgeBigInts), weight: 1 }, | ||
| ); | ||
|
|
||
| describe("rtBigIntWords", () => { | ||
| it("is identity for any BigInt under the word-buffer cap", () => { | ||
| fc.assert( | ||
| fc.property(bigIntArbWords, (b) => mod.rtBigIntWords(b) === b), | ||
| { | ||
| numRuns: FUZZ_RUNS, | ||
| examples: loadSeeds<bigint>("rtBigIntWords", reviveBigInt), | ||
| }, | ||
| ); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
seems a little strange to fuzz in CI