blob: c9b127668c91b9f94e487ef166b19f61b6f4abcd [file] [log] [blame]
// Copyright 2022 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 (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"go.fuchsia.dev/fuchsia/tools/build"
"go.fuchsia.dev/fuchsia/tools/integration/testsharder"
"go.fuchsia.dev/fuchsia/tools/lib/ffxutil"
"go.fuchsia.dev/fuchsia/tools/lib/jsonutil"
)
var (
updateGoldens = flag.Bool("update-goldens", false, "Whether to update goldens")
goldensDir = flag.String("goldens-dir", "testdata", "Directory containing goldens")
)
const testListPath = "fake-test-list.json"
type mockFFX struct {
ffxutil.MockFFXInstance
}
func (m *mockFFX) GetPBArtifacts(ctx context.Context, pbPath, group string) ([]string, error) {
return []string{"zbi"}, nil
}
// TestExecute runs golden tests for the execute() function.
//
// To add a new test case:
// 1. Add an entry to the `testCases` slice here.
// 2. Run `tools/integration/testsharder/update_goldens.sh` to generate the new
// golden file.
// 3. Add the new golden file as a dependency of the test executable in
// testsharder's BUILD.gn file.
func TestExecute(t *testing.T) {
ctx := context.Background()
// Clear pre-existing golden files to avoid leaving stale ones around.
if *updateGoldens {
files, err := filepath.Glob(filepath.Join(*goldensDir, "*.golden.json"))
if err != nil {
t.Fatal(err)
}
for _, f := range files {
if err := os.Remove(f); err != nil {
t.Fatal(err)
}
}
}
testCases := []struct {
name string
flags testsharderFlags
testSpecs []build.TestSpec
testDurations []build.TestDuration
testList []build.TestListEntry
modifiers []testsharder.TestModifier
packageRepos []build.PackageRepo
affectedTests []string
}{
{
name: "no tests",
},
{
name: "mixed device types",
testSpecs: []build.TestSpec{
fuchsiaTestSpec("foo"),
hostTestSpec("bar"),
},
},
{
// Two tests whose dimensions differ only by some random dimension
// ("other_dimension") should still be sharded separately.
name: "arbitrary dimensions",
testSpecs: []build.TestSpec{
{
Test: build.Test{
Name: "host_x64/foo.sh",
Path: "host_x64/foo.sh",
OS: "linux",
CPU: "x64",
Label: "//tools/other:foo(//build/toolchain/host_x64)",
},
Envs: []build.Environment{
{
Dimensions: build.DimensionSet{
"cpu": "x64",
"os": "Linux",
"other_dimension": "foo",
},
},
},
},
{
Test: build.Test{
Name: "host_x64/bar.sh",
Path: "host_x64/bar.sh",
OS: "linux",
CPU: "x64",
Label: "//tools/other:bar(//build/toolchain/host_x64)",
},
Envs: []build.Environment{
{
Dimensions: build.DimensionSet{
"cpu": "x64",
"os": "Linux",
"other_dimension": "bar",
},
},
},
},
},
},
{
name: "multiply",
flags: testsharderFlags{
targetDurationSecs: 5,
},
testSpecs: []build.TestSpec{
fuchsiaTestSpec("foo"),
fuchsiaTestSpec("bar"),
},
modifiers: []testsharder.TestModifier{
{
Name: "foo",
TotalRuns: 50,
},
{
Name: "bar",
},
},
testDurations: []build.TestDuration{
{
Name: "*",
MedianDuration: time.Millisecond,
},
},
},
{
name: "affected tests",
testSpecs: []build.TestSpec{
fuchsiaTestSpec("affected-hermetic"),
fuchsiaTestSpec("not-affected"),
},
testList: []build.TestListEntry{
testListEntry("affected-hermetic", true),
testListEntry("not-affected", false),
},
affectedTests: []string{
packageURL("affected-hermetic"),
},
},
{
name: "affected nonhermetic tests",
testSpecs: []build.TestSpec{
fuchsiaTestSpec("affected-nonhermetic"),
fuchsiaTestSpec("not-affected"),
},
affectedTests: []string{
packageURL("affected-nonhermetic"),
},
},
{
name: "target test count",
flags: testsharderFlags{
targetTestCount: 2,
},
testSpecs: []build.TestSpec{
fuchsiaTestSpec("foo1"),
fuchsiaTestSpec("foo2"),
fuchsiaTestSpec("foo3"),
fuchsiaTestSpec("foo4"),
},
},
{
name: "sharding by time",
flags: testsharderFlags{
targetDurationSecs: int((4 * time.Minute).Seconds()),
perTestTimeoutSecs: int((10 * time.Minute).Seconds()),
},
testSpecs: []build.TestSpec{
fuchsiaTestSpec("slow"),
fuchsiaTestSpec("fast1"),
fuchsiaTestSpec("fast2"),
fuchsiaTestSpec("fast3"),
},
testDurations: []build.TestDuration{
{
Name: "*",
MedianDuration: 2 * time.Second,
},
{
Name: packageURL("slow"),
MedianDuration: 5 * time.Minute,
},
},
},
{
name: "max shards per env",
flags: testsharderFlags{
// Given expected test durations of 4 minutes for each test it's
// impossible to satisfy both the target shard duration and the
// max shards per environment, so the target shard duration
// should effectively be ignored.
targetDurationSecs: int((5 * time.Minute).Seconds()),
maxShardsPerEnvironment: 2,
skipUnaffected: true,
},
testSpecs: []build.TestSpec{
fuchsiaTestSpec("affected1"),
fuchsiaTestSpec("affected2"),
fuchsiaTestSpec("affected3"),
fuchsiaTestSpec("affected4"),
fuchsiaTestSpec("unaffected1"),
fuchsiaTestSpec("unaffected2"),
fuchsiaTestSpec("nonhermetic1"),
fuchsiaTestSpec("nonhermetic2"),
},
testDurations: []build.TestDuration{
{
Name: "*",
MedianDuration: 4 * time.Minute,
},
},
affectedTests: []string{
packageURL("affected1"),
packageURL("affected2"),
packageURL("affected3"),
packageURL("affected4"),
},
testList: []build.TestListEntry{
testListEntry("affected1", true),
testListEntry("affected2", true),
testListEntry("affected3", true),
testListEntry("affected4", true),
testListEntry("unaffected1", true),
testListEntry("unaffected2", true),
testListEntry("nonhermetic1", false),
testListEntry("nonhermetic2", false),
},
},
{
name: "hermetic deps",
flags: testsharderFlags{
hermeticDeps: true,
},
testSpecs: []build.TestSpec{
fuchsiaTestSpec("foo"),
fuchsiaTestSpec("bar"),
fuchsiaTestSpec("baz"),
},
packageRepos: []build.PackageRepo{
{
Path: "pkg_repo1",
Blobs: filepath.Join("pkg_repo1", "blobs"),
Targets: filepath.Join("pkg_repo1", "targets.json"),
},
},
},
{
name: "multiply affected test",
flags: testsharderFlags{
affectedTestsMultiplyThreshold: 3,
targetDurationSecs: int(2 * time.Minute.Seconds()),
},
testSpecs: []build.TestSpec{
fuchsiaTestSpec("multiplied-affected-test"),
fuchsiaTestSpec("affected-test"),
fuchsiaTestSpec("unaffected-test"),
},
testDurations: []build.TestDuration{
{
Name: "*",
MedianDuration: time.Second,
},
},
affectedTests: []string{
packageURL("multiplied-affected-test"),
packageURL("affected-test"),
},
modifiers: []testsharder.TestModifier{
{
Name: "multiplied-affected-test",
TotalRuns: 100,
},
},
},
{
name: "multiply affected tests with large number of runs",
flags: testsharderFlags{
affectedTestsMultiplyThreshold: 3,
targetDurationSecs: int(5 * time.Minute.Seconds()),
},
testSpecs: []build.TestSpec{
fuchsiaTestSpec("affected-test1"),
fuchsiaTestSpec("affected-test2"),
fuchsiaTestSpec("affected-test3"),
},
testDurations: []build.TestDuration{
{
Name: "*",
// Test duration is very short relative to the target shard
// duration, so the tests should get multiplied many times.
MedianDuration: time.Millisecond,
},
},
affectedTests: []string{
packageURL("affected-test1"),
packageURL("affected-test2"),
packageURL("affected-test3"),
},
},
{
name: "test list with tags",
testSpecs: []build.TestSpec{
fuchsiaTestSpec("hermetic-test"),
fuchsiaTestSpec("nonhermetic-test"),
},
testList: []build.TestListEntry{
testListEntry("hermetic-test", true),
testListEntry("nonhermetic-test", false),
},
},
{
name: "skip unaffected tests",
flags: testsharderFlags{
skipUnaffected: true,
},
testSpecs: []build.TestSpec{
fuchsiaTestSpec("affected-hermetic-test"),
fuchsiaTestSpec("unaffected-hermetic-test"),
fuchsiaTestSpec("affected-nonhermetic-test"),
fuchsiaTestSpec("unaffected-nonhermetic-test"),
},
testList: []build.TestListEntry{
testListEntry("affected-hermetic-test", true),
testListEntry("unaffected-hermetic-test", true),
testListEntry("affected-nonhermetic-test", false),
testListEntry("unaffected-nonhermetic-test", false),
},
affectedTests: []string{
fuchsiaTestSpec("affected-hermetic-test").Name,
fuchsiaTestSpec("affected-nonhermetic-test").Name,
},
},
{
name: "run all tests if no affected tests",
flags: testsharderFlags{
skipUnaffected: true,
},
testSpecs: []build.TestSpec{
fuchsiaTestSpec("affected-hermetic-test"),
fuchsiaTestSpec("unaffected-hermetic-test"),
fuchsiaTestSpec("affected-nonhermetic-test"),
fuchsiaTestSpec("unaffected-nonhermetic-test"),
},
testList: []build.TestListEntry{
testListEntry("affected-hermetic-test", true),
testListEntry("unaffected-hermetic-test", true),
testListEntry("affected-nonhermetic-test", false),
testListEntry("unaffected-nonhermetic-test", false),
},
},
{
name: "run all tests if empty affected tests",
flags: testsharderFlags{
skipUnaffected: true,
},
testSpecs: []build.TestSpec{
fuchsiaTestSpec("affected-hermetic-test"),
fuchsiaTestSpec("unaffected-hermetic-test"),
fuchsiaTestSpec("affected-nonhermetic-test"),
fuchsiaTestSpec("unaffected-nonhermetic-test"),
},
testList: []build.TestListEntry{
testListEntry("affected-hermetic-test", true),
testListEntry("unaffected-hermetic-test", true),
testListEntry("affected-nonhermetic-test", false),
testListEntry("unaffected-nonhermetic-test", false),
},
affectedTests: []string{""},
},
{
name: "run all tests if no affected and affected only",
flags: testsharderFlags{
affectedOnly: true,
skipUnaffected: true,
},
testSpecs: []build.TestSpec{
fuchsiaTestSpec("affected-hermetic-test"),
fuchsiaTestSpec("unaffected-hermetic-test"),
fuchsiaTestSpec("affected-nonhermetic-test"),
fuchsiaTestSpec("unaffected-nonhermetic-test"),
},
testList: []build.TestListEntry{
testListEntry("affected-hermetic-test", true),
testListEntry("unaffected-hermetic-test", true),
testListEntry("affected-nonhermetic-test", false),
testListEntry("unaffected-nonhermetic-test", false),
},
},
{
name: "multiply unaffected hermetic tests",
flags: testsharderFlags{
skipUnaffected: true,
},
testSpecs: []build.TestSpec{
fuchsiaTestSpec("unaffected-hermetic-test"),
fuchsiaTestSpec("affected-nonhermetic-test"),
fuchsiaTestSpec("unaffected-hermetic-multiplied-test"),
},
testList: []build.TestListEntry{
testListEntry("unaffected-hermetic-test", true),
testListEntry("affected-nonhermetic-test", false),
testListEntry("unaffected-hermetic-multiplied-test", true),
},
affectedTests: []string{
fuchsiaTestSpec("affected-nonhermetic-test").Name,
},
modifiers: []testsharder.TestModifier{
{
Name: "unaffected-hermetic-multiplied-test",
TotalRuns: 100,
},
},
},
{
name: "boot test with modifiers",
flags: testsharderFlags{
targetDurationSecs: 5,
},
testSpecs: []build.TestSpec{
bootTestSpec("boot-test"),
bootTestSpec("another-boot-test"),
},
modifiers: []testsharder.TestModifier{
{
Name: "*",
TotalRuns: -1,
MaxAttempts: 1,
},
},
testList: []build.TestListEntry{
testListEntry("boot-test", true),
testListEntry("another-boot-test", true),
},
},
{
name: "various modifiers",
flags: testsharderFlags{
targetDurationSecs: 5,
},
testSpecs: []build.TestSpec{
fuchsiaTestSpec("foo"),
fuchsiaTestSpec("bar"),
fuchsiaTestSpec("baz"),
},
modifiers: []testsharder.TestModifier{
// default modifier
{
Name: "*",
TotalRuns: -1,
MaxAttempts: 2,
},
// multiplier
{
Name: "foo",
MaxAttempts: 1,
},
// change maxAttempts (but multiplier takes precedence)
{
Name: "foo",
TotalRuns: -1,
MaxAttempts: 1,
},
// change maxAttempts, set affected
{
Name: "bar",
Affected: true,
TotalRuns: -1,
MaxAttempts: 1,
},
},
testList: []build.TestListEntry{
testListEntry("foo", false),
testListEntry("bar", true),
testListEntry("baz", false),
},
testDurations: []build.TestDuration{
{
Name: "*",
MedianDuration: time.Millisecond,
},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
goldenBasename := strings.ReplaceAll(tc.name, " ", "_") + ".golden.json"
goldenFile := filepath.Join(*goldensDir, goldenBasename)
if *updateGoldens {
tc.flags.outputFile = goldenFile
} else {
tc.flags.outputFile = filepath.Join(t.TempDir(), goldenBasename)
}
tc.flags.buildDir = t.TempDir()
tc.flags.productBundleName = "core.x64"
if len(tc.modifiers) > 0 {
tc.flags.modifiersPath = writeTempJSONFile(t, tc.modifiers)
}
if len(tc.affectedTests) > 0 {
// Add a newline to the end of the file to test that it still calculates the
// correct number of affected tests even with extra whitespace.
tc.flags.affectedTestsPath = writeTempFile(t, strings.Join(tc.affectedTests, "\n")+"\n")
}
sdkManifest := map[string]interface{}{
"atoms": []interface{}{},
}
sdkManifestPath := filepath.Join(tc.flags.buildDir, "sdk", "manifest", "core")
if err := os.MkdirAll(filepath.Dir(sdkManifestPath), os.ModePerm); err != nil {
t.Fatal(err)
}
if err := jsonutil.WriteToFile(sdkManifestPath, sdkManifest); err != nil {
t.Fatal(err)
}
// Write test-list.json.
if err := jsonutil.WriteToFile(
filepath.Join(tc.flags.buildDir, testListPath),
build.TestList{Data: tc.testList, SchemaID: "experimental"},
); err != nil {
t.Fatal(err)
}
origGetHostPlatform := getHostPlatform
origGetFFX := testsharder.GetFFX
defer func() {
getHostPlatform = origGetHostPlatform
testsharder.GetFFX = origGetFFX
}()
getHostPlatform = func() (string, error) {
return "linux-x64", nil
}
testsharder.GetFFX = func(ctx context.Context, ffxPath, outputsDir string) (testsharder.FFXInterface, error) {
return &mockFFX{}, nil
}
writeDeps(t, tc.flags.buildDir, tc.testSpecs)
for _, repo := range tc.packageRepos {
if err := os.MkdirAll(filepath.Join(tc.flags.buildDir, repo.Path), 0o700); err != nil {
t.Fatal(err)
}
}
m := &fakeModules{
testSpecs: tc.testSpecs,
testDurations: tc.testDurations,
packageRepositories: tc.packageRepos,
}
if err := execute(ctx, tc.flags, m); err != nil {
t.Fatal(err)
}
if !*updateGoldens {
want := readShards(t, goldenFile)
got := readShards(t, tc.flags.outputFile)
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf(strings.Join([]string{
"Golden file mismatch!",
"To fix, run `tools/integration/testsharder/update_goldens.sh",
diff,
}, "\n"))
}
}
})
}
}
type fakeModules struct {
images []build.Image
testSpecs []build.TestSpec
testList string
testDurations []build.TestDuration
packageRepositories []build.PackageRepo
productBundles []build.ProductBundle
}
func (m *fakeModules) Platforms() []build.DimensionSet {
return []build.DimensionSet{
{
"device_type": "AEMU",
},
{
"cpu": "x64",
"os": "Linux",
},
{
"cpu": "x64",
"os": "Linux",
"other_dimension": "foo",
},
{
"cpu": "x64",
"os": "Linux",
"other_dimension": "bar",
},
}
}
func (m *fakeModules) Images() []build.Image {
return []build.Image{
{
Name: "qemu-kernel",
Path: "multiboot.bin",
Type: "kernel",
},
}
}
func (m *fakeModules) Args() build.Args {
return build.Args{"target_cpu": json.RawMessage(`"x64"`)}
}
func (m *fakeModules) TestListLocation() []string { return []string{testListPath} }
func (m *fakeModules) TestSpecs() []build.TestSpec { return m.testSpecs }
func (m *fakeModules) TestDurations() []build.TestDuration { return m.testDurations }
func (m *fakeModules) PackageRepositories() []build.PackageRepo { return m.packageRepositories }
func (m *fakeModules) ProductBundles() []build.ProductBundle { return m.productBundles }
func (m *fakeModules) Tools() build.Tools {
return build.Tools{
{
Name: "ffx",
Path: "host_x64/ffx",
OS: "linux",
CPU: "x64",
}}
}
func packageURL(basename string) string {
return fmt.Sprintf("fuchsia-pkg://fuchsia.com/%s#meta/%s.cm", basename, basename)
}
func fuchsiaTestSpec(basename string) build.TestSpec {
return build.TestSpec{
Test: build.Test{
Name: packageURL(basename),
PackageURL: packageURL(basename),
OS: "fuchsia",
CPU: "x64",
Label: fmt.Sprintf("//src/something:%s(//build/toolchain/fuchsia:x64)", basename),
},
Envs: []build.Environment{
{
Dimensions: build.DimensionSet{
"device_type": "AEMU",
},
},
},
}
}
func bootTestSpec(basename string) build.TestSpec {
return build.TestSpec{
Test: build.Test{
Name: packageURL(basename),
PackageURL: packageURL(basename),
OS: "fuchsia",
CPU: "x64",
Label: fmt.Sprintf("//src/something:%s(//build/toolchain/fuchsia:x64)", basename),
Isolated: true,
},
Envs: []build.Environment{
{
Dimensions: build.DimensionSet{
"device_type": "AEMU",
},
},
},
ProductBundle: "boot-test_product_bundle",
}
}
func hostTestSpec(basename string) build.TestSpec {
testPath := fmt.Sprintf("host_x64/%s", basename)
return build.TestSpec{
Test: build.Test{
Name: testPath,
Path: testPath,
OS: "linux",
CPU: "x64",
Label: fmt.Sprintf("//tools/other:%s(//build/toolchain/host_x64)", basename),
RuntimeDepsFile: filepath.Join("runtime_deps", basename+".json"),
},
Envs: []build.Environment{
{
Dimensions: build.DimensionSet{
"cpu": "x64",
"os": "Linux",
},
},
},
}
}
func testListEntry(basename string, hermetic bool) build.TestListEntry {
return build.TestListEntry{
Name: packageURL(basename),
Tags: []build.TestTag{
{Key: "hermetic", Value: strconv.FormatBool(hermetic)},
},
}
}
func writeDeps(t *testing.T, buildDir string, testSpecs []build.TestSpec) {
t.Helper()
// Write runtime deps files.
for _, testSpec := range testSpecs {
if testSpec.Path != "" {
touchFile(t, filepath.Join(buildDir, testSpec.Path))
}
if testSpec.RuntimeDepsFile == "" {
continue
}
absPath := filepath.Join(buildDir, testSpec.RuntimeDepsFile)
if err := os.MkdirAll(filepath.Dir(absPath), 0o700); err != nil {
t.Fatal(err)
}
runtimeDeps := []string{"host_x64/dep1", "host_x64/dep2"}
if err := jsonutil.WriteToFile(absPath, runtimeDeps); err != nil {
t.Fatal(err)
}
for _, dep := range runtimeDeps {
touchFile(t, filepath.Join(buildDir, dep))
}
}
}
func touchFile(t *testing.T, path string) {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, nil, 0o600); err != nil {
t.Fatal(err)
}
}
// readShards deserializes testsharder output from a JSON file.
func readShards(t *testing.T, path string) []testsharder.Shard {
var shards []testsharder.Shard
if err := jsonutil.ReadFromFile(path, &shards); err != nil {
if errors.Is(err, os.ErrNotExist) && strings.HasPrefix(path, *goldensDir) {
t.Fatalf("Golden file for case %q does not exist. To create it, run tools/integration/testsharder/update_goldens.sh", t.Name())
}
t.Fatal(err)
}
return shards
}
func writeTempJSONFile(t *testing.T, obj interface{}) string {
path := filepath.Join(t.TempDir(), "temp.json")
if err := jsonutil.WriteToFile(path, obj); err != nil {
t.Fatal(err)
}
return path
}
func writeTempFile(t *testing.T, contents string) string {
path := filepath.Join(t.TempDir(), "temp.txt")
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
return path
}