feat: implement mindev test cli command and rule loading by name#6551
Conversation
cd66b53 to
cde4c28
Compare
011fa89 to
cde4c28
Compare
evankanderson
left a comment
There was a problem hiding this comment.
You've added a bunch of code in pkg/ruletest -- can we restructure the existing rule execution tests to cover that code as well?
| runner := ruletest.NewRunner() | ||
| hasFailures := false | ||
|
|
||
| for _, dir := range args { |
There was a problem hiding this comment.
Can we accept files or directories?
Also, this feels like it might be a reasonable function in runner.go (where you could test it more easily).
| hasFailures := false | ||
|
|
||
| for _, dir := range args { | ||
| fmt.Printf("Running tests in %s...\n", dir) |
There was a problem hiding this comment.
use cmd.Printf() so that we can override the cmd.Output and get this output from the command.
| if err != nil { | ||
| return fmt.Errorf("error running tests in %s: %w", dir, err) | ||
| } |
There was a problem hiding this comment.
Not sure -- do we want to exit-early here, or collect errors from remaining directories?
| func (r *Runner) RunFile(filename string, src any) ([]TestResult, error) { | ||
| return r.runFileWithRules(filename, src, nil) | ||
| } |
There was a problem hiding this comment.
Why not just add the ruleTypes argument to RunFile?
| return nil, fmt.Errorf("error opening file: %s", yf) | ||
| } | ||
| rt, err := fileconvert.ReadResourceTyped[*minderv1.RuleType](decoder) | ||
| closer.Close() |
There was a problem hiding this comment.
I think it's okay to not handle errors from Close in this case, but the linter does not, so you'll need to do something to make it okay with that.
There was a problem hiding this comment.
Also, I'd prefer the defer Close() pattern, just in case extra logic or returns get added in later.
There was a problem hiding this comment.
(Yes, we could be deferring a bunch of filehandle closes, but those should be fairly cheap)
|
|
||
| func (r *Runner) runOneTest(name string, fn *starlark.Function, fileSystem fs.FS) TestResult { | ||
| tr := r.newTestCaseRunner(name, fileSystem) | ||
| func (r *Runner) runOneTest(name string, fn *starlark.Function, fileSystem fs.FS, ruleTypes map[string]*minderv1.RuleType) TestResult { |
There was a problem hiding this comment.
The linter wants you to fold the line at 130 characters.
|
|
||
| var allResults []TestResult | ||
| for _, file := range files { | ||
| results, err := r.runFileWithRules(file, nil, ruleTypes) |
There was a problem hiding this comment.
Rather than carrying around ruleTypes, I'd look to see if there's an object it might naturally live with that could encompass "test files in a directory".
| ruleTypes, err := loadRulesFromDir(dir) | ||
| if err != nil { | ||
| t.Fatalf("loading rules: %v", err) | ||
| } | ||
|
|
||
| files, err := DiscoverFiles(dir) |
There was a problem hiding this comment.
This looks like RunDir. Why different?
There was a problem hiding this comment.
Actually, why not simply call RunPaths, and then assert on the errors and results?
cde4c28 to
8f49124
Compare
|
Key Updates:
(Note: The filesystem mocking features are staged locally and will be pushed as a separate commit once this foundational work is settled.) |
evankanderson
left a comment
There was a problem hiding this comment.
A few comments -- I feel like your structure is close to optimal, but you can probably remove some near-duplicate code and get better test coverage with a few more tweaks.
| minderv1 "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1" | ||
| "github.com/mindersec/minder/pkg/fileconvert" | ||
| "go.starlark.net/starlark" | ||
| "go.starlark.net/starlarktest" | ||
| "go.starlark.net/syntax" |
There was a problem hiding this comment.
GCI wants the minder imports after the other third-party imports, in a separate section.
| minderv1 "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1" | |
| "github.com/mindersec/minder/pkg/fileconvert" | |
| "go.starlark.net/starlark" | |
| "go.starlark.net/starlarktest" | |
| "go.starlark.net/syntax" | |
| "go.starlark.net/starlark" | |
| "go.starlark.net/starlarktest" | |
| "go.starlark.net/syntax" | |
| minderv1 "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1" | |
| "github.com/mindersec/minder/pkg/fileconvert" | |
| globals, err := tr.runFile(filename, src) | ||
| if err != nil { | ||
| if evalErr, ok := errors.AsType[*starlark.EvalError](err); ok { | ||
| if evalErr, ok := err.(*starlark.EvalError); ok { |
There was a problem hiding this comment.
Why the change from errors.AsType?
| // directory, reporting results through t. | ||
| func (r *Runner) RunDir(t *testing.T, dir string) { | ||
| // directory. It also discovers and loads any *.yaml rule files in the directory. | ||
| func (r *Runner) RunDir(dir string) ([]TestResult, error) { |
There was a problem hiding this comment.
Not sure if you want to make this function private or roll it into RunPaths
| ruleTypes, err := loadRulesFromDir(dir) | ||
| if err != nil { | ||
| t.Fatalf("loading rules: %v", err) | ||
| } | ||
|
|
||
| files, err := DiscoverFiles(dir) |
There was a problem hiding this comment.
Actually, why not simply call RunPaths, and then assert on the errors and results?
| func TestTestDir(t *testing.T) { | ||
| t.Parallel() | ||
| r := NewRunner() | ||
| dir := t.TempDir() | ||
| content := []byte(` | ||
| def test_pass(): | ||
| assert.eq(1, 1) | ||
| `) | ||
| if err := os.WriteFile(filepath.Join(dir, "pass.star"), content, 0644); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| r.TestDir(t, dir) | ||
| } |
There was a problem hiding this comment.
What would it take to change all the above tests that use RunFile to have a single invocation of TestDir (or RunPaths) and then have specific tests which check for specific results?
I suspect that results might want to become something more sophisticated than a string (e.g. a file name + test name + output), but I think you might find that gives you benefits when it comes to creating e.g. XML output.
There was a problem hiding this comment.
Still hoping that this becomes:
r := NewRunner()
r.TestDir(t, "testdata")And that we can get good coverage from just that and the file contents in testdata.
There was a problem hiding this comment.
(We might even be able to delete some of the other tests!)
8242a0e to
eefe8c0
Compare
- Fix GCI import ordering in runner.go (minder imports in separate section) - Restore errors.AsType in RunFile for proper error-wrapping support - Make RunDir private; TestDir now delegates to RunPaths to remove duplication - Rename ruleNameOrPath to ruleName in builtinEval for clarity - Update mindev test Use field to accept files or directories
eefe8c0 to
0b195db
Compare
evankanderson
left a comment
There was a problem hiding this comment.
Sorry for the delay; I got sniped by #6558 and several life things (like getting a bike repaired).
This is looking better, but I'm going to push for even more integration testing in the pkg/ruletest to make sure we don't break the CLI by accident.
| } | ||
|
|
||
| rulePath := ruleName | ||
| if !filepath.IsAbs(rulePath) { |
There was a problem hiding this comment.
Why this check? What would happen if the rulename started with / on Unix?
| rulePath := ruleName | ||
| if !filepath.IsAbs(rulePath) { | ||
| callerFrame := thread.CallFrame(1) | ||
| if callerFile := callerFrame.Pos.Filename(); callerFile != "" { | ||
| rulePath = filepath.Join(filepath.Dir(callerFile), rulePath) | ||
| } | ||
| } | ||
|
|
||
| decoder, closer := fileconvert.DecoderForFile(rulePath) | ||
| if decoder == nil { | ||
| return nil, fmt.Errorf("rule %q not found in loaded rule types and no file found at path %s", ruleName, rulePath) | ||
| } | ||
| defer closer.Close() | ||
|
|
||
| rt, err := fileconvert.ReadResourceTyped[*minderv1.RuleType](decoder) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to parse rule type: %w", err) | ||
| } | ||
|
|
||
| return rt, nil |
There was a problem hiding this comment.
I'm worried about this code being hit unexpectedly, and things "working" when the primary path is broken.
Alternatively, I'm worried that we have no coverage for this path, and that it's code that seems like it does something but doesn't.
Is there a good reason to keep this code vs removing it and updating the test cases?
| // RunFile executes a single Starlark test file. If src is non-nil, it is | ||
| // used as the file contents. |
There was a problem hiding this comment.
The old comment was better. (It explained the testing action more clearly, and better explained the values that src can take.)
| // into a map of RuleTypes keyed by rule name. | ||
| func loadRulesFromDir(dir string) (map[string]*minderv1.RuleType, error) { | ||
| ruleTypes := make(map[string]*minderv1.RuleType) | ||
| yamlFiles, err := filepath.Glob(filepath.Join(dir, "*.yaml")) |
There was a problem hiding this comment.
#6558 will mean you'll need to expand this a little bit. Feel free to adjust e.g. ResourcesFromPaths to have a non-recursive version, or a recursion boolean (actually, it looks like it is ExpandFileArgs that would need adjustment).
| func TestTestDir(t *testing.T) { | ||
| t.Parallel() | ||
| r := NewRunner() | ||
| dir := t.TempDir() | ||
| content := []byte(` | ||
| def test_pass(): | ||
| assert.eq(1, 1) | ||
| `) | ||
| if err := os.WriteFile(filepath.Join(dir, "pass.star"), content, 0644); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| r.TestDir(t, dir) | ||
| } |
There was a problem hiding this comment.
(We might even be able to delete some of the other tests!)
| return func(tk *TestKit) { | ||
| tk.mockFS = fs | ||
| } | ||
| } |
There was a problem hiding this comment.
Can you adjust testkit as follows (rather than having separate paths for the WithGitDir and this? Also, I think "WithGitFiles" might be a better name here, to parallel the "Dir" option -- I don't feel strongly, but both are providing a mock FS?
The way I'd do this would be:
- rename
tk.gitDirtotk.gitFS, and change the type tobilly.Filesystem - WithGitDir would more-eagerly create the
osfs.New(dir) - This method would create a memfs, populate it, and assign it to
tk.gitFS. Note that we only need to return abilly.Filesystemhere, we don't need a full Git storage by using the Ingester interface.
| for _, res := range results { | ||
| if len(res.Failures) > 0 { | ||
| hasFailures = true | ||
| cmd.Printf("FAIL: %s\n", res.Name) |
There was a problem hiding this comment.
I think you'll want the filename here as well as the test name, particularly if the same test name could occur in multiple *.star files.
|
|
||
| if len(results) == 0 { | ||
| cmd.Printf("No tests found\n") | ||
| return err |
There was a problem hiding this comment.
err is nil here, so you could just return nil.
| return fmt.Errorf("one or more tests failed") | ||
| } | ||
|
|
||
| return err |
There was a problem hiding this comment.
err is also non-nil here (was last assigned on 28, then checked on 29)
- Remove t.Parallel() from TestDir to prevent panic when caller already called it - Add Filename field to TestResult; TestDir groups sub-tests by file/name - Restore more descriptive RunFile doc comment explaining src parameter - Remove path fallback from rule lookup: eval() now only accepts rule names, not file paths, resolving the dual-typed argument complexity - Update testdata/eval.star to reference rule by name (branch_protection_reviews) instead of file path (rule_type_sample.yaml) - Load ruleTypes in TestRunEvalFile so name-based lookup works in tests - Remove nil guard from mapToProto: empty entity maps now correctly flow through to the engine instead of silently returning nil
- Addressed Evan's feedback on testkit mocking (refactored to gitFS/WithGitFiles) - Fixed 'err' return values in cmd/dev/app/test/test.go - Added filename prefix to CLI test output - Fixed defer Close() pattern in ruletest/runner.go - Updated RunFile godoc
- Add test_fail_ prefix support to TestDir - Simplify runner_test.go to use TestDir - Rename intentionally failing tests in sample.star to use test_fail_ prefix
evankanderson
left a comment
There was a problem hiding this comment.
There are a few lint errors hanging around, but this is looking close.
I'd still like to see runner_test.go call r.TestDir(t, "testdata") if that works -- it feels like it would make adding new test cases really simple.
| if ruleType, ok := tr.ruleTypes[ruleName]; ok { | ||
| return ruleType, nil | ||
| } |
There was a problem hiding this comment.
Given that you're storing *minderv1.RuleType, you don't need the ,ok = formulation, since a nil pointer could just be the error case below:
| if ruleType, ok := tr.ruleTypes[ruleName]; ok { | |
| return ruleType, nil | |
| } | |
| if ruleType := tr.ruleTypes[ruleName]; ruleType != nil { | |
| return ruleType, nil | |
| } |
| }, | ||
| { | ||
| name: "invalid mock_fs type", | ||
| args: starlark.Tuple{starlark.String("rule.yaml")}, |
There was a problem hiding this comment.
(I know we don't really use this here, but this should be "fs_check", right?)
| // Clone Implements the Git trait. If gitFS is set, it initializes an | ||
| // in-memory Git repository backed by that filesystem. | ||
| func (tk *TestKit) Clone(_ context.Context, _ string, _ string) (*git.Repository, error) { | ||
| if tk.gitFS == nil { | ||
| return nil, ErrNotIngesterOverridden | ||
| } | ||
|
|
||
| storer := memory.NewStorage() | ||
|
|
||
| repo, err := git.Init(storer, tk.gitFS) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| w, err := repo.Worktree() | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| // Add all files from the worktree | ||
| status, err := w.Status() | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| for path := range status { | ||
| if _, err := w.Add(path); err != nil { | ||
| continue | ||
| } | ||
| } | ||
|
|
||
| _, err = w.Commit("Initial commit", &git.CommitOptions{ | ||
| Author: &object.Signature{ | ||
| Name: "TestKit", | ||
| Email: "testkit@minder.test", | ||
| When: time.Now(), | ||
| }, | ||
| }) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return repo, nil |
There was a problem hiding this comment.
Do we still need this? I don't think we do.
evankanderson
left a comment
There was a problem hiding this comment.
Looks good except for the lint errors. I'm particularly pleased at how runner_test.go was able to be simplified but still have good coverage.
| if !d.IsDir() && strings.HasSuffix(d.Name(), ".star") { | ||
| files = append(files, path) | ||
| if rt != nil && rt.Name != "" { | ||
| ruleTypes[rt.Name] = rt |
There was a problem hiding this comment.
(for a subsequent PR)
We should return an error if ruletypes[rt.Name] already exists.
| var dirs []string | ||
| for dir := range filesByDir { | ||
| dirs = append(dirs, dir) | ||
| } | ||
| sort.Strings(dirs) |
There was a problem hiding this comment.
This looks like a use-case for the Go generic map functions introduced a year or so ago:
| var dirs []string | |
| for dir := range filesByDir { | |
| dirs = append(dirs, dir) | |
| } | |
| sort.Strings(dirs) | |
| dirs = slices.Sorted(maps.Keys(filesByDir)) |
| // TestDir discovers and executes all *.star test files under the given | ||
| // directory, reporting results through t. It also loads *.yaml rules. | ||
| //nolint:tparallel // TestDir acts as a test runner helper, caller is responsible for t.Parallel | ||
| func (r *Runner) TestDir(t *testing.T, dir string) { |
There was a problem hiding this comment.
The linter doesn't like the //nolint: directive right after the other docs. You might just move this into runner_test, not export it, or export with a name that doesn't start with "Test*"
There was a problem hiding this comment.
This diff is beautiful, IMO.
| result := result | ||
| name := result.Filename + "/" + result.Name | ||
| t.Run(name, func(t *testing.T) { | ||
| if strings.HasPrefix(result.Name, "test_fail_") { |
There was a problem hiding this comment.
GCI wants this anonymous function to call t.Parallel() after calling t.Run() to produce a subtest.
Summary
This PR implements the CLI Scaffolding for the
mindevRule Testing Framework, wiring up the underlyingpkg/ruletestengine into an accessible CLI command and making the framework fully CI/CD ready.Addressing deferred tech debt from Phase 2, this PR also introduces "Rule Loading by Name", eliminating the need to hardcode file paths in Starlark scripts by dynamically pre-loading all YAML rule definitions from the target directory into an in-memory registry.
Key Features:
mindev testCLI Command: Added atestsubcommand tomindev. Developers can now execute their Starlark test files across entire directories by runningmindev test [directories...].eval()builtin has been updated to seamlessly look up and load rules directly by name from the pre-loaded YAML registry.eval()cannot find the specified rule by name in the registry, it safely falls back to resolving it as a file path, ensuring backward compatibility.mindev testexits with a non-zero exit code (1), making it perfectly suited for automated CI environments.Fixes #6507
Testing
make build) and verified the newtestcobra command registers properly../mindev test ./pkg/ruletest/testdata).1exit code and bubble up theone or more tests failederror for CI environments.