blob: 5281b50fc8692ac7ec084070740391dff70ce2cb [file] [log] [blame]
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"io/ioutil"
"log"
"net"
"net/url"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
"go.fuchsia.dev/fuchsia/tools/botanist/constants"
"go.fuchsia.dev/fuchsia/tools/botanist/target"
"go.fuchsia.dev/fuchsia/tools/integration/testsharder"
"go.fuchsia.dev/fuchsia/tools/lib/color"
"go.fuchsia.dev/fuchsia/tools/lib/environment"
"go.fuchsia.dev/fuchsia/tools/lib/logger"
"go.fuchsia.dev/fuchsia/tools/net/sshutil"
"go.fuchsia.dev/fuchsia/tools/testing/runtests"
"go.fuchsia.dev/fuchsia/tools/testing/tap"
"go.fuchsia.dev/fuchsia/tools/testing/testparser"
"go.fuchsia.dev/fuchsia/tools/testing/testrunner"
"golang.org/x/sync/errgroup"
)
// Fuchsia-specific environment variables possibly exposed to the testrunner.
const (
// A directory that will be automatically archived on completion of a task.
testOutDirEnvKey = "FUCHSIA_TEST_OUTDIR"
)
// Command-line flags
var (
// Whether to show Usage and exit.
help bool
// The path where a directory containing test results should be created.
outDir string
// Working directory of the local testing subprocesses.
localWD string
// Whether to use runtests when executing tests on fuchsia. If false, the
// default will be run_test_component.
useRuntests bool
// The output filename for the snapshot. This will be created in the outDir.
snapshotFile string
// Per-test timeout.
perTestTimeout time.Duration
// Logger level.
level = logger.InfoLevel
)
func usage() {
fmt.Printf(`testrunner [flags] tests-file
Executes all tests found in the JSON [tests-file]
Fuchsia tests require both the node address of the fuchsia instance and a private
SSH key corresponding to a authorized key to be set in the environment under
%s and %s respectively.
`, constants.DeviceAddrEnvKey, constants.SSHKeyEnvKey)
}
func main() {
flag.BoolVar(&help, "help", false, "Whether to show Usage and exit.")
flag.StringVar(&outDir, "out-dir", "", "Optional path where a directory containing test results should be created.")
flag.StringVar(&localWD, "C", "", "Working directory of local testing subprocesses; if unset the current working directory will be used.")
flag.BoolVar(&useRuntests, "use-runtests", false, "Whether to default to running fuchsia tests with runtests; if false, run_test_component will be used.")
flag.StringVar(&snapshotFile, "snapshot-output", "", "The output filename for the snapshot. This will be created in the output directory.")
// TODO(fxbug.dev/36480): Support different timeouts for different tests.
flag.DurationVar(&perTestTimeout, "per-test-timeout", 0, "Per-test timeout, applied to all tests. Ignored if <= 0.")
flag.Var(&level, "level", "Output verbosity, can be fatal, error, warning, info, debug or trace.")
flag.Usage = usage
flag.Parse()
if help || flag.NArg() != 1 {
flag.Usage()
flag.PrintDefaults()
return
}
const logFlags = log.Ltime | log.Lmicroseconds | log.Lshortfile
// Our mDNS library doesn't use the logger library.
log.SetFlags(logFlags)
log := logger.NewLogger(level, color.NewColor(color.ColorAuto), os.Stdout, os.Stderr, "testrunner ")
log.SetFlags(logFlags)
ctx := logger.WithLogger(context.Background(), log)
testsPath := flag.Arg(0)
tests, err := loadTests(testsPath)
if err != nil {
logger.Fatalf(ctx, "failed to load tests from %q: %v", testsPath, err)
}
// Configure a test outputs object, responsible for producing TAP output,
// recording data sinks, and archiving other test outputs.
testOutDir := filepath.Join(os.Getenv(testOutDirEnvKey), outDir)
if testOutDir == "" {
var err error
testOutDir, err = ioutil.TempDir("", "testrunner")
if err != nil {
logger.Fatalf(ctx, "failed to create a test output directory")
}
}
logger.Debugf(ctx, "test output directory: %s", testOutDir)
tapProducer := tap.NewProducer(os.Stdout)
tapProducer.Plan(len(tests))
outputs, err := createTestOutputs(tapProducer, testOutDir)
if err != nil {
logger.Fatalf(ctx, "failed to create test results object: %v", err)
}
defer outputs.Close()
var addr net.IPAddr
if deviceAddr, ok := os.LookupEnv(constants.DeviceAddrEnvKey); ok {
addrPtr, err := net.ResolveIPAddr("ip", deviceAddr)
if err != nil {
logger.Fatalf(ctx, "failed to parse device address %s: %s", deviceAddr, err)
}
addr = *addrPtr
}
sshKeyFile := os.Getenv(constants.SSHKeyEnvKey)
cleanUp, err := environment.Ensure()
if err != nil {
logger.Fatalf(ctx, "failed to setup environment: %v", err)
}
defer cleanUp()
serialSocketPath := os.Getenv(constants.SerialSocketEnvKey)
if err := execute(ctx, tests, outputs, addr, sshKeyFile, serialSocketPath, testOutDir); err != nil {
logger.Fatalf(ctx, err.Error())
}
}
func validateTest(test testsharder.Test) error {
if test.Name == "" {
return fmt.Errorf("one or more tests missing `name` field")
}
if test.OS == "" {
return fmt.Errorf("one or more tests missing `os` field")
}
if test.Runs <= 0 {
return fmt.Errorf("one or more tests with invalid `runs` field")
}
if test.Runs > 1 {
if test.RunAlgorithm == "" {
return fmt.Errorf("one or more tests with invalid `run_algorithm` field")
}
}
if test.OS == "fuchsia" && test.PackageURL == "" && test.Path == "" {
return fmt.Errorf("one or more fuchsia tests missing the `path` and `package_url` fields")
}
if test.OS != "fuchsia" {
if test.PackageURL != "" {
return fmt.Errorf("one or more host tests have a `package_url` field present")
} else if test.Path == "" {
return fmt.Errorf("one or more host tests missing the `path` field")
}
}
return nil
}
func loadTests(path string) ([]testsharder.Test, error) {
bytes, err := ioutil.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read %q: %w", path, err)
}
var tests []testsharder.Test
if err := json.Unmarshal(bytes, &tests); err != nil {
return nil, fmt.Errorf("failed to unmarshal %q: %w", path, err)
}
for _, test := range tests {
if err := validateTest(test); err != nil {
return nil, err
}
}
return tests, nil
}
type tester interface {
Test(context.Context, testsharder.Test, io.Writer, io.Writer, string) (runtests.DataSinkReference, error)
Close() error
EnsureSinks(context.Context, []runtests.DataSinkReference) error
RunSnapshot(context.Context, string) error
}
// TODO: write tests for this function. Tests were deleted in fxrev.dev/407968 as part of a refactoring.
func execute(ctx context.Context, tests []testsharder.Test, outputs *testOutputs, addr net.IPAddr, sshKeyFile, serialSocketPath, outDir string) error {
var fuchsiaSinks, localSinks []runtests.DataSinkReference
var fuchsiaTester, localTester tester
for _, test := range tests {
var t tester
var sinks *[]runtests.DataSinkReference
switch test.OS {
case "fuchsia":
if fuchsiaTester == nil {
var err error
if sshKeyFile != "" {
fuchsiaTester, err = newFuchsiaSSHTester(ctx, addr, sshKeyFile, outputs.outDir, serialSocketPath, useRuntests, perTestTimeout)
} else {
if serialSocketPath == "" {
return fmt.Errorf("%q must be set if %q is not set", constants.SerialSocketEnvKey, constants.SSHKeyEnvKey)
}
fuchsiaTester, err = newFuchsiaSerialTester(ctx, serialSocketPath, perTestTimeout)
}
if err != nil {
return fmt.Errorf("failed to initialize fuchsia tester: %w", err)
}
}
t = fuchsiaTester
sinks = &fuchsiaSinks
case "linux", "mac":
if test.OS == "linux" && runtime.GOOS != "linux" {
return fmt.Errorf("cannot run linux tests when GOOS = %q", runtime.GOOS)
}
if test.OS == "mac" && runtime.GOOS != "darwin" {
return fmt.Errorf("cannot run mac tests when GOOS = %q", runtime.GOOS)
}
// Initialize the fuchsia SSH tester to run the snapshot at the end in case
// we ran any host-target interaction tests.
if fuchsiaTester == nil && sshKeyFile != "" {
var err error
fuchsiaTester, err = newFuchsiaSSHTester(ctx, addr, sshKeyFile, outputs.outDir, serialSocketPath, useRuntests, perTestTimeout)
if err != nil {
logger.Errorf(ctx, "failed to initialize fuchsia tester: %s", err)
}
}
if localTester == nil {
localEnv := append(os.Environ(),
// Tell tests written in Rust to print stack on failures.
"RUST_BACKTRACE=1",
)
localTester = newSubprocessTester(localWD, localEnv, outputs.outDir, perTestTimeout)
}
t = localTester
sinks = &localSinks
default:
return fmt.Errorf("test %#v has unsupported OS: %q", test, test.OS)
}
results, err := runAndOutputTest(ctx, test, t, outputs, os.Stdout, os.Stderr, outDir)
if err != nil {
if isTestSkippedErr(err) {
// test was skipped intentionally, don't return error.
continue
}
return err
}
for _, result := range results {
*sinks = append(*sinks, result.DataSinks)
}
}
if fuchsiaTester != nil {
defer fuchsiaTester.Close()
}
if localTester != nil {
defer localTester.Close()
}
finalize := func(t tester, sinks []runtests.DataSinkReference) error {
if t != nil {
if err := t.RunSnapshot(ctx, snapshotFile); err != nil {
// This error usually has a different root cause that gets masked when we
// return this error. Log it so we can keep track of it, but don't fail.
logger.Errorf(ctx, err.Error())
}
if err := t.EnsureSinks(ctx, sinks); err != nil {
return err
}
}
return nil
}
if err := finalize(localTester, localSinks); err != nil {
return err
}
if err := finalize(fuchsiaTester, fuchsiaSinks); err != nil {
return err
}
return nil
}
// stdioBuffer is a simple thread-safe wrapper around bytes.Buffer. It
// implements the io.Writer interface.
type stdioBuffer struct {
// Used to protect access to `buf`.
mu sync.Mutex
// The underlying buffer.
buf bytes.Buffer
}
func (b *stdioBuffer) Write(p []byte) (n int, err error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func runAndOutputTest(ctx context.Context, test testsharder.Test, t tester, outputs *testOutputs, collectiveStdout, collectiveStderr io.Writer, outDir string) ([]*testrunner.TestResult, error) {
var results []*testrunner.TestResult
runTestCtx := ctx
if test.TimeoutSecs > 0 {
var cancel func()
runTestCtx, cancel = context.WithTimeout(ctx, time.Duration(test.TimeoutSecs)*time.Second)
defer cancel()
}
eg, runTestCtx := errgroup.WithContext(runTestCtx)
eg.Go(func() error {
for i := 0; i < test.Runs; i++ {
outDirForI := filepath.Join(outDir, url.PathEscape(strings.ReplaceAll(test.Name, ":", "")), strconv.Itoa(i))
result, err := runTestOnce(runTestCtx, test, t, i, collectiveStdout, collectiveStderr, outDirForI)
if err != nil {
return err
}
if err := outputs.record(*result); err != nil {
return err
}
results = append(results, result)
if (test.RunAlgorithm == testsharder.StopOnSuccess && result.Result == runtests.TestSuccess) ||
(test.RunAlgorithm == testsharder.StopOnFailure && result.Result == runtests.TestFailure) {
break
}
}
return nil
})
err := eg.Wait()
if errors.Is(err, context.DeadlineExceeded) && len(results) > 0 {
// If the timeout was reached but previous runs succeeded, we
// just want to return the completed runs instead of failing for
// a timeout failure.
err = nil
}
return results, err
}
func runTestOnce(ctx context.Context, test testsharder.Test, t tester, runIndex int, collectiveStdout, collectiveStderr io.Writer, outDir string) (*testrunner.TestResult, error) {
// The test case parser specifically uses stdout, so we need to have a
// dedicated stdout buffer.
stdout := new(bytes.Buffer)
stdio := new(stdioBuffer)
multistdout := io.MultiWriter(collectiveStdout, stdio, stdout)
multistderr := io.MultiWriter(collectiveStderr, stdio)
// In the case of running tests on QEMU over serial, we do not wish to
// forward test output to stdout, as QEMU is already redirecting serial
// output there: we do not want to double-print.
//
// This is a bit of a hack, but is a lesser evil than extending the
// testrunner CLI just to sidecar the information of 'is QEMU'.
againstQEMU := os.Getenv(constants.NodenameEnvKey) == target.DefaultQEMUNodename
if _, ok := t.(*fuchsiaSerialTester); ok && againstQEMU {
multistdout = io.MultiWriter(stdio, stdout)
}
result := runtests.TestSuccess
startTime := time.Now()
dataSinks, err := t.Test(ctx, test, multistdout, multistderr, outDir)
if err != nil {
result = runtests.TestFailure
if isTestSkippedErr(err) {
return nil, err
}
logger.Errorf(ctx, err.Error())
if sshutil.IsConnectionError(err) {
return nil, err
}
if runIndex > 0 && errors.Is(ctx.Err(), context.DeadlineExceeded) {
// If this is a rerun and the timeout was reached, return the
// DeadlineExceeded error so as not to record the output.
// Only completed runs will be recorded.
return nil, ctx.Err()
}
}
endTime := time.Now()
// Record the test details in the summary.
return &testrunner.TestResult{
Name: test.Name,
GNLabel: test.Label,
Stdio: stdio.buf.Bytes(),
Result: result,
Cases: testparser.Parse(stdout.Bytes()),
StartTime: startTime,
EndTime: endTime,
DataSinks: dataSinks,
RunIndex: runIndex,
}, nil
}