blob: 886d8f4cf5f23ed2348d812979aab7a6f36b8c65 [file]
package main
import (
"context"
"encoding/xml"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strconv"
"time"
"cloud.google.com/go/storage"
"go.skia.org/infra/go/gerrit"
"go.skia.org/infra/go/gitauth"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/go/util"
"google.golang.org/api/iterator"
"go.skia.org/infra/task_driver/go/lib/bazel"
"go.skia.org/infra/task_driver/go/lib/checkout"
"go.skia.org/infra/task_driver/go/lib/git_steps"
"go.skia.org/infra/task_driver/go/lib/os_steps"
"go.skia.org/infra/task_driver/go/td"
)
// flags
var (
outputPath = flag.String("output", "local-test-results", "Test result output path.")
bucketName = flag.String("bucket", "", "The GCS bucket name to upload the test result to.")
bazelCacheDir = flag.String("bazel_cache_dir", "", "Path to the Bazel cache directory.")
bazelRepoCacheDir = flag.String("bazel_repo_cache_dir", "", "Path to the Bazel repository cache directory.")
workdir = flag.String("workdir", "", "Directory to use for scratch work.")
rbeKey = flag.String("rbe_key", "", "Path to the service account key to use for RBE.")
projectId = flag.String("project_id", "skia-swarming-bots", "The GCE project.")
taskId = flag.String("task_id", "", "The Swarming task ID.")
taskName = flag.String("task_name", "e2e-test-runner", "The Swarming task name.")
checkoutFlags = checkout.SetupFlags(nil)
local = flag.Bool("local", true, "Running locally if true. As opposed to running on a bot.")
)
var (
testResultsFileName = "test_result.xml"
)
const (
maxObjectPrefixRetries = 10
)
// TestSuites is the root element for xUnit XML reports.
type TestSuites struct {
XMLName xml.Name `xml:"testsuites"`
Name string `xml:"name,attr"`
TestSuite []TestSuite `xml:"testsuite"`
}
// TestSuite represents a single suite of tests.
type TestSuite struct {
XMLName xml.Name `xml:"testsuite"`
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"`
Timestamp string `xml:"timestamp,attr"`
Time string `xml:"time,attr"`
TestCases []TestCase `xml:"testcase"`
}
// TestCase represents a single test case.
type TestCase struct {
XMLName xml.Name `xml:"testcase"`
Name string `xml:"name,attr"`
ClassName string `xml:"classname,attr"`
Result string `xml:"result,attr"`
Time string `xml:"time,attr"`
}
// generateUniqueObjectPrefix creates a unique GCS object prefix.
func generateUniqueObjectPrefix(ctx context.Context, client *storage.Client) (string, error) {
now := time.Now().UTC()
basePrefix := now.Format("2006-01-02/15-04-05")
objectPrefix := basePrefix
counter := 0
// Create a unique GCS folder for storing the test result.
for {
it := client.Bucket(*bucketName).Objects(ctx, &storage.Query{Prefix: objectPrefix + "/"})
_, err := it.Next()
if err == iterator.Done {
break
}
if err != nil {
return "", fmt.Errorf("failed to check for existing GCS objects: %w", err)
}
counter++
if counter > maxObjectPrefixRetries {
return "", fmt.Errorf("failed to find a unique object prefix after %d tries", counter)
}
objectPrefix = fmt.Sprintf("%s_%d", basePrefix, counter)
}
return objectPrefix, nil
}
// uploadFile uploads the given file to GCS.
func uploadFile(ctx context.Context, filePath string) error {
if *bucketName == "" {
return nil
}
client, err := storage.NewClient(ctx)
if err != nil {
return fmt.Errorf("storage.NewClient: %w", err)
}
defer client.Close()
objectPrefix, err := generateUniqueObjectPrefix(ctx, client)
if err != nil {
return fmt.Errorf("failed to generate unique object name: %w", err)
}
objectName := filepath.Join(objectPrefix, testResultsFileName)
// Open local file.
f, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("os.Open: %w", err)
}
defer f.Close()
wc := client.Bucket(*bucketName).Object(objectName).NewWriter(ctx)
wc.ContentType = "application/xml"
if _, err := io.Copy(wc, f); err != nil {
return fmt.Errorf("io.Copy: %w", err)
}
if err := wc.Close(); err != nil {
return fmt.Errorf("failed to close GCS writer: %w", err)
}
sklog.Infof("Successfully uploaded test result to gs://%s/%s", *bucketName, objectName)
return nil
}
// runNodejsTest runs an end-to-end Nodejs test.
func runNodejsTest(ctx context.Context, testFile string) (string, int, error) {
// Compute work dir path.
var workDir string
var err error
if *workdir == "" {
workDir, err = os.MkdirTemp("", "")
if err != nil {
return "", 0, err
}
defer util.RemoveAll(workDir)
} else {
workDir, err = os_steps.Abs(ctx, *workdir)
if err != nil {
td.Fatal(ctx, err)
}
}
// Check out the code.
ts, err := git_steps.Init(ctx, *local)
if err != nil {
td.Fatal(ctx, err)
}
if !*local {
client := httputils.DefaultClientConfig().WithTokenSource(ts).Client()
g, err := gerrit.NewGerrit("https://skia-review.googlesource.com", client)
if err != nil {
td.Fatal(ctx, err)
}
email, err := g.GetUserEmail(ctx)
if err != nil {
td.Fatal(ctx, err)
}
if _, err := gitauth.New(ctx, ts, "/tmp/.gitcookies", true, email); err != nil {
td.Fatal(ctx, err)
}
}
repoState, err := checkout.GetRepoState(checkoutFlags)
if err != nil {
td.Fatal(ctx, err)
}
repoPath := filepath.Join(workDir, "repo")
gitDir, err := checkout.EnsureGitCheckout(ctx, repoPath, repoState)
if err != nil {
td.Fatal(ctx, err)
}
opts := bazel.BazelOptions{
CachePath: *bazelCacheDir,
RepositoryCachePath: *bazelRepoCacheDir,
}
bzl, err := bazel.New(ctx, gitDir.Dir(), *rbeKey, opts)
if err != nil {
return "", 0, err
}
result, err := bzl.Do(ctx, "test", "--config=mayberemote", "--nocache_test_results", testFile, "--test_output=all")
if err != nil {
// Find the number of failing tests from the output.
re := regexp.MustCompile(`(\d+) (test|tests) FAILED`)
matches := re.FindStringSubmatch(result)
if len(matches) > 1 {
failures, err := strconv.Atoi(matches[1])
if err != nil {
sklog.Warningf("Failed to convert number of failures to int: %v", err)
}
return result, failures, nil
}
sklog.Warningf("Missing number of failures from output: %v", err)
return result, 1, nil
}
return result, 0, nil
}
// generateDummyTestResult generates a dummy test results xml.
// After adding real tests, this function must be removed.
func generateDummyTestResult(ctx context.Context) ([]byte, error) {
result, failures, err := runNodejsTest(ctx, "//perf/go/e2e/tests:example_nodejs_test")
if err != nil {
return nil, fmt.Errorf("failed to run a nodejs test: %w", err)
}
suites := TestSuites{
Name: "results",
TestSuite: []TestSuite{
{
Name: "dummy suite",
Tests: 1,
Failures: failures,
Errors: 0,
Skipped: 0,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Time: "0.1",
TestCases: []TestCase{
{
Name: "dummy test",
ClassName: "dummy.class",
Result: result,
Time: "0.1",
},
},
},
},
}
xmlBytes, err := xml.MarshalIndent(suites, "", " ")
if err != nil {
return nil, fmt.Errorf("error marshalling XML: %w", err)
}
return xmlBytes, nil
}
func main() {
ctx := td.StartRun(projectId, taskId, taskName, outputPath, local)
defer td.EndRun(ctx)
flag.Parse()
if *outputPath == "" {
sklog.Fatal("The --output flag must be provided.")
}
xmlBytes, err := generateDummyTestResult(ctx)
if err != nil {
sklog.Fatalf("Failed to generate test result: %v", err)
}
if *bucketName == "" {
if _, err := os.Stat(*outputPath); os.IsNotExist(err) {
if err := os.MkdirAll(*outputPath, 0755); err != nil {
sklog.Fatalf("Failed to create output directory %s: %v", *outputPath, err)
}
}
}
filePath := filepath.Join(*outputPath, testResultsFileName)
if err := os.WriteFile(filePath, xmlBytes, 0644); err != nil {
sklog.Fatalf("Failed to write to test result file: %v", err)
}
sklog.Infof("Successfully generated test result at %s", filePath)
if err := uploadFile(ctx, filePath); err != nil {
sklog.Fatalf("Failed to upload test result to GCS: %v", err)
}
}