github.com/gnolang/gno@v0.0.0-20240520182011-228e9d0192ce/gnovm/cmd/gno/test.go (about) 1 package main 2 3 import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "flag" 8 "fmt" 9 "log" 10 "os" 11 "path/filepath" 12 "runtime/debug" 13 "sort" 14 "strings" 15 "text/template" 16 "time" 17 18 "go.uber.org/multierr" 19 20 "github.com/gnolang/gno/gnovm/pkg/gnoenv" 21 gno "github.com/gnolang/gno/gnovm/pkg/gnolang" 22 "github.com/gnolang/gno/gnovm/pkg/gnomod" 23 "github.com/gnolang/gno/gnovm/pkg/transpiler" 24 "github.com/gnolang/gno/gnovm/tests" 25 "github.com/gnolang/gno/tm2/pkg/commands" 26 "github.com/gnolang/gno/tm2/pkg/errors" 27 "github.com/gnolang/gno/tm2/pkg/random" 28 "github.com/gnolang/gno/tm2/pkg/std" 29 "github.com/gnolang/gno/tm2/pkg/testutils" 30 ) 31 32 type testCfg struct { 33 verbose bool 34 rootDir string 35 run string 36 timeout time.Duration 37 transpile bool // TODO: transpile should be the default, but it needs to automatically transpile dependencies in memory. 38 updateGoldenTests bool 39 printRuntimeMetrics bool 40 withNativeFallback bool 41 } 42 43 func newTestCmd(io commands.IO) *commands.Command { 44 cfg := &testCfg{} 45 46 return commands.NewCommand( 47 commands.Metadata{ 48 Name: "test", 49 ShortUsage: "test [flags] <package> [<package>...]", 50 ShortHelp: "runs the tests for the specified packages", 51 LongHelp: `Runs the tests for the specified packages. 52 53 'gno test' recompiles each package along with any files with names matching the 54 file pattern "*_test.gno" or "*_filetest.gno". 55 56 The <package> can be directory or file path (relative or absolute). 57 58 - "*_test.gno" files work like "*_test.go" files, but they contain only test 59 functions. Benchmark and fuzz functions aren't supported yet. Similarly, only 60 tests that belong to the same package are supported for now (no "xxx_test"). 61 62 The package path used to execute the "*_test.gno" file is fetched from the 63 module name found in 'gno.mod', or else it is randomly generated like 64 "gno.land/r/XXXXXXXX". 65 66 - "*_filetest.gno" files on the other hand are kind of unique. They exist to 67 provide a way to interact and assert a gno contract, thanks to a set of 68 specific instructions that can be added using code comments. 69 70 "*_filetest.gno" must be declared in the 'main' package and so must have a 71 'main' function, that will be executed to test the target contract. 72 73 List of available instructions that can be used in "*_filetest.gno" files: 74 - "PKGPATH:" is a single line instruction that can be used to define the 75 package used to interact with the tested package. If not specified, "main" is 76 used. 77 - "MAXALLOC:" is a signle line instruction that can be used to define a limit 78 to the VM allocator. If this limit is exceeded, the VM will panic. Default to 79 0, no limit. 80 - "SEND:" is a single line instruction that can be used to send an amount of 81 token along with the transaction. The format is for example "1000000ugnot". 82 Default is empty. 83 - "Output:\n" (*) is a multiple lines instruction that can be used to assert 84 the output of the "*_filetest.gno" file. Any prints executed inside the 85 'main' function must match the lines that follows the "Output:\n" 86 instruction, or else the test fails. 87 - "Error:\n" works similarly to "Output:\n", except that it asserts the 88 stderr of the program, which in that case, comes from the VM because of a 89 panic, rather than the 'main' function. 90 - "Realm:\n" (*) is a multiple lines instruction that can be used to assert 91 what has been recorded in the store following the execution of the 'main' 92 function. 93 94 (*) The 'update-golden-tests' flag can be set to fill out the content of the 95 instruction with the actual content of the test instead of failing. 96 `, 97 }, 98 cfg, 99 func(_ context.Context, args []string) error { 100 return execTest(cfg, args, io) 101 }, 102 ) 103 } 104 105 func (c *testCfg) RegisterFlags(fs *flag.FlagSet) { 106 fs.BoolVar( 107 &c.verbose, 108 "v", 109 false, 110 "verbose output when running", 111 ) 112 113 fs.BoolVar( 114 &c.transpile, 115 "transpile", 116 false, 117 "transpile gno to go before testing", 118 ) 119 120 fs.BoolVar( 121 &c.updateGoldenTests, 122 "update-golden-tests", 123 false, 124 `writes actual as wanted for "Output:" and "Realm:" instructions`, 125 ) 126 127 fs.StringVar( 128 &c.rootDir, 129 "root-dir", 130 "", 131 "clone location of github.com/gnolang/gno (gno tries to guess it)", 132 ) 133 134 fs.StringVar( 135 &c.run, 136 "run", 137 "", 138 "test name filtering pattern", 139 ) 140 141 fs.DurationVar( 142 &c.timeout, 143 "timeout", 144 0, 145 "max execution time", 146 ) 147 148 fs.BoolVar( 149 &c.withNativeFallback, 150 "with-native-fallback", 151 false, 152 "use stdlibs/* if present, otherwise use supported native Go packages", 153 ) 154 155 fs.BoolVar( 156 &c.printRuntimeMetrics, 157 "print-runtime-metrics", 158 false, 159 "print runtime metrics (gas, memory, cpu cycles)", 160 ) 161 } 162 163 func execTest(cfg *testCfg, args []string, io commands.IO) error { 164 if len(args) < 1 { 165 return flag.ErrHelp 166 } 167 168 verbose := cfg.verbose 169 170 tempdirRoot, err := os.MkdirTemp("", "gno-transpile") 171 if err != nil { 172 log.Fatal(err) 173 } 174 defer os.RemoveAll(tempdirRoot) 175 176 // go.mod 177 modPath := filepath.Join(tempdirRoot, "go.mod") 178 err = makeTestGoMod(modPath, transpiler.ImportPrefix, "1.21") 179 if err != nil { 180 return fmt.Errorf("write .mod file: %w", err) 181 } 182 183 // guess opts.RootDir 184 if cfg.rootDir == "" { 185 cfg.rootDir = gnoenv.RootDir() 186 } 187 188 paths, err := targetsFromPatterns(args) 189 if err != nil { 190 return fmt.Errorf("list targets from patterns: %w", err) 191 } 192 if len(paths) == 0 { 193 io.ErrPrintln("no packages to test") 194 return nil 195 } 196 197 if cfg.timeout > 0 { 198 go func() { 199 time.Sleep(cfg.timeout) 200 panic("test timed out after " + cfg.timeout.String()) 201 }() 202 } 203 204 subPkgs, err := gnomod.SubPkgsFromPaths(paths) 205 if err != nil { 206 return fmt.Errorf("list sub packages: %w", err) 207 } 208 209 buildErrCount := 0 210 testErrCount := 0 211 for _, pkg := range subPkgs { 212 if cfg.transpile { 213 if verbose { 214 io.ErrPrintfln("=== PREC %s", pkg.Dir) 215 } 216 transpileOpts := newTranspileOptions(&transpileCfg{ 217 output: tempdirRoot, 218 }) 219 err := transpilePkg(importPath(pkg.Dir), transpileOpts) 220 if err != nil { 221 io.ErrPrintln(err) 222 io.ErrPrintln("FAIL") 223 io.ErrPrintfln("FAIL %s", pkg.Dir) 224 io.ErrPrintln("FAIL") 225 226 buildErrCount++ 227 continue 228 } 229 230 if verbose { 231 io.ErrPrintfln("=== BUILD %s", pkg.Dir) 232 } 233 tempDir, err := ResolvePath(tempdirRoot, importPath(pkg.Dir)) 234 if err != nil { 235 return errors.New("cannot resolve build dir") 236 } 237 err = goBuildFileOrPkg(tempDir, defaultTranspileCfg) 238 if err != nil { 239 io.ErrPrintln(err) 240 io.ErrPrintln("FAIL") 241 io.ErrPrintfln("FAIL %s", pkg.Dir) 242 io.ErrPrintln("FAIL") 243 244 buildErrCount++ 245 continue 246 } 247 } 248 249 if len(pkg.TestGnoFiles) == 0 && len(pkg.FiletestGnoFiles) == 0 { 250 io.ErrPrintfln("? %s \t[no test files]", pkg.Dir) 251 continue 252 } 253 254 sort.Strings(pkg.TestGnoFiles) 255 sort.Strings(pkg.FiletestGnoFiles) 256 257 startedAt := time.Now() 258 err = gnoTestPkg(pkg.Dir, pkg.TestGnoFiles, pkg.FiletestGnoFiles, cfg, io) 259 duration := time.Since(startedAt) 260 dstr := fmtDuration(duration) 261 262 if err != nil { 263 io.ErrPrintfln("%s: test pkg: %v", pkg.Dir, err) 264 io.ErrPrintfln("FAIL") 265 io.ErrPrintfln("FAIL %s \t%s", pkg.Dir, dstr) 266 io.ErrPrintfln("FAIL") 267 testErrCount++ 268 } else { 269 io.ErrPrintfln("ok %s \t%s", pkg.Dir, dstr) 270 } 271 } 272 if testErrCount > 0 || buildErrCount > 0 { 273 io.ErrPrintfln("FAIL") 274 return fmt.Errorf("FAIL: %d build errors, %d test errors", buildErrCount, testErrCount) 275 } 276 277 return nil 278 } 279 280 func gnoTestPkg( 281 pkgPath string, 282 unittestFiles, 283 filetestFiles []string, 284 cfg *testCfg, 285 io commands.IO, 286 ) error { 287 var ( 288 verbose = cfg.verbose 289 rootDir = cfg.rootDir 290 runFlag = cfg.run 291 printRuntimeMetrics = cfg.printRuntimeMetrics 292 293 stdin = io.In() 294 stdout = io.Out() 295 stderr = io.Err() 296 errs error 297 ) 298 299 mode := tests.ImportModeStdlibsOnly 300 if cfg.withNativeFallback { 301 // XXX: display a warn? 302 mode = tests.ImportModeStdlibsPreferred 303 } 304 if !verbose { 305 // TODO: speedup by ignoring if filter is file/*? 306 mockOut := bytes.NewBufferString("") 307 stdout = commands.WriteNopCloser(mockOut) 308 } 309 310 // testing with *_test.gno 311 if len(unittestFiles) > 0 { 312 // Determine gnoPkgPath by reading gno.mod 313 var gnoPkgPath string 314 modfile, err := gnomod.ParseAt(pkgPath) 315 if err == nil { 316 gnoPkgPath = modfile.Module.Mod.Path 317 } else { 318 gnoPkgPath = pkgPathFromRootDir(pkgPath, rootDir) 319 if gnoPkgPath == "" { 320 // unable to read pkgPath from gno.mod, generate a random realm path 321 io.ErrPrintfln("--- WARNING: unable to read package path from gno.mod or gno root directory; try creating a gno.mod file") 322 gnoPkgPath = transpiler.GnoRealmPkgsPrefixBefore + random.RandStr(8) 323 } 324 } 325 memPkg := gno.ReadMemPackage(pkgPath, gnoPkgPath) 326 327 // tfiles, ifiles := gno.ParseMemPackageTests(memPkg) 328 tfiles, ifiles := parseMemPackageTests(memPkg) 329 testPkgName := getPkgNameFromFileset(ifiles) 330 331 // run test files in pkg 332 if len(tfiles.Files) > 0 { 333 testStore := tests.TestStore( 334 rootDir, "", 335 stdin, stdout, stderr, 336 mode, 337 ) 338 if verbose { 339 testStore.SetLogStoreOps(true) 340 } 341 342 m := tests.TestMachine(testStore, stdout, gnoPkgPath) 343 if printRuntimeMetrics { 344 // from tm2/pkg/sdk/vm/keeper.go 345 // XXX: make maxAllocTx configurable. 346 maxAllocTx := int64(500 * 1000 * 1000) 347 348 m.Alloc = gno.NewAllocator(maxAllocTx) 349 } 350 m.RunMemPackage(memPkg, true) 351 err := runTestFiles(m, tfiles, memPkg.Name, verbose, printRuntimeMetrics, runFlag, io) 352 if err != nil { 353 errs = multierr.Append(errs, err) 354 } 355 } 356 357 // test xxx_test pkg 358 if len(ifiles.Files) > 0 { 359 testStore := tests.TestStore( 360 rootDir, "", 361 stdin, stdout, stderr, 362 mode, 363 ) 364 if verbose { 365 testStore.SetLogStoreOps(true) 366 } 367 368 m := tests.TestMachine(testStore, stdout, testPkgName) 369 370 memFiles := make([]*std.MemFile, 0, len(ifiles.FileNames())+1) 371 for _, f := range memPkg.Files { 372 for _, ifileName := range ifiles.FileNames() { 373 if f.Name == "gno.mod" || f.Name == ifileName { 374 memFiles = append(memFiles, f) 375 break 376 } 377 } 378 } 379 380 memPkg.Files = memFiles 381 memPkg.Name = testPkgName 382 memPkg.Path = memPkg.Path + "_test" 383 m.RunMemPackage(memPkg, true) 384 385 err := runTestFiles(m, ifiles, testPkgName, verbose, printRuntimeMetrics, runFlag, io) 386 if err != nil { 387 errs = multierr.Append(errs, err) 388 } 389 } 390 } 391 392 // testing with *_filetest.gno 393 { 394 filter := splitRegexp(runFlag) 395 for _, testFile := range filetestFiles { 396 testFileName := filepath.Base(testFile) 397 testName := "file/" + testFileName 398 if !shouldRun(filter, testName) { 399 continue 400 } 401 402 startedAt := time.Now() 403 if verbose { 404 io.ErrPrintfln("=== RUN %s", testName) 405 } 406 407 var closer func() (string, error) 408 if !verbose { 409 closer = testutils.CaptureStdoutAndStderr() 410 } 411 412 testFilePath := filepath.Join(pkgPath, testFileName) 413 err := tests.RunFileTest(rootDir, testFilePath, tests.WithSyncWanted(cfg.updateGoldenTests)) 414 duration := time.Since(startedAt) 415 dstr := fmtDuration(duration) 416 417 if err != nil { 418 errs = multierr.Append(errs, err) 419 io.ErrPrintfln("--- FAIL: %s (%s)", testName, dstr) 420 if verbose { 421 stdouterr, err := closer() 422 if err != nil { 423 panic(err) 424 } 425 fmt.Fprintln(os.Stderr, stdouterr) 426 } 427 continue 428 } 429 430 if verbose { 431 io.ErrPrintfln("--- PASS: %s (%s)", testName, dstr) 432 } 433 // XXX: add per-test metrics 434 } 435 } 436 437 return errs 438 } 439 440 // attempts to determine the full gno pkg path by analyzing the directory. 441 func pkgPathFromRootDir(pkgPath, rootDir string) string { 442 abPkgPath, err := filepath.Abs(pkgPath) 443 if err != nil { 444 log.Printf("could not determine abs path: %v", err) 445 return "" 446 } 447 abRootDir, err := filepath.Abs(rootDir) 448 if err != nil { 449 log.Printf("could not determine abs path: %v", err) 450 return "" 451 } 452 abRootDir += string(filepath.Separator) 453 if !strings.HasPrefix(abPkgPath, abRootDir) { 454 return "" 455 } 456 impPath := strings.ReplaceAll(abPkgPath[len(abRootDir):], string(filepath.Separator), "/") 457 for _, prefix := range [...]string{ 458 "examples/", 459 "gnovm/stdlibs/", 460 "gnovm/tests/stdlibs/", 461 } { 462 if strings.HasPrefix(impPath, prefix) { 463 return impPath[len(prefix):] 464 } 465 } 466 return "" 467 } 468 469 func runTestFiles( 470 m *gno.Machine, 471 files *gno.FileSet, 472 pkgName string, 473 verbose bool, 474 printRuntimeMetrics bool, 475 runFlag string, 476 io commands.IO, 477 ) (errs error) { 478 defer func() { 479 if r := recover(); r != nil { 480 errs = multierr.Append(fmt.Errorf("panic: %v\nstack:\n%v\ngno machine: %v", r, string(debug.Stack()), m.String()), errs) 481 } 482 }() 483 484 testFuncs := &testFuncs{ 485 PackageName: pkgName, 486 Verbose: verbose, 487 RunFlag: runFlag, 488 } 489 loadTestFuncs(pkgName, testFuncs, files) 490 491 // before/after statistics 492 numPackagesBefore := m.Store.NumMemPackages() 493 494 testmain, err := formatTestmain(testFuncs) 495 if err != nil { 496 log.Fatal(err) 497 } 498 499 m.RunFiles(files.Files...) 500 n := gno.MustParseFile("main_test.gno", testmain) 501 m.RunFiles(n) 502 503 for _, test := range testFuncs.Tests { 504 testFuncStr := fmt.Sprintf("%q", test.Name) 505 506 eval := m.Eval(gno.Call("runtest", testFuncStr)) 507 508 ret := eval[0].GetString() 509 if ret == "" { 510 err := errors.New("failed to execute unit test: %q", test.Name) 511 errs = multierr.Append(errs, err) 512 io.ErrPrintfln("--- FAIL: %s [internal gno testing error]", test.Name) 513 continue 514 } 515 516 // TODO: replace with amino or send native type? 517 var rep report 518 err = json.Unmarshal([]byte(ret), &rep) 519 if err != nil { 520 errs = multierr.Append(errs, err) 521 io.ErrPrintfln("--- FAIL: %s [internal gno testing error]", test.Name) 522 continue 523 } 524 525 if rep.Failed { 526 err := errors.New("failed: %q", test.Name) 527 errs = multierr.Append(errs, err) 528 } 529 530 if printRuntimeMetrics { 531 imports := m.Store.NumMemPackages() - numPackagesBefore - 1 532 // XXX: store changes 533 // XXX: max mem consumption 534 allocsVal := "n/a" 535 if m.Alloc != nil { 536 maxAllocs, allocs := m.Alloc.Status() 537 allocsVal = fmt.Sprintf("%s(%.2f%%)", 538 prettySize(allocs), 539 float64(allocs)/float64(maxAllocs)*100, 540 ) 541 } 542 io.ErrPrintfln("--- runtime: cycle=%s imports=%d allocs=%s", 543 prettySize(m.Cycles), 544 imports, 545 allocsVal, 546 ) 547 } 548 } 549 550 return errs 551 } 552 553 // mirror of stdlibs/testing.Report 554 type report struct { 555 Failed bool 556 Skipped bool 557 } 558 559 var testmainTmpl = template.Must(template.New("testmain").Parse(` 560 package {{ .PackageName }} 561 562 import ( 563 "testing" 564 ) 565 566 var tests = []testing.InternalTest{ 567 {{range .Tests}} 568 {"{{.Name}}", {{.Name}}}, 569 {{end}} 570 } 571 572 func runtest(name string) (report string) { 573 for _, test := range tests { 574 if test.Name == name { 575 return testing.RunTest({{printf "%q" .RunFlag}}, {{.Verbose}}, test) 576 } 577 } 578 panic("no such test: " + name) 579 return "" 580 } 581 `)) 582 583 type testFuncs struct { 584 Tests []testFunc 585 PackageName string 586 Verbose bool 587 RunFlag string 588 } 589 590 type testFunc struct { 591 Package string 592 Name string 593 } 594 595 func getPkgNameFromFileset(files *gno.FileSet) string { 596 if len(files.Files) <= 0 { 597 return "" 598 } 599 return string(files.Files[0].PkgName) 600 } 601 602 func formatTestmain(t *testFuncs) (string, error) { 603 var buf bytes.Buffer 604 if err := testmainTmpl.Execute(&buf, t); err != nil { 605 return "", err 606 } 607 return buf.String(), nil 608 } 609 610 func loadTestFuncs(pkgName string, t *testFuncs, tfiles *gno.FileSet) *testFuncs { 611 for _, tf := range tfiles.Files { 612 for _, d := range tf.Decls { 613 if fd, ok := d.(*gno.FuncDecl); ok { 614 fname := string(fd.Name) 615 if strings.HasPrefix(fname, "Test") { 616 tf := testFunc{ 617 Package: pkgName, 618 Name: fname, 619 } 620 t.Tests = append(t.Tests, tf) 621 } 622 } 623 } 624 } 625 return t 626 } 627 628 // parseMemPackageTests is copied from gno.ParseMemPackageTests 629 // for except to _filetest.gno 630 func parseMemPackageTests(memPkg *std.MemPackage) (tset, itset *gno.FileSet) { 631 tset = &gno.FileSet{} 632 itset = &gno.FileSet{} 633 for _, mfile := range memPkg.Files { 634 if !strings.HasSuffix(mfile.Name, ".gno") { 635 continue // skip this file. 636 } 637 if strings.HasSuffix(mfile.Name, "_filetest.gno") { 638 continue 639 } 640 n, err := gno.ParseFile(mfile.Name, mfile.Body) 641 if err != nil { 642 panic(errors.Wrap(err, "parsing file "+mfile.Name)) 643 } 644 if n == nil { 645 panic("should not happen") 646 } 647 if strings.HasSuffix(mfile.Name, "_test.gno") { 648 // add test file. 649 if memPkg.Name+"_test" == string(n.PkgName) { 650 itset.AddFiles(n) 651 } else { 652 tset.AddFiles(n) 653 } 654 } else if memPkg.Name == string(n.PkgName) { 655 // skip package file. 656 } else { 657 panic(fmt.Sprintf( 658 "expected package name [%s] or [%s_test] but got [%s] file [%s]", 659 memPkg.Name, memPkg.Name, n.PkgName, mfile)) 660 } 661 } 662 return tset, itset 663 } 664 665 func shouldRun(filter filterMatch, path string) bool { 666 if filter == nil { 667 return true 668 } 669 elem := strings.Split(path, "/") 670 ok, _ := filter.matches(elem, matchString) 671 return ok 672 }