github.com/bazelbuild/bazel-gazelle@v0.36.1-0.20240520142334-61b277ba6fed/testtools/files.go (about)

     1  /* Copyright 2018 The Bazel Authors. All rights reserved.
     2  
     3  Licensed under the Apache License, Version 2.0 (the "License");
     4  you may not use this file except in compliance with the License.
     5  You may obtain a copy of the License at
     6  
     7     http://www.apache.org/licenses/LICENSE-2.0
     8  
     9  Unless required by applicable law or agreed to in writing, software
    10  distributed under the License is distributed on an "AS IS" BASIS,
    11  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  See the License for the specific language governing permissions and
    13  limitations under the License.
    14  */
    15  
    16  package testtools
    17  
    18  import (
    19  	"bytes"
    20  	"context"
    21  	"errors"
    22  	"fmt"
    23  	"io"
    24  	"io/fs"
    25  	"os"
    26  	"os/exec"
    27  	"path"
    28  	"path/filepath"
    29  	"strconv"
    30  	"strings"
    31  	"testing"
    32  	"time"
    33  
    34  	"github.com/google/go-cmp/cmp"
    35  )
    36  
    37  const cmdTimeoutOrInterruptExitCode = -1
    38  
    39  // FileSpec specifies the content of a test file.
    40  type FileSpec struct {
    41  	// Path is a slash-separated path relative to the test directory. If Path
    42  	// ends with a slash, it indicates a directory should be created
    43  	// instead of a file.
    44  	Path string
    45  
    46  	// Symlink is a slash-separated path relative to the test directory. If set,
    47  	// it indicates a symbolic link should be created with this path instead of a
    48  	// file.
    49  	Symlink string
    50  
    51  	// Content is the content of the test file.
    52  	Content string
    53  
    54  	// NotExist asserts that no file at this path exists.
    55  	// It is only valid in CheckFiles.
    56  	NotExist bool
    57  }
    58  
    59  // CreateFiles creates a directory of test files. This is a more compact
    60  // alternative to testdata directories. CreateFiles returns a canonical path
    61  // to the directory and a function to call to clean up the directory
    62  // after the test.
    63  func CreateFiles(t *testing.T, files []FileSpec) (dir string, cleanup func()) {
    64  	t.Helper()
    65  	dir, err := os.MkdirTemp(os.Getenv("TEST_TEMPDIR"), "gazelle_test")
    66  	if err != nil {
    67  		t.Fatal(err)
    68  	}
    69  	dir, err = filepath.EvalSymlinks(dir)
    70  	if err != nil {
    71  		t.Fatal(err)
    72  	}
    73  
    74  	for _, f := range files {
    75  		if f.NotExist {
    76  			t.Fatalf("CreateFiles: NotExist may not be set: %s", f.Path)
    77  		}
    78  		path := filepath.Join(dir, filepath.FromSlash(f.Path))
    79  		if strings.HasSuffix(f.Path, "/") {
    80  			if err := os.MkdirAll(path, 0o700); err != nil {
    81  				os.RemoveAll(dir)
    82  				t.Fatal(err)
    83  			}
    84  			continue
    85  		}
    86  		if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
    87  			os.RemoveAll(dir)
    88  			t.Fatal(err)
    89  		}
    90  		if f.Symlink != "" {
    91  			if err := os.Symlink(f.Symlink, path); err != nil {
    92  				t.Fatal(err)
    93  			}
    94  			continue
    95  		}
    96  		if err := os.WriteFile(path, []byte(f.Content), 0o600); err != nil {
    97  			os.RemoveAll(dir)
    98  			t.Fatal(err)
    99  		}
   100  	}
   101  
   102  	return dir, func() { os.RemoveAll(dir) }
   103  }
   104  
   105  // CheckFiles checks that files in "dir" exist and have the content specified
   106  // in "files". Files not listed in "files" are not tested, so extra files
   107  // are allowed.
   108  func CheckFiles(t *testing.T, dir string, files []FileSpec) {
   109  	t.Helper()
   110  	for _, f := range files {
   111  		path := filepath.Join(dir, f.Path)
   112  
   113  		st, err := os.Stat(path)
   114  		if f.NotExist {
   115  			if err == nil {
   116  				t.Errorf("asserted to not exist, but does: %s", f.Path)
   117  			} else if !os.IsNotExist(err) {
   118  				t.Errorf("could not stat %s: %v", f.Path, err)
   119  			}
   120  			continue
   121  		}
   122  
   123  		if strings.HasSuffix(f.Path, "/") {
   124  			if err != nil {
   125  				t.Errorf("could not stat %s: %v", f.Path, err)
   126  			} else if !st.IsDir() {
   127  				t.Errorf("not a directory: %s", f.Path)
   128  			}
   129  		} else {
   130  			want := normalizeSpace(f.Content)
   131  			gotBytes, err := os.ReadFile(filepath.Join(dir, f.Path))
   132  			if err != nil {
   133  				t.Errorf("could not read %s: %v", f.Path, err)
   134  				continue
   135  			}
   136  			got := normalizeSpace(string(gotBytes))
   137  			if diff := cmp.Diff(want, got); diff != "" {
   138  				t.Errorf("%s diff (-want,+got):\n%s", f.Path, diff)
   139  			}
   140  		}
   141  	}
   142  }
   143  
   144  type TestGazelleGenerationArgs struct {
   145  	// Name is the name of the test.
   146  	Name string
   147  	// TestDataPathAbsolute is the absolute path to the test data directory.
   148  	// For example, /home/user/workspace/path/to/test_data/my_testcase.
   149  	TestDataPathAbsolute string
   150  	// TestDataPathRealtive is the workspace relative path to the test data directory.
   151  	// For example, path/to/test_data/my_testcase.
   152  	TestDataPathRelative string
   153  	// GazelleBinaryPath is the workspace relative path to the location of the gazelle binary
   154  	// we want to test.
   155  	GazelleBinaryPath string
   156  
   157  	// BuildInSuffix is the suffix for all test input build files. Includes the ".".
   158  	// Default: ".in", so input BUILD files should be named BUILD.in.
   159  	BuildInSuffix string
   160  
   161  	// BuildOutSuffix is the suffix for all test output build files. Includes the ".".
   162  	// Default: ".out", so out BUILD files should be named BUILD.out.
   163  	BuildOutSuffix string
   164  
   165  	// Timeout is the duration after which the generation process will be killed.
   166  	Timeout time.Duration
   167  }
   168  
   169  var (
   170  	argumentsFilename        = "arguments.txt"
   171  	expectedStdoutFilename   = "expectedStdout.txt"
   172  	expectedStderrFilename   = "expectedStderr.txt"
   173  	expectedExitCodeFilename = "expectedExitCode.txt"
   174  )
   175  
   176  // TestGazelleGenerationOnPath runs a full gazelle binary on a testdata directory.
   177  // With a test data directory of the form:
   178  // └── <testDataPath>
   179  //
   180  //	└── some_test
   181  //	    ├── WORKSPACE
   182  //	    ├── README.md --> README describing what the test does.
   183  //	    ├── arguments.txt --> newline delimited list of arguments to pass in (ignored if empty).
   184  //	    ├── expectedStdout.txt --> Expected stdout for this test.
   185  //	    ├── expectedStderr.txt --> Expected stderr for this test.
   186  //	    ├── expectedExitCode.txt --> Expected exit code for this test.
   187  //	    └── app
   188  //	        └── sourceFile.foo
   189  //	        └── BUILD.in --> BUILD file prior to running gazelle.
   190  //	        └── BUILD.out --> BUILD file expected after running gazelle.
   191  func TestGazelleGenerationOnPath(t *testing.T, args *TestGazelleGenerationArgs) {
   192  	t.Run(args.Name, func(t *testing.T) {
   193  		t.Helper() // Make the stack trace a little bit more clear.
   194  		if args.BuildInSuffix == "" {
   195  			args.BuildInSuffix = ".in"
   196  		}
   197  		if args.BuildOutSuffix == "" {
   198  			args.BuildOutSuffix = ".out"
   199  		}
   200  		var inputs []FileSpec
   201  		var goldens []FileSpec
   202  
   203  		config := &testConfig{}
   204  		f := func(path string, d fs.DirEntry, err error) error {
   205  			if err != nil {
   206  				t.Fatalf("File walk error on path %q. Error: %v", path, err)
   207  			}
   208  
   209  			shortPath := strings.TrimPrefix(path, args.TestDataPathAbsolute)
   210  
   211  			info, err := d.Info()
   212  			if err != nil {
   213  				t.Fatalf("File info error on path %q. Error: %v", path, err)
   214  			}
   215  
   216  			if info.IsDir() {
   217  				return nil
   218  			}
   219  
   220  			content, err := os.ReadFile(path)
   221  			if err != nil {
   222  				t.Errorf("os.ReadFile(%q) error: %v", path, err)
   223  			}
   224  
   225  			// Read in expected stdout, stderr, and exit code files.
   226  			if d.Name() == argumentsFilename {
   227  				config.Args = strings.Split(normalizeSpace(string(content)), "\n")
   228  				return nil
   229  			}
   230  			if d.Name() == expectedStdoutFilename {
   231  				config.Stdout = string(content)
   232  				return nil
   233  			}
   234  			if d.Name() == expectedStderrFilename {
   235  				config.Stderr = string(content)
   236  				return nil
   237  			}
   238  			if d.Name() == expectedExitCodeFilename {
   239  				config.ExitCode, err = strconv.Atoi(string(content))
   240  				if err != nil {
   241  					// Set the ExitCode to a sentinel value (-1) to ensure that if the caller is updating the files on disk the value is updated.
   242  					config.ExitCode = -1
   243  					t.Errorf("Failed to parse expected exit code (%q) error: %v", path, err)
   244  				}
   245  				return nil
   246  			}
   247  
   248  			if strings.HasSuffix(shortPath, args.BuildInSuffix) {
   249  				inputs = append(inputs, FileSpec{
   250  					Path:    filepath.Join(args.Name, strings.TrimSuffix(shortPath, args.BuildInSuffix)+".bazel"),
   251  					Content: string(content),
   252  				})
   253  			} else if strings.HasSuffix(shortPath, args.BuildOutSuffix) {
   254  				goldens = append(goldens, FileSpec{
   255  					Path:    filepath.Join(args.Name, strings.TrimSuffix(shortPath, args.BuildOutSuffix)+".bazel"),
   256  					Content: string(content),
   257  				})
   258  			} else {
   259  				inputs = append(inputs, FileSpec{
   260  					Path:    filepath.Join(args.Name, shortPath),
   261  					Content: string(content),
   262  				})
   263  				goldens = append(goldens, FileSpec{
   264  					Path:    filepath.Join(args.Name, shortPath),
   265  					Content: string(content),
   266  				})
   267  			}
   268  			return nil
   269  		}
   270  		if err := filepath.WalkDir(args.TestDataPathAbsolute, f); err != nil {
   271  			t.Fatal(err)
   272  		}
   273  
   274  		testdataDir, cleanup := CreateFiles(t, inputs)
   275  		workspaceRoot := filepath.Join(testdataDir, args.Name)
   276  
   277  		var stdout, stderr bytes.Buffer
   278  		var actualExitCode int
   279  		defer cleanup()
   280  		defer func() {
   281  			if t.Failed() {
   282  				shouldUpdate := os.Getenv("UPDATE_SNAPSHOTS") != ""
   283  				buildWorkspaceDirectory := os.Getenv("BUILD_WORKSPACE_DIRECTORY")
   284  				updateCommand := fmt.Sprintf("UPDATE_SNAPSHOTS=true bazel run %s", os.Getenv("TEST_TARGET"))
   285  				// srcTestDirectory is the directory of the source code of the test case.
   286  				srcTestDirectory := path.Join(buildWorkspaceDirectory, path.Dir(args.TestDataPathRelative), args.Name)
   287  				if shouldUpdate {
   288  					// Update stdout, stderr, exit code.
   289  					updateExpectedConfig(t, config.Stdout, redactWorkspacePath(stdout.String(), workspaceRoot), srcTestDirectory, expectedStdoutFilename)
   290  					updateExpectedConfig(t, config.Stderr, redactWorkspacePath(stderr.String(), workspaceRoot), srcTestDirectory, expectedStderrFilename)
   291  					updateExpectedConfig(t, fmt.Sprintf("%d", config.ExitCode), fmt.Sprintf("%d", actualExitCode), srcTestDirectory, expectedExitCodeFilename)
   292  
   293  					err := filepath.Walk(testdataDir, func(walkedPath string, info os.FileInfo, err error) error {
   294  						if err != nil {
   295  							return err
   296  						}
   297  						relativePath := strings.TrimPrefix(walkedPath, testdataDir)
   298  						if shouldUpdate {
   299  							if buildWorkspaceDirectory == "" {
   300  								t.Fatalf("Tried to update snapshots but no BUILD_WORKSPACE_DIRECTORY specified.\n Try %s.", updateCommand)
   301  							}
   302  
   303  							if info.Name() == "BUILD.bazel" {
   304  								destFile := strings.TrimSuffix(path.Join(buildWorkspaceDirectory, path.Dir(args.TestDataPathRelative)+relativePath), ".bazel") + args.BuildOutSuffix
   305  
   306  								err := copyFile(walkedPath, destFile)
   307  								if err != nil {
   308  									t.Fatalf("Failed to copy file %v to %v. Error: %v\n", walkedPath, destFile, err)
   309  								}
   310  							}
   311  
   312  						}
   313  						t.Logf("%q exists in %v", relativePath, testdataDir)
   314  						return nil
   315  					})
   316  					if err != nil {
   317  						t.Fatalf("Failed to walk file: %v", err)
   318  					}
   319  
   320  				} else {
   321  					t.Logf(`
   322  =====================================================================================
   323  Run %s to update BUILD.out and expected{Stdout,Stderr,ExitCode}.txt files.
   324  =====================================================================================
   325  `, updateCommand)
   326  				}
   327  			}
   328  		}()
   329  
   330  		ctx, cancel := context.WithTimeout(context.Background(), args.Timeout)
   331  		defer cancel()
   332  		cmd := exec.CommandContext(ctx, args.GazelleBinaryPath, config.Args...)
   333  		cmd.Stdout = &stdout
   334  		cmd.Stderr = &stderr
   335  		cmd.Dir = workspaceRoot
   336  		cmd.Env = append(os.Environ(), fmt.Sprintf("BUILD_WORKSPACE_DIRECTORY=%v", workspaceRoot))
   337  		if err := cmd.Run(); err != nil {
   338  			var e *exec.ExitError
   339  			if !errors.As(err, &e) {
   340  				t.Fatal(err)
   341  			}
   342  		}
   343  		errs := make([]error, 0)
   344  		actualExitCode = cmd.ProcessState.ExitCode()
   345  		if config.ExitCode != actualExitCode {
   346  			if actualExitCode == cmdTimeoutOrInterruptExitCode {
   347  				errs = append(errs, fmt.Errorf("gazelle exceeded the timeout or was interrupted"))
   348  			} else {
   349  
   350  				errs = append(errs, fmt.Errorf("expected gazelle exit code: %d\ngot: %d",
   351  					config.ExitCode, actualExitCode,
   352  				))
   353  			}
   354  		}
   355  		actualStdout := redactWorkspacePath(stdout.String(), workspaceRoot)
   356  		if normalizeSpace(config.Stdout) != normalizeSpace(actualStdout) {
   357  			errs = append(errs, fmt.Errorf("expected gazelle stdout: %s\ngot: %s",
   358  				config.Stdout, actualStdout,
   359  			))
   360  		}
   361  		actualStderr := redactWorkspacePath(stderr.String(), workspaceRoot)
   362  		if normalizeSpace(config.Stderr) != normalizeSpace(actualStderr) {
   363  			errs = append(errs, fmt.Errorf("expected gazelle stderr: %s\ngot: %s",
   364  				config.Stderr, actualStderr,
   365  			))
   366  		}
   367  		if len(errs) > 0 {
   368  			for _, err := range errs {
   369  				t.Log(err)
   370  			}
   371  			t.FailNow()
   372  		}
   373  
   374  		CheckFiles(t, testdataDir, goldens)
   375  	})
   376  }
   377  
   378  func copyFile(src string, dest string) error {
   379  	srcFile, err := os.Open(src)
   380  	if err != nil {
   381  		return err
   382  	}
   383  	defer srcFile.Close()
   384  
   385  	destFile, err := os.Create(dest)
   386  	if err != nil {
   387  		return err
   388  	}
   389  	defer destFile.Close()
   390  
   391  	_, err = io.Copy(destFile, srcFile)
   392  	if err != nil {
   393  		return err
   394  	}
   395  	err = destFile.Sync()
   396  	if err != nil {
   397  		return err
   398  	}
   399  	return nil
   400  }
   401  
   402  type testConfig struct {
   403  	Args     []string
   404  	ExitCode int
   405  	Stdout   string
   406  	Stderr   string
   407  }
   408  
   409  // updateExpectedConfig writes to an expected stdout, stderr, or exit code file
   410  // with the latest results of a test.
   411  func updateExpectedConfig(t *testing.T, expected string, actual string, srcTestDirectory string, expectedFilename string) {
   412  	if expected != actual {
   413  		destFile := path.Join(srcTestDirectory, expectedFilename)
   414  
   415  		err := os.WriteFile(destFile, []byte(actual), 0o644)
   416  		if err != nil {
   417  			t.Fatalf("Failed to write file %v. Error: %v\n", destFile, err)
   418  		}
   419  	}
   420  }
   421  
   422  // redactWorkspacePath replaces workspace path with a constant to make the test
   423  // output reproducible.
   424  func redactWorkspacePath(s, wsPath string) string {
   425  	return strings.ReplaceAll(s, wsPath, "%WORKSPACEPATH%")
   426  }
   427  
   428  func normalizeSpace(s string) string {
   429  	return strings.TrimSpace(strings.ReplaceAll(s, "\r\n", "\n"))
   430  }