// package diff contains diff generation helpers, particularly useful for tests. // // - Strings // - Files // - Runes // - JSON // - Testdata // - TestdataJSON package diff import ( "context" "errors" "fmt" "io/ioutil" "os" "os/exec" "path/filepath" "strings" "time" "go.uber.org/multierr" "oss.terrastruct.com/util-go/xdefer" "oss.terrastruct.com/util-go/xjson" ) // Strings diffs exp with got in a git style diff. // // The git style diff header will contain real paths to exp and got // on the file system so that you can easily inspect them. // // This behavior is particularly useful for when you need to update // a test with the new got. You can just copy and paste from the got // file in the diff header. // // It uses Files under the hood. func Strings(exp, got string) (ds string, err error) { defer xdefer.Errorf(&err, "failed to diff text") if exp == got { return "", nil } d, err := ioutil.TempDir("", "ts_d2_diff") if err != nil { return "", err } expPath := filepath.Join(d, "exp") gotPath := filepath.Join(d, "got") err = ioutil.WriteFile(expPath, []byte(exp), 0644) if err != nil { return "", err } err = ioutil.WriteFile(gotPath, []byte(got), 0644) if err != nil { return "", err } return Files(expPath, gotPath) } // Files diffs expPath with gotPath and prints a git style diff header. // // It uses git under the hood. func Files(expPath, gotPath string) (ds string, err error) { defer xdefer.Errorf(&err, "failed to diff files") _, err = os.Stat(expPath) if os.IsNotExist(err) { expPath = "/dev/null" } _, err = os.Stat(gotPath) if os.IsNotExist(err) { gotPath = "/dev/null" } ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() cmd := exec.CommandContext(ctx, "git", "-c", "diff.color=always", "diff", // Use the best diff-algorithm and highlight trailing whitespace. "--diff-algorithm=histogram", "--ws-error-highlight=all", "--no-index", expPath, gotPath) cmd.Env = append(cmd.Env, "GIT_CONFIG_NOSYSTEM=1", "HOME=") diffBytes, err := cmd.CombinedOutput() var ee *exec.ExitError if err != nil && !errors.As(err, &ee) { return "", fmt.Errorf("git diff failed: out=%q: %w", diffBytes, err) } ds = string(diffBytes) // Strips the diff header before --- // // diff --git a/var/folders/tf/yp9nqwbx4g5djqjxms03cvx80000gn/T/d2parser_test916758829/exp b/var/folders/tf/yp9nqwbx4g5djqjxms03cvx80000gn/T/d2parser_test916758829/got // index d48c704b..dbe709e6 100644 // --- a/var/folders/tf/yp9nqwbx4g5djqjxms03cvx80000gn/T/d2parser_test916758829/exp // +++ b/var/folders/tf/yp9nqwbx4g5djqjxms03cvx80000gn/T/d2parser_test916758829/got // @@ -1,5 +1,5 @@ // // becomes: // // --- a/var/folders/tf/yp9nqwbx4g5djqjxms03cvx80000gn/T/d2parser_test916758829/exp // +++ b/var/folders/tf/yp9nqwbx4g5djqjxms03cvx80000gn/T/d2parser_test916758829/got // @@ -1,5 +1,5 @@ i := strings.Index(ds, "index") if i > -1 { j := strings.IndexByte(ds[i:], '\n') if j > -1 { ds = ds[i+j+1:] } } return strings.TrimSpace(ds), nil } // Runes is like Strings but formats exp and got with each unicode codepoint on a separate // line and generates a diff of that. It's useful for autogenerated UTF-8 with // xrand.String as Strings won't generate a coherent diff with undisplayable characters. func Runes(exp, got string) error { if exp == got { return nil } expRunes := formatRunes(exp) gotRunes := formatRunes(got) ds, err := Strings(expRunes, gotRunes) if err != nil { return err } if ds != "" { return errors.New(ds) } return nil } func formatRunes(s string) string { return strings.Join(strings.Split(fmt.Sprintf("%#v", []rune(s)), ", "), "\n") } // TestdataJSON is for when you have JSON that is too large to easily keep embedded by the // tests in _test.go files. As well, it makes the acceptance of large changes trivial // unlike say fs/embed. // // TestdataJSON encodes got as JSON and diffs it against the stored json in path.exp.json. // The got JSON is stored in path.got.json. If the diff is empty, it returns nil. // // Otherwise it returns an error containing the diff. // // In order to accept changes path.got.json has to become path.exp.json. You can use // ./ci/testdata/accept.sh to rename all non stale path.got.json files to path.exp.json. // // You can scope it to a single test or folder, see ./ci/testdata/accept.sh --help // // Also see ./ci/testdata/clean.sh --help for cleaning the repository of all // path.got.json and path.exp.json files. // // You can also use $TESTDATA_ACCEPT=1 to update all path.exp.json files on the fly. // This is useful when you're regenerating the repository's testdata. You can't easily // use the accept script without rerunning go test multiple times as go test will return // after too many test failures and will not continue until they are fixed. // // You'll want to use -count=1 to disable go test's result caching if you do use // $TESTDATA_ACCEPT. // // TestdataJSON will automatically create nonexistent directories in path. // // Here's an example that you can play with to better understand the behaviour: // // err = diff.TestdataJSON(filepath.Join("testdata", t.Name()), "change me") // if err != nil { // t.Fatal(err) // } // // Normally you want to use t.Name() as path for clarity but you can pass in any string. // e.g. a single test could persist two json objects into testdata with: // // err = diff.TestdataJSON(filepath.Join("testdata", t.Name(), "1"), "change me 1") // if err != nil { // t.Fatal(err) // } // err = diff.TestdataJSON(filepath.Join("testdata", t.Name(), "2"), "change me 2") // if err != nil { // t.Fatal(err) // } // // These would persist in testdata/${t.Name()}/1.exp.json and testdata/${t.Name()}/2.exp.json // // It uses Files under the hood. // // note: testdata is the canonical Go directory for such persistent test only files. // It is unfortunately poorly documented. See https://pkg.go.dev/cmd/go/internal/test // So normally you'd want path to be filepath.Join("testdata", t.Name()). // This is also the reason this function is named "TestdataJSON". func TestdataJSON(path string, got interface{}) error { gotb := xjson.Marshal(got) gotb = append(gotb, '\n') return Testdata(path, ".json", gotb) } // ext includes period like path.Ext() func Testdata(path, ext string, got []byte) error { expPath := fmt.Sprintf("%s.exp%s", path, ext) gotPath := fmt.Sprintf("%s.got%s", path, ext) err := os.MkdirAll(filepath.Dir(gotPath), 0755) if err != nil { return err } err = ioutil.WriteFile(gotPath, []byte(got), 0600) if err != nil { return err } ds, err := Files(expPath, gotPath) if err != nil { return err } if ds != "" { if os.Getenv("TESTDATA_ACCEPT") != "" || os.Getenv("TA") != "" { return os.Rename(gotPath, expPath) } if os.Getenv("NO_DIFF") != "" || os.Getenv("ND") != "" { ds = "diff hidden with $NO_DIFF=1 or $ND=1" } return fmt.Errorf("diff (rerun with $TESTDATA_ACCEPT=1 or $TA=1 to accept):\n%s", ds) } return os.Remove(gotPath) } func JSON(exp, got interface{}) (string, error) { return Strings(string(xjson.Marshal(exp)), string(xjson.Marshal(got))) } func TestdataDir(testName, dir string) (err error) { defer xdefer.Errorf(&err, "failed to commit testdata dir %v", dir) testdataDir(&err, testName, dir) return err } func testdataDir(errs *error, testName, dir string) { ea, err := os.ReadDir(dir) if err != nil { *errs = multierr.Combine(*errs, err) return } for _, e := range ea { if e.IsDir() { testdataDir(errs, filepath.Join(testName, e.Name()), filepath.Join(dir, e.Name())) } else { ext := filepath.Ext(e.Name()) name := strings.TrimSuffix(e.Name(), ext) got, err := os.ReadFile(filepath.Join(dir, e.Name())) if err != nil { *errs = multierr.Combine(*errs, err) continue } err = Testdata(filepath.Join(testName, name), ext, got) if err != nil { *errs = multierr.Combine(*errs, err) } } } }