From 1301710a911e146c32456f75fc509b2663ec753e Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Thu, 8 Jan 2026 05:07:08 +0100 Subject: [PATCH] fix(ci): parallelize gotest, cleanup output, flakiness (#11113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: parallelize gotest by separating test/cli into own job split the Go Test workflow into two parallel jobs: - `unit-tests`: runs unit tests (excluding test/cli) - `cli-tests`: runs test/cli end-to-end tests test/cli takes ~3 minutes (~50% of total gotest time), so running it in parallel should reduce wall-clock CI time by ~1.5-2.5 minutes. both jobs produce JUnit XML and HTML reports for consistent debugging. * ci(gotest): reduce noise on test timeout panics add GOTRACEBACK=single to show only one goroutine stack instead of all when a test timeout panic occurs. this makes CI output much cleaner when tests hang. * fix(ci): prevent stderr from corrupting test JSON output - remove 2>&1 which mixed "go: downloading" stderr messages into JSON - add JSON validation before parsing - print failed test names for easier debugging * ci(gotest): use gotestsum for human-readable test output - replace per-package coverage loop with single gotestsum invocation - both unit-tests and cli-tests now show human-readable output - simplified coverage collection (single -coverprofile, no gocovmerge) - clarified step names to indicate they run tests * ci: fix codecov uploads by adding token - add CODECOV_TOKEN to gotest.yml and sharness.yml - update codecov-action to v5.5.2 - add fail_ci_if_error: false for robustness codecov stopped receiving coverage data ~1 year ago when they started requiring tokens for public repos * refactor(make): add test_unit and test_cli targets - add `make test_unit` for unit tests with coverage (used by CI) - add `make test_cli` for CLI integration tests (used by CI) - only disable colors when CI env var is set (local dev gets colors) - remove legacy targets: test_go_test, test_go_short, test_go_race, test_go_expensive - update gotest.yml to use make targets instead of inline commands - add test artifacts to .gitignore * fix(ci): move client/rpc tests to cli-tests job client/rpc tests use test/cli/harness which requires the ipfs binary. Move them from test_unit to test_cli where the binary is built. also: - update gotestsum to v1.13.0 - simplify workflow step names * fix(ci): use build tags when listing test packages go list needs build tags to properly exclude packages like fuse/mfs when running with TEST_FUSE=0 (nofuse tag). * fix(ci): move test/integration to cli-tests job test/integration tests need the ipfs binary, move them from test_unit to test_cli. * fix(test): fix flaky kubo-as-a-library and GetClosestPeers tests kubo-as-a-library: use `Bootstrap()` instead of raw `Swarm().Connect()` to fix race condition between swarm connection and bitswap peer discovery. `Bootstrap()` properly integrates peers into the routing system, ensuring bitswap learns about connected peers synchronously. GetClosestPeers: simplify retry logic using `EventuallyWithT` with 10-minute timeout. tests all 4 routing types (`auto`, `autoclient`, `dht`, `dhtclient`) against real bootstrap peers with patient polling. * fix(example): use bidirectional Swarm().Connect() for reliable bitswap - connect nodes bidirectionally (A→B and B→A) to simulate mutual peering - mutual peering protects connection from resource manager culling - use port 0 for random available ports (avoids CI conflicts) - enable LoopbackAddressesOnLanDHT for local testing - move retry logic to test file using require.Eventually * fix(ci): add test_examples target and parallel example-tests job - add `make test_examples` target to mk/golang.mk for consistency with test_unit/test_cli - move example tests to separate parallel CI job (example-tests) - example: use Bootstrap() with autoconf.FallbackBootstrapPeers for reliable bitswap - example: increase context timeout to 10 minutes - test: add 60s per-request timeout to GetClosestPeers (server has 30s routing timeout) - test: reduce EventuallyWithT to 3 minutes (locally passes in under 1 minute) * fix(ci): improve test targets, exclusion patterns, and artifact naming - define COVERPKG_EXCLUDE and UNIT_EXCLUDE as documented variables - use grep -vE with single regex instead of multiple grep -v calls - add mkdir -p before rm to ensure directories exist - add DEPS_GO dependency to test_cli target - make CLI test timeout configurable via TEST_CLI_TIMEOUT (default 10m) - fix test_examples cleanup on failure using subshell - reduce GetClosestPeers test wait time from 3m to 2m - rename artifacts to match job names: unit-tests-{junit,html}, cli-tests-{junit,html} - update cli-tests upload-artifact from v5 to v6 * fix(ci): fix unit test exclusion and speed up example test - fix UNIT_EXCLUDE regex to match client/rpc at end of path - remove public bootstrap peers from example (only connect to nodeA) - example test now runs in ~3s instead of timing out * fix(test): fix flaky TestAddMultipleGCLive race condition added time.Sleep after spawning GC goroutines to ensure they reach GCLock() before the test proceeds. without this, the adder's maybePauseForGC() might check GCRequested() before GC has even requested the lock, causing the lock to not be released and GC to block indefinitely. this matches the existing pattern in TestAddGCLive which already had this sleep. also replaced context.Background() with t.Context() in both TestAddMultipleGCLive and TestAddGCLive for proper test lifecycle management. * fix(example): use test harness settings for reliable CI the kubo-as-a-library example was flaky on CI. applied test-harness-like settings that match what transports_test.go uses: - TCP-only on 127.0.0.1 with random port (no QUIC/UDP) - explicitly disable non-TCP transports (QUIC, Relay, WebTransport, etc) - use NilRouterOption (no routing) since we connect peers directly - bitswap works with directly connected peers without DHT lookups - 2-minute context timeout - streaming output in test for debugging --- .github/workflows/gotest.yml | 124 +++++++++++++----- .github/workflows/sharness.yml | 2 + .gitignore | 5 + Rules.mk | 15 +-- core/coreunix/add_test.go | 29 ++-- coverage/Rules.mk | 27 +--- docs/examples/kubo-as-a-library/main.go | 73 ++++++----- docs/examples/kubo-as-a-library/main_test.go | 34 +++-- mk/golang.mk | 63 +++++---- .../delegated_routing_v1_http_server_test.go | 63 ++++----- test/dependencies/go.mod | 2 +- test/dependencies/go.sum | 4 +- test/unit/Rules.mk | 3 +- 13 files changed, 262 insertions(+), 182 deletions(-) diff --git a/.github/workflows/gotest.yml b/.github/workflows/gotest.yml index 9f329cbfd..8165eb12a 100644 --- a/.github/workflows/gotest.yml +++ b/.github/workflows/gotest.yml @@ -14,11 +14,13 @@ concurrency: cancel-in-progress: true jobs: - go-test: + # Unit tests with coverage collection (uploaded to Codecov) + unit-tests: if: github.repository == 'ipfs/kubo' || github.event_name == 'workflow_dispatch' runs-on: ${{ fromJSON(github.repository == 'ipfs/kubo' && '["self-hosted", "linux", "x64", "2xlarge"]' || '"ubuntu-latest"') }} - timeout-minutes: 20 + timeout-minutes: 15 env: + GOTRACEBACK: single # reduce noise on test timeout panics TEST_DOCKER: 0 TEST_FUSE: 0 TEST_VERBOSE: 1 @@ -36,12 +38,9 @@ jobs: go-version-file: 'go.mod' - name: Install missing tools run: sudo apt update && sudo apt install -y zsh - - name: 👉️ If this step failed, go to «Summary» (top left) → inspect the «Failures/Errors» table - env: - # increasing parallelism beyond 2 doesn't speed up the tests much - PARALLEL: 2 + - name: Run unit tests run: | - make -j "$PARALLEL" test/unit/gotest.junit.xml && + make test_unit && [[ ! $(jq -s -c 'map(select(.Action == "fail")) | .[]' test/unit/gotest.json) ]] - name: Upload coverage to Codecov uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 @@ -49,28 +48,8 @@ jobs: with: name: unittests files: coverage/unit_tests.coverprofile - - name: Test kubo-as-a-library example - run: | - # we want to first test with the kubo version in the go.mod file - go test -v ./... - - # we also want to test the examples against the current version of kubo - # however, that version might be in a fork so we need to replace the dependency - - # backup the go.mod and go.sum files to restore them after we run the tests - cp go.mod go.mod.bak - cp go.sum go.sum.bak - - # make sure the examples run against the current version of kubo - go mod edit -replace github.com/ipfs/kubo=./../../.. - go mod tidy - - go test -v ./... - - # restore the go.mod and go.sum files to their original state - mv go.mod.bak go.mod - mv go.sum.bak go.sum - working-directory: docs/examples/kubo-as-a-library + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false - name: Create a proper JUnit XML report uses: ipdxco/gotest-json-to-junit-xml@v1 with: @@ -80,7 +59,7 @@ jobs: - name: Archive the JUnit XML report uses: actions/upload-artifact@v6 with: - name: unit + name: unit-tests-junit path: test/unit/gotest.junit.xml if: failure() || success() - name: Create a HTML report @@ -93,7 +72,7 @@ jobs: - name: Archive the HTML report uses: actions/upload-artifact@v6 with: - name: html + name: unit-tests-html path: test/unit/gotest.html if: failure() || success() - name: Create a Markdown report @@ -106,3 +85,86 @@ jobs: - name: Set the summary run: cat test/unit/gotest.md >> $GITHUB_STEP_SUMMARY if: failure() || success() + + # End-to-end integration/regression tests from test/cli + # (Go-based replacement for legacy test/sharness shell scripts) + cli-tests: + if: github.repository == 'ipfs/kubo' || github.event_name == 'workflow_dispatch' + runs-on: ${{ fromJSON(github.repository == 'ipfs/kubo' && '["self-hosted", "linux", "x64", "2xlarge"]' || '"ubuntu-latest"') }} + timeout-minutes: 15 + env: + GOTRACEBACK: single # reduce noise on test timeout panics + TEST_VERBOSE: 1 + GIT_PAGER: cat + IPFS_CHECK_RCMGR_DEFAULTS: 1 + defaults: + run: + shell: bash + steps: + - name: Check out Kubo + uses: actions/checkout@v6 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + - name: Install missing tools + run: sudo apt update && sudo apt install -y zsh + - name: Run CLI tests + env: + IPFS_PATH: ${{ runner.temp }}/ipfs-test + run: make test_cli + - name: Create JUnit XML report + uses: ipdxco/gotest-json-to-junit-xml@v1 + with: + input: test/cli/cli-tests.json + output: test/cli/cli-tests.junit.xml + if: failure() || success() + - name: Archive JUnit XML report + uses: actions/upload-artifact@v6 + with: + name: cli-tests-junit + path: test/cli/cli-tests.junit.xml + if: failure() || success() + - name: Create HTML report + uses: ipdxco/junit-xml-to-html@v1 + with: + mode: no-frames + input: test/cli/cli-tests.junit.xml + output: test/cli/cli-tests.html + if: failure() || success() + - name: Archive HTML report + uses: actions/upload-artifact@v6 + with: + name: cli-tests-html + path: test/cli/cli-tests.html + if: failure() || success() + - name: Create Markdown report + uses: ipdxco/junit-xml-to-html@v1 + with: + mode: summary + input: test/cli/cli-tests.junit.xml + output: test/cli/cli-tests.md + if: failure() || success() + - name: Set summary + run: cat test/cli/cli-tests.md >> $GITHUB_STEP_SUMMARY + if: failure() || success() + + # Example tests (kubo-as-a-library) + example-tests: + if: github.repository == 'ipfs/kubo' || github.event_name == 'workflow_dispatch' + runs-on: ${{ fromJSON(github.repository == 'ipfs/kubo' && '["self-hosted", "linux", "x64", "2xlarge"]' || '"ubuntu-latest"') }} + timeout-minutes: 5 + env: + GOTRACEBACK: single + defaults: + run: + shell: bash + steps: + - name: Check out Kubo + uses: actions/checkout@v6 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + - name: Run example tests + run: make test_examples diff --git a/.github/workflows/sharness.yml b/.github/workflows/sharness.yml index 7275be8ef..ac32bf3a4 100644 --- a/.github/workflows/sharness.yml +++ b/.github/workflows/sharness.yml @@ -60,6 +60,8 @@ jobs: with: name: sharness files: kubo/coverage/sharness_tests.coverprofile + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false - name: Aggregate results run: find kubo/test/sharness/test-results -name 't*-*.sh.*.counts' | kubo/test/sharness/lib/sharness/aggregate-results.sh > kubo/test/sharness/test-results/summary.txt - name: 👉️ If this step failed, go to «Summary» (top left) → «HTML Report» → inspect the «Failures» column diff --git a/.gitignore b/.gitignore index cb147456b..890870a6e 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,11 @@ go-ipfs-source.tar.gz docs/examples/go-ipfs-as-a-library/example-folder/Qm* /test/sharness/t0054-dag-car-import-export-data/*.car +# test artifacts from make test_unit / test_cli +/test/unit/gotest.json +/test/unit/gotest.junit.xml +/test/cli/cli-tests.json + # ignore build output from snapcraft /ipfs_*.snap /parts diff --git a/Rules.mk b/Rules.mk index d8f16ada8..b04e3d73e 100644 --- a/Rules.mk +++ b/Rules.mk @@ -134,15 +134,14 @@ help: @echo '' @echo 'TESTING TARGETS:' @echo '' - @echo ' test - Run all tests' - @echo ' test_short - Run short go tests and short sharness tests' - @echo ' test_go_short - Run short go tests' - @echo ' test_go_test - Run all go tests' + @echo ' test - Run all tests (test_go_fmt, test_unit, test_cli, test_sharness)' + @echo ' test_short - Run fast tests (test_go_fmt, test_unit)' + @echo ' test_unit - Run unit tests with coverage (excludes test/cli)' + @echo ' test_cli - Run CLI integration tests (requires built binary)' + @echo ' test_go_fmt - Check Go source formatting' @echo ' test_go_build - Build kubo for all platforms from .github/build-platforms.yml' - @echo ' test_go_expensive - Run all go tests and build all platforms' - @echo ' test_go_race - Run go tests with the race detector enabled' - @echo ' test_go_lint - Run the `golangci-lint` vetting tool' + @echo ' test_go_lint - Run golangci-lint' @echo ' test_sharness - Run sharness tests' - @echo ' coverage - Collects coverage info from unit tests and sharness' + @echo ' coverage - Collect coverage info from unit tests and sharness' @echo .PHONY: help diff --git a/core/coreunix/add_test.go b/core/coreunix/add_test.go index a11dd13e4..d5b06176a 100644 --- a/core/coreunix/add_test.go +++ b/core/coreunix/add_test.go @@ -30,6 +30,7 @@ import ( const testPeerID = "QmTFauExutTsy4XP6JbMFcw2Wa9645HJt2bTqL6qYDCKfe" func TestAddMultipleGCLive(t *testing.T) { + ctx := t.Context() r := &repo.Mock{ C: config.Config{ Identity: config.Identity{ @@ -38,13 +39,13 @@ func TestAddMultipleGCLive(t *testing.T) { }, D: syncds.MutexWrap(datastore.NewMapDatastore()), } - node, err := core.NewNode(context.Background(), &core.BuildCfg{Repo: r}) + node, err := core.NewNode(ctx, &core.BuildCfg{Repo: r}) if err != nil { t.Fatal(err) } out := make(chan interface{}, 10) - adder, err := NewAdder(context.Background(), node.Pinning, node.Blockstore, node.DAG) + adder, err := NewAdder(ctx, node.Pinning, node.Blockstore, node.DAG) if err != nil { t.Fatal(err) } @@ -67,7 +68,7 @@ func TestAddMultipleGCLive(t *testing.T) { go func() { defer close(out) - _, _ = adder.AddAllAndPin(context.Background(), slf) + _, _ = adder.AddAllAndPin(ctx, slf) // Ignore errors for clarity - the real bug would be gc'ing files while adding them, not this resultant error }() @@ -80,9 +81,12 @@ func TestAddMultipleGCLive(t *testing.T) { gc1started := make(chan struct{}) go func() { defer close(gc1started) - gc1out = gc.GC(context.Background(), node.Blockstore, node.Repo.Datastore(), node.Pinning, nil) + gc1out = gc.GC(ctx, node.Blockstore, node.Repo.Datastore(), node.Pinning, nil) }() + // Give GC goroutine time to reach GCLock (will block there waiting for adder) + time.Sleep(time.Millisecond * 100) + // GC shouldn't get the lock until after the file is completely added select { case <-gc1started: @@ -119,9 +123,12 @@ func TestAddMultipleGCLive(t *testing.T) { gc2started := make(chan struct{}) go func() { defer close(gc2started) - gc2out = gc.GC(context.Background(), node.Blockstore, node.Repo.Datastore(), node.Pinning, nil) + gc2out = gc.GC(ctx, node.Blockstore, node.Repo.Datastore(), node.Pinning, nil) }() + // Give GC goroutine time to reach GCLock + time.Sleep(time.Millisecond * 100) + select { case <-gc2started: t.Fatal("gc shouldn't have started yet") @@ -155,6 +162,7 @@ func TestAddMultipleGCLive(t *testing.T) { } func TestAddGCLive(t *testing.T) { + ctx := t.Context() r := &repo.Mock{ C: config.Config{ Identity: config.Identity{ @@ -163,13 +171,13 @@ func TestAddGCLive(t *testing.T) { }, D: syncds.MutexWrap(datastore.NewMapDatastore()), } - node, err := core.NewNode(context.Background(), &core.BuildCfg{Repo: r}) + node, err := core.NewNode(ctx, &core.BuildCfg{Repo: r}) if err != nil { t.Fatal(err) } out := make(chan interface{}) - adder, err := NewAdder(context.Background(), node.Pinning, node.Blockstore, node.DAG) + adder, err := NewAdder(ctx, node.Pinning, node.Blockstore, node.DAG) if err != nil { t.Fatal(err) } @@ -193,7 +201,7 @@ func TestAddGCLive(t *testing.T) { go func() { defer close(addDone) defer close(out) - _, err := adder.AddAllAndPin(context.Background(), slf) + _, err := adder.AddAllAndPin(ctx, slf) if err != nil { t.Error(err) } @@ -211,7 +219,7 @@ func TestAddGCLive(t *testing.T) { gcstarted := make(chan struct{}) go func() { defer close(gcstarted) - gcout = gc.GC(context.Background(), node.Blockstore, node.Repo.Datastore(), node.Pinning, nil) + gcout = gc.GC(ctx, node.Blockstore, node.Repo.Datastore(), node.Pinning, nil) }() // gc shouldn't start until we let the add finish its current file. @@ -255,9 +263,6 @@ func TestAddGCLive(t *testing.T) { last = c } - ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) - defer cancel() - set := cid.NewSet() err = dag.Walk(ctx, dag.GetLinksWithDAG(node.DAG), last, set.Visit) if err != nil { diff --git a/coverage/Rules.mk b/coverage/Rules.mk index 48fce2856..84a4a1887 100644 --- a/coverage/Rules.mk +++ b/coverage/Rules.mk @@ -3,33 +3,14 @@ include mk/header.mk GOCC ?= go $(d)/coverage_deps: $$(DEPS_GO) cmd/ipfs/ipfs - rm -rf $(@D)/unitcover && mkdir $(@D)/unitcover rm -rf $(@D)/sharnesscover && mkdir $(@D)/sharnesscover -ifneq ($(IPFS_SKIP_COVER_BINS),1) -$(d)/coverage_deps: test/bin/gocovmerge -endif - .PHONY: $(d)/coverage_deps -# unit tests coverage -UTESTS_$(d) := $(shell $(GOCC) list -f '{{if (or (len .TestGoFiles) (len .XTestGoFiles))}}{{.ImportPath}}{{end}}' $(go-flags-with-tags) ./... | grep -v go-ipfs/vendor | grep -v go-ipfs/Godeps) +# unit tests coverage is now produced by test_unit target in mk/golang.mk +# (outputs coverage/unit_tests.coverprofile and test/unit/gotest.json) -UCOVER_$(d) := $(addsuffix .coverprofile,$(addprefix $(d)/unitcover/, $(subst /,_,$(UTESTS_$(d))))) - -$(UCOVER_$(d)): $(d)/coverage_deps ALWAYS - $(eval TMP_PKG := $(subst _,/,$(basename $(@F)))) - $(eval TMP_DEPS := $(shell $(GOCC) list -f '{{range .Deps}}{{.}} {{end}}' $(go-flags-with-tags) $(TMP_PKG) | sed 's/ /\n/g' | grep ipfs/go-ipfs) $(TMP_PKG)) - $(eval TMP_DEPS_LIST := $(call join-with,$(comma),$(TMP_DEPS))) - $(GOCC) test $(go-flags-with-tags) $(GOTFLAGS) -v -covermode=atomic -json -coverpkg=$(TMP_DEPS_LIST) -coverprofile=$@ $(TMP_PKG) | tee -a test/unit/gotest.json - - -$(d)/unit_tests.coverprofile: $(UCOVER_$(d)) - gocovmerge $^ > $@ - -TGTS_$(d) := $(d)/unit_tests.coverprofile - -.PHONY: $(d)/unit_tests.coverprofile +TGTS_$(d) := # sharness tests coverage $(d)/ipfs: GOTAGS += testrunmain @@ -46,7 +27,7 @@ endif export IPFS_COVER_DIR:= $(realpath $(d))/sharnesscover/ $(d)/sharness_tests.coverprofile: export TEST_PLUGIN=0 -$(d)/sharness_tests.coverprofile: $(d)/ipfs cmd/ipfs/ipfs-test-cover $(d)/coverage_deps test_sharness +$(d)/sharness_tests.coverprofile: $(d)/ipfs cmd/ipfs/ipfs-test-cover $(d)/coverage_deps test/bin/gocovmerge test_sharness (cd $(@D)/sharnesscover && find . -type f | gocovmerge -list -) > $@ diff --git a/docs/examples/kubo-as-a-library/main.go b/docs/examples/kubo-as-a-library/main.go index ffa86c7f0..8b2181ed7 100644 --- a/docs/examples/kubo-as-a-library/main.go +++ b/docs/examples/kubo-as-a-library/main.go @@ -47,7 +47,7 @@ func setupPlugins(externalPluginsPath string) error { return nil } -func createTempRepo(swarmPort int) (string, error) { +func createTempRepo() (string, error) { repoPath, err := os.MkdirTemp("", "ipfs-shell") if err != nil { return "", fmt.Errorf("failed to get temp dir: %s", err) @@ -59,15 +59,28 @@ func createTempRepo(swarmPort int) (string, error) { return "", err } - // Configure custom ports to avoid conflicts with other IPFS instances. - // This demonstrates how to customize the node's network addresses. + // Use TCP-only on loopback with random port for reliable local testing. + // This matches what kubo's test harness uses (test/cli/transports_test.go). + // QUIC/UDP transports are avoided because they may be throttled on CI. cfg.Addresses.Swarm = []string{ - fmt.Sprintf("/ip4/0.0.0.0/tcp/%d", swarmPort), - fmt.Sprintf("/ip4/0.0.0.0/udp/%d/quic-v1", swarmPort), - fmt.Sprintf("/ip4/0.0.0.0/udp/%d/quic-v1/webtransport", swarmPort), - fmt.Sprintf("/ip4/0.0.0.0/udp/%d/webrtc-direct", swarmPort), + "/ip4/127.0.0.1/tcp/0", } + // Explicitly disable non-TCP transports for reliability. + cfg.Swarm.Transports.Network.QUIC = config.False + cfg.Swarm.Transports.Network.Relay = config.False + cfg.Swarm.Transports.Network.WebTransport = config.False + cfg.Swarm.Transports.Network.WebRTCDirect = config.False + cfg.Swarm.Transports.Network.Websocket = config.False + cfg.AutoTLS.Enabled = config.False + + // Disable routing - we don't need DHT for direct peer connections. + // Bitswap works with directly connected peers without needing DHT lookups. + cfg.Routing.Type = config.NewOptionalString("none") + + // Disable bootstrap for this example - we manually connect only the peers we need. + cfg.Bootstrap = []string{} + // When creating the repository, you can define custom settings on the repository, such as enabling experimental // features (See experimental-features.md) or customizing the gateway endpoint. // To do such things, you should modify the variable `cfg`. For example: @@ -106,10 +119,14 @@ func createNode(ctx context.Context, repoPath string) (*core.IpfsNode, error) { // Construct the node nodeOptions := &core.BuildCfg{ - Online: true, - Routing: libp2p.DHTOption, // This option sets the node to be a full DHT node (both fetching and storing DHT Records) - // Routing: libp2p.DHTClientOption, // This option sets the node to be a client DHT node (only fetching records) - Repo: repo, + Online: true, + // For this example, we use NilRouterOption (no routing) since we connect peers directly. + // Bitswap works with directly connected peers without needing DHT lookups. + // In production, you would typically use: + // Routing: libp2p.DHTOption, // Full DHT node (stores and fetches records) + // Routing: libp2p.DHTClientOption, // DHT client (only fetches records) + Routing: libp2p.NilRouterOption, + Repo: repo, } return core.NewNode(ctx, nodeOptions) @@ -118,8 +135,7 @@ func createNode(ctx context.Context, repoPath string) (*core.IpfsNode, error) { var loadPluginsOnce sync.Once // Spawns a node to be used just for this run (i.e. creates a tmp repo). -// The swarmPort parameter specifies the port for libp2p swarm listeners. -func spawnEphemeral(ctx context.Context, swarmPort int) (icore.CoreAPI, *core.IpfsNode, error) { +func spawnEphemeral(ctx context.Context) (icore.CoreAPI, *core.IpfsNode, error) { var onceErr error loadPluginsOnce.Do(func() { onceErr = setupPlugins("") @@ -129,7 +145,7 @@ func spawnEphemeral(ctx context.Context, swarmPort int) (icore.CoreAPI, *core.Ip } // Create a Temporary Repo - repoPath, err := createTempRepo(swarmPort) + repoPath, err := createTempRepo() if err != nil { return nil, nil, fmt.Errorf("failed to create temp repo: %s", err) } @@ -207,8 +223,7 @@ func main() { defer cancel() // Spawn a local peer using a temporary path, for testing purposes - // Using port 4010 to avoid conflict with default IPFS port 4001 - ipfsA, nodeA, err := spawnEphemeral(ctx, 4010) + ipfsA, nodeA, err := spawnEphemeral(ctx) if err != nil { panic(fmt.Errorf("failed to spawn peer node: %s", err)) } @@ -222,9 +237,8 @@ func main() { fmt.Printf("Added file to peer with CID %s\n", peerCidFile.String()) // Spawn a node using a temporary path, creating a temporary repo for the run - // Using port 4011 (different from nodeA's port 4010) fmt.Println("Spawning Kubo node on a temporary repo") - ipfsB, _, err := spawnEphemeral(ctx, 4011) + ipfsB, _, err := spawnEphemeral(ctx) if err != nil { panic(fmt.Errorf("failed to spawn ephemeral node: %s", err)) } @@ -297,11 +311,12 @@ func main() { fmt.Printf("Got directory back from IPFS (IPFS path: %s) and wrote it to %s\n", cidDirectory.String(), outputPathDirectory) - /// --- Part IV: Getting a file from the IPFS Network + /// --- Part IV: Getting a file from another IPFS node - fmt.Println("\n-- Going to connect to a few nodes in the Network as bootstrappers --") + fmt.Println("\n-- Connecting to nodeA and fetching content via bitswap --") - // Get nodeA's address so we can fetch the file we added to it + // Get nodeA's actual listening address dynamically. + // We configured TCP-only on 127.0.0.1 with random port, so this will be a TCP address. peerAddrs, err := ipfsA.Swarm().LocalAddrs(ctx) if err != nil { panic(fmt.Errorf("could not get peer addresses: %s", err)) @@ -309,26 +324,18 @@ func main() { peerMa := peerAddrs[0].String() + "/p2p/" + nodeA.Identity.String() bootstrapNodes := []string{ - // In production, use autoconf.FallbackBootstrapPeers from boxo/autoconf - // which includes well-known IPFS bootstrap peers like: + // In production, use real bootstrap peers like: // "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN", - // "/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa", - // "/ip4/104.131.131.82/tcp/4001/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ", - - // You can add custom peers here. For example, another IPFS node: - // "/ip4/192.0.2.1/tcp/4001/p2p/QmYourPeerID...", - // "/ip4/192.0.2.1/udp/4001/quic-v1/p2p/QmYourPeerID...", - - // nodeA's address (the peer we created above that has our test file) + // For this example, we only connect to nodeA which has our test content. peerMa, } - fmt.Println("Connecting to peers...") + fmt.Println("Connecting to peer...") err = connectToPeers(ctx, ipfsB, bootstrapNodes) if err != nil { panic(fmt.Errorf("failed to connect to peers: %s", err)) } - fmt.Println("Connected to peers") + fmt.Println("Connected to peer") exampleCIDStr := peerCidFile.RootCid().String() diff --git a/docs/examples/kubo-as-a-library/main_test.go b/docs/examples/kubo-as-a-library/main_test.go index be601c6a9..ecc2a592a 100644 --- a/docs/examples/kubo-as-a-library/main_test.go +++ b/docs/examples/kubo-as-a-library/main_test.go @@ -1,21 +1,39 @@ package main import ( + "bytes" + "io" + "os" "os/exec" "strings" "testing" + "time" ) func TestExample(t *testing.T) { - out, err := exec.Command("go", "run", "main.go").Output() + t.Log("Starting go run main.go...") + start := time.Now() + + cmd := exec.Command("go", "run", "main.go") + cmd.Env = append(os.Environ(), "GOLOG_LOG_LEVEL=error") // reduce libp2p noise + + // Stream output to both test log and capture buffer for verification + // This ensures we see progress even if the process is killed + var buf bytes.Buffer + cmd.Stdout = io.MultiWriter(os.Stdout, &buf) + cmd.Stderr = io.MultiWriter(os.Stderr, &buf) + + err := cmd.Run() + + elapsed := time.Since(start) + t.Logf("Command completed in %v", elapsed) + + out := buf.String() if err != nil { - var stderr string - if xe, ok := err.(*exec.ExitError); ok { - stderr = string(xe.Stderr) - } - t.Fatalf("running example (%v): %s\n%s", err, string(out), stderr) + t.Fatalf("running example (%v):\n%s", err, out) } - if !strings.Contains(string(out), "All done!") { - t.Errorf("example did not run successfully") + + if !strings.Contains(out, "All done!") { + t.Errorf("example did not complete successfully, output:\n%s", out) } } diff --git a/mk/golang.mk b/mk/golang.mk index b50179a0a..53bf5fca2 100644 --- a/mk/golang.mk +++ b/mk/golang.mk @@ -41,40 +41,57 @@ define go-build $(GOCC) build $(go-flags-with-tags) -o "$@" "$(1)" endef -test_go_test: $$(DEPS_GO) - $(GOCC) test $(go-flags-with-tags) $(GOTFLAGS) ./... -.PHONY: test_go_test +# Only disable colors when running in CI (non-interactive terminal) +GOTESTSUM_NOCOLOR := $(if $(CI),--no-color,) -# Build all platforms from .github/build-platforms.yml +# Packages excluded from coverage (test code and examples are not production code) +COVERPKG_EXCLUDE := /(test|docs/examples)/ + +# Packages excluded from unit tests: coverage exclusions + client/rpc (tested by test_cli) +UNIT_EXCLUDE := /(test|docs/examples)/|/client/rpc$$ + +# Unit tests with coverage +# Produces JSON for CI reporting and coverage profile for Codecov +test_unit: test/bin/gotestsum $$(DEPS_GO) + mkdir -p test/unit coverage + rm -f test/unit/gotest.json coverage/unit_tests.coverprofile + gotestsum $(GOTESTSUM_NOCOLOR) --jsonfile test/unit/gotest.json -- $(go-flags-with-tags) $(GOTFLAGS) -covermode=atomic -coverprofile=coverage/unit_tests.coverprofile -coverpkg=$$($(GOCC) list $(go-tags) ./... | grep -vE '$(COVERPKG_EXCLUDE)' | tr '\n' ',' | sed 's/,$$//') $$($(GOCC) list $(go-tags) ./... | grep -vE '$(UNIT_EXCLUDE)') +.PHONY: test_unit + +# CLI/integration tests (requires built binary in PATH) +# Includes test/cli, test/integration, and client/rpc +# Produces JSON for CI reporting +# Override TEST_CLI_TIMEOUT for local development: make test_cli TEST_CLI_TIMEOUT=5m +TEST_CLI_TIMEOUT ?= 10m +test_cli: cmd/ipfs/ipfs test/bin/gotestsum $$(DEPS_GO) + mkdir -p test/cli + rm -f test/cli/cli-tests.json + PATH="$(CURDIR)/cmd/ipfs:$(CURDIR)/test/bin:$$PATH" gotestsum $(GOTESTSUM_NOCOLOR) --jsonfile test/cli/cli-tests.json -- -v -timeout=$(TEST_CLI_TIMEOUT) ./test/cli/... ./test/integration/... ./client/rpc/... +.PHONY: test_cli + +# Example tests (docs/examples/kubo-as-a-library) +# Tests against both published and current kubo versions +# Uses timeout to ensure CI gets output before job-level timeout kills everything +TEST_EXAMPLES_TIMEOUT ?= 2m +test_examples: + cd docs/examples/kubo-as-a-library && go test -v -timeout=$(TEST_EXAMPLES_TIMEOUT) ./... && cp go.mod go.mod.bak && cp go.sum go.sum.bak && (go mod edit -replace github.com/ipfs/kubo=./../../.. && go mod tidy && go test -v -timeout=$(TEST_EXAMPLES_TIMEOUT) ./...; ret=$$?; mv go.mod.bak go.mod; mv go.sum.bak go.sum; exit $$ret) +.PHONY: test_examples + +# Build kubo for all platforms from .github/build-platforms.yml test_go_build: bin/test-go-build-platforms .PHONY: test_go_build -test_go_short: GOTFLAGS += -test.short -test_go_short: test_go_test -.PHONY: test_go_short - -test_go_race: GOTFLAGS += -race -test_go_race: test_go_test -.PHONY: test_go_race - -test_go_expensive: test_go_test test_go_build -.PHONY: test_go_expensive -TEST_GO += test_go_expensive - +# Check Go source formatting test_go_fmt: bin/test-go-fmt .PHONY: test_go_fmt -TEST_GO += test_go_fmt +# Run golangci-lint (used by CI) test_go_lint: test/bin/golangci-lint golangci-lint run --timeout=3m ./... .PHONY: test_go_lint -test_go: $(TEST_GO) - -# Version check is no longer needed - go.mod enforces minimum version -.PHONY: check_go_version - +TEST_GO := test_go_fmt test_unit test_cli test_examples TEST += $(TEST_GO) -TEST_SHORT += test_go_fmt test_go_short +TEST_SHORT += test_go_fmt test_unit diff --git a/test/cli/delegated_routing_v1_http_server_test.go b/test/cli/delegated_routing_v1_http_server_test.go index 7883fa793..ffcc571b7 100644 --- a/test/cli/delegated_routing_v1_http_server_test.go +++ b/test/cli/delegated_routing_v1_http_server_test.go @@ -2,7 +2,6 @@ package cli import ( "context" - "encoding/json" "strings" "testing" "time" @@ -21,11 +20,6 @@ import ( "github.com/stretchr/testify/require" ) -// swarmPeersOutput is used to parse the JSON output of 'ipfs swarm peers --enc=json' -type swarmPeersOutput struct { - Peers []struct{} `json:"Peers"` -} - func TestRoutingV1Server(t *testing.T) { t.Parallel() @@ -206,11 +200,14 @@ func TestRoutingV1Server(t *testing.T) { c, err := client.New(node.GatewayURL()) require.NoError(t, err) - // Try to get closest peers - should fail gracefully with an error + // Try to get closest peers - should fail gracefully with an error. + // Use 60-second timeout (server has 30s routing timeout). testCid, err := cid.Decode("QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn") require.NoError(t, err) - _, err = c.GetClosestPeers(context.Background(), testCid) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + _, err = c.GetClosestPeers(ctx, testCid) require.Error(t, err) // All these routing types should indicate DHT is not available // The exact error message may vary based on implementation details @@ -224,7 +221,7 @@ func TestRoutingV1Server(t *testing.T) { } }) - t.Run("GetClosestPeers returns peers for self", func(t *testing.T) { + t.Run("GetClosestPeers returns peers", func(t *testing.T) { t.Parallel() routingTypes := []string{"auto", "autoclient", "dht", "dhtclient"} @@ -242,47 +239,33 @@ func TestRoutingV1Server(t *testing.T) { }) node.StartDaemon() - // Create client before waiting so we can probe DHT readiness c, err := client.New(node.GatewayURL()) require.NoError(t, err) // Query for closest peers to our own peer ID key := peer.ToCid(node.PeerID()) - // Wait for node to connect to bootstrap peers and populate WAN DHT routing table - minPeers := len(autoconf.FallbackBootstrapPeers) - require.EventuallyWithT(t, func(t *assert.CollectT) { - res := node.RunIPFS("swarm", "peers", "--enc=json") - var output swarmPeersOutput - err := json.Unmarshal(res.Stdout.Bytes(), &output) - assert.NoError(t, err) - peerCount := len(output.Peers) - // Wait until we have at least minPeers connected - assert.GreaterOrEqual(t, peerCount, minPeers, - "waiting for at least %d bootstrap peers, currently have %d", minPeers, peerCount) - }, 60*time.Second, time.Second) - - // Wait for DHT to be ready by probing GetClosestPeers until it succeeds - require.EventuallyWithT(t, func(t *assert.CollectT) { - probeCtx, probeCancel := context.WithTimeout(context.Background(), 30*time.Second) - defer probeCancel() - probeIter, probeErr := c.GetClosestPeers(probeCtx, key) - if probeErr == nil { - probeIter.Close() + // Wait for WAN DHT routing table to be populated. + // The server has a 30-second routing timeout, so we use 60 seconds + // per request to allow for network latency while preventing hangs. + // Total wait time is 2 minutes (locally passes in under 1 minute). + var records []*types.PeerRecord + require.EventuallyWithT(t, func(ct *assert.CollectT) { + ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) + defer cancel() + resultsIter, err := c.GetClosestPeers(ctx, key) + if !assert.NoError(ct, err) { + return } - assert.NoError(t, probeErr, "DHT should be ready to handle GetClosestPeers") + records, err = iter.ReadAllResults(resultsIter) + assert.NoError(ct, err) }, 2*time.Minute, 5*time.Second) - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - resultsIter, err := c.GetClosestPeers(ctx, key) - require.NoError(t, err) - - records, err := iter.ReadAllResults(resultsIter) - require.NoError(t, err) - // Verify we got some peers back from WAN DHT - assert.NotEmpty(t, records, "should return some peers close to own peerid") + require.NotEmpty(t, records, "should return peers close to own peerid") + + // Per IPIP-0476, GetClosestPeers returns at most 20 peers + assert.LessOrEqual(t, len(records), 20, "IPIP-0476 limits GetClosestPeers to 20 peers") // Verify structure of returned records for _, record := range records { diff --git a/test/dependencies/go.mod b/test/dependencies/go.mod index 826fd60eb..75bbdf72c 100644 --- a/test/dependencies/go.mod +++ b/test/dependencies/go.mod @@ -15,7 +15,7 @@ require ( github.com/ipfs/iptb-plugins v0.5.1 github.com/multiformats/go-multiaddr v0.16.1 github.com/multiformats/go-multihash v0.2.3 - gotest.tools/gotestsum v1.12.3 + gotest.tools/gotestsum v1.13.0 ) require ( diff --git a/test/dependencies/go.sum b/test/dependencies/go.sum index 829479591..78d6acaef 100644 --- a/test/dependencies/go.sum +++ b/test/dependencies/go.sum @@ -985,8 +985,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools/gotestsum v1.12.3 h1:jFwenGJ0RnPkuKh2VzAYl1mDOJgbhobBDeL2W1iEycs= -gotest.tools/gotestsum v1.12.3/go.mod h1:Y1+e0Iig4xIRtdmYbEV7K7H6spnjc1fX4BOuUhWw2Wk= +gotest.tools/gotestsum v1.13.0 h1:+Lh454O9mu9AMG1APV4o0y7oDYKyik/3kBOiCqiEpRo= +gotest.tools/gotestsum v1.13.0/go.mod h1:7f0NS5hFb0dWr4NtcsAsF0y1kzjEFfAil0HiBQJE03Q= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= diff --git a/test/unit/Rules.mk b/test/unit/Rules.mk index 69404637c..915d08f9a 100644 --- a/test/unit/Rules.mk +++ b/test/unit/Rules.mk @@ -2,7 +2,8 @@ include mk/header.mk CLEAN += $(d)/gotest.json $(d)/gotest.junit.xml -$(d)/gotest.junit.xml: test/bin/gotestsum coverage/unit_tests.coverprofile +# Convert gotest.json (produced by test_unit) to JUnit XML format +$(d)/gotest.junit.xml: test/bin/gotestsum $(d)/gotest.json gotestsum --no-color --junitfile $@ --raw-command cat $(@D)/gotest.json include mk/footer.mk