blob: 870581b968bcb158e6d65240d306f7902e7c8fcb [file] [log] [blame]
// Copyright 2020 The ChromiumOS Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package bundle
import (
"context"
"encoding/json"
"io"
"strings"
"time"
"go.chromium.org/tast/core/dut"
"go.chromium.org/tast/core/errors"
"go.chromium.org/tast/core/internal/bundle/legacyjson"
"go.chromium.org/tast/core/internal/command"
"go.chromium.org/tast/core/internal/testcontext"
"go.chromium.org/tast/core/internal/testing"
)
const (
statusSuccess = 0 // bundle ran successfully
statusError = 1 // unclassified runtime error was encountered
statusBadArgs = 2 // bad command-line flags or other args were supplied
statusBadTests = 3 // errors in test registration (bad names, missing test functions, etc.)
statusBadPatterns = 4 // one or more bad test patterns were passed to the bundle
_ = 5 // deprecated
)
// Delegate injects functions as a part of test bundle framework implementation.
type Delegate struct {
// TestHook is called before each test in the test bundle if it is not nil.
// The returned closure is executed after the test if it is not nil.
TestHook func(context.Context, *testing.TestHookState) func(context.Context, *testing.TestHookState)
// RunHook is called at the beginning of a bundle execution if it is not nil.
// The returned closure is executed at the end if it is not nil.
// In case of errors, no test in the test bundle will run.
RunHook func(context.Context) (func(context.Context) error, error)
// Ready is called at the beginning of a bundle execution if it is not
// nil and -waituntilready is set to true (default).
// systemTestsTimeout is the timeout for waiting for system services
// to be ready in seconds.
// Local test bundles can specify a function to wait for the DUT to be
// ready for tests to run. It is recommended to write informational
// messages with testing.ContextLog to let the user know the reason for
// the delay. In case of errors, no test in the test bundle will run.
// This field has an effect only for local test bundles.
Ready func(ctx context.Context, systemTestsTimeout time.Duration) error
// BeforeReboot is called before every reboot if it is not nil.
// This field has an effect only for remote test bundles.
BeforeReboot func(ctx context.Context, d *dut.DUT) error
// BeforeDownload is called before the framework attempts to download
// external data files if it is not nil.
//
// Test bundles can install this hook to recover from possible network
// outage caused by previous tests. Note that it is called only when
// the framework needs to download one or more external data files.
//
// Since no specific timeout is set to ctx, do remember to set a
// reasonable timeout at the beginning of the hook to avoid blocking
// for long time.
BeforeDownload func(ctx context.Context)
}
// run reads a JSON-marshaled BundleArgs struct from stdin and performs the requested action.
// Default arguments may be specified via args, which will also be updated from stdin.
// The caller should exit with the returned status code.
func run(ctx context.Context, clArgs []string, stdin io.Reader, stdout, stderr io.Writer, scfg *StaticConfig) int {
args, err := readArgs(clArgs, stderr)
if err != nil {
return command.WriteError(stderr, err)
}
if errs := scfg.registry.Errors(); len(errs) > 0 {
es := make([]string, len(errs))
for i, err := range errs {
es[i] = err.Error()
}
err := command.NewStatusErrorf(statusBadTests, "error(s) in registered tests: %v", strings.Join(es, ", "))
return command.WriteError(stderr, err)
}
switch args.mode {
case modeDumpTests:
tests, err := testsToRun(scfg, nil)
if err != nil {
return command.WriteError(stderr, err)
}
switch args.dumpFormat {
case dumpFormatLegacyJSON:
if err := writeTestsAsLegacyJSON(stdout, tests); err != nil {
return command.WriteError(stderr, err)
}
return statusSuccess
case dumpFormatProto:
if err := testing.WriteTestsAsProto(stdout, tests); err != nil {
return command.WriteError(stderr, err)
}
default:
return command.WriteError(stderr, errors.Errorf("invalid dump format %v", args.dumpFormat))
}
return statusSuccess
case modeRPC:
if err := RunRPCServer(stdin, stdout, scfg); err != nil {
return command.WriteError(stderr, err)
}
return statusSuccess
case modeRPCTCP:
port := args.port
handshakeReq := args.handshake
if err := RunRPCServerTCP(port, handshakeReq, stdin, stdout, stderr, scfg); err != nil {
return command.WriteError(stderr, err)
}
return statusSuccess
default:
return command.WriteError(stderr, command.NewStatusErrorf(statusBadArgs, "invalid mode %v", args.mode))
}
}
func writeTestsAsLegacyJSON(w io.Writer, tests []*testing.TestInstance) error {
var infos []*legacyjson.EntityWithRunnabilityInfo
for _, test := range tests {
// If we encounter errors while checking test dependencies,
// treat the test as not skipped. When we actually try to
// run the test later, it will fail with errors.
var skipReason string
if reasons, err := test.Deps().Check(nil); err == nil && len(reasons) > 0 {
skipReason = strings.Join(append([]string(nil), reasons...), ", ")
}
infos = append(infos, &legacyjson.EntityWithRunnabilityInfo{
EntityInfo: *legacyjson.MustEntityInfoFromProto(test.EntityProto()),
SkipReason: skipReason,
})
}
return json.NewEncoder(w).Encode(infos)
}
// StaticConfig contains configurations unique to a test bundle.
//
// The supplied functions are used to provide customizations that apply to all
// entities in a test bundle. They may contain bundle-specific code.
type StaticConfig struct {
// registry is a registry to be used to find entities.
registry *testing.Registry
// runHook is run at the beginning of the entire series of tests if non-nil.
// The returned closure is executed after the entire series of tests if not nil.
runHook func(context.Context, time.Duration) (func(context.Context) error, error)
// testHook is run before each test if non-nil.
// If this function panics or reports errors, the precondition (if any)
// will not be prepared and the test function will not run.
// The returned closure is executed after a test if not nil.
testHook func(context.Context, *testing.TestHookState) func(context.Context, *testing.TestHookState)
// beforeReboot is run before every reboot if non-nil.
// The function must not call DUT.Reboot() or it will cause infinite recursion.
beforeReboot func(context.Context, *dut.DUT) error
// beforeDownload is run before downloading external data files if non-nil.
beforeDownload func(context.Context)
// defaultTestTimeout contains the default maximum time allotted to each test.
// It is only used if testing.Test.Timeout is unset.
defaultTestTimeout time.Duration
}
// NewStaticConfig constructs StaticConfig from given parameters.
func NewStaticConfig(reg *testing.Registry, defaultTestTimeout time.Duration, d Delegate) *StaticConfig {
return &StaticConfig{
registry: reg,
runHook: func(ctx context.Context, systemTestsTimeout time.Duration) (func(context.Context) error, error) {
pd, ok := testcontext.PrivateDataFromContext(ctx)
if !ok {
panic("BUG: PrivateData not available in run hook")
}
if d.Ready != nil && pd.WaitUntilReady {
ctxWithTimeout := ctx
if pd.WaitUntilReadyTimeout > 0 {
var cancel context.CancelFunc
ctxWithTimeout, cancel = context.WithTimeout(ctx, pd.WaitUntilReadyTimeout)
defer cancel()
}
if err := d.Ready(ctxWithTimeout, systemTestsTimeout); err != nil {
return nil, err
}
}
if d.RunHook == nil {
return nil, nil
}
return d.RunHook(ctx)
},
testHook: d.TestHook,
beforeReboot: d.BeforeReboot,
beforeDownload: d.BeforeDownload,
defaultTestTimeout: defaultTestTimeout,
}
}