Skip to content
Merged
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-11-11", "symptom": "ako/mxcli#2100 (mendixlabs/mxcli#1805): a .test.mdl test calling a microflow with Apply entity access = Yes failed CE0114 at build ('A microflow that does not apply entity access can only call microflows that also do not apply entity access') and MDL-SEC22 at check; the same for an applying @setup. Making the generated test microflow apply entity access then failed CE0150 on the runner's own handlers ('attributes of System.Error … cannot be accessed in microflows that apply entity access').", "cause": "Three generators wrote callers without the setting: writeTestFlow (endpoint), GenerateTestRunner (legacy MxTest.TestRunner) and CheckSource (MxTest.Check_* wrappers). The runner's verdict and @throws handlers read $latestError/Message directly, which an applying flow may not.", "file": "cmd/mxcli/testrunner/caller_entity_access.go (callerAppliesEntityAccess, errorMessageFlow helper), generator_endpoint.go (attachOnError numbers its handlers), generator.go, check_source.go, runner.go (legacy cleanup), build_attribution.go", "insight": "CE0114 has one direction, so the safe constant is 'apply' (the same call RegisterEndpoint made for #1654) — but the setting has a second, opposite constraint (CE0150), and the fix has to be measured against both: mx check on 11.12.1 at Off and Production showed an applying flow reading an ordinary entity's attribute is 0 errors, so CE0150 is the only cost. Mendix's CE0150 text prescribes the helper. The runtime half needs no measurement because the endpoint runs tests under Core.createSystemContext. A test body that reads $latestError/ itself is the one case 'apply' would newly break, so it keeps the old setting; under the legacy runner that decision is per suite because all tests share one microflow. The reported fix named two generators; the legacy runner was a third.", "refs": ["ako/mxcli#2100", "mendixlabs/mxcli#1805", "ako/mxcli#1654"], "test": "cmd/mxcli/testrunner/caller_entity_access_test.go; mdl-examples/bug-tests/test-runner-2100-*"}
27 changes: 14 additions & 13 deletions .claude/skills/mendix/test-microflows/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ description: "Write and run MDL-based microflow tests with `mxcli test` — anno
| 190-232 | Test File Formats |
| 233-248 | string Operations |
| 249-272 | Running Tests |
| 273-470 | How It Works |
| 471-509 | Check a test file before you run it |
| 510-631 | Writing Good Tests |
| 632-646 | Test File Organization |
| 647-659 | Interpreting Failures |
| 660-681 | CI Integration |
| 682-688 | Related Skills |
| 689-693 | Warm test loop (`mxcli test --local [--watch]`, `--attach`, `run --local --test-endpoint`) |
| 273-471 | How It Works |
| 472-510 | Check a test file before you run it |
| 511-632 | Writing Good Tests |
| 633-647 | Test File Organization |
| 648-660 | Interpreting Failures |
| 661-682 | CI Integration |
| 683-689 | Related Skills |
| 690-694 | Warm test loop (`mxcli test --local [--watch]`, `--attach`, `run --local --test-endpoint`) |

<!-- /skill-index -->
# Test Microflows Skill
Expand Down Expand Up @@ -325,11 +325,12 @@ chained, `--local` ran with the app's startup logic suppressed, and a suite that
depended on startup state passed under `--attach` and failed under `--local` for
reasons unrelated to the code.

`MxTest.RegisterEndpoint` applies entity access, so it can call a startup
microflow that does or does not (CE0114 only forbids a non-applying microflow
calling an applying one); yours still runs with its own setting. A build error
located in `MxTest.RegisterEndpoint` or `MxTest.RegisterTestEndpoint` is reported
as an mxcli defect, not as an error in your project.
`MxTest.RegisterEndpoint` and the generated test microflows apply entity
access, so they can call a microflow that does or does not (CE0114 only forbids
a non-applying microflow calling an applying one); yours still runs with its own
setting. Exception: a test whose own error handler reads `$latestError/…` gets a
flow that does not apply it, since an applying one may not (CE0150). A build
error in an `MxTest` document mxcli generated is reported as an mxcli defect.

One thing rollback does **not** cover: whatever the startup microflow writes
happens at boot, outside any test's transaction, so `@cleanup rollback` does not
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Fixed

- **A test can call a microflow that applies entity access** (ako/mxcli#2100, mendixlabs/mxcli#1805) — the generated test microflows, the legacy runner's `MxTest.TestRunner` and the wrappers `mxcli check` sees did not apply entity access, so a test (or its `@setup`) calling a production microflow that does failed with CE0114 at build time and MDL-SEC22 at check time, and the only workaround was weakening the production microflow. They now apply it, as `MxTest.RegisterEndpoint` already did. An applying microflow may not read `$latestError/Message` (CE0150), so the runner's error handlers read the message through a generated helper, `MxTest.Test_ErrorMessage`, that does not apply entity access and is removed with the test microflows; failure messages and `@throws` matching are unchanged. A test whose own error handler reads `$latestError/…` keeps a non-applying microflow, so it builds as before. Tests run in a system context, so nothing changes at run time.
- **`run --local --watch` applies a change in about 5 s instead of 20-28 s on Mendix versions that bundle the client themselves** (ako/mxcli#2150) — measured on a blank 11.16.0 app: a microflow edit took 20-22 s and a page edit 25-28 s with a runtime restart that dropped every browser session, because each change cost a fresh `mxbuild --serve` and a cold build. mxbuild deletes the client source a build wrote, so its next build bundled a client without pages. That source is now kept while a build runs and put back before the next one: the incremental build takes about 2 s, a page edit is a reload, and an open browser tab shows it without a restart or a new login. The full build remains the fallback when the bundle still comes out incomplete. A deleted page's module stays in `dist/pages`, unused, until the next full build. On Windows the copies are read without blocking mxbuild's own deletes, and if a build still fails because `deployment/web` was in use, the kept source is given up for that run and builds go back to the full rebuild.
- **`check` accepts the data-container names Studio Pro stores in a grid row, a control bar and a combo box option** (ako/mxcli#2109) — `describe page` → `exec` of four FactoryManagement pages was refused on its own output (MDL-BUTTON02, MDL-PAGEARG01): a show page in a selectable data grid's column passing the grid's selection (`$grid1`), a control-bar button passing the data view around its grid, and a combo box's option content reading the data view around the combo box. All three build in Studio Pro's own pages. `check` and `exec` now accept them and write what Studio Pro stores, and the pages report `Unchanged page`.
- **A binding to an outer data view keeps that data view** (ako/mxcli#2107) — a markdown viewer or an attribute-based visibility condition inside a nested data view, bound to the outer one's object, was described as a bare qualified name and rewritten against the nearest data view: CE7247, or "has no attribute". `$dvOuter.Attr` and `$dvOuter/Assoc/Attr` now work on a pluggable widget's attribute property and in `Visible: … in (…)`, as on input widgets, and `describe` prints them; the six FactoryManagement pages report `Unchanged page`.
Expand Down
1 change: 1 addition & 0 deletions cmd/mxcli/testrunner/build_attribution.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ func buildFailureHint(other []docker.BuildProblem) string {
var runnerDocuments = map[string]string{
endpointStartupFlow: "to register the test endpoint at startup and then run your after-startup microflow",
endpointRegisterAction: "to register the test endpoint",
errorMessageFlow: "to read a raised error's message for the test microflows",
}

// runnerDocumentPattern matches the `document` MxBuild reports — the document's
Expand Down
149 changes: 149 additions & 0 deletions cmd/mxcli/testrunner/caller_entity_access.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// SPDX-License-Identifier: Apache-2.0

// The generated test callers apply entity access (ako/mxcli#2100, upstream
// mendixlabs/mxcli#1805).
//
// A test calls the project's microflows, and MxBuild refuses a microflow that
// does not apply entity access calling one that does (CE0114). The callers —
// each endpoint test microflow, the legacy runner's MxTest.TestRunner, and the
// MxTest.Check_* wrappers `mxcli check` and the editor see — were written
// without the setting, so a test could not call a production microflow that
// applies it, and changing the production microflow to suit the harness is no
// fix. The rule has one direction (MDL-SEC22's table; RegisterEndpoint applies
// entity access for the same reason, ako/mxcli#1654), so an applying caller
// may call either kind.
//
// Applying entity access costs one thing, measured with mx check on 11.12.1 at
// security Off and Production: the flow may not read an attribute of
// System.Error or System.SoapFault (CE0150). The generated handlers read the
// raised message, so they pass $latestError to errorMessageFlow, a helper that
// does not apply entity access — what CE0150's own text prescribes. An applying
// flow reading an ordinary entity's attribute is 0 errors at both levels, so
// nothing else in a test body is affected.
//
// Nothing changes at run time: every test runs under a system context
// (Core.createSystemContext), which entity access does not restrict.
package testrunner

import (
"regexp"
"strconv"
"strings"

"github.com/mendixlabs/mxcli/mdl/langver"
)

const applyEntityAccessAnnotation = "@applyentityaccess"

// callerAppliesEntityAccess reports whether the generated caller of these
// tests applies entity access: yes, unless a test body itself reads an error
// object's attributes. Such a body — a custom handler inspecting $latestError —
// builds today in a non-applying caller and would fail CE0150 in an applying
// one, so it keeps the setting it had. A test that both reads the error and
// calls an applying microflow cannot be served by either setting; it has to
// pass the error to a non-applying microflow of its own, as Mendix says.
func callerAppliesEntityAccess(tests ...TestCase) bool {
for _, tc := range tests {
if readsErrorAttributes(tc.MDL, tc.Version) {
return false
}
}
return true
}

// errorAttributeAccess is a member read on the variables Mendix binds to a
// raised error inside a handler, matched without regard to case so no spelling
// the model accepts slips past: a miss would cost the test its build.
var errorAttributeAccess = regexp.MustCompile(`(?i)\$latest(error|soapfault)\s*/`)

// readsErrorAttributes reports whether a test body reads an attribute of the
// error being handled, outside string literals and comments.
func readsErrorAttributes(body string, v langver.Version) bool {
return errorAttributeAccess.MatchString(maskLiteralsAndComments(body, v))
}

// maskLiteralsAndComments blanks the text inside string literals and comments,
// with the same rules as scanLiteralState, so a pattern matched against the
// result only sees code.
func maskLiteralsAndComments(body string, v langver.Version) string {
b := []byte(body)
inString, inLineComment, inBlockComment := false, false, false
for i := 0; i < len(b); i++ {
c := b[i]
switch {
case c == '\n':
inLineComment = false
case inLineComment:
b[i] = ' '
case inBlockComment:
if c == '*' && i+1 < len(b) && b[i+1] == '/' {
inBlockComment = false
b[i], b[i+1] = ' ', ' '
i++
continue
}
b[i] = ' '
case inString:
switch {
case c == '\\' && v < langver.V1 && i+1 < len(b):
b[i] = ' '
if b[i+1] != '\n' {
b[i+1] = ' '
}
i++
case c == '\'':
inString = false
default:
b[i] = ' '
}
case c == '\'':
inString = true
case c == '-' && i+1 < len(b) && b[i+1] == '-':
inLineComment = true
b[i] = ' '
case c == '/' && i+1 < len(b) && b[i+1] == '*':
inBlockComment = true
b[i], b[i+1] = ' ', ' '
i++
}
}
return string(b)
}

// suiteHasFlows reports whether a generated script writes any test microflow,
// and so needs errorMessageFlow: a test whose @expect did not compile gets none.
func suiteHasFlows(suite *TestSuite) bool {
for _, tc := range suite.Tests {
if len(tc.AssertionErrors) == 0 {
return true
}
}
return false
}

// writeErrorMessageFlow defines errorMessageFlow. It does not apply entity
// access — that is its whole purpose. It is written with the test microflows
// rather than with the endpoint, so a run attached to an endpoint an older
// mxcli installed still has it, and it is removed with them.
func writeErrorMessageFlow(b *strings.Builder) {
b.WriteString("/** Returns a raised error's message to a test microflow, which applies entity access and may not read it (CE0150). */\n")
b.WriteString(createFlow + " " + errorMessageFlow + " (RaisedError: System.Error)\n")
b.WriteString("RETURNS String AS $Message\n")
b.WriteString("BEGIN\n")
b.WriteString(" DECLARE $Message String = $RaisedError/Message;\n")
b.WriteString(" RETURN $Message;\n")
writeFlowEnd(b)
}

// errorMessageVar names the variable the nth handler of a test reads the
// message into. scope keeps the legacy runner's tests, which share one
// microflow, apart; the endpoint's tests each have their own.
func errorMessageVar(scope string, n int) string {
return "$MxTestErrorMessage" + scope + "_" + strconv.Itoa(n)
}

// readErrorMessage is the handler line that puts the message of the error
// being handled in msgVar.
func readErrorMessage(msgVar string) string {
return " " + msgVar + " = CALL MICROFLOW " + errorMessageFlow + "(RaisedError = $latestError);"
}
Loading
Loading