From 92d3dfd682cd41ca002408f486825e690be570a3 Mon Sep 17 00:00:00 2001 From: stagknee <241821830+stagknee@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:45:02 -0500 Subject: [PATCH] fix: mxcli test finds test files in subfolders (closes #1363) ParseTestDir skipped every sub-directory, so `mxcli test tests` found nothing when suites live in tests//*.test.mdl. It now walks the tree in lexical order, skipping hidden folders; the "no tests found" hint walks the same way. Co-Authored-By: Claude Sonnet 5.5 --- ...list-found-0-test-s-suites-live-tests.json | 1 + CHANGELOG.md | 1 + cmd/mxcli/cmd_test_run.go | 2 +- cmd/mxcli/testrunner/parser.go | 65 +++++++--- cmd/mxcli/testrunner/parser_dir_test.go | 111 ++++++++++++++++++ cmd/mxcli/testrunner/runner.go | 19 ++- docs-site/src/tools/running-tests.md | 2 +- docs-site/src/tools/testing.md | 2 +- 8 files changed, 171 insertions(+), 32 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/cmd-mxcli/2026-10-09-mxcli-test-tests-list-found-0-test-s-suites-live-tests.json create mode 100644 cmd/mxcli/testrunner/parser_dir_test.go diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-09-mxcli-test-tests-list-found-0-test-s-suites-live-tests.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-09-mxcli-test-tests-list-found-0-test-s-suites-live-tests.json new file mode 100644 index 000000000..121abc190 --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-09-mxcli-test-tests-list-found-0-test-s-suites-live-tests.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli", "date": "2026-10-09", "symptom": "`mxcli test tests --list` prints `Found 0 test(s)` when the suites live in `tests//*.test.mdl`, while `mxcli test tests/Sales --list` finds them", "cause": "`testrunner.ParseTestDir` listed the directory with `os.ReadDir` and `continue`d on every sub-directory, so only test files directly inside the named folder were ever read", "file": "`cmd/mxcli/testrunner/parser.go` (`ParseTestDir`, `walkTestTree`), `cmd/mxcli/testrunner/runner.go` (`emptySuiteError`)", "insight": "**Discovery is a recursive walk now, and hidden folders below the root are skipped** (`.git`, `.mxcli`; the root itself is always read, so `mxcli test .` works). `emptySuiteError` must use the same walk or its `were not read` hint goes blind to exactly the nested layout the fix enables. `filepath.WalkDir` does not follow a root that is a symlink, so `walkTestTree` resolves it first (`os.ReadDir` used to follow it). Layout test: `parser_dir_test.go`; the `FilesRead` counter and the per-file `FileErrors` isolation are unchanged and the walk keeps going past a bad file. No other discovery rule (node_modules etc.) exists in the test runner, so none was added.", "refs": ["mendixlabs/mxcli#1363"]} diff --git a/CHANGELOG.md b/CHANGELOG.md index 68988b1d9..ba666da16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **`mxcli test tests` finds test files in subfolders** (mendixlabs/mxcli#1363) — `mxcli test tests --list` printed `Found 0 test(s)` when the suites live in `tests//*.test.mdl`, while `mxcli test tests/Sales --list` found them: a directory argument only read the files directly inside it. It now reads `*.test.mdl` and `*.test.md` at any depth, in path order, skipping hidden folders (`.git`, `.mxcli`). A file that cannot be parsed is still reported on its own and does not stop the others, and the "no tests found" hint now also names misnamed `.mdl` / `.md` files in subfolders. - **A navigation sub-menu no longer keeps the action of the page item it replaces** (mendixlabs/mxcli#1341) — turning menu item `'Work'` into sub-menu `'Work'` with `create or replace navigation` (or `create or modify menu`) paired the two by caption and carried the page item's `Forms$FormAction` onto the sub-menu, reporting it as an action "MDL cannot express"; `mx check` then failed with CE0548 "Items with subitems cannot have an action themselves." A sub-menu is now always written with no action (measured on 11.14.0). - **`mxcli test --attach` and the dev loop's "already serving" detection see a live app on Windows** (mendixlabs/mxcli#1284) — both read a pid from a handshake file (`.mxcli/test-endpoint.json`, `.mxcli/run-local.json`) and tested it with `Signal(0)`, which Go supports only as Kill on Windows, so every pid read as dead: `test --attach` always refused with "the app that published … is no longer running" while the app was up, and a command that looks for a dev loop already serving the project (the recompile warning, for one) never found it. Liveness is now `internal/procalive.Alive`, which uses OpenProcess and WaitForSingleObject on Windows (the check `run --local` already used for mxbuild) and `Signal(0)` elsewhere. No change on Linux or macOS. - **`run stop` on Windows no longer reports a run that has shut down as still alive** — it printed "failed: 1 process(es) of pid N's run are still alive" and exited 1 for a run that had stopped, because its liveness check counted any pid Windows could still open as alive, and an exited process stays openable while its parent holds a handle to it. It now uses the same check as `test --attach` (above), and reports "stopped: pid N". diff --git a/cmd/mxcli/cmd_test_run.go b/cmd/mxcli/cmd_test_run.go index 0060609d4..0a06f886e 100644 --- a/cmd/mxcli/cmd_test_run.go +++ b/cmd/mxcli/cmd_test_run.go @@ -103,7 +103,7 @@ Examples: # Run tests from a test file mxcli test tests/microflows.test.mdl -p app.mpr - # Run all tests in a directory + # Run all tests in a directory, subfolders included (tests/Sales/*.test.mdl) mxcli test tests/ -p app.mpr # Output JUnit XML for CI diff --git a/cmd/mxcli/testrunner/parser.go b/cmd/mxcli/testrunner/parser.go index 7ec625fd4..a9933fad3 100644 --- a/cmd/mxcli/testrunner/parser.go +++ b/cmd/mxcli/testrunner/parser.go @@ -8,6 +8,7 @@ import ( "bufio" "errors" "fmt" + "io/fs" "os" "path/filepath" "regexp" @@ -150,37 +151,63 @@ func ParseTestFile(path string) (*TestSuite, error) { }, nil } -// ParseTestDir parses all test files in a directory. +// ParseTestDir parses all test files under a directory, at any depth, in +// lexical path order. A suite per module (tests/Sales/*.test.mdl) is the usual +// layout, so `mxcli test tests` must find what `mxcli test tests/Sales` finds +// (mendixlabs/mxcli#1363). Hidden folders (.git, .mxcli, ...) are not entered. func ParseTestDir(dir string) (*TestSuite, error) { - entries, err := os.ReadDir(dir) - if err != nil { - return nil, fmt.Errorf("reading test directory: %w", err) - } - suite := &TestSuite{ Name: filepath.Base(dir), } - for _, e := range entries { - if e.IsDir() { - continue + err := walkTestTree(dir, func(path string, d fs.DirEntry) { + if !isTestFile(d.Name()) { + return } - name := e.Name() - if isTestFile(name) { - path := filepath.Join(dir, name) - suite.FilesRead++ - sub, err := ParseTestFile(path) - if err != nil { - suite.FileErrors = append(suite.FileErrors, FileError{Path: path, Err: err}) - continue - } - suite.Tests = append(suite.Tests, sub.Tests...) + suite.FilesRead++ + sub, err := ParseTestFile(path) + if err != nil { + suite.FileErrors = append(suite.FileErrors, FileError{Path: path, Err: err}) + return } + suite.Tests = append(suite.Tests, sub.Tests...) + }) + if err != nil { + return nil, fmt.Errorf("reading test directory: %w", err) } return suite, nil } +// walkTestTree calls fn for every non-directory entry under root, in lexical +// order, without entering hidden (dot-prefixed) folders below root. The root +// itself is always walked, whatever it is called: `mxcli test .` has a root +// named ".". A root that is a symlink is followed (as os.ReadDir did); +// symlinked folders below it are not, which keeps a link cycle from looping. +func walkTestTree(root string, fn func(path string, d fs.DirEntry)) error { + if info, err := os.Lstat(root); err == nil && info.Mode()&os.ModeSymlink != 0 { + if resolved, err := filepath.EvalSymlinks(root); err == nil { + root = resolved + } + } + return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if path != root && strings.HasPrefix(d.Name(), ".") { + return filepath.SkipDir + } + return nil + } + if path == root { + return fmt.Errorf("%s is not a directory", root) + } + fn(path, d) + return nil + }) +} + // isTestFile returns true if the filename matches a test file pattern. func isTestFile(name string) bool { lower := strings.ToLower(name) diff --git a/cmd/mxcli/testrunner/parser_dir_test.go b/cmd/mxcli/testrunner/parser_dir_test.go new file mode 100644 index 000000000..fda3bf633 --- /dev/null +++ b/cmd/mxcli/testrunner/parser_dir_test.go @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Apache-2.0 + +package testrunner + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// mendixlabs/mxcli#1363: `mxcli test tests --list` reported "Found 0 test(s)" +// when the suites live in tests//*.test.mdl, while `mxcli test +// tests/Sales --list` found them. ParseTestDir skipped every subdirectory. + +const ( + nestedTestA = "/**\n * @test in A\n */\n$x = 1;\n/\n" + nestedTestB = "# Spec\n\n```mdl-test\n/** @test in B sub */\n$y = 2;\n```\n" + nestedTestTop = "/**\n * @test at top\n */\n$z = 3;\n/\n" + nestedHidden = "/**\n * @test hidden\n */\n$h = 4;\n/\n" +) + +func writeNested(t *testing.T, root, rel, content string) { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func TestParseTestDirFindsTestsInSubfolders(t *testing.T) { + dir := filepath.Join(t.TempDir(), "tests") + writeNested(t, dir, "A/a.test.mdl", nestedTestA) + writeNested(t, dir, "B/sub/b.test.md", nestedTestB) + writeNested(t, dir, "top.test.mdl", nestedTestTop) + writeNested(t, dir, ".hidden/x.test.mdl", nestedHidden) + + suite, err := ParseTestDir(dir) + if err != nil { + t.Fatalf("ParseTestDir: %v", err) + } + if suite.Name != "tests" { + t.Errorf("suite name = %q, want %q", suite.Name, "tests") + } + if suite.FilesRead != 3 { + t.Errorf("FilesRead = %d, want 3 (the hidden folder is not read)", suite.FilesRead) + } + var got []string + for _, tc := range suite.Tests { + got = append(got, tc.Name) + } + // Lexical walk order: A, B/sub, then top.test.mdl. + want := "in A,in B sub,at top" + if strings.Join(got, ",") != want { + t.Errorf("tests = %v, want %s", got, want) + } + if len(suite.FileErrors) != 0 { + t.Errorf("unexpected file errors: %v", suite.FileErrors) + } +} + +func TestParseTestDirBadFileInSubfolderDoesNotStopTheWalk(t *testing.T) { + dir := t.TempDir() + writeNested(t, dir, "A/a.test.mdl", nestedTestA) + writeNested(t, dir, "B/bad.test.mdl", badTestB) + writeNested(t, dir, "C/c.test.mdl", goodTestC) + + suite, err := ParseTestDir(dir) + if err != nil { + t.Fatalf("ParseTestDir returned a hard error: %v", err) + } + if suite.FilesRead != 3 { + t.Errorf("FilesRead = %d, want 3", suite.FilesRead) + } + if len(suite.Tests) != 2 || suite.Tests[0].Name != "in A" || suite.Tests[1].Name != "also good" { + t.Errorf("tests = %+v, want [in A, also good]", suite.Tests) + } + if len(suite.FileErrors) != 1 || !strings.Contains(filepath.ToSlash(suite.FileErrors[0].Path), "B/bad.test.mdl") { + t.Fatalf("FileErrors = %+v, want exactly B/bad.test.mdl", suite.FileErrors) + } +} + +func TestParseTestDirHiddenRootIsStillRead(t *testing.T) { + // Only hidden folders BELOW the root are skipped: `mxcli test .` has a root + // named ".", and a project kept under a dot-folder is the user's choice. + dir := filepath.Join(t.TempDir(), ".suite") + writeNested(t, dir, "a.test.mdl", nestedTestA) + suite, err := ParseTestDir(dir) + if err != nil { + t.Fatalf("ParseTestDir: %v", err) + } + if suite.FilesRead != 1 || len(suite.Tests) != 1 { + t.Errorf("FilesRead = %d, tests = %d, want 1 and 1", suite.FilesRead, len(suite.Tests)) + } +} + +func TestEmptySuiteNamesSkippedCandidatesInSubfolders(t *testing.T) { + dir := t.TempDir() + writeNested(t, dir, "Sales/workflow.mdl", "show entities;\n") + writeNested(t, dir, ".git/ignored.mdl", "show entities;\n") + msg := emptySuiteError([]string{dir}).Error() + if !strings.Contains(msg, "workflow.mdl") { + t.Errorf("nested skipped candidate not named:\n%s", msg) + } + if strings.Contains(msg, "ignored.mdl") { + t.Errorf("file in a hidden folder listed:\n%s", msg) + } +} diff --git a/cmd/mxcli/testrunner/runner.go b/cmd/mxcli/testrunner/runner.go index c24a6c4f3..219fb91f2 100644 --- a/cmd/mxcli/testrunner/runner.go +++ b/cmd/mxcli/testrunner/runner.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "io" + "io/fs" "os" "os/exec" "path/filepath" @@ -1003,18 +1004,16 @@ func emptySuiteError(paths []string) error { if err != nil || !info.IsDir() { continue } - entries, err := os.ReadDir(path) - if err != nil { - continue - } - for _, e := range entries { - if e.IsDir() || isTestFile(e.Name()) { - continue + // Same walk as ParseTestDir, so a misnamed file in tests/Sales/ is + // named too. + _ = walkTestTree(path, func(file string, d fs.DirEntry) { + if isTestFile(d.Name()) { + return } - if l := strings.ToLower(e.Name()); strings.HasSuffix(l, ".mdl") || strings.HasSuffix(l, ".md") { - skipped = append(skipped, filepath.Join(path, e.Name())) + if l := strings.ToLower(d.Name()); strings.HasSuffix(l, ".mdl") || strings.HasSuffix(l, ".md") { + skipped = append(skipped, file) } - } + }) } sort.Strings(skipped) diff --git a/docs-site/src/tools/running-tests.md b/docs-site/src/tools/running-tests.md index 103443325..f54b5c24d 100644 --- a/docs-site/src/tools/running-tests.md +++ b/docs-site/src/tools/running-tests.md @@ -72,7 +72,7 @@ first call only stages the change. ## Basic Usage ```bash -# Run all tests in a directory +# Run all tests in a directory, including its subfolders mxcli test tests/ -p app.mpr # Run a specific test file diff --git a/docs-site/src/tools/testing.md b/docs-site/src/tools/testing.md index a5ac6dc44..c8cc9b9a7 100644 --- a/docs-site/src/tools/testing.md +++ b/docs-site/src/tools/testing.md @@ -26,7 +26,7 @@ Tests execute against a real Mendix runtime. **Docker is one way to get one, not ## Quick Start ```bash -# Run all tests in a directory +# Run all tests in a directory, including its subfolders mxcli test tests/ -p app.mpr # Run a specific test file