github.com/qri-io/qri@v0.10.1-0.20220104210721-c771715036cb/cmd/test_runner_test.go (about) 1 package cmd 2 3 import ( 4 "bytes" 5 "context" 6 "errors" 7 "fmt" 8 "io/ioutil" 9 "os" 10 "path/filepath" 11 "runtime" 12 "strconv" 13 "strings" 14 "sync" 15 "testing" 16 "text/scanner" 17 "time" 18 19 "github.com/qri-io/dataset" 20 "github.com/qri-io/ioes" 21 "github.com/qri-io/qri/auth/key" 22 run "github.com/qri-io/qri/automation/run" 23 "github.com/qri-io/qri/base" 24 "github.com/qri-io/qri/base/dsfs" 25 "github.com/qri-io/qri/dsref" 26 "github.com/qri-io/qri/lib" 27 "github.com/qri-io/qri/logbook" 28 "github.com/qri-io/qri/registry" 29 "github.com/qri-io/qri/registry/regserver" 30 remotemock "github.com/qri-io/qri/remote/mock" 31 "github.com/qri-io/qri/repo" 32 repotest "github.com/qri-io/qri/repo/test" 33 "github.com/qri-io/qri/transform/startf" 34 "github.com/spf13/cobra" 35 ) 36 37 // TestRunner holds data used to run tests 38 type TestRunner struct { 39 RepoRoot *repotest.TempRepo 40 RepoPath string 41 42 Context context.Context 43 ContextDone func() 44 TmpDir string 45 Streams ioes.IOStreams 46 InStream *bytes.Buffer 47 OutStream *bytes.Buffer 48 ErrStream *bytes.Buffer 49 DsfsTsFunc func() time.Time 50 LogbookTsFunc func() int64 51 LocOrig *time.Location 52 XformVersion string 53 CmdR *cobra.Command 54 Teardown func() 55 CmdDoneCh chan struct{} 56 TestCrypto key.CryptoGenerator 57 58 Registry *registry.Registry 59 } 60 61 // NewTestRunner constructs a new TestRunner 62 func NewTestRunner(t *testing.T, peerName, testName string) *TestRunner { 63 root, err := repotest.NewTempRepoFixedProfileID(peerName, testName) 64 if err != nil { 65 t.Fatalf("creating temp repo: %s", err) 66 } 67 return newTestRunnerFromRoot(&root) 68 } 69 70 // NewTestRunnerWithMockRemoteClient constructs a test runner with a mock remote client 71 func NewTestRunnerWithMockRemoteClient(t *testing.T, peerName, testName string) *TestRunner { 72 root, err := repotest.NewTempRepoFixedProfileID(peerName, testName) 73 if err != nil { 74 t.Fatalf("creating temp repo: %s", err) 75 } 76 root.UseMockRemoteClient = true 77 return newTestRunnerFromRoot(&root) 78 } 79 80 // NewTestRunnerUsingPeerInfoWithMockRemoteClient constructs a test runner using an 81 // explicit testPeer, as well as a mock remote client 82 func NewTestRunnerUsingPeerInfoWithMockRemoteClient(t *testing.T, peerInfoNum int, peerName, testName string) *TestRunner { 83 root, err := repotest.NewTempRepoUsingPeerInfo(peerInfoNum, peerName, testName) 84 if err != nil { 85 t.Fatalf("creating temp repo: %s", err) 86 } 87 root.UseMockRemoteClient = true 88 return newTestRunnerFromRoot(&root) 89 } 90 91 // NewTestRunnerWithTempRegistry constructs a test runner with a mock registry connection 92 func NewTestRunnerWithTempRegistry(t *testing.T, peerName, testName string) *TestRunner { 93 t.Helper() 94 root, err := repotest.NewTempRepoFixedProfileID(peerName, testName) 95 if err != nil { 96 t.Fatalf("creating temp repo: %s", err) 97 } 98 99 ctx, cancel := context.WithCancel(context.Background()) 100 // TODO(dustmop): Switch to root.TestCrypto. Until then, we're reusing the 101 // same testPeers, leading to different nodes with the same profileID 102 g := repotest.NewTestCrypto() 103 reg, teardownRegistry, err := regserver.NewTempRegistry(ctx, "registry", testName+"_registry", g) 104 if err != nil { 105 t.Fatalf("creating registry: %s", err) 106 } 107 108 // TODO (b5) - wouldn't it be nice if we could pass the client as an instance configuration 109 // option? that'd require re-thinking the way we do NewQriCommand 110 _, server := regserver.NewMockServerRegistry(*reg) 111 112 tr := newTestRunnerFromRoot(&root) 113 tr.Registry = reg 114 prevTeardown := tr.Teardown 115 tr.Teardown = func() { 116 cancel() 117 teardownRegistry() 118 server.Close() 119 if prevTeardown != nil { 120 prevTeardown() 121 } 122 } 123 124 root.GetConfig().Registry.Location = server.URL 125 if err := root.WriteConfigFile(); err != nil { 126 t.Fatalf("writing config file: %s", err) 127 } 128 129 return tr 130 } 131 132 func useConsistentRunIDs() { 133 source := strings.NewReader(strings.Repeat("OmgZombies!?!?!", 200)) 134 run.SetIDRand(source) 135 } 136 137 func newTestRunnerFromRoot(root *repotest.TempRepo) *TestRunner { 138 ctx, cancel := context.WithCancel(context.Background()) 139 useConsistentRunIDs() 140 141 tr := TestRunner{ 142 RepoRoot: root, 143 RepoPath: filepath.Join(root.RootPath, "qri"), 144 Context: ctx, 145 ContextDone: cancel, 146 TestCrypto: root.TestCrypto, 147 } 148 149 // TmpDir will be removed recursively, only if it is non-empty 150 tr.TmpDir = "" 151 152 // To keep hashes consistent, artificially specify the timestamp by overriding 153 // the dsfs.Timestamp func 154 dsfsCounter := 0 155 tr.DsfsTsFunc = dsfs.Timestamp 156 dsfs.Timestamp = func() time.Time { 157 dsfsCounter++ 158 return time.Date(2001, 01, 01, 01, dsfsCounter, 01, 01, time.UTC) 159 } 160 161 // Do the same for logbook.NewTimestamp 162 bookCounter := 0 163 tr.LogbookTsFunc = logbook.NewTimestamp 164 logbook.NewTimestamp = func() int64 { 165 bookCounter++ 166 return time.Date(2001, 01, 01, 01, bookCounter, 01, 01, time.UTC).Unix() 167 } 168 169 // Set IOStreams 170 tr.Streams, tr.InStream, tr.OutStream, tr.ErrStream = ioes.NewTestIOStreams() 171 setNoColor(true) 172 //setNoPrompt(true) 173 174 // Set the location to New York so that timezone printing is consistent 175 location, _ := time.LoadLocation("America/New_York") 176 tr.LocOrig = StringerLocation 177 StringerLocation = location 178 179 // Stub the version of starlark, because it is output when transforms run 180 tr.XformVersion = startf.Version 181 startf.Version = "test_version" 182 183 return &tr 184 } 185 186 // Delete cleans up after a TestRunner is done being used. 187 func (runner *TestRunner) Delete() { 188 if runner.Teardown != nil { 189 runner.Teardown() 190 } 191 if runner.TmpDir != "" { 192 os.RemoveAll(runner.TmpDir) 193 } 194 // restore random RunID generator 195 run.SetIDRand(nil) 196 dsfs.Timestamp = runner.DsfsTsFunc 197 logbook.NewTimestamp = runner.LogbookTsFunc 198 StringerLocation = runner.LocOrig 199 startf.Version = runner.XformVersion 200 runner.ContextDone() 201 runner.RepoRoot.Delete() 202 } 203 204 // MakeTmpDir returns a temporary directory that runner will delete when done 205 func (runner *TestRunner) MakeTmpDir(t *testing.T, pattern string) string { 206 if runner.TmpDir != "" { 207 t.Fatal("can only make one tmpDir at a time") 208 } 209 tmpDir, err := ioutil.TempDir("", pattern) 210 if err != nil { 211 t.Fatal(err) 212 } 213 runner.TmpDir = tmpDir 214 return tmpDir 215 } 216 217 // TODO(dustmop): Look into using options instead of multiple exec functions. 218 219 // ExecCommand executes the given command string 220 func (runner *TestRunner) ExecCommand(cmdText string) error { 221 var shutdown func() <-chan error 222 runner.CmdR, shutdown = runner.CreateCommandRunner(runner.Context) 223 if err := executeCommand(runner.CmdR, cmdText); err != nil { 224 timedShutdown(fmt.Sprintf("ExecCommand: %q\n", cmdText), shutdown) 225 return err 226 } 227 228 return timedShutdown(fmt.Sprintf("ExecCommand: %q\n", cmdText), shutdown) 229 } 230 231 // ExecCommandWithStdin executes the given command string with the string as stdin content 232 func (runner *TestRunner) ExecCommandWithStdin(ctx context.Context, cmdText, stdinText string) error { 233 setNoColor(true) 234 runner.Streams.In = strings.NewReader(stdinText) 235 ctors := Constructors{ 236 CryptoGenerator: runner.RepoRoot.TestCrypto, 237 InitIPFS: repotest.InitIPFSRepo, 238 } 239 cmd, shutdown := NewQriCommand(ctx, runner.RepoPath, ctors, runner.Streams) 240 cmd.SetOutput(runner.OutStream) 241 runner.CmdR = cmd 242 if err := executeCommand(runner.CmdR, cmdText); err != nil { 243 return err 244 } 245 246 return timedShutdown(fmt.Sprintf("ExecCommandWithStdin: %q\n", cmdText), shutdown) 247 } 248 249 // ExecCommandCombinedOutErr executes the command with a combined stdout and stderr stream 250 func (runner *TestRunner) ExecCommandCombinedOutErr(cmdText string) error { 251 ctx, cancel := context.WithCancel(runner.Context) 252 var shutdown func() <-chan error 253 runner.CmdR, shutdown = runner.CreateCommandRunnerCombinedOutErr(ctx) 254 if err := executeCommand(runner.CmdR, cmdText); err != nil { 255 shutDownErr := <-shutdown() 256 if shutDownErr != nil { 257 log.Errorf("error shutting down %q: %q", cmdText, shutDownErr) 258 } 259 cancel() 260 return err 261 } 262 263 err := timedShutdown(fmt.Sprintf("ExecCommandCombinedOutErr: %q\n", cmdText), shutdown) 264 cancel() 265 return err 266 } 267 268 func timedShutdown(cmd string, shutdown func() <-chan error) error { 269 waitForDone := make(chan error) 270 go func() { 271 select { 272 case <-time.NewTimer(time.Second * 3).C: 273 waitForDone <- fmt.Errorf("%s shutdown didn't send on 'done' channel within 3 seconds of context cancellation", cmd) 274 case err := <-shutdown(): 275 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { 276 err = nil 277 } 278 waitForDone <- err 279 } 280 }() 281 return <-waitForDone 282 } 283 284 func shutdownRepoGraceful(cancel context.CancelFunc, r repo.Repo) error { 285 var ( 286 wg sync.WaitGroup 287 err error 288 ) 289 wg.Add(1) 290 291 go func() { 292 <-r.Done() 293 err = r.DoneErr() 294 wg.Done() 295 }() 296 cancel() 297 wg.Wait() 298 return err 299 } 300 301 // ExecCommandWithContext executes the given command with a context 302 func (runner *TestRunner) ExecCommandWithContext(ctx context.Context, cmdText string) error { 303 var shutdown func() <-chan error 304 runner.CmdR, shutdown = runner.CreateCommandRunner(ctx) 305 if err := executeCommand(runner.CmdR, cmdText); err != nil { 306 return err 307 } 308 309 return timedShutdown(fmt.Sprintf("ExecCommandWithContext: %q\n", cmdText), shutdown) 310 } 311 312 func (runner *TestRunner) MustExecuteQuotedCommand(t *testing.T, quotedCmdText string) string { 313 var shutdown func() <-chan error 314 runner.CmdR, shutdown = runner.CreateCommandRunner(runner.Context) 315 316 if err := executeQuotedCommand(runner.CmdR, quotedCmdText); err != nil { 317 _, callerFile, callerLine, ok := runtime.Caller(1) 318 if !ok { 319 t.Fatal(err) 320 } else { 321 t.Fatalf("%s:%d: %s", callerFile, callerLine, err) 322 } 323 } 324 if err := timedShutdown(fmt.Sprintf("MustExecuteQuotedCommand: %q\n", quotedCmdText), shutdown); err != nil { 325 t.Error(err) 326 } 327 return runner.GetCommandOutput() 328 } 329 330 // CreateCommandRunner returns a cobra runable command. 331 func (runner *TestRunner) CreateCommandRunner(ctx context.Context) (*cobra.Command, func() <-chan error) { 332 return runner.newCommandRunner(ctx, false) 333 } 334 335 // CreateCommandRunnerCombinedOutErr returns a cobra command that combines stdout and stderr 336 func (runner *TestRunner) CreateCommandRunnerCombinedOutErr(ctx context.Context) (*cobra.Command, func() <-chan error) { 337 cmd, shutdown := runner.newCommandRunner(ctx, true) 338 return cmd, shutdown 339 } 340 341 func (runner *TestRunner) newCommandRunner(ctx context.Context, combineOutErr bool) (*cobra.Command, func() <-chan error) { 342 runner.IOReset() 343 streams := runner.Streams 344 if combineOutErr { 345 streams = ioes.NewIOStreams(runner.InStream, runner.OutStream, runner.OutStream) 346 } 347 var opts []lib.Option 348 if runner.RepoRoot.UseMockRemoteClient { 349 opts = append(opts, lib.OptRemoteClientConstructor(remotemock.NewClient)) 350 } 351 ctors := Constructors{ 352 CryptoGenerator: runner.RepoRoot.TestCrypto, 353 InitIPFS: repotest.InitIPFSRepo, 354 } 355 cmd, shutdown := NewQriCommand(ctx, runner.RepoPath, ctors, streams, opts...) 356 cmd.SetOutput(runner.OutStream) 357 return cmd, shutdown 358 } 359 360 // Username returns the test username from the config's profile 361 func (runner *TestRunner) Username() string { 362 return runner.RepoRoot.GetConfig().Profile.Peername 363 } 364 365 // IOReset resets the io streams 366 func (runner *TestRunner) IOReset() { 367 runner.InStream.Reset() 368 runner.OutStream.Reset() 369 runner.ErrStream.Reset() 370 } 371 372 // FileExists returns whether the file exists 373 func (runner *TestRunner) FileExists(file string) bool { 374 if _, err := os.Stat(file); os.IsNotExist(err) { 375 return false 376 } 377 return true 378 } 379 380 // LookupVersionInfo returns a versionInfo for the ref, or nil if not found 381 func (runner *TestRunner) LookupVersionInfo(t *testing.T, refStr string) *dsref.VersionInfo { 382 ctx, cancel := context.WithCancel(context.Background()) 383 defer cancel() 384 r, err := runner.RepoRoot.Repo(ctx) 385 if err != nil { 386 t.Fatal(err) 387 } 388 389 dr, err := dsref.Parse(refStr) 390 if err != nil { 391 t.Fatal(err) 392 } 393 394 // TODO(b5): me shortcut is handled by an instance, it'd be nice we had a 395 // function in the repo package that deduplicated this in both places 396 if dr.Username == "me" { 397 dr.Username = r.Profiles().Owner(ctx).Peername 398 } 399 400 if _, err := r.ResolveRef(ctx, &dr); err != nil { 401 return nil 402 } 403 404 // TODO(b5): TestUnlinkNoHistory relies on a nil-return versionInfo, so 405 // we need to ignore this error for now 406 vi, _ := repo.GetVersionInfoShim(r, dr) 407 // if err != nil { 408 // t.Fatal(err) 409 // } 410 411 // TODO(b5) - hand-creating a shutdown function to satisfy "timedshutdown", 412 // which works with an instance in most other cases 413 shutdown := func() <-chan error { 414 finished := make(chan error) 415 go func() { 416 <-r.Done() 417 finished <- r.DoneErr() 418 }() 419 420 cancel() 421 return finished 422 } 423 424 err = timedShutdown("LookupVersionInfo", shutdown) 425 if err != nil { 426 t.Fatal(err) 427 } 428 429 return vi 430 } 431 432 // GetPathForDataset fetches a path for dataset index 433 func (runner *TestRunner) GetPathForDataset(t *testing.T, index int) string { 434 path, err := runner.RepoRoot.GetPathForDataset(index) 435 if err != nil { 436 t.Fatal(err) 437 } 438 return path 439 } 440 441 // ReadBodyFromIPFS reads body data from an IPFS repo by path string, 442 func (runner *TestRunner) ReadBodyFromIPFS(t *testing.T, path string) string { 443 body, err := runner.RepoRoot.ReadBodyFromIPFS(path) 444 if err != nil { 445 t.Fatal(err) 446 } 447 return body 448 } 449 450 // DatasetMarshalJSON reads the dataset head and marshals it as json 451 func (runner *TestRunner) DatasetMarshalJSON(t *testing.T, ref string) string { 452 data, err := runner.RepoRoot.DatasetMarshalJSON(ref) 453 if err != nil { 454 t.Fatal(err) 455 } 456 return data 457 } 458 459 // MustLoadDataset loads the dataset or fails 460 func (runner *TestRunner) MustLoadDataset(t *testing.T, ref string) *dataset.Dataset { 461 ds, err := runner.RepoRoot.LoadDataset(ref) 462 if err != nil { 463 t.Fatal(err) 464 } 465 return ds 466 } 467 468 // MustExec runs a command, returning standard output, failing the test if there's an error 469 func (runner *TestRunner) MustExec(t *testing.T, cmdText string) string { 470 if err := runner.ExecCommand(cmdText); err != nil { 471 _, callerFile, callerLine, ok := runtime.Caller(1) 472 if !ok { 473 t.Fatal(err) 474 } else { 475 t.Fatalf("executing command: %q\n%s:%d: %s", cmdText, callerFile, callerLine, err) 476 } 477 } 478 return runner.GetCommandOutput() 479 } 480 481 // MustExec runs a command, returning combined standard output and standard err 482 func (runner *TestRunner) MustExecCombinedOutErr(t *testing.T, cmdText string) string { 483 t.Helper() 484 ctx, cancel := context.WithCancel(runner.Context) 485 var shutdown func() <-chan error 486 runner.CmdR, shutdown = runner.CreateCommandRunnerCombinedOutErr(ctx) 487 err := executeCommand(runner.CmdR, cmdText) 488 if err != nil { 489 cancel() 490 _, callerFile, callerLine, ok := runtime.Caller(1) 491 if !ok { 492 t.Fatal(err) 493 } else { 494 t.Fatalf("%s:%d: %s", callerFile, callerLine, err) 495 } 496 } 497 498 err = timedShutdown("MustExecCombinedOutErr", shutdown) 499 cancel() 500 if err != nil { 501 t.Fatal(err) 502 } 503 return runner.GetCommandOutput() 504 } 505 506 // MustWriteFile writes to a file, failing the test if there's an error 507 func (runner *TestRunner) MustWriteFile(t *testing.T, filename, contents string) { 508 if err := ioutil.WriteFile(filename, []byte(contents), os.FileMode(0644)); err != nil { 509 t.Fatal(err) 510 } 511 } 512 513 // MustReadFile reads a file, failing the test if there's an error 514 func (runner *TestRunner) MustReadFile(t *testing.T, filename string) string { 515 bytes, err := ioutil.ReadFile(filename) 516 if err != nil { 517 t.Fatal(err) 518 } 519 return string(bytes) 520 } 521 522 // Must asserts that the function result passed to it is not an error 523 func (runner *TestRunner) Must(t *testing.T, err error) { 524 if err != nil { 525 _, callerFile, callerLine, ok := runtime.Caller(1) 526 if !ok { 527 t.Fatal(err) 528 } else { 529 t.Fatalf("%s:%d: %s", callerFile, callerLine, err) 530 } 531 } 532 } 533 534 // GetCommandOutput returns the standard output from the previously executed 535 // command 536 func (runner *TestRunner) GetCommandOutput() string { 537 outputText := "" 538 if buffer, ok := runner.Streams.Out.(*bytes.Buffer); ok { 539 outputText = runner.niceifyTempDirs(buffer.String()) 540 } 541 return outputText 542 } 543 544 // GetCommandErrOutput fetches the stderr value from the previously executed 545 // command 546 func (runner *TestRunner) GetCommandErrOutput() string { 547 errOutText := "" 548 if buffer, ok := runner.Streams.ErrOut.(*bytes.Buffer); ok { 549 errOutText = runner.niceifyTempDirs(buffer.String()) 550 } 551 return errOutText 552 } 553 554 func (runner *TestRunner) niceifyTempDirs(text string) string { 555 text = strings.Replace(text, runner.RepoRoot.RootPath, "/root", -1) 556 realRoot, err := filepath.EvalSymlinks(runner.RepoRoot.RootPath) 557 if err == nil { 558 text = strings.Replace(text, realRoot, "/root", -1) 559 } 560 return text 561 } 562 563 func executeCommand(root *cobra.Command, cmd string) error { 564 // fmt.Printf("exec command: %s\n", cmd) 565 cmd = strings.TrimPrefix(cmd, "qri ") 566 args := strings.Split(cmd, " ") 567 return executeCommandC(root, args...) 568 } 569 570 func executeQuotedCommand(root *cobra.Command, cmd string) error { 571 cmd = strings.TrimPrefix(cmd, "qri ") 572 573 var s scanner.Scanner 574 s.Init(strings.NewReader(cmd)) 575 var args []string 576 for tok := s.Scan(); tok != scanner.EOF; tok = s.Scan() { 577 arg := s.TokenText() 578 if unquoted, err := strconv.Unquote(arg); err == nil { 579 arg = unquoted 580 } 581 582 args = append(args, arg) 583 } 584 585 return executeCommandC(root, args...) 586 } 587 588 func executeCommandC(root *cobra.Command, args ...string) (err error) { 589 root.SetArgs(args) 590 _, err = root.ExecuteC() 591 return err 592 } 593 594 // AddDatasetToRefstore adds a dataset to the test runner's refstore. It ignores the upper-levels 595 // of our stack, namely cmd/ and lib/, which means it can be used to add a dataset with a name 596 // that is using upper-case characters. 597 func (runner *TestRunner) AddDatasetToRefstore(t *testing.T, refStr string, ds *dataset.Dataset) { 598 ctx, cancel := context.WithCancel(context.Background()) 599 defer cancel() 600 601 ref, err := dsref.ParseHumanFriendly(refStr) 602 if err != nil && err != dsref.ErrBadCaseName { 603 t.Fatal(err) 604 } 605 606 ds.Peername = ref.Username 607 ds.Name = ref.Name 608 609 r, err := runner.RepoRoot.Repo(ctx) 610 if err != nil { 611 t.Fatal(err) 612 } 613 614 // WARNING: here we're assuming the provided ref matches the owner peername 615 author := r.Logbook().Owner() 616 617 // Reserve the name in the logbook, which provides an initID 618 initID, err := r.Logbook().WriteDatasetInit(ctx, author, ds.Name) 619 if err != nil { 620 t.Fatal(err) 621 } 622 623 // No existing commit 624 emptyHeadRef := "" 625 626 if _, err = base.SaveDataset(ctx, r, r.Filesystem().DefaultWriteFS(), author, initID, emptyHeadRef, ds, nil, base.SaveSwitches{}); err != nil { 627 t.Fatal(err) 628 } 629 630 cancel() 631 <-r.Done() 632 }