blob: 7f3d1a5007404a22ccf32b5f28a7d1114a7146f1 [file]
// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package e2etests
import (
"bytes"
"context"
"errors"
"flag"
"io/fs"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
"go.chromium.org/build/hashigo/digest"
"go.chromium.org/build/siso/build"
"go.chromium.org/build/siso/build/buildconfig"
"go.chromium.org/build/siso/build/ninjabuild"
"go.chromium.org/build/siso/hashfs"
)
var (
testSerial = flag.Bool("siso-test-serial", false, "execute the actual test sequentially in the current process")
)
// runInSubProcess runs the test in a separate process to allow safe chdir.
// It returns true if the test should proceed (i.e., we are in the subprocess).
// It returns false if we are in the parent process and should skip the actual test logic.
func runInSubProcess(t *testing.T) bool {
t.Helper()
if *testSerial {
return true // Execute the actual test in the current process
}
t.Parallel()
exe, err := os.Executable()
if err != nil {
t.Fatalf("could not get executable: %v", err)
}
// -siso-test-serial is the recursion guard; it must precede any forwarded
// argument, because flag parsing stops at the first non-flag argument and
// an unparsed guard means infinite re-exec (fork bomb).
args := []string{"-test.run=^" + t.Name() + "$", "-siso-test-serial"}
skipNext := false
// We must selectively forward flags from the parent `go test` runner to the subprocess:
// 1. Keep safe flags (e.g., -test.v, -test.timeout) to preserve expected user behavior.
// 2. Drop multiplier flags (e.g., -test.count) to prevent exponential test executions.
// 3. Rewrite profiling/output flags (e.g., -test.cpuprofile) by appending the test name
// so that parallel subprocesses do not concurrently write to and corrupt a single shared file.
for i, arg := range os.Args[1:] {
if skipNext {
skipNext = false
continue
}
if strings.HasPrefix(arg, "-test.") {
if strings.HasPrefix(arg, "-test.v=") || arg == "-test.v" ||
strings.HasPrefix(arg, "-test.short=") || arg == "-test.short" ||
strings.HasPrefix(arg, "-test.failfast=") || arg == "-test.failfast" ||
strings.HasPrefix(arg, "-test.paniconexit0=") || arg == "-test.paniconexit0" ||
strings.HasPrefix(arg, "-test.timeout=") || arg == "-test.timeout" {
args = append(args, arg)
} else if strings.HasPrefix(arg, "-test.cpuprofile") ||
strings.HasPrefix(arg, "-test.memprofile") ||
strings.HasPrefix(arg, "-test.mutexprofile") ||
strings.HasPrefix(arg, "-test.blockprofile") ||
strings.HasPrefix(arg, "-test.trace") ||
strings.HasPrefix(arg, "-test.outputdir") {
// Handle both `-test.cpuprofile=cpu.prof` and `-test.cpuprofile cpu.prof`
val := ""
key := arg
hasEq := strings.Contains(arg, "=")
if hasEq {
parts := strings.SplitN(arg, "=", 2)
key = parts[0]
val = parts[1]
} else if i+1 < len(os.Args[1:]) {
val = os.Args[1:][i+1]
skipNext = true
}
if val != "" {
newVal := val + "." + t.Name()
if key == "-test.outputdir" {
if err := os.MkdirAll(newVal, 0755); err != nil {
t.Fatalf("os.MkdirAll(%q)=%v; want nil err", newVal, err)
}
}
args = append(args, key+"="+newVal)
} else {
args = append(args, arg)
}
}
} else {
args = append(args, arg)
}
}
cmd := exec.CommandContext(t.Context(), exe, args...)
out, err := cmd.CombinedOutput()
if len(out) > 0 {
t.Logf("\n%s", out)
}
if err != nil {
t.Fatalf("subprocess test failed: %v", err)
}
return false // Indicates parent should skip test logic
}
// tempDir returns real path of temp dir.
// mac uses /tmp -> private/tmp symlink, so TempDir may contains
// symlink in the path, which confuses hashfs, so use EvalSymlinks
// to make it real path.
func tempDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
dir, err := filepath.EvalSymlinks(dir)
if err != nil {
t.Fatal(err)
}
return dir
}
func setupFiles(t *testing.T, dir, name string, deletes []string) {
t.Helper()
root := filepath.Join("testdata", name)
err := filepath.Walk(root, func(pathname string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
name, err := filepath.Rel(root, pathname)
if err != nil {
return err
}
if info.IsDir() {
return os.MkdirAll(filepath.Join(dir, name), 0755)
}
if info.Mode()&fs.ModeSymlink == fs.ModeSymlink {
target, err := os.Readlink(pathname)
if err != nil {
return err
}
return os.Symlink(target, filepath.Join(dir, name))
}
buf, err := os.ReadFile(pathname)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, name), buf, info.Mode())
})
if err != nil {
t.Fatal(err)
}
for _, name := range deletes {
err = os.Remove(filepath.Join(dir, name))
if err != nil {
t.Fatal(err)
}
}
}
// make sure file at dir/name is modified, i.e. have different mtime.
// gen takes old content and returns new content.
func modifyFile(t *testing.T, dir, name string, gen func([]byte) []byte) {
t.Helper()
oldTime := time.Now()
t.Logf("-- modify %s", name)
fullname := filepath.Join(dir, name)
fi, err := os.Stat(fullname)
if err != nil {
t.Fatal(err)
}
buf, err := os.ReadFile(fullname)
if err != nil {
t.Fatal(err)
}
buf = gen(buf)
err = os.WriteFile(fullname, buf, fi.Mode())
if err != nil {
t.Fatal(err)
}
for {
err = os.Chtimes(fullname, time.Time{}, time.Now())
if err != nil {
t.Fatal(err)
}
nfi, err := os.Stat(fullname)
if err != nil {
t.Fatal(err)
}
if fi.ModTime().Equal(nfi.ModTime()) || oldTime.Equal(nfi.ModTime()) {
time.Sleep(1 * time.Millisecond)
continue
}
t.Logf("-- modified %s %s", name, nfi.ModTime())
return
}
}
// like modifyFile, make sure file at dir/name exists and mtime is updated.
func touchFile(t *testing.T, dir, name string) {
t.Helper()
t.Logf("-- touch %s", name)
oldTime := time.Now()
fullname := filepath.Join(dir, name)
_, err := os.Stat(fullname)
if errors.Is(err, fs.ErrNotExist) {
err = os.WriteFile(fullname, nil, 0644)
if err != nil {
t.Fatal(err)
}
} else if err != nil {
t.Fatal(err)
}
for {
err = os.Chtimes(fullname, time.Time{}, time.Now())
if err != nil {
t.Fatal(err)
}
nfi, err := os.Stat(fullname)
if err != nil {
t.Fatal(err)
}
// chtimes might not make the file is newer than previous
// build's artifact.
// make sure it's newer than any of previous build's
// artifact, so touch would trigger the step that
// use the file as input.
if oldTime.Equal(nfi.ModTime()) {
time.Sleep(1 * time.Millisecond)
continue
}
t.Logf("-- touched %s %s", name, nfi.ModTime())
return
}
}
// readFile returns the content of the file at path, failing the test if it
// cannot be read.
func readFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(b)
}
type syncBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (w *syncBuffer) Write(data []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
return w.buf.Write(data)
}
func (w *syncBuffer) String() string {
w.mu.Lock()
defer w.mu.Unlock()
return w.buf.String()
}
func (w *syncBuffer) Bytes() []byte {
w.mu.Lock()
defer w.mu.Unlock()
return w.buf.Bytes()
}
func setupBuild(ctx context.Context, t *testing.T, dir string, fsopt hashfs.Option) (build.Options, *ninjabuild.Graph, func()) {
t.Helper()
var cleanups []func()
dir, err := filepath.EvalSymlinks(dir)
if err != nil {
t.Fatal(err)
}
// not t.Chdir as it needs to restore current working directory
// by cleanup (i.e. build finished), not at the end of test.
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
cleanups = append(cleanups, func() {
err := os.Chdir(wd)
if err != nil {
t.Fatal(err)
}
})
err = os.MkdirAll(filepath.Join(dir, "out/siso"), 0755)
if err != nil {
t.Fatal(err)
}
err = os.Chdir(filepath.Join(dir, "out/siso"))
if err != nil {
t.Fatal(err)
}
var hashfsSetStateLog syncBuffer
fsopt.SetStateLogger = &hashfsSetStateLog
hashFS, err := hashfs.New(ctx, fsopt)
if err != nil {
t.Fatal(err)
}
err = hashFS.WaitReady(ctx)
if err != nil {
t.Fatal(err)
}
cleanups = append(cleanups, func() {
err := hashFS.Close(ctx)
if err != nil {
t.Fatal(err)
}
if s := hashfsSetStateLog.buf.String(); s != "" {
t.Log(s)
}
})
config, err := buildconfig.New(ctx, "@config//main.star", map[string]string{}, map[string]fs.FS{
"config": os.DirFS(filepath.Join(dir, "build/config/siso")),
"config_overrides": os.DirFS(filepath.Join(dir, ".siso_remote")),
})
if err != nil {
t.Fatal(err)
}
path := build.NewPath(dir, "out/siso")
depsLog, err := ninjabuild.NewDepsLog(ctx, ".siso_deps")
if err != nil {
t.Fatal(err)
}
cleanups = append(cleanups, func() {
err := depsLog.Close()
if err != nil {
t.Fatal(err)
}
})
stepConfig, err := ninjabuild.NewStepConfig(ctx, digest.SHA256, config, path, "build.ninja", ".")
if err != nil {
t.Fatal(err)
}
nstate, err := ninjabuild.Load(ctx, "build.ninja", path)
if err != nil {
t.Fatal(err)
}
graph := ninjabuild.NewGraph(ctx, "build.ninja", nstate, config, path, hashFS, stepConfig, depsLog)
cachestore, err := build.NewLocalCache(".siso_cache")
if err != nil {
t.Logf("no local cache enabled: %v", err)
}
cache, err := build.NewCache(ctx, build.CacheOptions{
Store: cachestore,
})
if err != nil {
t.Fatal(err)
}
var explain syncBuffer
cleanups = append(cleanups, func() {
if s := explain.buf.String(); s != "" {
t.Log(s)
}
})
opt := build.Options{
Path: path,
HashFS: hashFS,
// Forward active build label from fsopt so test builds tag output artifacts in .siso_fs_state.
BuildLabel: fsopt.BuildLabel,
REExecEnable: true,
Cache: cache,
FailuresAllowed: 1,
Limits: build.UnitTestLimits(ctx),
ExplainWriter: &explain,
}
return opt, graph, func() {
for _, cleanup := range slices.Backward(cleanups) {
cleanup()
}
}
}
func openDepsLog(ctx context.Context, t *testing.T, dir string) (*ninjabuild.DepsLog, func()) {
t.Helper()
var cleanups []func()
// not t.Chdir as it needs to restore current working directory
// by cleanup (i.e. build finished), not at the end of test.
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
cleanups = append(cleanups, func() {
err := os.Chdir(wd)
if err != nil {
t.Fatal(err)
}
})
err = os.Chdir(filepath.Join(dir, "out/siso"))
if err != nil {
t.Fatal(err)
}
depsLog, err := ninjabuild.NewDepsLog(ctx, ".siso_deps")
if err != nil {
t.Fatal(err)
}
cleanups = append(cleanups, func() {
err := depsLog.Close()
if err != nil {
t.Fatal(err)
}
})
return depsLog, func() {
for _, cleanup := range slices.Backward(cleanups) {
cleanup()
}
}
}
// skipUnlessNsjailUsable skips the test unless nsjail can actually run here:
// it may be installed yet unable to build its mount namespace in restricted
// environments (unprivileged CI containers), so probe with a trivial action.
func skipUnlessNsjailUsable(t *testing.T) {
t.Helper()
nsjailPath, err := exec.LookPath("nsjail")
if err != nil {
t.Skip("This test requires nsjail: not found in PATH")
}
out, err := exec.Command(nsjailPath, "--quiet", "--chroot", "/", "--cwd", "/", "--disable_rlimits", "--", "/bin/true").CombinedOutput()
if err != nil {
t.Skipf("This test requires a working nsjail: %v: %s", err, strings.TrimSpace(string(out)))
}
}