github.com/cockroachdb/pebble@v0.0.0-20231214172447-ab4952c5f87b/metamorphic/meta.go (about)

     1  // Copyright 2023 The LevelDB-Go and Pebble Authors. All rights reserved. Use
     2  // of this source code is governed by a BSD-style license that can be found in
     3  // the LICENSE file.
     4  
     5  // Package metamorphic provides a testing framework for running randomized tests
     6  // over multiple Pebble databases with varying configurations. Logically
     7  // equivalent operations should result in equivalent output across all
     8  // configurations.
     9  package metamorphic
    10  
    11  import (
    12  	"context"
    13  	"fmt"
    14  	"io"
    15  	"os"
    16  	"os/exec"
    17  	"path"
    18  	"path/filepath"
    19  	"regexp"
    20  	"sort"
    21  	"strconv"
    22  	"testing"
    23  	"time"
    24  
    25  	"github.com/cockroachdb/pebble/internal/base"
    26  	"github.com/cockroachdb/pebble/internal/dsl"
    27  	"github.com/cockroachdb/pebble/internal/randvar"
    28  	"github.com/cockroachdb/pebble/internal/testkeys"
    29  	"github.com/cockroachdb/pebble/vfs"
    30  	"github.com/cockroachdb/pebble/vfs/errorfs"
    31  	"github.com/pmezard/go-difflib/difflib"
    32  	"github.com/stretchr/testify/require"
    33  	"golang.org/x/exp/rand"
    34  	"golang.org/x/sync/errgroup"
    35  )
    36  
    37  type runAndCompareOptions struct {
    38  	seed              uint64
    39  	ops               randvar.Static
    40  	previousOpsPath   string
    41  	initialStatePath  string
    42  	initialStateDesc  string
    43  	traceFile         string
    44  	innerBinary       string
    45  	mutateTestOptions []func(*TestOptions)
    46  	customRuns        map[string]string
    47  	numInstances      int
    48  	runOnceOptions
    49  }
    50  
    51  // A RunOption configures the behavior of RunAndCompare.
    52  type RunOption interface {
    53  	apply(*runAndCompareOptions)
    54  }
    55  
    56  // Seed configures generation to use the provided seed. Seed may be used to
    57  // deterministically reproduce the same run.
    58  type Seed uint64
    59  
    60  func (s Seed) apply(ro *runAndCompareOptions) { ro.seed = uint64(s) }
    61  
    62  // ExtendPreviousRun configures RunAndCompare to use the output of a previous
    63  // metamorphic test run to seed the this run. It's used in the crossversion
    64  // metamorphic tests, in which a data directory is upgraded through multiple
    65  // versions of Pebble, exercising upgrade code paths and cross-version
    66  // compatibility.
    67  //
    68  // The opsPath should be the filesystem path to the ops file containing the
    69  // operations run within the previous iteration of the metamorphic test. It's
    70  // used to inform operation generation to prefer using keys used in the previous
    71  // run, which are therefore more likely to be "interesting."
    72  //
    73  // The initialStatePath argument should be the filesystem path to the data
    74  // directory containing the database where the previous run of the metamorphic
    75  // test left off.
    76  //
    77  // The initialStateDesc argument is presentational and should hold a
    78  // human-readable description of the initial state.
    79  func ExtendPreviousRun(opsPath, initialStatePath, initialStateDesc string) RunOption {
    80  	return closureOpt(func(ro *runAndCompareOptions) {
    81  		ro.previousOpsPath = opsPath
    82  		ro.initialStatePath = initialStatePath
    83  		ro.initialStateDesc = initialStateDesc
    84  	})
    85  }
    86  
    87  var (
    88  	// UseDisk configures RunAndCompare to use the physical filesystem for all
    89  	// generated runs.
    90  	UseDisk = closureOpt(func(ro *runAndCompareOptions) {
    91  		ro.mutateTestOptions = append(ro.mutateTestOptions, func(to *TestOptions) { to.useDisk = true })
    92  	})
    93  	// UseInMemory configures RunAndCompare to use an in-memory virtual
    94  	// filesystem for all generated runs.
    95  	UseInMemory = closureOpt(func(ro *runAndCompareOptions) {
    96  		ro.mutateTestOptions = append(ro.mutateTestOptions, func(to *TestOptions) { to.useDisk = false })
    97  	})
    98  )
    99  
   100  // OpCount configures the random variable for the number of operations to
   101  // generate.
   102  func OpCount(rv randvar.Static) RunOption {
   103  	return closureOpt(func(ro *runAndCompareOptions) { ro.ops = rv })
   104  }
   105  
   106  // RuntimeTrace configures each test run to collect a runtime trace and output
   107  // it with the provided filename.
   108  func RuntimeTrace(name string) RunOption {
   109  	return closureOpt(func(ro *runAndCompareOptions) { ro.traceFile = name })
   110  }
   111  
   112  // InnerBinary configures the binary that is called for each run. If not
   113  // specified, this binary (os.Args[0]) is called.
   114  func InnerBinary(path string) RunOption {
   115  	return closureOpt(func(ro *runAndCompareOptions) { ro.innerBinary = path })
   116  }
   117  
   118  // ParseCustomTestOption adds support for parsing the provided CustomOption from
   119  // OPTIONS files serialized by the metamorphic tests. This RunOption alone does
   120  // not cause the metamorphic tests to run with any variant of the provided
   121  // CustomOption set.
   122  func ParseCustomTestOption(name string, parseFn func(value string) (CustomOption, bool)) RunOption {
   123  	return closureOpt(func(ro *runAndCompareOptions) { ro.customOptionParsers[name] = parseFn })
   124  }
   125  
   126  // AddCustomRun adds an additional run of the metamorphic tests, using the
   127  // provided OPTIONS file contents. The default options will be used, except
   128  // those options that are overriden by the provided OPTIONS string.
   129  func AddCustomRun(name string, serializedOptions string) RunOption {
   130  	return closureOpt(func(ro *runAndCompareOptions) { ro.customRuns[name] = serializedOptions })
   131  }
   132  
   133  type closureOpt func(*runAndCompareOptions)
   134  
   135  func (f closureOpt) apply(ro *runAndCompareOptions) { f(ro) }
   136  
   137  // RunAndCompare runs the metamorphic tests, using the provided root directory
   138  // to hold test data.
   139  func RunAndCompare(t *testing.T, rootDir string, rOpts ...RunOption) {
   140  	runOpts := runAndCompareOptions{
   141  		ops:        randvar.NewUniform(1000, 10000),
   142  		customRuns: map[string]string{},
   143  		runOnceOptions: runOnceOptions{
   144  			customOptionParsers: map[string]func(string) (CustomOption, bool){},
   145  		},
   146  	}
   147  	for _, o := range rOpts {
   148  		o.apply(&runOpts)
   149  	}
   150  	if runOpts.seed == 0 {
   151  		runOpts.seed = uint64(time.Now().UnixNano())
   152  	}
   153  
   154  	require.NoError(t, os.MkdirAll(rootDir, 0755))
   155  	metaDir, err := os.MkdirTemp(rootDir, time.Now().Format("060102-150405.000"))
   156  	require.NoError(t, err)
   157  	require.NoError(t, os.MkdirAll(metaDir, 0755))
   158  	defer func() {
   159  		if !t.Failed() && !runOpts.keep {
   160  			_ = os.RemoveAll(metaDir)
   161  		}
   162  	}()
   163  
   164  	rng := rand.New(rand.NewSource(runOpts.seed))
   165  	opCount := runOpts.ops.Uint64(rng)
   166  
   167  	// Generate a new set of random ops, writing them to <dir>/ops. These will be
   168  	// read by the child processes when performing a test run.
   169  	km := newKeyManager(runOpts.numInstances)
   170  	cfg := presetConfigs[rng.Intn(len(presetConfigs))]
   171  	if runOpts.previousOpsPath != "" {
   172  		// During cross-version testing, we load keys from an `ops` file
   173  		// produced by a metamorphic test run of an earlier Pebble version.
   174  		// Seeding the keys ensure we generate interesting operations, including
   175  		// ones with key shadowing, merging, etc.
   176  		opsPath := filepath.Join(filepath.Dir(filepath.Clean(runOpts.previousOpsPath)), "ops")
   177  		opsData, err := os.ReadFile(opsPath)
   178  		require.NoError(t, err)
   179  		ops, err := parse(opsData, parserOpts{})
   180  		require.NoError(t, err)
   181  		loadPrecedingKeys(t, ops, &cfg, km)
   182  	}
   183  	if runOpts.numInstances > 1 {
   184  		// The multi-instance variant does not support all operations yet.
   185  		//
   186  		// TODO(bilal): Address this and use the default configs.
   187  		cfg = multiInstancePresetConfig
   188  		cfg.numInstances = runOpts.numInstances
   189  	}
   190  	ops := generate(rng, opCount, cfg, km)
   191  	opsPath := filepath.Join(metaDir, "ops")
   192  	formattedOps := formatOps(ops)
   193  	require.NoError(t, os.WriteFile(opsPath, []byte(formattedOps), 0644))
   194  
   195  	// runOptions performs a particular test run with the specified options. The
   196  	// options are written to <run-dir>/OPTIONS and a child process is created to
   197  	// actually execute the test.
   198  	runOptions := func(t *testing.T, opts *TestOptions) {
   199  		if opts.Opts.Cache != nil {
   200  			defer opts.Opts.Cache.Unref()
   201  		}
   202  		for _, fn := range runOpts.mutateTestOptions {
   203  			fn(opts)
   204  		}
   205  		runDir := filepath.Join(metaDir, path.Base(t.Name()))
   206  		require.NoError(t, os.MkdirAll(runDir, 0755))
   207  
   208  		optionsPath := filepath.Join(runDir, "OPTIONS")
   209  		optionsStr := optionsToString(opts)
   210  		require.NoError(t, os.WriteFile(optionsPath, []byte(optionsStr), 0644))
   211  
   212  		args := []string{
   213  			"-keep=" + fmt.Sprint(runOpts.keep),
   214  			"-run-dir=" + runDir,
   215  			"-test.run=" + t.Name() + "$",
   216  		}
   217  		if runOpts.numInstances > 1 {
   218  			args = append(args, "--num-instances="+strconv.Itoa(runOpts.numInstances))
   219  		}
   220  		if runOpts.traceFile != "" {
   221  			args = append(args, "-test.trace="+filepath.Join(runDir, runOpts.traceFile))
   222  		}
   223  
   224  		binary := os.Args[0]
   225  		if runOpts.innerBinary != "" {
   226  			binary = runOpts.innerBinary
   227  		}
   228  		cmd := exec.Command(binary, args...)
   229  		out, err := cmd.CombinedOutput()
   230  		if err != nil {
   231  			t.Fatalf(`
   232  ===== SEED =====
   233  %d
   234  ===== ERR =====
   235  %v
   236  ===== OUT =====
   237  %s
   238  ===== OPTIONS =====
   239  %s
   240  ===== OPS =====
   241  %s
   242  ===== HISTORY =====
   243  %s`, runOpts.seed, err, out, optionsStr, formattedOps, readFile(filepath.Join(runDir, "history")))
   244  		}
   245  	}
   246  
   247  	var names []string
   248  	options := map[string]*TestOptions{}
   249  
   250  	// Create the standard options.
   251  	for i, opts := range standardOptions() {
   252  		name := fmt.Sprintf("standard-%03d", i)
   253  		names = append(names, name)
   254  		options[name] = opts
   255  	}
   256  
   257  	// Create the custom option runs, if any.
   258  	for name, customOptsStr := range runOpts.customRuns {
   259  		options[name] = defaultTestOptions()
   260  		if err := parseOptions(options[name], customOptsStr, runOpts.customOptionParsers); err != nil {
   261  			t.Fatalf("custom opts %q: %s", name, err)
   262  		}
   263  	}
   264  	// Sort the custom options names for determinism (they're currently in
   265  	// random order from map iteration).
   266  	sort.Strings(names[len(names)-len(runOpts.customRuns):])
   267  
   268  	// Create random options. We make an arbitrary choice to run with as many
   269  	// random options as we have standard options.
   270  	nOpts := len(options)
   271  	for i := 0; i < nOpts; i++ {
   272  		name := fmt.Sprintf("random-%03d", i)
   273  		names = append(names, name)
   274  		opts := randomOptions(rng, runOpts.customOptionParsers)
   275  		options[name] = opts
   276  	}
   277  
   278  	// If the user provided the path to an initial database state to use, update
   279  	// all the options to pull from it.
   280  	if runOpts.initialStatePath != "" {
   281  		for _, o := range options {
   282  			var err error
   283  			o.initialStatePath, err = filepath.Abs(runOpts.initialStatePath)
   284  			require.NoError(t, err)
   285  			o.initialStateDesc = runOpts.initialStateDesc
   286  		}
   287  	}
   288  
   289  	// Run the options.
   290  	t.Run("execution", func(t *testing.T) {
   291  		for _, name := range names {
   292  			name := name
   293  			t.Run(name, func(t *testing.T) {
   294  				t.Parallel()
   295  				runOptions(t, options[name])
   296  			})
   297  		}
   298  	})
   299  	// NB: The above 'execution' subtest will not complete until all of the
   300  	// individual execution/ subtests have completed. The grouping within the
   301  	// `execution` subtest ensures all the histories are available when we
   302  	// proceed to comparing against the base history.
   303  
   304  	// Don't bother comparing output if we've already failed.
   305  	if t.Failed() {
   306  		return
   307  	}
   308  
   309  	t.Run("compare", func(t *testing.T) {
   310  		getHistoryPath := func(name string) string {
   311  			return filepath.Join(metaDir, name, "history")
   312  		}
   313  
   314  		base := readHistory(t, getHistoryPath(names[0]))
   315  		base = reorderHistory(base)
   316  		for i := 1; i < len(names); i++ {
   317  			t.Run(names[i], func(t *testing.T) {
   318  				lines := readHistory(t, getHistoryPath(names[i]))
   319  				lines = reorderHistory(lines)
   320  				diff := difflib.UnifiedDiff{
   321  					A:       base,
   322  					B:       lines,
   323  					Context: 5,
   324  				}
   325  				text, err := difflib.GetUnifiedDiffString(diff)
   326  				require.NoError(t, err)
   327  				if text != "" {
   328  					// NB: We force an exit rather than using t.Fatal because the latter
   329  					// will run another instance of the test if -count is specified, while
   330  					// we're happy to exit on the first failure.
   331  					optionsStrA := optionsToString(options[names[0]])
   332  					optionsStrB := optionsToString(options[names[i]])
   333  
   334  					fmt.Printf(`
   335  		===== SEED =====
   336  		%d
   337  		===== DIFF =====
   338  		%s/{%s,%s}
   339  		%s
   340  		===== OPTIONS %s =====
   341  		%s
   342  		===== OPTIONS %s =====
   343  		%s
   344  		===== OPS =====
   345  		%s
   346  		`, runOpts.seed, metaDir, names[0], names[i], text, names[0], optionsStrA, names[i], optionsStrB, formattedOps)
   347  					os.Exit(1)
   348  				}
   349  			})
   350  		}
   351  	})
   352  }
   353  
   354  type runOnceOptions struct {
   355  	keep                bool
   356  	maxThreads          int
   357  	errorRate           float64
   358  	failRegexp          *regexp.Regexp
   359  	numInstances        int
   360  	customOptionParsers map[string]func(string) (CustomOption, bool)
   361  }
   362  
   363  // A RunOnceOption configures the behavior of a single run of the metamorphic
   364  // tests.
   365  type RunOnceOption interface {
   366  	applyOnce(*runOnceOptions)
   367  }
   368  
   369  // KeepData keeps the database directory, even on successful runs. If the test
   370  // used an in-memory filesystem, the in-memory filesystem will be persisted to
   371  // the run directory.
   372  type KeepData struct{}
   373  
   374  func (KeepData) apply(ro *runAndCompareOptions) { ro.keep = true }
   375  func (KeepData) applyOnce(ro *runOnceOptions)   { ro.keep = true }
   376  
   377  // InjectErrorsRate configures the run to inject errors into read-only
   378  // filesystem operations and retry injected errors.
   379  type InjectErrorsRate float64
   380  
   381  func (r InjectErrorsRate) apply(ro *runAndCompareOptions) { ro.errorRate = float64(r) }
   382  func (r InjectErrorsRate) applyOnce(ro *runOnceOptions)   { ro.errorRate = float64(r) }
   383  
   384  // MaxThreads sets an upper bound on the number of parallel execution threads
   385  // during replay.
   386  type MaxThreads int
   387  
   388  func (m MaxThreads) apply(ro *runAndCompareOptions) { ro.maxThreads = int(m) }
   389  func (m MaxThreads) applyOnce(ro *runOnceOptions)   { ro.maxThreads = int(m) }
   390  
   391  // FailOnMatch configures the run to fail immediately if the history matches the
   392  // provided regular expression.
   393  type FailOnMatch struct {
   394  	*regexp.Regexp
   395  }
   396  
   397  func (f FailOnMatch) apply(ro *runAndCompareOptions) { ro.failRegexp = f.Regexp }
   398  func (f FailOnMatch) applyOnce(ro *runOnceOptions)   { ro.failRegexp = f.Regexp }
   399  
   400  // MultiInstance configures the number of pebble instances to create.
   401  type MultiInstance int
   402  
   403  func (m MultiInstance) apply(ro *runAndCompareOptions) { ro.numInstances = int(m) }
   404  func (m MultiInstance) applyOnce(ro *runOnceOptions)   { ro.numInstances = int(m) }
   405  
   406  // RunOnce performs one run of the metamorphic tests. RunOnce expects the
   407  // directory named by `runDir` to already exist and contain an `OPTIONS` file
   408  // containing the test run's configuration. The history of the run is persisted
   409  // to a file at the path `historyPath`.
   410  //
   411  // The `seed` parameter is not functional; it's used for context in logging.
   412  func RunOnce(t TestingT, runDir string, seed uint64, historyPath string, rOpts ...RunOnceOption) {
   413  	runOpts := runOnceOptions{
   414  		customOptionParsers: map[string]func(string) (CustomOption, bool){},
   415  	}
   416  	for _, o := range rOpts {
   417  		o.applyOnce(&runOpts)
   418  	}
   419  
   420  	opsPath := filepath.Join(filepath.Dir(filepath.Clean(runDir)), "ops")
   421  	opsData, err := os.ReadFile(opsPath)
   422  	require.NoError(t, err)
   423  
   424  	ops, err := parse(opsData, parserOpts{})
   425  	require.NoError(t, err)
   426  	_ = ops
   427  
   428  	optionsPath := filepath.Join(runDir, "OPTIONS")
   429  	optionsData, err := os.ReadFile(optionsPath)
   430  	require.NoError(t, err)
   431  
   432  	// NB: It's important to use defaultTestOptions() here as the base into
   433  	// which we parse the serialized options. It contains the relevant defaults,
   434  	// like the appropriate block-property collectors.
   435  	testOpts := defaultTestOptions()
   436  	opts := testOpts.Opts
   437  	require.NoError(t, parseOptions(testOpts, string(optionsData), runOpts.customOptionParsers))
   438  
   439  	// Always use our custom comparer which provides a Split method, splitting
   440  	// keys at the trailing '@'.
   441  	opts.Comparer = testkeys.Comparer
   442  	// Use an archive cleaner to ease post-mortem debugging.
   443  	opts.Cleaner = base.ArchiveCleaner{}
   444  
   445  	// Set up the filesystem to use for the test. Note that by default we use an
   446  	// in-memory FS.
   447  	if testOpts.useDisk {
   448  		opts.FS = vfs.Default
   449  		require.NoError(t, os.RemoveAll(opts.FS.PathJoin(runDir, "data")))
   450  	} else {
   451  		opts.Cleaner = base.ArchiveCleaner{}
   452  		if testOpts.strictFS {
   453  			opts.FS = vfs.NewStrictMem()
   454  		} else {
   455  			opts.FS = vfs.NewMem()
   456  		}
   457  	}
   458  	opts.WithFSDefaults()
   459  
   460  	threads := testOpts.threads
   461  	if runOpts.maxThreads < threads {
   462  		threads = runOpts.maxThreads
   463  	}
   464  
   465  	dir := opts.FS.PathJoin(runDir, "data")
   466  	// Set up the initial database state if configured to start from a non-empty
   467  	// database. By default tests start from an empty database, but split
   468  	// version testing may configure a previous metamorphic tests's database
   469  	// state as the initial state.
   470  	if testOpts.initialStatePath != "" {
   471  		require.NoError(t, setupInitialState(dir, testOpts))
   472  	}
   473  
   474  	// Wrap the filesystem with one that will inject errors into read
   475  	// operations with *errorRate probability.
   476  	opts.FS = errorfs.Wrap(opts.FS, errorfs.ErrInjected.If(
   477  		dsl.And[errorfs.Op](errorfs.Reads, errorfs.Randomly(runOpts.errorRate, int64(seed))),
   478  	))
   479  
   480  	if opts.WALDir != "" {
   481  		if runOpts.numInstances > 1 {
   482  			// TODO(bilal): Allow opts to diverge on a per-instance basis, and use
   483  			// that to set unique WAL dirs for all instances in multi-instance mode.
   484  			opts.WALDir = ""
   485  		} else {
   486  			opts.WALDir = opts.FS.PathJoin(runDir, opts.WALDir)
   487  		}
   488  	}
   489  
   490  	historyFile, err := os.Create(historyPath)
   491  	require.NoError(t, err)
   492  	defer historyFile.Close()
   493  	writers := []io.Writer{historyFile}
   494  
   495  	if testing.Verbose() {
   496  		writers = append(writers, os.Stdout)
   497  	}
   498  	h := newHistory(runOpts.failRegexp, writers...)
   499  
   500  	m := newTest(ops)
   501  	require.NoError(t, m.init(h, dir, testOpts, runOpts.numInstances))
   502  
   503  	if threads <= 1 {
   504  		for m.step(h) {
   505  			if err := h.Error(); err != nil {
   506  				fmt.Fprintf(os.Stderr, "Seed: %d\n", seed)
   507  				fmt.Fprintln(os.Stderr, err)
   508  				m.maybeSaveData()
   509  				os.Exit(1)
   510  			}
   511  		}
   512  	} else {
   513  		eg, ctx := errgroup.WithContext(context.Background())
   514  		for t := 0; t < threads; t++ {
   515  			t := t // bind loop var to scope
   516  			eg.Go(func() error {
   517  				for idx := 0; idx < len(m.ops); idx++ {
   518  					// Skip any operations whose receiver object hashes to a
   519  					// different thread. All operations with the same receiver
   520  					// are performed from the same thread. This goroutine is
   521  					// only responsible for executing operations that hash to
   522  					// `t`.
   523  					if hashThread(m.ops[idx].receiver(), threads) != t {
   524  						continue
   525  					}
   526  
   527  					// Some operations have additional synchronization
   528  					// dependencies. If this operation has any, wait for its
   529  					// dependencies to complete before executing.
   530  					for _, waitOnIdx := range m.opsWaitOn[idx] {
   531  						select {
   532  						case <-ctx.Done():
   533  							// Exit if some other thread already errored out.
   534  							return ctx.Err()
   535  						case <-m.opsDone[waitOnIdx]:
   536  						}
   537  					}
   538  
   539  					m.ops[idx].run(m, h.recorder(t, idx))
   540  
   541  					// If this operation has a done channel, close it so that
   542  					// other operations that synchronize on this operation know
   543  					// that it's been completed.
   544  					if ch := m.opsDone[idx]; ch != nil {
   545  						close(ch)
   546  					}
   547  
   548  					if err := h.Error(); err != nil {
   549  						return err
   550  					}
   551  				}
   552  				return nil
   553  			})
   554  		}
   555  		if err := eg.Wait(); err != nil {
   556  			fmt.Fprintf(os.Stderr, "Seed: %d\n", seed)
   557  			fmt.Fprintln(os.Stderr, err)
   558  			m.maybeSaveData()
   559  			os.Exit(1)
   560  		}
   561  	}
   562  
   563  	if runOpts.keep && !testOpts.useDisk {
   564  		m.maybeSaveData()
   565  	}
   566  }
   567  
   568  func hashThread(objID objID, numThreads int) int {
   569  	// Fibonacci hash https://probablydance.com/2018/06/16/fibonacci-hashing-the-optimization-that-the-world-forgot-or-a-better-alternative-to-integer-modulo/
   570  	return int((11400714819323198485 * uint64(objID)) % uint64(numThreads))
   571  }
   572  
   573  // Compare runs the metamorphic tests in the provided runDirs and compares their
   574  // histories.
   575  func Compare(t TestingT, rootDir string, seed uint64, runDirs []string, rOpts ...RunOnceOption) {
   576  	historyPaths := make([]string, len(runDirs))
   577  	for i := 0; i < len(runDirs); i++ {
   578  		historyPath := filepath.Join(rootDir, runDirs[i]+"-"+time.Now().Format("060102-150405.000"))
   579  		runDirs[i] = filepath.Join(rootDir, runDirs[i])
   580  		_ = os.Remove(historyPath)
   581  		historyPaths[i] = historyPath
   582  	}
   583  	defer func() {
   584  		for _, path := range historyPaths {
   585  			_ = os.Remove(path)
   586  		}
   587  	}()
   588  
   589  	for i, runDir := range runDirs {
   590  		RunOnce(t, runDir, seed, historyPaths[i], rOpts...)
   591  	}
   592  
   593  	if t.Failed() {
   594  		return
   595  	}
   596  
   597  	i, diff := CompareHistories(t, historyPaths)
   598  	if i != 0 {
   599  		fmt.Printf(`
   600  ===== DIFF =====
   601  %s/{%s,%s}
   602  %s
   603  `, rootDir, runDirs[0], runDirs[i], diff)
   604  		os.Exit(1)
   605  	}
   606  }
   607  
   608  // TestingT is an interface wrapper around *testing.T
   609  type TestingT interface {
   610  	require.TestingT
   611  	Failed() bool
   612  }
   613  
   614  func readFile(path string) string {
   615  	history, err := os.ReadFile(path)
   616  	if err != nil {
   617  		return fmt.Sprintf("err: %v", err)
   618  	}
   619  
   620  	return string(history)
   621  }