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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ jobs:
- name: Test
run: pnpm test:js

test-fuzz:

Copy link
Copy Markdown
Member

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

name: Fuzz Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: ./.github/actions/setup-env
- name: Build
run: pnpm build
- name: Fuzz
run: pnpm test:fuzz:round_trip
env:
FUZZ_RUNS: "1000"

lint:
name: Lint
runs-on: ubuntu-latest
Expand Down
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@
"scripts": {
"build": "pnpm build:js && pnpm build:zig",
"build:js": "tsc --outDir lib",
"build:zig": "cd examples && zig build --fork=..",
"build:zig": "pnpm build:zig:examples && pnpm build:zig:tests",
"build:zig:examples": "cd examples && zig build --fork=..",
"build:zig:tests": "cd tests && zig build --fork=.. --prefix ../zig-out",
"test:zig": "zig build test:zapi",
"test:js": "vitest run examples/**/*.test.ts",
"test:fuzz:round_trip": "pnpm build:zig && vitest run tests/**/*.test.ts",
"test": "pnpm test:zig && pnpm test:js",
"lint:zig": "zig fmt src",
"lint:js": "pnpm biome check",
Expand All @@ -34,6 +37,7 @@
"@biomejs/biome": "^2.5.8",
"@chainsafe/biomejs-config": "^1.0.0",
"@types/node": "^24.1.0",
"fast-check": "^4.8.0",
"typescript": "^5.8.3",
"vitest": "^4.1.10"
},
Expand Down
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

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

8 changes: 8 additions & 0 deletions tests/build.zig
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, .{});
}
38 changes: 38 additions & 0 deletions tests/build.zig.zon
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",
},
},
}
58 changes: 58 additions & 0 deletions tests/fuzz/README.md
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).
154 changes: 154 additions & 0 deletions tests/fuzz/bigint.fuzz.test.ts
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),
},
);
});
});
Loading