Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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/<Module>/*.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"]}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"area": "cmd/mxcli", "date": "2026-10-15", "symptom": "`mxcli test tests/ --list` printed `Found 0 test(s)` and exited 0 over a tree whose only suites sat in a deliberate positive-control folder (`tests/unit/_control`) kept out of the default run, and that read as a green result", "cause": "The control was out of the default run only because discovery (before #1363) never entered sub-folders; once discovery recursed there was no opt-out, and `--list` treats an empty listing as success (a real run with 0 tests already exits 1 with `no tests found`) while saying nothing about why", "file": "`cmd/mxcli/testrunner/parser.go` (`walkTestTree`, `TestSuite.SkippedDirs`), `cmd/mxcli/testrunner/runner.go` (`ListTests`, `emptySuiteError`, `skippedDirsLine`)", "insight": "**Making discovery recursive needs an opt-out in the same change, and an opt-out must be reported.** Folders starting with `_` below the root are skipped, like dot-folders (the Go toolchain convention); the root named on the command line is always read, so `mxcli test tests/unit/_control` runs the control. `_` folders are collected into `SkippedDirs` and printed on the line after `Found N test(s)` by both the run and `--list`; dot-folders are not (`.git`/`.mxcli` noise). `ListTests` takes a second writer (`hints`, stderr from the command) and writes `emptySuiteError`'s explanation there when it found nothing, so stdout stays what scripts parse; its exit status is still 0, which is the contract of a listing, while a run with no tests exits 1. Do not fix a 'silent 0' by making `--list` fail: CI uses it to ask what exists. Tests: `parser_dir_test.go` (the `_control` cases, the root-is-read control, the hint writer).", "refs": ["mendixlabs/mxcli#1363"]}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"area": "cmd/mxcli", "date": "2026-10-15", "symptom": "`mxcli test tests` fails with `reading test directory: open .../locked: permission denied` and runs nothing when one subfolder (a locked folder, a root-owned folder in a mounted volume) cannot be read, although `tests/Sales/*.test.mdl` next to it is fine", "cause": "`walkTestTree` returned the `filepath.WalkDir` callback's `err` for every entry, so an error below the root became the whole walk's error and `ParseTestDir` returned it as a hard failure", "file": "`cmd/mxcli/testrunner/parser.go` (`walkTestTree`, `ParseTestDir`), `cmd/mxcli/testrunner/runner.go` (`noTestsParsedError`, `reportFileErrors`, `suiteFileErrorResults`)", "insight": "**Moving from a one-level `ReadDir` to a recursive walk turns every subfolder's error into the whole run's error unless errors below the root are made per-entry.** Only an error on the root is fatal; below it `onErr` appends a `FileError{Dir: true}` and the callback returns nil (WalkDir already skips the unreadable folder), so the run still goes red through `FileErrors` and keeps every readable test. Every consumer of `FileErrors` then needs the folder case worded as `could not be read`, not `failed to parse`. **Tests that run as root (CI, devcontainers) cannot see `chmod 000`, and Windows ignores it**: the real chmod test skips there, so the logic is also pinned by injecting the error through the `walkFunc` seam (`walkTestTreeWith`), which runs everywhere. The same walk now serves `testFilesMTime` (`--watch`), so discovery rules cannot drift between the parser and the change detector.", "refs": ["mendixlabs/mxcli#1363", "mendixlabs/mxcli#1368"]}
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### 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/<Module>/*.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`) and folders starting with `_` (the Go convention, so a positive-control suite can sit in `tests/unit/_control` outside the default run; the folder you name is always read, so `mxcli test tests/unit/_control` runs it). Skipped `_` folders are named after the `Found N test(s)` line of a run and at the end of a `--list`, so they do not disappear silently, and `--list` over a tree with no tests now says why on stderr (it still exits 0; a run with no tests still exits 1). A file that cannot be parsed is still reported on its own and does not stop the others; neither does a subfolder that cannot be read (a locked folder, a root-owned one in a mounted volume), which is reported by name and turns the run red while the tests in the other folders still run. The "no tests found" hint now also names misnamed `.mdl` / `.md` files in subfolders and the `_` folders that were skipped.

## [0.27.0] - 2026-10-15

Headline: **`exec` is all or nothing: when a statement fails at write time, the whole run is rolled back, so a script never leaves a project half-changed.** Alongside it: a wave of `describe` → `check` → `exec` fixes from round-trip runs over real apps (the Siemens FSM template, the GenAI showcase, Marketplace modules), Mendix 9 projects can take microflows, nanoflows and pluggable widgets again, `check -p` resolves many more of the references `exec` and the build would refuse, the classic `filemanager` widget and role-based `Visible: roles (…)` are in MDL, and the companion draws a module's domain model. The MongoDB driver is upgraded for the October 2026 BSON advisories.
Expand Down
13 changes: 11 additions & 2 deletions cmd/mxcli/cmd_test_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,18 @@ 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

# Discovery is fully recursive: point at the tests folder, not the project
# root (mxcli test . would search deployment/, node_modules/, javasource/ ...).
# A symlinked subfolder is not followed; an unreadable one is reported and
# the tests in the other folders still run.

# Folders starting with _ below the one you name are skipped (and listed);
# name one to run it, e.g. a positive-control suite
mxcli test tests/unit/_control -p app.mpr

# Output JUnit XML for CI
mxcli test tests/ -p app.mpr --junit results.xml

Expand Down Expand Up @@ -169,7 +178,7 @@ Examples:
// resolveTestPaths here too: listing that cannot find a path execution
// finds is a confusing split, and `mxcli test tests/ -p app/App.mpr
// --list` hit exactly that (mxcli-formula1 findings #15).
if err := testrunner.ListTests(resolveTestPaths(args, projectPath), os.Stdout); err != nil {
if err := testrunner.ListTests(resolveTestPaths(args, projectPath), os.Stdout, os.Stderr); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
Expand Down
154 changes: 134 additions & 20 deletions cmd/mxcli/testrunner/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"bufio"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
Expand Down Expand Up @@ -89,7 +90,8 @@ func (tc TestCase) AssertionCount() int {
type TestSuite struct {
Name string // Suite name (derived from file name)
Tests []TestCase // Test cases
// FileErrors holds one entry per test file that could not be parsed.
// FileErrors holds one entry per test file that could not be parsed, and per
// folder below the root that could not be read (Dir set).
//
// A malformed file used to abort the whole run, so an unrelated bad file
// stopped every other test in the directory from being listed or executed
Expand All @@ -103,12 +105,24 @@ type TestSuite struct {
// made `mxcli test <dir>` say "0 test(s) in 1 file(s)" for a directory nothing
// had been read from (ako/mxcli-maintenance §5).
FilesRead int
// SkippedDirs lists the folders discovery left out because their name starts
// with "_" (sorted, without duplicates, as the path was reached from the
// command line). A suite that is kept out of the default run on purpose, such
// as a positive control, must not vanish from the output: "Found 0 test(s)"
// over a tree whose tests all sit in tests/unit/_control read as a green run
// (mendixlabs/mxcli#1363). Hidden folders are not listed; they are .git and
// .mxcli noise.
SkippedDirs []string
}

// FileError is a test file that could not be parsed, and why.
// FileError is a test file that could not be parsed, or a folder that could not
// be read, and why.
type FileError struct {
Path string
Err error
// Dir is true when Path is a folder that could not be read, rather than a
// test file that could not be parsed.
Dir bool
}

// ParseTestFile parses a test file (.test.mdl or .test.md) and extracts test cases.
Expand Down Expand Up @@ -150,35 +164,135 @@ 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, ...) and folders
// starting with "_" are not entered below the root; the latter are reported in
// SkippedDirs.
//
// A folder below the root that cannot be read is reported in FileErrors (Dir
// set) and the walk carries on, so the tests in the other folders still run. An
// error on the root itself is returned: there is nothing to run.
func ParseTestDir(dir string) (*TestSuite, error) {
entries, err := os.ReadDir(dir)
suite := &TestSuite{
Name: filepath.Base(dir),
}

skipped, err := walkTestTree(dir, func(path string, d fs.DirEntry) {
if !isTestFile(d.Name()) {
return
}
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...)
}, func(path string, err error) {
suite.FileErrors = append(suite.FileErrors, FileError{Path: path, Err: err, Dir: true})
})
if err != nil {
return nil, fmt.Errorf("reading test directory: %w", err)
}
suite.SkippedDirs = skipped

suite := &TestSuite{
Name: filepath.Base(dir),
}
return suite, nil
}

for _, e := range entries {
if e.IsDir() {
continue
// walkTestTree calls fn for every non-directory entry under root, in lexical
// order. Below root it does not enter hidden (dot-prefixed) folders, and does
// not enter folders whose name starts with "_" either, which it returns (as
// reached from root, in walk order). That is the Go toolchain's rule for "./..."
// and it gives a suite a way to stay out of the default run: a deliberate
// positive control lives in tests/unit/_control and is run by naming it.
//
// The root itself is always walked, whatever it is called: `mxcli test .` has a
// root named ".", and `mxcli test tests/_control` is how that control is run. 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.
//
// An error reading the root fails the walk: there is nothing to run. An error
// below it belongs to that entry alone: it goes to onErr (nil to ignore it) and
// the walk carries on, because one folder that cannot be read (a locked folder, a
// root-owned one in a mounted volume) must not cost the tests in the others.
//
// Every path handed to fn, onErr and the returned list, and the text of the
// errors, is under root as the caller wrote it, not under the symlink's target.
func walkTestTree(root string, fn func(path string, d fs.DirEntry), onErr func(path string, err error)) ([]string, error) {
return walkTestTreeWith(filepath.WalkDir, root, fn, onErr)
}

// walkFunc is the shape of filepath.WalkDir, so a test can inject a walk error:
// chmod 000 cannot make a folder unreadable on Windows, or for root.
type walkFunc func(root string, fn fs.WalkDirFunc) error

func walkTestTreeWith(walk walkFunc, root string, fn func(path string, d fs.DirEntry), onErr func(path string, err error)) ([]string, error) {
walkRoot := root
if info, err := os.Lstat(root); err == nil && info.Mode()&os.ModeSymlink != 0 {
if resolved, err := filepath.EvalSymlinks(root); err == nil {
walkRoot = resolved
}
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
}
var skipped []string
err := walk(walkRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil {
err = underTyped(root, walkRoot, err)
if path == walkRoot {
return err
}
if onErr != nil {
onErr(underTypedPath(root, walkRoot, path), err)
}
// WalkDir does not descend into a folder it could not read.
return nil
}
if d.IsDir() {
if path == walkRoot {
return nil
}
if strings.HasPrefix(d.Name(), ".") {
return filepath.SkipDir
}
suite.Tests = append(suite.Tests, sub.Tests...)
if strings.HasPrefix(d.Name(), "_") {
skipped = append(skipped, underTypedPath(root, walkRoot, path))
return filepath.SkipDir
}
return nil
}
if path == walkRoot {
return fmt.Errorf("%s is not a directory", root)
}
fn(underTypedPath(root, walkRoot, path), d)
return nil
})
return skipped, err
}

// underTypedPath returns path, which the walk reached under walkRoot (root with
// its symlink resolved), as the same path under root.
func underTypedPath(root, walkRoot, path string) string {
if walkRoot == root {
return path
}
if rel, err := filepath.Rel(walkRoot, path); err == nil {
return filepath.Join(root, rel)
}
return path
}

return suite, nil
// underTyped does the same for the path an I/O error names, so that the message
// shows the folder the user typed and not where a symlink leads.
func underTyped(root, walkRoot string, err error) error {
if walkRoot == root {
return err
}
var pe *fs.PathError
if errors.As(err, &pe) {
return &fs.PathError{Op: pe.Op, Path: underTypedPath(root, walkRoot, pe.Path), Err: pe.Err}
}
return err
}

// isTestFile returns true if the filename matches a test file pattern.
Expand Down
Loading
Loading