Skip to content

Commit 5227e8e

Browse files
hjothahjothamendix
authored andcommitted
fix: stabilize MDL test runner and docker flow
Several unrelated papercuts made the integration/test-runner flow unreliable in CI. This folds the fixes into one scope (MDL test runner + docker pathing) since they were discovered together while chasing the same green build. Test-runner and generator: - `varPattern` / `assignPattern` now match on any line in the test body (added `(?m)` flag and tightened anchoring). Without this, multi-line test cases saw `$var = CREATE ...` silently left un-rewritten, which then failed the compiled microflow with "variable is not declared". - Drop the `isSideEffectStatement` / auto-`ON ERROR CONTINUE` wrapping for DELETE/COMMIT/CHANGE. The blanket wrap hid actual test failures behind silent passes; if a test wants tolerant error handling it can write it explicitly. - `runner.Run` now ensures the `.docker/` stack exists before building or restarting (new `ensureDockerStack` helper that calls `docker.Init` on demand). Previously a fresh checkout with no `.docker/docker-compose.yml` failed with a raw `stat` error before any test ran. Docker layer: - `resolveMxBuild` / `ResolveMxForVersion` now accept a preferred project version and prefer exact-match cached binaries over the lexicographically-last glob entry (which sorted Mendix 9 after 11). `AnyCachedMxBuildPath` and the MxBuild downloader share the new `newestVersionedPath` helper that parses `[major,minor,patch,build]` numerically. - `findPADDir` / `isPADDir` recognise the already-extracted layout (`app/`, `bin/`, `etc/`, `lib/`) produced by newer PAD outputs where the Dockerfile is not emitted. - `Check` resolves the project's version from the MPR reader and passes it through, so check-on-build uses the same `mx` binary the project was last built against. Microflow builder type inference: - `flowBuilder.lookupMicroflowReturnType` walks the raw microflow unit to fetch the return type of a called microflow. When the builder knows the return type, `registerResultVariableType` records it in `varTypes` with qualified entity name, so subsequent CHANGE / attribute-access activities on the returned variable resolve correctly. This is what lets tests like `$Product = CALL MfTest.M012_CreateEntity(); CHANGE $Product SET Price = 10.0;` validate without forcing the test author to re-declare the variable type. Makefile / scripts: - `make test-mdl` now goes through `scripts/run-mdl-tests.sh`, which copies the project into a temp directory before running the bootstrap MDL + test spec. The old direct invocation mutated the repo's seed `app.mpr`, so re-running the target picked up stale state. Regression tests: - `TestFindPADDir_ExtractedLayoutInRoot` for the new PAD layout - generator tests cover the multiline assign pattern - builder-type test uses `MockBackend.ListMicroflowsFunc` to assert that a subsequent CHANGE resolves against the returned entity
1 parent be7f7c5 commit 5227e8e

18 files changed

Lines changed: 746 additions & 60 deletions

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ test-integration:
151151
# Usage: make test-mdl MPR=path/to/app.mpr
152152
MPR ?= app.mpr
153153
test-mdl: build
154-
$(BUILD_DIR)/$(BINARY_NAME) test mdl-examples/doctype-tests/microflow-spec.test.mdl -p $(MPR)
154+
./scripts/run-mdl-tests.sh "$(abspath $(MPR))" "$(abspath $(BUILD_DIR)/$(BINARY_NAME))"
155155

156156
# Lint all code (Go + TypeScript)
157157
lint: lint-go lint-ts

cmd/mxcli/docker/build.go

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func Build(opts BuildOptions) error {
6565

6666
// Step 2: Resolve MxBuild
6767
fmt.Fprintln(w, "Resolving MxBuild...")
68-
mxbuildPath, err := resolveMxBuild(opts.MxBuildPath)
68+
mxbuildPath, err := resolveMxBuild(opts.MxBuildPath, pv.ProductVersion)
6969
if err != nil {
7070
// Auto-download fallback
7171
fmt.Fprintln(w, " MxBuild not found locally, downloading from CDN...")
@@ -96,7 +96,7 @@ func Build(opts BuildOptions) error {
9696
// Step 4: Pre-build check
9797
if !opts.SkipCheck {
9898
fmt.Fprintln(w, "Checking project for errors...")
99-
mxPath, err := ResolveMx(opts.MxBuildPath)
99+
mxPath, err := ResolveMxForVersion(opts.MxBuildPath, pv.ProductVersion)
100100
if err != nil {
101101
fmt.Fprintf(w, " Skipping check: %v\n", err)
102102
} else {
@@ -383,8 +383,10 @@ func Run(opts RunOptions) error {
383383
}
384384

385385
// findPADDir searches for a PAD output in the output directory tree.
386-
// It looks for a Dockerfile first, then for docker_compose/Default.yaml as a fallback
387-
// (MxBuild 11.6.3+ generates docker_compose instead of a Dockerfile).
386+
// It recognizes:
387+
// - classic PAD output with a Dockerfile
388+
// - PAD output with docker_compose/Default.yaml
389+
// - newer PAD output already extracted at the build root (app/bin/etc/lib)
388390
func findPADDir(outputDir string) (string, error) {
389391
// Check output dir itself
390392
if isPADDir(outputDir) {
@@ -405,19 +407,48 @@ func findPADDir(outputDir string) (string, error) {
405407
}
406408
}
407409

408-
return "", fmt.Errorf("no PAD output found in %s (looked for Dockerfile or docker_compose/Default.yaml)", outputDir)
410+
return "", fmt.Errorf("no PAD output found in %s (looked for Dockerfile, docker_compose/Default.yaml, or extracted app/bin/etc/lib layout)", outputDir)
409411
}
410412

411413
// isPADDir checks if a directory contains PAD output.
412-
// Recognizes both Dockerfile-based PAD and docker_compose-based PAD.
414+
// Recognizes Dockerfile-based PAD, docker_compose-based PAD, and newer
415+
// already-extracted PAD layout at the build root.
413416
func isPADDir(dir string) bool {
414417
if _, err := os.Stat(filepath.Join(dir, "Dockerfile")); err == nil {
415418
return true
416419
}
417420
if _, err := os.Stat(filepath.Join(dir, "docker_compose", "Default.yaml")); err == nil {
418421
return true
419422
}
420-
return false
423+
return hasExtractedPADLayout(dir)
424+
}
425+
426+
func hasExtractedPADLayout(dir string) bool {
427+
requiredDirs := []string{
428+
filepath.Join(dir, "app"),
429+
filepath.Join(dir, "bin"),
430+
filepath.Join(dir, "etc"),
431+
filepath.Join(dir, "lib"),
432+
}
433+
for _, path := range requiredDirs {
434+
info, err := os.Stat(path)
435+
if err != nil || !info.IsDir() {
436+
return false
437+
}
438+
}
439+
440+
requiredFiles := []string{
441+
filepath.Join(dir, "bin", "start"),
442+
filepath.Join(dir, "lib", "runtime", "launcher", "runtimelauncher.jar"),
443+
}
444+
for _, path := range requiredFiles {
445+
info, err := os.Stat(path)
446+
if err != nil || info.IsDir() {
447+
return false
448+
}
449+
}
450+
451+
return true
421452
}
422453

423454
// flattenPADDir moves contents from a PAD subdirectory to the output root directory.

cmd/mxcli/docker/build_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,34 @@ func TestFindPADDir_DockerCompose_InSubdir(t *testing.T) {
434434
}
435435
}
436436

437+
func TestFindPADDir_ExtractedLayoutInRoot(t *testing.T) {
438+
dir := t.TempDir()
439+
for _, subdir := range []string{
440+
filepath.Join(dir, "app"),
441+
filepath.Join(dir, "etc"),
442+
filepath.Join(dir, "bin"),
443+
filepath.Join(dir, "lib", "runtime", "launcher"),
444+
} {
445+
if err := os.MkdirAll(subdir, 0755); err != nil {
446+
t.Fatalf("mkdir %s: %v", subdir, err)
447+
}
448+
}
449+
if err := os.WriteFile(filepath.Join(dir, "bin", "start"), []byte("#!/bin/sh\n"), 0755); err != nil {
450+
t.Fatalf("write start script: %v", err)
451+
}
452+
if err := os.WriteFile(filepath.Join(dir, "lib", "runtime", "launcher", "runtimelauncher.jar"), []byte("fake"), 0644); err != nil {
453+
t.Fatalf("write launcher: %v", err)
454+
}
455+
456+
padDir, err := findPADDir(dir)
457+
if err != nil {
458+
t.Fatalf("unexpected error: %v", err)
459+
}
460+
if padDir != dir {
461+
t.Errorf("expected %s, got %s", dir, padDir)
462+
}
463+
}
464+
437465
func TestFindPADDir_PrefersDockerfile(t *testing.T) {
438466
dir := t.TempDir()
439467
// Both Dockerfile and docker_compose exist — Dockerfile wins

cmd/mxcli/docker/check.go

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"path/filepath"
1111
"runtime"
1212
"strings"
13+
14+
"github.com/mendixlabs/mxcli/sdk/mpr"
1315
)
1416

1517
// CheckOptions configures the mx check command.
@@ -44,7 +46,15 @@ func Check(opts CheckOptions) error {
4446
}
4547

4648
// Resolve mx binary
47-
mxPath, err := ResolveMx(opts.MxBuildPath)
49+
projectVersion := ""
50+
if opts.ProjectPath != "" {
51+
if reader, err := mpr.Open(opts.ProjectPath); err == nil {
52+
projectVersion = reader.ProjectVersion().ProductVersion
53+
reader.Close()
54+
}
55+
}
56+
57+
mxPath, err := ResolveMxForVersion(opts.MxBuildPath, projectVersion)
4858
if err != nil {
4959
return err
5060
}
@@ -88,12 +98,25 @@ func mxBinaryName() string {
8898
return "mx"
8999
}
90100

101+
func mxBinaryNames() []string {
102+
if runtime.GOOS == "windows" {
103+
return []string{"mx.exe", "mx"}
104+
}
105+
return []string{"mx"}
106+
}
107+
91108
// ResolveMx finds the mx executable.
92109
// Priority: derive from mxbuild path > PATH lookup.
93110
func ResolveMx(mxbuildPath string) (string, error) {
111+
return ResolveMxForVersion(mxbuildPath, "")
112+
}
113+
114+
// ResolveMxForVersion finds the mx executable, preferring the project's exact
115+
// Mendix version when multiple local installations or cached downloads exist.
116+
func ResolveMxForVersion(mxbuildPath, preferredVersion string) (string, error) {
94117
if mxbuildPath != "" {
95118
// Resolve mxbuild first to handle directory paths
96-
resolvedMxBuild, err := resolveMxBuild(mxbuildPath)
119+
resolvedMxBuild, err := resolveMxBuild(mxbuildPath, preferredVersion)
97120
if err == nil {
98121
// Look for mx in the same directory as mxbuild
99122
mxDir := filepath.Dir(resolvedMxBuild)
@@ -123,24 +146,47 @@ func ResolveMx(mxbuildPath string) (string, error) {
123146
return p, nil
124147
}
125148

149+
if preferredVersion != "" {
150+
if studioProDir := ResolveStudioProDir(preferredVersion); studioProDir != "" {
151+
for _, name := range mxBinaryNames() {
152+
candidate := filepath.Join(studioProDir, "modeler", name)
153+
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
154+
return candidate, nil
155+
}
156+
}
157+
}
158+
}
159+
126160
// Try OS-specific known locations (Studio Pro on Windows) before cached downloads.
127-
for _, pattern := range mendixSearchPaths(mxBinaryName()) {
128-
matches, _ := filepath.Glob(pattern)
129-
if len(matches) > 0 {
130-
return matches[len(matches)-1], nil
161+
if matches := globVersionedMatches(mendixSearchPaths(mxBinaryName())); len(matches) > 0 {
162+
if exact := exactVersionedPath(matches, preferredVersion); exact != "" {
163+
return exact, nil
164+
}
165+
if newest := newestVersionedPath(matches); newest != "" {
166+
return newest, nil
131167
}
132168
}
133169

134-
// Try cached mxbuild installations (~/.mxcli/mxbuild/*/modeler/mx).
135-
// NOTE: lexicographic sort is imperfect for versions (e.g. "9.x" > "10.x"),
136-
// but this is a fallback-of-last-resort — in practice users typically have
137-
// only one mxbuild version installed.
138-
if home, err := os.UserHomeDir(); err == nil {
139-
matches, _ := filepath.Glob(filepath.Join(home, ".mxcli", "mxbuild", "*", "modeler", mxBinaryName()))
140-
if len(matches) > 0 {
141-
return matches[len(matches)-1], nil
170+
if preferredVersion != "" {
171+
if p := CachedMxPath(preferredVersion); p != "" {
172+
return p, nil
142173
}
143174
}
175+
if p := AnyCachedMxPath(); p != "" {
176+
return p, nil
177+
}
144178

145179
return "", fmt.Errorf("mx not found; specify --mxbuild-path pointing to Mendix installation directory")
146180
}
181+
182+
func CachedMxPath(version string) string {
183+
cacheDir, err := MxBuildCacheDir(version)
184+
if err != nil {
185+
return ""
186+
}
187+
return cachedBinaryPath(cacheDir, mxBinaryNames())
188+
}
189+
190+
func AnyCachedMxPath() string {
191+
return anyCachedBinaryPath(mxBinaryNames())
192+
}

cmd/mxcli/docker/check_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,33 @@ func TestCheck_SkipUpdateWidgetsFlag(t *testing.T) {
118118
t.Error("check should still be called")
119119
}
120120
}
121+
122+
func TestResolveMxForVersion_PrefersExactCachedVersion(t *testing.T) {
123+
dir := t.TempDir()
124+
setTestHomeDir(t, dir)
125+
t.Setenv("PATH", "")
126+
127+
versions := []string{"9.24.40.80973", "11.6.3", "11.9.0"}
128+
var expected string
129+
for _, version := range versions {
130+
modelerDir := filepath.Join(dir, ".mxcli", "mxbuild", version, "modeler")
131+
if err := os.MkdirAll(modelerDir, 0755); err != nil {
132+
t.Fatal(err)
133+
}
134+
bin := filepath.Join(modelerDir, mxBinaryName())
135+
if err := os.WriteFile(bin, []byte("fake"), 0755); err != nil {
136+
t.Fatal(err)
137+
}
138+
if version == "11.9.0" {
139+
expected = bin
140+
}
141+
}
142+
143+
result, err := ResolveMxForVersion("", "11.9.0")
144+
if err != nil {
145+
t.Fatalf("unexpected error: %v", err)
146+
}
147+
if result != expected {
148+
t.Errorf("expected exact cached mx %s, got %s", expected, result)
149+
}
150+
}

cmd/mxcli/docker/detect.go

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,12 @@ func findMxBuildInDir(dir string) string {
7373
// CDN downloads are Linux binaries that cannot run natively on Windows.
7474
// The explicit path can be the binary itself or a directory containing it
7575
// (e.g., a Mendix installation root with modeler/mxbuild inside).
76-
func resolveMxBuild(explicitPath string) (string, error) {
76+
func resolveMxBuild(explicitPath string, preferredVersion ...string) (string, error) {
77+
targetVersion := ""
78+
if len(preferredVersion) > 0 {
79+
targetVersion = preferredVersion[0]
80+
}
81+
7782
if explicitPath != "" {
7883
info, err := os.Stat(explicitPath)
7984
if err != nil {
@@ -94,13 +99,31 @@ func resolveMxBuild(explicitPath string) (string, error) {
9499
return p, nil
95100
}
96101

102+
// Try the exact Studio Pro installation for the project version first.
103+
// This keeps Docker builds on the same Mendix line as the project when
104+
// multiple installs are present locally.
105+
if targetVersion != "" {
106+
if studioProDir := ResolveStudioProDir(targetVersion); studioProDir != "" {
107+
if found := findMxBuildInDir(studioProDir); found != "" {
108+
return found, nil
109+
}
110+
}
111+
}
112+
97113
// Try OS-specific known locations (Studio Pro on Windows) BEFORE cached downloads.
98114
// On Windows, CDN downloads are Linux binaries — Studio Pro's mxbuild.exe is preferred.
99-
for _, pattern := range mxbuildSearchPaths() {
100-
matches, _ := filepath.Glob(pattern)
101-
if len(matches) > 0 {
102-
// Return the last match (likely newest version)
103-
return matches[len(matches)-1], nil
115+
if matches := globVersionedMatches(mxbuildSearchPaths()); len(matches) > 0 {
116+
if exact := exactVersionedPath(matches, targetVersion); exact != "" {
117+
return exact, nil
118+
}
119+
if newest := newestVersionedPath(matches); newest != "" {
120+
return newest, nil
121+
}
122+
}
123+
124+
if targetVersion != "" {
125+
if p := CachedMxBuildPath(targetVersion); p != "" {
126+
return p, nil
104127
}
105128
}
106129

cmd/mxcli/docker/detect_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,3 +268,33 @@ func TestResolveMxBuild_PrefersStudioProOverCache(t *testing.T) {
268268
t.Errorf("expected Studio Pro binary %s, got %s (should prefer Studio Pro over cache)", studioBin, result)
269269
}
270270
}
271+
272+
func TestResolveMxBuild_PrefersExactCachedVersion(t *testing.T) {
273+
dir := t.TempDir()
274+
setTestHomeDir(t, dir)
275+
t.Setenv("PATH", "")
276+
277+
versions := []string{"9.24.40.80973", "11.6.3", "11.9.0"}
278+
var expected string
279+
for _, version := range versions {
280+
modelerDir := filepath.Join(dir, ".mxcli", "mxbuild", version, "modeler")
281+
if err := os.MkdirAll(modelerDir, 0755); err != nil {
282+
t.Fatal(err)
283+
}
284+
bin := filepath.Join(modelerDir, mxbuildBinaryName())
285+
if err := os.WriteFile(bin, []byte("fake"), 0755); err != nil {
286+
t.Fatal(err)
287+
}
288+
if version == "11.9.0" {
289+
expected = bin
290+
}
291+
}
292+
293+
result, err := resolveMxBuild("", "11.9.0")
294+
if err != nil {
295+
t.Fatalf("unexpected error: %v", err)
296+
}
297+
if result != expected {
298+
t.Errorf("expected exact cached version %s, got %s", expected, result)
299+
}
300+
}

0 commit comments

Comments
 (0)