diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index 2ed3c6321cb..c28502d10b7 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -29,6 +29,7 @@ type testCfg struct { printEvents bool debug bool debugAddr string + noCache bool } func newTestCmd(io commands.IO) *commands.Command { @@ -167,6 +168,13 @@ func (c *testCfg) RegisterFlags(fs *flag.FlagSet) { "", "enable interactive debugger using tcp address in the form [host]:port", ) + + fs.BoolVar( + &c.noCache, + "no-cache", + false, + "disable test result caching", + ) } func execTest(cfg *testCfg, args []string, io commands.IO) error { @@ -214,6 +222,7 @@ func execTest(cfg *testCfg, args []string, io commands.IO) error { opts.Metrics = cfg.printRuntimeMetrics opts.Events = cfg.printEvents opts.Debug = cfg.debug + opts.NoCache = cfg.noCache opts.FailfastFlag = cfg.failfast buildErrCount := 0 diff --git a/gnovm/cmd/gno/testdata/test/filetest_events.txtar b/gnovm/cmd/gno/testdata/test/filetest_events.txtar index 87f873980d5..61d0b1eef1e 100644 --- a/gnovm/cmd/gno/testdata/test/filetest_events.txtar +++ b/gnovm/cmd/gno/testdata/test/filetest_events.txtar @@ -1,6 +1,6 @@ # Test with a valid _filetest.gno file -gno test -print-events . +gno test -print-events . -no-cache ! stdout .+ stderr 'ok \. \d+\.\d\ds' diff --git a/gnovm/cmd/gno/testdata/test/valid_filetest.txtar b/gnovm/cmd/gno/testdata/test/valid_filetest.txtar index bd73ce3dc99..651e0afdddb 100644 --- a/gnovm/cmd/gno/testdata/test/valid_filetest.txtar +++ b/gnovm/cmd/gno/testdata/test/valid_filetest.txtar @@ -1,6 +1,6 @@ # Test with a valid _filetest.gno file -gno test . +gno test . -no-cache ! stdout .+ stderr 'ok \. \d+\.\d\ds' diff --git a/gnovm/pkg/test/cache.go b/gnovm/pkg/test/cache.go new file mode 100644 index 00000000000..308e08c5451 --- /dev/null +++ b/gnovm/pkg/test/cache.go @@ -0,0 +1,198 @@ +package test + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + gno "github.com/gnolang/gno/gnovm/pkg/gnolang" +) + +const ( + // TestCacheDirName is the name of the directory where test cache is stored + TestCacheDirName = ".gno-test-cache" +) + +type TestCache struct { + Key TestCacheKey + // Test result output + Output string `json:"output"` + // Test execution time + Duration time.Duration `json:"duration"` + // Timestamp when the cache was created + Timestamp time.Time `json:"timestamp"` +} + +// TestCacheKey represents the key information for test cache validation +type TestCacheKey struct { + // Hash of test file content + FileHash string `json:"fileHash"` + // Package dependency information + Dependencies map[string]PackageInfo `json:"dependencies"` +} + +// PackageInfo contains information about a package's state +type PackageInfo struct { + // Hash of all package files combined + ContentHash string `json:"contentHash"` + // Import path of the package + Path string `json:"path"` + // Package files and their hashes + Files map[string]string `json:"files"` +} + +// TestFuncCache represents the cache for a single test function +type TestFuncCache struct { + Key TestCacheKey + // Test function name + TestName string `json:"testName"` + // Test result output + Output string `json:"output"` + // Test execution time + Duration time.Duration `json:"duration"` + // Timestamp when the cache was created + Timestamp time.Time `json:"timestamp"` + // Test result + Result report `json:"result"` +} + +// TestFileCache represents the cache for a test file containing multiple test functions +type TestFileCache struct { + // Map of test function name to its cache + Tests map[string]*TestFuncCache `json:"tests"` +} + +// cacheDir returns the path to the test cache directory +func cacheDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + cacheDir := filepath.Join(homeDir, TestCacheDirName) + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return "", fmt.Errorf("failed to create cache directory: %w", err) + } + return cacheDir, nil +} + +// cacheFilePath returns the path to the cache file for a given test file +func cacheFilePath(testFile string) (string, error) { + cacheDir, err := cacheDir() + if err != nil { + return "", err + } + // Use hash of absolute path to avoid file name collisions + absPath, err := filepath.Abs(testFile) + if err != nil { + return "", fmt.Errorf("failed to get absolute path: %w", err) + } + hash := fmt.Sprintf("%x", sha256.Sum256([]byte(absPath))) + return filepath.Join(cacheDir, hash+".json"), nil +} + +// loadTestCache loads the cached test result for a given test file +func loadTestCache(testFile string, content []byte) (*TestCache, error) { + cacheFile, err := cacheFilePath(testFile) + if err != nil { + return nil, err + } + + data, err := os.ReadFile(cacheFile) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read cache file: %w", err) + } + + var cache TestCache + if err := json.Unmarshal(data, &cache); err != nil { + return nil, fmt.Errorf("failed to unmarshal cache: %w", err) + } + + if !isValidCache(&cache, testFile, content) { + return nil, nil + } + + return &cache, nil +} + +// saveTestCache saves the test result to cache +func saveTestCache(testFile string, content []byte, output string, duration time.Duration) error { + cacheFile, err := cacheFilePath(testFile) + if err != nil { + return err + } + + key, err := computeCacheKey(testFile, content) + if err != nil { + return fmt.Errorf("failed to compute cache key: %w", err) + } + + cache := TestCache{ + Key: key, + Output: output, + Duration: duration, + Timestamp: time.Now(), + } + + data, err := json.Marshal(cache) + if err != nil { + return fmt.Errorf("failed to marshal cache: %w", err) + } + + if err := os.WriteFile(cacheFile, data, 0o644); err != nil { + return fmt.Errorf("failed to write cache file: %w", err) + } + + return nil +} + +// computeCacheKey generates a cache key for the given test file +func computeCacheKey(testFile string, content []byte) (TestCacheKey, error) { + key := TestCacheKey{ + FileHash: fmt.Sprintf("%x", sha256.Sum256(content)), + Dependencies: make(map[string]PackageInfo), + } + + fileNode, err := gno.ParseFile(testFile, string(content)) + if err != nil { + return key, fmt.Errorf("failed to parse file: %w", err) + } + + // process each import declaration + decls := fileNode.Decls + for _, decl := range decls { + if decl == nil { + continue + } + if importDecl, ok := decl.(*gno.ImportDecl); ok { + if importDecl.PkgPath == "" { + continue + } + key.Dependencies[importDecl.PkgPath] = PackageInfo{ + Path: importDecl.PkgPath, + } + } + } + + return key, nil +} + +// isValidCache checks if the cached result is still valid +func isValidCache(cache *TestCache, testFile string, content []byte) bool { + currentKey, err := computeCacheKey(testFile, content) + if err != nil { + return false + } + + // Check file content hash + if cache.Key.FileHash != currentKey.FileHash { + return false + } + + return true +} diff --git a/gnovm/pkg/test/cache_test.go b/gnovm/pkg/test/cache_test.go new file mode 100644 index 00000000000..beba7c7d7e7 --- /dev/null +++ b/gnovm/pkg/test/cache_test.go @@ -0,0 +1,95 @@ +package test + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTestCache(t *testing.T) { + t.Run("getCacheDir", func(t *testing.T) { + dir, err := cacheDir() + require.NoError(t, err) + require.NotEmpty(t, dir) + + info, err := os.Stat(dir) + require.NoError(t, err) + require.True(t, info.IsDir()) + }) + + t.Run("cache operations", func(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.gno") + testContent := []byte("package main\n\nfunc main() {}") + err := os.WriteFile(testFile, testContent, 0o644) + require.NoError(t, err) + + output := "test output" + duration := time.Second + err = saveTestCache(testFile, testContent, output, duration) + require.NoError(t, err) + + cache, err := loadTestCache(testFile, testContent) + require.NoError(t, err) + require.NotNil(t, cache) + + assert.Equal(t, output, cache.Output) + assert.Equal(t, duration, cache.Duration) + assert.NotEmpty(t, cache.Key.FileHash) + assert.NotZero(t, cache.Timestamp) + + modifiedContent := []byte("package main\n\nfunc main() { println() }") + cache, err = loadTestCache(testFile, modifiedContent) + require.NoError(t, err) + assert.Nil(t, cache) + }) + + t.Run("cache file path collision", func(t *testing.T) { + tmpDir := t.TempDir() + + testFile1 := filepath.Join(tmpDir, "dir1", "test.gno") + testFile2 := filepath.Join(tmpDir, "dir2", "test.gno") + + require.NoError(t, os.MkdirAll(filepath.Dir(testFile1), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Dir(testFile2), 0o755)) + + content1 := []byte("package main\n\nfunc test1() {}") + content2 := []byte("package main\n\nfunc test2() {}") + + require.NoError(t, os.WriteFile(testFile1, content1, 0o644)) + require.NoError(t, os.WriteFile(testFile2, content2, 0o644)) + + // make sure the cache file paths for each file are different + path1, err := cacheFilePath(testFile1) + require.NoError(t, err) + + path2, err := cacheFilePath(testFile2) + require.NoError(t, err) + + assert.NotEqual(t, path1, path2) + }) + + t.Run("invalid cache operations", func(t *testing.T) { + cache, err := loadTestCache("non_existent_file.gno", []byte{}) + require.NoError(t, err) + assert.Nil(t, cache) + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "invalid.gno") + err = os.WriteFile(testFile, []byte("invalid json"), 0o644) + require.NoError(t, err) + + cacheFile, err := cacheFilePath(testFile) + require.NoError(t, err) + err = os.WriteFile(cacheFile, []byte("invalid json"), 0o644) + require.NoError(t, err) + + cache, err = loadTestCache(testFile, []byte("test content")) + assert.Error(t, err) + assert.Nil(t, cache) + }) +} diff --git a/gnovm/pkg/test/test.go b/gnovm/pkg/test/test.go index fe87944f913..f070eaf3e9e 100644 --- a/gnovm/pkg/test/test.go +++ b/gnovm/pkg/test/test.go @@ -142,6 +142,8 @@ type TestOptions struct { Metrics bool // Uses Error to print the events emitted. Events bool + // Whether to disable test result caching + NoCache bool filetestBuffer bytes.Buffer outWriter proxyWriter @@ -273,7 +275,34 @@ func Test(memPkg *gnovm.MemPackage, fsDir string, opts *TestOptions) error { fmt.Fprintf(opts.Error, "=== RUN %s\n", testName) } - changed, err := opts.runFiletest(testFileName, []byte(testFile.Body)) + // Try to load from cache first + cache, err := loadTestCache(testFilePath, []byte(testFile.Body)) + if err != nil { + fmt.Fprintf(opts.Error, "Warning: failed to load cache: %v\n", err) + } + + var changed string + if !opts.NoCache && cache != nil && !opts.Sync { + // Use cached result + duration := cache.Duration + if opts.Verbose { + fmt.Fprintf(opts.Error, "=== CACHED %s (%s)\n", testName, fmtDuration(duration)) + fmt.Fprint(opts.Error, cache.Output) + } + continue + } + + // Run the test and cache the result + changed, err = opts.runFiletest(testFileName, []byte(testFile.Body)) + duration := time.Since(startedAt) + + if err == nil && !opts.NoCache && !opts.Sync { + // Cache successful test results + if cacheErr := saveTestCache(testFilePath, []byte(testFile.Body), opts.filetestBuffer.String(), duration); cacheErr != nil { + fmt.Fprintf(opts.Error, "Warning: failed to save cache: %v\n", cacheErr) + } + } + if changed != "" { // Note: changed always == "" if opts.Sync == false. err = os.WriteFile(testFilePath, []byte(changed), 0o644) @@ -282,7 +311,6 @@ func Test(memPkg *gnovm.MemPackage, fsDir string, opts *TestOptions) error { } } - duration := time.Since(startedAt) dstr := fmtDuration(duration) if err != nil { fmt.Fprintf(opts.Error, "--- FAIL: %s (%s)\n", testName, dstr) @@ -291,8 +319,6 @@ func Test(memPkg *gnovm.MemPackage, fsDir string, opts *TestOptions) error { } else if opts.Verbose { fmt.Fprintf(opts.Error, "--- PASS: %s (%s)\n", testName, dstr) } - - // XXX: add per-test metrics } } diff --git a/gnovm/pkg/test/test_test.go b/gnovm/pkg/test/test_test.go new file mode 100644 index 00000000000..b9d4dc4d011 --- /dev/null +++ b/gnovm/pkg/test/test_test.go @@ -0,0 +1,240 @@ +package test + +import ( + "testing" + + "github.com/gnolang/gno/gnovm" + gno "github.com/gnolang/gno/gnovm/pkg/gnolang" + "github.com/stretchr/testify/assert" +) + +func TestLoadTestFuncs(t *testing.T) { + tests := []struct { + name string + pkgName string + fileBody string + want []testFunc + }{ + { + name: "empty file set", + pkgName: "test", + fileBody: ` + package test + `, + want: nil, + }, + { + name: "single test function", + pkgName: "test", + fileBody: ` + package test + func TestSomething(t *testing.T) {} + `, + want: []testFunc{ + {Package: "test", Name: "TestSomething"}, + }, + }, + { + name: "multiple test functions", + pkgName: "test", + fileBody: ` + package test + func TestOne(t *testing.T) {} + func TestTwo(t *testing.T) {} + func helper() {} + func TestThree(t *testing.T) {} + `, + want: []testFunc{ + {Package: "test", Name: "TestOne"}, + {Package: "test", Name: "TestTwo"}, + {Package: "test", Name: "TestThree"}, + }, + }, + { + name: "non-test functions", + pkgName: "test", + fileBody: ` + package test + func helper() {} + func regular() {} + func test() {} + `, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Parse the test file + file, err := gno.ParseFile("test.gno", tt.fileBody) + if err != nil { + t.Fatalf("failed to parse test file: %v", err) + } + + fileSet := &gno.FileSet{} + fileSet.AddFiles(file) + + // Run the function + got := loadTestFuncs(tt.pkgName, fileSet) + + // Assert the results + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseMemPackageTests(t *testing.T) { + tests := []struct { + name string + memPkg *gnovm.MemPackage + wantTSet bool // Whether there are regular test files + wantITSet bool // Whether there are integration test files + wantITFiles int // Number of integration test files + wantFTFiles int // Number of file test files + }{ + { + name: "empty package", + memPkg: &gnovm.MemPackage{ + Name: "test", + Path: "test", + Files: nil, + }, + wantTSet: false, + wantITSet: false, + wantITFiles: 0, + wantFTFiles: 0, + }, + { + name: "case with only regular test files", + memPkg: &gnovm.MemPackage{ + Name: "test", + Path: "test", + Files: []*gnovm.MemFile{ + { + Name: "something_test.gno", + Body: `package test + func TestSomething(t *testing.T) {}`, + }, + }, + }, + wantTSet: true, + wantITSet: false, + wantITFiles: 0, + wantFTFiles: 0, + }, + { + name: "case with only integration test files", + memPkg: &gnovm.MemPackage{ + Name: "test", + Path: "test", + Files: []*gnovm.MemFile{ + { + Name: "integration_test.gno", + Body: `package test_test + func TestIntegration(t *testing.T) {}`, + }, + }, + }, + wantTSet: false, + wantITSet: true, + wantITFiles: 1, + wantFTFiles: 0, + }, + { + name: "case with only file tests", + memPkg: &gnovm.MemPackage{ + Name: "test", + Path: "test", + Files: []*gnovm.MemFile{ + { + Name: "something_filetest.gno", + Body: `package test + // File test content`, + }, + }, + }, + wantTSet: false, + wantITSet: false, + wantITFiles: 0, + wantFTFiles: 1, + }, + { + name: "case with all types of test files", + memPkg: &gnovm.MemPackage{ + Name: "test", + Path: "test", + Files: []*gnovm.MemFile{ + { + Name: "normal_test.gno", + Body: `package test + func TestNormal(t *testing.T) {}`, + }, + { + Name: "integration_test.gno", + Body: `package test_test + func TestIntegration(t *testing.T) {}`, + }, + { + Name: "file_filetest.gno", + Body: `package test + // File test content`, + }, + { + Name: "regular.gno", + Body: `package test + func Regular() {}`, + }, + }, + }, + wantTSet: true, + wantITSet: true, + wantITFiles: 1, + wantFTFiles: 1, + }, + { + name: "ignore files with incorrect extensions", + memPkg: &gnovm.MemPackage{ + Name: "test", + Path: "test", + Files: []*gnovm.MemFile{ + { + Name: "test.txt", + Body: "Any content", + }, + }, + }, + wantTSet: false, + wantITSet: false, + wantITFiles: 0, + wantFTFiles: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tset, itset, itfiles, ftfiles := parseMemPackageTests(tt.memPkg) + + // Check regular test files + if tt.wantTSet { + assert.NotNil(t, tset) + assert.Greater(t, len(tset.Files), 0) + } else { + assert.Equal(t, 0, len(tset.Files)) + } + + // Check integration test files + if tt.wantITSet { + assert.NotNil(t, itset) + assert.Greater(t, len(itset.Files), 0) + } else { + assert.Equal(t, 0, len(itset.Files)) + } + + // Check number of integration test files + assert.Equal(t, tt.wantITFiles, len(itfiles)) + + // Check number of file test files + assert.Equal(t, tt.wantFTFiles, len(ftfiles)) + }) + } +}