Files
substrate/tools/junittool/main.go
T
Steven Shriver 7c0c70c7a2 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
2026-09-28 22:01:26 +00:00

133 lines
3.9 KiB
Go

// 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
}