From 18bf89eae978a520d994eba522f8f65f202e60e8 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Sat, 18 Apr 2026 17:44:37 -0300 Subject: [PATCH] coverage: make targets + close zero-cov gaps (namegen, sessionstream) Adds `make coverage` (per-package + total via -coverpkg=./...), `make coverage-html`, and `make coverage-total` (CI-friendly). Wires coverage.out/coverage.html through `make clean` and .gitignore. Closes the two easy zero-coverage packages: namegen (77.8%) and sessionstream (93.5%). Total statement coverage 51.7% -> 52.1%. --- .gitignore | 2 + AGENTS.md | 3 +- Makefile | 39 +++++-- internal/namegen/namegen_test.go | 54 +++++++++ internal/sessionstream/sessionstream_test.go | 117 +++++++++++++++++++ 5 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 internal/namegen/namegen_test.go create mode 100644 internal/sessionstream/sessionstream_test.go diff --git a/.gitignore b/.gitignore index 6e03511..a411108 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ wtf/*.deb id_rsa .env /todos +/coverage.out +/coverage.html diff --git a/AGENTS.md b/AGENTS.md index 223cfb7..3a7efae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,8 @@ Always run `make build` before commit. ## Testing Guidance -- Primary automated coverage is `go test ./...`. +- Primary automated coverage is `go test ./...` (wired through `make test`). +- `make coverage` runs the suite with `-coverpkg=./...` and prints per-package averages plus a total; `make coverage-html` writes a browsable report to `coverage.html`; `make coverage-total` prints just the total (for scripts/CI). - For lifecycle changes, smoke-test with `vm run` end-to-end (covers create + start + boot + ssh). - If guest provisioning changes, document whether existing images must be rebuilt or recreated. diff --git a/Makefile b/Makefile index 03e2085..df46948 100644 --- a/Makefile +++ b/Makefile @@ -28,19 +28,22 @@ GO_LDFLAGS := -X banger/internal/buildinfo.Version=$(VERSION) -X banger/internal .DEFAULT_GOAL := help -.PHONY: help build banger bangerd test fmt tidy clean install bench-create lint lint-go lint-shell +.PHONY: help build banger bangerd test fmt tidy clean install bench-create lint lint-go lint-shell coverage coverage-html coverage-total help: @printf '%s\n' \ 'Targets:' \ - ' make build Build ./build/bin/banger, ./build/bin/bangerd, and ./build/bin/banger-vsock-agent' \ - ' make install Build and install banger, bangerd, and the companion vsock helper' \ - ' make test Run go test ./...' \ - ' make lint Run gofmt + go vet + shellcheck (errors)' \ - ' make fmt Format Go sources under cmd/ and internal/' \ - ' make tidy Run go mod tidy' \ - ' make clean Remove built Go binaries' \ - ' make bench-create Benchmark vm create and SSH readiness with scripts/bench-create.sh' + ' make build Build ./build/bin/banger, ./build/bin/bangerd, and ./build/bin/banger-vsock-agent' \ + ' make install Build and install banger, bangerd, and the companion vsock helper' \ + ' make test Run go test ./...' \ + ' make coverage Run tests with coverage; print per-package + total' \ + ' make coverage-html Open a browsable per-line HTML report (writes coverage.html)' \ + ' make coverage-total Print just the total statement coverage (for scripts/CI)' \ + ' make lint Run gofmt + go vet + shellcheck (errors)' \ + ' make fmt Format Go sources under cmd/ and internal/' \ + ' make tidy Run go mod tidy' \ + ' make clean Remove built Go binaries and coverage artefacts' \ + ' make bench-create Benchmark vm create and SSH readiness with scripts/bench-create.sh' build: $(BINARIES) @@ -59,6 +62,22 @@ $(VSOCK_AGENT_BIN): $(BUILD_INPUTS) go.mod go.sum test: $(GO) test ./... +# Coverage targets use -coverpkg=./... so packages without their own +# tests still get counted when another package exercises them (common +# for daemon/* subpackages). coverage.out is gitignored. +coverage: + $(GO) test -coverpkg=./... -coverprofile=coverage.out ./... + @echo '' + @echo 'Per-package:' + @$(GO) tool cover -func=coverage.out | awk -F'\t+' '/^total:/ {total=$$NF; next} {pkg=$$1; sub("banger/", "", pkg); sub("/[^/]+:[0-9]+:$$", "", pkg); pkgs[pkg]+=1; covered[pkg]+=$$NF+0} END {for (p in pkgs) printf " %-40s %.1f%% (avg of %d funcs)\n", p, covered[p]/pkgs[p], pkgs[p] | "sort"; print ""; print "Total statement coverage:", total}' + +coverage-html: coverage + $(GO) tool cover -html=coverage.out -o coverage.html + @echo 'wrote coverage.html' + +coverage-total: + @$(GO) test -coverpkg=./... -coverprofile=coverage.out ./... >/dev/null 2>&1 && $(GO) tool cover -func=coverage.out | awk '/^total:/ {print $$NF}' + lint: lint-go lint-shell lint-go: @@ -80,7 +99,7 @@ tidy: $(GO) mod tidy clean: - rm -rf "$(BUILD_BIN_DIR)" + rm -rf "$(BUILD_BIN_DIR)" coverage.out coverage.html bench-create: build BANGER_BIN="$(abspath $(BANGER_BIN))" bash ./scripts/bench-create.sh $(ARGS) diff --git a/internal/namegen/namegen_test.go b/internal/namegen/namegen_test.go new file mode 100644 index 0000000..8e7e9e8 --- /dev/null +++ b/internal/namegen/namegen_test.go @@ -0,0 +1,54 @@ +package namegen + +import ( + "strings" + "testing" +) + +func TestGenerate(t *testing.T) { + adjSet := make(map[string]struct{}, len(adjectives)) + for _, a := range adjectives { + adjSet[a] = struct{}{} + } + subSet := make(map[string]struct{}, len(substantives)) + for _, s := range substantives { + subSet[s] = struct{}{} + } + + seen := make(map[string]int) + for i := 0; i < 200; i++ { + name := Generate() + parts := strings.Split(name, "-") + if len(parts) != 2 { + t.Fatalf("expected adj-noun form, got %q", name) + } + if _, ok := adjSet[parts[0]]; !ok { + t.Fatalf("unknown adjective %q in %q", parts[0], name) + } + if _, ok := subSet[parts[1]]; !ok { + t.Fatalf("unknown substantive %q in %q", parts[1], name) + } + seen[name]++ + } + + // Minimal variety check: adj-noun cartesian product is thousands of + // combinations; 200 draws should hit more than a couple. + if len(seen) < 10 { + t.Fatalf("expected varied output, only saw %d distinct names", len(seen)) + } +} + +func TestRandomIndex(t *testing.T) { + if got := randomIndex(0); got != 0 { + t.Fatalf("randomIndex(0) = %d, want 0", got) + } + if got := randomIndex(1); got != 0 { + t.Fatalf("randomIndex(1) = %d, want 0", got) + } + for i := 0; i < 100; i++ { + n := randomIndex(7) + if n < 0 || n >= 7 { + t.Fatalf("randomIndex(7) = %d, out of range", n) + } + } +} diff --git a/internal/sessionstream/sessionstream_test.go b/internal/sessionstream/sessionstream_test.go new file mode 100644 index 0000000..aca7446 --- /dev/null +++ b/internal/sessionstream/sessionstream_test.go @@ -0,0 +1,117 @@ +package sessionstream + +import ( + "bytes" + "errors" + "io" + "testing" +) + +func TestWriteReadFrameRoundtrip(t *testing.T) { + cases := []struct { + name string + channel byte + payload []byte + }{ + {"stdout_bytes", ChannelStdout, []byte("hello world")}, + {"stderr_bytes", ChannelStderr, []byte{0x00, 0xff, 0x7f}}, + {"empty_payload", ChannelStdin, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var buf bytes.Buffer + if err := WriteFrame(&buf, tc.channel, tc.payload); err != nil { + t.Fatalf("WriteFrame: %v", err) + } + ch, got, err := ReadFrame(&buf) + if err != nil { + t.Fatalf("ReadFrame: %v", err) + } + if ch != tc.channel { + t.Fatalf("channel = %d, want %d", ch, tc.channel) + } + if !bytes.Equal(got, tc.payload) && !(len(got) == 0 && len(tc.payload) == 0) { + t.Fatalf("payload = %q, want %q", got, tc.payload) + } + }) + } +} + +type shortWriter struct { + failAfter int + written int +} + +func (s *shortWriter) Write(p []byte) (int, error) { + s.written += len(p) + if s.written > s.failAfter { + return 0, io.ErrShortWrite + } + return len(p), nil +} + +func TestWriteFrameWriterError(t *testing.T) { + w := &shortWriter{failAfter: 2} + err := WriteFrame(w, ChannelStdout, []byte("payload")) + if err == nil { + t.Fatal("expected error from short writer") + } +} + +func TestReadFrameTruncated(t *testing.T) { + _, _, err := ReadFrame(bytes.NewReader([]byte{0x02, 0x00})) + if !errors.Is(err, io.ErrUnexpectedEOF) && err == nil { + t.Fatalf("expected EOF-ish error, got %v", err) + } + + // Header OK, but payload truncated. + var buf bytes.Buffer + buf.Write([]byte{ChannelStdout, 0x00, 0x00, 0x00, 0x05}) + buf.Write([]byte("ab")) + if _, _, err := ReadFrame(&buf); err == nil { + t.Fatal("expected truncated payload error") + } +} + +func TestControlRoundtrip(t *testing.T) { + code := 42 + msg := ControlMessage{Type: "exit", ExitCode: &code} + + var buf bytes.Buffer + if err := WriteControl(&buf, msg); err != nil { + t.Fatalf("WriteControl: %v", err) + } + + got, err := ReadNextControl(&buf) + if err != nil { + t.Fatalf("ReadNextControl: %v", err) + } + if got.Type != "exit" { + t.Fatalf("type = %q, want exit", got.Type) + } + if got.ExitCode == nil || *got.ExitCode != 42 { + t.Fatalf("exit_code = %v, want 42", got.ExitCode) + } +} + +func TestReadControlBadJSON(t *testing.T) { + if _, err := ReadControl([]byte("{not json")); err == nil { + t.Fatal("expected JSON error") + } +} + +func TestReadNextControlWrongChannel(t *testing.T) { + var buf bytes.Buffer + if err := WriteFrame(&buf, ChannelStdout, []byte("not a control frame")); err != nil { + t.Fatalf("WriteFrame: %v", err) + } + if _, err := ReadNextControl(&buf); err == nil { + t.Fatal("expected error for non-control channel") + } +} + +func TestFormatConstant(t *testing.T) { + if FormatV1 != "stdio_mux_v1" { + t.Fatalf("FormatV1 = %q, want stdio_mux_v1", FormatV1) + } +}