Skip to content

cryptopeer-interop-testing

Skill cryptopeer-interop-testing. Use when writing or running tests that span languages or processes in the cryptopeer stack: Dart, Go and JS interop for UDX and libp2p, loopback and in-memory test hosts, Docker NAT and netem setups, browser and TURN tests, timeouts and flaky network tests, and the dart test, go test and vitest commands and CI presets of each repo.

SKILL.md (Claude Code, Codex, Cursor) · cryptopeer-interop-testing.md (Boubou) · install: curl -fsSL https://cryptopeer.net/skills/install.sh | sh -s -- cryptopeer-interop-testing

The cryptopeer stack has three implementations of UDX (dart-udx, go-udx, js-udx) and two libp2p stacks that talk to each other (dart_libp2p and go-libp2p through go-libp2p-udx-transport, plus js-libp2p through @go-ricochet/libp2p-udx). This skill covers the tests that run across these: what exists, how to run it, how to write a new one, and how to keep it stable. For the API of a single package, load its own skill (udx-dart, udx-go, udx-js, go-libp2p-udx, dart-libp2p-host and so on). When a test fails because peers do not connect, load cryptopeer-connectivity-debugging.

Every interop test finds the other implementation by a relative path, so all repos must be checked out side by side in one directory:

<dir>/dart-udx <dir>/go-udx <dir>/js-udx <dir>/go-libp2p-udx-transport
<dir>/dart-libp2p <dir>/dart-libp2p-kad-dht <dir>/dart-libp2p-pubsub
<dir>/dart-libp2p-webrtc <dir>/go-ricochet <dir>/ricochet-dart-client
Path used By
../go-udx (from dart-udx) dart-udx/test/interop/go_udx_interop_test.dart
../../dart-udx (from go-udx/interop) go-udx/interop/*_test.go
../../dart-libp2p (from go-udx/interop) go-udx/interop/docker-test.sh, docker-netem-fullstack.sh
../dart-libp2p go-libp2p-udx-transport/interop_test.go
../../../go-udx, ../../../go-libp2p-udx-transport replace lines in dart-libp2p/interop/go-peer/go.mod
../dart-libp2p/interop/go-peer, or GO_PEER_DIR kad-dht and pubsub test/interop/helpers/go_process_manager.dart
../go-udx, ../dart-udx, ../go-libp2p-udx-transport, ../dart-libp2p js-udx test helpers
../go-ricochet/ricochet (a built binary) ricochet-dart-client/test/support/ricochet_server.dart

Toolchains: Dart (dart-libp2p’s dev dependencies need SDK 3.6; the library allows 3.5), Go (the go.mod files ask for 1.25.7, go-ricochet for 1.26.10), Node 20+ for js-udx (CI uses 22.18), Node 22+ for the WebRTC browser harness, Flutter for dart-libp2p-ble, and Docker for the NAT, netem and TURN setups.

dart-libp2p/pubspec_overrides.yaml (git-ignored) points dart_udx at ../dart-udx. The demos use the same mechanism; see dart-libp2p-demos/README.md. Delete or adjust it when you want to test against the published packages.

Repo Unit / default Interop or slow suites
dart-udx dart test dart test test/interop/ (builds ../go-udx/cmd/interop-server)
go-udx go test -race ./...; go test -short ./... skips the slow lossy transfers go test -v ./interop/; UDX_THROUGHPUT=1; UDX_NETEM_BASELINE=1 (inside the netem container)
js-udx npm test (vitest run); npm run ci = typecheck, build, lint, test npm run interop:go-udx; UDX_SLOW_TESTS=1 npm test
go-libp2p-udx-transport go test ./... interop_test.go runs in the same go test and needs Dart
dart-libp2p dart test -P ci (excludes tags docker and interop, retries twice) dart test test/interop/ or dart test -t interop; dart test -t docker
dart-libp2p-kad-dht, dart-libp2p-pubsub dart test dart test test/interop/
dart-libp2p-webrtc dart test tags browser and turn skip themselves when their tools are missing
dart-libp2p-ble flutter test none: radios are in memory
go-ricochet go test -count=1 ./... integration tests need RICOCHET_TEST_POSTGRES_DSN; test/nat/run.sh; test/integration/docker/run_test.sh
ricochet-dart-client dart test (one file at a time, concurrency: 1) needs the built server and PostgreSQL (RICOCHET_TEST_PG)
go-p2p-forge go test ./... none

Run one Dart test by name with dart test <file> --name "<regex>" or --plain-name "<text>". Run one Go test with go test -run 'TestName' -v -count=1 ./pkg. Run one vitest file with npx vitest run <path>.

  • dart-udx/test/interop/go_udx_interop_test.dart builds go-udx’s cmd/interop-server, waits for READY <port> on its stderr, and sends raw packets to it.

  • go-udx/interop/dart_interop_test.go runs dart run bin/interop_server.dart in ../../dart-udx the same way.

  • go-udx/interop/bulk_interop_test.go and multistream_interop_test.go drive the Dart bulk peer in interop/dartpeer. They skip when dart is not on PATH. UDX_BULK_PEER replaces the peer command; js-udx uses it:

    Terminal window
    UDX_BULK_PEER="node /path/to/js-udx/tools/bulk-peer/bulk-peer.mjs" \
    go test ./interop -run 'TestBulk|TestMultiStream' -count=1 -v

    npm run interop:go-udx in js-udx builds first and runs exactly this.

  • js-udx/packages/udx/test/go-interop.test.ts and dart-interop.test.ts build tools/go-peer and tools/dart-peer and test both directions: handshake, 4 MiB echo, 8 concurrent streams, ping, path MTU discovery.

  • js-udx/packages/udx/test/go-vectors.test.ts checks encoding byte for byte against vectors from go-udx. Regenerate them with npm run vectors after a wire change in go-udx.

  • dart-libp2p/test/interop/ (all @Tags(['interop'])): host, identify, ping, identify push (go_interop_host_test.dart), Noise/Yamux upgrader pipeline (go_interop_test.dart), circuit relay v2 with a Go relay (go_interop_relay_test.dart), relayed identify and DCUtR, AutoNAT v2, and UDX host and keepalive stability. interop/README.md lists the directions.
  • go-libp2p-udx-transport/interop_test.go runs dart-libp2p’s bin/interop_echo_server.dart (prints READY <port> <peer-id> on stderr) and dials it from go-libp2p over UDX, and the reverse.
  • js-udx/packages/libp2p-udx/test/interop.test.ts: js-libp2p against go-libp2p and dart-libp2p, each side dialing: Noise, Yamux, ping, identify, 1 MiB echo. compliance.test.ts runs js-libp2p’s transport compliance suite.
  • dart-libp2p-kad-dht/test/interop/ and dart-libp2p-pubsub/test/interop/ reuse dart-libp2p’s Go peer.
  • dart-libp2p-webrtc/test/interop/: js-libp2p in Chrome, Firefox or WebKit through Playwright, js-libp2p and go-libp2p relays. Setup: (cd interop/browser && npm install), choose a browser with BROWSER=chrome|chromium|firefox|webkit, set NODE if node is older than 22. TURN tests need docker compose -f docker/coturn/compose.yaml up -d or TURN_URL=turn:user:pass@host:port. Details: dart-libp2p-webrtc skill.

The Go test peer (dart-libp2p/interop/go-peer)

Section titled “The Go test peer (dart-libp2p/interop/go-peer)”

One binary with many modes, built by GoProcessManager.ensureBinary before each run (go build -o go-peer .; incremental, so a stale binary is never used). Flags: --mode, --port, --target, --relay, --message, --transport=tcp|udx, --config=<yaml> (yamux keepalive_interval, connection_write_timeout), plus --key, --value, --cid, --topic. Modes: server, client, ping, echo-server, echo-client, push-test, relay, relay-echo-server, relay-echo-client, dht-server, dht-relay-server, dht-put-value, dht-get-value, dht-provide, dht-find-providers, pubsub-server, pubsub-client, autonat-server, autonat-client.

It prints PeerID: , Listening: , CircuitAddr: and Ready on stdout; GoProcessManager parses them and takes the 127.0.0.1 listen address. It is also a handy known-good peer by hand:

Terminal window
cd dart-libp2p/interop/go-peer && go build -o go-peer .
./go-peer --mode=ping --transport=udx --target=/ip4/127.0.0.1/udp/4001/udx/p2p/12D3KooW...

Follow the pattern the existing suites share: build the peer, start it, wait for a ready marker with a deadline, read its address from the output, and kill it in teardown whatever happens.

@Tags(['interop'])
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:test/test.dart';
void main() {
late Process peer;
late int port;
setUpAll(() async {
final build = await Process.run('go', ['build', '-o', 'interop-server', './cmd/interop-server/'],
workingDirectory: '../go-udx');
if (build.exitCode != 0) fail('Go build failed: ${build.stderr}');
peer = await Process.start('../go-udx/interop-server', []);
final ready = Completer<int>();
peer.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((line) {
print('[peer] $line'); // keep the peer's log in the test output
if (line.startsWith('READY ') && !ready.isCompleted) ready.complete(int.parse(line.substring(6)));
});
port = await ready.future.timeout(const Duration(seconds: 10));
});
tearDownAll(() async {
peer.kill();
await peer.exitCode;
});
test('round trip', () async { /* dial 127.0.0.1:$port */ }, timeout: const Timeout(Duration(seconds: 30)));
}

Rules that the existing tests learned the hard way:

  • Keep the child’s stdin open if it exits on EOF. dart-libp2p’s bin/interop_echo_server.dart stops when its stdin closes. From Go, take cmd.StdinPipe() and close it at the end of the test; without a pipe, stdin is /dev/null and the server stops as soon as it is ready.
  • Print the peer’s output into the test log (t.Logf("[dart-server] %s", line), print('[go-server] $line')). A failed interop test without the other side’s log is guesswork.
  • Bind to port 0 and read the port back (READY <port>, Listening: ...). Fixed ports collide between parallel runs.
  • Skip, do not fail, when a toolchain or sibling is missing, as js-udx (describe.skipIf(goPeerUnavailable() !== undefined)), go-udx’s bulk tests (exec.LookPath("dart") then t.Skip) and ricochet-dart-client (skip: await RicochetTestServer.available(), which returns a reason string) do. Put a reason in the skip message that says how to fix it.
  • Tag Dart interop tests with @Tags(['interop']) (or docker) and declare the tag in dart_test.yaml, so dart test -P ci leaves them out.
Future<Host> _host() async {
final keyPair = await generateEd25519KeyPair();
final host = await Libp2p.new_([
Libp2p.identity(keyPair),
Libp2p.transport(TCPTransport()),
Libp2p.security(await NoiseSecurity.create(keyPair)),
Libp2p.listenAddrs([MultiAddr('/ip4/127.0.0.1/tcp/0')]),
Libp2p.autoNAT(false),
Libp2p.relay(false),
Libp2p.autoRelay(false),
Libp2p.holePunching(false),
]);
await host.start();
return host;
}
// Dial with the listener's real addresses:
await a.connect(AddrInfo(b.id, b.network.listenAddresses));
// tearDown: await a.close(); await b.close();

(From dart-libp2p/test/network/dial_peer_id_mismatch_test.dart.) Use host.network.listenAddresses, not host.addrs: the default addrsFactory (defaultAddrsFactory in basic_host.dart) removes loopback and unspecified addresses, so host.addrs is empty on loopback. For UDX, listen on /ip4/127.0.0.1/udp/0/udx. Turn off AutoNAT, relay, AutoRelay and hole punching in tests that do not test them; they start timers and background dials. Always close() hosts in teardown: a live timer keeps the Dart process from exiting.

The test/real_net_stack.dart helper in dart-libp2p, dart-libp2p-pubsub and dart-libp2p-merkle-crdt builds a full UDX + Noise + Yamux node by hand with createLibp2pNode(...) and a passThroughAddrsFactory that keeps loopback addresses. Copy it when a test needs a hand-built Swarm/BasicHost. A hand-built host must call RecordRegistry.register<pb.PeerRecord>(...) itself, as test/integration/holepunch_network/containers/dart-peer/peer_main.dart does; Libp2p.new_ does that for you.

  • BLE: package:dart_libp2p_ble/testing.dart gives FakeBleNetwork and FakeBleRadio; pass a radio to BleTransport and the rest of the stack is real. FakeBleNetwork(maxValueLength:, latency:), setInRange, setRssi. No permissions, advertising limits or lost links: see dart-libp2p-ble.
  • Bitswap: dart-libp2p-bitswap/test/fake_swarm.dart (mockito mocks of host, network and peerstore that record every message).
  • dart-libp2p mocks: dart-libp2p/test/mocks/ (mock transport, connections, multiplexer, host, peerstore).
  • Time: dart-udx’s FakeClock (advance(Duration)) through UDXMultiplexer(socket, clock: ...) drives UDX timers by hand.
h, err := libp2p.New(
libp2p.NoTransports,
libp2p.Transport(udxtransport.NewTransport),
libp2p.Security(noise.ID, noise.New),
libp2p.Muxer(yamux.ID, yamux.DefaultTransport),
libp2p.ListenAddrStrings("/ip4/127.0.0.1/udp/0/udx"),
libp2p.ResourceManager(&network.NullResourceManager{}),
)

(From go-libp2p-udx-transport/host_test.go.) go-udx has NewMockClock in clock.go. go-p2p-forge’s forgetest package has NewMockStream, NewMockStreamWithFrame (preloaded request bytes) and NewPipeStream (blocking reads over net.Pipe, honours deadlines: use it to test timeouts). go-ricochet’s integration helpers (test/integration/helpers_test.go) start a real server per test with newTestServer(t) and skip without RICOCHET_TEST_POSTGRES_DSN.

js-udx tests run against workspace sources, so npm test needs no build (the examples project does use the build). simulation.test.ts shows a deterministic link with delay, loss and reordering on ManualClock, without sockets: the pattern for testing loss recovery without real time.

Setup What it does Run
dart-libp2p/test/integration/holepunch_network/ Two peers behind cone or symmetric NAT gateways, a relay, STUN; DCUtR dart test test/integration/holepunch_network/holepunch_network_integration_test.dart; scripts/run_dcutr_scenario.sh /tmp/out; scripts/run_dcutr_scenario.sh --go /tmp/out for a go-libp2p relay and peer (--go must come first)
dart-libp2p/test/integration/udx_dht_relay_netem/ DHT + relay under netem ./run_test.sh baseline|good-4g|poor-4g|terrible, or NETEM_DELAY, NETEM_JITTER, NETEM_LOSS
go-udx/interop/docker-test.sh Go peer in Docker with delay and reorder, Dart echo client ./docker-test.sh 50 25
go-udx/interop/docker-netem-fullstack.sh Same, plus loss, then dart-libp2p/bin/interop_netem_test.dart ./docker-netem-fullstack.sh 50 25 2
go-udx/interop/netem-baseline.sh go-udx congestion control over a matrix of delay/loss/reorder ./netem-baseline.sh 4194304 120
go-ricochet/test/nat/run.sh Two clients behind NAT routers, 1 MiB through the relay, then a hole punch ./run.sh; NAT_TYPE=symmetric ./run.sh
dart-libp2p-demos/internet_chat/docker/compose.yml Relay + DHT + GossipSub + DCUtR chat behind simulated NATs docker compose -f docker/compose.yml up --build; NAT_TYPE=symmetric
dart-libp2p-webrtc/docker/ coturn for TURN tests; linux-test.Dockerfile runs the suite on Linux docker build -f docker/linux-test.Dockerfile -t dart-libp2p-webrtc-test .

Facts these setups depend on:

  • The simulated internet is 100.70.0.0/16 for Dart-only runs, because MultiAddr.isPublic() rejects RFC 1918 ranges and DCUtR would have no addresses. The go-libp2p variant uses 11.70.0.0/16, because go-libp2p treats 100.64.0.0/10 as private and will not hole punch from it.
  • The NAT gateways add WAN_DELAY (default 50ms). With no delay, one punch always arrives before the other leaves and Linux conntrack rewrites the port, so the punch fails however well it is timed. A failed punch leaves conntrack entries that make a retry within 30 s fail too.
  • Cold starts need 15 to 20 s before NAT rules and relay connections exist.
  • netem on lo delays each direction, so delay Xms gives about 2X RTT. tc needs --cap-add=NET_ADMIN.
  • Peer containers take DEBUG_LOGGERS=p2p-holepunch,UDXTransport and serve GET /conns with a relayed flag per connection.
  • Give every network test an explicit timeout that is longer than the library’s own: dart_libp2p’s dial timeout is 15 s (30 s through a relay), the multistream read timeout 30 s. A test timeout shorter than these hides the real error behind TimeoutException.
  • Wait for a condition, not for a fixed sleep. js-udx’s tests poll with until(cond, timeoutMs); Dart tests wait on futures and streams with .timeout(...).
  • dart-libp2p’s CI preset retries a failed test twice (retry: 2) because some network tests depend on timing on slow runners. Do not add retries to hide a deterministic failure: run the test 10 times locally first (go test -count=10, or a shell loop around dart test).
  • Two test files that each start a Ricochet server time out when they run in parallel; ricochet-dart-client sets concurrency: 1 in dart_test.yaml. Do the same when tests share a fixed port, a database or a Docker setup.
  • Hole-punch and NAT results vary by run. The harness counts passes over several runs; treat one failed punch as data, not as a bug, unless it repeats.
  • Idle and keepalive tests take tens of seconds. Gate them behind an env var (UDX_SLOW_TESTS=1) or -short (Go), as the repos do.
  • dart-libp2p (.github/workflows/ci.yml): Dart 3.6 and stable, dart pub get, dart analyze (warnings fail), dart test -P ci; a second job removes dev_dependencies and runs dart analyze lib on 3.5 to check the minimum SDK. Interop and Docker suites do not run in CI.
  • js-udx (check.yml): Node 22.18, npm ci, npm run ci. The interop tests skip themselves because the sibling repos are not checked out.
  • go-ricochet (ci.yml): PostgreSQL 16 as a service, GOTOOLCHAIN: local, go build, go vet, README example compile check, govulncheck (informational), load schema.sql, go test -count=1 ./... with RICOCHET_TEST_POSTGRES_DSN. release.yml calls it.
  • The other repos have no workflow: run their suites locally before a release.

A new interop suite should follow the same rule: CI runs what needs only the repo itself; cross-repo, Docker and browser suites are tagged or skip themselves, and the README of the suite says how to run it.

  • Some interop tests fail instead of skipping. dart-udx’s go_udx_interop_test.dart, go-udx’s TestGoDartInterop, go-libp2p-udx-transport’s TestInterop* and the kad-dht and pubsub interop tests have no skip: plain dart test or go test ./... fails without the sibling repo and toolchain. Exclude them by path when you only want unit tests (dart test test/<dir>, go test -run '^TestHost').
  • go-libp2p cannot parse /udx unless udxtransport is imported. The package registers the protocol (code 0x0300) in init(). A Go test helper that only parses addresses still needs the import.
  • Both ends must speak UDX wire version 3. A v2 peer’s packets are dropped, so the test sees a dial timeout, not a protocol error. Upgrade every sibling together.
  • Crossing dials. dart_udx 4.0.0+ keeps crossing dials as two connections, like go-udx and js-udx. A test that expects two simultaneous createSocket dials to meet as one connection is wrong.
  • Relay limits cut data tests. A go-libp2p relay’s defaults limit a relayed connection to 2 minutes and 128 KiB. A 1 MiB echo through a go-libp2p relay fails by design; go-ricochet’s relay has no limit unless relay_limits sets one.
  • Only the dialer’s identify is done when connect() returns. A Dart connect() waits for its own identify (or throws IdentifyTimeoutException); the listener identifies the dialer on its own schedule. On the listening side, wait for the peerstore or an event before asserting on the dialer’s protocols or addresses.
  • The Go peer’s Docker image (go-udx/interop/Dockerfile) rewrites the replace paths with sed; it builds from the parent directory of all checkouts, so run the scripts from their own directory.
  • Never change a wire format (UDX frames, Noise, Yamux, protobuf messages) to make one side’s test pass. Fix the side that is wrong and check it against the other two implementations.
  • Never commit a pubspec_overrides.yaml, a local replace that points outside the repo, or a built peer binary.
  • Never leave a child process or Docker container running after a test: kill it in tearDown/defer, and use trap ... EXIT in scripts.
  • Never weaken a test (longer sleeps, more retries, skipped assertions) to hide a failure you have not explained.
  • Do not point tests at public relays or STUN servers; the Docker setups use internal ones so results are repeatable.

Built by Stephan Februarygithub.com/stephanfebLinkedIn