diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go index 7d9a32f..b7f8799 100644 --- a/pkg/runtime/runtime.go +++ b/pkg/runtime/runtime.go @@ -385,19 +385,34 @@ func applyPythonFormulaTestScope(req *runtimev0.TestRequest, spec *pythonhelpers // owns pytest's private distinction between positional collection targets // and -k name expressions. if spec.Output == pythonhelpers.OutputJUnitXML { - var exactSelectors, namePatterns []string + target := strings.TrimSpace(req.GetTarget()) + var exactSelectors, relativeNodeSelectors, namePatterns []string for _, filter := range req.GetFilters() { - if isExactPytestSelector(filter) { + if isRelativePytestNodeSelector(filter) { + relativeNodeSelectors = append(relativeNodeSelectors, strings.TrimSpace(filter)) + } else if isExactPytestSelector(filter) { exactSelectors = append(exactSelectors, filter) } else if strings.TrimSpace(filter) != "" { namePatterns = append(namePatterns, filter) } } + if len(relativeNodeSelectors) > 0 { + if !isPytestFileTarget(target) { + return fmt.Errorf("Python relative node selectors %q require one .py target, got %q", relativeNodeSelectors, target) + } + if len(exactSelectors) > 0 || len(namePatterns) > 0 { + return fmt.Errorf("Python relative node selectors cannot be mixed with independent exact selectors or name filters; use a typed TestSelection") + } + for _, selector := range relativeNodeSelectors { + exactSelectors = append(exactSelectors, target+"::"+strings.TrimPrefix(selector, "::")) + } + target = "" + } nameFilters, err := normalizePytestNameFilters(namePatterns) if err != nil { return err } - if target := strings.TrimSpace(req.GetTarget()); target != "" { + if target != "" { exactSelectors = append(exactSelectors, target) } if len(exactSelectors) > 0 { @@ -441,11 +456,37 @@ func isExactPytestSelector(value string) bool { return strings.HasSuffix(normalized, ".py") || strings.Contains(normalized, ".py/") } +// isRelativePytestNodeSelector recognizes the pytest identity suffix callers +// naturally pair with TestRequest.target: `Class::method`. It is not a valid +// positional operand on its own. The runtime joins it to the file target so an +// identical logical request cannot accidentally execute two unrelated +// selectors and collect zero cases. +func isRelativePytestNodeSelector(value string) bool { + value = strings.TrimSpace(value) + if !strings.Contains(value, "::") { + return false + } + return !isPytestFileTarget(strings.SplitN(value, "::", 2)[0]) +} + +func isPytestFileTarget(value string) bool { + value = strings.TrimSpace(strings.ReplaceAll(value, `\`, "/")) + return value != "" && !strings.Contains(value, "::") && strings.HasSuffix(value, ".py") +} + func pythonDefaultTestScope(req *runtimev0.TestRequest) (string, []string, error) { if req.GetSelection() == nil { target := strings.TrimSpace(req.GetTarget()) namePatterns := make([]string, 0, len(req.GetFilters())) + var relativeNode string for _, filter := range req.GetFilters() { + if isRelativePytestNodeSelector(filter) { + if relativeNode != "" { + return "", nil, fmt.Errorf("Python default test request has multiple relative node selectors; use one typed TestSelection") + } + relativeNode = strings.TrimSpace(filter) + continue + } if !isExactPytestSelector(filter) { namePatterns = append(namePatterns, filter) continue @@ -455,6 +496,12 @@ func pythonDefaultTestScope(req *runtimev0.TestRequest) (string, []string, error } target = strings.TrimSpace(filter) } + if relativeNode != "" { + if !isPytestFileTarget(target) || len(namePatterns) > 0 { + return "", nil, fmt.Errorf("Python relative node selector %q requires one .py target and no name filters; use a typed TestSelection", relativeNode) + } + target += "::" + strings.TrimPrefix(relativeNode, "::") + } filters, err := normalizePytestNameFilters(namePatterns) return target, filters, err } diff --git a/pkg/runtime/runtime_test.go b/pkg/runtime/runtime_test.go index 1fedece..36c7596 100644 --- a/pkg/runtime/runtime_test.go +++ b/pkg/runtime/runtime_test.go @@ -185,6 +185,46 @@ commands = pytest --pyargs broadpkg {posargs} assertRuntimeTestLeftSourceClean(t, root) } +// TestRuntimeFormulaCombinesFileTargetAndRelativeNodeFilter is the exact +// production request shape emitted by Mind's regression-test capability. A +// relative `Class::method` filter belongs beneath the file target; passing the +// two strings as independent pytest operands collects zero tests. +func TestRuntimeFormulaCombinesFileTargetAndRelativeNodeFilter(t *testing.T) { + if _, err := exec.LookPath("uv"); err != nil { + t.Fatalf("uv is required for the production Python runtime: %v", err) + } + root := t.TempDir() + for path, content := range map[string]string{ + "tox.ini": `[testenv] +commands = pytest {posargs} +`, + "test_selected.py": `class TestThing: + def test_selected_pass(self): + assert 2 + 2 == 4 + + def test_unselected_fail(self): + assert False +`, + } { + if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(path)), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + svc := pythonservice.New(&resources.Agent{Kind: "codefly:service", Name: "python"}) + svc.SourceLocation = root + resp, err := pythonruntime.New(svc).Test(context.Background(), &runtimev0.TestRequest{ + Target: "test_selected.py", Filters: []string{"TestThing::test_selected_pass"}, + }) + if err != nil { + t.Fatalf("Test: %v", err) + } + if resp.GetResult().GetState() != runtimev0.TestRunResult_PASSED || resp.GetCounts().GetTotal() != 1 || resp.GetCounts().GetPassed() != 1 { + t.Fatalf("relative-node result = %s counts=%+v, want one selected passing case\n%s", resp.GetResult().GetState(), resp.GetCounts(), resp.GetOutput()) + } + assertRuntimeTestLeftSourceClean(t, root) +} + // TestRuntimeFormulaExactFilterReplacesBroadPytestDiscovery proves exact node // identities carried in the repeated filters field remain exact targets. This // is the shape used by result-driven graders that own a set of test identities.