CI: create JUnit artifacts and gate on all tests passing (#1873)

This change wires the `gotestsum` seam into the actual CI and ensures
that all tests _run_ and _pass_ using a new "gate" that verifies the
JUnit artifacts they produce. Specifically, the new gate works as
follows:

1. _registers_ that a test _should_ run and produce an artifact
2. _verifies_ that the artifact was produced and _all_ tests passed
(none were skipped).

The registration phase happens when a test is run in CI and requires
that the test be provided a `E2E_JUNIT_FILE` env variable.

Partial work for #1871

- [x] Tests pass
   ```
  Run make build-junittool
  make build-junittool
  bin/junittool verify -manifest "${ARTIFACTS}/expected-junit.txt"
  shell: /usr/bin/bash -e {0}
  env:
    E2E_ATENET_DATAPLANE: agentgateway
    ARTIFACTS: /home/runner/work/substrate/substrate/_artifacts
go -C tools/junittool build -o
/home/runner/work/substrate/substrate/bin//junittool .
FILE TESTS FAILURES ERRORS SKIPPED
/home/runner/work/substrate/substrate/_artifacts/e2e-gvisor.xml 84 0 0 4
/home/runner/work/substrate/substrate/_artifacts/e2e-microvm.xml 84 0 0
5
/home/runner/work/substrate/substrate/_artifacts/e2e-mitm.xml 1 0 0 0
/home/runner/work/substrate/substrate/_artifacts/e2e-mitm-microvm.xml 1
0 0 0

/home/runner/work/substrate/substrate/_artifacts/e2e-networking-mitm.xml
11 0 0 1

/home/runner/work/substrate/substrate/_artifacts/e2e-networking-mitm-microvm.xml
11 0 0 1
  TOTAL   
  ```
- [x] Appropriate changes to documentation are included in the PR
This commit is contained in:
Steven Shriver
2026-09-28 22:01:26 +00:00
committed by GitHub
parent 5b7417e85d
commit 7c0c70c7a2
14 changed files with 1034 additions and 3 deletions
+55 -2
View File
@@ -38,6 +38,10 @@ jobs:
# A bound well clear of the observed runtime. Without one the platform
# default applies, and a wedged step burns a free-tier slot for six hours.
timeout-minutes: 45
env:
# Where each runner writes its JUnit. Every invocation needs its own file;
# a shared path means the last writer wins.
ARTIFACTS: ${{ github.workspace }}/_artifacts
steps:
- name: Checkout
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
@@ -51,16 +55,39 @@ jobs:
# this guards, and that happens on the push to main, not on the PR.
- name: Verify immutable PostgreSQL migrations
run: hack/verify/postgresql-migrations.sh
- run: go test -race -v ./...
# gotestsum wraps go test to emit JUnit; the go test arguments are unchanged.
- name: unit tests
run: hack/run-tool.sh gotestsum --junitfile "${ARTIFACTS}/unit.xml"
--jsonfile "${ARTIFACTS}/unit.json" --format standard-verbose
-- -race -v ./...
# tools/apitool has its own module so we need to run it explicitly.
# run-tool.sh resolves the repo root itself, so it works from the subdir.
- name: apitool tests
working-directory: tools/apitool
run: go test -race -v ./...
run: ../../hack/run-tool.sh gotestsum --junitfile "${ARTIFACTS}/apitool.xml"
--jsonfile "${ARTIFACTS}/apitool.json" --format standard-verbose
-- -race -v ./...
# Root-gated tests (overlay mounts, whiteout mknod, trusted.* xattrs, ...)
# skip for the unprivileged runner user above; rerun the packages that
# contain them (any test importing internal/roottest) under sudo.
- name: root-gated tests
env:
ROOT_JUNIT_FILE: ${{ github.workspace }}/_artifacts/root.xml
run: hack/run-root-tests.sh -race -v
# An exit code cannot distinguish "all passed" from "none ran". A registered
# file that was never written fails too.
- name: Require tests to have run
if: always()
run: |
make build-junittool
bin/junittool verify -manifest "${ARTIFACTS}/expected-junit.txt"
- name: Upload test results
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: test-results-unit
path: ${{ env.ARTIFACTS }}
if-no-files-found: error
- name: verify
run: hack/verify-all.sh
# One kind cluster exercises BOTH runtimes. Free x86-64 ubuntu-latest runners
@@ -95,6 +122,9 @@ jobs:
) }}
env:
E2E_ATENET_DATAPLANE: ${{ matrix.dataplane }}
# E2E_JUNIT_FILE is set per step, not here: the six lanes below share this
# job, so one job-level path would have each overwrite the last.
ARTIFACTS: ${{ github.workspace }}/_artifacts
steps:
- name: Checkout
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
@@ -151,6 +181,8 @@ jobs:
hack/install-ate-kind.sh --deploy-demo-egress
hack/install-ate-kind.sh --deploy-demo-egress-microvm
- name: Run E2E tests (gVisor)
env:
E2E_JUNIT_FILE: ${{ github.workspace }}/_artifacts/e2e-gvisor.xml
run: hack/run-e2e-kind.sh -v -args --no-color
- name: Run E2E tests (micro-VM)
# The same suites again, with every fixture repointed at its micro-VM
@@ -160,6 +192,7 @@ jobs:
# two runs do not contend for the one kind node.
env:
E2E_SANDBOX_CLASS: microvm
E2E_JUNIT_FILE: ${{ github.workspace }}/_artifacts/e2e-microvm.xml
run: hack/run-e2e-kind.sh -v -args --no-color
- name: Deploy MITM egress (sdsmint)
# Swap the passthrough egress gateway for the sdsmint variant, which
@@ -177,6 +210,7 @@ jobs:
# internal/e2e/suites/egressmitm).
env:
E2E_EGRESS_MITM: "1"
E2E_JUNIT_FILE: ${{ github.workspace }}/_artifacts/e2e-mitm.xml
run: hack/run-e2e-kind.sh ./internal/e2e/suites/egressmitm -v -args --no-color
- name: Run E2E tests (egress MITM trust, micro-VM)
# The same proof with the probe on the micro-VM runtime. Trust DELIVERY
@@ -186,6 +220,7 @@ jobs:
env:
E2E_EGRESS_MITM: "1"
E2E_SANDBOX_CLASS: microvm
E2E_JUNIT_FILE: ${{ github.workspace }}/_artifacts/e2e-mitm-microvm.xml
run: hack/run-e2e-kind.sh ./internal/e2e/suites/egressmitm -v -args --no-color
- name: Deploy MITM egress demo
# The networking suite's egress tests need a fixture that trusts the
@@ -196,12 +231,30 @@ jobs:
- name: Run E2E tests (networking, MITM egress)
env:
E2E_EGRESS_MITM: "1"
E2E_JUNIT_FILE: ${{ github.workspace }}/_artifacts/e2e-networking-mitm.xml
run: hack/run-e2e-kind.sh ./internal/e2e/suites/networking -run '^TestActorEgress' -v -args --no-color
- name: Run E2E tests (networking, MITM egress, micro-VM)
env:
E2E_EGRESS_MITM: "1"
E2E_SANDBOX_CLASS: microvm
E2E_JUNIT_FILE: ${{ github.workspace }}/_artifacts/e2e-networking-mitm-microvm.xml
run: hack/run-e2e-kind.sh ./internal/e2e/suites/networking -run '^TestActorEgress' -v -args --no-color
# Every lane must have reported tests; a `-run` filter that stops matching
# otherwise leaves its lane green and empty. The set comes from the manifest
# each lane registers itself in, so adding a lane needs no change here, and
# run-e2e.sh refuses to run in CI without E2E_JUNIT_FILE.
- name: Require every lane to have run tests
if: always()
run: |
make build-junittool
bin/junittool verify -manifest "${ARTIFACTS}/expected-junit.txt"
- name: Upload test results
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: test-results-e2e-${{ matrix.dataplane }}
path: ${{ env.ARTIFACTS }}
if-no-files-found: error
- name: Dump diagnostics on failure
if: failure()
run: |
+10 -1
View File
@@ -17,6 +17,10 @@ __pycache__/
# Generated scratch directory for benchmark automation
/benchmarking/automation/scratch/
# Test artifacts (JUnit XML, gotestsum JSON). CI writes these into the
# workspace, and hack/verify-all.sh refuses to run against a dirty tree.
/_artifacts/
# Python gRPC clients, generated by benchmarking/locust/codegen/generate.sh
/benchmarking/locust/common/*_pb2.py
/benchmarking/locust/common/*_pb2_grpc.py
@@ -55,8 +59,13 @@ Thumbs.db
# Local worktrees
/.worktrees/
# Stray local build outputs (go build ./tools/... without -o)
# Stray outputs from `go build` without -o. Root-module tools land at the repo
# root; tools with their own go.mod land beside their source. Use
# `make build-<tool>`, which writes to /bin/ above.
/validate-image-cache
/setup-gcp
/tools/apitool/apitool
/tools/junittool/junittool
# Substrate agent workspace scratchpads
.agents/scratch/
+1
View File
@@ -83,6 +83,7 @@ See the [metric registry](docs/observability.md#the-metric-registry) section of
2. Ensure changes do not break existing tests.
3. Run `make verify` locally before requesting a code review to catch common issues like missed copyright headers or formatting drift.
4. For end-to-end tests involving the actual infrastructure, ensure you have a running cluster (setup via `hack/ate-dev-env.sh.example` and `go run ./tools/setup-gcp bootstrap`).
5. A test that skips when a precondition is missing — Docker, a cluster, an artifact directory — must **fail** on it in CI, because a skip and a pass are the same exit code. Resolve strictness through a named predicate rather than an inline `os.Getenv("CI")`; `cmd/ateapi/internal/store/dockerenv.Required()` is the reference implementation. Prove the strict branch red before merging: a guard only ever observed passing is not known to guard anything. See `docs/dev/best-practices/ci-fail-closed.md`.
## Security Considerations
+7
View File
@@ -96,6 +96,13 @@ build-ate-setup:
build-atenet:
$(GO) build -ldflags "$(LDFLAGS)" -o $(BINDIR)/atenet ./cmd/atenet
# Registers the JUnit files a CI run will write, and verifies they hold tests.
# Separate module, so it builds by path. -o keeps the binary in $(BINDIR),
# which is gitignored and removed by `clean`.
.PHONY: build-junittool
build-junittool:
$(GO) -C tools/junittool build -o $(CURDIR)/$(BINDIR)/junittool .
.PHONY: build-demos
build-demos:
$(KO) build $(KO_NAMING) $(KO_FLAGS) \
+5
View File
@@ -39,6 +39,11 @@ far from its cause.
- Table-driven tests with `t.Run` subtests are the default shape.
- Prefer a real test implementation when one exists: the PostgreSQL test fixture for the store, `envtest` for
the Kubernetes API. Release resources with `t.Cleanup`.
- A test that skips on a missing precondition must fail on it in CI instead —
a skipped test and a passing one are the same exit code. Resolve strictness
through a named predicate (`dockerenv.Required()`), and prove the strict
branch red before merging. See
[CI fail-closed preconditions](dev/best-practices/ci-fail-closed.md).
## TODOs
+110
View File
@@ -0,0 +1,110 @@
# CI Fail-Closed Preconditions
How to write a test that skips locally when a dependency is absent, but fails
in CI. One code path, strictness resolved from the environment.
Counterpart to [Code Style Guide](../../code-style-guide.md) § Testing.
## Why
A test needing Docker, a cluster, or an artifact directory has three options:
| | Locally | In CI |
|---|---|---|
| Hard-fail everywhere | Unrunnable without setup | Correct |
| Skip everywhere | Runnable | **Green having run nothing** |
| Skip locally, fail in CI | Runnable | Correct |
A skip and a pass are the same exit code. ~280 PostgreSQL-backed tests once
skipped when their testcontainer failed to start, including a 2,800-line store
contract, and the job stayed green.
## Convention
Resolve strictness in one named function. GitHub Actions sets `CI=true`.
### Go
Reference implementation, `cmd/ateapi/internal/store/dockerenv.Required()`:
```go
func Required() bool {
return os.Getenv("CI") == "true" || os.Getenv("REQUIRE_DOCKER") == "true"
}
```
Callers branch on it (`storetest.go:187-192`, `atepg/main_test.go:105-110`):
```go
if containerErr != nil {
if dockerenv.Required() {
t.Fatalf("PostgreSQL testcontainer unavailable and required "+
"(CI or REQUIRE_DOCKER is set): %v", containerErr)
}
t.Skipf("PostgreSQL testcontainer unavailable (requires Docker): %v", containerErr)
}
```
Both messages name the cause; the fatal one also names what made it fatal.
### Bash
```bash
if [[ -z "${E2E_JUNIT_FILE:-}" && "${CI:-}" == "true" ]]; then
echo "run-e2e.sh: E2E_JUNIT_FILE must be set when CI=true." >&2
echo " Set it to a path unique to this run, e.g." >&2
echo " E2E_JUNIT_FILE=\"\${ARTIFACTS}/e2e-gvisor.xml\"" >&2
exit 1
fi
```
## Rules
1. **One named predicate.** `Required()`, `inCI()`. An inline `os.Getenv("CI")`
cannot be audited or changed in one place.
2. **Make the strict branch reachable locally.** Pair `CI` with an override:
`REQUIRE_DOCKER=true`, or a flag such as junittool's `-reject-duplicate`.
3. **Failures state the fix.** Name the variable to set and a valid value.
Assume the reader has not seen this check before.
4. **Never warn instead of failing.** Warnings in a green run are not read.
## Verifying both branches
Only the permissive branch runs by default. Force the strict branch before
merging:
```bash
# Docker precondition, without Docker
sudo systemctl stop docker.socket docker.service
CI=true go test ./cmd/ateapi/internal/store/... # expect FAIL, not SKIP
# JUnit precondition, unset
CI=true hack/run-e2e.sh ./internal/e2e/suites/example # expect exit 1
# Duplicate registration
CI=true go -C tools/junittool run . register /tmp/a.xml
CI=true go -C tools/junittool run . register /tmp/a.xml # expect exit 1
```
Two traps that have produced false passes:
- `DOCKER_HOST=/nonexistent` does not disable Docker. testcontainers walks a
six-source resolution chain and reaches the real daemon.
- `systemctl stop docker` leaves `docker.socket` active, which restarts the
daemon on the next connection. Stop both units.
## When not to use this
- **The precondition should always hold.** Absence is a bug: fail everywhere.
- **The test can assert it instead.** A skip on cluster state usually means a
missing precondition check in the harness.
- **The test can create what it needs.** Do that rather than branching.
## Current users
`dockerenv.Required()` is the reference implementation. The same shape is in
`hack/run-e2e.sh` and `hack/run-root-tests.sh` (a JUnit path is mandatory in
CI) and `tools/junittool` (re-registering a path is fatal in CI).
`git grep -n 'CI.*==.*true'` finds the current set. Not maintained as an
inventory.
+16
View File
@@ -148,12 +148,28 @@ test_argv+=(-args --e2e)
test_argv+=(${extra_e2e_args[@]+"${extra_e2e_args[@]}"})
test_argv+=(${e2e_args[@]+"${e2e_args[@]}"})
# CI requires a JUnit file: it checks that every test run reported tests, and a
# run without one cannot be checked. Optional locally, where this stays a plain
# `go test`.
if [[ -z "${E2E_JUNIT_FILE:-}" && "${CI:-}" == "true" ]]; then
echo "run-e2e.sh: E2E_JUNIT_FILE must be set when CI=true." >&2
echo " Set it to a path unique to this run, e.g." >&2
echo " E2E_JUNIT_FILE=\"\${ARTIFACTS}/e2e-gvisor.xml\"" >&2
echo " CI verifies each run reported at least one test; a run that writes" >&2
echo " no JUnit file cannot be verified." >&2
exit 1
fi
# E2E_JUNIT_FILE opts into a machine-readable record of the run: the XML for
# report consumers, the JSON event stream for failure analysis. Unset, this is a
# plain go test and gotestsum is never built, so a local run needs no toolchain
# beyond go itself.
if [[ -n "${E2E_JUNIT_FILE:-}" ]]; then
mkdir -p "$(dirname "${E2E_JUNIT_FILE}")"
# Claim the path before running, so a run killed part-way still owes a file
# that `junittool verify` will report as missing. junittool rejects a path
# another run already claimed, which would otherwise be overwritten.
go -C "${ROOT}/tools/junittool" run . register "${E2E_JUNIT_FILE}"
exec "${ROOT}/hack/run-tool.sh" gotestsum \
--junitfile "${E2E_JUNIT_FILE}" \
--jsonfile "${E2E_JUNIT_FILE%.xml}.json" \
+13
View File
@@ -51,8 +51,21 @@ test_args=(-count=1 -timeout "${ROOT_TEST_TIMEOUT:-10m}" "$@")
# that under sudo would leave root-owned entries in the user's build cache.
# The runner is only ever a prefix — the privilege dispatch below is unchanged,
# because a root-gated package run without root self-skips and reports success.
# CI requires a JUnit file; see hack/run-e2e.sh for the same precondition.
if [[ -z "${ROOT_JUNIT_FILE:-}" && "${CI:-}" == "true" ]]; then
echo "run-root-tests.sh: ROOT_JUNIT_FILE must be set when CI=true." >&2
echo " Set it to a path unique to this run, e.g." >&2
echo " ROOT_JUNIT_FILE=\"\${ARTIFACTS}/root.xml\"" >&2
echo " CI verifies each run reported at least one test; a run that writes" >&2
echo " no JUnit file cannot be verified." >&2
exit 1
fi
if [[ -n "${ROOT_JUNIT_FILE:-}" ]]; then
mkdir -p "$(dirname "${ROOT_JUNIT_FILE}")"
# Claim the path before running. Runs as the invoking user, before the sudo
# dispatch below, so the manifest is not left root-owned.
go -C "${ROOT}/tools/junittool" run . register "${ROOT_JUNIT_FILE}"
runner=("$("${ROOT}/hack/run-tool.sh" --print-bin-path gotestsum)"
--junitfile "${ROOT_JUNIT_FILE}"
--jsonfile "${ROOT_JUNIT_FILE%.xml}.json"
+3
View File
@@ -0,0 +1,3 @@
module github.com/agent-substrate/substrate/tools/junittool
go 1.27.0
+162
View File
@@ -0,0 +1,162 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"encoding/xml"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"text/tabwriter"
)
// testSuites matches the JUnit gotestsum writes; only counts are read. Summed
// from the testsuite children, not the root attribute, so a root disagreeing
// with its children cannot inflate the total.
type testSuites struct {
Suites []struct {
Name string `xml:"name,attr"`
Tests int `xml:"tests,attr"`
Failures int `xml:"failures,attr"`
Errors int `xml:"errors,attr"`
Skipped int `xml:"skipped,attr"`
} `xml:"testsuite"`
}
type totals struct {
tests, failures, errors, skipped int
}
func (t *totals) add(o totals) {
t.tests += o.tests
t.failures += o.failures
t.errors += o.errors
t.skipped += o.skipped
}
func parse(path string) (totals, error) {
raw, err := os.ReadFile(path)
if err != nil {
return totals{}, err
}
var ts testSuites
if err := xml.Unmarshal(raw, &ts); err != nil {
return totals{}, fmt.Errorf("parsing %s: %w", path, err)
}
var out totals
for _, s := range ts.Suites {
out.add(totals{tests: s.Tests, failures: s.Failures, errors: s.Errors, skipped: s.Skipped})
}
return out, nil
}
// expand resolves each argument to JUnit files. A directory contributes the
// *.xml inside it; anything else passes through literally, so a missing file
// reaches parse() and is reported.
func expand(args []string) ([]string, error) {
var out []string
for _, a := range args {
info, err := os.Stat(a)
if err != nil || !info.IsDir() {
out = append(out, a)
continue
}
matches, err := filepath.Glob(filepath.Join(a, "*.xml"))
if err != nil {
return nil, fmt.Errorf("expanding %s: %w", a, err)
}
sort.Strings(matches)
out = append(out, matches...)
}
return out, nil
}
// row is one file's contribution. err set means the counts are meaningless.
type row struct {
path string
t totals
err error
}
// result is the verify decision, separate from reporting so it can be tested
// without capturing output.
type result struct {
rows []row
grand totals
empty []string
unreadable []string
}
// ok reports whether every file was readable and met the minimum.
func (r result) ok() bool { return len(r.empty) == 0 && len(r.unreadable) == 0 }
// evaluate reads and classifies every path. min applies per file, never to the
// total, so one empty file fails even when others are full. Order matches the
// manifest.
func evaluate(paths []string, min int) result {
var res result
for _, p := range paths {
t, err := parse(p)
res.rows = append(res.rows, row{path: p, t: t, err: err})
if err != nil {
res.unreadable = append(res.unreadable, p)
continue
}
res.grand.add(t)
if t.tests < min {
res.empty = append(res.empty, p)
}
}
return res
}
// write renders the per-file table to out and the reasons for failure to errOut.
func (r result) write(out, errOut io.Writer, min int) {
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "FILE\tTESTS\tFAILURES\tERRORS\tSKIPPED")
for _, row := range r.rows {
if row.err != nil {
fmt.Fprintf(w, "%s\t-\t-\t-\t-\n", row.path)
continue
}
fmt.Fprintf(w, "%s\t%d\t%d\t%d\t%d\n", row.path, row.t.tests, row.t.failures, row.t.errors, row.t.skipped)
}
fmt.Fprintf(w, "TOTAL\t%d\t%d\t%d\t%d\n", r.grand.tests, r.grand.failures, r.grand.errors, r.grand.skipped)
w.Flush()
for _, row := range r.rows {
if row.err != nil {
fmt.Fprintf(errOut, "junittool: %v\n", row.err)
}
}
for _, p := range r.empty {
fmt.Fprintf(errOut,
"junittool: %s reported %d test(s), want at least %d. "+
"Check the package list and -run filter of the run that wrote it.\n",
p, r.testsFor(p), min)
}
}
// testsFor returns the count recorded for path, for error messages.
func (r result) testsFor(path string) int {
for _, row := range r.rows {
if row.path == path {
return row.t.tests
}
}
return 0
}
+216
View File
@@ -0,0 +1,216 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// write puts content at dir/name and returns the path.
func write(t *testing.T, dir, name, content string) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatalf("writing %s: %v", p, err)
}
return p
}
const twoSuites = `<testsuites tests="10" failures="1" errors="0">
<testsuite name="pkg/a" tests="7" failures="1" errors="0" skipped="2"></testsuite>
<testsuite name="pkg/b" tests="3" failures="0" errors="0" skipped="0"></testsuite>
</testsuites>`
// What gotestsum emits when a package matched no tests: suite present, count
// zero. Distinct from a document with no suites at all.
const zeroTests = `<testsuites tests="0" failures="0" errors="0">
<testsuite name="pkg/a" tests="0" failures="0"></testsuite>
</testsuites>`
func TestParse(t *testing.T) {
dir := t.TempDir()
for _, tc := range []struct {
name string
content string
want totals
wantErr bool
}{
{name: "two suites are summed", content: twoSuites,
want: totals{tests: 10, failures: 1, errors: 0, skipped: 2}},
{name: "zero-test suite", content: zeroTests},
{name: "no suites at all", content: `<testsuites></testsuites>`},
// Children win over the root attribute.
{name: "root attribute ignored in favour of children",
content: `<testsuites tests="5"><testsuite tests="0"></testsuite></testsuites>`},
{name: "malformed", content: "not xml at all", wantErr: true},
{name: "empty file", content: "", wantErr: true},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := parse(write(t, dir, strings.ReplaceAll(tc.name, " ", "_")+".xml", tc.content))
if tc.wantErr {
if err == nil {
t.Fatalf("parse() = %+v, want error", got)
}
return
}
if err != nil {
t.Fatalf("parse() error = %v", err)
}
if got != tc.want {
t.Errorf("parse() = %+v, want %+v", got, tc.want)
}
})
}
}
func TestParseMissingFile(t *testing.T) {
if _, err := parse(filepath.Join(t.TempDir(), "absent.xml")); err == nil {
t.Fatal("parse() on a missing file = nil error, want error")
}
}
func TestExpand(t *testing.T) {
dir := t.TempDir()
a := write(t, dir, "a.xml", twoSuites)
b := write(t, dir, "b.xml", twoSuites)
write(t, dir, "notes.txt", "ignored")
empty := filepath.Join(dir, "empty")
if err := os.Mkdir(empty, 0o755); err != nil {
t.Fatal(err)
}
t.Run("directory expands to its xml, sorted", func(t *testing.T) {
got, err := expand([]string{dir})
if err != nil {
t.Fatalf("expand() error = %v", err)
}
if strings.Join(got, ",") != strings.Join([]string{a, b}, ",") {
t.Errorf("expand() = %v, want [%s %s]", got, a, b)
}
})
t.Run("non-xml files are not picked up", func(t *testing.T) {
got, _ := expand([]string{dir})
for _, p := range got {
if filepath.Ext(p) != ".xml" {
t.Errorf("expand() included %s, want only .xml", p)
}
}
})
t.Run("empty directory contributes nothing", func(t *testing.T) {
got, err := expand([]string{empty})
if err != nil || len(got) != 0 {
t.Errorf("expand(empty dir) = %v, %v; want no paths and no error", got, err)
}
})
// Passed through, not dropped, so evaluate() reports it missing.
t.Run("missing path passes through literally", func(t *testing.T) {
missing := filepath.Join(dir, "absent.xml")
got, err := expand([]string{missing})
if err != nil || len(got) != 1 || got[0] != missing {
t.Errorf("expand(missing) = %v, %v; want [%s]", got, err, missing)
}
})
}
func TestEvaluate(t *testing.T) {
dir := t.TempDir()
good := write(t, dir, "good.xml", twoSuites)
good2 := write(t, dir, "good2.xml", twoSuites)
zero := write(t, dir, "zero.xml", zeroTests)
bad := write(t, dir, "bad.xml", "not xml")
missing := filepath.Join(dir, "absent.xml")
for _, tc := range []struct {
name string
paths []string
min int
wantOK bool
wantEmpty int
wantUnreadable int
wantTotalTests int
}{
{name: "all populated", paths: []string{good, good2}, min: 1,
wantOK: true, wantTotalTests: 20},
{name: "single zero-test file", paths: []string{zero}, min: 1, wantEmpty: 1},
// Why min is per-file: tests in one file must not cover for another
// that reported none.
{name: "one empty file among populated ones",
paths: []string{good, zero}, min: 1, wantEmpty: 1, wantTotalTests: 10},
{name: "missing file", paths: []string{missing}, min: 1, wantUnreadable: 1},
{name: "malformed file", paths: []string{bad}, min: 1, wantUnreadable: 1},
{name: "missing and empty are counted separately",
paths: []string{missing, zero}, min: 1, wantEmpty: 1, wantUnreadable: 1},
{name: "min above the reported count fails a populated file",
paths: []string{good}, min: 11, wantEmpty: 1, wantTotalTests: 10},
{name: "min at the reported count passes",
paths: []string{good}, min: 10, wantOK: true, wantTotalTests: 10},
{name: "no paths is vacuously ok", paths: nil, min: 1, wantOK: true},
} {
t.Run(tc.name, func(t *testing.T) {
res := evaluate(tc.paths, tc.min)
if res.ok() != tc.wantOK {
t.Errorf("ok() = %v, want %v (empty=%v unreadable=%v)",
res.ok(), tc.wantOK, res.empty, res.unreadable)
}
if len(res.empty) != tc.wantEmpty {
t.Errorf("empty = %v, want %d entries", res.empty, tc.wantEmpty)
}
if len(res.unreadable) != tc.wantUnreadable {
t.Errorf("unreadable = %v, want %d entries", res.unreadable, tc.wantUnreadable)
}
if res.grand.tests != tc.wantTotalTests {
t.Errorf("grand.tests = %d, want %d", res.grand.tests, tc.wantTotalTests)
}
if len(res.rows) != len(tc.paths) {
t.Errorf("rows = %d, want one per path (%d)", len(res.rows), len(tc.paths))
}
})
}
}
// The failure must name the file; otherwise the reader compares them by hand.
func TestWriteNamesTheOffendingFile(t *testing.T) {
dir := t.TempDir()
zero := write(t, dir, "zero.xml", zeroTests)
good := write(t, dir, "good.xml", twoSuites)
var out, errOut strings.Builder
evaluate([]string{good, zero}, 1).write(&out, &errOut, 1)
if !strings.Contains(errOut.String(), zero) {
t.Errorf("stderr does not name the empty file %s:\n%s", zero, errOut.String())
}
if strings.Contains(errOut.String(), good) {
t.Errorf("stderr names the passing file %s, which is noise:\n%s", good, errOut.String())
}
if !strings.Contains(out.String(), "TOTAL") {
t.Errorf("stdout has no TOTAL row:\n%s", out.String())
}
}
func TestTotalsAdd(t *testing.T) {
got := totals{tests: 1, failures: 2, errors: 3, skipped: 4}
got.add(totals{tests: 10, failures: 20, errors: 30, skipped: 40})
want := totals{tests: 11, failures: 22, errors: 33, skipped: 44}
if got != want {
t.Errorf("add() = %+v, want %+v", got, want)
}
}
+132
View File
@@ -0,0 +1,132 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Command junittool records the JUnit files a CI run will write, then checks
// they reported tests.
//
// junittool register <path> record a file this run will write
// junittool verify -manifest <file> require every recorded file to hold tests
//
// An exit code cannot distinguish "all tests passed" from "no test ran".
// `go test ./...` passes the e2e suites with no cluster, and a package filter
// that stops matching reports success having run nothing.
//
// Registering before the run has two effects: the expected set comes from the
// runs themselves, so adding a run needs no change to verify; and a run that
// died part-way is distinguishable from one that never existed.
//
// No dependencies — it runs on the critical path of every CI job.
package main
import (
"flag"
"fmt"
"os"
)
const usage = `usage:
junittool register [-manifest FILE] [-reject-duplicate] <junit.xml>
junittool verify (-manifest FILE | <junit.xml|dir>...) [-min N]
`
func main() {
if len(os.Args) < 2 {
fmt.Fprint(os.Stderr, usage)
os.Exit(2)
}
switch os.Args[1] {
case "register":
os.Exit(runRegister(os.Args[2:]))
case "verify":
os.Exit(runVerify(os.Args[2:]))
case "-h", "--help", "help":
fmt.Print(usage)
os.Exit(0)
default:
fmt.Fprintf(os.Stderr, "junittool: unknown subcommand %q\n%s", os.Args[1], usage)
os.Exit(2)
}
}
// inCI follows cmd/ateapi/internal/store/dockerenv.Required: strict in CI,
// permissive locally, where re-running into the same file is ordinary.
// See docs/dev/best-practices/ci-fail-closed.md.
func inCI() bool { return os.Getenv("CI") == "true" }
func runRegister(args []string) int {
fs := flag.NewFlagSet("register", flag.ExitOnError)
manifest := fs.String("manifest", "",
"manifest to append to (default: expected-junit.txt beside the entry)")
reject := fs.Bool("reject-duplicate", inCI(),
"treat a repeat registration as an error (defaults true when CI=true)")
_ = fs.Parse(args)
if fs.NArg() != 1 {
fmt.Fprint(os.Stderr, usage)
return 2
}
entry := fs.Arg(0)
path := *manifest
if path == "" {
path = defaultManifest(entry)
}
if err := register(path, entry, *reject); err != nil {
fmt.Fprintf(os.Stderr, "junittool register: %v\n", err)
return 1
}
return 0
}
func runVerify(args []string) int {
fs := flag.NewFlagSet("verify", flag.ExitOnError)
min := fs.Int("min", 1, "minimum number of test cases EACH file must report")
manifest := fs.String("manifest", "", "manifest listing the JUnit paths that were registered")
_ = fs.Parse(args)
var paths []string
switch {
case *manifest != "":
if fs.NArg() > 0 {
fmt.Fprintln(os.Stderr, "junittool verify: -manifest takes no positional arguments")
return 2
}
var err error
if paths, err = readManifest(*manifest); err != nil {
fmt.Fprintf(os.Stderr, "junittool verify: %v\n", err)
return 1
}
if len(paths) == 0 {
fmt.Fprintf(os.Stderr,
"junittool verify: %s is empty. No test run registered a JUnit file, "+
"so nothing ran.\n", *manifest)
return 1
}
case fs.NArg() > 0:
var err error
if paths, err = expand(fs.Args()); err != nil {
fmt.Fprintf(os.Stderr, "junittool verify: %v\n", err)
return 1
}
default:
fmt.Fprint(os.Stderr, usage)
return 2
}
res := evaluate(paths, *min)
res.write(os.Stdout, os.Stderr, *min)
if !res.ok() {
return 1
}
return 0
}
+104
View File
@@ -0,0 +1,104 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// errDuplicateEntry is returned when a path is already recorded. Registration
// is the only point where both runs are still visible; after the overwrite the
// first has left no trace.
var errDuplicateEntry = errors.New(
"already registered by an earlier run; give each run its own path, " +
"or the second overwrites the first and only the second is verified")
// readManifest returns the recorded paths, deduplicated, in first-seen order.
// Duplicates are collapsed, not rejected: a run repeated locally records its
// path again. Rejecting one is register's job, where a repeat and a collision
// are still distinguishable.
func readManifest(path string) ([]string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading manifest: %w", err)
}
return dedupe(strings.Split(string(raw), "\n")), nil
}
// dedupe trims, drops blanks, and keeps the first occurrence of each entry.
func dedupe(lines []string) []string {
seen := map[string]bool{}
var out []string
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || seen[line] {
continue
}
seen[line] = true
out = append(out, line)
}
return out
}
// register appends entry to the manifest at path, creating it if absent. With
// rejectDuplicate, a repeat is an error rather than a no-op. Callers register
// before running, so a run killed part-way still has a record, and verify
// reports its file as missing.
func register(path, entry string, rejectDuplicate bool) error {
entry = strings.TrimSpace(entry)
if entry == "" {
return errors.New("refusing to register an empty entry")
}
if strings.ContainsAny(entry, "\n\r") {
return fmt.Errorf("entry %q contains a newline: one path per line", entry)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("creating manifest directory: %w", err)
}
existing, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("reading manifest: %w", err)
}
for _, e := range dedupe(strings.Split(string(existing), "\n")) {
if e != entry {
continue
}
if rejectDuplicate {
return fmt.Errorf("%s: %w", entry, errDuplicateEntry)
}
return nil
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("opening manifest: %w", err)
}
defer f.Close()
if _, err := fmt.Fprintln(f, entry); err != nil {
return fmt.Errorf("appending to manifest: %w", err)
}
return nil
}
// defaultManifest puts the manifest beside the JUnit file, so runs sharing an
// artifact directory share a manifest.
func defaultManifest(entry string) string {
return filepath.Join(filepath.Dir(entry), "expected-junit.txt")
}
+200
View File
@@ -0,0 +1,200 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// lines returns the manifest's contents split for comparison.
func lines(t *testing.T, path string) []string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("reading %s: %v", path, err)
}
var out []string
for _, l := range strings.Split(string(raw), "\n") {
if l != "" {
out = append(out, l)
}
}
return out
}
func TestRegisterCreatesAndAppends(t *testing.T) {
m := filepath.Join(t.TempDir(), "nested", "expected-junit.txt")
// Parent directory does not exist yet: registration can precede any other
// write to the artifact directory.
if err := register(m, "/a/one.xml", true); err != nil {
t.Fatalf("first register: %v", err)
}
if err := register(m, "/a/two.xml", true); err != nil {
t.Fatalf("second register: %v", err)
}
got := lines(t, m)
want := []string{"/a/one.xml", "/a/two.xml"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("manifest = %v, want %v", got, want)
}
}
func TestRegisterDuplicate(t *testing.T) {
for _, tc := range []struct {
name string
rejectDuplicate bool
wantErr bool
wantLines int
}{
// Two runs sharing a path: the second overwrites the first, and only
// the second is verified.
{name: "rejected when asked", rejectDuplicate: true, wantErr: true, wantLines: 1},
// A run repeated locally registers again; not an error, and must not
// duplicate the entry.
{name: "tolerated and not duplicated", rejectDuplicate: false, wantLines: 1},
} {
t.Run(tc.name, func(t *testing.T) {
m := filepath.Join(t.TempDir(), "expected-junit.txt")
if err := register(m, "/a/one.xml", tc.rejectDuplicate); err != nil {
t.Fatalf("first register: %v", err)
}
err := register(m, "/a/one.xml", tc.rejectDuplicate)
if tc.wantErr {
if !errors.Is(err, errDuplicateEntry) {
t.Fatalf("second register error = %v, want errDuplicateEntry", err)
}
} else if err != nil {
t.Fatalf("second register: %v", err)
}
if got := lines(t, m); len(got) != tc.wantLines {
t.Errorf("manifest = %v, want %d line(s)", got, tc.wantLines)
}
})
}
}
// Whole-entry comparison, not substring: paths sharing a prefix are distinct.
func TestRegisterDistinguishesSimilarPaths(t *testing.T) {
m := filepath.Join(t.TempDir(), "expected-junit.txt")
for _, e := range []string{"/a/e2e.xml", "/a/e2e-microvm.xml", "/a/e2e-microvm-mitm.xml"} {
if err := register(m, e, true); err != nil {
t.Fatalf("register(%s): %v", e, err)
}
}
if got := lines(t, m); len(got) != 3 {
t.Errorf("manifest = %v, want 3 distinct entries", got)
}
}
func TestRegisterRejectsMalformedEntries(t *testing.T) {
for _, tc := range []struct{ name, entry string }{
{name: "empty", entry: ""},
{name: "whitespace only", entry: " "},
{name: "embedded newline", entry: "/a/one.xml\n/a/two.xml"},
{name: "embedded carriage return", entry: "/a/one.xml\r/a/two.xml"},
} {
t.Run(tc.name, func(t *testing.T) {
m := filepath.Join(t.TempDir(), "expected-junit.txt")
if err := register(m, tc.entry, true); err == nil {
t.Fatalf("register(%q) = nil, want error", tc.entry)
}
})
}
}
// Entries are trimmed on write, so a padded path still matches its file.
func TestRegisterTrimsEntry(t *testing.T) {
m := filepath.Join(t.TempDir(), "expected-junit.txt")
if err := register(m, " /a/one.xml ", true); err != nil {
t.Fatalf("register: %v", err)
}
if got := lines(t, m); len(got) != 1 || got[0] != "/a/one.xml" {
t.Errorf("manifest = %v, want [/a/one.xml]", got)
}
// The trimmed form is what a repeat is compared against.
if err := register(m, "/a/one.xml", true); !errors.Is(err, errDuplicateEntry) {
t.Errorf("repeat after trim = %v, want errDuplicateEntry", err)
}
}
func TestDefaultManifest(t *testing.T) {
got := defaultManifest("/tmp/artifacts/e2e-gvisor.xml")
want := filepath.Join("/tmp/artifacts", "expected-junit.txt")
if got != want {
t.Errorf("defaultManifest() = %s, want %s", got, want)
}
}
func TestReadManifest(t *testing.T) {
dir := t.TempDir()
for _, tc := range []struct {
name string
content string
want []string
}{
{name: "one per line", content: "a.xml\nb.xml\n", want: []string{"a.xml", "b.xml"}},
{name: "duplicates collapse", content: "a.xml\nb.xml\na.xml\n", want: []string{"a.xml", "b.xml"}},
{name: "all duplicates collapse to one", content: "a.xml\na.xml\na.xml\n", want: []string{"a.xml"}},
{name: "first occurrence sets the order", content: "b.xml\na.xml\nb.xml\n", want: []string{"b.xml", "a.xml"}},
{name: "blank lines ignored", content: "\na.xml\n\n\nb.xml\n", want: []string{"a.xml", "b.xml"}},
{name: "whitespace trimmed", content: " a.xml \n\tb.xml\t\n", want: []string{"a.xml", "b.xml"}},
{name: "no trailing newline", content: "a.xml", want: []string{"a.xml"}},
{name: "empty manifest yields nothing", content: "", want: nil},
{name: "whitespace-only yields nothing", content: "\n \n\t\n", want: nil},
} {
t.Run(tc.name, func(t *testing.T) {
p := filepath.Join(dir, strings.ReplaceAll(tc.name, " ", "_")+".txt")
if err := os.WriteFile(p, []byte(tc.content), 0o644); err != nil {
t.Fatal(err)
}
got, err := readManifest(p)
if err != nil {
t.Fatalf("readManifest() error = %v", err)
}
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
t.Errorf("readManifest() = %v, want %v", got, tc.want)
}
})
}
}
func TestReadManifestMissingFile(t *testing.T) {
if _, err := readManifest(filepath.Join(t.TempDir(), "absent.txt")); err == nil {
t.Fatal("readManifest() on a missing file = nil error, want error")
}
}
// What register writes is what readManifest returns.
func TestRegisterThenReadRoundTrip(t *testing.T) {
m := filepath.Join(t.TempDir(), "expected-junit.txt")
want := []string{"/a/gvisor.xml", "/a/microvm.xml", "/a/mitm.xml"}
for _, e := range want {
if err := register(m, e, true); err != nil {
t.Fatalf("register(%s): %v", e, err)
}
}
got, err := readManifest(m)
if err != nil {
t.Fatalf("readManifest: %v", err)
}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("round trip = %v, want %v", got, want)
}
}