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/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-15-mxcli-test-list-found-0-tests-exits-0-positive-control-folder.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-15-mxcli-test-list-found-0-tests-exits-0-positive-control-folder.json new file mode 100644 index 000000000..a9158b2e1 --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-15-mxcli-test-list-found-0-tests-exits-0-positive-control-folder.json @@ -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"]} diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-15-mxcli-test-one-unreadable-subfolder-fails-the-whole-run.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-15-mxcli-test-one-unreadable-subfolder-fails-the-whole-run.json new file mode 100644 index 000000000..792a3c63d --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-15-mxcli-test-one-unreadable-subfolder-fails-the-whole-run.json @@ -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"]} diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-17-run-local-detach-windows-console-window-per-child.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-17-run-local-detach-windows-console-window-per-child.json new file mode 100644 index 000000000..f681849b5 --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-17-run-local-detach-windows-console-window-per-child.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli", "date": "2026-10-17", "symptom": "On Windows, `mxcli run --local --detach` opens a visible console window for every console program the detached run starts (java, mxbuild, gradle); several terminal windows pop up during a boot. The same run without `--detach`, as a background child of a shell, shows none", "cause": "`setDetachAttrs` started the run with `CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS`. DETACHED_PROCESS gives the run no console at all, so Windows allocates a new console, with its own window, for each console child the run starts", "file": "`cmd/mxcli/cmd_run_detach_windows.go` (`setDetachAttrs`); test `cmd/mxcli/cmd_run_detach_windows_test.go`", "insight": "A process with no console is not a quiet process: every console child it starts gets a NEW visible console. What silences a tree is one console that has no window, shared by all of it. CREATE_NO_WINDOW gives the run exactly that, and its children inherit it. Windows ignores CREATE_NO_WINDOW when it is combined with DETACHED_PROCESS, so the flag cannot simply be added to the old set. `HideWindow` does not help: it hides the run's own window only, not those of its children. Prove it by the mechanism, not the flag: a helper started through `setDetachAttrs` reports `GetConsoleCP` (0 = no console), `GetConsoleWindow` and `GetConsoleProcessList`; a console child of the helper must appear in the run's own process list and have no window. Before the fix the helper had no console and its child was alone on a visible window; after, the child shares the run's console, which has no window. Real run on Mendix 11.12.5 (Windows Terminal as the default terminal host): v0.26.0 showed 4 new visible windows during a boot, the fix 0, in two runs. A hidden console does not make a `conhost.exe` disappear (the run owns one), so count windows, not conhost processes. The run no longer shares the caller's console, so closing the launching terminal does not touch it, and `run stop` (`taskkill /T /F`) is unaffected", "refs": ["mendixlabs/mxcli#1484"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-bare-enum-return-type-check-exec-disagree.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-bare-enum-return-type-check-exec-disagree.json new file mode 100644 index 000000000..57f872335 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-16-bare-enum-return-type-check-exec-disagree.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-16", "symptom": "`create microflow M.F () returns M.Enum as $V` (also nanoflow and rule) passed `check -p --references` and was then refused by exec with \"entity 'M.Enum' not found for return type\"; for a rule check refused it too, with a misleading \"a rule must return a Boolean or an enumeration, not Entity\". `returns enum M.Enum`, `Enumeration(M.Enum)` and a bare enum name as a PARAMETER type all worked", "cause": "the parser records a bare Module.Name as an entity reference; the create handlers retry it as an enumeration for parameters but their return-type block only looked for an entity, while declaredTypeResolver.check was written against the general rule (entity or enumeration) and validateRule saw the unretyped entity kind", "file": "`mdl/executor/cmd_microflows_build.go` (microflow and nanoflow build), `mdl/executor/cmd_rules_create.go`, `bareEnumerationType` in `cmd_microflows_helpers.go`, `declaredTypeResolver.bareEnumeration` and the rule case in `validate.go`", "insight": "A fallback added for one declaration site (parameters) but not its sibling (return types) is a check/exec disagreement, because the checker was written against the general rule, not the site. When a handler resolves a name in one place, grep for the same lookup at every sibling site before trusting a green `check -p`; and a check-time validator that reads the AST kind (validateRule: Boolean or enumeration) needs the same retyping exec applies, or it refuses what exec now accepts. Settled by a PedApp assertAgree test per kind plus a stored-BSON comparison against the `enum` spelling, and mx check 11.12.5 at 0 errors. The same ambiguity is still open for a Java action's bare enum parameter and return type, which stores an entity type (CE1613)", "refs": ["mendixlabs/mxcli#1562"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-check-without-project-reports-datagrid-keys-as-dropped.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-check-without-project-reports-datagrid-keys-as-dropped.json new file mode 100644 index 000000000..8e197daa2 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-check-without-project-reports-datagrid-keys-as-dropped.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-17", "symptom": "`mxcli check pers.mdl` without `-p` warned `widget dgAttr (datagrid) property configurationAttribute is not recognized and will be silently dropped on write [MDL-WIDGET07]` (and the same for configurationStorageType, storeFiltersInPersonalization, refreshInterval, columnsHidable, showEmptyPlaceholder, a column's minWidth/minWidthLimit/allowEventPropagation/exportValue), although `exec -p` stores each of them and `check -p` accepts them (mendixlabs/mxcli#1573). An Expression-typed key (rowClass, a column's columnClass) was worse: MDL-WIDGET42 ERROR \"a plain-value property cannot hold the expression\", so a script `exec` takes failed `check` outright, and so did `describe` output carrying one.", "cause": "DATAGRID has no embedded definition (sdk/widgets/definitions holds combobox, gallery, the filters, image): its .def.json is generated from the project's own package, and an embedded one would take precedence over that package for every write because RefreshWidgetDefinitions skips an id the registry already holds. With no project the keyword therefore resolved to no definition, and validateWidgetTreeIn fell through to validateStaticWidgetUnknownProps, whose vocabulary is the union of what the built-in widgets' builders read and describe prints; validateWidgetExpressionValues, given no definition and no item mapping, called every key a plain-value property.", "fix": "validate_widget_embedded_schema.go: for a keyword widget the registry has no definition for (DATAGRID, and its `column`), read the property list of the template mxcli embeds for it (widgets.GetTemplate; the grid's own keys, the `columns` item type for a column). A key the template declares (or the `Params` of a text template) is accepted by MDL-WIDGET07, and its declared type decides MDL-WIDGET42 (Expression takes an expression). A key it does not declare still draws MDL-WIDGET07 with a did-you-mean from the template's names, now worded as what is true: it cannot be verified without a project. Nothing changes with a project, or for a built-in widget.", "file": "mdl/executor/validate_widget_embedded_schema.go", "test": "mdl/executor/validate_widget_embedded_schema_test.go (every key the template declares, derived from it); mdl-examples/bug-tests/1573-datagrid-by-key-settings-check-no-project.mdl", "insight": "`make check-mdl` runs `check` with no project, so the symptom is invisible to the usual loop (`check -p`) and shows only when a script is checked in a repo or by a tool without a project; the reproduction needs the pair `check` vs `check -p` on one file. The embedded template is the 11.6 baseline, kept conservative on purpose (widget-engine.md), so it is evidence of what a key IS and never of what it is not: the Data grid 2 template carries the same 40 grid keys and 21 column keys as the 11.12.5 package (3.4.0), but a newer widget version can add more, which is why an undeclared key is reported as unverifiable rather than as dropped, and why the misspelled-key case keeps its warning. Adding an embedded datagrid.def.json looks like the obvious fix and is the wrong one: it would change every write, not just the check."} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-describe-context-names-association.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-describe-context-names-association.json new file mode 100644 index 000000000..0b3e5b17e --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-describe-context-names-association.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-17", "symptom": "`describe page` prints `-- Context: $currentObject (Shop.Order_Customer)` — the ASSOCIATION — under a data view or list view bound over an association (`DataSource: $currentObject/Shop.Order_Customer`), where the entity it reaches (`Shop.Customer`) belongs; the page itself is correct (`mx check` clean) and only the comment, and consumers that qualify names against the context, were wrong (mendixlabs/mxcli#1571)", "cause": "`dataSourceEntityContext` returned `rawDataSource.Reference` for every kind but a flow. The Reference means a different thing per source kind: the entity for a database source, the flow for a microflow/nanoflow source (the 6e59e0e3e fix), the association for an association source, the page parameter's name for a parameter source. `associationSourcePath` read each step's DestinationEntity but kept only the association names, so a single-hop source carried nothing but the association", "file": "`mdl/executor/cmd_pages_describe_flowcontext.go` (`dataSourceEntityContext`), `mdl/executor/cmd_pages_describe.go` (`rawDataSource.Destination`, `associationSourceDestination`), `mdl/executor/cmd_pages_describe_datasource.go` (the two sites that build an association source)", "insight": "Deriving the context entity from a datasource reference needs a per-kind rule, and each kind with a non-entity reference is its own instance of this bug: ask what `Reference` holds before using it as an entity. For an association source the answer is stored, not inferred: Forms$AssociationSource, a DataViewSource and a pluggable XPath source all keep EntityRef.Steps[last].DestinationEntity, which is already right in both directions (owner to child and back), for a reference set, and for a destination that specializes the association's end — so read it rather than working out parent/child from the domain model (ALTER PAGE's `entityFromEntityRef` does the same, FINDINGS #55). A step with no stored destination keeps the old output. Not fixed here, same function: a `parameter` source's Reference is the page parameter's NAME, so a data view over `$Order` prints `(Order)`, not `(Shop.Order)`; the entity is stored in the same DataViewSource's EntityRef. The check-time `childContext` already treats an association source as unresolved on purpose"} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-describe-page-drops-datagrid-by-key-settings.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-describe-page-drops-datagrid-by-key-settings.json new file mode 100644 index 000000000..5aea4bfcb --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-describe-page-drops-datagrid-by-key-settings.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-17", "symptom": "`describe page` printed a Data grid 2 as `datagrid dg (DataSource: database from Shop.Ord, PageSize: 25, Pagination: virtualScrolling)` although exec had stored `configurationStorageType: localStorage`, `itemSelectionMethod: rowClick`, `refreshInterval: 30`, `columnsHidable: false`, a column's `minWidth`, labels and the empty-state placeholder (mendixlabs/mxcli#1573). Executing the description wrote the widget's default over every one of them, with `check` and `mx check` at 0 errors; a Studio Pro-authored grid lost its `onClickTrigger: double` the same way. The Gallery had the same loss (pagination, pageSize, showEmptyPlaceholder, state storage).", "cause": "The write side takes any pluggable-widget setting by its own property key (the explicit-key pass), but the describer for the widgets it has dedicated extractors for (datagrid2, gallery, combobox, filters) read only the properties MDL has a keyword for. The generic pluggable path that does print by key (extractExplicitProperties) is skipped for those widget types (isKnownCustomWidgetType), and it has no notion of a default, so it could not simply be switched on: it would print the whole schema of every untouched widget.", "fix": "cmd_pages_describe_bykey.go: nonDefaultPropsByKey reads each property's declared type and default from the stored widget's own Type.ObjectType (and the item type of `columns`) and prints a setting only when it differs from what exec writes for it left out; wired into the datagrid2 and gallery describers, each column, and the emptyPlaceholder slot. Text templates compare against the text the widget ships (Translations), an Expression prints unquoted (quoted when it is only a string literal), an attribute prints bare only where exec resolves a bare name; a value with no MDL spelling (attribute over associations) is named in a `-- NOT re-executable` comment.", "file": "mdl/executor/cmd_pages_describe_bykey.go", "test": "mdl/executor/cmd_pages_describe_bykey_test.go (against the shipped Data grid 2 / Gallery templates); mdl-examples/bug-tests/1573-datagrid-by-key-settings-describe.mdl", "insight": "Three traps decided the design. (1) DefaultValue is not the default exec writes: four text templates have DefaultValue '' but store their shipped text from Translations, so comparing with DefaultValue printed four labels on every grid; the untouched-grid test is built from the shipped widget template for that reason. (2) `describe -> exec` printing 'Unchanged page' proves nothing about the writer: exec keeps the stored page when the statement equals its description, so a lossy describer is a fixed point of itself; the round trip only shows after `drop page` + exec of the description, compared as BSON with ids stripped (v0.27.0 differs on all six pages of the bug-test script, the fix is identical on all six). (3) A by-key `itemSelection: Single` is stored as PrimitiveValue with Selection 'None' (the explicit pass routes a Selection-typed key as a primitive: mappedKeys holds mapping Sources, not keys), i.e. no selection at all, so the bug-test uses `Selection:`; describe skips Selection-typed keys, which keeps a describe -> exec -> BSON comparison from hiding that write-side defect."} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-navigation-targets-not-resolved-by-check-or-exec.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-navigation-targets-not-resolved-by-check-or-exec.json new file mode 100644 index 000000000..71a4583a7 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-navigation-targets-not-resolved-by-check-or-exec.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-17", "symptom": "`create or modify navigation` (or `create or replace`, deprecated or `mdl 1` spelling) naming a home page, role home, login page, not-found page or a menu item's page/microflow/nanoflow that does not exist passed `check -p` (\"Check passed!\") and `exec` (\"Navigation profile 'Responsive' updated.\"); only `mx check` objected: CE1613 \"The selected page 'Shop.NoSuchHome' no longer exists.\" at Navigation profile, and one per bad menu item. A page's own show page / call microflow and an unknown icon were refused. Same for `create menu`.", "cause": "The reference-resolution passes were added one reference kind at a time (validateWidgetReferences for widget trees, validateIconRefs, validateNavigationRoles for the role only), so navigation targets were in none of them. The exec handler built each menu action with a pageBuilder whose tolerateDanglingRefs is true for menus (so a target stored by name never failed), and read home/login/not-found pages straight into the spec; CREATE OR REPLACE NAVIGATION rewrites the whole profile.", "file": "`mdl/executor/validate_navigation_targets.go` (`navigationTargets`, `unresolvedNavigationTargets`, `validateNavigationTargets` called from `validateProgramWithWarnings`; `validateNavigationTargetsForExec` called first in `execAlterNavigation` and `execCreateMenu`); forward references via `navigationDefRefs` in `eagerDefRefs` and `Executor.enterScriptFlows` (`scriptTargets`)", "insight": "A reference pass written per kind leaves every kind it does not list silently unchecked, and the escape is silent both ways (check passes, exec writes, the build reports it one build later) — when adding a statement that names documents, grep every place a page/microflow name is resolved and ask which statements are NOT in the list. Navigation targets are written by a statement that replaces the whole profile, so the refusal has to come before the write, in the handler as well as in the pre-flight (`exec --no-check` skips the pre-flight). Reuse the same lookups (buildPageQualifiedNames and friends). A target stored by name and created further down the script must follow the flow-call rule (forwardFlowCalls, #1405) rather than statement order: a first version that made it an MDL-ORDER01 error broke a working script shape (navigation written before its pages: upstream execs it and mx check reports 0 errors) — measure what upstream does with the other order before enforcing one. Measured on a Mendix 11.12.5 app: 4 CE1613 before for the reported script, 0 errors after a valid navigation; an empty listing from the backend is silence, not a finding (as in validateForwardDefRefs)", "refs": ["mendixlabs/mxcli#1569"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-quoted-editable-expression-dropped.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-quoted-editable-expression-dropped.json new file mode 100644 index 000000000..3ebb73137 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-quoted-editable-expression-dropped.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-17","symptom":"A quoted `Editable: '$currentObject/Locked = false'` on a checkbox or text box passes check and exec (\"Created page\") and is stored `Editable: Always` with no ConditionalEditabilitySettings, so a field meant to be locked stays editable; `describe page` prints the widget without any Editable. The quoted `Visible: ''` beside it is stored as a conditional visibility, and the unquoted `Editable: $currentObject/Locked = false` works. `Editable: false` on an input was dropped the same way","cause":"`applyConditionalSettings` (cmd_pages_builder_v3.go) consulted only `pages.CanonicalEditability`, which accepts exactly Always|Never|Conditional and answers (\"\", false) for any other string, so the `else if` did not fire and the default Always stayed. `Visible` goes through `pages.StaticVisibleExpression`, whose default branch hands any other non-empty string back as the expression. MDL-WIDGET19 let every bool/string through for both keys (\"a static form StaticVisibleExpression consumes\"), true for Visible and false for Editable; a bool never reached the builder at all because it read the value with GetStringProp, which is \"\" for a bool","file":"`sdk/pages/pages_widgets.go` (new `StaticEditability`), `mdl/executor/widget_editable_static.go` (new: `staticEditability`, `editableCondition`, `editableValueProblem`), `cmd_pages_builder_v3.go` (`applyConditionalSettings`), `pluggable_system_props.go` (`widgetAsksEditability`), `validate_widgets.go` (MDL-WIDGET19, MDL-WIDGET13), `validate_page_button_context.go` (MDL-BUTTON02), `validate_widget_editability.go` (MDL-WIDGET20), `validate_listview_editable_inputs.go` (MDL-WIDGET31)","insight":"Two sibling properties with the same grammar slot but different consumers: one takes any string as an expression, the other only an enum word and skips the rest silently. A skipped write must be a refusal, so the fix is one rule for both keys (a string that is not the property's plain value is its condition) PLUS a check that names what still cannot be stored (a lone word such as `Neever`, text that is not an expression, `Conditional` with no condition). Every reader of `EditableIf` is a second consumer of the same value and has to see the quoted spelling too: MDL-WIDGET13 (association step, CE0117), MDL-BUTTON02 (own container name), MDL-WIDGET41 (pluggable Editability gate) and MDL-WIDGET20; fixing only the builder leaves them blind. Not every widget stores the enum: a list view and a grid column have a plain `Editable bool` (ako/mxcli#510), so a boolean there must NOT be read as Never/Always (`plainEditableWidgetTypes`). A quoted string reaches the validators unparsed, so a malformed quoted condition needs `visitor.ParsesAsExpression`; the bare spelling fails in the grammar. Quoted Visible still gets none of those checks (WIDGET13, BUTTON02, a syntax check) — left as is, same family. ALTER PAGE `set (Editable: '')` keeps its loud refusal (pagemutator setWidgetEditableMut); accepting it is about five lines (settings element plus the Conditional enum), not one. Control: same script on the pre-fix binary stores Always/null for the quoted widgets; the fix stores Conditional + expression, byte-equal (ids stripped) to the unquoted spelling, and describe -> exec reports `Unchanged page`. Side find, not fixed: a data view stores its static editability from its own ReadOnly flag (Forms$DataView.Editability), which the builder never sets from `Editable:`, so `DATAVIEW (Editable: Never)` is still stored Always (measured: bson dump shows Editability Always for Never, false and true alike); only the condition (ConditionalEditabilitySettings) is carried, and the quoted spelling now matches the bare one there","refs":[],"rules":["MDL-WIDGET19","MDL-WIDGET13","MDL-BUTTON02","MDL-WIDGET20","MDL-WIDGET41"], "refs": ["mendixlabs/mxcli#1567"]} diff --git a/.claude/skills/fix-issue/findings/sdk/2026-10-16-js-action-parameter-reference-error-signature-case.json b/.claude/skills/fix-issue/findings/sdk/2026-10-16-js-action-parameter-reference-error-signature-case.json new file mode 100644 index 000000000..68922eee5 --- /dev/null +++ b/.claude/skills/fix-issue/findings/sdk/2026-10-16-js-action-parameter-reference-error-signature-case.json @@ -0,0 +1 @@ +{"area": "sdk/javaactions", "date": "2026-10-16", "symptom": "A `create javascript action` whose body uses its own parameters (`Url`, `ReadOnly`) builds cleanly and throws `ReferenceError: Url is not defined` the first time the action runs. `mxcli check` and `mx check` report nothing. After a build the .js signature reads `JS_Open(url, readOnly)` where mxcli wrote `JS_Open(Url, ReadOnly)`, body untouched. A Studio Pro-authored action edited through `create or modify` turns `Geocode(address, ...)` into `Geocode(Address, ...)` over a body that still reads `address`", "cause": "`GenerateJavaScriptSource` wrote the model parameter name verbatim in the signature and the JSDoc `@param`. mxbuild and Studio Pro regenerate the file from the model, keeping only the user code, EXTRA CODE and imports, and lower-case the first character of each parameter name (only the first: `URL` -> `uRL`, `MyID` -> `myID`, `_Lead` unchanged), so the body was written against identifiers the next build removes", "file": "`sdk/javaactions/jssource.go` (`GenerateJavaScriptSource`, `JavaScriptParameterName`), goldens `sdk/javaactions/testdata/mxbuild/JS_*.js.golden`", "insight": "Generated source that a later tool regenerates must follow that tool's naming rule, or the user code inside it breaks silently: it is the user's, so the generator cannot fix it afterwards and nothing between MDL and the click can see it. Measure the rule against the real generator, not against the docs: create an action with awkward names (`URL, MyID, Some_Name, A, XMLData, _Lead, Obj2Name, Eid`), run mxbuild, read what it left (`uRL, myID, some_Name, a, xMLData, _Lead, obj2Name, eid`) - 'camelCase it' would have been wrong for `URL`. Keep the model name (the database keys on it; describe must round-trip) and change only the generated identifier. The earlier finding for the theme switcher worked around this by writing lower-case bodies; the generator was still wrong. Bodies are not rewritten, so a body that uses the declared case still fails: a `check` warning is the separate follow-up. mxbuild also rejects names that differ only in case (`Duplicate parameter name`), which mxcli does not check", "test": "TestJavaScriptParameterName_MatchesMxbuild, TestGenerateJavaScriptSource_SignatureMatchesMxbuild, TestWriteJavaScriptSourceFile_RewriteKeepsStudioProSignature", "refs": ["mendixlabs/mxcli#1560"]} diff --git a/.claude/skills/mendix/check-syntax/reference/references-resolved.md b/.claude/skills/mendix/check-syntax/reference/references-resolved.md index e454edd39..53bbda579 100644 --- a/.claude/skills/mendix/check-syntax/reference/references-resolved.md +++ b/.claude/skills/mendix/check-syntax/reference/references-resolved.md @@ -10,7 +10,14 @@ Beyond the page, microflow, entity and icon names a statement names directly, - an access rule's `where […]` members; - an entity event handler's microflow; - `Module.Enum.Key` in an expression; -- a view entity's FROM / JOIN entities. +- a view entity's FROM / JOIN entities; +- the pages and flows a navigation profile or a menu document names: the home + page (and one per user role), a home flow, the not-found page, and every menu + item's page, microflow or nanoflow at any depth. The login page is not + resolved: it may be the theme's static login.html rather than a page. The target must + exist, or be created by the script (further down only in a run that rolls + back, as for a flow call); `exec` refuses the same statement + before it rewrites the profile or menu. ## The project's Mendix version diff --git a/.claude/skills/mendix/javascript-actions/SKILL.md b/.claude/skills/mendix/javascript-actions/SKILL.md index a8869513e..7d893fd9f 100644 --- a/.claude/skills/mendix/javascript-actions/SKILL.md +++ b/.claude/skills/mendix/javascript-actions/SKILL.md @@ -12,14 +12,14 @@ description: "Create, call and drop JavaScript actions — client-side logic cal |---|---| | 31-39 | When to Use This Skill | | 40-51 | Overview | -| 52-85 | Syntax | -| 86-147 | Examples | -| 148-159 | Calling from a nanoflow | -| 160-184 | The generated .js file | -| 185-193 | Inspecting | -| 194-202 | Validation Checklist | -| 203-211 | Common Errors | -| 212-216 | Related Documentation | +| 52-94 | Syntax | +| 95-156 | Examples | +| 157-168 | Calling from a nanoflow | +| 169-193 | The generated .js file | +| 194-202 | Inspecting | +| 203-211 | Validation Checklist | +| 212-221 | Common Errors | +| 222-226 | Related Documentation | # Mendix JavaScript Actions Skill @@ -83,6 +83,15 @@ Same type system as Java actions: bare `pEntity` for instance parameters - Append `NOT NULL` to mark a parameter required. +### Parameter names in the body + +The body sees each parameter with its **first character lower-cased**: `A` is +`a`, `ReadOnly` is `readOnly`, `URL` is `uRL` (only the first character changes), +`_Lead` stays `_Lead`. That is the signature Studio Pro and mxbuild generate, and +mxcli writes it. The model keeps the declared name and `describe` prints it as +declared. A body that uses the declared spelling throws `ReferenceError` at run +time and nothing in `check` or the build reports it. + ## Examples Simple action (Web default): @@ -104,7 +113,7 @@ CREATE JAVASCRIPT ACTION MyFirstModule.JSA_Add ( B: Integer NOT NULL ) RETURNS Integer AS $$ - return Promise.resolve(A + B); + return Promise.resolve(a + b); $$; ``` @@ -119,7 +128,7 @@ CREATE JAVASCRIPT ACTION MyFirstModule.JSA_ShowToast ( EXPOSED AS 'Show Toast' IN 'UI' PLATFORM Native AS $$ - console.log(Message); + console.log(message); return Promise.resolve(true); $$; ``` @@ -134,7 +143,7 @@ CREATE OR MODIFY JAVASCRIPT ACTION MyFirstModule.JSA_Add ( C: Integer ) RETURNS Integer AS $$ - return Promise.resolve(A + B + (C || 0)); + return Promise.resolve(a + b + (c || 0)); $$; ``` @@ -168,13 +177,13 @@ This is the nanoflow counterpart of `CALL JAVA ACTION` (microflows). Use it in a // END EXTRA CODE /** - * @param {number} A - * @param {number} B + * @param {number} a + * @param {number} b * @returns {Promise.} */ -export async function JSA_Add(A, B) { +export async function JSA_Add(a, b) { // BEGIN USER CODE - return Promise.resolve(A + B); + return Promise.resolve(a + b); // END USER CODE } ``` @@ -208,6 +217,7 @@ DESCRIBE JAVASCRIPT ACTION Module.Name; -- re-executable MDL (signature + body | `already exists — use create or modify to overwrite` | Duplicate name without `OR MODIFY` | Add `OR MODIFY` | | Action runs nowhere / wrong client | `PLATFORM` too narrow | Set `PLATFORM All` (or the right target) | | `mismatched input 'platform'` | Clause out of order | Put `platform` after `exposed as`, before `as` | +| `ReferenceError: Url is not defined` when the action runs | The body uses the declared parameter name; the `.js` signature has the first character lower-cased | Write `url`, not `Url` | ## Related Documentation diff --git a/CHANGELOG.md b/CHANGELOG.md index ec1630394..a0840307e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`describe module` prints an entity's grants after the associations they name** (mendixlabs/mxcli#1409) — an entity's grant naming one of its associations was printed with the entity, ahead of the association, so replaying the output stopped with "entity ModA.EntA has no member(s) EntA_EntB", leaving the entity without its association or access. `check` passed the same script; it now reports a grant naming an association created further down the script, with the reorder. - **`import from mapping` over an XML schema or web service mapping imports XML** (mendixlabs/mxcli#1232) — the activity was always written with content type JSON, so re-executing a microflow whose import uses a mapping from a web service operation failed `mx check` with "The selected import mapping cannot handle content type JSON." until the activity was opened in Studio Pro. The content type now follows the mapping's source, and a stored one is kept on rewrite; a REST call's mapping result handling gets the same. - **Workflow user tasks keep "Auto-assign when targeting results in one user"** (mendixlabs/mxcli#1547) — every single- and multi-user task was written with `AutoAssignSingleTargetUser = false`, MDL had no spelling for it and `describe workflow` did not print it, so `create or modify workflow` turned off a box ticked in Studio Pro. When the targeting then yielded one user, the task was not assigned to them and completing it from a microflow failed at runtime with "You can't complete this user task, it is not assigned to you"; `check` and `mx check` were silent. The new clause `auto assign single user` (on `user task` and `multi user task`) writes it, `describe` prints it when set, and a `create or modify` or `replace activity` that does not restate a stored one is refused, like `await all users`. Omitted, it is off, which is what Studio Pro 11.14 stores for a new task. The MCP backend sent `false` too and now sends the task's value. +- **`check` without a project no longer reports a Data grid 2's own settings as dropped, or refuses an expression on one** (mendixlabs/mxcli#1573) — `exec -p` stores `configurationStorageType`, `configurationAttribute`, `storeFiltersInPersonalization`, `refreshInterval`, `columnsHidable`, `rowClass`, a column's `minWidth` and the rest of the widget's own keys, and `check -p` accepts them, but `check` alone warned MDL-WIDGET07 "not recognized and will be silently dropped on write" for each, and refused an Expression-typed one (`rowClass`, a column's `columnClass`) as an MDL-WIDGET42 error, so a script `exec` takes failed `make check-mdl`. Data grid 2 has no embedded definition (it is generated from the project's package, and an embedded one would take precedence over that package on every write), so the check judged its keys against the built-in widgets' vocabulary. It now reads the property list of the Data grid 2 template mxcli embeds: a key the template declares is accepted and its declared type decides MDL-WIDGET42. A key it does not declare still warns — a misspelled `configurationStorageTyp` too, with the spelling it probably meant — but says what is true without a project: that the key cannot be verified, and is dropped on write unless the installed widget declares it. With `-p`, and for every other widget, nothing changes. +- **`describe page` prints a Data grid 2's and a Gallery's settings that MDL has no keyword for** (mendixlabs/mxcli#1573) — a pluggable widget takes every setting by its own key (`configurationStorageType: localStorage`, `itemSelectionMethod: rowClick`, `refreshInterval: 30`, a column's `minWidth`), and `exec` stores it with `mx check` clean, but `describe page` printed only the keyword properties (`PageSize`, `Pagination`, `Selection`, `onClick`). Executing the description therefore wrote the widget's default over each of them, silently: measured on Mendix 11.12.5, a grid with 22 settings changed came back with the 4 that have keywords, and a Studio Pro-authored grid lost its `onClickTrigger: double`. `describe` now prints each setting whose stored value differs from the default, under its key — enumerations and plain values, text templates (with their `{N}` parameters), expressions, attributes (bare inside a data container, qualified where a bare name would not resolve), a column's own settings and the empty-state placeholder slot. The default is read from the widget's own stored definition, not a list, and a text template counts as default when it holds the text the widget ships for it, so a grid that sets none of them describes exactly as before. A setting with no spelling (an attribute reached over associations) is named in a `-- NOT re-executable` comment. A Gallery had the same loss and takes the same fix. +- **`describe page` names the entity an association data source reaches, not the association** (mendixlabs/mxcli#1571) — under a data view, list view, grid or gallery bound over an association, the context comment read `-- Context: $currentObject (Shop.Order_Customer)`: the association, where `(Shop.Customer)` belongs. The page was right (`mx check` clean); the comment misled anyone reading `describe` to learn what a nested widget binds, and the same context decides how an association name inside the container is spelled. The entity is the destination stored on the source's last hop, so it is correct from either end of the association, for a reference set, and for a specialization of the association's end. The `DataSource:` text is unchanged. A source with no stored destination prints what it did before. +- **`check -p` and `exec` refuse a navigation profile or menu document that names a page or flow that does not exist** (mendixlabs/mxcli#1569) — `create or modify navigation` with a home page, role home, not-found page or menu item (at any depth, in both the deprecated and the `mdl 1` spelling) pointing at a missing page, microflow or nanoflow passed `check -p` ("Check passed!") and `exec` ("Navigation profile 'Responsive' updated."), and only `mx check` objected: CE1613 "The selected page 'Shop.NoSuchHome' no longer exists.", once for the home page and once per menu item. A page's own `show page` and `call microflow` and an icon name were already resolved; a navigation statement was not, and it rewrites the whole profile. `check -p` now reports each unresolved target with the profile or menu document, the menu item path (`menu item 'Admin' > 'Users'`) and the missing name, and `exec` refuses the statement before it writes anything. A target the script creates resolves, wherever it stands in a run that rolls back (plain `exec`, `check`), as a call to a flow created further down does (mendixlabs/mxcli#1405); `exec --continue-on-error`, the REPL and `-c` refuse one created further down. A standalone `create menu` document is covered, and one stored as excluded only warns, as an excluded page does. A login page is not resolved: the sign-in page may be the theme's static `login.html` rather than a page document. +- **A bare `returns Module.Name` that names an enumeration is accepted by `exec`, as `check` already said** (mendixlabs/mxcli#1562) — `create microflow M.F () returns M.Variant as $V` (and the nanoflow and rule forms) passed `check -p --references` and was then refused by `exec` with "entity 'M.Variant' not found for return type". The parser cannot tell an entity from an enumeration for a bare qualified name; `exec` resolved that for parameters (the entity first, then an enumeration) but not for return types. All three kinds now do, and store exactly what `returns enum M.Variant` stores. A rule's `check` refused the bare form too, with a misleading "a rule must return a Boolean or an enumeration, not Entity". A name that is both an entity and an enumeration stays the entity, as for a parameter, and a name that is neither now says "entity or enumeration" in `check` and in `exec`, for return types and parameters alike, where it used to say only "entity". `returns enum M.Variant` and `Enumeration(M.Variant)` always worked and are what `describe` prints. +- **A JavaScript action's parameters are written with the names mxbuild generates** (mendixlabs/mxcli#1560) — `create javascript action … (Url: String, ReadOnly: Boolean)` wrote `export async function JS_Open(Url, ReadOnly)`, while mxbuild and Studio Pro regenerate the signature with the first character of each parameter lower-cased, `JS_Open(url, readOnly)`, and keep the body. A body using the declared `Url` then threw `ReferenceError` the first time the action ran, with `check`, `mx check` and the build all clean. The signature and the JSDoc `@param` names now carry that name (only the first character changes: `URL` is `uRL`, `MyID` is `myID`, `_Lead` stays), measured against mxbuild 11.12.5, so what `exec` writes is what a build leaves. The same rewrite used to break a Studio Pro-authored action on its first edit through MDL: `Geocode(address, …)` became `Geocode(Address, …)` over a body that still read `address`. The model keeps the declared parameter names, `describe` prints them as declared, and bodies are not rewritten, so a body reads `readOnly`, not `ReadOnly`. +- **A quoted `Editable: ''` is stored as a conditional editability instead of being dropped** (mendixlabs/mxcli#1567) — `CHECKBOX cb (Attribute: Flag, Editable: '$currentObject/Locked = false')` passed `check`, `exec` said "Created page", and the widget was stored `Editable: Always` with no condition, so a field meant to be locked stayed editable (`mx check` also reported 0 errors). `Visible: ''` beside it was stored as a conditional visibility, and the unquoted `Editable: $currentObject/Locked = false` worked: the page builder read `Editable` only as one of `Always`/`Never`/`Conditional` and skipped every other string without a word. A string that is not one of those is now the condition, exactly as for `Visible`, and `describe` prints it back bare, so a describe → exec round trip is stable. `Editable: false` / `true` on an input widget were dropped the same way and now store `Never` / `Always` (a list view and a grid column keep their own plain boolean, and a data view keeps its existing handling). `check` refuses what still cannot be stored — a misspelt `Editable: Neever`, a quoted text that is not an expression, `Editable: Conditional` with no condition — as MDL-WIDGET19 naming the accepted spellings, and a quoted condition now gets the checks the bare one gets (MDL-WIDGET13 association steps, MDL-BUTTON02, MDL-WIDGET41 on a pluggable widget that declares no Editability, MDL-WIDGET20, and MDL-WIDGET31 no longer warns about an input marked `editable: false`). `alter page … set (Editable: '')` still refuses a quoted condition with its existing message. +- **`mxcli run --local --detach` no longer opens a console window per child process on Windows** (mendixlabs/mxcli#1484) — a detached run on Windows made several terminal windows pop up during a boot, one for each console program it started (Java, MxBuild, Gradle). The run was created without a console (`DETACHED_PROCESS`), so Windows gave each console child a new, visible one. It is now created with a console of its own that has no window (`CREATE_NO_WINDOW`), which its children share. Measured on Mendix 11.12.5: v0.26.0 showed 4 new visible windows during a boot, this build 0. Closing the launching terminal still does not end the run, and `run stop` is unchanged. +- **`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`) 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 diff --git a/cmd/mxcli/cmd_run_detach_windows.go b/cmd/mxcli/cmd_run_detach_windows.go index 29a9fa5db..a46f6411e 100644 --- a/cmd/mxcli/cmd_run_detach_windows.go +++ b/cmd/mxcli/cmd_run_detach_windows.go @@ -13,18 +13,28 @@ import ( const ( createNewProcessGroup = 0x00000200 - detachedProcess = 0x00000008 + createNoWindow = 0x08000000 ) -// setDetachAttrs starts the background run without a console and in a process -// group of its own, so closing the calling console does not end it. +// setDetachAttrs starts the background run with a console of its own that has +// no window, in a process group of its own, so closing the calling console does +// not end it. +// +// The run must have a console. With DETACHED_PROCESS it has none, and Windows +// then gives every console program it starts (java, mxbuild, gradle) a new +// console with a visible window (mendixlabs/mxcli#1484). CREATE_NO_WINDOW gives +// the run one hidden console that those children inherit, so none appears. +// Windows ignores CREATE_NO_WINDOW when it is combined with DETACHED_PROCESS, +// so the two must not be set together. func setDetachAttrs(cmd *exec.Cmd) { - cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNewProcessGroup | detachedProcess, HideWindow: true} + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNewProcessGroup | createNoWindow, HideWindow: true} } // terminateProcess ends the run and its whole process tree. Windows has no -// SIGTERM for a console-less process, so this is not a graceful stop: the -// run's own teardown (removing --test-endpoint) does not run. +// SIGTERM, and the run's hidden console (CREATE_NO_WINDOW) is not one the +// caller is attached to, so no Ctrl-Break can reach it either: this is not a +// graceful stop, and the run's own teardown (removing --test-endpoint) does +// not run. func terminateProcess(pid int) error { return exec.Command("taskkill", "/T", "/F", "/PID", fmt.Sprint(pid)).Run() } diff --git a/cmd/mxcli/cmd_run_detach_windows_test.go b/cmd/mxcli/cmd_run_detach_windows_test.go new file mode 100644 index 000000000..f60cff302 --- /dev/null +++ b/cmd/mxcli/cmd_run_detach_windows_test.go @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows + +package main + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + "unsafe" +) + +// `run --local --detach` opened a visible console window for every console +// program the detached run started — java, mxbuild, gradle +// (mendixlabs/mxcli#1484). The run was created DETACHED_PROCESS, which gives +// it no console, so Windows allocated a new visible one for each console child. +// These tests start a helper through setDetachAttrs and ask the helper, and a +// console child of the helper, what console each ended up with — the mechanism, +// not just the flag. + +const ( + detachHelperEnv = "MXCLI_TEST_DETACH_HELPER" // "parent" or "child" + detachOutEnv = "MXCLI_TEST_DETACH_OUT" // report file the helper writes +) + +// consoleReport is what a helper process says about its own console. +type consoleReport struct { + PID uint32 `json:"pid"` + ConsoleCP uint32 `json:"consoleCP"` // 0 = no console attached + ConsoleWindow uintptr `json:"consoleWindow"` // 0 = no console window + Visible bool `json:"visible"` + ConsolePIDs []uint32 `json:"consolePIDs"` // processes attached to the same console +} + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + user32 = syscall.NewLazyDLL("user32.dll") + procGetConsoleCP = kernel32.NewProc("GetConsoleCP") + procGetConsoleWindow = kernel32.NewProc("GetConsoleWindow") + procGetConsoleProcList = kernel32.NewProc("GetConsoleProcessList") + procIsWindowVisible = user32.NewProc("IsWindowVisible") +) + +func describeConsole() consoleReport { + r := consoleReport{PID: uint32(os.Getpid())} + cp, _, _ := procGetConsoleCP.Call() + r.ConsoleCP = uint32(cp) + hwnd, _, _ := procGetConsoleWindow.Call() + r.ConsoleWindow = hwnd + if hwnd != 0 { + v, _, _ := procIsWindowVisible.Call(hwnd) + r.Visible = v != 0 + } + buf := make([]uint32, 32) + n, _, _ := procGetConsoleProcList.Call(uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf))) + if n > 0 && int(n) <= len(buf) { + r.ConsolePIDs = buf[:n] + } + return r +} + +func writeReport(t *testing.T, path string, r consoleReport) { + t.Helper() + b, err := json.Marshal(r) + if err != nil { + t.Fatalf("encode report: %v", err) + } + if err := os.WriteFile(path, b, 0o644); err != nil { + t.Fatalf("write report: %v", err) + } +} + +func readReport(t *testing.T, path string) consoleReport { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("helper wrote no report: %v", err) + } + var r consoleReport + if err := json.Unmarshal(b, &r); err != nil { + t.Fatalf("decode report %q: %v", b, err) + } + return r +} + +// TestDetachConsoleHelper is not a test: it is the detached run ("parent") and +// the console program the run starts ("child", e.g. java). +func TestDetachConsoleHelper(t *testing.T) { + role, out := os.Getenv(detachHelperEnv), os.Getenv(detachOutEnv) + if role == "" { + t.Skip("helper process only") + } + if role == "child" { + writeReport(t, out, describeConsole()) + os.Exit(0) + } + // The parent starts a console child exactly as the real run starts java: + // no console flags of its own. + child := exec.Command(os.Args[0], "-test.run=^TestDetachConsoleHelper$") + child.Env = append(os.Environ(), detachHelperEnv+"=child", detachOutEnv+"="+out+".child") + if err := child.Run(); err != nil { + t.Fatalf("start console child: %v", err) + } + writeReport(t, out, describeConsole()) + os.Exit(0) +} + +// TestDetachedRunHasHiddenConsoleItsChildrenShare is the behavioural proof for +// mendixlabs/mxcli#1484: the detached run has a console (so its console +// children inherit it instead of getting a new one), and that console has no +// window (so nothing pops up). +func TestDetachedRunHasHiddenConsoleItsChildrenShare(t *testing.T) { + out := filepath.Join(t.TempDir(), "report.json") + cmd := exec.Command(os.Args[0], "-test.run=^TestDetachConsoleHelper$") + cmd.Env = append(os.Environ(), detachHelperEnv+"=parent", detachOutEnv+"="+out) + setDetachAttrs(cmd) + if err := cmd.Run(); err != nil { + t.Fatalf("detached helper: %v", err) + } + parent := readReport(t, out) + child := readReport(t, out+".child") + t.Logf("detached run: %+v", parent) + t.Logf("its console child: %+v", child) + + if parent.ConsoleCP == 0 { + t.Errorf("the detached run has no console, so Windows gives each console child a new visible one; want a console of its own") + } + if parent.ConsoleWindow != 0 { + t.Errorf("the detached run's console has a window (%#x); want a hidden one", parent.ConsoleWindow) + } + if child.ConsoleCP == 0 { + t.Errorf("the run's console child has no console") + } + if child.ConsoleWindow != 0 || child.Visible { + t.Errorf("the run's console child got a console window (%#x, visible=%v); want none", child.ConsoleWindow, child.Visible) + } + shared := false + for _, p := range child.ConsolePIDs { + if p == parent.PID { + shared = true + } + } + if !shared { + t.Errorf("the run's console child (pid %d) is on console %v, not the run's (pid %d): it got a console of its own", child.PID, child.ConsolePIDs, parent.PID) + } +} + +// The flag set the behaviour above depends on, spelled with literals so a +// change to the constants is caught. CREATE_NO_WINDOW is ignored when combined +// with DETACHED_PROCESS, so the two must not appear together. +func TestSetDetachAttrsFlags(t *testing.T) { + const ( + createNewProcessGroupFlag = 0x00000200 + detachedProcessFlag = 0x00000008 + createNoWindowFlag = 0x08000000 + ) + cmd := exec.Command("cmd") + setDetachAttrs(cmd) + flags := cmd.SysProcAttr.CreationFlags + if flags&createNewProcessGroupFlag == 0 { + t.Errorf("flags %#x lack CREATE_NEW_PROCESS_GROUP: the run would share the caller's Ctrl-C", flags) + } + if flags&createNoWindowFlag == 0 { + t.Errorf("flags %#x lack CREATE_NO_WINDOW", flags) + } + if flags&detachedProcessFlag != 0 { + t.Errorf("flags %#x contain DETACHED_PROCESS: the run has no console and its children each get a visible one", flags) + } +} diff --git a/cmd/mxcli/cmd_test_run.go b/cmd/mxcli/cmd_test_run.go index 567677d76..9a2e883a9 100644 --- a/cmd/mxcli/cmd_test_run.go +++ b/cmd/mxcli/cmd_test_run.go @@ -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 @@ -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) } diff --git a/cmd/mxcli/syntax/features_integration.go b/cmd/mxcli/syntax/features_integration.go index 7f02bfdc7..b17148de7 100644 --- a/cmd/mxcli/syntax/features_integration.go +++ b/cmd/mxcli/syntax/features_integration.go @@ -658,7 +658,7 @@ DESCRIBE DATABASE CONNECTION Ops.Erp;`, "javascript action", "javascript", "js action", "call javascript", "platform", "exposed as", "javascriptaction", }, - Syntax: "LIST JAVASCRIPT ACTIONS [IN Module];\nDESCRIBE JAVASCRIPT ACTION Module.Name;\nCREATE [OR MODIFY] JAVASCRIPT ACTION Module.Name [FOLDER 'path'](...) RETURNS Type [PLATFORM Web|Native|Hybrid|All] AS $$ ... $$;\nDROP JAVASCRIPT ACTION Module.Name;\n\nWrites the unit plus javascriptsource//actions/.js. AS $$ ... $$ is mandatory. PLATFORM defaults to Web.", + Syntax: "LIST JAVASCRIPT ACTIONS [IN Module];\nDESCRIBE JAVASCRIPT ACTION Module.Name;\nCREATE [OR MODIFY] JAVASCRIPT ACTION Module.Name [FOLDER 'path'](...) RETURNS Type [PLATFORM Web|Native|Hybrid|All] AS $$ ... $$;\nDROP JAVASCRIPT ACTION Module.Name;\n\nWrites the unit plus javascriptsource//actions/.js. AS $$ ... $$ is mandatory. PLATFORM defaults to Web. The body sees each parameter with its first character lower-cased (ReadOnly is readOnly, URL is uRL), as mxbuild generates the signature.", Example: "mdl 1;\nCREATE JAVASCRIPT ACTION Utils.IsStrictMode() RETURNS Boolean\nPLATFORM Web\nAS $$\nreturn Promise.resolve((function(){ return !this; })());\n$$;\n\n-- Exposed, native, with parameters\nCREATE JAVASCRIPT ACTION Utils.ShowToast(\n Message: String NOT NULL,\n Duration: Integer\n) RETURNS Boolean\nEXPOSED AS 'Show Toast' IN 'UI'\nPLATFORM Native\nAS $$\nreturn Promise.resolve(true);\n$$;", SeeAlso: []string{"java-action"}, }) diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index 33233592b..a089834ad 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -166,6 +166,9 @@ LIST IMPACT OF htmlelement; "-- written (name attributes as $currentObject/Attr). A plain value is static.\n" + "TEXTBOX name (Attribute: Attr, Visible: $currentObject/IsActive, Editable: $currentObject/Status != 'Closed')\n" + "TEXTBOX name (Attribute: Attr, Visible: false, Editable: Never)\n" + + "-- A condition may also be a quoted string holding the same expression; Editable:\n" + + "-- false / true mean Never / Always.\n" + + "TEXTBOX name (Attribute: Attr, Editable: '$currentObject/Status != ''Closed''')\n" + "-- The bracketed Visible: [IsActive] (attributes rooted for you) is the deprecated\n" + "-- spelling, MDL-DEPR081.\n\n" + "-- Actions\nACTIONBUTTON name (Caption: 'C', Action: SAVE CHANGES, ButtonStyle: Primary)\nLINKBUTTON name (Caption: 'C', Action: ...)\n\n" + diff --git a/cmd/mxcli/testrunner/parser.go b/cmd/mxcli/testrunner/parser.go index 7ec625fd4..06673490b 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" @@ -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 @@ -103,12 +105,24 @@ type TestSuite struct { // made `mxcli test ` 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. @@ -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. diff --git a/cmd/mxcli/testrunner/parser_dir_test.go b/cmd/mxcli/testrunner/parser_dir_test.go new file mode 100644 index 000000000..f59b62eb3 --- /dev/null +++ b/cmd/mxcli/testrunner/parser_dir_test.go @@ -0,0 +1,263 @@ +// 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) + } +} + +// A folder starting with "_" below the root is skipped, like a dot-folder (the +// Go toolchain's convention), so a deliberate positive-control suite can sit +// beside the real ones without joining the default run. Skipping must not be +// silent: the walk reports what it left out. The root itself is always read. +const nestedControl = "/**\n * @test control\n */\n$c = 5;\n/\n" + +func TestParseTestDirSkipsUnderscoreFoldersAndReportsThem(t *testing.T) { + dir := filepath.Join(t.TempDir(), "tests") + writeNested(t, dir, "unit/a.test.mdl", nestedTestA) + writeNested(t, dir, "unit/_control/c.test.mdl", nestedControl) + writeNested(t, dir, "unit/sub/_x/deep/x.test.mdl", nestedControl) + writeNested(t, dir, "_top/t.test.mdl", nestedControl) + writeNested(t, dir, ".hidden/h.test.mdl", nestedHidden) + + suite, err := ParseTestDir(dir) + if err != nil { + t.Fatalf("ParseTestDir: %v", err) + } + if suite.FilesRead != 1 || len(suite.Tests) != 1 || suite.Tests[0].Name != "in A" { + t.Fatalf("FilesRead = %d, tests = %+v, want only \"in A\"", suite.FilesRead, suite.Tests) + } + var got []string + for _, d := range suite.SkippedDirs { + got = append(got, filepath.ToSlash(strings.TrimPrefix(d, dir))) + } + // Sorted; the dot-folder is noise (.git, .mxcli) and is not reported; a + // skipped folder's own subfolders are not walked, so not listed either. + want := "/_top,/unit/_control,/unit/sub/_x" + if strings.Join(got, ",") != want { + t.Errorf("SkippedDirs = %v, want %s", got, want) + } +} + +func TestParseTestDirReadsAnUnderscoreRoot(t *testing.T) { + // `mxcli test tests/_control` runs the control suite: only folders BELOW the + // root are skipped. + dir := filepath.Join(t.TempDir(), "tests", "_control") + writeNested(t, dir, "c.test.mdl", nestedControl) + writeNested(t, dir, "deeper/d.test.mdl", nestedTestA) + writeNested(t, dir, "_inner/i.test.mdl", nestedTestB) + + suite, err := ParseTestDir(dir) + if err != nil { + t.Fatalf("ParseTestDir: %v", err) + } + if suite.FilesRead != 2 || len(suite.Tests) != 2 { + t.Errorf("FilesRead = %d, tests = %d, want 2 and 2 (root read, _inner skipped)", suite.FilesRead, len(suite.Tests)) + } + if len(suite.SkippedDirs) != 1 || filepath.Base(suite.SkippedDirs[0]) != "_inner" { + t.Errorf("SkippedDirs = %v, want just _inner", suite.SkippedDirs) + } +} + +func TestParseTestFilesDeduplicatesAndSortsSkippedDirs(t *testing.T) { + // The same folder named twice on the command line is reported once. + dir := t.TempDir() + writeNested(t, dir, "b/_b/x.test.mdl", nestedControl) + writeNested(t, dir, "a/_a/x.test.mdl", nestedControl) + suite, err := parseTestFiles([]string{dir, dir}) + if err != nil { + t.Fatalf("parseTestFiles: %v", err) + } + if len(suite.SkippedDirs) != 2 || filepath.Base(suite.SkippedDirs[0]) != "_a" || filepath.Base(suite.SkippedDirs[1]) != "_b" { + t.Errorf("SkippedDirs = %v, want [_a _b]", suite.SkippedDirs) + } +} + +func TestSkippedDirsLine(t *testing.T) { + if got := skippedDirsLine(nil); got != "" { + t.Errorf("no skipped folders: line = %q, want empty", got) + } + got := skippedDirsLine([]string{"tests/unit/_control"}) + want := " Skipped folder(s) starting with _: tests/unit/_control (name one explicitly to run it)" + if got != want { + t.Errorf("line = %q, want %q", got, want) + } + many := []string{"a/_1", "a/_2", "a/_3", "a/_4", "a/_5", "a/_6", "a/_7"} + got = skippedDirsLine(many) + if !strings.Contains(got, "a/_5") || strings.Contains(got, "a/_6") || !strings.Contains(got, "and 2 more") { + t.Errorf("seven folders: want five named and \"and 2 more\":\n%s", got) + } + if strings.Contains(got, "\n") { + t.Errorf("line must be one line:\n%s", got) + } +} + +func TestEmptySuiteNamesAFolderThatHoldsTheTests(t *testing.T) { + // The report that started this: the only suites are in a "_" folder, the + // default run finds nothing, and the message must say where they went. + dir := filepath.Join(t.TempDir(), "tests") + writeNested(t, dir, "unit/_control/c.test.mdl", nestedControl) + msg := emptySuiteError([]string{dir}).Error() + if !strings.Contains(msg, "_control") || !strings.Contains(msg, "name one") { + t.Errorf("message does not name the skipped folder and how to run it:\n%s", msg) + } + if strings.Contains(msg, "were not read") { + t.Errorf("claimed misnamed files where there are none:\n%s", msg) + } + // Control: a tree with no "_" folder does not mention them. + plain := emptySuiteError([]string{dirWith(t)}).Error() + if strings.Contains(plain, "starting with _") { + t.Errorf("empty directory mentions skipped folders:\n%s", plain) + } +} + +func TestListTestsWithZeroTestsExplainsOnTheHintWriterAndSucceeds(t *testing.T) { + dir := filepath.Join(t.TempDir(), "tests") + writeNested(t, dir, "unit/_control/c.test.mdl", nestedControl) + + var out, hints strings.Builder + if err := ListTests([]string{dir}, &out, &hints); err != nil { + t.Fatalf("ListTests returned %v, want nil: --list stays exit 0", err) + } + if !strings.Contains(out.String(), "Found 0 test(s):") { + t.Errorf("stdout lost the count line:\n%s", out.String()) + } + if !strings.Contains(out.String(), "Skipped folder(s) starting with _") { + t.Errorf("stdout does not report the skipped folder:\n%s", out.String()) + } + if !strings.Contains(hints.String(), "no tests found") || !strings.Contains(hints.String(), "_control") { + t.Errorf("hint writer missing the explanation:\n%s", hints.String()) + } + if strings.Contains(out.String(), "no tests found") { + t.Errorf("explanation leaked to stdout, which scripts parse:\n%s", out.String()) + } +} + +func TestListTestsWithTestsWritesNoHint(t *testing.T) { + dir := t.TempDir() + writeNested(t, dir, "a.test.mdl", nestedTestA) + var out, hints strings.Builder + if err := ListTests([]string{dir}, &out, &hints); err != nil { + t.Fatalf("ListTests: %v", err) + } + if hints.String() != "" { + t.Errorf("hint written for a non-empty listing:\n%s", hints.String()) + } +} + +func TestListTestsEveryFileUnparseableSaysSo(t *testing.T) { + // Mirrors Run: a directory whose every file is malformed is not an empty one. + dir := t.TempDir() + writeNested(t, dir, "bad.test.mdl", badTestB) + var out, hints strings.Builder + if err := ListTests([]string{dir}, &out, &hints); err == nil { + t.Fatal("ListTests returned nil for an unparseable file, want the existing non-zero exit") + } + if !strings.Contains(hints.String(), "no tests could be parsed") || strings.Contains(hints.String(), "no tests found") { + t.Errorf("hint = %q, want the \"no tests could be parsed\" variant", hints.String()) + } +} diff --git a/cmd/mxcli/testrunner/parser_file_isolation_test.go b/cmd/mxcli/testrunner/parser_file_isolation_test.go index 38b31ef95..c2fa4ad73 100644 --- a/cmd/mxcli/testrunner/parser_file_isolation_test.go +++ b/cmd/mxcli/testrunner/parser_file_isolation_test.go @@ -165,7 +165,7 @@ func TestListTestsReportsFileErrorsAndFails(t *testing.T) { writeBytes(t, dir, "b_bad.test.mdl", badTestB) var out strings.Builder - err := ListTests([]string{dir}, &out) + err := ListTests([]string{dir}, &out, nil) if err == nil { t.Fatal("ListTests returned nil, want an error so the command exits non-zero") } diff --git a/cmd/mxcli/testrunner/parser_unreadable_test.go b/cmd/mxcli/testrunner/parser_unreadable_test.go new file mode 100644 index 000000000..a8c675865 --- /dev/null +++ b/cmd/mxcli/testrunner/parser_unreadable_test.go @@ -0,0 +1,336 @@ +// SPDX-License-Identifier: Apache-2.0 + +package testrunner + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +// mendixlabs/mxcli#1368 review: with discovery recursive, one folder that cannot +// be read (a locked folder, a root-owned folder in a mounted volume) must not +// fail the whole run. Below the root the error belongs to that entry; only an +// error on the root itself is fatal. + +func dirEntryOf(t *testing.T, path string) fs.DirEntry { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat %s: %v", path, err) + } + return fs.FileInfoToDirEntry(info) +} + +// fakeWalk replays what filepath.WalkDir does for a tree whose "locked" folder +// cannot be read: the callback is called for the folder first, and again with the +// ReadDir error. It makes the logic testable where chmod 000 does not block +// reading (Windows) or does not apply (root). +func fakeWalk(t *testing.T, permErr error) walkFunc { + t.Helper() + return func(root string, fn fs.WalkDirFunc) error { + sales := filepath.Join(root, "Sales") + locked := filepath.Join(root, "locked") + steps := []struct { + path string + err error + }{ + {root, nil}, + {sales, nil}, + {filepath.Join(sales, "a.test.mdl"), nil}, + {locked, nil}, + {locked, permErr}, + {filepath.Join(root, "z.test.mdl"), nil}, + } + for _, s := range steps { + err := fn(s.path, dirEntryOf(t, s.path), s.err) + if errors.Is(err, fs.SkipDir) { + continue + } + if err != nil { + return err + } + } + return nil + } +} + +func lockedFixture(t *testing.T) string { + t.Helper() + root := filepath.Join(t.TempDir(), "tests") + writeNested(t, root, "Sales/a.test.mdl", nestedTestA) + writeNested(t, root, "z.test.mdl", nestedTestTop) + if err := os.MkdirAll(filepath.Join(root, "locked"), 0o755); err != nil { + t.Fatal(err) + } + return root +} + +func TestWalkTestTreeErrorBelowTheRootIsThatEntrysAndTheWalkContinues(t *testing.T) { + root := lockedFixture(t) + permErr := &fs.PathError{Op: "open", Path: filepath.Join(root, "locked"), Err: fs.ErrPermission} + + var files []string + var errPaths []string + var errs []error + _, err := walkTestTreeWith(fakeWalk(t, permErr), root, + func(path string, d fs.DirEntry) { files = append(files, filepath.Base(path)) }, + func(path string, err error) { errPaths = append(errPaths, path); errs = append(errs, err) }) + if err != nil { + t.Fatalf("walk returned %v, want nil: an error below the root is that entry's", err) + } + if got := strings.Join(files, ","); got != "a.test.mdl,z.test.mdl" { + t.Errorf("files visited = %q, want both readable files", got) + } + if len(errPaths) != 1 || errPaths[0] != filepath.Join(root, "locked") { + t.Fatalf("onErr paths = %v, want just the locked folder", errPaths) + } + if !errors.Is(errs[0], fs.ErrPermission) { + t.Errorf("onErr error = %v, want the permission error", errs[0]) + } +} + +func TestWalkTestTreeErrorBelowTheRootWithoutACallbackIsIgnored(t *testing.T) { + // emptySuiteError walks only to name candidates; it passes no callback. + root := lockedFixture(t) + permErr := &fs.PathError{Op: "open", Path: filepath.Join(root, "locked"), Err: fs.ErrPermission} + var files int + if _, err := walkTestTreeWith(fakeWalk(t, permErr), root, + func(string, fs.DirEntry) { files++ }, nil); err != nil { + t.Fatalf("walk returned %v, want nil", err) + } + if files != 2 { + t.Errorf("files visited = %d, want 2", files) + } +} + +func TestWalkTestTreeErrorOnTheRootIsFatal(t *testing.T) { + root := lockedFixture(t) + rootErr := &fs.PathError{Op: "open", Path: root, Err: fs.ErrPermission} + walk := func(r string, fn fs.WalkDirFunc) error { return fn(r, nil, rootErr) } + called := false + _, err := walkTestTreeWith(walk, root, func(string, fs.DirEntry) {}, + func(string, error) { called = true }) + if !errors.Is(err, fs.ErrPermission) { + t.Fatalf("walk returned %v, want the root's permission error", err) + } + if called { + t.Error("the root's error was handed to onErr; it must fail the run instead") + } +} + +func TestParseTestDirUnreadableSubfolderKeepsTheOtherTests(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod 000 does not stop a directory being read on Windows; the walk logic is covered by TestWalkTestTreeErrorBelowTheRootIsThatEntrysAndTheWalkContinues") + } + if os.Geteuid() == 0 { + t.Skip("running as root: chmod 000 does not stop root reading the folder") + } + root := lockedFixture(t) + locked := filepath.Join(root, "locked") + if err := os.Chmod(locked, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(locked, 0o755) }) + + suite, err := ParseTestDir(root) + if err != nil { + t.Fatalf("ParseTestDir returned a hard error: %v", err) + } + if len(suite.Tests) != 2 || suite.FilesRead != 2 { + t.Errorf("tests = %d, FilesRead = %d, want the 2 readable files kept", len(suite.Tests), suite.FilesRead) + } + if len(suite.FileErrors) != 1 || !suite.FileErrors[0].Dir || suite.FileErrors[0].Path != locked { + t.Fatalf("FileErrors = %+v, want exactly the locked folder, marked as a folder", suite.FileErrors) + } + if !errors.Is(suite.FileErrors[0].Err, fs.ErrPermission) { + t.Errorf("error = %v, want a permission error", suite.FileErrors[0].Err) + } + // A folder that cannot be read is not an empty one, and the run stays red. + if res := suiteFileErrorResults(suite.FileErrors); len(res) != 1 || res[0].Status != StatusError { + t.Errorf("results = %+v, want one ERROR so the run is red", res) + } +} + +func TestParseTestDirSymlinkedRootReportsThePathTheUserTyped(t *testing.T) { + target := filepath.Join(t.TempDir(), "realtests") + writeNested(t, target, "A/a.test.mdl", nestedTestA) + writeNested(t, target, "B/bad.test.mdl", badTestB) + writeNested(t, target, "_control/c.test.mdl", nestedControl) + link := filepath.Join(t.TempDir(), "linked") + if err := os.Symlink(target, link); err != nil { + t.Skipf("cannot create a symlink here: %v", err) + } + + suite, err := ParseTestDir(link) + if err != nil { + t.Fatalf("ParseTestDir: %v", err) + } + if len(suite.Tests) != 1 || !strings.HasPrefix(suite.Tests[0].SourceFile, link) { + t.Errorf("tests = %+v, want one test whose SourceFile is under %s", suite.Tests, link) + } + if len(suite.FileErrors) != 1 || !strings.HasPrefix(suite.FileErrors[0].Path, link) { + t.Errorf("FileErrors = %+v, want the bad file under %s", suite.FileErrors, link) + } + if len(suite.SkippedDirs) != 1 || !strings.HasPrefix(suite.SkippedDirs[0], link) { + t.Errorf("SkippedDirs = %v, want the _ folder under %s", suite.SkippedDirs, link) + } + for _, p := range append(suite.SkippedDirs, suite.FileErrors[0].Path, suite.Tests[0].SourceFile) { + if strings.Contains(p, "realtests") { + t.Errorf("path %q shows the resolved target, not the path typed", p) + } + } +} + +func TestWalkTestTreeSymlinkedRootReportsErrorsUnderTheTypedName(t *testing.T) { + target := filepath.Join(t.TempDir(), "realtests") + writeNested(t, target, "Sales/a.test.mdl", nestedTestA) + writeNested(t, target, "z.test.mdl", nestedTestTop) + if err := os.MkdirAll(filepath.Join(target, "locked"), 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(t.TempDir(), "linked") + if err := os.Symlink(target, link); err != nil { + t.Skipf("cannot create a symlink here: %v", err) + } + resolved, err := filepath.EvalSymlinks(link) + if err != nil { + t.Fatal(err) + } + permErr := &fs.PathError{Op: "open", Path: filepath.Join(resolved, "locked"), Err: fs.ErrPermission} + + var gotPath string + var gotErr error + _, err = walkTestTreeWith(fakeWalk(t, permErr), link, func(string, fs.DirEntry) {}, + func(path string, err error) { gotPath, gotErr = path, err }) + if err != nil { + t.Fatalf("walk: %v", err) + } + if want := filepath.Join(link, "locked"); gotPath != want { + t.Errorf("onErr path = %q, want %q", gotPath, want) + } + if gotErr == nil || strings.Contains(gotErr.Error(), "realtests") || !strings.Contains(gotErr.Error(), "linked") { + t.Errorf("error text = %v, want the path the user typed, not the resolved one", gotErr) + } + if !errors.Is(gotErr, fs.ErrPermission) { + t.Errorf("rewriting the path lost the cause: %v", gotErr) + } +} + +// The messages say what happened. "file(s) failed to parse" for a folder that +// could not be read sends the reader to a parse error that does not exist. +func TestFailureMessagesDistinguishFoldersFromFiles(t *testing.T) { + dirErr := FileError{Path: "tests/locked", Err: &fs.PathError{Op: "open", Path: "tests/locked", Err: fs.ErrPermission}, Dir: true} + fileErr := FileError{Path: "tests/bad.test.mdl", Err: errors.New("bad block")} + + if got := noTestsParsedError([]FileError{dirErr}).Error(); !strings.Contains(got, "could not be read") || strings.Contains(got, "parse") { + t.Errorf("folders only: %q, want \"could not be read\" and no mention of parsing", got) + } + if got := noTestsParsedError([]FileError{fileErr}).Error(); got != "no tests could be parsed: 1 file(s) failed to parse" { + t.Errorf("files only: %q, want the unchanged message", got) + } + got := noTestsParsedError([]FileError{fileErr, dirErr}).Error() + if !strings.Contains(got, "1 file(s) failed to parse") || !strings.Contains(got, "1 folder(s) could not be read") { + t.Errorf("mixed: %q, want both counts", got) + } + + var out strings.Builder + err := reportFileErrors([]FileError{dirErr}, &out) + if err == nil || strings.Contains(err.Error(), "parsed") || !strings.Contains(err.Error(), "folder(s) could not be read") { + t.Errorf("reportFileErrors error = %v, want a folder wording", err) + } + if strings.Contains(out.String(), "could not be parsed") || !strings.Contains(out.String(), "tests/locked") { + t.Errorf("report:\n%s", out.String()) + } + + res := suiteFileErrorResults([]FileError{dirErr, fileErr}) + if !strings.Contains(res[0].Name, "folder could not be read") || !strings.Contains(res[1].Name, "file could not be parsed") { + t.Errorf("result names = %q, %q", res[0].Name, res[1].Name) + } +} + +// testFilesMTime shares discovery with the parser, so it cannot disagree with it +// about what is a test: hidden and "_" folders below the root are not entered, +// and the root is entered whatever it is called. +func TestTestFilesMTimeFollowsTheDiscoveryRules(t *testing.T) { + old := time.Now().Add(-2 * time.Hour) + now := time.Now() + touch := func(path string, at time.Time) { + t.Helper() + if err := os.Chtimes(path, at, at); err != nil { + t.Fatal(err) + } + } + + dir := filepath.Join(t.TempDir(), "tests") + writeNested(t, dir, "A/a.test.mdl", nestedTestA) + writeNested(t, dir, ".hidden/h.test.mdl", nestedHidden) + writeNested(t, dir, "_control/c.test.mdl", nestedControl) + for _, f := range []string{"A/a.test.mdl", ".hidden/h.test.mdl", "_control/c.test.mdl"} { + touch(filepath.Join(dir, filepath.FromSlash(f)), old) + } + for _, d := range []string{"A", ".hidden", "_control", "."} { + touch(filepath.Join(dir, d), old) + } + before := testFilesMTime([]string{dir}) + + touch(filepath.Join(dir, ".hidden", "h.test.mdl"), now) + touch(filepath.Join(dir, "_control", "c.test.mdl"), now) + for _, d := range []string{"A", ".hidden", "_control", "."} { + touch(filepath.Join(dir, d), old) + } + if after := testFilesMTime([]string{dir}); after.After(before) { + t.Error("an edit in a hidden or _ folder moved the change signal, but discovery never reads those") + } + + touch(filepath.Join(dir, "A", "a.test.mdl"), now) + touch(filepath.Join(dir, "A"), old) + if after := testFilesMTime([]string{dir}); !after.After(before) { + t.Error("an edit in a subfolder produced no change signal") + } + + // The root itself is read whatever it is called, as for the parser. + hiddenRoot := filepath.Join(t.TempDir(), "_suite") + writeNested(t, hiddenRoot, "x.test.mdl", nestedTestA) + touch(filepath.Join(hiddenRoot, "x.test.mdl"), now) + touch(hiddenRoot, old) + if got := testFilesMTime([]string{hiddenRoot}); got.Before(now.Add(-time.Minute)) { + t.Errorf("a root named with a leading _ was not read: mtime = %v", got) + } +} + +// The mapping from the resolved root back to the path the user typed is pure, so +// it is tested without a symlink (creating one needs a privilege on Windows). +func TestUnderTypedMapsAResolvedRootBackToWhatWasTyped(t *testing.T) { + typed := filepath.Join("work", "tests") + resolved := filepath.Join("mnt", "data", "suites") + + if got, want := underTypedPath(typed, resolved, filepath.Join(resolved, "A", "a.test.mdl")), filepath.Join(typed, "A", "a.test.mdl"); got != want { + t.Errorf("underTypedPath = %q, want %q", got, want) + } + if got := underTypedPath(typed, typed, filepath.Join(typed, "A")); got != filepath.Join(typed, "A") { + t.Errorf("no symlink: path changed to %q", got) + } + // A "./tests" root stays as typed when nothing was resolved. + if got := underTypedPath("./tests", "./tests", "tests/A"); got != "tests/A" { + t.Errorf("no symlink: path rewritten to %q", got) + } + + cause := fs.ErrPermission + err := underTyped(typed, resolved, &fs.PathError{Op: "open", Path: filepath.Join(resolved, "locked"), Err: cause}) + if strings.Contains(err.Error(), "suites") || !strings.Contains(err.Error(), filepath.Join(typed, "locked")) { + t.Errorf("error text = %q, want the typed path", err) + } + if !errors.Is(err, cause) { + t.Errorf("the cause was lost: %v", err) + } + plain := errors.New("not a path error") + if got := underTyped(typed, resolved, plain); got != plain { + t.Errorf("an error that names no path was changed to %v", got) + } +} diff --git a/cmd/mxcli/testrunner/runner.go b/cmd/mxcli/testrunner/runner.go index 0ab909a48..0582d2863 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" @@ -182,6 +183,9 @@ func Run(opts RunOptions) (*SuiteResult, error) { return nil, fmt.Errorf("parsing test files: %w", err) } fmt.Fprintf(w, " Found %d test(s) in %d file(s)\n", len(suite.Tests), suite.FilesRead) + if line := skippedDirsLine(suite.SkippedDirs); line != "" { + fmt.Fprintln(w, line) + } for _, fe := range suite.FileErrors { fmt.Fprintf(w, " ERROR %s: %v\n", fe.Path, fe.Err) } @@ -190,7 +194,7 @@ func Run(opts RunOptions) (*SuiteResult, error) { // A directory whose every file is malformed is not an empty directory, // and must not be reported as one (#903). if len(suite.FileErrors) > 0 { - return nil, fmt.Errorf("no tests could be parsed: %d file(s) failed to parse", len(suite.FileErrors)) + return nil, noTestsParsedError(suite.FileErrors) } return nil, emptySuiteError(opts.TestFiles) } @@ -448,13 +452,27 @@ func writeJUnit(opts RunOptions, result *SuiteResult, w io.Writer) error { } // ListTests parses test files and prints the test names without executing. -func ListTests(files []string, w io.Writer) error { +// +// When nothing was found it also says why, on hints (stderr for the command, so +// a script reading the listing on w is unaffected; nil discards it). The exit +// status does not change: listing an empty tree is not an error, a run of one is +// (#1363). +func ListTests(files []string, w, hints io.Writer) error { suite, err := parseTestFiles(files) if err != nil { return err } fmt.Fprintf(w, "Found %d test(s):\n", len(suite.Tests)) + if len(suite.Tests) == 0 && hints != nil { + // Same decision as Run: a directory whose every file is malformed is not + // an empty one. + if len(suite.FileErrors) > 0 { + fmt.Fprintf(hints, "%v\n", noTestsParsedError(suite.FileErrors)) + } else { + fmt.Fprintf(hints, "%v\n", emptySuiteError(files)) + } + } for _, tc := range suite.Tests { fmt.Fprintf(w, " %s: %s\n", tc.ID, tc.Name) for _, flow := range tc.Setups { @@ -480,21 +498,55 @@ func ListTests(files []string, w io.Writer) error { fmt.Fprintf(w, " (no assertions — this test can only report that the body did not throw)\n") } } + // After the list, so the colon of the Found line still introduces the tests. + if line := skippedDirsLine(suite.SkippedDirs); line != "" { + fmt.Fprintln(w, line) + } return reportFileErrors(suite.FileErrors, w) } -// reportFileErrors prints the files that could not be parsed and returns an -// error when there were any, so listing a partly-readable directory exits -// non-zero instead of looking like a clean listing. +// reportFileErrors prints the files that could not be parsed and the folders +// that could not be read, and returns an error when there were any, so listing a +// partly-readable directory exits non-zero instead of looking like a clean +// listing. func reportFileErrors(errs []FileError, w io.Writer) error { if len(errs) == 0 { return nil } - fmt.Fprintf(w, "\n%d file(s) could not be parsed:\n", len(errs)) + summary := failureSummary(errs) + fmt.Fprintf(w, "\n%s:\n", summary) for _, fe := range errs { fmt.Fprintf(w, " ERROR %s: %v\n", fe.Path, fe.Err) } - return fmt.Errorf("%d test file(s) could not be parsed", len(errs)) + return errors.New(summary) +} + +// failureSummary counts what discovery could not use, in words that fit each +// kind: a test file fails to parse, a folder fails to be read. Calling an +// unreadable folder a parse failure sends the reader looking for a syntax error +// that is not there. +func failureSummary(errs []FileError) string { + files, dirs := countFailures(errs) + var parts []string + if files > 0 { + parts = append(parts, fmt.Sprintf("%d test file(s) could not be parsed", files)) + } + if dirs > 0 { + parts = append(parts, fmt.Sprintf("%d folder(s) could not be read", dirs)) + } + return strings.Join(parts, " and ") +} + +// countFailures splits errs into unparseable test files and unreadable folders. +func countFailures(errs []FileError) (files, dirs int) { + for _, fe := range errs { + if fe.Dir { + dirs++ + } else { + files++ + } + } + return files, dirs } // suiteFileErrorResults turns unparseable files into ERROR results so they are @@ -504,8 +556,12 @@ func reportFileErrors(errs []FileError, w io.Writer) error { func suiteFileErrorResults(errs []FileError) []TestResult { out := make([]TestResult, 0, len(errs)) for _, fe := range errs { + what := "file could not be parsed" + if fe.Dir { + what = "folder could not be read" + } out = append(out, TestResult{ - Name: fmt.Sprintf("%s (file could not be parsed)", filepath.Base(fe.Path)), + Name: fmt.Sprintf("%s (%s)", filepath.Base(fe.Path), what), Status: StatusError, Message: fe.Err.Error(), SourceFile: fe.Path, @@ -537,6 +593,7 @@ func parseTestFiles(paths []string) (*TestSuite, error) { combined.Tests = append(combined.Tests, dirSuite.Tests...) combined.FileErrors = append(combined.FileErrors, dirSuite.FileErrors...) combined.FilesRead += dirSuite.FilesRead + combined.SkippedDirs = append(combined.SkippedDirs, dirSuite.SkippedDirs...) } else { combined.FilesRead++ fileSuite, err := ParseTestFile(path) @@ -554,6 +611,8 @@ func parseTestFiles(paths []string) (*TestSuite, error) { } } + combined.SkippedDirs = sortedUnique(combined.SkippedDirs) + // Re-number test IDs to be globally unique for i := range combined.Tests { combined.Tests[i].ID = fmt.Sprintf("test_%d", i+1) @@ -1016,42 +1075,113 @@ func runCompose(dockerDir string, args ...string) error { // always the intended test under the wrong name, and printing it turns a dead end // into a rename. func emptySuiteError(paths []string) error { - var skipped []string + var skipped, skippedDirs []string for _, path := range paths { info, err := os.Stat(path) 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. + dirs, _ := 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) } - } + }, nil) + skippedDirs = append(skippedDirs, dirs...) } sort.Strings(skipped) + skippedDirs = sortedUnique(skippedDirs) + var msg string if len(skipped) == 0 { - return fmt.Errorf("no tests found: a test file is named *.test.mdl or *.test.md, " + - "and holds one or more `/** @test ... */` blocks") + msg = "no tests found: a test file is named *.test.mdl or *.test.md, " + + "and holds one or more `/** @test ... */` blocks" + } else { + named, more := capNamed(skipped) + msg = fmt.Sprintf("no tests found: discovery matches on the file NAME, so these were not read%s:\n"+ + " %s\n"+ + "Rename one to *.test.mdl (or *.test.md) to have it picked up", + more, strings.Join(named, "\n ")) + } + if len(skippedDirs) > 0 { + // The suites may sit in a folder that was left out on purpose. + named, more := capNamed(slashed(skippedDirs)) + msg += fmt.Sprintf("\nFolders starting with _ are skipped below the folder you name%s; name one to run it:\n %s", + more, strings.Join(named, "\n ")) + } + return errors.New(msg) +} + +// noTestsParsedError is the run's answer when files or folders were found and +// none yielded a test: not an empty directory, so not emptySuiteError. +func noTestsParsedError(errs []FileError) error { + files, dirs := countFailures(errs) + var parts []string + if files > 0 { + parts = append(parts, fmt.Sprintf("%d file(s) failed to parse", files)) + } + if dirs > 0 { + parts = append(parts, fmt.Sprintf("%d folder(s) could not be read", dirs)) + } + if files == 0 { + return fmt.Errorf("no tests could be read: %s", strings.Join(parts, " and ")) } - const maxNamed = 5 - named := skipped + return fmt.Errorf("no tests could be parsed: %s", strings.Join(parts, " and ")) +} + +// maxNamed is how many paths a report names before "and N more". +const maxNamed = 5 + +// capNamed returns at most maxNamed of items and, when some were left out, the +// text " (and N more)" to append to the sentence that lists them. +func capNamed(items []string) (named []string, more string) { + named = items if len(named) > maxNamed { named = named[:maxNamed] } - more := "" - if len(skipped) > len(named) { - more = fmt.Sprintf(" (and %d more)", len(skipped)-len(named)) + if len(items) > len(named) { + more = fmt.Sprintf(" (and %d more)", len(items)-len(named)) + } + return named, more +} + +// skippedDirsLine is the one line that says discovery left folders out. The run +// and --list both print it after "Found N test(s)", so nothing disappears +// silently. Empty when there is nothing to say. +func skippedDirsLine(dirs []string) string { + if len(dirs) == 0 { + return "" } - return fmt.Errorf("no tests found: discovery matches on the file NAME, so these were not read%s:\n"+ - " %s\n"+ - "Rename one to *.test.mdl (or *.test.md) to have it picked up", - more, strings.Join(named, "\n ")) + named, more := capNamed(slashed(dirs)) + return fmt.Sprintf(" Skipped folder(s) starting with _: %s%s (name one explicitly to run it)", + strings.Join(named, ", "), more) +} + +// slashed returns paths with forward slashes, so a line reads the same on every +// platform. +func slashed(paths []string) []string { + out := make([]string, len(paths)) + for i, p := range paths { + out[i] = filepath.ToSlash(p) + } + return out +} + +// sortedUnique sorts items in place and returns the distinct values. +func sortedUnique(items []string) []string { + if len(items) == 0 { + return nil + } + sort.Strings(items) + out := items[:1] + for _, it := range items[1:] { + if it != out[len(out)-1] { + out = append(out, it) + } + } + return out } diff --git a/cmd/mxcli/testrunner/watch.go b/cmd/mxcli/testrunner/watch.go index 84b4e5959..bd7e3e5da 100644 --- a/cmd/mxcli/testrunner/watch.go +++ b/cmd/mxcli/testrunner/watch.go @@ -5,9 +5,9 @@ package testrunner import ( "fmt" "io" + "io/fs" "os" "os/signal" - "path/filepath" "syscall" "time" @@ -169,7 +169,10 @@ func watchMTime(opts RunOptions) time.Time { // // A directory is walked rather than stat'ed: on Linux a directory's own mtime // changes when an entry is created or removed but *not* when an existing file is -// edited in place, which is the common case this loop exists to catch. +// edited in place, which is the common case this loop exists to catch. It is +// walked by walkTestTree, the walk the parser uses, so the two cannot disagree +// about which files are tests (hidden and "_" folders below the root, and the +// root itself, are treated alike). func testFilesMTime(paths []string) time.Time { var newest time.Time for _, p := range paths { @@ -183,15 +186,16 @@ func testFilesMTime(paths []string) time.Time { } continue } - filepath.WalkDir(p, func(path string, d os.DirEntry, err error) error { - if err != nil || d.IsDir() || !isTestFile(d.Name()) { - return nil //nolint:nilerr // an unreadable entry is not a change + // An unreadable entry is not a change: no onErr, and the root's own error + // leaves newest as it was. + _, _ = walkTestTree(p, func(path string, d fs.DirEntry) { + if !isTestFile(d.Name()) { + return } if fi, err := d.Info(); err == nil && fi.ModTime().After(newest) { newest = fi.ModTime() } - return nil - }) + }, nil) // Catch a deletion too: the directory's own mtime moves when an entry // goes away, and the walk above cannot see a file that is no longer there. if info.ModTime().After(newest) { diff --git a/docs-site/src/internals/widget-engine.md b/docs-site/src/internals/widget-engine.md index 630f42b33..bd61096bb 100644 --- a/docs-site/src/internals/widget-engine.md +++ b/docs-site/src/internals/widget-engine.md @@ -228,6 +228,8 @@ Definitions are loaded in priority order. Higher-priority definitions override l Definitions are looked up by MDL name (case-insensitive): `COMBOBOX`, `GALLERY`, or any custom name. +**DATAGRID has no embedded definition, on purpose.** Its `.def.json` is generated from the project's own package, and an embedded one would sit ahead of that package for every write (`RefreshWidgetDefinitions` skips a widget id the registry already holds). So without a project the registry has no definition for it, and `mxcli check` reads the property list of the template mxcli embeds for the widget (`sdk/widgets/templates/`, via `embeddedSchemaFor`): a key the template declares is accepted and its declared type decides whether an expression is allowed (MDL-WIDGET42). The template is the oldest-supported baseline, so a key it does not declare is reported as one that cannot be verified without a project (MDL-WIDGET07), not as one that is dropped. + ### Validation at Load Time When a definition is loaded, it is validated: diff --git a/docs-site/src/language/alter-page.md b/docs-site/src/language/alter-page.md index 73a818739..c8b532d60 100644 --- a/docs-site/src/language/alter-page.md +++ b/docs-site/src/language/alter-page.md @@ -50,7 +50,7 @@ ALTER PAGE Module.EditPage { | `Class` | CSS class names | `SET (Class: 'card p-3') ON cMain` | | `Style` | Inline CSS | `SET (Style: 'margin: 8px;') ON cBox` | | `DynamicClasses` | Runtime-computed CSS classes | `SET (DynamicClasses: if $currentObject/IsActive then 'is-active' else '') ON cMain` | -| `Editable` | Editability mode | `SET (Editable: ReadOnly) ON txtEmail` | +| `Editable` | Editability: `Always`, `Never`, or a bare condition expression (the quoted form is refused in `SET`) | `SET (Editable: Never) ON txtEmail` | | `Visible` | Visibility expression | `SET (Visible: '$showField') ON txtPhone` | | `Name` | Widget name | `SET (Name: 'txtFullName') ON txtName` | diff --git a/docs-site/src/language/microflow-structure.md b/docs-site/src/language/microflow-structure.md index c3948bd19..a4aa0d481 100644 --- a/docs-site/src/language/microflow-structure.md +++ b/docs-site/src/language/microflow-structure.md @@ -117,6 +117,17 @@ RETURN $Order; RETURN $FilteredOrders; ``` +### Returning an Enumeration + +Declare the type in the signature with `returns enum Module.Name`; `returns Enumeration(Module.Name)` is the same. A bare `returns Module.Name` is also accepted when no entity has that name and an enumeration does. If an entity and an enumeration share the name, the entity wins, so write `enum` when a name could be either. Nanoflows and rules take the same spellings, and a rule must return a Boolean or an enumeration. + +```sql +CREATE MICROFLOW Shop.SUB_Pick () RETURNS enum Shop.Variant AS $Variant +BEGIN + RETURN Shop.Variant.Original; +END; +``` + ### Returning Nothing If the microflow returns nothing (void), you can omit the `RETURN` or use: diff --git a/docs-site/src/language/pages.md b/docs-site/src/language/pages.md index db83a9221..8faeac003 100644 --- a/docs-site/src/language/pages.md +++ b/docs-site/src/language/pages.md @@ -144,7 +144,9 @@ Input widgets can be conditionally editable: TEXTBOX txtStatus (Label: 'Status', Attribute: Status, Editable: $currentObject/Status != 'Closed') ``` -Static values: `Editable: Never`, `Editable: Always`. +Static values: `Editable: Never`, `Editable: Always`; `Editable: false` and `Editable: true` mean `Never` and `Always`. + +The condition may also be written as a quoted string, exactly as for `Visible:` — `Editable: '$currentObject/Status != ''Closed'''` stores the same condition as the bare form above (a quote inside the string is doubled). Any other value, such as a misspelt `Neever`, is refused by `mxcli check` (`MDL-WIDGET19`) instead of leaving the field editable. ### Pluggable Widgets diff --git a/docs-site/src/language/widget-types.md b/docs-site/src/language/widget-types.md index 0dd3e3f3e..8c819ce0b 100644 --- a/docs-site/src/language/widget-types.md +++ b/docs-site/src/language/widget-types.md @@ -127,6 +127,33 @@ DATAGRID dgOrders (DataSource: DATABASE Sales.Order, PageSize: 20) { | `PagingPosition` | Position of paging controls | `PagingPosition: both` | | `ShowPagingButtons` | When to show paging buttons | `ShowPagingButtons: auto` | +**Any other Data grid 2 setting** can be set by its widget key — the name the +widget's own definition gives it (`DESCRIBE WIDGET TYPE datagrid` lists them with +their types, defaults and allowed values). The grid's personalization, for +example, is stored in the browser or in an attribute of the surrounding object: + +```sql +-- in the browser's local storage +DATAGRID dgLocal (DataSource: DATABASE Sales.Order, + configurationStorageType: localStorage, + storeFiltersInPersonalization: true) + +-- in an attribute of the data view the grid sits in +DATAVIEW dvPref (DataSource: $Pref) { + DATAGRID dgAttr (DataSource: DATABASE Sales.Order, + configurationStorageType: attribute, + configurationAttribute: Config) +} +``` + +`DESCRIBE PAGE` prints every such setting whose stored value differs from the +widget's default, under the same key, so describing a page and executing the +description gives back the page it started from. A setting it cannot spell +(an attribute reached over an association) is named in a `-- NOT re-executable` +comment instead of being dropped without a word. Column settings with no keyword +of their own (`minWidth`, `minWidthLimit`, `allowEventPropagation`, …) are +written and described the same way, and so are a Gallery's. + **Column properties:** | Property | Values | Default | Description | @@ -259,7 +286,7 @@ All input widgets share common properties: |----------|-------------|---------| | `Label` | Field label text | `Label: 'Customer Name'` | | `Attribute` | Entity attribute to bind to | `Attribute: Name` | -| `Editable` | Editability mode | `Editable: ReadOnly` | +| `Editable` | Editability: `Always`, `Never`, `true`/`false`, or a condition expression (bare or quoted) | `Editable: Never` | | `Visible` | Visibility expression | `Visible: '$showField'` | Each input widget takes only some attribute types. Measured with mxbuild 11.14.0, @@ -641,7 +668,7 @@ These properties are shared across many widget types: | `DynamicClasses` | Runtime-computed CSS classes (expression; stacks on `Class`) | `DynamicClasses: if $currentObject/IsActive then 'is-active' else ''` | | `DesignProperties` | Atlas design properties | `DesignProperties: ('Spacing top': 'Large', 'Full width': ON)` | | `Visible` | Visibility expression | `Visible: '$showSection'` | -| `Editable` | Editability mode | `Editable: ReadOnly` | +| `Editable` | Editability: `Always`, `Never`, `true`/`false`, or a condition expression (bare or quoted) | `Editable: Never` | ## See Also diff --git a/docs-site/src/reference/microflow/create-javascript-action.md b/docs-site/src/reference/microflow/create-javascript-action.md index f75cd13ed..109c9a4af 100644 --- a/docs-site/src/reference/microflow/create-javascript-action.md +++ b/docs-site/src/reference/microflow/create-javascript-action.md @@ -32,6 +32,16 @@ If `OR MODIFY` is specified and the action already exists, its parameters, retur type, exposed-as settings, platform, and body are updated in place; the UUID is preserved. +### Parameter names in the body + +The model keeps a parameter's name as declared, but the generated `.js` file uses +the name Studio Pro and mxbuild generate: the **first character lower-cased**, +nothing else. The body is written against that name. `ReadOnly` is `readOnly`, +`Url` is `url`, `URL` is `uRL` (not `url`), `MyID` is `myID`, `_Lead` stays +`_Lead`. mxcli writes the signature this way and does not rewrite the body, so a +body that uses the declared spelling (`ReadOnly`) throws a `ReferenceError` when +the action runs. `DESCRIBE` still prints the declared names. + ### Platform The optional `PLATFORM` clause restricts where the action runs: `Web`, `Native`, @@ -96,7 +106,7 @@ CREATE JAVASCRIPT ACTION MyModule.JSA_Add ( B: Integer NOT NULL ) RETURNS Integer AS $$ - return Promise.resolve(A + B); + return Promise.resolve(a + b); $$; ``` @@ -110,7 +120,7 @@ CREATE JAVASCRIPT ACTION MyModule.JSA_ShowToast ( EXPOSED AS 'Show Toast' IN 'UI' PLATFORM Native AS $$ - console.log(Message); + console.log(message); return Promise.resolve(true); $$; ``` @@ -124,7 +134,7 @@ CREATE OR MODIFY JAVASCRIPT ACTION MyModule.JSA_Add ( C: Integer ) RETURNS Integer AS $$ - return Promise.resolve(A + B + (C || 0)); + return Promise.resolve(a + b + (c || 0)); $$; ``` diff --git a/docs-site/src/reference/page/create-page.md b/docs-site/src/reference/page/create-page.md index dd74a5ed2..3b1c0d20f 100644 --- a/docs-site/src/reference/page/create-page.md +++ b/docs-site/src/reference/page/create-page.md @@ -218,7 +218,7 @@ These properties are available on most widget types: | `Class` | CSS class names | `Class: 'card mx-spacing-top-large'` | | `Style` | Inline CSS | `Style: 'padding: 16px;'` | | `DynamicClasses` | Runtime-computed CSS classes (expression; stacks on `Class`) | `DynamicClasses: if $currentObject/IsActive then 'is-active' else ''` | -| `Editable` | Edit control | `Editable: NEVER` or `Editable: ALWAYS` | +| `Editable` | Edit control: `Always`, `Never`, or a condition expression (bare or quoted) | `Editable: NEVER` or `Editable: $currentObject/Locked = false` | | `Visible` | Visibility expression | `Visible: '$showField'` | | `DesignProperties` | Atlas design properties | `DesignProperties: ('Spacing top': 'Large')` | diff --git a/docs-site/src/tools/running-tests.md b/docs-site/src/tools/running-tests.md index 5f37644a6..0a11a1881 100644 --- a/docs-site/src/tools/running-tests.md +++ b/docs-site/src/tools/running-tests.md @@ -82,7 +82,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 @@ -92,6 +92,42 @@ mxcli test tests/sales.test.mdl -p app.mpr mxcli test tests/integration.test.md -p app.mpr ``` +Discovery walks the directory and every subfolder, in path order. Below the +folder you name it skips folders starting with `.` (`.git`, `.mxcli`) and folders +starting with `_`, the Go convention. Use `_` for a suite that should stay out of +the default run, such as a positive control that is meant to fail: + +```text +tests/unit/Sales/orders.test.mdl # run by `mxcli test tests/` +tests/unit/_control/control.test.mdl # skipped, and named in the output +``` + +```bash +# Run the control on its own: the folder you name is always read +mxcli test tests/unit/_control -p app.mpr +``` + +Skipped `_` folders are named in the output (after the `Found N test(s)` line of +a run, at the end of a `--list`), so they do not disappear silently. A run that +finds no tests exits 1. `--list` exits 0 even when it finds none, and then prints +the reason, including any `_` folders it skipped, on stderr. + +Discovery is fully recursive and has no other skip list, so point `mxcli test` at +the folder that holds your tests (`tests/`), not at the project root. From the +project root, `mxcli test .` searches `deployment/`, `node_modules/`, `javasource/`, `vendorlib/` +and every other folder, which is slow, and runs any stray `*.test.mdl` it finds +there. + +Two more rules follow from the walk: + +- A symlinked folder **below** the folder you name is not followed (the walk + reports it as a file, so it is ignored without a message). A symlinked folder + you name yourself is followed, and errors and listings show the path you typed. +- A folder that cannot be read (a locked folder, a root-owned folder in a mounted + volume) is reported by name as "could not be read", the run exits non-zero, and + the tests in the other folders still run. Only a root that cannot be read stops + the run. + ## Choosing a mode | | Boot cost per run | Database | Needs Docker | diff --git a/docs-site/src/tools/testing.md b/docs-site/src/tools/testing.md index a5ac6dc44..2dbee7dc3 100644 --- a/docs-site/src/tools/testing.md +++ b/docs-site/src/tools/testing.md @@ -26,7 +26,8 @@ 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 (folders starting +# with _ are skipped; name one to run it: mxcli test tests/unit/_control) mxcli test tests/ -p app.mpr # Run a specific test file diff --git a/mdl-examples/bug-tests/1560-js-action-parameter-case.mdl b/mdl-examples/bug-tests/1560-js-action-parameter-case.mdl new file mode 100644 index 000000000..ed2b695d4 --- /dev/null +++ b/mdl-examples/bug-tests/1560-js-action-parameter-case.mdl @@ -0,0 +1,68 @@ +mdl 1; +-- ============================================================================ +-- Bug: a JavaScript action's body threw ReferenceError for its own parameters +-- ============================================================================ +-- +-- Symptom +-- An action declared with `Url: String, ReadOnly: Boolean` and a body that +-- uses `Url` and `ReadOnly` was written with +-- +-- export async function JS_Open(Url, ReadOnly) +-- +-- `mxcli check` passed and so did `mx check`. After a build, mxbuild had +-- regenerated the signature as `JS_Open(url, readOnly)` and kept the body, so +-- `Url` and `ReadOnly` no longer existed and the action threw a +-- ReferenceError when a user clicked it. Nothing warned. The same happened to +-- an action authored in Studio Pro the first time it was changed through +-- `create or modify`: `Geocode(address, ...)` became `Geocode(Address, ...)` +-- with a body that still read `address`. +-- +-- Cause +-- mxbuild and Studio Pro lower-case the FIRST character of each parameter name +-- in the generated .js (signature and JSDoc @param); the model keeps the +-- declared name. mxcli wrote the declared name, so the file it wrote was not +-- the file the build produces. +-- +-- Fix +-- mxcli writes the name mxbuild writes: only the first character is +-- lower-cased (URL -> uRL, MyID -> myID, _Lead -> _Lead). The model parameter +-- keeps its declared name, so `describe` prints it as declared. Bodies are not +-- rewritten: a body sees the lower-first name. +-- +-- Verify +-- mxcli exec mdl-examples/bug-tests/1560-js-action-parameter-case.mdl -p app.mpr +-- +-- Then the signature in the file is what a build will produce: +-- +-- grep -q "export async function JsParamCase_Open(url, readOnly)" \ +-- javascriptsource/jsparamcase/actions/JsParamCase_Open.js +-- +-- and `describe javascript action JsParamCase.JsParamCase_Open` still prints +-- `Url: String` and `ReadOnly: Boolean`. +-- +-- Running mxbuild on the project must leave that signature as it is; before +-- the fix it rewrote it to lower-first and broke the body. +-- ============================================================================ + +create module JsParamCase; + +create or modify javascript action JsParamCase.JsParamCase_Open( + Url: String, + ReadOnly: Boolean +) returns String +as $$ +// The body uses the names mxbuild generates: first character lower-cased. +return Promise.resolve(url + (readOnly ? " (read only)" : "")); +$$; + +create or modify javascript action JsParamCase.JsParamCase_Names( + URL: String, + MyID: String, + Some_Name: String, + XMLData: String, + _Lead: String, + Obj2Name: String +) returns String +as $$ +return Promise.resolve(uRL + myID + some_Name + xMLData + _Lead + obj2Name); +$$; diff --git a/mdl-examples/bug-tests/1562-enum-return-type.mdl b/mdl-examples/bug-tests/1562-enum-return-type.mdl new file mode 100644 index 000000000..31414e251 --- /dev/null +++ b/mdl-examples/bug-tests/1562-enum-return-type.mdl @@ -0,0 +1,72 @@ +mdl 1; +-- ============================================================================ +-- A bare `returns Module.Name` that names an enumeration +-- ============================================================================ +-- +-- POSITIVE TEST: `mxcli check` (with and without -p --references) and `exec` +-- MUST accept it. +-- +-- The parser cannot tell an entity from an enumeration for a bare qualified +-- name, so it records an entity reference. `exec` resolved that for PARAMETERS +-- (entity first, then enumeration) but not for RETURN TYPES, while `check` +-- resolved both the same way: a script `check` passed failed at exec with +-- "entity 'Shop.Variant' not found for return type". For a rule, check refused +-- it too, with a misleading "a rule must return a Boolean or an enumeration, +-- not Entity". +-- +-- A microflow, a nanoflow and a rule now accept the bare name, and store exactly +-- what `returns enum Module.Name` stores. A name that is both an entity and an +-- enumeration stays the entity, as it does for a parameter. +-- +-- Measured on mxbuild 11.12.5: this script execs and the app checks at 0 errors. + +CREATE MODULE EnumReturn; + +CREATE OR MODIFY ENUMERATION EnumReturn.Variant ( + Original 'Original', + Released 'Released', + None 'None' +); + +CREATE OR MODIFY PERSISTENT ENTITY EnumReturn.Customer ( + "Name": String(50) +); + +-- The bare name, as a parameter (already worked) and as a return type. +CREATE OR MODIFY MICROFLOW EnumReturn.SUB_Pick ($Current: EnumReturn.Variant) +RETURNS EnumReturn.Variant AS $Variant +BEGIN + RETURN EnumReturn.Variant.Original; +END; + +CREATE OR MODIFY NANOFLOW EnumReturn.NF_Pick () +RETURNS EnumReturn.Variant AS $Variant +BEGIN + RETURN EnumReturn.Variant.Released; +END; + +CREATE OR MODIFY RULE EnumReturn.RL_Pick ($Customer: EnumReturn.Customer) +RETURNS EnumReturn.Variant AS $Variant +BEGIN + RETURN EnumReturn.Variant.None; +END; + +-- The explicit spellings, which always worked, store the same thing. +CREATE OR MODIFY MICROFLOW EnumReturn.SUB_PickExplicit () +RETURNS enum EnumReturn.Variant AS $Variant +BEGIN + RETURN EnumReturn.Variant.Original; +END; + +CREATE OR MODIFY MICROFLOW EnumReturn.SUB_PickFunction () +RETURNS Enumeration(EnumReturn.Variant) AS $Variant +BEGIN + RETURN EnumReturn.Variant.Original; +END; + +-- A bare entity name is still an entity. +CREATE OR MODIFY MICROFLOW EnumReturn.SUB_PickCustomer () +RETURNS EnumReturn.Customer AS $Customer +BEGIN + RETURN empty; +END; diff --git a/mdl-examples/bug-tests/1567-quoted-editable-expression.fail.mdl b/mdl-examples/bug-tests/1567-quoted-editable-expression.fail.mdl new file mode 100644 index 000000000..6e1d15c1e --- /dev/null +++ b/mdl-examples/bug-tests/1567-quoted-editable-expression.fail.mdl @@ -0,0 +1,28 @@ +mdl 1; +-- Negative companion of 1567-quoted-editable-expression.mdl: `check` must refuse every +-- Editable value that cannot be stored, naming the accepted spellings, instead of +-- letting the widget stay editable. Each statement below is refused on its own. +-- mendixlabs/mxcli#1567. + +create entity if not exists QEdit.Item (Code: String(50), Locked: Boolean default false); + +-- A misspelt enum word (MDL-WIDGET19). +create or modify page QEdit.Bad_Word (Title: 'Bad', Layout: Atlas_Core.Atlas_Default, Params: ( $Item: QEdit.Item )) { + layoutgrid lg { row { column (DesktopWidth: AutoFill) { + dataview dv (Datasource: $Item) { textbox txtA (Attribute: Code, Editable: 'Neever') } + } } } +}; + +-- A quoted expression that is not an expression (MDL-WIDGET19). +create or modify page QEdit.Bad_Syntax (Title: 'Bad', Layout: Atlas_Core.Atlas_Default, Params: ( $Item: QEdit.Item )) { + layoutgrid lg { row { column (DesktopWidth: AutoFill) { + dataview dv (Datasource: $Item) { textbox txtB (Attribute: Code, Editable: '$currentObject/Locked = = false') } + } } } +}; + +-- A quoted expression that follows an association (MDL-WIDGET13, CE0117). +create or modify page QEdit.Bad_Assoc (Title: 'Bad', Layout: Atlas_Core.Atlas_Default, Params: ( $Item: QEdit.Item )) { + layoutgrid lg { row { column (DesktopWidth: AutoFill) { + dataview dv (Datasource: $Item) { textbox txtC (Attribute: Code, Editable: '$currentObject/QEdit.Item_Item/Code = ''x''') } + } } } +}; diff --git a/mdl-examples/bug-tests/1567-quoted-editable-expression.mdl b/mdl-examples/bug-tests/1567-quoted-editable-expression.mdl new file mode 100644 index 000000000..0da89bb47 --- /dev/null +++ b/mdl-examples/bug-tests/1567-quoted-editable-expression.mdl @@ -0,0 +1,55 @@ +mdl 1; +-- A quoted `Editable: ''` was dropped on write (mendixlabs/mxcli#1567). +-- +-- `Visible: '$currentObject/Locked = false'` was stored as a conditional +-- visibility, because pages.StaticVisibleExpression returns any string that is not +-- true/false as the condition. Editable shares the grammar slot but not the +-- consumer: pages.CanonicalEditability knew only Always | Never | Conditional and +-- the builder skipped every other string without a word. The widget was stored +-- `Editable: Always` with no ConditionalEditabilitySettings, so a field meant to +-- be locked stayed editable -- `check` clean, `exec` reporting "Created page", +-- `mx check` at 0 errors. +-- +-- Now the quoted spelling is the bare expression's twin: both store +-- `Editable: Conditional` plus the condition, and `describe page` prints either +-- as the bare form. `Editable: false` / `true` (also dropped before) store +-- Never / Always. +-- +-- Verify: exec this script, then `describe page QEdit.Item_Edit` -- the first four +-- inputs print their condition bare (`Editable: $currentObject/Locked = false`, +-- the `!= 'x'` form, ...), txtNever and txtFalse print `Editable: Never`, and the +-- stored page passes `mx check`. Values that cannot be stored are refused by +-- `check` instead: see 1567-quoted-editable-expression.fail.mdl. + +create entity if not exists QEdit.Item ( + Code: String(50), + Note: String(200), + Locked: Boolean default false +); + +create or modify page QEdit.Item_Edit ( + Title: 'Item', + Layout: Atlas_Core.Atlas_Default, + Params: ( $Item: QEdit.Item ) +) { + layoutgrid lg { + row { + column (DesktopWidth: AutoFill) { + dataview dv (Datasource: $Item) { + -- Quoted and bare spellings of the same condition store the same thing. + textbox txtQuoted (Attribute: Code, Editable: '$currentObject/Locked = false') + textbox txtBare (Attribute: Code, Editable: $currentObject/Locked = false) + -- A string literal inside the quoted expression has its quotes doubled. + textarea txtLiteral (Attribute: Note, Editable: '$currentObject/Code != ''x''') + checkbox cbLocked (Attribute: Locked, Editable: '$currentObject/Code = empty') + -- The enum words keep their meaning, quoted or not. + textbox txtNever (Attribute: Code, Editable: 'Never') + textbox txtFalse (Attribute: Code, Editable: false) + textbox txtAlways (Attribute: Code, Editable: 'Always') + } + } + } + } +}; + +describe page QEdit.Item_Edit; diff --git a/mdl-examples/bug-tests/1569-navigation-target-not-found.mdl b/mdl-examples/bug-tests/1569-navigation-target-not-found.mdl new file mode 100644 index 000000000..2487c184d --- /dev/null +++ b/mdl-examples/bug-tests/1569-navigation-target-not-found.mdl @@ -0,0 +1,58 @@ +mdl 1; +-- A navigation profile and a menu document name pages and microflows +-- (mendixlabs/mxcli#1569). +-- +-- A navigation statement points at documents in five places: the home page +-- (also one per user role), a home flow, the not-found page and every menu item +-- at any depth. (Its login page may be the theme's static login.html, so it is +-- not resolved.) None of them was resolved: `check -p` said +-- "Check passed!", `exec` wrote the profile, and `mx check` was the first to +-- object: +-- +-- [CE1613] "The selected page 'Shop.NoSuchHome' no longer exists." +-- +-- once for the home page and once per menu item with a missing page or flow. +-- +-- This file is the shape that must PASS: every target exists by the time it is +-- needed, or is created by this script (a navigation written BEFORE the page it +-- names is accepted too, as a call to a flow created further down is: the target +-- is stored by name and a run that rolls back leaves nothing dangling). A target +-- that exists nowhere is only visible with a project, and `make check-mdl` runs +-- without one, so the refused cases are Go tests: +-- mdl/executor/validate_navigation_targets_test.go. + +create module Zz2; + +create page Zz2.Landing ( + Title: 'Landing', + Layout: Atlas_Core.Atlas_Default +) { + dynamictext heading (Content: 'Landing', RenderMode: H1) +}; + +create microflow Zz2.ACT_Open () +begin + log info 'open'; +end; + +-- Created further down than the navigation that opens it. +create or modify navigation Responsive + home page Zz2.Landing +{ + menu item 'Landing' ( OnClick: show page Zz2.Landing, Icon: glyph 57377 ) + menu 'Tools' ( Icon: glyph 57377 ) { + menu item 'Open' ( OnClick: call microflow Zz2.ACT_Open, Icon: glyph 57377 ) + menu item 'Later' ( OnClick: show page Zz2.Later, Icon: glyph 57377 ) + } +}; + +create page Zz2.Later ( + Title: 'Later', + Layout: Atlas_Core.Atlas_Default +) { + dynamictext heading (Content: 'Later', RenderMode: H1) +}; + +create or modify menu Zz2.Main_Menu { + menu item 'Landing' ( OnClick: show page Zz2.Landing, Icon: glyph 57377 ) +}; diff --git a/mdl-examples/bug-tests/1571-describe-context-association.mdl b/mdl-examples/bug-tests/1571-describe-context-association.mdl new file mode 100644 index 000000000..fec2aa8fb --- /dev/null +++ b/mdl-examples/bug-tests/1571-describe-context-association.mdl @@ -0,0 +1,107 @@ +mdl 1; +-- ============================================================================ +-- Bug (mendixlabs/mxcli#1571) — `describe page` named the ASSOCIATION as the +-- context entity of a data container bound over an association: +-- +-- dataview dvCustomer (DataSource: $currentObject/CtxAssoc.Order_Customer) { +-- -- Context: $currentObject (CtxAssoc.Order_Customer) <- the association +-- +-- The datasource reference was used verbatim as the context entity. That is +-- right for a database source (the reference is the entity) and wrong for an +-- association source, whose reference is the association's qualified name — the +-- same mistake the microflow/nanoflow case had (795b-flow-datasource-context- +-- entity.mdl). The page itself was always correct (`mx check` clean); only the +-- comment, and anything resolving names against the context, was wrong. +-- +-- The entity the association reaches is stored on the last hop of the source +-- (EntityRef.Steps[last].DestinationEntity), so describe reads it: the other +-- end for a reference, the element entity for a reference set, and the owner when +-- the page is read from the child's side. +-- +-- Describe output is not asserted by CI for a script (Go tests cover it: +-- mdl/executor/cmd_pages_describe_assoc_context_test.go); this script builds the +-- shapes so the output can be read by hand. +-- +-- Manual verification (needs a project with Atlas_Core): +-- +-- mxcli exec 1571-describe-context-association.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe page CtxAssoc.Probe" +-- mxcli -p app.mpr -c "describe page CtxAssoc.ProbeFromCustomer" +-- +-- Expect, under dvCustomer: -- Context: $currentObject (CtxAssoc.Customer) +-- under lvLines: -- Context: $currentObject (CtxAssoc.OrderLine), $lvLines (selection) +-- under lvOrders (Probe- +-- FromCustomer): -- Context: $currentObject (CtxAssoc.Order), $lvOrders (selection) +-- Before the fix each named the association (CtxAssoc.Order_Customer, +-- CtxAssoc.Order_Line, CtxAssoc.Order_Customer). The `DataSource:` text is +-- unchanged, and describe -> exec still reports "Unchanged page". +-- ============================================================================ + +create module CtxAssoc; +create module role CtxAssoc.User; + +@position(100, 100) +create persistent entity CtxAssoc.Customer ( + Name: string(200) +); + +@position(400, 100) +create persistent entity CtxAssoc.Order ( + Number: string(200) +); + +@position(700, 100) +create persistent entity CtxAssoc.OrderLine ( + Description: string(200) +); + +-- A reference: Order -> Customer. +create association CtxAssoc.Order_Customer from CtxAssoc.Order to CtxAssoc.Customer; + +-- A reference set: Order -> OrderLine. +create association CtxAssoc.Order_Line from CtxAssoc.Order to CtxAssoc.OrderLine type ReferenceSet; + +create or modify page CtxAssoc.Probe ( + Title: 'Probe', + Layout: Atlas_Core.Atlas_Default, + Params: ( $Order: CtxAssoc.Order ) +) { + layoutgrid lg { + row { + column (DesktopWidth: AutoFill) { + dataview dvOrder (DataSource: $Order) { + -- Over a reference: the context is the Customer, not the association. + dataview dvCustomer (DataSource: $currentObject/CtxAssoc.Order_Customer) { + textbox tbName (Label: 'Name', Attribute: Name) + } + -- Over a reference set: the context is one OrderLine. + listview lvLines (DataSource: $currentObject/CtxAssoc.Order_Line) { + textbox tbDescription (Label: 'Description', Attribute: Description, Editable: Never) + } + } + } + } + } +}; + +-- The same reference read from the other side: the context is the Order. +create or modify page CtxAssoc.ProbeFromCustomer ( + Title: 'Probe', + Layout: Atlas_Core.Atlas_Default, + Params: ( $Customer: CtxAssoc.Customer ) +) { + layoutgrid lg { + row { + column (DesktopWidth: AutoFill) { + dataview dvCustomer (DataSource: $Customer) { + listview lvOrders (DataSource: $currentObject/CtxAssoc.Order_Customer) { + textbox tbNumber (Label: 'Number', Attribute: Number, Editable: Never) + } + } + } + } + } +}; + +describe page CtxAssoc.Probe; +describe page CtxAssoc.ProbeFromCustomer; diff --git a/mdl-examples/bug-tests/1573-datagrid-by-key-settings-check-no-project.mdl b/mdl-examples/bug-tests/1573-datagrid-by-key-settings-check-no-project.mdl new file mode 100644 index 000000000..2ae33377e --- /dev/null +++ b/mdl-examples/bug-tests/1573-datagrid-by-key-settings-check-no-project.mdl @@ -0,0 +1,83 @@ +mdl 1; +-- ============================================================================ +-- Bug: `mxcli check` without a project called a Data grid 2's own settings +-- "not recognized and silently dropped" (mendixlabs/mxcli#1573) +-- ============================================================================ +-- +-- A Data grid 2 takes every one of its settings by the widget's own property +-- key. `exec -p` stores each of these and `check -p` accepts them. `check` +-- without a project — which is what `make check-mdl` runs on this file — warned +-- MDL-WIDGET07 for each of them ("property `configurationAttribute` is not +-- recognized and will be silently dropped on write"), and REFUSED the two +-- Expression-typed ones as MDL-WIDGET42 ("a plain-value property cannot hold +-- the expression"), which makes the file fail `check` outright. +-- +-- Without a project DATAGRID has no definition (it is generated from the +-- project's own package), so the check judged its keys against the vocabulary +-- of the built-in widgets. It now reads the property list of the Data grid 2 +-- template mxcli embeds: a key that template declares is accepted, and an +-- Expression-typed one takes an expression. A key it does not declare is still +-- reported, now as one that cannot be verified without a project — the +-- template is a baseline, not the installed widget. +-- +-- This file must pass `mxcli check` with no MDL-WIDGET07 and no MDL-WIDGET42. +-- The negative case (a misspelled key still warns) is a Go test: +-- mdl/executor/validate_widget_embedded_schema_test.go. +-- ============================================================================ + +CREATE MODULE BugDgCheck; + +CREATE OR MODIFY PERSISTENT ENTITY BugDgCheck.Ord ( + Code: String(20), + Amount: Decimal, + Open: Boolean +); + +CREATE OR MODIFY PERSISTENT ENTITY BugDgCheck.GridPref ( + Config: String(unlimited) +); + +-- Keyword settings and settings by key, grid and column. +CREATE PAGE BugDgCheck.Orders_Settings (Title: 'Settings', Layout: Atlas_Core.Atlas_Default) { + datagrid dgSettings ( + DataSource: database BugDgCheck.Ord, + PageSize: 25, + Pagination: virtualScrolling, + Selection: Single, + refreshInterval: 30, + columnsHidable: false, + showEmptyPlaceholder: custom, + rowClass: if $currentObject/Open then 'row-open' else 'row-closed', + exportDialogLabel: 'Exporting' + ) { + column (Attribute: Code, Caption: 'Code', Sortable: true, columnClass: if $currentObject/Open then 'is-open' else '') { textfilter tf } + column (Attribute: Amount, ShowContentAs: customContent, ColumnWidth: autoFit, minWidth: manual, minWidthLimit: 150, allowEventPropagation: false) { + dynamictext amt (Content: 'amt') + } + } +}; + +-- Personalization kept in the browser. +CREATE PAGE BugDgCheck.Orders_Local (Title: 'Local', Layout: Atlas_Core.Atlas_Default) { + datagrid dgLocal ( + DataSource: database BugDgCheck.Ord, + configurationStorageType: localStorage, + storeFiltersInPersonalization: true + ) { + column (Attribute: Code) { textfilter tf } + } +}; + +-- Personalization kept in an attribute of the surrounding object. +CREATE PAGE BugDgCheck.Orders_Attribute (Title: 'Attribute', Layout: Atlas_Core.Atlas_Default, Params: ( $Pref: BugDgCheck.GridPref )) { + dataview dvPref (DataSource: $Pref) { + datagrid dgAttr ( + DataSource: database BugDgCheck.Ord, + configurationStorageType: attribute, + configurationAttribute: Config, + storeFiltersInPersonalization: true + ) { + column (Attribute: Code) { textfilter tf } + } + } +}; diff --git a/mdl-examples/bug-tests/1573-datagrid-by-key-settings-describe.mdl b/mdl-examples/bug-tests/1573-datagrid-by-key-settings-describe.mdl new file mode 100644 index 000000000..b5aa30d75 --- /dev/null +++ b/mdl-examples/bug-tests/1573-datagrid-by-key-settings-describe.mdl @@ -0,0 +1,158 @@ +mdl 1; +-- ============================================================================ +-- Bug: DESCRIBE PAGE dropped a Data grid 2's / Gallery's settings that MDL has +-- no keyword for (mendixlabs/mxcli#1573) +-- ============================================================================ +-- +-- A pluggable widget has far more settings than MDL has keywords. exec takes +-- every one of them under the widget's own property key, stores it, and +-- `mx check` is clean — but `describe page` printed only the keyword +-- properties (PageSize, Pagination, Selection, …). A describe -> edit -> exec +-- cycle therefore wrote the widget's default back over each of them, silently: +-- the personalization storage, the selection method, the labels, the column +-- minimum width, the empty-state placeholder. +-- +-- `describe page` now prints every setting whose stored value differs from the +-- default, under its key, so describe -> exec reproduces the stored widget: +-- +-- describe page BugDgByKey.Orders_Local +-- datagrid dgLocal (DataSource: database from BugDgByKey.Ord, +-- configurationStorageType: 'localStorage', …) +-- +-- This script is the INPUT side: it must keep passing `mxcli check`, and every +-- setting below is one the description has to carry. The BSON round trip needs a +-- project; it is asserted by mdl/executor/cmd_pages_describe_bykey_test.go +-- (against the shipped widget template) and was measured on Mendix 11.12.5: +-- exec this script, drop each page, exec its description, compare the page +-- BSON with ids stripped (identical) and `mx check` (0 errors). +-- +-- Two Data grid 2 rules the settings below respect (mxcli check enforces both): +-- * a setting the widget's editor config hides must hold its default +-- (MDL-WIDGET10): itemSelectionMode needs a non-checkbox selection method, +-- showSelectAllToggle needs the checkbox method, a column's hidable needs +-- columnsHidable on; +-- * columnsFilterable: false needs columns without a filter (page E). +-- +-- An Expression-typed setting (rowClass) is covered by the Go test: the checker +-- cannot tell it is one without a project (a separate fix). +-- ============================================================================ + +CREATE MODULE BugDgByKey; + +CREATE OR MODIFY PERSISTENT ENTITY BugDgByKey.Ord ( + Code: String(20), + Amount: Decimal, + Placed: DateTime, + Open: Boolean +); + +CREATE OR MODIFY PERSISTENT ENTITY BugDgByKey.GridPref ( + Config: String(unlimited) +); + +-- A: selection, refresh, loading, paging, labels, empty-state placeholder, and +-- the column settings that have no keyword. +CREATE PAGE BugDgByKey.Orders_All (Title: 'All settings', Layout: Atlas_Core.Atlas_Default) { + datagrid dgAll ( + DataSource: database BugDgByKey.Ord, + Selection: Multi, + itemSelectionMethod: rowClick, + itemSelectionMode: toggle, + keepSelection: true, + refreshInterval: 30, + refreshIndicator: true, + loadingType: skeleton, + columnsSortable: false, + columnsResizable: false, + columnsDraggable: false, + showEmptyPlaceholder: custom, + onClickTrigger: double, + PageSize: 50, + PagingPosition: top, + ShowPagingButtons: auto, + exportDialogLabel: 'Exporting', + cancelExportLabel: 'Stop export', + selectRowLabel: 'Pick row', + selectAllRowsLabel: 'Pick all', + filterSectionTitle: 'Narrow down', + selectedCountTemplateSingular: '1 row picked', + selectedCountTemplatePlural: 'Many rows picked' + ) { + emptyplaceholder { + dynamictext noRows (Content: 'Nothing here') + } + column (Attribute: Code, Hidable: no) { textfilter tf } + column (Attribute: Amount, ShowContentAs: customContent, ColumnWidth: autoFit, minWidth: manual, minWidthLimit: 150, allowEventPropagation: false) { + dynamictext amt (Content: 'amt') + } + column (Attribute: Placed, Alignment: center, WrapText: true, Tooltip: 'when') { datefilter df } + } +}; + +-- B: "load more" paging with a row count. +CREATE PAGE BugDgByKey.Orders_LoadMore (Title: 'Load more', Layout: Atlas_Core.Atlas_Default) { + datagrid dgMore ( + DataSource: database BugDgByKey.Ord, + Pagination: loadMore, + showNumberOfRows: true, + loadMoreButtonCaption: 'Show more', + advanced: true + ) { + column (Attribute: Code) { textfilter tf } + } +}; + +-- C: the grid's personalization stored in an attribute of the surrounding object. +CREATE PAGE BugDgByKey.Orders_Attribute (Title: 'Attribute', Layout: Atlas_Core.Atlas_Default, Params: ( $Pref: BugDgByKey.GridPref )) { + dataview dvPref (DataSource: $Pref) { + datagrid dgAttr ( + DataSource: database BugDgByKey.Ord, + configurationStorageType: attribute, + configurationAttribute: Config, + storeFiltersInPersonalization: false + ) { + column (Attribute: Code) { textfilter tf } + } + } +}; + +-- D: the grid's personalization stored in the browser; a plain selection grid +-- with the columns' hide switch off. +CREATE PAGE BugDgByKey.Orders_Local (Title: 'Local', Layout: Atlas_Core.Atlas_Default) { + datagrid dgLocal ( + DataSource: database BugDgByKey.Ord, + configurationStorageType: localStorage, + storeFiltersInPersonalization: false, + columnsHidable: false, + Selection: Multi, + showSelectAllToggle: false + ) { + column (Attribute: Code) { textfilter tf } + } +}; + +-- E: no column filters, so the grid can switch filtering off. +CREATE PAGE BugDgByKey.Orders_NoFilters (Title: 'No filters', Layout: Atlas_Core.Atlas_Default) { + datagrid dgNoFilter (DataSource: database BugDgByKey.Ord, columnsFilterable: false) { + column (Attribute: Code) + } +}; + +-- F: a Gallery takes its settings by key the same way. +CREATE PAGE BugDgByKey.Orders_Gallery (Title: 'Gallery', Layout: Atlas_Core.Atlas_Default) { + gallery glOrders ( + DataSource: database BugDgByKey.Ord, + Selection: Multi, + itemSelectionMode: toggle, + pagination: loadMore, + pageSize: 5, + showEmptyPlaceholder: custom + ) { + emptyplaceholder { + dynamictext glNoRows (Content: 'Nothing here') + } + template { + dynamictext glCode (Content: 'order') + } + } +}; diff --git a/mdl-examples/bug-tests/javascript-action-source-dir-case.mdl b/mdl-examples/bug-tests/javascript-action-source-dir-case.mdl index 13d55f78a..0432c7402 100644 --- a/mdl-examples/bug-tests/javascript-action-source-dir-case.mdl +++ b/mdl-examples/bug-tests/javascript-action-source-dir-case.mdl @@ -43,7 +43,7 @@ create or modify javascript action JsDirCase.JsDirCase_Echo( as $$ // MXCLI_MARKER — if this text is missing from the deployed bundle, the source // was written to a directory Mendix does not read. -return Promise.resolve(Value); +return Promise.resolve(value); $$; create or modify nanoflow JsDirCase.ACT_Echo() diff --git a/mdl-examples/doctype-tests/07b-javascript-action-examples.mdl b/mdl-examples/doctype-tests/07b-javascript-action-examples.mdl index 14c39b290..f6bc1dc31 100644 --- a/mdl-examples/doctype-tests/07b-javascript-action-examples.mdl +++ b/mdl-examples/doctype-tests/07b-javascript-action-examples.mdl @@ -39,7 +39,7 @@ create javascript action JsTest.Add( B: Integer not null ) returns Integer as $$ -return Promise.resolve(A + B); +return Promise.resolve(a + b); $$; -- MARK: Exposed (toolbox) action targeting native @@ -51,7 +51,7 @@ create javascript action JsTest.ShowToast( exposed as 'Show Toast' in 'UI' platform Native as $$ -console.log(Message); +console.log(message); return Promise.resolve(true); $$; @@ -63,7 +63,7 @@ create or modify javascript action JsTest.Add( C: Integer ) returns Integer as $$ -return Promise.resolve(A + B + (C || 0)); +return Promise.resolve(a + b + (c || 0)); $$; -- MARK: Enumeration parameter (issue #680) @@ -82,14 +82,14 @@ create javascript action JsTest.FormatSeverity( Level: Enumeration(JsTest.Severity) not null ) returns String as $$ -return Promise.resolve(String(Level)); +return Promise.resolve(String(level)); $$; create javascript action JsTest.NextSeverity( Current: ENUM JsTest.Severity not null ) returns Enumeration(JsTest.Severity) as $$ -return Promise.resolve(Current); +return Promise.resolve(current); $$; -- MARK: Inspect diff --git a/mdl-examples/doctype-tests/37-theme-switcher-examples.mdl b/mdl-examples/doctype-tests/37-theme-switcher-examples.mdl index 82248daca..f8b9b7800 100644 --- a/mdl-examples/doctype-tests/37-theme-switcher-examples.mdl +++ b/mdl-examples/doctype-tests/37-theme-switcher-examples.mdl @@ -72,15 +72,15 @@ as $$ var root = document.documentElement; root.classList.remove("theme-light", "theme-dark"); try { - if (Theme === "light" || Theme === "dark") { - root.classList.add("theme-" + Theme); - window.localStorage.setItem("mxcli-theme", Theme); + if (theme === "light" || theme === "dark") { + root.classList.add("theme-" + theme); + window.localStorage.setItem("mxcli-theme", theme); } else { window.localStorage.removeItem("mxcli-theme"); } } catch (e) { - if (Theme === "light" || Theme === "dark") { - root.classList.add("theme-" + Theme); + if (theme === "light" || theme === "dark") { + root.classList.add("theme-" + theme); } } return Promise.resolve(true); diff --git a/mdl-examples/doctype-tests/39-expose-as-toolbox-action.mdl b/mdl-examples/doctype-tests/39-expose-as-toolbox-action.mdl index e1becf3e9..b7803003f 100644 --- a/mdl-examples/doctype-tests/39-expose-as-toolbox-action.mdl +++ b/mdl-examples/doctype-tests/39-expose-as-toolbox-action.mdl @@ -130,7 +130,7 @@ exposed as 'Slugify' in 'Toolbox demo' icon 'assets/toolbox-icon-64.png' platform Web as $$ -return Promise.resolve(String(Input).toLowerCase().replace(/[^a-z0-9]+/g, '-')); +return Promise.resolve(String(input).toLowerCase().replace(/[^a-z0-9]+/g, '-')); $$; -- ============================================================================= diff --git a/mdl/backend/modelsdk/javascript_write_retain_test.go b/mdl/backend/modelsdk/javascript_write_retain_test.go index 1330fff27..d823f230f 100644 --- a/mdl/backend/modelsdk/javascript_write_retain_test.go +++ b/mdl/backend/modelsdk/javascript_write_retain_test.go @@ -111,3 +111,85 @@ func TestWriteJavaScriptSourceFile_NewFileHasEmptySections(t *testing.T) { t.Errorf("new stub has unexpected sections:\n%s", got) } } + +// mxbuild and Studio Pro regenerate a JavaScript action's signature with the +// first character of each parameter name lower-cased and keep the body, so the +// file mxcli writes has to carry those names or the body is written against +// identifiers that stop existing at the next build. +func TestWriteJavaScriptSourceFile_ParametersAreLowerFirst(t *testing.T) { + dir := t.TempDir() + b := &Backend{path: filepath.Join(dir, "App.mpr")} + params := []*types.JavaActionParameter{ + {Name: "Url", ParameterType: &types.StringType{}}, + {Name: "ReadOnly", ParameterType: &types.BooleanType{}}, + } + if err := b.WriteJavaScriptSourceFile("Demo", "JS_Open", `return url + (readOnly ? " (read only)" : "");`, params, &types.StringType{}); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(b.jsActionSourceDir("Demo"), "JS_Open.js")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + " * @param {string} url\n", + " * @param {boolean} readOnly\n", + "export async function JS_Open(url, readOnly) {\n", + } { + if !strings.Contains(string(got), want) { + t.Errorf("source lacks %q:\n%s", want, got) + } + } +} + +// Rewriting an action authored in Studio Pro must reproduce the signature it +// has: the model declares Address, the file says address, and the body uses +// address. Before, the rewrite turned the signature into Address while the body +// kept address. +func TestWriteJavaScriptSourceFile_RewriteKeepsStudioProSignature(t *testing.T) { + const stored = `// This file was generated by Mendix Studio Pro. +import { Big } from "big.js"; + +// BEGIN EXTRA CODE +// END EXTRA CODE + +/** + * @param {string} [address] + * @param {string} [geocodingProvider] + * @param {string} [providerApiKey] + * @returns {Promise.} + */ +export async function Locate(address, geocodingProvider, providerApiKey) { + // BEGIN USER CODE + return address + geocodingProvider; + // END USER CODE +} +` + dir := t.TempDir() + b := &Backend{path: filepath.Join(dir, "App.mpr")} + jsDir := b.jsActionSourceDir("Demo") + if err := os.MkdirAll(jsDir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(jsDir, "Locate.js") + if err := os.WriteFile(path, []byte(stored), 0o644); err != nil { + t.Fatal(err) + } + params := []*types.JavaActionParameter{ + {Name: "Address", ParameterType: &types.StringType{}}, + {Name: "GeocodingProvider", ParameterType: &types.StringType{}}, + {Name: "ProviderApiKey", ParameterType: &types.StringType{}}, + } + if err := b.WriteJavaScriptSourceFile("Demo", "Locate", "return address + geocodingProvider + providerApiKey;", params, &types.StringType{}); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(path) + const sig = "export async function Locate(address, geocodingProvider, providerApiKey) {\n" + if !strings.Contains(string(got), sig) { + t.Errorf("rewrite changed the Studio Pro signature:\n%s", got) + } + for _, name := range []string{"address", "geocodingProvider", "providerApiKey"} { + if !strings.Contains(string(got), " "+name+"\n") { + t.Errorf("JSDoc lost @param %s:\n%s", name, got) + } + } +} diff --git a/mdl/executor/cmd_javascript_actions_mock_test.go b/mdl/executor/cmd_javascript_actions_mock_test.go index 8b7e10170..d64dd3c7b 100644 --- a/mdl/executor/cmd_javascript_actions_mock_test.go +++ b/mdl/executor/cmd_javascript_actions_mock_test.go @@ -269,3 +269,50 @@ func TestJavaScriptActionRewrite_CarriesParameterDescription(t *testing.T) { t.Errorf("Fresh: description %q, category %q — a new parameter inherited a description", p.Description, p.Category) } } + +// Only the identifiers in the generated .js are lower-cased, the way mxbuild +// regenerates them. The parameter names in the model are what the author +// declared, so describe prints them unchanged and re-running its output is +// stable. +func TestCreateJavaScriptAction_ModelKeepsDeclaredParameterCase(t *testing.T) { + mod := mkModule("WebMod") + h := mkHierarchy(mod) + + var created *types.JavaScriptAction + var written []*types.JavaActionParameter + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListJavaScriptActionsFunc: func() ([]*types.JavaScriptAction, error) { return nil, nil }, + CreateJavaScriptActionFunc: func(jsa *types.JavaScriptAction) error { + created = jsa + return nil + }, + WriteJavaScriptSourceFileFunc: func(_, _, _ string, params []*types.JavaActionParameter, _ types.CodeActionReturnType) error { + written = params + return nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + assertNoError(t, execCreateJavaScriptAction(ctx, &ast.CreateJavaScriptActionStmt{ + Name: ast.QualifiedName{Module: "WebMod", Name: "JS_Open"}, + Parameters: []ast.JavaActionParam{ + {Name: "Url", Type: ast.DataType{Kind: ast.TypeString}}, + {Name: "ReadOnly", Type: ast.DataType{Kind: ast.TypeBoolean}}, + }, + ReturnType: ast.DataType{Kind: ast.TypeString}, + JavaScriptCode: "return url;", + })) + + if created == nil || len(created.Parameters) != 2 { + t.Fatalf("action = %+v", created) + } + for i, want := range []string{"Url", "ReadOnly"} { + if created.Parameters[i].Name != want { + t.Errorf("model parameter %d = %q, want %q", i, created.Parameters[i].Name, want) + } + if len(written) == 2 && written[i].Name != want { + t.Errorf("parameter %d handed to the .js writer = %q, want the model name %q", i, written[i].Name, want) + } + } +} diff --git a/mdl/executor/cmd_menus.go b/mdl/executor/cmd_menus.go index a6b912ff6..78db228a3 100644 --- a/mdl/executor/cmd_menus.go +++ b/mdl/executor/cmd_menus.go @@ -70,6 +70,15 @@ func execCreateMenu(ctx *ExecContext, s *ast.CreateMenuStmt) error { return mdlerrors.NewAlreadyExists("menu", s.Name.String()) } + // Resolve the pages and flows its items name before anything is written, a + // folder included. An excluded document is not validated by Mendix and keeps + // its dangling targets, as an excluded page does. + if existing == nil || !existing.Excluded { + if err := validateNavigationTargetsForExec(ctx, s); err != nil { + return err + } + } + var existingContainer model.ID if existing != nil { existingContainer = existing.ContainerID diff --git a/mdl/executor/cmd_microflows_build.go b/mdl/executor/cmd_microflows_build.go index 511fdbc71..43f0b7d97 100644 --- a/mdl/executor/cmd_microflows_build.go +++ b/mdl/executor/cmd_microflows_build.go @@ -326,12 +326,12 @@ func buildMicroflowFromStmt(ctx *ExecContext, s *ast.CreateMicroflowStmt, opts b if entityID == "" { // Bare qualified name in microflow context is treated as TypeEntity by the // visitor, but it may actually be an enumeration. Try enum lookup before failing. - if found := findEnumeration(ctx, p.Type.EntityRef.Module, p.Type.EntityRef.Name); found != nil { - s.Parameters[i].Type = ast.DataType{Kind: ast.TypeEnumeration, EnumRef: p.Type.EntityRef} + if retyped, ok := bareEnumerationType(ctx, p.Type); ok { + s.Parameters[i].Type = retyped p = s.Parameters[i] } else { return nil, mdlerrors.NewNotFoundMsg("entity", p.Type.EntityRef.Module+"."+p.Type.EntityRef.Name, - fmt.Sprintf("entity '%s.%s' not found for parameter '%s'", p.Type.EntityRef.Module, p.Type.EntityRef.Name, p.Name)) + fmt.Sprintf("entity or enumeration '%s.%s' not found for parameter '%s'", p.Type.EntityRef.Module, p.Type.EntityRef.Name, p.Name)) } } } @@ -362,8 +362,13 @@ func buildMicroflowFromStmt(ctx *ExecContext, s *ast.CreateMicroflowStmt, opts b if s.ReturnType.Type.EntityRef != nil && !isBuiltinModuleEntity(s.ReturnType.Type.EntityRef.Module) { entityID := entityResolver(*s.ReturnType.Type.EntityRef) if entityID == "" { - return nil, mdlerrors.NewNotFoundMsg("entity", s.ReturnType.Type.EntityRef.Module+"."+s.ReturnType.Type.EntityRef.Name, - fmt.Sprintf("entity '%s.%s' not found for return type", s.ReturnType.Type.EntityRef.Module, s.ReturnType.Type.EntityRef.Name)) + // A bare qualified name may be an enumeration, as for parameters above. + if retyped, ok := bareEnumerationType(ctx, s.ReturnType.Type); ok && s.ReturnType.Type.Kind == ast.TypeEntity { + s.ReturnType.Type = retyped + } else { + return nil, mdlerrors.NewNotFoundMsg("entity", s.ReturnType.Type.EntityRef.Module+"."+s.ReturnType.Type.EntityRef.Name, + fmt.Sprintf("entity or enumeration '%s.%s' not found for return type", s.ReturnType.Type.EntityRef.Module, s.ReturnType.Type.EntityRef.Name)) + } } } // Validate enumeration references for return type @@ -630,12 +635,12 @@ func buildNanoflowFromStmt(ctx *ExecContext, s *ast.CreateNanoflowStmt, opts bui if entityID == "" { // Bare qualified name in microflow context is treated as TypeEntity by the // visitor, but it may actually be an enumeration. Try enum lookup before failing. - if found := findEnumeration(ctx, p.Type.EntityRef.Module, p.Type.EntityRef.Name); found != nil { - s.Parameters[i].Type = ast.DataType{Kind: ast.TypeEnumeration, EnumRef: p.Type.EntityRef} + if retyped, ok := bareEnumerationType(ctx, p.Type); ok { + s.Parameters[i].Type = retyped p = s.Parameters[i] } else { return nil, mdlerrors.NewNotFoundMsg("entity", p.Type.EntityRef.Module+"."+p.Type.EntityRef.Name, - fmt.Sprintf("entity '%s.%s' not found for parameter '%s'", p.Type.EntityRef.Module, p.Type.EntityRef.Name, p.Name)) + fmt.Sprintf("entity or enumeration '%s.%s' not found for parameter '%s'", p.Type.EntityRef.Module, p.Type.EntityRef.Name, p.Name)) } } } @@ -662,8 +667,13 @@ func buildNanoflowFromStmt(ctx *ExecContext, s *ast.CreateNanoflowStmt, opts bui if s.ReturnType.Type.EntityRef != nil && !isBuiltinModuleEntity(s.ReturnType.Type.EntityRef.Module) { entityID := entityResolver(*s.ReturnType.Type.EntityRef) if entityID == "" { - return nil, mdlerrors.NewNotFoundMsg("entity", s.ReturnType.Type.EntityRef.Module+"."+s.ReturnType.Type.EntityRef.Name, - fmt.Sprintf("entity '%s.%s' not found for return type", s.ReturnType.Type.EntityRef.Module, s.ReturnType.Type.EntityRef.Name)) + // A bare qualified name may be an enumeration, as for parameters above. + if retyped, ok := bareEnumerationType(ctx, s.ReturnType.Type); ok && s.ReturnType.Type.Kind == ast.TypeEntity { + s.ReturnType.Type = retyped + } else { + return nil, mdlerrors.NewNotFoundMsg("entity", s.ReturnType.Type.EntityRef.Module+"."+s.ReturnType.Type.EntityRef.Name, + fmt.Sprintf("entity or enumeration '%s.%s' not found for return type", s.ReturnType.Type.EntityRef.Module, s.ReturnType.Type.EntityRef.Name)) + } } } if s.ReturnType.Type.Kind == ast.TypeEnumeration && s.ReturnType.Type.EnumRef != nil { diff --git a/mdl/executor/cmd_microflows_helpers.go b/mdl/executor/cmd_microflows_helpers.go index d3caaefe9..655015c9e 100644 --- a/mdl/executor/cmd_microflows_helpers.go +++ b/mdl/executor/cmd_microflows_helpers.go @@ -70,6 +70,22 @@ func convertASTToMicroflowDataType(dt ast.DataType, entityResolver func(ast.Qual } } +// bareEnumerationType resolves the entity/enumeration ambiguity of a declared +// type. The parser cannot tell an entity from an enumeration for a bare +// `Module.Name` and always records an entity reference, so a caller that has +// found no entity of that name asks here whether an enumeration has it. It +// returns dt retyped as that enumeration, or false when none does. +// +// Parameters and return types of microflows, nanoflows and rules all resolve +// through this, so the three kinds accept the same spellings; `check` makes the +// same decision for a project that is not yet written in declaredTypeResolver. +func bareEnumerationType(ctx *ExecContext, dt ast.DataType) (ast.DataType, bool) { + if dt.EntityRef == nil || findEnumeration(ctx, dt.EntityRef.Module, dt.EntityRef.Name) == nil { + return dt, false + } + return ast.DataType{Kind: ast.TypeEnumeration, EnumRef: dt.EntityRef}, true +} + // The Mendix expression renderer lives in mdl/mendixexpr, where the visitor // can reach it too (fmt --upgrade, ako/mxcli#804). These names are the // executor's handles on it. diff --git a/mdl/executor/cmd_navigation.go b/mdl/executor/cmd_navigation.go index d41bf366d..ecf455ecf 100644 --- a/mdl/executor/cmd_navigation.go +++ b/mdl/executor/cmd_navigation.go @@ -31,6 +31,13 @@ func execAlterNavigation(ctx *ExecContext, s *ast.AlterNavigationStmt) error { return err } + // Resolve the pages and flows it names, for the same reason: this statement + // replaces the whole profile, so a target that does not exist would cost the + // stored menu and be reported only by the next build (CE1613). + if err := validateNavigationTargetsForExec(ctx, s); err != nil { + return err + } + nav, err := ctx.Backend.GetNavigation() if err != nil { return mdlerrors.NewBackend("get navigation", err) diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index db57bcca8..5136154fa 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -593,22 +593,25 @@ func applyConditionalSettings(widget pages.Widget, w *ast.WidgetV3) { } if editableIf := w.GetStringProp("EditableIf"); editableIf != "" { - bw.ConditionalEditability = &pages.ConditionalEditabilitySettings{ - BaseElement: model.BaseElement{ - ID: model.ID(types.GenerateID()), - TypeName: "Forms$ConditionalEditabilitySettings", - }, - Expression: editableIf, - } - } else if editable, ok := pages.CanonicalEditability(w.GetStringProp("Editable")); ok { + bw.ConditionalEditability = newConditionalEditability(editableIf) + } else if enum, expr, ok := staticEditability(w); ok { // `Editable: Never` — parsed and validated (MDL-WIDGET20 checks the widget // TYPE) and then dropped, because nothing carried it to the writers, which // hardcoded "Always". Same shape as the `Visible: false` case above. // + // A string that is not an enum word is the condition, exactly as for + // Visible: `Editable: '$currentObject/Locked = false'` is the quoted + // spelling of the bare expression. It used to be skipped here, so the + // widget stayed Always. + // // EDITABLE IF wins when both are given: the conditional settings element is // what makes the enum "Conditional", so honouring a plain `Editable` too // would write an enum contradicting the element beside it. - bw.Editable = editable + if expr != "" { + bw.ConditionalEditability = newConditionalEditability(expr) + } else { + bw.Editable = enum + } } } diff --git a/mdl/executor/cmd_pages_describe.go b/mdl/executor/cmd_pages_describe.go index 0f40abdf3..eee36d64b 100644 --- a/mdl/executor/cmd_pages_describe.go +++ b/mdl/executor/cmd_pages_describe.go @@ -664,6 +664,11 @@ type rawDataSource struct { // are rendered, since both re-parse against the source's own entity. SearchAttributes []string ContextVariable string // association source: context variable name (empty → $currentObject) + // Destination is the entity an association source's last hop arrives at, as + // stored (EntityRef.Steps[last].DestinationEntity). Reference is the + // association there, so this is the entity the source puts in scope. Empty + // when the model does not store it. + Destination string // EntitySteps is set on a database source reached over associations from a // context object: a Forms$ListViewXPathSource whose EntityRef is an // IndirectEntityRef. Each hop keeps its stored DestinationEntity, which may @@ -697,6 +702,26 @@ type rawDataSourceArg struct { Value string } +// associationSourceDestination returns the entity the last hop of an association +// source arrives at, as stored — "" when there is no hop or none is stored. +// associationSourcePath keeps only the association of a single hop, so this +// reads the same steps for the entity. +func associationSourceDestination(ds map[string]any) string { + entityRef, ok := ds["EntityRef"].(map[string]any) + if !ok || entityRef == nil { + return "" + } + steps := getBsonArrayElements(entityRef["Steps"]) + if len(steps) == 0 { + return "" + } + last, ok := steps[len(steps)-1].(map[string]any) + if !ok { + return "" + } + return extractString(last["DestinationEntity"]) +} + // associationSourcePath reconstructs the association navigation of a // Forms$AssociationSource (EntityRef = IndirectEntityRef of association steps) // into its path ("Module.Assoc" or "Module.Assoc/…") plus the context variable @@ -772,6 +797,11 @@ type rawDataGridColumn struct { Visible string // expression, e.g. "true" DynamicCellClass string // expression Tooltip string // text + // ExtraProps are the column settings MDL has no keyword for (minWidth, + // allowEventPropagation, …) that differ from their defaults, printed under + // the key the widget declares. OmittedProps names those that could not be. + ExtraProps []rawExplicitProp + OmittedProps []string } // rawWidget represents a widget from raw BSON data for MDL output. @@ -925,6 +955,12 @@ type rawWidget struct { // describe -> exec round trip cannot silently delete a widget's body. // See unreconstructedContainers. OmittedContainers []string + + // OmittedProps names properties whose stored value differs from the default + // and that DESCRIBE has no spelling for (an attribute reached over + // associations, …). Emitted as a comment, like OmittedContainers, so a + // describe -> exec round trip cannot reset them without saying so. + OmittedProps []string // Data container context: entity qualified name provided by this container EntityContext string // Full widget ID (e.g. "com.mendix.widget.custom.switch.Switch") diff --git a/mdl/executor/cmd_pages_describe_assoc_context_test.go b/mdl/executor/cmd_pages_describe_assoc_context_test.go new file mode 100644 index 000000000..9525f207b --- /dev/null +++ b/mdl/executor/cmd_pages_describe_assoc_context_test.go @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: Apache-2.0 + +// A data container bound over an association reported the *association* as its +// context entity — `-- Context: $currentObject (Shop.Order_Customer)` instead of +// `(Shop.Customer)` — because the datasource reference was used verbatim. That is +// right for a database source, where the reference is the entity, and wrong for +// a microflow source (its flow) and for an association source (its association). +// The flow case was fixed first; these tests cover the association case. +// +// Storage carries the answer: every step of an association source's EntityRef +// stores the DestinationEntity it arrives at, in either direction. +package executor + +import ( + "strings" + "testing" +) + +func assocStep(assoc, dest string) map[string]any { + return map[string]any{"$Type": "DomainModels$EntityRefStep", "Association": assoc, "DestinationEntity": dest} +} + +func indirectRef(steps ...map[string]any) map[string]any { + items := make([]any, len(steps)) + for i, s := range steps { + items[i] = s + } + return map[string]any{"$Type": "DomainModels$IndirectEntityRef", "Steps": items} +} + +func pageVariable(param string) map[string]any { + return map[string]any{"$Type": "Forms$PageVariable", "PageParameter": param} +} + +// assocDataSource is a Forms$AssociationSource over steps from $currentObject. +func assocDataSource(steps ...map[string]any) map[string]any { + return map[string]any{"$Type": "Forms$AssociationSource", "EntityRef": indirectRef(steps...)} +} + +// outerOrderView wraps inner in a data view over page parameter $Order, the way +// the field report nests it. +func outerOrderView(inner map[string]any) map[string]any { + return map[string]any{ + "$Type": "Forms$DataView", + "Name": "dvOuter", + "DataSource": map[string]any{ + "$Type": "Forms$DataViewSource", + "EntityRef": map[string]any{"$Type": "DomainModels$DirectEntityRef", "Entity": "Shop.Order"}, + "SourceVariable": pageVariable("Order"), + }, + "Widgets": []any{int32(2), inner}, + } +} + +// A context comment is written only above a container's body, so each nested +// container carries one child. +func containerChild() map[string]any { + return map[string]any{"$Type": "Forms$TextBox", "Name": "tbInner"} +} + +func nestedDataView(ds map[string]any) map[string]any { + return map[string]any{"$Type": "Forms$DataView", "Name": "dvInner", "DataSource": ds, "Widgets": []any{int32(2), containerChild()}} +} + +func nestedListView(ds map[string]any) map[string]any { + return map[string]any{"$Type": "Forms$ListView", "Name": "lvInner", "DataSource": ds, "Widgets": []any{int32(2), containerChild()}} +} + +// A data view over a reference reaches the association's other end, and a list +// view over a reference set reaches its elements — in both directions. +func TestDescribe_AssociationSourceContextIsTheEntityReached(t *testing.T) { + tests := []struct { + name string + inner map[string]any + wantLine string + wantExpr string + notWanted string + }{ + { + name: "reference, owner to child", + inner: nestedDataView(map[string]any{"$Type": "Forms$DataViewSource", "EntityRef": indirectRef(assocStep("Shop.Order_Customer", "Shop.Customer")), "SourceVariable": pageVariable("")}), + wantLine: "-- Context: $currentObject (Shop.Customer)\n", + wantExpr: "DataSource: $currentObject/Shop.Order_Customer", + notWanted: "(Shop.Order_Customer)", + }, + { + name: "reference seen from the child's side", + inner: nestedListView(assocDataSource(assocStep("Shop.Order_Customer", "Shop.Order"))), + wantLine: "-- Context: $currentObject (Shop.Order), $lvInner (selection)\n", + wantExpr: "DataSource: $currentObject/Shop.Order_Customer", + notWanted: "(Shop.Order_Customer)", + }, + { + name: "reference set", + inner: nestedListView(assocDataSource(assocStep("Shop.Order_Line", "Shop.OrderLine"))), + wantLine: "-- Context: $currentObject (Shop.OrderLine), $lvInner (selection)\n", + wantExpr: "DataSource: $currentObject/Shop.Order_Line", + notWanted: "(Shop.Order_Line)", + }, + { + // The stored destination can be a specialization of the association's + // own end, and that — not the end — is the entity in scope. + name: "stored destination is a specialization of the end", + inner: nestedListView(assocDataSource(assocStep("Shop.Order_Line", "Shop.SpecialLine"))), + wantLine: "-- Context: $currentObject (Shop.SpecialLine), $lvInner (selection)\n", + wantExpr: "DataSource: $currentObject/Shop.Order_Line", + notWanted: "(Shop.Order_Line)", + }, + { + name: "two hops: the last destination", + inner: nestedListView(assocDataSource( + assocStep("Shop.Order_Customer", "Shop.Customer"), + assocStep("Shop.Address_Customer", "Shop.Address"))), + wantLine: "-- Context: $currentObject (Shop.Address), $lvInner (selection)\n", + wantExpr: "DataSource: $currentObject/Shop.Order_Customer/Shop.Customer/Shop.Address_Customer/Shop.Address", + notWanted: "(Shop.Order_Customer", + }, + { + name: "over a page parameter instead of the context object", + inner: nestedDataView(map[string]any{ + "$Type": "Forms$DataViewSource", + "EntityRef": indirectRef(assocStep("Shop.Order_Customer", "Shop.Customer")), + "SourceVariable": pageVariable("Order"), + }), + wantLine: "-- Context: $currentObject (Shop.Customer)\n", + wantExpr: "DataSource: $Order/Shop.Order_Customer", + notWanted: "(Shop.Order_Customer)", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := describeWidget(t, outerOrderView(tc.inner)) + if !strings.Contains(got, tc.wantLine) { + t.Errorf("describe output lacks %q:\n%s", tc.wantLine, got) + } + if strings.Contains(got, tc.notWanted) { + t.Errorf("describe output still names the association as the context %q:\n%s", tc.notWanted, got) + } + // Only the context comment changes: the datasource text is what + // exec reads back. + if !strings.Contains(got, tc.wantExpr) { + t.Errorf("describe output lacks the unchanged datasource %q:\n%s", tc.wantExpr, got) + } + }) + } +} + +// The pluggable Gallery / Data grid 2 / chart datasource reads through the same +// parser (#941): an XPath source with no entity and the association hops in +// EntityRef. +func TestParseDataSource_PluggableAssociationSourceContextIsTheEntityReached(t *testing.T) { + ds := parseDataSource(map[string]any{ + "$Type": "CustomWidgets$CustomWidgetXPathSource", + "EntityRef": indirectRef(assocStep("Shop.Order_Line", "Shop.OrderLine")), + }) + if ds == nil || ds.Type != "association" { + t.Fatalf("parseDataSource = %+v, want an association source", ds) + } + if got := dataSourceEntityContext(nil, ds); got != "Shop.OrderLine" { + t.Errorf("dataSourceEntityContext = %q, want Shop.OrderLine", got) + } + if got := dataSourceExpr(nil, ds); got != "$currentObject/Shop.Order_Line" { + t.Errorf("dataSourceExpr = %q, want the unchanged $currentObject/Shop.Order_Line", got) + } +} + +// A source whose destination is not stored keeps the reference: no worse than +// before, as the flow fix does for a flow it cannot resolve. +func TestDataSourceEntityContext_AssociationWithoutStoredDestinationKeepsTheReference(t *testing.T) { + ds := parseDataSource(assocDataSource(assocStep("Shop.Order_Customer", ""))) + if ds == nil || ds.Type != "association" { + t.Fatalf("parseDataSource = %+v, want an association source", ds) + } + if got := dataSourceEntityContext(nil, ds); got != "Shop.Order_Customer" { + t.Errorf("dataSourceEntityContext = %q, want the reference kept", got) + } + + // Two hops of which one has no stored destination: the path prints the + // associations alone, and so does the fallback. + ds = parseDataSource(assocDataSource(assocStep("Shop.A_B", "Shop.B"), assocStep("Shop.B_C", ""))) + if got := dataSourceEntityContext(nil, ds); got != "Shop.A_B/Shop.B_C" { + t.Errorf("dataSourceEntityContext = %q, want the reference kept", got) + } +} + +// Controls: a database source keeps naming its own entity, and so does a +// datasource reached over associations that is still a database retrieve. +func TestDescribe_DatabaseSourceContextUnchanged(t *testing.T) { + got := describeWidget(t, outerOrderView(nestedListView(map[string]any{ + "$Type": "Forms$ListViewXPathSource", + "EntityRef": map[string]any{"$Type": "DomainModels$DirectEntityRef", "Entity": "Shop.OrderLine"}, + }))) + if want := "-- Context: $currentObject (Shop.OrderLine), $lvInner (selection)\n"; !strings.Contains(got, want) { + t.Errorf("describe output lacks %q:\n%s", want, got) + } + + got = describeWidget(t, outerOrderView(nestedListView(map[string]any{ + "$Type": "Forms$ListViewXPathSource", + "EntityRef": indirectRef(assocStep("Shop.Order_Line", "Shop.OrderLine")), + }))) + if want := "-- Context: $currentObject (Shop.OrderLine), $lvInner (selection)\n"; !strings.Contains(got, want) { + t.Errorf("describe output lacks %q:\n%s", want, got) + } +} diff --git a/mdl/executor/cmd_pages_describe_bykey.go b/mdl/executor/cmd_pages_describe_bykey.go new file mode 100644 index 000000000..8ee3aec95 --- /dev/null +++ b/mdl/executor/cmd_pages_describe_bykey.go @@ -0,0 +1,394 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "sort" + "strings" +) + +// Settings addressed by the widget's own property key. +// +// A pluggable widget has far more settings than MDL gives a keyword. The write +// side takes every one of them under the key the widget's package declares: +// +// datagrid dg (DataSource: database Shop.Ord, configurationStorageType: localStorage) +// +// DESCRIBE read only the handful it has keywords for (PageSize, Pagination, +// Selection, …), so a grid authored with the rest described back without them +// and a describe -> exec cycle put every one back to the widget's default — +// silently, with `mx check` at 0 errors. +// +// # What is printed +// +// A setting is printed when its stored value differs from what exec writes for +// a property left out, and only then. That keeps every grid that sets none of +// them describing byte for byte as it did. +// +// "What exec writes" is not always the schema's DefaultValue. A text template's +// DefaultValue is '' while the widget ships text for it ("Export progress"), and +// the writer stores that text when the property is absent; comparing against '' +// would print four labels on every grid. The schema is the widget's own, read +// from the document's stored Type — the definition the value was written +// against — so nothing here is a list of widgets or of defaults. + +// widgetPropSchema is one property type of a stored widget, as far as DESCRIBE +// needs it. +type widgetPropSchema struct { + Key string + Type string // "Boolean", "Integer", "Enumeration", "TextTemplate", … + // Default is the schema's DefaultValue. + Default string + // DataSourceProperty is the key of the datasource property this one is + // linked to; "" when it binds to the context object (an attribute property + // resolves against the datasource's entity when linked, the enclosing data + // container's when not). + DataSourceProperty string + // Translations is a text template's shipped default text per language code. + Translations map[string]string + // FirstTranslation is the text of the first shipped translation, the + // fallback for a language the widget ships no text for. + FirstTranslation string +} + +// propertySchemasByID reads the property types of a stored widget (or of a +// nested object type) into a map keyed by the property type's $ID, which is +// what a stored property's TypePointer holds. +func propertySchemasByID(propTypes []any) map[string]widgetPropSchema { + out := make(map[string]widgetPropSchema, len(propTypes)) + for _, pt := range propTypes { + ptMap, ok := pt.(map[string]any) + if !ok { + continue + } + id := extractBinaryID(ptMap["$ID"]) + key := extractString(ptMap["PropertyKey"]) + if id == "" || key == "" { + continue + } + sc := widgetPropSchema{Key: key} + if vt, ok := ptMap["ValueType"].(map[string]any); ok { + sc.Type = extractString(vt["Type"]) + sc.Default = extractString(vt["DefaultValue"]) + sc.DataSourceProperty = extractString(vt["DataSourceProperty"]) + for _, tr := range getBsonArrayElements(vt["Translations"]) { + trMap, ok := tr.(map[string]any) + if !ok { + continue + } + text := extractString(trMap["Text"]) + if sc.Translations == nil { + sc.Translations = map[string]string{} + sc.FirstTranslation = text + } + sc.Translations[extractString(trMap["LanguageCode"])] = text + } + } + out[id] = sc + } + return out +} + +// widgetPropertySchemas is propertySchemasByID for a custom widget's own +// property types. +func widgetPropertySchemas(w map[string]any) map[string]widgetPropSchema { + widgetType, ok := w["Type"].(map[string]any) + if !ok { + return nil + } + objType, ok := widgetType["ObjectType"].(map[string]any) + if !ok { + return nil + } + return propertySchemasByID(getBsonArrayElements(objType["PropertyTypes"])) +} + +// objectListPropertySchemas is propertySchemasByID for the item type of the +// object-list property listKey (a data grid's `columns`). +func objectListPropertySchemas(w map[string]any, listKey string) map[string]widgetPropSchema { + widgetType, ok := w["Type"].(map[string]any) + if !ok { + return nil + } + objType, ok := widgetType["ObjectType"].(map[string]any) + if !ok { + return nil + } + for _, pt := range getBsonArrayElements(objType["PropertyTypes"]) { + ptMap, ok := pt.(map[string]any) + if !ok || extractString(ptMap["PropertyKey"]) != listKey { + continue + } + vt, ok := ptMap["ValueType"].(map[string]any) + if !ok { + return nil + } + itemType, ok := vt["ObjectType"].(map[string]any) + if !ok { + return nil + } + return propertySchemasByID(getBsonArrayElements(itemType["PropertyTypes"])) + } + return nil +} + +// byKeyOptions steers nonDefaultPropsByKey. +type byKeyOptions struct { + // Skip names the keys MDL already spells another way (PageSize, Selection, + // onClick, …) or that have a body of their own (columns, slots). + Skip map[string]bool + // AttrEntity returns the entity a bare attribute name of property sc + // resolves against on re-execution; "" when there is none. + AttrEntity func(sc widgetPropSchema) string +} + +// nonDefaultPropsByKey returns, in key order, the properties of a stored widget +// object (or object-list item) whose value differs from what exec writes for a +// property left out — each as the `key: value` it is written back with. +// +// omitted names the properties that differ and that no MDL spelling carries, so +// the caller can say so rather than lose them quietly. +func nonDefaultPropsByKey(ctx *ExecContext, objProps []any, schemas map[string]widgetPropSchema, opts byKeyOptions) (props []rawExplicitProp, omitted []string) { + for _, prop := range objProps { + propMap, ok := prop.(map[string]any) + if !ok { + continue + } + sc, ok := schemas[extractBinaryID(propMap["TypePointer"])] + if !ok || opts.Skip[sc.Key] { + continue + } + value, ok := propMap["Value"].(map[string]any) + if !ok { + continue + } + switch sc.Type { + case "Action", "DataSource", "Widgets", "Object", "Selection": + // Carried elsewhere: an action by onClick / its own key, a datasource + // by DataSource:, a widget slot or an object list by its body, and a + // selection by Selection:. + continue + case "Boolean", "Integer", "Decimal", "String", "Enumeration": + pv := extractString(value["PrimitiveValue"]) + if pv == "" || pv == sc.Default { + continue + } + props = append(props, rawExplicitProp{Key: sc.Key, Value: pv, ValueType: sc.Type}) + case "Expression": + expr := extractString(value["Expression"]) + if expr == "" || expr == sc.Default { + continue + } + props = append(props, expressionByKeyProp(ctx, sc.Key, expr)) + case "TextTemplate": + text, tt := extractTextTemplateText(value) + params := []string(nil) + if tt != nil { + params = extractTextTemplateParameters(ctx, tt) + } + if len(params) == 0 && textTemplateIsDefault(value, sc) { + continue + } + props = append(props, rawExplicitProp{Key: sc.Key, Value: text, ValueType: sc.Type, Params: params}) + case "Attribute": + attrRef, ok := value["AttributeRef"].(map[string]any) + if !ok || attrRef == nil { + continue + } + attr := extractString(attrRef["Attribute"]) + if attr == "" { + continue + } + if entityRef, ok := attrRef["EntityRef"].(map[string]any); ok && len(entityRef) > 0 { + // A path over associations has no spelling the explicit-key pass + // reads back. + omitted = append(omitted, sc.Key) + continue + } + scope := "" + if opts.AttrEntity != nil { + scope = opts.AttrEntity(sc) + } + props = append(props, rawExplicitProp{Key: sc.Key, Value: byKeyAttributeName(ctx, attr, scope), IsRef: true}) + default: + // A kind this does not know (an association, an icon, an image, a + // file): if it holds a value, say it was left out. + if valueHoldsSomething(value, "EntityRef", "Icon", "Image", "File", "AttributeRef", "Expression", "PrimitiveValue") { + omitted = append(omitted, sc.Key) + } + } + } + sort.Slice(props, func(i, j int) bool { return props[i].Key < props[j].Key }) + sort.Strings(omitted) + return props, omitted +} + +// byKeyAttributeName is the attribute as the explicit-key pass reads it back: a +// bare name, as the rest of describe writes one, when exec resolves that against +// the entity the property binds to; the qualified name when it would not — no +// data container to resolve against, or an attribute of another entity (one +// inherited from a generalization). The writer takes any name with two dots as +// qualified. +// +// The scope is a qualified entity name when the container's source is an +// entity, and only a name (a page parameter's) when it is not; the latter cannot +// be compared, and Mendix binds such a property to an attribute of that +// container's entity anyway. +func byKeyAttributeName(ctx *ExecContext, attr, scopeEntity string) string { + if ctx != nil && ctx.describeQualifyAttrs { + return attr + } + if scopeEntity == "" { + return attr + } + if strings.Contains(scopeEntity, ".") && !strings.EqualFold(attributeEntity(attr), scopeEntity) { + return attr + } + return shortAttributeName(attr) +} + +// attributeEntity is Module.Entity of Module.Entity.Attribute. +func attributeEntity(attr string) string { + if idx := strings.LastIndex(attr, "."); idx >= 0 { + return attr[:idx] + } + return "" +} + +// expressionByKeyProp renders an Expression-typed property. The explicit-key +// pass takes the unquoted form as the expression itself, so that is what is +// printed — except for an expression that is nothing but a string literal +// (`'cls'`), which unquoted would be read as the string `cls` and stored +// without its quotes; it is printed as the quoted text of the expression. +func expressionByKeyProp(ctx *ExecContext, key, expr string) rawExplicitProp { + if _, isLiteral := mendixStringLiteral(expr); isLiteral { + return rawExplicitProp{Key: key, Value: mdlQuote(ctx, expr), IsRef: true} + } + return rawExplicitProp{Key: key, Value: expr, IsRef: true} +} + +// textTemplateIsDefault reports whether a stored text template is what exec +// writes for the property left out: the text the widget ships for it, or no text +// at all. Compared per language, falling back to the first shipped text for a +// language it ships none for. +func textTemplateIsDefault(value map[string]any, sc widgetPropSchema) bool { + stored := map[string]string{} + if tt, ok := value["TextTemplate"].(map[string]any); ok && tt != nil { + if template, ok := tt["Template"].(map[string]any); ok && template != nil { + for _, item := range getBsonArrayElements(template["Items"]) { + m, ok := item.(map[string]any) + if !ok { + continue + } + if text := extractString(m["Text"]); text != "" { + stored[extractString(m["LanguageCode"])] = text + } + } + } + } + if len(stored) == 0 { + // Nothing stored: a null template, which is what a property the widget's + // editor config hides is written with, or an empty one. Neither is a + // setting MDL can restate — `key: ''` is written as the widget's text, + // the same as leaving the property out — so printing it would only + // change the description of every grid whose property is hidden. + return true + } + for lang, text := range stored { + shipped, ok := sc.Translations[lang] + if !ok { + shipped = sc.FirstTranslation + } + if text != shipped { + return false + } + } + return true +} + +// valueHoldsSomething reports whether any of the named fields of a widget value +// is set to something other than null, "" or an empty map. +func valueHoldsSomething(value map[string]any, fields ...string) bool { + for _, f := range fields { + switch v := value[f].(type) { + case nil: + case string: + if v != "" { + return true + } + case map[string]any: + if len(v) > 0 { + return true + } + default: + return true + } + } + return false +} + +// dataGridByKeySkip are the grid-level keys MDL spells another way or carries in +// a body: the paging keywords, the selection, the datasource, the columns, the +// slot widgets, and the action slots. (Action-, DataSource-, Widgets-, Object- +// and Selection-typed properties are skipped by type; these are the scalar ones +// with a keyword of their own.) +var dataGridByKeySkip = map[string]bool{ + "pageSize": true, "pagination": true, "pagingPosition": true, "showPagingButtons": true, +} + +// dataGridColumnKeySkip are the column keys extractDataGrid2Column already +// reads under their MDL names. +var dataGridColumnKeySkip = map[string]bool{ + "showContentAs": true, "attribute": true, "header": true, "dynamicText": true, + "tooltip": true, "visible": true, "sortable": true, "resizable": true, + "draggable": true, "hidable": true, "width": true, "size": true, + "alignment": true, "columnClass": true, "wrapText": true, +} + +// galleryByKeySkip is dataGridByKeySkip for a Gallery: the column counts are +// DesktopColumns / TabletColumns / PhoneColumns, and the click trigger has its +// own line. +var galleryByKeySkip = map[string]bool{ + "desktopItems": true, "tabletItems": true, "phoneItems": true, "onClickTrigger": true, +} + +// extractDataGrid2ByKeyProps returns the grid-level settings of a Data grid 2 +// that DESCRIBE has no keyword for and that differ from their defaults, and the +// keys it could not print. enclosingEntity is the entity of the data container +// the grid sits in (what an attribute property linked to no datasource binds +// to), gridEntity the grid's own datasource entity. +func extractDataGrid2ByKeyProps(ctx *ExecContext, w map[string]any, enclosingEntity, gridEntity string) ([]rawExplicitProp, []string) { + return extractWidgetByKeyProps(ctx, w, dataGridByKeySkip, enclosingEntity, gridEntity) +} + +// extractGalleryByKeyProps is extractDataGrid2ByKeyProps for a Gallery, which +// takes its settings by key the same way and described back without them the +// same way (pagination, page size, empty placeholder, state storage, …). +func extractGalleryByKeyProps(ctx *ExecContext, w map[string]any, enclosingEntity, galleryEntity string) ([]rawExplicitProp, []string) { + return extractWidgetByKeyProps(ctx, w, galleryByKeySkip, enclosingEntity, galleryEntity) +} + +func extractWidgetByKeyProps(ctx *ExecContext, w map[string]any, skip map[string]bool, enclosingEntity, ownEntity string) ([]rawExplicitProp, []string) { + obj, ok := w["Object"].(map[string]any) + if !ok { + return nil, nil + } + return nonDefaultPropsByKey(ctx, getBsonArrayElements(obj["Properties"]), widgetPropertySchemas(w), byKeyOptions{ + Skip: skip, + AttrEntity: func(sc widgetPropSchema) string { + if sc.DataSourceProperty != "" { + return ownEntity + } + return enclosingEntity + }, + }) +} + +// extractDataGrid2ColumnByKeyProps is extractDataGrid2ByKeyProps for one +// column's object. +func extractDataGrid2ColumnByKeyProps(ctx *ExecContext, colObj map[string]any, colSchemas map[string]widgetPropSchema, entityContext string) ([]rawExplicitProp, []string) { + return nonDefaultPropsByKey(ctx, getBsonArrayElements(colObj["Properties"]), colSchemas, byKeyOptions{ + Skip: dataGridColumnKeySkip, + AttrEntity: func(widgetPropSchema) string { return entityContext }, + }) +} diff --git a/mdl/executor/cmd_pages_describe_bykey_test.go b/mdl/executor/cmd_pages_describe_bykey_test.go new file mode 100644 index 000000000..ad5ce5d52 --- /dev/null +++ b/mdl/executor/cmd_pages_describe_bykey_test.go @@ -0,0 +1,415 @@ +// SPDX-License-Identifier: Apache-2.0 + +// DESCRIBE has to print a Data grid 2's settings that MDL has no keyword for, +// under the key the widget declares, or a describe -> exec cycle resets each of +// them to the widget's default. +// +// datagrid dg (DataSource: database Shop.Ord, configurationStorageType: localStorage) +// +// exec stores that, `describe page` printed only the keyword properties +// (PageSize, Pagination, …), and re-executing the description wrote the default +// back — silently, at `mx check` 0 errors. +// +// The fixtures here are the shipped Data grid 2 template, not a hand-written +// list of its properties, so "an untouched grid prints nothing" is checked +// against the widget's real schema and real default values. +package executor + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/sdk/widgets" +) + +const dataGridWidgetID = "com.mendix.widget.web.datagrid.Datagrid" + +// normalizeTemplateJSON turns a decoded JSON template into the shape a decoded +// BSON document has: the typed-array marker that opens every array is an int, +// not the float64 encoding/json makes of it. +func normalizeTemplateJSON(v any) any { + switch x := v.(type) { + case map[string]any: + for k, e := range x { + x[k] = normalizeTemplateJSON(e) + } + return x + case []any: + for i, e := range x { + if f, ok := e.(float64); ok && i == 0 { + x[i] = int(f) + continue + } + x[i] = normalizeTemplateJSON(e) + } + return x + } + return v +} + +// dataGridFromTemplate is an untouched Data grid 2: the shipped template's +// schema and the values exec writes for a property left out. +func dataGridFromTemplate(t *testing.T) map[string]any { + t.Helper() + tmpl, err := widgets.GetTemplate(dataGridWidgetID) + if err != nil || tmpl == nil { + t.Fatalf("Data grid 2 template: %v, %v", tmpl, err) + } + raw, err := json.Marshal(map[string]any{"Type": tmpl.Type, "Object": tmpl.Object}) + if err != nil { + t.Fatal(err) + } + var w map[string]any + if err := json.Unmarshal(raw, &w); err != nil { + t.Fatal(err) + } + normalizeTemplateJSON(w) + w["$Type"] = "CustomWidgets$CustomWidget" + w["Name"] = "dg" + return w +} + +// gridValue is the stored Value of the grid-level property key. +func gridValue(t *testing.T, w map[string]any, key string) map[string]any { + t.Helper() + ids := buildPropertyTypeKeyMap(w, false) + for _, p := range getBsonArrayElements(w["Object"].(map[string]any)["Properties"]) { + pm := p.(map[string]any) + if ids[extractBinaryID(pm["TypePointer"])] == key { + return pm["Value"].(map[string]any) + } + } + t.Fatalf("template has no property %q", key) + return nil +} + +func describeGrid(t *testing.T, w map[string]any, enclosing string) rawWidget { + t.Helper() + ctx := (&Executor{}).newExecContext(nil) + got := parseRawWidget(ctx, w, enclosing) + if len(got) != 1 { + t.Fatalf("parseRawWidget returned %d widgets", len(got)) + } + return got[0] +} + +func explicitByKey(ps []rawExplicitProp) map[string]rawExplicitProp { + m := map[string]rawExplicitProp{} + for _, p := range ps { + m[p.Key] = p + } + return m +} + +func explicitKeyList(ps []rawExplicitProp) []string { + var out []string + for _, p := range ps { + out = append(out, p.Key) + } + return out +} + +// An untouched grid describes exactly as before: every value the template holds +// is the widget's default, the text templates hold the text the widget ships +// for them, and a template hidden by the editor config is null. +func TestDataGridByKey_UntouchedGridPrintsNothing(t *testing.T) { + got := describeGrid(t, dataGridFromTemplate(t), "") + if len(got.ExplicitProperties) != 0 { + t.Errorf("untouched grid printed %v; its description must not change", explicitKeyList(got.ExplicitProperties)) + } + if len(got.OmittedProps) != 0 { + t.Errorf("untouched grid reported omitted properties %v", got.OmittedProps) + } +} + +// Every kind of setting a grid has, set away from its default, is printed under +// its own key; the ones left alone are not. +func TestDataGridByKey_PrintsEachNonDefaultKind(t *testing.T) { + w := dataGridFromTemplate(t) + gridValue(t, w, "configurationStorageType")["PrimitiveValue"] = "localStorage" // enumeration + gridValue(t, w, "columnsHidable")["PrimitiveValue"] = "false" // boolean + gridValue(t, w, "refreshInterval")["PrimitiveValue"] = "30" // integer + gridValue(t, w, "rowClass")["Expression"] = "if $currentObject/Open then 'a' else 'b'" + gridValue(t, w, "exportDialogLabel")["TextTemplate"].(map[string]any)["Template"].(map[string]any)["Items"].([]any)[1].(map[string]any)["Text"] = "Saving" + gridValue(t, w, "configurationAttribute")["AttributeRef"] = map[string]any{"Attribute": "Shop.GridPref.Config"} + + got := describeGrid(t, w, "Shop.GridPref") + props := explicitByKey(got.ExplicitProperties) + + want := map[string]string{ + "configurationStorageType": "'localStorage'", + "columnsHidable": "false", + "refreshInterval": "30", + "rowClass": "if $currentObject/Open then 'a' else 'b'", + "exportDialogLabel": "'Saving'", + "configurationAttribute": "Config", + } + ctx := (&Executor{}).newExecContext(nil) + for key, wantValue := range want { + p, ok := props[key] + if !ok { + t.Errorf("%s was not printed; got %v", key, explicitKeyList(got.ExplicitProperties)) + continue + } + if v := explicitPropValue(ctx, p); v != wantValue { + t.Errorf("%s printed as %s, want %s", key, v, wantValue) + } + } + if len(got.ExplicitProperties) != len(want) { + t.Errorf("printed %v, want exactly %d settings", explicitKeyList(got.ExplicitProperties), len(want)) + } +} + +// The attribute is written bare where exec resolves a bare name — inside a data +// container — and qualified where it would not: there is nothing to resolve a +// bare name against on a page-level grid. +func TestDataGridByKey_AttributeIsBareOnlyInsideAContainer(t *testing.T) { + w := dataGridFromTemplate(t) + gridValue(t, w, "configurationAttribute")["AttributeRef"] = map[string]any{"Attribute": "Shop.GridPref.Config"} + + if v := explicitByKey(describeGrid(t, w, "Shop.GridPref").ExplicitProperties)["configurationAttribute"].Value; v != "Config" { + t.Errorf("inside a data view over the entity: %q, want Config", v) + } + // A page parameter's data view names no entity; the property still binds to + // that container's entity. + if v := explicitByKey(describeGrid(t, w, "Pref").ExplicitProperties)["configurationAttribute"].Value; v != "Config" { + t.Errorf("inside a data view over a page parameter: %q, want Config", v) + } + if v := explicitByKey(describeGrid(t, w, "").ExplicitProperties)["configurationAttribute"].Value; v != "Shop.GridPref.Config" { + t.Errorf("on a page-level grid: %q, want the qualified name", v) + } + if v := explicitByKey(describeGrid(t, w, "Shop.Other").ExplicitProperties)["configurationAttribute"].Value; v != "Shop.GridPref.Config" { + t.Errorf("attribute of another entity than the container's: %q, want the qualified name", v) + } +} + +// What MDL already spells another way must not be printed twice: writing +// `PageSize: 25` and `pageSize: 25` would put two values on one property. +func TestDataGridByKey_SkipsWhatHasAKeywordOfItsOwn(t *testing.T) { + w := dataGridFromTemplate(t) + gridValue(t, w, "pageSize")["PrimitiveValue"] = "25" + gridValue(t, w, "pagination")["PrimitiveValue"] = "virtualScrolling" + gridValue(t, w, "pagingPosition")["PrimitiveValue"] = "top" + gridValue(t, w, "showPagingButtons")["PrimitiveValue"] = "auto" + // A Selection-typed property is carried by `Selection:`; and a stray + // PrimitiveValue on it is not a setting. + gridValue(t, w, "itemSelection")["Selection"] = "Multi" + + got := describeGrid(t, w, "") + if len(got.ExplicitProperties) != 0 { + t.Errorf("printed %v for keys MDL already has a keyword for", explicitKeyList(got.ExplicitProperties)) + } + if got.PageSize != "25" || got.Pagination != "virtualScrolling" || got.Selection != "Multi" { + t.Errorf("the keyword properties were not read: PageSize=%q Pagination=%q Selection=%q", + got.PageSize, got.Pagination, got.Selection) + } +} + +// An expression that is only a string literal would, unquoted, be read back as +// the string and stored without its quotes — a different expression. It is +// printed as the quoted text of the expression instead. +func TestDataGridByKey_ExpressionThatIsAStringLiteralIsQuoted(t *testing.T) { + w := dataGridFromTemplate(t) + gridValue(t, w, "rowClass")["Expression"] = "'is-open'" + + ctx := (&Executor{}).newExecContext(nil) + p := explicitByKey(describeGrid(t, w, "").ExplicitProperties)["rowClass"] + if got, want := explicitPropValue(ctx, p), `'''is-open'''`; got != want { + t.Errorf("rowClass printed as %s, want %s", got, want) + } +} + +// A path over associations has no spelling the explicit-key pass reads back. It +// must not be printed as if it had one, and must not vanish. +func TestDataGridByKey_AttributeOverAnAssociationIsNamedNotDropped(t *testing.T) { + w := dataGridFromTemplate(t) + gridValue(t, w, "configurationAttribute")["AttributeRef"] = map[string]any{ + "Attribute": "Shop.GridPref.Config", + "EntityRef": map[string]any{"$Type": "DomainModels$IndirectEntityRef"}, + } + + got := describeGrid(t, w, "Shop.Ord") + if _, printed := explicitByKey(got.ExplicitProperties)["configurationAttribute"]; printed { + t.Errorf("printed an attribute path the writer cannot read back") + } + if len(got.OmittedProps) != 1 || got.OmittedProps[0] != "configurationAttribute" { + t.Errorf("OmittedProps = %v, want [configurationAttribute]", got.OmittedProps) + } + + var buf bytes.Buffer + writeOmittedPropsNote(&buf, " ", got.OmittedProps) + if out := buf.String(); !strings.Contains(out, "-- NOT re-executable: configurationAttribute") { + t.Errorf("note does not name the property:\n%s", out) + } +} + +// A column's own settings follow the same rule, minus the keys describe already +// reads under their MDL names. +func TestDataGridByKey_ColumnSettings(t *testing.T) { + w := dataGridFromTemplate(t) + + schemas := objectListPropertySchemas(w, "columns") + if len(schemas) == 0 { + t.Fatal("no column schema in the template") + } + // The template's columns object-list holds one default column. + var column map[string]any + for _, p := range getBsonArrayElements(w["Object"].(map[string]any)["Properties"]) { + pm := p.(map[string]any) + if objs := getBsonArrayElements(pm["Value"].(map[string]any)["Objects"]); len(objs) > 0 { + column = objs[0].(map[string]any) + } + } + if column == nil { + t.Skip("template carries no default column") + } + + if props, omitted := extractDataGrid2ColumnByKeyProps(nil, column, schemas, "Shop.Ord"); len(props) != 0 || len(omitted) != 0 { + t.Errorf("untouched column printed %v / omitted %v", explicitKeyList(props), omitted) + } + + setColumn := func(key, field string, value any) { + ids := map[string]string{} + for id, sc := range schemas { + ids[id] = sc.Key + } + for _, p := range getBsonArrayElements(column["Properties"]) { + pm := p.(map[string]any) + if ids[extractBinaryID(pm["TypePointer"])] == key { + pm["Value"].(map[string]any)[field] = value + return + } + } + t.Fatalf("column has no property %q", key) + } + setColumn("minWidth", "PrimitiveValue", "manual") + setColumn("minWidthLimit", "PrimitiveValue", "150") + setColumn("allowEventPropagation", "PrimitiveValue", "false") + // Read under their own names by extractDataGrid2Column: not repeated. + setColumn("alignment", "PrimitiveValue", "right") + setColumn("size", "PrimitiveValue", "3") + + props, _ := extractDataGrid2ColumnByKeyProps(nil, column, schemas, "Shop.Ord") + got := explicitByKey(props) + for key, want := range map[string]string{"minWidth": "manual", "minWidthLimit": "150", "allowEventPropagation": "false"} { + if got[key].Value != want { + t.Errorf("column %s = %q, want %q (printed %v)", key, got[key].Value, want, explicitKeyList(props)) + } + } + for _, key := range []string{"alignment", "size"} { + if _, dup := got[key]; dup { + t.Errorf("column %s printed by key as well as under its MDL name", key) + } + } + + // …and DESCRIBE of the whole grid carries them on the column. + described := false + for _, col := range describeGrid(t, w, "Shop.Ord").DataGridColumns { + if explicitByKey(col.ExtraProps)["minWidth"].Value == "manual" { + described = true + } + } + if !described { + t.Error("describe of the grid does not carry the column's minWidth") + } +} + +// The description has to parse, and carry each value under the key it was +// printed with — otherwise it is text that merely looks right. +func TestDataGridByKey_DescriptionReparses(t *testing.T) { + w := dataGridFromTemplate(t) + gridValue(t, w, "configurationStorageType")["PrimitiveValue"] = "localStorage" + gridValue(t, w, "columnsHidable")["PrimitiveValue"] = "false" + gridValue(t, w, "exportDialogLabel")["TextTemplate"].(map[string]any)["Template"].(map[string]any)["Items"].([]any)[1].(map[string]any)["Text"] = "It's saving" + + raw := describeGrid(t, w, "") + raw.DataSource = &rawDataSource{Type: "database", Reference: "Shop.Ord"} + raw.Name = "dg" + + var buf bytes.Buffer + ctx := (&Executor{}).newExecContext(nil) + ctx.Output = &buf + outputWidgetMDLV3(ctx, raw, 1) + described := buf.String() + + prog, errs := visitor.Build("create page Shop.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) {\n" + described + "};") + if len(errs) > 0 { + t.Fatalf("description does not parse: %v\n%s", errs[0], described) + } + _ = prog + for _, want := range []string{ + "configurationStorageType: 'localStorage'", + "columnsHidable: false", + "exportDialogLabel: 'It''s saving'", + } { + if !strings.Contains(described, want) { + t.Errorf("description lacks %q:\n%s", want, described) + } + } + if errs := checkErrors(t, "create page Shop.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) {\n"+described+"};"); len(errs) > 0 { + t.Errorf("check refused the description:\n %s\n%s", strings.Join(errs, "\n "), described) + } +} + +// The empty-state placeholder is a widget slot the grid declares. It is +// printed, because showEmptyPlaceholder: custom is printed and means "look in +// the slot". +func TestDataGridByKey_EmptyPlaceholderSlotIsKept(t *testing.T) { + w := dataGridFromTemplate(t) + gridValue(t, w, "showEmptyPlaceholder")["PrimitiveValue"] = "custom" + gridValue(t, w, "emptyPlaceholder")["Widgets"] = []any{2, map[string]any{ + "$Type": "Forms$DynamicText", "Name": "none_here", + "Content": map[string]any{"Template": map[string]any{"Items": []any{2, + map[string]any{"LanguageCode": "en_US", "Text": "Nothing here"}}}}, + }} + + got := describeGrid(t, w, "") + if len(got.ChildSlots) != 1 || got.ChildSlots[0].Keyword != "emptyplaceholder" { + t.Fatalf("ChildSlots = %+v, want the emptyplaceholder slot only (the control bar has its own block)", got.ChildSlots) + } +} + +// A Gallery takes its settings by key the same way, and had the same loss. Its +// column counts and click trigger already have MDL names and are not repeated. +func TestGalleryByKey_PrintsNonDefaultSettingsOnly(t *testing.T) { + tmpl, err := widgets.GetTemplate("com.mendix.widget.web.gallery.Gallery") + if err != nil || tmpl == nil { + t.Fatalf("Gallery template: %v, %v", tmpl, err) + } + raw, _ := json.Marshal(map[string]any{"Type": tmpl.Type, "Object": tmpl.Object}) + var w map[string]any + if err := json.Unmarshal(raw, &w); err != nil { + t.Fatal(err) + } + normalizeTemplateJSON(w) + w["$Type"] = "CustomWidgets$CustomWidget" + w["Name"] = "gl" + + if got := describeGrid(t, w, ""); len(got.ExplicitProperties) != 0 || len(got.OmittedProps) != 0 { + t.Errorf("untouched gallery printed %v / omitted %v", explicitKeyList(got.ExplicitProperties), got.OmittedProps) + } + + gridValue(t, w, "pagination")["PrimitiveValue"] = "loadMore" + gridValue(t, w, "pageSize")["PrimitiveValue"] = "5" + gridValue(t, w, "desktopItems")["PrimitiveValue"] = "3" + gridValue(t, w, "onClickTrigger")["PrimitiveValue"] = "double" + + got := describeGrid(t, w, "") + props := explicitByKey(got.ExplicitProperties) + for key, want := range map[string]string{"pagination": "loadMore", "pageSize": "5"} { + if props[key].Value != want { + t.Errorf("gallery %s = %q, want %q (printed %v)", key, props[key].Value, want, explicitKeyList(got.ExplicitProperties)) + } + } + for _, key := range []string{"desktopItems", "onClickTrigger"} { + if _, dup := props[key]; dup { + t.Errorf("gallery %s printed by key as well as under its MDL name", key) + } + } + if got.DesktopColumns != "3" || got.OnClickTrigger != "double" { + t.Errorf("MDL-named properties not read: DesktopColumns=%q OnClickTrigger=%q", got.DesktopColumns, got.OnClickTrigger) + } +} diff --git a/mdl/executor/cmd_pages_describe_datasource.go b/mdl/executor/cmd_pages_describe_datasource.go index a71fdd7a4..567d93030 100644 --- a/mdl/executor/cmd_pages_describe_datasource.go +++ b/mdl/executor/cmd_pages_describe_datasource.go @@ -104,7 +104,7 @@ func parseDataSource(ds map[string]any) *rawDataSource { } case dsType == dsTypeAssociation: if path, ctxVar := associationSourcePath(ds); path != "" { - return &rawDataSource{Type: "association", Reference: path, ContextVariable: ctxVar} + return &rawDataSource{Type: "association", Reference: path, ContextVariable: ctxVar, Destination: associationSourceDestination(ds)} } case dsType == dsTypeCustomWidgetDatabase: if got := parseCustomWidgetDatabaseSource(ds); got != nil { @@ -329,7 +329,7 @@ func entityPathExpr(ds *rawDataSource) string { // or straight from a page or snippet parameter. func parseContextSource(ds map[string]any) *rawDataSource { if path, ctxVar := associationSourcePath(ds); path != "" { - return &rawDataSource{Type: "association", Reference: path, ContextVariable: ctxVar} + return &rawDataSource{Type: "association", Reference: path, ContextVariable: ctxVar, Destination: associationSourceDestination(ds)} } if sv, ok := ds["SourceVariable"].(map[string]any); ok && sv != nil { if param := extractString(sv["PageParameter"]); param != "" { diff --git a/mdl/executor/cmd_pages_describe_flowcontext.go b/mdl/executor/cmd_pages_describe_flowcontext.go index 4b71321b0..6ddd7a6ca 100644 --- a/mdl/executor/cmd_pages_describe_flowcontext.go +++ b/mdl/executor/cmd_pages_describe_flowcontext.go @@ -11,14 +11,21 @@ import ( // dataSourceEntityContext returns the entity a datasource puts in scope for the // widgets inside it. // -// For a database or association source the datasource reference *is* the entity. -// For a microflow or nanoflow source it is the flow's own qualified name, so using -// it directly named the flow as the context entity — `-- Context: $currentObject +// For a database source the datasource reference *is* the entity. For a +// microflow or nanoflow source it is the flow's own qualified name, so using it +// directly named the flow as the context entity — `-- Context: $currentObject // (Module.GetOrders)` instead of `(Module.Order)` — and any consumer that resolves // attributes against the context was looking up the wrong element. The flow's // return type is resolved instead, falling back to the reference when the flow // cannot be found or does not return an object/list, which is what the caller // used before. +// +// For an association source the reference is the association's own qualified +// name (`Module.Order_Customer`), the same mistake with a different element. The +// entity it reaches is the destination stored on its last hop, which already +// accounts for the direction travelled (owner to child or back, reference or +// reference set) and for a specialization of the association's end; it is read +// rather than re-derived. A source that stores none keeps the reference. func dataSourceEntityContext(ctx *ExecContext, ds *rawDataSource) string { if ds == nil || ds.Reference == "" { return "" @@ -28,6 +35,10 @@ func dataSourceEntityContext(ctx *ExecContext, ds *rawDataSource) string { if entity := flowReturnEntity(ctx, ds.Type, ds.Reference); entity != "" { return entity } + case "association": + if ds.Destination != "" { + return ds.Destination + } } return ds.Reference } diff --git a/mdl/executor/cmd_pages_describe_flowcontext_test.go b/mdl/executor/cmd_pages_describe_flowcontext_test.go index 9fcd9fba9..a65171260 100644 --- a/mdl/executor/cmd_pages_describe_flowcontext_test.go +++ b/mdl/executor/cmd_pages_describe_flowcontext_test.go @@ -37,8 +37,10 @@ func TestDataTypeEntity(t *testing.T) { } } -// TestDataSourceEntityContext_NonFlowSources: a database or association source -// already names its entity, so it must pass straight through. +// TestDataSourceEntityContext_NonFlowSources: a database source already names its +// entity, so it passes straight through. An association source with no stored +// destination (cmd_pages_describe_assoc_context_test.go covers one that has it) +// and a parameter source keep their reference too. func TestDataSourceEntityContext_NonFlowSources(t *testing.T) { for _, dsType := range []string{"database", "association", "parameter"} { t.Run(dsType, func(t *testing.T) { diff --git a/mdl/executor/cmd_pages_describe_omitted.go b/mdl/executor/cmd_pages_describe_omitted.go index 00ad66a39..4d97cc06b 100644 --- a/mdl/executor/cmd_pages_describe_omitted.go +++ b/mdl/executor/cmd_pages_describe_omitted.go @@ -138,3 +138,16 @@ func writeOmittedContainerNote(out io.Writer, prefix string, omitted []string) { fmt.Fprintf(out, "%s-- reproduce. Re-running this script would DROP it. Inspect the widget with\n", prefix) fmt.Fprintf(out, "%s-- `mxcli widget describe ` and re-add the block by hand.\n", prefix) } + +// writeOmittedPropsNote emits, as a comment, the properties whose stored value +// differs from the default and that DESCRIBE has no spelling for — the property +// counterpart of writeOmittedContainerNote. The output still parses and +// re-executes; it simply does not carry them, and says so. +func writeOmittedPropsNote(out io.Writer, prefix string, omitted []string) { + if len(omitted) == 0 { + return + } + fmt.Fprintf(out, "%s-- NOT re-executable: %s — stored with a non-default value that DESCRIBE has\n", + prefix, strings.Join(omitted, ", ")) + fmt.Fprintf(out, "%s-- no spelling for. Re-running this script would reset it to the widget's default.\n", prefix) +} diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index a094d5529..c15d1d424 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -100,6 +100,21 @@ func looksNumeric(s string) bool { return body != "." } +// appendExplicitProps appends a pluggable widget's settings printed under their +// own key: `key: value`, and a text template's `{N}` bindings as the +// `Params` companion — a `{1}` re-executed without its parameter is CE0720, +// so the companion travels with the text it belongs to (#575). +func appendExplicitProps(ctx *ExecContext, props []string, explicit []rawExplicitProp) []string { + for _, ep := range explicit { + props = append(props, fmt.Sprintf("%s: %s", ep.Key, explicitPropValue(ctx, ep))) + if len(ep.Params) > 0 { + props = append(props, fmt.Sprintf("%sParams: (%s)", + ep.Key, strings.Join(formatParametersV3(ep.Params), ", "))) + } + } + return props +} + // appendDataGridPagingProps appends non-default paging properties for DataGrid2. func appendDataGridPagingProps(props []string, w rawWidget) []string { if w.PageSize != "" && w.PageSize != "20" { @@ -765,12 +780,17 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { props = appendNamedActionProps(props, w) // Add paging properties if non-default props = appendDataGridPagingProps(props, w) + // Every other setting that differs from its default, under the + // widget's own key (configurationStorageType, columnsHidable, …). + props = appendExplicitProps(ctx, props, w.ExplicitProperties) props = appendAppearanceProps(ctx, props, w) - // Output CONTROLBAR and columns as children - hasContent := len(w.ControlBar) > 0 || len(w.DataGridColumns) > 0 + // Output CONTROLBAR, the widget slots and columns as children + hasContent := len(w.ControlBar) > 0 || len(w.DataGridColumns) > 0 || + len(w.ChildSlots) > 0 || len(w.OmittedProps) > 0 if hasContent { formatWidgetProps(ctx.Output, prefix, header, props, " {\n") outputDataContainerContext(ctx.Output, prefix+" ", w.Name, w.EntityContext, true) + writeOmittedPropsNote(ctx.Output, prefix+" ", w.OmittedProps) // Output CONTROLBAR section if control bar widgets present if len(w.ControlBar) > 0 { fmt.Fprintf(ctx.Output, "%s controlbar {\n", prefix) @@ -779,6 +799,8 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { } fmt.Fprintf(ctx.Output, "%s }\n", prefix) } + // The empty-state placeholder, and any other slot the grid declares. + outputChildSlots(ctx, w.ChildSlots, prefix+" ", indent+1) // Output columns. A DataGrid 2 column has no name in the model, // so none is written: the one derived from its attribute gave two // columns over one attribute the same name (#749). ALTER PAGE @@ -818,12 +840,17 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { props = append(props, actionProp("onClick", w.OnClick)) } props = appendNamedActionProps(props, w) + // Every other setting that differs from its default, under the + // widget's own key (pagination, pageSize, …). + props = appendExplicitProps(ctx, props, w.ExplicitProperties) props = appendAppearanceProps(ctx, props, w) // Output filter and content widgets - hasContent := len(w.Children) > 0 || len(w.FilterWidgets) > 0 + hasContent := len(w.Children) > 0 || len(w.FilterWidgets) > 0 || + len(w.ChildSlots) > 0 || len(w.OmittedProps) > 0 if hasContent { formatWidgetProps(ctx.Output, prefix, header, props, " {\n") outputDataContainerContext(ctx.Output, prefix+" ", w.Name, w.EntityContext, true) + writeOmittedPropsNote(ctx.Output, prefix+" ", w.OmittedProps) // Output FILTER section if filter widgets present if len(w.FilterWidgets) > 0 { fmt.Fprintf(ctx.Output, "%s filter {\n", prefix) @@ -840,6 +867,8 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { } fmt.Fprintf(ctx.Output, "%s }\n", prefix) } + // The empty-state placeholder, and any other slot the gallery declares. + outputChildSlots(ctx, w.ChildSlots, prefix+" ", indent+1) fmt.Fprintf(ctx.Output, "%s}\n", prefix) } else { formatWidgetProps(ctx.Output, prefix, header, props, "\n") @@ -884,15 +913,7 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { // the datasource is gone. The DESCRIBE text was byte-identical before // and after, so only mx check separated them (#956). props = appendWidgetDataSources(ctx, props, w) - for _, ep := range w.ExplicitProperties { - props = append(props, fmt.Sprintf("%s: %s", ep.Key, explicitPropValue(ctx, ep))) - // A `{1}` re-executed without its parameter is CE0720, so the - // companion travels with the text it belongs to (#575). - if len(ep.Params) > 0 { - props = append(props, fmt.Sprintf("%sParams: (%s)", - ep.Key, strings.Join(formatParametersV3(ep.Params), ", "))) - } - } + props = appendExplicitProps(ctx, props, w.ExplicitProperties) // onClick action (ledger #67 — reported on CustomChart) if w.OnClick != "" { props = append(props, actionProp("onClick", w.OnClick)) @@ -1304,6 +1325,8 @@ func outputDataGrid2ColumnV3(ctx *ExecContext, prefix string, col rawDataGridCol if col.Tooltip != "" { props = append(props, fmt.Sprintf("Tooltip: %s", mdlQuote(ctx, col.Tooltip))) } + // Any other column setting that differs from its default, by its own key. + props = appendExplicitProps(ctx, props, col.ExtraProps) // No name: Mendix stores none on a DataGrid 2 column (#749). header := "column" @@ -1311,11 +1334,12 @@ func outputDataGrid2ColumnV3(ctx *ExecContext, prefix string, col rawDataGridCol // the builder routes a filter back to its own slot by widget type. Emitting // only the content widgets deleted the filter of every custom-content column // on a describe→exec round trip (ako/mxcli#489). - hasContent := len(col.ContentWidgets) > 0 || len(col.FilterWidgets) > 0 + hasContent := len(col.ContentWidgets) > 0 || len(col.FilterWidgets) > 0 || len(col.OmittedProps) > 0 if hasContent { // Output column with content block formatWidgetProps(ctx.Output, prefix, header, props, " {\n") + writeOmittedPropsNote(ctx.Output, prefix+" ", col.OmittedProps) for _, widget := range col.ContentWidgets { outputWidgetMDLV3(ctx, widget, len(prefix)/2+1) } diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index 3eeee111b..3347af919 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -522,6 +522,17 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s } widget.DataGridColumns = extractDataGrid2Columns(ctx, w, widget.EntityContext) widget.ControlBar = extractDataGrid2ControlBar(ctx, w) + // Every other grid setting that differs from its default, by the + // widget's own key, and the widget slots other than the control bar + // (the empty-state placeholder). Without them a describe -> exec + // cycle reset the personalization, selection, paging and label + // settings to the widget's defaults. + widget.ExplicitProperties, widget.OmittedProps = extractDataGrid2ByKeyProps(ctx, w, inheritedCtx, widget.EntityContext) + for _, slot := range extractChildSlots(ctx, w, widget.EntityContext) { + if slot.PropertyKey != "filtersPlaceholder" { + widget.ChildSlots = append(widget.ChildSlots, slot) + } + } // onClick action (ledger #67): read the client action back with full // parameter mappings so a describe round-trip re-emits the onClick. // By SOURCE, not by the literal key — the widget may store it as @@ -548,6 +559,16 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s return extractGalleryContent(ctx, w, widget.EntityContext) }) widget.FilterWidgets = extractGalleryFilters(ctx, w) + // Pagination, page size, state storage and the rest of the gallery's + // settings that differ from their defaults, by the widget's own key, + // and the slots other than `template` and `filter` (the empty-state + // placeholder). + widget.ExplicitProperties, widget.OmittedProps = extractGalleryByKeyProps(ctx, w, inheritedCtx, widget.EntityContext) + for _, slot := range extractChildSlots(ctx, w, widget.EntityContext) { + if slot.PropertyKey != "content" && slot.PropertyKey != "filtersPlaceholder" { + widget.ChildSlots = append(widget.ChildSlots, slot) + } + } // The row click and the other two action slots, read the way the // data grid's are: the click by SOURCE, the rest by the widget's own // key. Without them the write fix for #842 was one-way — describe → diff --git a/mdl/executor/cmd_pages_describe_pluggable.go b/mdl/executor/cmd_pages_describe_pluggable.go index 9a688efe5..8b6ed80e6 100644 --- a/mdl/executor/cmd_pages_describe_pluggable.go +++ b/mdl/executor/cmd_pages_describe_pluggable.go @@ -244,12 +244,14 @@ func extractDataGrid2Columns(ctx *ExecContext, w map[string]any, entityContext . entCtx = entityContext[0] } var columns []rawDataGridColumn + colSchemas := objectListPropertySchemas(w, "columns") for _, colObj := range objects { colMap, ok := colObj.(map[string]any) if !ok { continue } col := extractDataGrid2Column(ctx, colMap, colPropKeyMap, entCtx) + col.ExtraProps, col.OmittedProps = extractDataGrid2ColumnByKeyProps(ctx, colMap, colSchemas, entCtx) // Every object of the resolved `columns` list is a column. The // actions column has no attribute and an empty header, and keeping // only columns with one of those dropped it with all its widgets, diff --git a/mdl/executor/cmd_rules_create.go b/mdl/executor/cmd_rules_create.go index 1e0f254f4..d7af12b89 100644 --- a/mdl/executor/cmd_rules_create.go +++ b/mdl/executor/cmd_rules_create.go @@ -168,12 +168,12 @@ func execCreateRule(ctx *ExecContext, s *ast.CreateRuleStmt) error { if entityID == "" { // Bare qualified name in microflow context is treated as TypeEntity by the // visitor, but it may actually be an enumeration. Try enum lookup before failing. - if found := findEnumeration(ctx, p.Type.EntityRef.Module, p.Type.EntityRef.Name); found != nil { - s.Parameters[i].Type = ast.DataType{Kind: ast.TypeEnumeration, EnumRef: p.Type.EntityRef} + if retyped, ok := bareEnumerationType(ctx, p.Type); ok { + s.Parameters[i].Type = retyped p = s.Parameters[i] } else { return mdlerrors.NewNotFoundMsg("entity", p.Type.EntityRef.Module+"."+p.Type.EntityRef.Name, - fmt.Sprintf("entity '%s.%s' not found for parameter '%s'", p.Type.EntityRef.Module, p.Type.EntityRef.Name, p.Name)) + fmt.Sprintf("entity or enumeration '%s.%s' not found for parameter '%s'", p.Type.EntityRef.Module, p.Type.EntityRef.Name, p.Name)) } } } @@ -200,8 +200,13 @@ func execCreateRule(ctx *ExecContext, s *ast.CreateRuleStmt) error { if s.ReturnType.Type.EntityRef != nil && !isBuiltinModuleEntity(s.ReturnType.Type.EntityRef.Module) { entityID := entityResolver(*s.ReturnType.Type.EntityRef) if entityID == "" { - return mdlerrors.NewNotFoundMsg("entity", s.ReturnType.Type.EntityRef.Module+"."+s.ReturnType.Type.EntityRef.Name, - fmt.Sprintf("entity '%s.%s' not found for return type", s.ReturnType.Type.EntityRef.Module, s.ReturnType.Type.EntityRef.Name)) + // A bare qualified name may be an enumeration, as for parameters above. + if retyped, ok := bareEnumerationType(ctx, s.ReturnType.Type); ok && s.ReturnType.Type.Kind == ast.TypeEntity { + s.ReturnType.Type = retyped + } else { + return mdlerrors.NewNotFoundMsg("entity", s.ReturnType.Type.EntityRef.Module+"."+s.ReturnType.Type.EntityRef.Name, + fmt.Sprintf("entity or enumeration '%s.%s' not found for return type", s.ReturnType.Type.EntityRef.Module, s.ReturnType.Type.EntityRef.Name)) + } } } if s.ReturnType.Type.Kind == ast.TypeEnumeration && s.ReturnType.Type.EnumRef != nil { diff --git a/mdl/executor/enum_return_type_pedapp_test.go b/mdl/executor/enum_return_type_pedapp_test.go new file mode 100644 index 000000000..7ffcc50f1 --- /dev/null +++ b/mdl/executor/enum_return_type_pedapp_test.go @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "context" + "fmt" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/mendixlabs/mxcli/model" +) + +// check -p and exec must agree on a bare `Module.Name` return type that names +// an enumeration: check resolves the name against entities AND enumerations, +// so exec has to as well. Run on the Studio Pro-authored PedApp fixture, with a +// control that stores the explicit `enum Module.Name` spelling. + +const enumReturnSetup = "mdl 1;\ncreate enumeration MyFirstModule.Variant ( Original 'Original', Released 'Released', None 'None' );" + +// enumReturnDecl is one flow declaration returning `spelling`, named name. +func enumReturnDecl(kind, name, spelling string) string { + switch kind { + case "microflow": + return fmt.Sprintf("create or modify microflow MyFirstModule.%s () returns %s as $Variant begin return MyFirstModule.Variant.Original; end;", name, spelling) + case "nanoflow": + return fmt.Sprintf("create or modify nanoflow MyFirstModule.%s () returns %s as $Variant begin return MyFirstModule.Variant.Original; end;", name, spelling) + default: + return fmt.Sprintf("create or modify rule MyFirstModule.%s ($c: MyFirstModule.Variant) returns %s as $Variant begin return MyFirstModule.Variant.Original; end;", name, spelling) + } +} + +func TestCheckExecAgree_BareEnumerationReturnType(t *testing.T) { + for _, kind := range []string{"microflow", "nanoflow", "rule"} { + t.Run(kind, func(t *testing.T) { + exec, out, dir := openPedAppCopy(t) + if err := agreeExec(t, exec, enumReturnSetup); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + // Control: the explicit spellings both accept. + assertAgree(t, exec, dir, "mdl 1;\n"+enumReturnDecl(kind, "Ctl", "enum MyFirstModule.Variant"), "") + assertAgree(t, exec, dir, "mdl 1;\n"+enumReturnDecl(kind, "Ctl2", "Enumeration(MyFirstModule.Variant)"), "") + // The bare name: check and exec both accept. + assertAgree(t, exec, dir, "mdl 1;\n"+enumReturnDecl(kind, "Bare", "MyFirstModule.Variant"), "") + // A name that is neither an entity nor an enumeration: both refuse. + nope := "mdl 1;\n" + enumReturnDecl(kind, "NopeFlow", "MyFirstModule.Nope") + assertAgree(t, exec, dir, nope, "MyFirstModule.Nope") + // Both say the name may have meant either kind. + if got := strings.Join(agreeCheck(t, exec, dir, nope), "\n"); !strings.Contains(got, "return type of "+kind+" MyFirstModule.NopeFlow: entity or enumeration MyFirstModule.Nope does not exist") { + t.Errorf("check must name both kinds, reported:\n%s", got) + } + if err := agreeExec(t, exec, nope); err == nil || !strings.Contains(err.Error(), "entity or enumeration 'MyFirstModule.Nope' not found for return type") { + t.Errorf("exec must name both kinds, got: %v", err) + } + }) + } +} + +// A rule that returns an entity is still refused by both: the fallback widens +// what a bare name can mean, not what a rule may return. +func TestCheckExecAgree_RuleReturningEntity(t *testing.T) { + exec, out, dir := openPedAppCopy(t) + setup := enumReturnSetup + "\ncreate persistent entity MyFirstModule.Thing (Name: String(10));" + if err := agreeExec(t, exec, setup); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + assertAgree(t, exec, dir, "mdl 1;\n"+enumReturnDecl("rule", "Bad", "MyFirstModule.Thing"), + "a rule must return a Boolean or an enumeration, not Entity") +} + +// The same script in one run: the enumeration is created by the script itself. +func TestCheckExecAgree_BareEnumerationReturnType_SameScript(t *testing.T) { + for _, kind := range []string{"microflow", "nanoflow", "rule"} { + t.Run(kind, func(t *testing.T) { + exec, _, dir := openPedAppCopy(t) + assertAgree(t, exec, dir, enumReturnSetup+"\n"+enumReturnDecl(kind, "Bare", "MyFirstModule.Variant"), "") + }) + } +} + +// What the bare spelling stores equals what `enum X` stores, byte for byte +// apart from the document's own id and name. +func TestBareEnumerationReturnType_StoredBytesEqualExplicit(t *testing.T) { + for _, kind := range []string{"microflow", "nanoflow", "rule"} { + t.Run(kind, func(t *testing.T) { + exec, out, _ := openPedAppCopy(t) + script := enumReturnSetup + "\n" + + enumReturnDecl(kind, "Explicit", "enum MyFirstModule.Variant") + "\n" + + enumReturnDecl(kind, "Function", "Enumeration(MyFirstModule.Variant)") + "\n" + + enumReturnDecl(kind, "Bare", "MyFirstModule.Variant") + if err := agreeExec(t, exec, script); err != nil { + t.Fatalf("exec: %v\n%s", err, out.String()) + } + explicit := normalisedFlowBytes(t, exec, kind, "Explicit") + for _, name := range []string{"Function", "Bare"} { + if got := normalisedFlowBytes(t, exec, kind, name); got != explicit { + t.Errorf("%s stores something other than the explicit `enum` spelling:\n got: %s\nwant: %s", name, got, explicit) + } + } + }) + } +} + +// normalisedFlowBytes returns the stored unit of MyFirstModule. as +// extended JSON with the identities removed: every binary value (the $ID, the +// flow pointers, the StableId are all GUIDs) and the document's own name. +func normalisedFlowBytes(t *testing.T, exec *Executor, kind, name string) string { + t.Helper() + ctx := exec.newExecContext(context.Background()) + var id model.ID + switch kind { + case "microflow": + all, err := ctx.Backend.ListMicroflows() + if err != nil { + t.Fatal(err) + } + for _, m := range all { + if m.Name == name { + id = m.ID + } + } + case "nanoflow": + all, err := ctx.Backend.ListNanoflows() + if err != nil { + t.Fatal(err) + } + for _, m := range all { + if m.Name == name { + id = m.ID + } + } + default: + all, err := ctx.Backend.ListRules() + if err != nil { + t.Fatal(err) + } + for _, m := range all { + if m.Name == name { + id = m.ID + } + } + } + if id == "" { + t.Fatalf("%s MyFirstModule.%s not found", kind, name) + } + raw, err := ctx.Backend.GetRawUnitBytes(id) + if err != nil { + t.Fatal(err) + } + var d bson.D + if err := bson.Unmarshal(raw, &d); err != nil { + t.Fatal(err) + } + out, err := bson.MarshalExtJSON(stripFlowIdentity(d), false, false) + if err != nil { + t.Fatal(err) + } + return string(out) +} + +// stripFlowIdentity drops every binary value and the top-level Name, recursively. +func stripFlowIdentity(v bson.D) bson.D { + var walk func(x any, top bool) any + walk = func(x any, top bool) any { + switch t := x.(type) { + case bson.D: + var out bson.D + for _, e := range t { + if _, isID := e.Value.(primitive.Binary); isID || (top && e.Key == "Name") { + continue + } + out = append(out, bson.E{Key: e.Key, Value: walk(e.Value, false)}) + } + return out + case bson.A: + out := make(bson.A, len(t)) + for i, e := range t { + out[i] = walk(e, false) + } + return out + default: + return x + } + } + return walk(v, true).(bson.D) +} diff --git a/mdl/executor/enum_return_type_test.go b/mdl/executor/enum_return_type_test.go new file mode 100644 index 000000000..4de54fc99 --- /dev/null +++ b/mdl/executor/enum_return_type_test.go @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// A bare `Module.Name` return type is ambiguous: the parser cannot tell an +// entity from an enumeration and records an entity reference. exec already +// resolved that for PARAMETERS (entity first, then enumeration); the return +// types of a microflow, a nanoflow and a rule did not, so a script that +// `check` accepted failed at exec with "entity 'Shop.Variant' not found for +// return type". + +// enumReturnCapture records what the create handlers hand to the backend. +type enumReturnCapture struct { + microflow *microflows.Microflow + nanoflow *microflows.Nanoflow + rule *microflows.Rule +} + +// enumReturnCtx builds a mock project with module Shop, an enumeration +// Shop.Variant, an entity Shop.Customer and, when twin is set, an entity that +// shares the enumeration's name. +func enumReturnCtx(t *testing.T, twin bool) (*ExecContext, *enumReturnCapture) { + t.Helper() + mod := mkModule("Shop") + enum := mkEnumeration(mod.ID, "Variant", "Original", "Released") + ents := []*domainmodel.Entity{mkEntity(mod.ID, "Customer")} + if twin { + ents = append(ents, mkEntity(mod.ID, "Variant")) + } + dm := mkDomainModel(mod.ID, ents...) + h := mkHierarchy(mod) + withContainer(h, enum.ContainerID, mod.ID) + + got := &enumReturnCapture{} + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListEnumerationsFunc: func() ([]*model.Enumeration, error) { return []*model.Enumeration{enum}, nil }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { return []*domainmodel.DomainModel{dm}, nil }, + ListMicroflowsFunc: func() ([]*microflows.Microflow, error) { return nil, nil }, + ListNanoflowsFunc: func() ([]*microflows.Nanoflow, error) { return nil, nil }, + ListRulesFunc: func() ([]*microflows.Rule, error) { return nil, nil }, + CreateMicroflowFunc: func(mf *microflows.Microflow) error { got.microflow = mf; return nil }, + CreateNanoflowFunc: func(nf *microflows.Nanoflow) error { got.nanoflow = nf; return nil }, + CreateRuleFunc: func(r *microflows.Rule) error { got.rule = r; return nil }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + return ctx, got +} + +// enumReturnFlows are the three declaration sites, each with the return type +// spelled as %s. +var enumReturnFlows = map[string]string{ + "microflow": "create or modify microflow Shop.SUB_Pick () returns %s as $Variant begin return Shop.Variant.Original; end;", + "nanoflow": "create or modify nanoflow Shop.NF_Pick () returns %s as $Variant begin return Shop.Variant.Original; end;", + "rule": "create or modify rule Shop.RL_Pick ($c: Shop.Customer) returns %s as $Variant begin return Shop.Variant.Original; end;", +} + +// execEnumReturn parses and executes one flow declaration. +func execEnumReturn(t *testing.T, ctx *ExecContext, kind, returnSpelling string) error { + t.Helper() + src := "mdl 1;\n" + fmt.Sprintf(enumReturnFlows[kind], returnSpelling) + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v\n%s", errs[0], src) + } + switch s := prog.Statements[0].(type) { + case *ast.CreateMicroflowStmt: + return execCreateMicroflow(ctx, s) + case *ast.CreateNanoflowStmt: + return execCreateNanoflow(ctx, s) + case *ast.CreateRuleStmt: + return execCreateRule(ctx, s) + default: + t.Fatalf("unexpected statement %T", s) + return nil + } +} + +func (c *enumReturnCapture) returnType(kind string) microflows.DataType { + switch kind { + case "microflow": + return c.microflow.ReturnType + case "nanoflow": + return c.nanoflow.ReturnType + default: + return c.rule.ReturnType + } +} + +func TestBareEnumReturnType_StoredAsEnumeration(t *testing.T) { + for kind := range enumReturnFlows { + t.Run(kind, func(t *testing.T) { + ctx, got := enumReturnCtx(t, false) + if err := execEnumReturn(t, ctx, kind, "Shop.Variant"); err != nil { + t.Fatalf("bare enumeration return type must be accepted: %v", err) + } + et, ok := got.returnType(kind).(*microflows.EnumerationType) + if !ok { + t.Fatalf("return type = %T, want *EnumerationType", got.returnType(kind)) + } + if et.EnumerationQualifiedName != "Shop.Variant" { + t.Errorf("enumeration = %q, want Shop.Variant", et.EnumerationQualifiedName) + } + }) + } +} + +// The bare spelling must store exactly what the explicit spellings store. +func TestBareEnumReturnType_EqualsExplicitSpellings(t *testing.T) { + for kind := range enumReturnFlows { + t.Run(kind, func(t *testing.T) { + var stored []*microflows.EnumerationType + for _, spelling := range []string{"enum Shop.Variant", "Enumeration(Shop.Variant)", "Shop.Variant"} { + ctx, got := enumReturnCtx(t, false) + if err := execEnumReturn(t, ctx, kind, spelling); err != nil { + t.Fatalf("%q: %v", spelling, err) + } + et, ok := got.returnType(kind).(*microflows.EnumerationType) + if !ok { + t.Fatalf("%q: return type = %T, want *EnumerationType", spelling, got.returnType(kind)) + } + stored = append(stored, et) + } + for i := 1; i < len(stored); i++ { + if stored[i].EnumerationQualifiedName != stored[0].EnumerationQualifiedName { + t.Errorf("spelling %d stored %+v, explicit spelling stored %+v", i, stored[i], stored[0]) + } + } + }) + } +} + +// An unknown name names both candidates, so the user is not told to look for an +// entity when they may have meant an enumeration. +func TestBareEnumReturnType_UnknownName(t *testing.T) { + for kind := range enumReturnFlows { + t.Run(kind, func(t *testing.T) { + ctx, _ := enumReturnCtx(t, false) + err := execEnumReturn(t, ctx, kind, "Shop.Nope") + assertError(t, err) + assertContainsStr(t, err.Error(), "entity or enumeration 'Shop.Nope' not found for return type") + }) + } +} + +// A bare name that is an entity stays an entity, and wins when an entity and an +// enumeration share the name — the order the parameter path already has. +func TestBareEnumReturnType_EntityStaysEntity(t *testing.T) { + for _, kind := range []string{"microflow", "nanoflow"} { + t.Run(kind, func(t *testing.T) { + // Shop.Customer is an entity only. + ctx, got := enumReturnCtx(t, false) + if err := execEnumReturn(t, ctx, kind, "Shop.Customer"); err != nil { + t.Fatalf("entity return type: %v", err) + } + ot, ok := got.returnType(kind).(*microflows.ObjectType) + if !ok || ot.EntityQualifiedName != "Shop.Customer" { + t.Fatalf("return type = %#v, want ObjectType Shop.Customer", got.returnType(kind)) + } + + // Shop.Variant is both an entity and an enumeration: the entity wins. + ctx, got = enumReturnCtx(t, true) + if err := execEnumReturn(t, ctx, kind, "Shop.Variant"); err != nil { + t.Fatalf("twin return type: %v", err) + } + if ot, ok := got.returnType(kind).(*microflows.ObjectType); !ok || ot.EntityQualifiedName != "Shop.Variant" { + t.Errorf("return type = %#v, want ObjectType Shop.Variant (entity wins)", got.returnType(kind)) + } + }) + } +} + +// The parameter path is the model for the return path: the same bare name must +// resolve the same way in either position. +func TestBareEnumParameter_StillResolves(t *testing.T) { + ctx, got := enumReturnCtx(t, false) + src := "mdl 1;\ncreate or modify microflow Shop.SUB_Take ($v: Shop.Variant) begin return; end;" + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatal(errs[0]) + } + if err := execCreateMicroflow(ctx, prog.Statements[0].(*ast.CreateMicroflowStmt)); err != nil { + t.Fatal(err) + } + if _, ok := got.microflow.Parameters[0].Type.(*microflows.EnumerationType); !ok { + t.Fatalf("parameter type = %T, want *EnumerationType", got.microflow.Parameters[0].Type) + } +} diff --git a/mdl/executor/exec_context.go b/mdl/executor/exec_context.go index 65ed39287..c813e1e3c 100644 --- a/mdl/executor/exec_context.go +++ b/mdl/executor/exec_context.go @@ -64,6 +64,12 @@ type ExecContext struct { // flow the script creates further down is accepted (mendixlabs/mxcli#1405). forwardFlowCalls bool + // scriptTargets are the pages, microflows and nanoflows the program being + // run declares, set under the same all-or-nothing run as scriptFlows + // (Executor.enterScriptFlows). A navigation target is stored by name, so one + // the script creates further down resolves there, as a flow call does. + scriptTargets map[defKey]bool + // Format controls output formatting (table, json, etc.). Format OutputFormat diff --git a/mdl/executor/executor.go b/mdl/executor/executor.go index f7e913792..f5c6c6bc4 100644 --- a/mdl/executor/executor.go +++ b/mdl/executor/executor.go @@ -261,6 +261,7 @@ type Executor struct { // scriptFlows then holds every flow the program being run declares. forwardFlowCalls bool scriptFlows map[string]*selfFlow + scriptTargets map[defKey]bool // pages and flows the program declares, same run settings map[string]any cache *executorCache catalog *catalog.Catalog @@ -539,11 +540,15 @@ func (e *Executor) enterScriptFlows(prog *ast.Program) func() { if !e.forwardFlowCalls { return func() {} } - prev := e.scriptFlows + prev, prevTargets := e.scriptFlows, e.scriptTargets flows := make(map[string]*selfFlow, len(prev)) for k, v := range prev { flows[k] = v } + targets := make(map[defKey]bool, len(prevTargets)) + for k, v := range prevTargets { + targets[k] = v + } for _, stmt := range prog.Statements { switch s := stmt.(type) { case *ast.CreateMicroflowStmt: @@ -551,9 +556,16 @@ func (e *Executor) enterScriptFlows(prog *ast.Program) func() { case *ast.CreateNanoflowStmt: declareScriptFlow(flows, true, s.Name, s.ReturnType) } + // What a navigation target may name, wherever the declaration stands. + if key, _ := definedBy(stmt); key.name != "" { + switch key.kind { + case defPage, defMicroflow, defNanoflow: + targets[key] = true + } + } } - e.scriptFlows = flows - return func() { e.scriptFlows = prev } + e.scriptFlows, e.scriptTargets = flows, targets + return func() { e.scriptFlows, e.scriptTargets = prev, prevTargets } } // enterLanguage runs the following statements under a program's language diff --git a/mdl/executor/executor_dispatch.go b/mdl/executor/executor_dispatch.go index bed4aee28..8b05cd15d 100644 --- a/mdl/executor/executor_dispatch.go +++ b/mdl/executor/executor_dispatch.go @@ -105,6 +105,7 @@ func (e *Executor) newExecContext(ctx context.Context) *ExecContext { LanguageVersion: e.langVersion, forwardFlowCalls: e.forwardFlowCalls, scriptFlows: e.scriptFlows, + scriptTargets: e.scriptTargets, describeLang: e.describeLang, SqlMgr: e.sqlMgr, ThemeRegistry: e.themeRegistry, diff --git a/mdl/executor/pluggable_system_props.go b/mdl/executor/pluggable_system_props.go index 8891a56f8..e8ca68bfe 100644 --- a/mdl/executor/pluggable_system_props.go +++ b/mdl/executor/pluggable_system_props.go @@ -64,8 +64,8 @@ func widgetAsksEditability(w *ast.WidgetV3) bool { if w.GetStringProp("EditableIf") != "" { return true } - e, ok := pages.CanonicalEditability(w.GetStringProp("Editable")) - return ok && e != "Always" + enum, expr, ok := staticEditability(w) + return ok && (expr != "" || enum != "Always") } // systemPropsOfType returns the system properties a CustomWidgetType declares, diff --git a/mdl/executor/validate.go b/mdl/executor/validate.go index 42cd7fe3e..86a541ea0 100644 --- a/mdl/executor/validate.go +++ b/mdl/executor/validate.go @@ -450,6 +450,11 @@ func validateProgramWithWarnings(ctx *ExecContext, prog *ast.Program) ([]error, // worse than the two above: a module-qualified role here makes the project // unloadable rather than merely failing the build (mendixlabs/mxcli#1001). errors = append(errors, validateNavigationRoles(ctx, prog)...) + // Resolve the pages and flows a navigation profile or menu document names: + // home, login and not-found pages and every menu item. Written by name and + // unchecked, so a typo was CE1613 at build time, after the whole menu had + // been rewritten. + errors = append(errors, validateNavigationTargets(ctx, prog, sc)...) // Resolve what a GRANT / REVOKE, a user role and a demo user name: the // entity, document or member, and the module and user roles. exec refuses // the grants after the statements before them are written, and writes the @@ -782,9 +787,21 @@ func validateWithContext(ctx *ExecContext, stmt ast.Statement, sc *scriptContext return mdlerrors.NewNotFound("module", s.Name.Module) } } + // A bare `returns Module.Name` that names an enumeration is one: exec + // retypes it the same way before it validates the rule. One that names + // nothing is reported as that, not as "not a Boolean or an enumeration". + ruleReturn := s.ReturnType + if ruleReturn != nil && ctx.Connected() { + r := newDeclaredTypeResolver(ctx, sc) + if retyped, ok := r.bareEnumeration(ruleReturn.Type); ok { + ruleReturn = &ast.MicroflowReturnType{Type: retyped, Variable: ruleReturn.Variable} + } else if msg := r.check(ruleReturn.Type, "return type of rule "+s.Name.String()); msg != "" { + return mdlerrors.NewValidationf("rule '%s' has reference errors:\n - %s", s.Name.String(), msg) + } + } // The same validateRule the executor calls, so `check` and `exec` cannot // disagree about what a rule may contain. - if errMsg := validateRule(s.Name.String(), s.Body, s.ReturnType); errMsg != "" { + if errMsg := validateRule(s.Name.String(), s.Body, ruleReturn); errMsg != "" { return mdlerrors.NewValidationf("%s", strings.TrimRight(errMsg, "\n")) } if validationErrors := ValidateRuleBody(s); len(validationErrors) > 0 { diff --git a/mdl/executor/validate_declared_types.go b/mdl/executor/validate_declared_types.go index 9640e0153..01f48d130 100644 --- a/mdl/executor/validate_declared_types.go +++ b/mdl/executor/validate_declared_types.go @@ -91,13 +91,33 @@ func (r *declaredTypeResolver) check(dt ast.DataType, where string) string { if r.entityExists(*qn) || r.enumExists(*qn) { return "" } - msg := fmt.Sprintf("%s: entity %s does not exist", where, qn.String()) + // A bare scalar name may have meant an enumeration, so the message names both. + kind := "entity" + if dt.Kind == ast.TypeEntity { + kind = "entity or enumeration" + } + msg := fmt.Sprintf("%s: %s %s does not exist", where, kind, qn.String()) if hint := entityNameHint(*qn); hint != "" { msg += ". " + hint } return msg } +// bareEnumeration is the check-side twin of bareEnumerationType: a bare +// `Module.Name` that names no entity but names an enumeration, as exec's +// create handlers read it. It returns dt retyped as that enumeration, or false +// when dt is anything else — including a name both an entity and an +// enumeration have, which exec keeps as the entity. +func (r *declaredTypeResolver) bareEnumeration(dt ast.DataType) (ast.DataType, bool) { + if dt.Kind != ast.TypeEntity || dt.EntityRef == nil || isBuiltinModuleEntity(dt.EntityRef.Module) { + return dt, false + } + if r.entityExists(*dt.EntityRef) || !r.enumExists(*dt.EntityRef) { + return dt, false + } + return ast.DataType{Kind: ast.TypeEnumeration, EnumRef: dt.EntityRef}, true +} + // flowSignatureErrors resolves a microflow's, nanoflow's or rule's parameter // and return types. func flowSignatureErrors(ctx *ExecContext, sc *scriptContext, kind string, name ast.QualifiedName, diff --git a/mdl/executor/validate_definition_order.go b/mdl/executor/validate_definition_order.go index 4636458b4..9c02a16bc 100644 --- a/mdl/executor/validate_definition_order.go +++ b/mdl/executor/validate_definition_order.go @@ -66,8 +66,20 @@ type defRef struct { // stored by name, so a run that rolls back on failure resolves it against // the whole script (mendixlabs/mxcli#1405); see flowCallRefs. flowCall bool + // byName marks a reference stored by name that is only resolved by looking + // it up when the statement runs: a navigation profile's or menu document's + // page, microflow or nanoflow. Like a flow call, one the script creates + // further down is accepted by a run that rolls back (forwardFlowCalls) and + // refused by one that cannot; unlike a call, it is not a call, so + // forwardByNameRefusal words the refusal. + byName bool } +// forwardByNameRefusal is why a run that cannot roll back refuses a navigation +// target the script creates further down. +const forwardByNameRefusal = "the target is stored by name and looked up when the statement runs, and this run cannot be rolled back (--continue-on-error, the REPL, -c), so a later failure would leave it dangling. " + + "Create the target first; plain `mxcli exec`, which rolls back, accepts the script as it is" + // flowCallRefs marks refs as flow-call targets. func flowCallRefs(refs []defRef) []defRef { for i := range refs { @@ -124,7 +136,7 @@ func ValidateScriptDefinitionOrder(prog *ast.Program) []linter.Violation { // Whether a flow call may run ahead of its callee depends on how // the script runs, which this tier cannot know: the project tier // (validateForwardDefRefs) and exec decide (mendixlabs/mxcli#1405). - if ref.flowCall { + if ref.flowCall || ref.byName { continue } kind, at, ok := laterPlainCreate(plain, created, ref) @@ -274,6 +286,11 @@ func eagerDefRefs(stmt ast.Statement) []defRef { return oneDefRef(s.Nanoflow, defNanoflow, "grant execute on nanoflow "+s.Nanoflow.String()) case *ast.GrantPageAccessStmt: return oneDefRef(s.Page, defPage, "grant view on page "+s.Page.String()) + + case *ast.AlterNavigationStmt, *ast.CreateMenuStmt: + // The home, login and not-found pages and every menu item's target, + // which the handler resolves before it writes the profile or menu. + return navigationDefRefs(stmt) } return nil } @@ -495,7 +512,7 @@ func validateForwardDefRefs(ctx *ExecContext, prog *ast.Program) []error { break } } - if resolved || (ref.flowCall && ctx.forwardFlowCalls) { + if resolved || ((ref.flowCall || ref.byName) && ctx.forwardFlowCalls) { continue } for _, k := range ref.kinds { @@ -509,6 +526,11 @@ func validateForwardDefRefs(ctx *ExecContext, prog *ast.Program) []error { i+1, ref.site, k, ref.name, at+1, forwardFlowCallRefusal)) break } + if ref.byName { + errs = append(errs, fmt.Errorf("statement %d: %s references %s %s before it is created (statement %d): %s", + i+1, ref.site, k, ref.name, at+1, forwardByNameRefusal)) + break + } if _, isPlain := plain[key]; isPlain { break // MDL-ORDER01 reports it, without a project } diff --git a/mdl/executor/validate_listview_editable_inputs.go b/mdl/executor/validate_listview_editable_inputs.go index da27d95e3..87f5bf92a 100644 --- a/mdl/executor/validate_listview_editable_inputs.go +++ b/mdl/executor/validate_listview_editable_inputs.go @@ -76,7 +76,9 @@ func firstEditableInput(widgets []*ast.WidgetV3, registry *WidgetRegistry) *ast. if typ == "listview" { continue } - never := strings.EqualFold(c.GetStringProp("Editable"), "Never") + // `editable: false` is Never too, quoted or not (pages.StaticEditability). + enum, _, hasStatic := staticEditability(c) + never := hasStatic && enum == "Never" if editableWidgetTypes[typ] && typ != "dataview" && !never { return c } diff --git a/mdl/executor/validate_listview_editable_inputs_test.go b/mdl/executor/validate_listview_editable_inputs_test.go index 22c7fbe26..48afdcdad 100644 --- a/mdl/executor/validate_listview_editable_inputs_test.go +++ b/mdl/executor/validate_listview_editable_inputs_test.go @@ -98,6 +98,28 @@ func TestMDLWIDGET31_ListViewInputsNotEditable(t *testing.T) { }`, want: 0, }, + { + // `editable: false` and the quoted spellings are Never as well: the + // author already made these inputs read-only, so there is nothing to warn about. + name: "control: inputs marked editable false or 'Never'", + src: `create page M.P (title: 'P', layout: Atlas_Core.Atlas_Default) { + listview lv (datasource: database M.Thing) { + textbox t (label: 'N', attribute: Name, editable: false) + textbox u (label: 'M', attribute: Name, editable: 'Never') + } +}`, + want: 0, + }, + { + // A condition is not Never: the input can still be editable, so the list view still decides. + name: "an input with a quoted condition is still warned about", + src: `create page M.P (title: 'P', layout: Atlas_Core.Atlas_Default) { + listview lv (datasource: database M.Thing) { + textbox t (label: 'N', attribute: Name, editable: '$currentObject/Name = empty') + } +}`, + want: 1, + }, { // A nested list view's inputs are governed by the NESTED list // view's Editable, so the outer one is not the one to report. diff --git a/mdl/executor/validate_navigation_targets.go b/mdl/executor/validate_navigation_targets.go new file mode 100644 index 000000000..f12dfc0f1 --- /dev/null +++ b/mdl/executor/validate_navigation_targets.go @@ -0,0 +1,327 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Reference validation for the targets a navigation statement names. +// +// A navigation profile points at documents in five places: its home page (and +// one per user role), a home flow, the not-found page, and every menu item at +// any depth. Its login page is not one of them: the sign-in page may be a +// static file of the theme (login.html) rather than a page document, so a name +// that resolves to no page is not a dangling reference, and mx check accepts it. A standalone menu document carries the same +// menu items. Nothing resolved any of them: the names were written to the model +// as they stood, `check -p` said "Check passed!", and the first sign of a typo +// was MxBuild, one build later: +// +// [CE1613] "The selected page 'Shop.NoSuchHome' no longer exists." +// +// Measured on a Mendix 11.12.5 app, one such error for the home page and one +// for each menu item whose page or microflow was missing. +// +// A reference-resolution pass added per reference kind leaves every kind it does +// not list silently unchecked, and this was one of them: a page's `show page` +// and `call microflow` actions and an icon name were refused, while the same +// action on a menu item was not. +// +// # Why exec refuses too, and before it writes +// +// CREATE OR REPLACE NAVIGATION rewrites the whole profile — home pages, login +// page, menu tree — so a target that does not exist replaces the stored menu +// with one the build rejects. The handler therefore runs the same check as +// `check -p` before it reads or writes anything (the menu builder stores a +// target by name and never fails on it), and `exec`'s own pre-flight refuses the +// script before any statement is written. `exec --no-check` skips the +// pre-flight, which is why the handler cannot rely on it. +// +// # Script-aware, forward references as for a flow call +// +// A page or flow the script creates BEFORE the navigation resolves. One it +// creates further down resolves too in a run that rolls back, which is what +// plain `mxcli exec` and `mxcli check` are: the target is stored by name, and a +// later failure undoes the whole run, so nothing is left dangling. That is the +// rule a call to a flow created further down already follows +// (mendixlabs/mxcli#1405, forwardFlowCalls), and it keeps a script that writes +// the navigation first working — upstream built and checked such a script clean +// (measured on 11.12.5, 0 errors). A run that cannot roll back +// (--continue-on-error, the REPL, -c) refuses it, from validateForwardDefRefs, +// because a later failure would leave the target dangling; the references are in +// eagerDefRefs for that pass. This pass resolves against the project plus what +// the script creates anywhere, so a forward name is reported once, there. +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" +) + +// Target kinds a navigation statement can name. They are the document kinds the +// definition-order passes use, so one list serves both. +const ( + navPage = defPage + navMicroflow = defMicroflow + navNanoflow = defNanoflow + navEntity = defEntity +) + +// navTarget is one reference a navigation statement or menu document makes. +type navTarget struct { + kind string // navPage, navMicroflow, navNanoflow or navEntity + name string // the qualified name as written + where string // where it sits, e.g. `menu item 'Admin' > 'Users'` +} + +// navigationTargets collects every reference a navigation statement or a menu +// document names, with where it was written. label is "navigation Responsive" or +// "menu Shop.Main_Menu"; ok is false for any other statement. +// +// native reports whether a navigation profile is a native one. It matters for +// exactly one clause: a native profile's `home microflow` is stored as the +// nanoflow it is (see checkProfileClauses), so the name is looked up as one. +func navigationTargets(stmt ast.Statement, native func(profile string) bool) (label string, out []navTarget, ok bool) { + switch s := stmt.(type) { + case *ast.AlterNavigationStmt: + label = "navigation " + s.ProfileName + for _, hp := range s.HomePages { + where, kind := "home page", navPage + switch { + case hp.IsNanoflow: + where, kind = "home nanoflow", navNanoflow + case !hp.IsPage: + where, kind = "home microflow", navMicroflow + if native != nil && native(s.ProfileName) { + kind = navNanoflow + } + } + if hp.ForRole != nil { + where += " for " + hp.ForRole.Name + } + out = append(out, navTarget{kind: kind, name: hp.Target.String(), where: where}) + } + // The login page is deliberately not a target: see the file comment. + if s.NotFoundPage != nil { + out = append(out, navTarget{kind: navPage, name: s.NotFoundPage.String(), where: "not found page"}) + } + out = append(out, menuItemTargets(s.MenuItems, "")...) + return label, out, true + case *ast.CreateMenuStmt: + return "menu " + s.Name.String(), menuItemTargets(s.Items, ""), true + } + return "", nil, false +} + +// menuItemTargets walks a menu's items at any depth. path is the captions of the +// groups an item sits in, so a message names the item the way the author wrote +// it: `menu item 'Admin' > 'Users'`. +func menuItemTargets(items []ast.NavMenuItemDef, path string) []navTarget { + var out []navTarget + for _, it := range items { + where := "menu item " + path + "'" + it.Caption + "'" + add := func(kind, name string) { + if name = unquoteQualifiedName(strings.TrimSpace(name)); name != "" { + out = append(out, navTarget{kind: kind, name: name, where: where}) + } + } + // The item's action is the whole truth (every kind a menu item stores); + // Page and Microflow are the old clause spelling of the same two, set only + // for a hand-built AST or alongside Action. + if it.Action == nil { + if it.Page != nil { + add(navPage, it.Page.String()) + } + if it.Microflow != nil { + add(navMicroflow, it.Microflow.String()) + } + } + for a := it.Action; a != nil; a = a.ThenAction { + switch a.Type { + case "showPage": + add(navPage, a.Target) + case "microflow": + add(navMicroflow, a.Target) + case "nanoflow": + add(navNanoflow, a.Target) + case "create": + add(navEntity, a.Target) + } + } + out = append(out, menuItemTargets(it.Items, path+"'"+it.Caption+"' > ")...) + } + return out +} + +// navProfileIsNative reports whether the stored profile of that name is a native +// one. A project that cannot be read has no native profile as far as this goes. +func navProfileIsNative(ctx *ExecContext, profile string) bool { + nav, err := ctx.Backend.GetNavigation() + if err != nil || nav == nil { + return false + } + for _, p := range nav.Profiles { + if strings.EqualFold(p.Name, profile) { + return p.IsNative + } + } + return false +} + +// unresolvedNavigationTargets resolves the targets against the project and what +// the script creates (sc), and returns one line per target that resolves to +// nothing, in the order they were written. +// +// The lookups are the ones every other page, microflow, nanoflow and entity +// reference is resolved with (validateWidgetReferences), and so are the words: +// "page not found: Shop.X". sc may be empty; exec passes an empty one because by +// the time a statement runs, what the script created earlier is in the project. +// +// A listing that comes back EMPTY is a backend that could not answer, as often +// as a project with none, and reporting every reference on that basis is the +// false error validateForwardDefRefs also declines to make: that kind is skipped. +func unresolvedNavigationTargets(ctx *ExecContext, targets []navTarget, sc *scriptContext) []string { + if len(targets) == 0 || !ctx.Connected() { + return nil + } + if sc == nil { + sc = newScriptContext() + } + stored := map[string]map[string]bool{} + storedOf := func(kind string) map[string]bool { + if m, done := stored[kind]; done { + return m + } + var m map[string]bool + switch kind { + case navPage: + m = buildPageQualifiedNames(ctx) + case navMicroflow: + m = buildMicroflowQualifiedNames(ctx) + case navNanoflow: + m = buildNanoflowQualifiedNames(ctx) + case navEntity: + m = buildEntityQualifiedNames(ctx) + } + stored[kind] = m + return m + } + inScript := func(kind, name string) bool { + switch kind { + case navPage: + return sc.pages[name] + case navMicroflow: + return sc.microflows[name] + case navNanoflow: + return sc.nanoflows[name] + case navEntity: + return sc.entities[name] + } + return false + } + + var out []string + for _, t := range targets { + known := storedOf(t.kind) + // The build*QualifiedNames helpers return an empty set both when the + // project has none of a kind and when the list could not be read, so + // an empty set is not evidence: refusing on it would turn a read + // failure into a false "not found". The cost is that a nanoflow target + // in a project with no nanoflows is left to mx check. + if len(known) == 0 || known[t.name] || inScript(t.kind, t.name) { + continue + } + out = append(out, fmt.Sprintf("%s: %s not found: %s", t.where, t.kind, t.name)) + } + return out +} + +// navigationTargetError is the one error a statement's unresolved targets make. +func navigationTargetError(label string, unresolved []string) error { + return mdlerrors.NewValidationf("%s has reference errors:\n - %s", label, strings.Join(unresolved, "\n - ")) +} + +// validateNavigationTargets resolves the navigation targets of every statement +// in the program (`check -p`, and exec's pre-flight). A menu document that is +// stored as excluded keeps its dangling targets and reports them as warnings: +// Mendix does not validate an excluded document. +func validateNavigationTargets(ctx *ExecContext, prog *ast.Program, sc *scriptContext) []error { + if !ctx.Connected() { + return nil + } + native := func(profile string) bool { return navProfileIsNative(ctx, profile) } + + var errs []error + for i, stmt := range prog.Statements { + label, targets, ok := navigationTargets(stmt, native) + if !ok { + continue + } + unresolved := unresolvedNavigationTargets(ctx, targets, sc) + if len(unresolved) == 0 { + continue + } + if m, isMenu := stmt.(*ast.CreateMenuStmt); isMenu && storedMenuExcluded(ctx, m.Name) { + sc.warnExcluded("menu", m.Name.String(), unresolved) + continue + } + errs = append(errs, fmt.Errorf("statement %d: %w", i+1, navigationTargetError(label, unresolved))) + } + return errs +} + +// validateNavigationTargetsForExec is the same check when the statement runs. +// The project holds everything the script created before it. In a run that +// rolls back (forwardFlowCalls) a target the script creates further down counts +// too, as a flow call's callee does (mendixlabs/mxcli#1405): it is stored by +// name, and a later failure undoes the whole run, so nothing dangles. +func validateNavigationTargetsForExec(ctx *ExecContext, stmt ast.Statement) error { + label, targets, ok := navigationTargets(stmt, func(profile string) bool { return navProfileIsNative(ctx, profile) }) + if !ok { + return nil + } + var sc *scriptContext + if ctx.forwardFlowCalls && len(ctx.scriptTargets) > 0 { + sc = newScriptContext() + for key := range ctx.scriptTargets { + switch key.kind { + case defPage: + sc.pages[key.name] = true + case defMicroflow: + sc.microflows[key.name] = true + case defNanoflow: + sc.nanoflows[key.name] = true + } + } + } + if unresolved := unresolvedNavigationTargets(ctx, targets, sc); len(unresolved) > 0 { + return navigationTargetError(label, unresolved) + } + return nil +} + +// storedMenuExcluded reports whether the project already holds the menu +// document as excluded, which exec keeps on a rewrite. +func storedMenuExcluded(ctx *ExecContext, name ast.QualifiedName) bool { + md, err := ctx.Backend.GetMenuDocumentByQualifiedName(name.Module, name.Name) + return err == nil && md != nil && md.Excluded +} + +// navigationDefRefs are the references a navigation statement or a menu document +// looks up when it runs, for the definition-order passes. A page or flow is +// stored by name (byName), so a run that rolls back accepts one created further +// down; a create-object action's entity is resolved by the page builder and is +// not. +func navigationDefRefs(stmt ast.Statement) []defRef { + label, targets, ok := navigationTargets(stmt, nil) + if !ok { + return nil + } + var out []defRef + for _, t := range targets { + if !strings.Contains(t.name, ".") { + continue // an unqualified name cannot be placed in a module + } + // A create-object action's entity is resolved by the page builder when it + // runs; a page or flow is only stored by name. + out = append(out, defRef{name: t.name, kinds: []string{t.kind}, site: label + ": " + t.where, byName: t.kind != navEntity}) + } + return out +} diff --git a/mdl/executor/validate_navigation_targets_test.go b/mdl/executor/validate_navigation_targets_test.go new file mode 100644 index 000000000..a28d53289 --- /dev/null +++ b/mdl/executor/validate_navigation_targets_test.go @@ -0,0 +1,513 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" + "github.com/mendixlabs/mxcli/sdk/pages" + "github.com/mendixlabs/mxcli/sdk/security" +) + +// A navigation statement names pages and microflows in five places: the home +// page (also per role), a home flow, the not-found page, and every menu item at +// any depth (the login page may be the theme's login.html, so it is not one). `check -p` resolved none of them, and `exec` +// wrote them by name, so a typo reached the model and was first reported by +// `mx check` as CE1613 "The selected page 'X' no longer exists." — after exec +// had rewritten the whole menu (CREATE OR REPLACE NAVIGATION replaces it). +// +// The project here has Shop (a user module), Administration (stands for a +// Marketplace module) and System. + +type navFixture struct { + mb *mock.MockBackend + ctx *ExecContext + updates int // UpdateNavigationProfile calls + added int // AddNavigationProfile calls + menuSets int // CreateMenuDocument + UpdateMenuDocument calls +} + +func newNavFixture(t *testing.T) *navFixture { + t.Helper() + shop, admin, system := mkModule("Shop"), mkModule("Administration"), mkModule("System") + h := mkHierarchy(shop, admin, system) + + f := &navFixture{} + f.mb = &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListPagesFunc: func() ([]*pages.Page, error) { + return []*pages.Page{ + mkPage(shop.ID, "Home"), + mkPage(shop.ID, "Orders"), + mkPage(admin.ID, "Account_Overview"), + mkPage(system.ID, "Login"), + }, nil + }, + ListMicroflowsFunc: func() ([]*microflows.Microflow, error) { + return []*microflows.Microflow{ + mkMicroflow(shop.ID, "ACT_Open"), + mkMicroflow(admin.ID, "Rebuild"), + mkMicroflow(system.ID, "Logout"), + }, nil + }, + ListNanoflowsFunc: func() ([]*microflows.Nanoflow, error) { + return []*microflows.Nanoflow{mkNanoflow(shop.ID, "NF_Go")}, nil + }, + GetNavigationFunc: func() (*types.NavigationDocument, error) { + return &types.NavigationDocument{Profiles: []*types.NavigationProfile{{ + Name: "Responsive", + Kind: "Responsive", + HomePage: &types.NavHomePage{Page: "Shop.Home"}, + MenuItems: []*types.NavMenuItem{{Caption: "Stored", Page: "Shop.Home"}}, + }}}, nil + }, + UpdateNavigationProfileFunc: func(model.ID, string, types.NavigationProfileSpec) error { + f.updates++ + return nil + }, + AddNavigationProfileFunc: func(model.ID, string) error { f.added++; return nil }, + GetProjectSecurityFunc: func() (*security.ProjectSecurity, error) { + return &security.ProjectSecurity{UserRoles: []*security.UserRole{{Name: "Administrator"}}}, nil + }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{shop, admin, system}, nil }, + GetModuleByNameFunc: func(name string) (*model.Module, error) { + for _, m := range []*model.Module{shop, admin, system} { + if m.Name == name { + return m, nil + } + } + return nil, nil + }, + CreateMenuDocumentFunc: func(*types.MenuDocument) error { f.menuSets++; return nil }, + UpdateMenuDocumentFunc: func(*types.MenuDocument) error { f.menuSets++; return nil }, + } + f.ctx, _ = newMockCtx(t, withBackend(f.mb), withHierarchy(h)) + return f +} + +func buildProg(t *testing.T, script string) *ast.Program { + t.Helper() + prog, errs := visitor.Build(script) + if len(errs) > 0 { + t.Fatalf("does not parse: %v\n%s", errs[0], script) + } + return prog +} + +// check is `mxcli check -p`: the program-level reference validation. +func (f *navFixture) check(t *testing.T, script string) (string, []string) { + t.Helper() + errs, warnings := validateProgramWithWarnings(f.ctx, buildProg(t, script)) + var msgs []string + for _, e := range errs { + msgs = append(msgs, e.Error()) + } + return strings.Join(msgs, "\n"), warnings +} + +// exec runs every statement through its handler, as `exec` does after its +// pre-flight, and returns the first refusal. +func (f *navFixture) exec(t *testing.T, script string) error { + t.Helper() + for _, stmt := range buildProg(t, script).Statements { + var err error + switch s := stmt.(type) { + case *ast.AlterNavigationStmt: + err = execAlterNavigation(f.ctx, s) + case *ast.CreateMenuStmt: + err = execCreateMenu(f.ctx, s) + default: + t.Fatalf("unexpected statement %T", stmt) + } + if err != nil { + return err + } + } + return nil +} + +// The two spellings of the reported navigation, with the same four bad targets: +// the home page, a page and a microflow inside a group, and a top-level page. +const bogusNavDeprecated = ` +create or replace navigation Responsive + home page Shop.NoSuchHome + menu ( + menu 'G' ( + menu item 'Bogus' page Shop.NoSuchPage; + menu item 'Bogus2' microflow Shop.ACT_NoSuchFlow; + ); + menu item 'Bogus3' page Shop.NoSuchPage3; + ); +` + +const bogusNavV1 = `mdl 1; +create or modify navigation Responsive + home page Shop.NoSuchHome +{ + menu item 'Bogus3' ( OnClick: show page Shop.NoSuchPage3 ) + menu 'G' { + menu item 'Bogus' ( OnClick: show page Shop.NoSuchPage ) + menu item 'Bogus2' ( OnClick: call microflow Shop.ACT_NoSuchFlow ) + } +}; +` + +func TestNavigationTargets_CheckRefusesUnresolvedTargets(t *testing.T) { + for name, script := range map[string]string{"deprecated": bogusNavDeprecated, "mdl 1": bogusNavV1} { + t.Run(name, func(t *testing.T) { + f := newNavFixture(t) + got, _ := f.check(t, script) + for _, want := range []string{ + "navigation Responsive", + "home page: page not found: Shop.NoSuchHome", + "menu item 'G' > 'Bogus': page not found: Shop.NoSuchPage", + "menu item 'G' > 'Bogus2': microflow not found: Shop.ACT_NoSuchFlow", + "menu item 'Bogus3': page not found: Shop.NoSuchPage3", + } { + if !strings.Contains(got, want) { + t.Errorf("check -p lacks %q in:\n%s", want, got) + } + } + // The group a nested item sits in is part of where it is. + if !strings.Contains(got, "'G' > 'Bogus'") { + t.Errorf("a nested item does not name its group:\n%s", got) + } + }) + } +} + +func TestNavigationTargets_CheckAcceptsTargetsThatExist(t *testing.T) { + // Existing pages and flows, a Marketplace-module page and microflow, System + // elements, a nanoflow, a role-based home, the login page, and a sign-out + // item (which has no target). + script := `mdl 1; +create or modify navigation Responsive + home page Shop.Home + home microflow Shop.ACT_Open for Administrator + login page System.Login + not found page Shop.Orders +{ + menu item 'Home' ( OnClick: show page Shop.Home ) + menu 'Admin' { + menu item 'Accounts' ( OnClick: show page Administration.Account_Overview ) + menu item 'Rebuild' ( OnClick: call microflow Administration.Rebuild ) + menu 'Deeper' { + menu item 'Go' ( OnClick: call nanoflow Shop.NF_Go ) + menu item 'Out' ( OnClick: call microflow System.Logout ) + } + } + menu item 'Sign out' ( OnClick: sign out ) +}; +` + f := newNavFixture(t) + if got, _ := f.check(t, script); got != "" { + t.Errorf("check -p refused a navigation whose targets all exist:\n%s", got) + } +} + +// A page or microflow the SAME script creates earlier resolves. This is the +// control for the scriptContext exemption: it is what makes a project that +// does not have the page yet checkable. +func TestNavigationTargets_CheckResolvesTargetsCreatedEarlierInTheScript(t *testing.T) { + script := `mdl 1; +create page Shop.NewPage ( Title: 'New', Layout: Atlas_Core.Atlas_Default ) { }; +create microflow Shop.ACT_New () begin end; +create or modify navigation Responsive + home page Shop.NewPage +{ + menu item 'Flow' ( OnClick: call microflow Shop.ACT_New ) +}; +` + f := newNavFixture(t) + if got, _ := f.check(t, script); got != "" { + t.Errorf("a target created earlier in the script was refused:\n%s", got) + } +} + +func TestNavigationTargets_CheckCoversEveryTargetKind(t *testing.T) { + cases := map[string]struct{ clause, want string }{ + "home page": {"home page Shop.Gone", "home page: page not found: Shop.Gone"}, + "home microflow": {"home microflow Shop.Gone", "home microflow: microflow not found: Shop.Gone"}, + "role-based home page": {"home page Shop.Gone for Administrator", "home page for Administrator: page not found: Shop.Gone"}, + "role-based home flow": {"home microflow Shop.Gone for Administrator", "home microflow for Administrator: microflow not found: Shop.Gone"}, + "not found page": {"not found page Shop.Gone", "not found page: page not found: Shop.Gone"}, + "menu item nanoflow": {"{ menu item 'X' ( OnClick: call nanoflow Shop.Gone ) }", "menu item 'X': nanoflow not found: Shop.Gone"}, + "menu item create+show": {"{ menu item 'X' ( OnClick: create object Shop.Order then show page Shop.Gone ) }", "menu item 'X': page not found: Shop.Gone"}, + "three deep sub-menus": {"{ menu 'A' { menu 'B' { menu 'C' { menu item 'X' ( OnClick: show page Shop.Gone ) } } } }", "'A' > 'B' > 'C' > 'X': page not found: Shop.Gone"}, + "old clause, top level": {"menu ( menu item 'X' page Shop.Gone; )", "menu item 'X': page not found: Shop.Gone"}, + "old clause, in a group": {"menu ( menu 'G' ( menu item 'X' microflow Shop.Gone; ); )", "'G' > 'X': microflow not found: Shop.Gone"}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + f := newNavFixture(t) + // "create object" needs the entity to exist for the builder; the + // reference check reports the page, which is what the case is about. + got, _ := f.check(t, "create or modify navigation Responsive "+c.clause+";") + if !strings.Contains(got, c.want) { + t.Errorf("check -p lacks %q in:\n%s", c.want, got) + } + }) + } +} + +// The sign-in page may be the theme's static login.html, not a page document, +// so a login page the project does not have is accepted — by mx check too. +// The not-found page beside it is still resolved, which makes this the control. +func TestNavigationTargets_CheckDoesNotResolveTheLoginPage(t *testing.T) { + f := newNavFixture(t) + got, _ := f.check(t, "create or modify navigation Responsive login page Shop.Gone not found page Shop.AlsoGone;") + if strings.Contains(got, "Shop.Gone") { + t.Errorf("a login page outside the project was refused:\n%s", got) + } + if !strings.Contains(got, "not found page: page not found: Shop.AlsoGone") { + t.Errorf("the not-found page beside it was not resolved:\n%s", got) + } +} + +func TestNavigationTargets_CheckMenuDocuments(t *testing.T) { + f := newNavFixture(t) + got, _ := f.check(t, `create or modify menu Shop.Main_Menu { + menu item 'Ok' ( OnClick: show page Shop.Home ) + menu 'G' { menu item 'Bad' ( OnClick: show page Shop.Gone ) } +};`) + for _, want := range []string{"menu Shop.Main_Menu", "'G' > 'Bad': page not found: Shop.Gone"} { + if !strings.Contains(got, want) { + t.Errorf("check -p lacks %q in:\n%s", want, got) + } + } + if strings.Contains(got, "'Ok'") { + t.Errorf("a resolving item was reported:\n%s", got) + } +} + +// Mendix does not validate an excluded document, so a menu document stored as +// excluded keeps its dangling targets and says so as a warning, as an excluded +// page does. +func TestNavigationTargets_ExcludedMenuDocumentWarns(t *testing.T) { + f := newNavFixture(t) + shop, _ := f.mb.GetModuleByName("Shop") + f.mb.ListMenuDocumentsFunc = func() ([]*types.MenuDocument, error) { + return []*types.MenuDocument{{ID: "md1", ContainerID: shop.ID, Name: "Old_Menu", Excluded: true}}, nil + } + f.mb.GetMenuDocumentByQualifiedNameFunc = func(_, name string) (*types.MenuDocument, error) { + return &types.MenuDocument{ID: "md1", ContainerID: shop.ID, Name: name, Excluded: true}, nil + } + got, warnings := f.check(t, `create or modify menu Shop.Old_Menu { menu item 'Bad' ( OnClick: show page Shop.Gone ) };`) + if got != "" { + t.Errorf("an excluded menu document blocked check:\n%s", got) + } + if len(warnings) != 1 || !strings.Contains(warnings[0], "Shop.Gone") { + t.Errorf("warnings = %v, want one naming Shop.Gone", warnings) + } +} + +// exec refuses BEFORE it writes: CREATE OR REPLACE NAVIGATION rewrites the +// whole menu, so a partial write is the stored menu lost for a typo. +func TestNavigationTargets_ExecRefusesBeforeWriting(t *testing.T) { + for name, script := range map[string]string{"deprecated": bogusNavDeprecated, "mdl 1": bogusNavV1} { + t.Run(name, func(t *testing.T) { + f := newNavFixture(t) + before, _ := f.mb.GetNavigation() + err := f.exec(t, script) + if err == nil { + t.Fatal("exec accepted a navigation whose targets do not exist") + } + for _, want := range []string{"Shop.NoSuchHome", "Shop.NoSuchPage", "Shop.ACT_NoSuchFlow", "'Bogus3'"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("refusal lacks %q: %v", want, err) + } + } + if f.updates != 0 || f.added != 0 { + t.Errorf("the profile was written: %d updates, %d added", f.updates, f.added) + } + after, _ := f.mb.GetNavigation() + if len(before.Profiles[0].MenuItems) != len(after.Profiles[0].MenuItems) || + before.Profiles[0].HomePage.Page != after.Profiles[0].HomePage.Page { + t.Errorf("the stored profile changed: %+v -> %+v", before.Profiles[0], after.Profiles[0]) + } + }) + } +} + +// check and exec decide alike: what check -p passes, exec writes. +func TestNavigationTargets_ExecWritesWhatCheckPasses(t *testing.T) { + script := `mdl 1; +create or modify navigation Responsive + home page Shop.Home +{ + menu item 'Orders' ( OnClick: show page Shop.Orders ) + menu 'Admin' { menu item 'Accounts' ( OnClick: show page Administration.Account_Overview ) } + menu item 'Flow' ( OnClick: call microflow Shop.ACT_Open ) +}; +` + f := newNavFixture(t) + if got, _ := f.check(t, script); got != "" { + t.Fatalf("check refused a valid navigation:\n%s", got) + } + if err := f.exec(t, script); err != nil { + t.Fatalf("exec refused what check passed: %v", err) + } + if f.updates != 1 { + t.Errorf("updates = %d, want 1", f.updates) + } +} + +func TestNavigationTargets_ExecRefusesMenuDocumentBeforeWriting(t *testing.T) { + f := newNavFixture(t) + f.mb.GetMenuDocumentByQualifiedNameFunc = func(_, _ string) (*types.MenuDocument, error) { return nil, nil } + err := f.exec(t, `create or modify menu Shop.Main_Menu { menu item 'Bad' ( OnClick: show page Shop.Gone ) };`) + if err == nil || !strings.Contains(err.Error(), "Shop.Gone") { + t.Fatalf("err = %v, want a refusal naming Shop.Gone", err) + } + if f.menuSets != 0 { + t.Errorf("the menu document was written (%d writes)", f.menuSets) + } + // CONTROL: a resolving target is written. + if err := f.exec(t, `create or modify menu Shop.Main_Menu { menu item 'Ok' ( OnClick: show page Shop.Home ) };`); err != nil { + t.Fatalf("a resolving menu was refused: %v", err) + } + if f.menuSets != 1 { + t.Errorf("menu writes = %d, want 1", f.menuSets) + } +} + +// A navigation first and its page or flow created further down: the target is +// stored by name, so a run that rolls back (plain `exec`, and `check`) accepts +// it, as it accepts a call to a flow created further down (mendixlabs/mxcli#1405). +// Measured on upstream/main, Mendix 11.12.5: such a script execs and `mx check` +// reports 0 errors, so refusing it would break a working script. +const forwardNavScript = `mdl 1; +create or modify navigation Responsive + home page Shop.Later +{ + menu item 'Flow' ( OnClick: call microflow Shop.ACT_Later ) +}; +create page Shop.Later ( Title: 'Later', Layout: Atlas_Core.Atlas_Default ) { }; +create microflow Shop.ACT_Later () begin end; +` + +func TestNavigationTargets_ForwardReferenceIsAcceptedByARunThatRollsBack(t *testing.T) { + // No project: the order rule has nothing to say about a stored-by-name target. + for _, v := range ValidateScriptDefinitionOrder(buildProg(t, forwardNavScript)) { + t.Errorf("project-free order check reported %s: %s", v.RuleID, v.Message) + } + f := newNavFixture(t) + f.ctx.forwardFlowCalls = true + if got, _ := f.check(t, forwardNavScript); got != "" { + t.Errorf("check -p refused a navigation written before its targets:\n%s", got) + } +} + +// A run that cannot roll back would leave the target dangling if a later +// statement failed, so it refuses, with the reason. +func TestNavigationTargets_ForwardReferenceIsRefusedByARunThatCannotRollBack(t *testing.T) { + f := newNavFixture(t) + f.ctx.forwardFlowCalls = false + got, _ := f.check(t, forwardNavScript) + for _, want := range []string{ + "navigation Responsive: home page references page Shop.Later before it is created", + "navigation Responsive: menu item 'Flow' references microflow Shop.ACT_Later before it is created", + "cannot be rolled back", + } { + if !strings.Contains(got, want) { + t.Errorf("check -p lacks %q in:\n%s", want, got) + } + } +} + +// The handler resolves a target the program declares further down only in a +// run that rolls back, and only the kinds that are stored by name. +func TestNavigationTargets_ExecResolvesForwardTargetsOnlyInARunThatRollsBack(t *testing.T) { + script := "create or modify navigation Responsive home page Shop.Later;" + declared := map[defKey]bool{{kind: defPage, name: "Shop.Later"}: true} + + f := newNavFixture(t) + f.ctx.forwardFlowCalls, f.ctx.scriptTargets = true, declared + if err := f.exec(t, script); err != nil { + t.Fatalf("a target declared further down was refused in a run that rolls back: %v", err) + } + if f.updates != 1 { + t.Errorf("updates = %d, want 1", f.updates) + } + + for name, setup := range map[string]func(*ExecContext){ + "run that cannot roll back": func(c *ExecContext) { c.forwardFlowCalls, c.scriptTargets = false, declared }, + "target the script lacks": func(c *ExecContext) { c.forwardFlowCalls, c.scriptTargets = true, map[defKey]bool{} }, + "declared as another kind": func(c *ExecContext) { + c.forwardFlowCalls, c.scriptTargets = true, map[defKey]bool{{kind: defMicroflow, name: "Shop.Later"}: true} + }, + } { + f := newNavFixture(t) + setup(f.ctx) + if err := f.exec(t, script); err == nil || !strings.Contains(err.Error(), "page not found: Shop.Later") { + t.Errorf("%s: err = %v, want a refusal naming Shop.Later", name, err) + } + if f.updates != 0 { + t.Errorf("%s: the profile was written", name) + } + } +} + +// The executor declares what the program creates before it runs the first +// statement, for the pages, microflows and nanoflows a navigation may name. +func TestNavigationTargets_EnterScriptFlowsDeclaresPagesAndFlows(t *testing.T) { + e := &Executor{forwardFlowCalls: true} + restore := e.enterScriptFlows(buildProg(t, forwardNavScript+"create nanoflow Shop.NF_Later () begin end;\n")) + for _, k := range []defKey{{defPage, "Shop.Later"}, {defMicroflow, "Shop.ACT_Later"}, {defNanoflow, "Shop.NF_Later"}} { + if !e.scriptTargets[k] { + t.Errorf("scriptTargets lacks %v: %v", k, e.scriptTargets) + } + } + restore() + if len(e.scriptTargets) != 0 { + t.Errorf("scriptTargets not restored: %v", e.scriptTargets) + } + // A run that cannot roll back declares nothing. + e = &Executor{} + e.enterScriptFlows(buildProg(t, forwardNavScript)) + if len(e.scriptTargets) != 0 { + t.Errorf("a run that cannot roll back declared %v", e.scriptTargets) + } +} + +// Without a rollback, a target created further down by `create or modify` is +// judged against what the project holds: it does not, so exec would stop at the +// navigation. +func TestNavigationTargets_ForwardOrModifyIsRefusedWithoutRollback(t *testing.T) { + f := newNavFixture(t) + f.ctx.forwardFlowCalls = false + got, _ := f.check(t, `mdl 1; +create or modify navigation Responsive home page Shop.Later; +create or modify page Shop.Later ( Title: 'Later', Layout: Atlas_Core.Atlas_Default ) { }; +`) + for _, want := range []string{"navigation Responsive: home page", "Shop.Later", "before it is created"} { + if !strings.Contains(got, want) { + t.Errorf("check -p lacks %q in:\n%s", want, got) + } + } +} + +// A native profile's `home microflow` is stored as the nanoflow it is, so the +// name is looked up among the nanoflows. +func TestNavigationTargets_NativeHomeMicroflowIsLookedUpAsNanoflow(t *testing.T) { + f := newNavFixture(t) + f.mb.GetNavigationFunc = func() (*types.NavigationDocument, error) { + return &types.NavigationDocument{Profiles: []*types.NavigationProfile{ + {Name: "NativePhone", IsNative: true}, + }}, nil + } + if got, _ := f.check(t, "create or modify navigation NativePhone home microflow Shop.NF_Go;"); got != "" { + t.Errorf("a native home naming an existing nanoflow was refused:\n%s", got) + } + got, _ := f.check(t, "create or modify navigation NativePhone home nanoflow Shop.Gone;") + if !strings.Contains(got, "home nanoflow: nanoflow not found: Shop.Gone") { + t.Errorf("check -p lacks the native home nanoflow in:\n%s", got) + } +} diff --git a/mdl/executor/validate_page_button_context.go b/mdl/executor/validate_page_button_context.go index 63482465d..2ec23c6bd 100644 --- a/mdl/executor/validate_page_button_context.go +++ b/mdl/executor/validate_page_button_context.go @@ -132,6 +132,12 @@ func checkOwnContainerName(w *ast.WidgetV3, nearest, locationPrefix string) []li flag(p.written+" expression", v, "CE0117") } } + // A quoted `Editable: ''` is the same condition as the bare one. + if _, routed := w.Properties["EditableIf"]; !routed { + if expr, ok := editableCondition(w); ok { + flag("Editable expression", expr, "CE0117") + } + } return out } diff --git a/mdl/executor/validate_widget_editability.go b/mdl/executor/validate_widget_editability.go index 4ed64f614..472e97d64 100644 --- a/mdl/executor/validate_widget_editability.go +++ b/mdl/executor/validate_widget_editability.go @@ -101,7 +101,11 @@ func validateWidgetEditability(w *ast.WidgetV3, locationPrefix string) []linter. // (lowered to EditableIf, and riding on ConditionalEditabilitySettings these // types do not have) is not. Only the second is worth reporting. if plainEditableWidgetTypes[typ] && !strings.EqualFold(key, "editableif") { - return nil + // A quoted condition is the bracket form's twin: it is the part of + // `Editable:` these types have no place for. + if _, expr, ok := staticEditability(w); !ok || expr == "" { + return nil + } } return []linter.Violation{{ RuleID: "MDL-WIDGET20", diff --git a/mdl/executor/validate_widget_embedded_schema.go b/mdl/executor/validate_widget_embedded_schema.go new file mode 100644 index 000000000..478bd8efe --- /dev/null +++ b/mdl/executor/validate_widget_embedded_schema.go @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "sort" + "strings" + "sync" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/modelsdk/widgets" +) + +// What a check without a project knows about Data grid 2. +// +// A widget's properties come from its package (.mpk), which only a project +// has. The widgets mxcli embeds a definition for (combobox, gallery, the grid +// filters, image) are known without one. DATAGRID is not among them: its +// definition is generated from the project's own package, and embedding one +// would put it ahead of that package for every write +// (RefreshWidgetDefinitions skips an id the registry already holds). +// +// So without a project the widget had no definition at all, and the check fell +// back on the vocabulary of the built-in widgets — a union of what their +// builders read and what describe prints. Every Data grid 2 setting outside it +// was reported as "not recognized and will be silently dropped on write" +// (MDL-WIDGET07) although exec writes it, and an Expression-typed one was an +// ERROR (MDL-WIDGET42, "a plain-value property cannot hold the expression"). +// `mxcli check` of a script `exec` takes, and `describe` prints, failed. +// +// What mxcli does have is the template it embeds for the widget, which declares +// every property. That is a baseline (the oldest supported Mendix version, see +// widget-engine.md), so it is evidence of what a key IS, never of what it is +// not: a key it declares is accepted, a key it does not is reported as one that +// cannot be verified without a project. + +// embeddedWidgetSchema is the property list of a widget template mxcli embeds. +type embeddedWidgetSchema struct { + // Title and Version are the template's: "Data grid 2", "11.6.3". + Title string + Version string + // Names are the declared property keys, in declaration order. + Names []string + // types maps a lower-cased key to its declared type ("Expression", + // "TextTemplate", "Boolean", …). + types map[string]string +} + +// declares reports whether the template declares key, case-insensitively — or +// the `Params` companion of a text-template property, which carries its +// `{N}` parameters. +func (s *embeddedWidgetSchema) declares(key string) bool { + if s == nil { + return false + } + lower := strings.ToLower(key) + if _, ok := s.types[lower]; ok { + return true + } + if base, found := strings.CutSuffix(lower, "params"); found { + return s.types[base] == "TextTemplate" + } + return false +} + +// operation is the engine operation that writes key ("expression", +// "texttemplate", "attribute", "primitive"), or "" for a kind with none. +func (s *embeddedWidgetSchema) operation(key string) (string, bool) { + if s == nil { + return "", false + } + t, ok := s.types[strings.ToLower(key)] + if !ok { + return "", false + } + return operationForValueType(t), true +} + +// pluggableWidgetIDOfKeyword is the id of the pluggable widget an MDL keyword +// names, whatever the Mendix version (DATAGRID → Data grid 2), or "". +func pluggableWidgetIDOfKeyword(keyword string) string { + upper := strings.ToUpper(keyword) + for _, m := range keywordDispatchTable { + if m.Keyword != upper { + continue + } + for _, b := range m.Bindings { + if b.Kind == bindingKindPluggable && b.WidgetID != "" { + return b.WidgetID + } + } + } + return "" +} + +var embeddedSchemas sync.Map // widgetID + "/" + listKey -> *embeddedWidgetSchema (nil when there is none) + +// embeddedSchemaFor returns the embedded schema for the keyword widget w — +// the grid's own properties for `datagrid`, a column's for a `column` inside +// one — or nil when w is not a keyword widget the registry lacks a definition +// for (everything else is judged exactly as before). +func embeddedSchemaFor(w, parent *ast.WidgetV3) *embeddedWidgetSchema { + if w == nil || w.TypeIsGeneric || explicitWidgetID(w) != "" { + return nil + } + if id := pluggableWidgetIDOfKeyword(w.Type); id != "" { + return loadEmbeddedSchema(id, "") + } + if strings.EqualFold(w.Type, "column") && parent != nil && !parent.TypeIsGeneric && explicitWidgetID(parent) == "" { + if id := pluggableWidgetIDOfKeyword(parent.Type); id != "" { + return loadEmbeddedSchema(id, "columns") + } + } + return nil +} + +// loadEmbeddedSchema reads the property list of the embedded template for +// widgetID — or, with listKey, the item type of that object-list property. +func loadEmbeddedSchema(widgetID, listKey string) *embeddedWidgetSchema { + cacheKey := widgetID + "/" + listKey + if v, ok := embeddedSchemas.Load(cacheKey); ok { + s, _ := v.(*embeddedWidgetSchema) + return s + } + var schema *embeddedWidgetSchema + if tmpl, err := widgets.GetTemplate(widgetID); err == nil && tmpl != nil { + if propTypes := embeddedTemplatePropertyTypes(tmpl.Type, listKey); len(propTypes) > 0 { + schema = &embeddedWidgetSchema{Title: tmpl.Name, Version: tmpl.Version, types: map[string]string{}} + if listKey != "" { + schema.Title += " column" + } + for _, pt := range propTypes { + key, _ := pt["PropertyKey"].(string) + if key == "" { + continue + } + kind := "" + if vt, ok := pt["ValueType"].(map[string]any); ok { + kind, _ = vt["Type"].(string) + } + schema.Names = append(schema.Names, key) + schema.types[strings.ToLower(key)] = kind + } + sort.Strings(schema.Names) + } + } + embeddedSchemas.Store(cacheKey, schema) + return schema +} + +// embeddedTemplatePropertyTypes lists the property types of a template's widget type — +// or of the item type of its object-list property listKey. +func embeddedTemplatePropertyTypes(widgetType map[string]any, listKey string) []map[string]any { + objType, _ := widgetType["ObjectType"].(map[string]any) + list := embeddedPropertyTypeMaps(objType) + if listKey == "" { + return list + } + for _, pt := range list { + if key, _ := pt["PropertyKey"].(string); key != listKey { + continue + } + vt, _ := pt["ValueType"].(map[string]any) + itemType, _ := vt["ObjectType"].(map[string]any) + return embeddedPropertyTypeMaps(itemType) + } + return nil +} + +// embeddedPropertyTypeMaps is an object type's PropertyTypes as maps; the typed-array +// marker that opens the array is not one. +func embeddedPropertyTypeMaps(objType map[string]any) []map[string]any { + var out []map[string]any + if objType == nil { + return nil + } + arr, _ := objType["PropertyTypes"].([]any) + for _, e := range arr { + if m, ok := e.(map[string]any); ok { + out = append(out, m) + } + } + return out +} diff --git a/mdl/executor/validate_widget_embedded_schema_test.go b/mdl/executor/validate_widget_embedded_schema_test.go new file mode 100644 index 000000000..5cee16349 --- /dev/null +++ b/mdl/executor/validate_widget_embedded_schema_test.go @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: Apache-2.0 + +// `mxcli check` without a project must not call a Data grid 2 setting "not +// recognized and silently dropped" when the widget declares it. +// +// datagrid dgAttr (DataSource: database Shop.Ord, configurationStorageType: attribute, +// configurationAttribute: Config) +// +// `exec` stores both, and `check -p` accepts them. `check` alone warned +// MDL-WIDGET07 for each — and refused an Expression-typed one (rowClass) as +// MDL-WIDGET42, an ERROR — because DATAGRID has no embedded definition and the +// check fell back on the vocabulary of the built-in widgets. +// +// The keys here come from the Data grid 2 template mxcli embeds, not from a +// list typed for the test. +package executor + +import ( + "fmt" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// noProjectFindings is what `mxcli check script.mdl` (no -p) reports for the +// widget rules in question. +func noProjectFindings(t *testing.T, script string) []linter.Violation { + t.Helper() + var out []linter.Violation + for _, v := range ValidateProgram(parseMDL(t, script), "") { + if v.RuleID == "MDL-WIDGET07" || v.RuleID == "MDL-WIDGET42" { + out = append(out, v) + } + } + return out +} + +func describeFindings(vs []linter.Violation) string { + var lines []string + for _, v := range vs { + lines = append(lines, fmt.Sprintf("[%s %s] %s", v.RuleID, v.Severity, v.Message)) + } + return strings.Join(lines, "\n ") +} + +// The personalization settings the issue is about, in the three shapes a script +// uses them (exec stores every one). +func TestCheckWithoutProject_DataGridDeclaredKeysAreNotReportedAsDropped(t *testing.T) { + script := `create page Shop.Ord_Overview ( Title: 'Orders', Layout: Atlas_Core.Atlas_Default ) { + datagrid dg (DataSource: database Shop.Ord, PageSize: 25, Pagination: virtualScrolling, + itemSelection: Single, refreshInterval: 30, columnsHidable: false, showEmptyPlaceholder: custom) { + column (Attribute: Code, Caption: 'Code', Sortable: true) { textfilter tf } + } +}; +create page Shop.Ord_Local ( Title: 'Local', Layout: Atlas_Core.Atlas_Default ) { + datagrid dgLocal (DataSource: database Shop.Ord, configurationStorageType: localStorage, storeFiltersInPersonalization: true) { + column (Attribute: Code) { textfilter tf } + } +}; +create page Shop.Ord_Attr ( Title: 'Attr', Layout: Atlas_Core.Atlas_Default, Params: ( $Pref: Shop.GridPref ) ) { + dataview dvPref (DataSource: $Pref) { + datagrid dgAttr (DataSource: database Shop.Ord, configurationStorageType: attribute, + configurationAttribute: Config, storeFiltersInPersonalization: true) { + column (Attribute: Code) { textfilter tf2 } + } + } +};` + if got := noProjectFindings(t, script); len(got) != 0 { + t.Errorf("check without a project reported declared Data grid 2 keys:\n %s", describeFindings(got)) + } +} + +// Expression-typed keys, grid and column: an error before, because the check +// could not tell the key takes one. +func TestCheckWithoutProject_DataGridExpressionKeysTakeAnExpression(t *testing.T) { + script := `create page Shop.P ( Title: 'P', Layout: Atlas_Core.Atlas_Default ) { + datagrid dg (DataSource: database Shop.Ord, rowClass: if $currentObject/Open then 'open' else 'closed') { + column (Attribute: Code, columnClass: if $currentObject/Open then 'a' else 'b') { textfilter tf } + } +};` + if got := noProjectFindings(t, script); len(got) != 0 { + t.Errorf("check without a project refused an expression on an Expression-typed key:\n %s", describeFindings(got)) + } +} + +// The column settings that have no MDL keyword. +func TestCheckWithoutProject_DataGridColumnDeclaredKeysAreNotReportedAsDropped(t *testing.T) { + script := `create page Shop.P ( Title: 'P', Layout: Atlas_Core.Atlas_Default ) { + datagrid dg (DataSource: database Shop.Ord) { + column (Attribute: Code, ShowContentAs: customContent, ColumnWidth: autoFit, minWidth: manual, + minWidthLimit: 150, allowEventPropagation: false, exportValue: 'x') { + dynamictext t (Content: 'x') + } + } +};` + if got := noProjectFindings(t, script); len(got) != 0 { + t.Errorf("check without a project reported declared column keys:\n %s", describeFindings(got)) + } +} + +// A misspelled key is still reported — the point of the rule — and says what is +// true: it cannot be verified without a project, and it names the key it +// probably meant. +func TestCheckWithoutProject_DataGridMisspelledKeyStillWarns(t *testing.T) { + script := `create page Shop.P ( Title: 'P', Layout: Atlas_Core.Atlas_Default ) { + datagrid dg (DataSource: database Shop.Ord, configurationStorageTyp: localStorage) { + column (Attribute: Code, minWidthLimt: 150) { textfilter tf } + } +};` + got := noProjectFindings(t, script) + if len(got) != 2 { + t.Fatalf("want 2 findings (the grid key and the column key), got %d:\n %s", len(got), describeFindings(got)) + } + for _, v := range got { + if v.RuleID != "MDL-WIDGET07" || v.Severity != linter.SeverityWarning { + t.Errorf("want a MDL-WIDGET07 warning, got %s", describeFindings([]linter.Violation{v})) + } + } + grid, column := got[0].Message, got[1].Message + if !strings.Contains(grid, "`configurationStorageTyp`") || !strings.Contains(grid, "did you mean `configurationStorageType`") { + t.Errorf("grid finding does not name the key and its likely spelling:\n %s", grid) + } + if !strings.Contains(column, "`minWidthLimt`") || !strings.Contains(column, "did you mean `minWidthLimit`") { + t.Errorf("column finding does not name the key and its likely spelling:\n %s", column) + } + for _, msg := range []string{grid, column} { + if !strings.Contains(msg, "without a project") { + t.Errorf("finding does not say it cannot be verified without a project:\n %s", msg) + } + } +} + +// The embedded definition is a baseline, not the installed widget: a key it does +// not declare may be one a newer widget version added. The message must not +// claim the value is lost as a fact; it says the key cannot be verified. +func TestCheckWithoutProject_UndeclaredKeyIsNotClaimedToBeDropped(t *testing.T) { + got := noProjectFindings(t, `create page Shop.P ( Title: 'P', Layout: Atlas_Core.Atlas_Default ) { + datagrid dg (DataSource: database Shop.Ord, aSettingFromANewerWidget: true) +};`) + if len(got) != 1 { + t.Fatalf("want 1 finding, got %d:\n %s", len(got), describeFindings(got)) + } + msg := got[0].Message + if !strings.Contains(msg, "cannot be verified without a project") { + t.Errorf("message does not say the key cannot be verified:\n %s", msg) + } + if strings.Contains(msg, "is not recognized and will be silently dropped") { + t.Errorf("message states as fact that the value is dropped:\n %s", msg) + } +} + +// A built-in widget has no embedded template; its message is the one it always +// had, so the widgets the vocabulary was written for are judged as before. +func TestCheckWithoutProject_BuiltInWidgetUnknownKeyIsUnchanged(t *testing.T) { + got := noProjectFindings(t, `create page Shop.P ( Title: 'P', Layout: Atlas_Core.Atlas_Default ) { + textbox tb (Attribute: Code, columnsHidable: false) +};`) + if len(got) != 1 || !strings.Contains(got[0].Message, "is not recognized and will be silently dropped on write") { + t.Errorf("want the unchanged MDL-WIDGET07 message for a text box, got:\n %s", describeFindings(got)) + } +} + +// Derived from the schema, not typed: every scalar key the embedded Data grid 2 +// template declares, written as the value its kind takes, draws neither rule. +// A key added to the template is covered the day it is added. +func TestCheckWithoutProject_EveryDeclaredDataGridKeyIsAccepted(t *testing.T) { + schema := loadEmbeddedSchema(embeddedDataGridID, "") + if schema == nil || len(schema.Names) < 30 { + t.Fatalf("Data grid 2 schema = %+v, want the template's ~40 keys", schema) + } + values := map[string]string{ + "Boolean": "true", "Integer": "1", "Decimal": "1", "String": "'x'", "Enumeration": "'x'", + "TextTemplate": "'x'", "Expression": "if true then 'a' else 'b'", "Attribute": "Code", + } + var props []string + for _, key := range schema.Names { + kind := schema.types[strings.ToLower(key)] + v, ok := values[kind] + if !ok { + continue // datasource, selection, action, widgets, object: other rules and other syntax + } + props = append(props, key+": "+v) + } + if len(props) < 25 { + t.Fatalf("only %d scalar keys in the template", len(props)) + } + script := "create page Shop.P ( Title: 'P', Layout: Atlas_Core.Atlas_Default ) {\n datagrid dg (DataSource: database Shop.Ord, " + + strings.Join(props, ", ") + ")\n};" + if got := noProjectFindings(t, script); len(got) != 0 { + t.Errorf("a key the template declares was reported:\n %s", describeFindings(got)) + } +} + +// The column item type, the same way. +func TestCheckWithoutProject_EveryDeclaredDataGridColumnKeyIsAccepted(t *testing.T) { + schema := loadEmbeddedSchema(embeddedDataGridID, "columns") + if schema == nil || len(schema.Names) < 15 { + t.Fatalf("column schema = %+v, want the template's ~20 keys", schema) + } + values := map[string]string{ + "Boolean": "true", "Integer": "1", "Decimal": "1", "String": "'x'", "Enumeration": "'x'", + "TextTemplate": "'x'", "Expression": "if true then 'a' else 'b'", "Attribute": "Code", + } + var props []string + for _, key := range schema.Names { + if v, ok := values[schema.types[strings.ToLower(key)]]; ok { + props = append(props, key+": "+v) + } + } + script := "create page Shop.P ( Title: 'P', Layout: Atlas_Core.Atlas_Default ) {\n datagrid dg (DataSource: database Shop.Ord) {\n column (" + + strings.Join(props, ", ") + ")\n }\n};" + if got := noProjectFindings(t, script); len(got) != 0 { + t.Errorf("a column key the template declares was reported:\n %s", describeFindings(got)) + } +} + +const embeddedDataGridID = "com.mendix.widget.web.datagrid.Datagrid" diff --git a/mdl/executor/validate_widget_expression_value.go b/mdl/executor/validate_widget_expression_value.go index 9f6ab6f17..4ba898ed0 100644 --- a/mdl/executor/validate_widget_expression_value.go +++ b/mdl/executor/validate_widget_expression_value.go @@ -26,6 +26,16 @@ import ( // built-in widget or an item. func validateWidgetExpressionValues(w *ast.WidgetV3, def *WidgetDefinition, item *ObjectListMapping, isObjectListItem bool, locationPrefix string) []linter.Violation { + return validateWidgetExpressionValuesIn(w, def, item, isObjectListItem, nil, locationPrefix) +} + +// validateWidgetExpressionValuesIn is validateWidgetExpressionValues for a +// widget that may have an embedded schema (embeddedSchemaFor), which stands in +// for the definition a check without a project does not have: the kind it +// declares for a key decides, and a key it does not declare is left to +// MDL-WIDGET07 rather than called a plain-value property. +func validateWidgetExpressionValuesIn(w *ast.WidgetV3, def *WidgetDefinition, item *ObjectListMapping, + isObjectListItem bool, schema *embeddedWidgetSchema, locationPrefix string) []linter.Violation { if w == nil || len(w.Properties) == 0 { return nil } @@ -52,13 +62,16 @@ func validateWidgetExpressionValues(w *ast.WidgetV3, def *WidgetDefinition, item continue } op, known := expressionValueOperation(key, def, item) + if !known && def == nil && item == nil { + op, known = schema.operation(key) + } switch { case op == "expression": continue case op == "image" && isImageObjectVariable(text): // `Image: $currentObject` binds the object in context (#1476). continue - case isExpr && !known && (def != nil || item != nil): + case isExpr && !known && (def != nil || item != nil || schema != nil): // A key the definition does not map: MDL-WIDGET01 (unknown) or // MDL-WIDGET06 (known, unmapped) already speaks for it, and the // builder resolves its kind from the template. diff --git a/mdl/executor/validate_widgets.go b/mdl/executor/validate_widgets.go index 7b9de4977..c61db2935 100644 --- a/mdl/executor/validate_widgets.go +++ b/mdl/executor/validate_widgets.go @@ -311,12 +311,20 @@ func validateWidgetTreeIn(widgets []*ast.WidgetV3, registry *WidgetRegistry, loc // so gets past every other rule. Needs the parent's definition, and stays // quiet without one for the same reason MDL-WIDGET26 does. out = append(out, validateUnroutedChildren(w, def, locationPrefix)...) + // A keyword widget the registry has no definition for (DATAGRID: its + // definition is generated from the project's package) is judged by the + // property list of the template mxcli embeds for it, not by the + // vocabulary of the built-in widgets. + var embedded *embeddedWidgetSchema + if def == nil && !isObjectListItem { + embedded = embeddedSchemaFor(w, parent) + } // An expression on a generic key whose schema kind takes none — which // used to be written as an empty value (MDL-WIDGET42). if mapping != nil { out = append(out, validateWidgetExpressionValues(w, nil, mapping, true, locationPrefix)...) } else { - out = append(out, validateWidgetExpressionValues(w, def, nil, isObjectListItem, locationPrefix)...) + out = append(out, validateWidgetExpressionValuesIn(w, def, nil, isObjectListItem, embedded, locationPrefix)...) } // A generic widget type that resolved to nothing is already reported as // MDL-WIDGET25 (the kind is wrong). Validating its properties on top of @@ -330,7 +338,7 @@ func validateWidgetTreeIn(widgets []*ast.WidgetV3, registry *WidgetRegistry, loc // no definition for the project's own widget — the built-in allow-list // would report every property it has. if def == nil && !isObjectListItem && !w.TypeIsGeneric && explicitWidgetID(w) == "" { - out = append(out, validateStaticWidgetUnknownProps(w, locationPrefix)...) + out = append(out, validateStaticWidgetUnknownPropsIn(w, locationPrefix, embedded, registryProjectPath(registry) != "")...) // #1465: the vocabulary above is a cross-widget union, so a key a // tab page cannot store (CaptionParams, Class, Style) passes it. out = append(out, validateTabPageProps(w, locationPrefix)...) @@ -1109,9 +1117,19 @@ func isKnownStaticWidgetProp(key string) bool { // could false-positive on a valid property. Pluggable widgets are validated // separately (MDL-WIDGET01) and must not reach here. func validateStaticWidgetUnknownProps(w *ast.WidgetV3, locationPrefix string) []linter.Violation { + return validateStaticWidgetUnknownPropsIn(w, locationPrefix, nil, false) +} + +// validateStaticWidgetUnknownPropsIn is validateStaticWidgetUnknownProps for a +// widget that may have an embedded schema (embeddedSchemaFor): Data grid 2, +// whose definition only a project can supply. A key that schema declares is +// known. A key it does not is reported as one that cannot be verified, not as +// one that is dropped — the schema is the baseline template, and a newer +// widget version can declare more. +func validateStaticWidgetUnknownPropsIn(w *ast.WidgetV3, locationPrefix string, schema *embeddedWidgetSchema, projectOpen bool) []linter.Violation { var out []linter.Violation for _, key := range sortedPropertyKeys(w) { - if isKnownStaticWidgetProp(key) { + if isKnownStaticWidgetProp(key) || schema.declares(key) { continue } // Dynamic-text format keys placed at the widget level are reported by @@ -1133,18 +1151,37 @@ func validateStaticWidgetUnknownProps(w *ast.WidgetV3, locationPrefix string) [] if isInputSubmitProp(w.Type, key) { continue } + candidates := staticWidgetKnownPropList + if schema != nil { + // The widget's own names first: they are what the key was meant to be. + candidates = append(append([]string(nil), schema.Names...), staticWidgetKnownPropList...) + } hint := "" - if suggestion := nearestKey(key, staticWidgetKnownPropList); suggestion != "" { + if suggestion := nearestKey(key, candidates); suggestion != "" { hint = fmt.Sprintf(" — did you mean `%s`?", suggestion) } - out = append(out, linter.Violation{ + message := fmt.Sprintf( + "%s: %s property `%s` is not recognized and will be silently dropped on write%s", + locationPrefix, widgetLabel(w.Name, w.Type), key, hint, + ) + if schema != nil { + // Not "will be dropped": the template is the oldest supported version, + // and the installed widget may declare more. + message = fmt.Sprintf( + "%s: %s property `%s` is not declared by the embedded %s definition (%s), so it cannot be verified without a project — "+ + "it is silently dropped on write unless the installed widget declares it%s", + locationPrefix, widgetLabel(w.Name, w.Type), key, schema.Title, schema.Version, hint, + ) + } + v := linter.Violation{ RuleID: "MDL-WIDGET07", Severity: linter.SeverityWarning, - Message: fmt.Sprintf( - "%s: %s property `%s` is not recognized and will be silently dropped on write%s", - locationPrefix, widgetLabel(w.Name, w.Type), key, hint, - ), - }) + Message: message, + } + if schema != nil && !projectOpen { + v.Suggestion = "Run check with -p to look the key up in the installed widget." + } + out = append(out, v) } return out } @@ -1301,7 +1338,24 @@ func validateConsumableConditional(w *ast.WidgetV3, locationPrefix string) []lin } switch v.(type) { case bool, string: - continue // a static form StaticVisibleExpression consumes + // A static form StaticVisibleExpression / StaticEditability consumes. + // Visible takes any string as its condition; Editable takes an enum + // word, a boolean or a condition, and refuses what is none of them + // here, because the writer has nowhere to put it. + if p.plain == "Editable" { + if problem := editableValueProblem(w); problem != "" { + out = append(out, linter.Violation{ + RuleID: "MDL-WIDGET19", + Severity: linter.SeverityError, + Message: fmt.Sprintf( + "%s: %s: %s — Editable takes Always, Never (or true / false), or a condition "+ + "expression such as `Editable: $currentObject/Locked = false`, bare or in quotes; "+ + "anything else cannot be stored and would leave the widget editable", + locationPrefix, widgetLabel(w.Name, w.Type), problem), + }) + } + } + continue } out = append(out, linter.Violation{ RuleID: "MDL-WIDGET19", @@ -1417,6 +1471,13 @@ func validateWidgetExpressionAssociations(w *ast.WidgetV3, locationPrefix string var out []linter.Violation for _, key := range expressionWidgetProps { expr, ok := lookupWidgetProp(w, key) + shown := key + if key == "EditableIf" && (!ok || expr == "") { + // A quoted `Editable: ''` is the same condition. + if e, quoted := editableCondition(w); quoted { + expr, ok, shown = e, true, "Editable" + } + } if !ok || expr == "" { continue } @@ -1426,7 +1487,7 @@ func validateWidgetExpressionAssociations(w *ast.WidgetV3, locationPrefix string Severity: linter.SeverityError, Message: fmt.Sprintf( "%s: widget `%s` property `%s` expression traverses association `%s` — Mendix expressions cannot follow associations (CE0117). Bind the value via a data binding (contentparams/attribute) or precompute it onto the bound entity (e.g. a calculated attribute).", - locationPrefix, w.Name, key, m[1], + locationPrefix, w.Name, shown, m[1], ), }) } diff --git a/mdl/executor/widget_editable_quoted_expression_test.go b/mdl/executor/widget_editable_quoted_expression_test.go new file mode 100644 index 000000000..9750ed392 --- /dev/null +++ b/mdl/executor/widget_editable_quoted_expression_test.go @@ -0,0 +1,401 @@ +// SPDX-License-Identifier: Apache-2.0 + +// A quoted `Editable: ''` on an input widget. +// +// `Visible: '$currentObject/Locked = false'` is stored as a conditional +// visibility: pages.StaticVisibleExpression hands any string that is not true or +// false back as the expression. `Editable` took the same grammar slot but a +// different consumer, pages.CanonicalEditability, which knows only the enum words +// Always | Never | Conditional and answers "not one of mine" for the rest. The +// builder's `else if` then simply did not fire, so the widget kept the default +// Always: check clean, exec "Created page", and a field the author meant to lock +// stayed editable. +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +const lockedExpr = "$currentObject/Locked = false" + +// buildInputFromMDL runs the real parser and page builder over one widget. +func buildInputFromMDL(t *testing.T, widget string) pages.Widget { + t.Helper() + src := "create page Mod.P (Title: 'T') {\n " + widget + "\n}" + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse %q: %v", widget, errs) + } + page := prog.Statements[0].(*ast.CreatePageStmtV3) + pb := &pageBuilder{widgetScope: map[string]model.ID{}} + w, err := pb.buildWidgetV3(page.Widgets[0]) + if err != nil { + t.Fatalf("building %q: %v", widget, err) + } + return w +} + +func baseWidgetOf(t *testing.T, w pages.Widget) *pages.BaseWidget { + t.Helper() + g, ok := w.(interface{ GetBaseWidget() *pages.BaseWidget }) + if !ok { + t.Fatalf("%T has no BaseWidget", w) + } + return g.GetBaseWidget() +} + +// storedEditability is what the writers store for a built widget: the enum and +// the condition beside it. +func storedEditability(t *testing.T, w pages.Widget) (enum, expr string) { + t.Helper() + bw := baseWidgetOf(t, w) + if bw.ConditionalEditability != nil { + expr = bw.ConditionalEditability.Expression + } + return pages.WidgetEditability(bw), expr +} + +func storedVisibility(t *testing.T, w pages.Widget) string { + t.Helper() + bw := baseWidgetOf(t, w) + if bw.ConditionalVisibility == nil { + return "" + } + return bw.ConditionalVisibility.Expression +} + +var editableInputs = []struct{ name, tmpl string }{ + {"checkbox", `CHECKBOX w (Label: 'A', Attribute: Mod.E.Flag, %s)`}, + {"textbox", `TEXTBOX w (Label: 'C', Attribute: Mod.E.Code, %s)`}, +} + +func inputWith(tmpl, prop string) string { return strings.Replace(tmpl, "%s", prop, 1) } + +// The quoted spelling stores exactly what the unquoted one stores. +func TestQuotedEditableExpression_EqualsUnquotedSpelling(t *testing.T) { + for _, in := range editableInputs { + t.Run(in.name, func(t *testing.T) { + quoted := buildInputFromMDL(t, inputWith(in.tmpl, `Editable: '`+lockedExpr+`'`)) + unquoted := buildInputFromMDL(t, inputWith(in.tmpl, `Editable: `+lockedExpr)) + + wantEnum, wantExpr := storedEditability(t, unquoted) + if wantEnum != "Conditional" || wantExpr != lockedExpr { + t.Fatalf("control: the unquoted spelling stored (%q, %q), want (Conditional, %q)", + wantEnum, wantExpr, lockedExpr) + } + gotEnum, gotExpr := storedEditability(t, quoted) + if gotEnum != wantEnum || gotExpr != wantExpr { + t.Errorf("quoted Editable stored (%q, %q), want what the unquoted spelling stores (%q, %q) — "+ + "the quoted expression was dropped and the field stays editable", + gotEnum, gotExpr, wantEnum, wantExpr) + } + }) + } +} + +// A string literal inside the expression is written with its quotes doubled in +// the quoted spelling and must come out as the unquoted spelling's text. +func TestQuotedEditableExpression_EscapedQuotes(t *testing.T) { + quoted := buildInputFromMDL(t, `TEXTBOX w (Attribute: Mod.E.Code, Editable: '$currentObject/Code != ''x''')`) + unquoted := buildInputFromMDL(t, `TEXTBOX w (Attribute: Mod.E.Code, Editable: $currentObject/Code != 'x')`) + _, want := storedEditability(t, unquoted) + enum, got := storedEditability(t, quoted) + if want != "$currentObject/Code != 'x'" { + t.Fatalf("control: unquoted stored %q", want) + } + if enum != "Conditional" || got != want { + t.Errorf("quoted stored (%q, %q), want (Conditional, %q)", enum, got, want) + } +} + +// describe prints a stored condition as `Editable: `; that has to rebuild +// the same condition, and so does the quoted spelling of it. +func TestQuotedEditableExpression_DescribeRoundTrip(t *testing.T) { + w := buildInputFromMDL(t, `CHECKBOX w (Attribute: Mod.E.Flag, Editable: '`+lockedExpr+`')`) + _, expr := storedEditability(t, w) + if expr == "" { + t.Fatal("the quoted expression was not stored, so there is nothing for describe to print") + } + printed := widgetConditionMDL("Editable", expr) + again := buildInputFromMDL(t, `CHECKBOX w (Attribute: Mod.E.Flag, `+printed+`)`) + enum, got := storedEditability(t, again) + if enum != "Conditional" || got != expr { + t.Errorf("describe line %q rebuilt as (%q, %q), want (Conditional, %q)", printed, enum, got, expr) + } +} + +// Controls: the enum words keep mapping to the enum, in any case, quoted or not. +func TestQuotedEditable_EnumWordsStillMapToTheEnum(t *testing.T) { + cases := []struct{ prop, want string }{ + {`Editable: 'Never'`, "Never"}, + {`Editable: 'never'`, "Never"}, + {`Editable: Never`, "Never"}, + {`Editable: 'Always'`, "Always"}, + {`Editable: 'ALWAYS'`, "Always"}, + {`Editable: Always`, "Always"}, + } + for _, c := range cases { + t.Run(c.prop, func(t *testing.T) { + enum, expr := storedEditability(t, buildInputFromMDL(t, `CHECKBOX w (Attribute: Mod.E.Flag, `+c.prop+`)`)) + if enum != c.want || expr != "" { + t.Errorf("%s stored (%q, %q), want (%q, no condition)", c.prop, enum, expr, c.want) + } + }) + } +} + +// A boolean said what it meant and was dropped too: GetStringProp answers "" +// for a bool, so `Editable: false` on a text box left it editable. false is the +// enum's Never, true its Always. +func TestEditableBoolean_IsNotDropped(t *testing.T) { + cases := []struct{ prop, want string }{ + {`Editable: false`, "Never"}, + {`Editable: 'false'`, "Never"}, + {`Editable: true`, "Always"}, + {`Editable: 'TRUE'`, "Always"}, + } + for _, in := range editableInputs { + for _, c := range cases { + t.Run(in.name+"/"+c.prop, func(t *testing.T) { + enum, expr := storedEditability(t, buildInputFromMDL(t, inputWith(in.tmpl, c.prop))) + if enum != c.want || expr != "" { + t.Errorf("%s stored (%q, %q), want (%q, no condition)", c.prop, enum, expr, c.want) + } + }) + } + } +} + +// Control: nothing authored, nothing stored. +func TestQuotedEditable_NothingAuthoredStoresNothing(t *testing.T) { + enum, expr := storedEditability(t, buildInputFromMDL(t, `CHECKBOX w (Attribute: Mod.E.Flag)`)) + if enum != "Always" || expr != "" { + t.Errorf("no Editable stored (%q, %q), want (Always, no condition)", enum, expr) + } +} + +// Visible is unchanged: its quoted expression is a conditional visibility, false +// is the constant condition "false", true stores nothing. +func TestQuotedVisible_Unchanged(t *testing.T) { + cases := []struct{ prop, want string }{ + {`Visible: '` + lockedExpr + `'`, lockedExpr}, + {`Visible: ` + lockedExpr, lockedExpr}, + {`Visible: false`, "false"}, + {`Visible: 'false'`, "false"}, + {`Visible: true`, ""}, + } + for _, c := range cases { + t.Run(c.prop, func(t *testing.T) { + w := buildInputFromMDL(t, `CHECKBOX w (Attribute: Mod.E.Flag, `+c.prop+`)`) + if got := storedVisibility(t, w); got != c.want { + t.Errorf("%s stored visibility %q, want %q", c.prop, got, c.want) + } + if enum, expr := storedEditability(t, w); enum != "Always" || expr != "" { + t.Errorf("%s changed editability to (%q, %q)", c.prop, enum, expr) + } + }) + } +} + +// A list view's and a grid column's Editable is a plain bool built from +// GetBoolProp (ako/mxcli#510) — a different property from the input widgets' +// editability enum. A bool there must not also be read as Never/Always on the +// widget's BaseWidget. +func TestEditableBoolean_PlainBoolTypesKeepTheirOwnProperty(t *testing.T) { + for _, typ := range []string{"listview", "column"} { + for _, v := range []any{true, false, "false"} { + w := &ast.WidgetV3{Type: typ, Name: "w", Properties: map[string]any{"Editable": v}} + lv := &pages.ListView{} + applyConditionalSettings(lv, w) + if got := lv.BaseWidget.Editable; got != "" { + t.Errorf("%s with Editable: %v set the editability enum to %q", typ, v, got) + } + if lv.ConditionalEditability != nil { + t.Errorf("%s with Editable: %v stored a condition %q", typ, v, lv.ConditionalEditability.Expression) + } + } + } +} + +// ---- check ---------------------------------------------------------------- + +func checkMessages(t *testing.T, widget, rule string) []string { + t.Helper() + src := "mdl 1;\ncreate page Mod.P (Title: 'T', Layout: Atlas_Core.Atlas_Default, Params: ($E: Mod.E)) {\n" + + " dataview dvE (DataSource: $E) {\n " + widget + "\n }\n};" + prog := parseMDL(t, src) + var out []string + for _, v := range ValidateWidgetProperties(prog, "") { + if v.RuleID == rule { + out = append(out, v.Message+" | "+v.Suggestion) + } + } + for _, v := range ValidatePageButtonContext(prog) { + if v.RuleID == rule { + out = append(out, v.Message+" | "+v.Suggestion) + } + } + return out +} + +// check refuses what exec cannot store, naming the accepted spellings, and +// stays quiet on what it can. +func TestMDLWIDGET19_EditableValues(t *testing.T) { + cases := []struct { + name string + prop string + refuse bool + }{ + {"quoted expression", `Editable: '` + lockedExpr + `'`, false}, + {"quoted expression with a literal", `Editable: '$currentObject/Code != ''x'''`, false}, + {"unquoted expression", `Editable: ` + lockedExpr, false}, + {"Never", `Editable: Never`, false}, + {"quoted Always", `Editable: 'Always'`, false}, + {"false", `Editable: false`, false}, + // Strings that are neither an enum word nor an expression the grammar reads. + {"misspelt enum word", `Editable: Neever`, true}, + {"misspelt enum word, quoted", `Editable: 'ReadOnly'`, true}, + {"malformed quoted expression", `Editable: '$currentObject/Locked = = false'`, true}, + {"Conditional without a condition", `Editable: Conditional`, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := checkMessages(t, `CHECKBOX w (Attribute: Mod.E.Flag, `+c.prop+`)`, "MDL-WIDGET19") + if !c.refuse { + if len(got) != 0 { + t.Fatalf("MDL-WIDGET19 refused a value exec stores: %v", got) + } + return + } + if len(got) != 1 { + t.Fatalf("want one MDL-WIDGET19, got %v", got) + } + for _, want := range []string{"Always", "Never"} { + if !strings.Contains(got[0], want) { + t.Errorf("message should name the accepted spellings (%s missing): %s", want, got[0]) + } + } + }) + } +} + +// A quoted Editable expression gets the expression checks the unquoted one gets. +func TestQuotedEditableExpression_GetsTheExpressionChecks(t *testing.T) { + const assoc = `$currentObject/Mod.E_E/Code = 'x'` + unquoted := checkMessages(t, `CHECKBOX w (Attribute: Mod.E.Flag, Editable: `+assoc+`)`, "MDL-WIDGET13") + if len(unquoted) != 1 { + t.Fatalf("control: the unquoted spelling should earn one MDL-WIDGET13, got %v", unquoted) + } + quoted := checkMessages(t, `CHECKBOX w (Attribute: Mod.E.Flag, Editable: '$currentObject/Mod.E_E/Code = ''x''')`, "MDL-WIDGET13") + if len(quoted) != 1 { + t.Errorf("the quoted spelling of the same expression earned %d MDL-WIDGET13, want 1: %v", len(quoted), quoted) + } + + // The widget's own data container is not a variable in its own context. + unquoted = checkMessages(t, `CHECKBOX w (Attribute: Mod.E.Flag, Editable: $dvE/Locked = false)`, "MDL-BUTTON02") + if len(unquoted) != 1 { + t.Fatalf("control: the unquoted spelling should earn one MDL-BUTTON02, got %v", unquoted) + } + quoted = checkMessages(t, `CHECKBOX w (Attribute: Mod.E.Flag, Editable: '$dvE/Locked = false')`, "MDL-BUTTON02") + if len(quoted) != 1 { + t.Errorf("the quoted spelling of the same expression earned %d MDL-BUTTON02, want 1: %v", len(quoted), quoted) + } +} + +// A button has no editability, quoted or not (MDL-WIDGET20, #928). +func TestMDLWIDGET20_QuotedExpressionOnAButton(t *testing.T) { + got := widgetViolations(t, buttonPage(`editable: '`+lockedExpr+`'`), "MDL-WIDGET20") + if len(got) != 1 { + t.Fatalf("got %d MDL-WIDGET20 violations for a quoted expression on a button, want 1", len(got)) + } +} + +// On a list view and a grid column `Editable: true` is the plain property, and +// a quoted expression is the part that is genuinely dropped. +func TestMDLWIDGET20_QuotedExpressionOnPlainEditableTypes(t *testing.T) { + src := `create page W.P ( Title: 'P' ) +{ + LISTVIEW lv (DataSource: DATABASE W.Product, Editable: '` + lockedExpr + `') { + DYNAMICTEXT dt ( Content: 'x' ) + } +}` + if got := widgetViolations(t, src, "MDL-WIDGET20"); len(got) != 1 { + t.Fatalf("got %d MDL-WIDGET20 violations for a quoted expression on a list view, want 1", len(got)) + } +} + +// A pluggable widget that does not declare Editability has no place to store a +// condition; the quoted spelling is refused like the unquoted one. +func TestApplyPluggableSystemSettings_QuotedExpression(t *testing.T) { + cases := []struct { + name string + declared []string + wantErr bool + }{ + {"declared", []string{"Editability"}, false}, + {"undeclared", []string{"Visibility"}, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cw := &pages.CustomWidget{RawType: typeDeclaring(c.declared...), Editable: "Always"} + w := &ast.WidgetV3{Type: "switch", Name: "w", Properties: map[string]any{"Editable": lockedExpr}} + err := applyPluggableSystemSettings(cw, w) + if c.wantErr != (err != nil) { + t.Fatalf("err = %v, want error: %v", err, c.wantErr) + } + if !c.wantErr { + if cw.Editable != "Conditional" || cw.ConditionalEditability == nil || + cw.ConditionalEditability.Expression != lockedExpr { + t.Errorf("stored (%q, %v), want (Conditional, %q)", cw.Editable, cw.ConditionalEditability, lockedExpr) + } + } + }) + } +} + +// A pluggable widget that declares Editability takes the same values, and is +// refused the same way for the ones that cannot be stored. +func TestMDLWIDGET19_PluggableWidgetEditableValues(t *testing.T) { + cases := []struct { + prop string + refuse bool + }{ + {`Editable: '` + lockedExpr + `'`, false}, + {`Editable: Never`, false}, + {`Editable: false`, false}, + {`Editable: 'Neever'`, true}, + {`Editable: '$currentObject/Locked = = false'`, true}, + {`Editable: Conditional`, true}, + } + for _, c := range cases { + t.Run(c.prop, func(t *testing.T) { + got := checkMessages(t, `combobox c (Attribute: Status, `+c.prop+`)`, "MDL-WIDGET19") + if c.refuse != (len(got) == 1) || len(got) > 1 { + t.Errorf("%s: want refused=%v, got %v", c.prop, c.refuse, got) + } + }) + } +} + +// A data view stores its static editability from its own ReadOnly flag, not from +// BaseWidget.Editable, so only the condition is carried: the quoted spelling must +// store the same ConditionalEditabilitySettings the bare one does. +func TestQuotedEditableExpression_DataViewMatchesBare(t *testing.T) { + build := func(props map[string]any) *pages.DataView { + dv := &pages.DataView{} + applyConditionalSettings(dv, &ast.WidgetV3{Type: "dataview", Name: "dv", Properties: props}) + return dv + } + bare := build(map[string]any{"EditableIf": lockedExpr}) + quoted := build(map[string]any{"Editable": lockedExpr}) + if bare.ConditionalEditability == nil || quoted.ConditionalEditability == nil || + bare.ConditionalEditability.Expression != quoted.ConditionalEditability.Expression { + t.Errorf("bare stored %v, quoted stored %v", bare.ConditionalEditability, quoted.ConditionalEditability) + } +} diff --git a/mdl/executor/widget_editable_static.go b/mdl/executor/widget_editable_static.go new file mode 100644 index 000000000..472800091 --- /dev/null +++ b/mdl/executor/widget_editable_static.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "regexp" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// The static (non-bracket, non-bare-expression) reading of `Editable:`. +// +// `Visible` and `Editable` share a grammar slot: a string literal lands in the +// widget's plain `Visible` / `Editable` property, and what it means is the +// consumer's business. Visible's consumer (pages.StaticVisibleExpression) reads +// any string that is not true or false as the condition. Editable's used to read +// only the three enum words and skip every other string without a word, so +// `Editable: '$currentObject/Locked = false'` was stored as Always — a field +// meant to be locked stayed editable, with check clean and exec reporting +// success. One rule now serves both: a string that is not the property's plain +// value is its condition (pages.StaticEditability). + +// staticEditability resolves the plain `Editable:` value of w, reporting whether +// the author wrote one. +// +// A list view and a grid column are the exception to "a boolean is Always or +// Never": their Editable is a plain bool of their own (ako/mxcli#510), read by +// their builders from GetBoolProp, and not the editability enum. A boolean there +// is that property's value, not a request for the enum. +func staticEditability(w *ast.WidgetV3) (enum, expr string, ok bool) { + v, present := lookupPropCI(w, "Editable") + if !present { + return "", "", false + } + if plainEditableWidgetTypes[strings.ToLower(w.Type)] && isBooleanValue(v) { + return "", "", false + } + return pages.StaticEditability(v) +} + +// isBooleanValue reports whether v is a boolean or the string spelling of one. +func isBooleanValue(v any) bool { + switch t := v.(type) { + case bool: + return true + case string: + s := strings.ToLower(strings.TrimSpace(t)) + return s == "true" || s == "false" + } + return false +} + +// editableCondition is the client expression of w's conditional editability +// however it was written: the bare or bracketed form (EditableIf), or a quoted +// `Editable: ''`. Every check that reads EditableIf asks this, so the +// quoted spelling gets the checks the unquoted one gets. +func editableCondition(w *ast.WidgetV3) (string, bool) { + if expr := w.GetStringProp("EditableIf"); expr != "" { + return expr, true + } + _, expr, ok := staticEditability(w) + return expr, ok && expr != "" +} + +// newConditionalEditability is the settings element of a conditional editability. +func newConditionalEditability(expr string) *pages.ConditionalEditabilitySettings { + return &pages.ConditionalEditabilitySettings{ + BaseElement: model.BaseElement{ + ID: model.ID(types.GenerateID()), + TypeName: "Forms$ConditionalEditabilitySettings", + }, + Expression: expr, + } +} + +var loneWordRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// editableValueProblem reports why the plain `Editable:` value of w cannot be +// stored, or "" when it can. exec stores every other value, so this is the +// whole list of what check must refuse. +func editableValueProblem(w *ast.WidgetV3) string { + enum, expr, ok := staticEditability(w) + if !ok { + return "" + } + switch { + case enum == "Conditional": + return "`Editable: Conditional` names no condition" + case enum != "": + return "" + case loneWordRe.MatchString(expr): + // A client expression has no bare identifiers, so a lone word is a + // misspelt enum word (`Neever`, `ReadOnly`) and not a condition. + return "`Editable: " + expr + "` is not an Editable value" + case !visitor.ParsesAsExpression(expr): + return "`Editable: " + expr + "` is not a valid expression" + } + return "" +} diff --git a/mdl/visitor/visitor_argument_binding.go b/mdl/visitor/visitor_argument_binding.go index 9bef6b924..847696f9a 100644 --- a/mdl/visitor/visitor_argument_binding.go +++ b/mdl/visitor/visitor_argument_binding.go @@ -197,6 +197,17 @@ func BareExpression(expr string) bool { return bareArgumentText(ctx.(parser.IExpressionContext)) == expr } +// ParsesAsExpression reports whether text is one MDL expression, read with the +// same grammar rule as a bare `Visible: ` / `Editable: `. check asks +// it of a quoted condition, whose text the parser otherwise never sees. +func ParsesAsExpression(text string) bool { + if strings.TrimSpace(text) == "" { + return false + } + _, ok := parseRule(text, func(p *parser.MDLParser) antlr.ParserRuleContext { return p.Expression() }) + return ok +} + // bareArgumentText is the expression a bare workflow argument stores: its // source text, as the Mendix expression editor would hold it. Unlike a // microflow expression stored as written (buildSourceExpression), a string in diff --git a/sdk/javaactions/jssource.go b/sdk/javaactions/jssource.go index 3bb3455b1..167cfd6aa 100644 --- a/sdk/javaactions/jssource.go +++ b/sdk/javaactions/jssource.go @@ -5,6 +5,8 @@ package javaactions import ( "fmt" "strings" + "unicode" + "unicode/utf8" ) // GenerateJavaScriptSource renders the .js stub Studio Pro writes for a @@ -42,7 +44,7 @@ func GenerateJavaScriptSource(actionName string, userCode string, params []*Java // JSDoc: one @param per parameter, then the @returns. sb.WriteString("/**\n") for _, p := range params { - sb.WriteString(fmt.Sprintf(" * @param {%s} %s\n", jsDocType(p.ParameterType), p.Name)) + sb.WriteString(fmt.Sprintf(" * @param {%s} %s\n", jsDocType(p.ParameterType), JavaScriptParameterName(p.Name))) } sb.WriteString(fmt.Sprintf(" * @returns {%s}\n", jsDocReturnType(returnType))) sb.WriteString(" */\n") @@ -50,7 +52,7 @@ func GenerateJavaScriptSource(actionName string, userCode string, params []*Java // Exported async function with the action's parameters. names := make([]string, 0, len(params)) for _, p := range params { - names = append(names, p.Name) + names = append(names, JavaScriptParameterName(p.Name)) } sb.WriteString(fmt.Sprintf("export async function %s(%s) {\n", actionName, strings.Join(names, ", "))) sb.WriteString("\t// BEGIN USER CODE\n") @@ -67,6 +69,31 @@ func GenerateJavaScriptSource(actionName string, userCode string, params []*Java return sb.String() } +// JavaScriptParameterName returns the identifier a parameter has in the .js +// file: the model name with its first character lower-cased and the rest +// untouched. This is the rule mxbuild and Studio Pro apply when they regenerate +// the signature and the JSDoc of a JavaScript action, keeping only the user +// code and the EXTRA CODE section. The model name itself is never changed. +// +// Measured with mxbuild 11.12.5 on an action declaring URL, MyID, Some_Name, A, +// XMLData, _Lead, Obj2Name and Eid, which it regenerated as uRL, myID, +// some_Name, a, xMLData, _Lead, obj2Name and eid: only the first character is +// lowered (URL is not url), underscores and later capitals are left alone, and +// a leading underscore has no case. Studio Pro-authored actions agree: the model +// parameters PhoneNumber and Address, GeocodingProvider, ProviderApiKey are +// phoneNumber and address, geocodingProvider, providerApiKey in their .js. +// +// A body written against the declared case therefore throws a ReferenceError +// once mxbuild regenerates the signature, and nothing warns. The signature must +// be generated here the way mxbuild would, so the body sees what will run. +func JavaScriptParameterName(name string) string { + r, size := utf8.DecodeRuneInString(name) + if r == utf8.RuneError { + return name // empty, or not valid UTF-8: leave it as it is + } + return string(unicode.ToLower(r)) + name[size:] +} + // jsDocType maps a parameter type to a JSDoc type annotation (best-effort). func jsDocType(t CodeActionParameterType) string { switch typeNameOf(t) { diff --git a/sdk/javaactions/jssource_golden_test.go b/sdk/javaactions/jssource_golden_test.go new file mode 100644 index 000000000..db253ae1d --- /dev/null +++ b/sdk/javaactions/jssource_golden_test.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 + +package javaactions + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// The goldens testdata/mxbuild/JS_*.js.golden are the .js files mxbuild 11.12.5 +// left behind for two JavaScript actions mxcli had created on a 11.12.5 app, +// after a package build (CRLF converted to LF). mxbuild regenerates a JavaScript +// action's file from the model and keeps only the user code, EXTRA CODE and the +// import list, so the signature in the golden is the one the user code has to be +// written against. JS_Open's stored body uses the declared spelling (Url): that +// is the defect, kept verbatim — mxbuild does not touch a body. +// +// Only the identifiers are compared, not the whole file: mxbuild also adds an +// `import { Big }` line and marks nullable parameters `[name]`, which mxcli does +// not write yet. + +var jsMxbuildCases = []struct { + name string + params []*JavaActionParameter +}{ + {"JS_Open", []*JavaActionParameter{param("Url", &StringType{}), param("ReadOnly", &BooleanType{})}}, + {"JS_Cases", []*JavaActionParameter{ + param("URL", &StringType{}), param("MyID", &StringType{}), param("Some_Name", &StringType{}), + param("A", &StringType{}), param("XMLData", &StringType{}), param("_Lead", &StringType{}), + param("Obj2Name", &StringType{}), param("Eid", &StringType{}), + }}, +} + +var ( + jsSignatureLine = regexp.MustCompile(`(?m)^export async function .*$`) + jsDocParamName = regexp.MustCompile(`(?m)^ \* @param \{[^}]*\} \[?([^\]\s]+)\]?$`) +) + +func jsIdentifiers(src string) (signature string, docParams []string) { + for _, m := range jsDocParamName.FindAllStringSubmatch(src, -1) { + docParams = append(docParams, m[1]) + } + return jsSignatureLine.FindString(src), docParams +} + +// Regenerating over the file mxbuild wrote must give the same signature and +// the same @param names: this is the steady state of an mxcli edit followed by +// a build, and the user code must not lose an identifier it uses. +func TestGenerateJavaScriptSource_SignatureMatchesMxbuild(t *testing.T) { + for _, c := range jsMxbuildCases { + t.Run(c.name, func(t *testing.T) { + b, err := os.ReadFile(filepath.Join("testdata", "mxbuild", c.name+".js.golden")) + if err != nil { + t.Fatal(err) + } + golden := strings.ReplaceAll(string(b), "\r\n", "\n") + imports, extra := JavaScriptRetainedSections(golden) + got := GenerateJavaScriptSource(c.name, "return 1;", c.params, &StringType{}, imports, extra) + + wantSig, wantDoc := jsIdentifiers(golden) + gotSig, gotDoc := jsIdentifiers(got) + if wantSig == "" || len(wantDoc) != len(c.params) { + t.Fatalf("golden is not what the test expects (signature %q, @param %v)", wantSig, wantDoc) + } + if gotSig != wantSig { + t.Errorf("signature differs from mxbuild's:\n mxbuild: %s\n mxcli: %s", wantSig, gotSig) + } + if strings.Join(gotDoc, ",") != strings.Join(wantDoc, ",") { + t.Errorf("@param names differ from mxbuild's:\n mxbuild: %v\n mxcli: %v", wantDoc, gotDoc) + } + }) + } +} diff --git a/sdk/javaactions/jssource_test.go b/sdk/javaactions/jssource_test.go new file mode 100644 index 000000000..bf6f73bf2 --- /dev/null +++ b/sdk/javaactions/jssource_test.go @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 + +package javaactions + +import ( + "strings" + "testing" +) + +// mxbuild regenerates a JavaScript action's signature and JSDoc from the model +// and keeps only the user code and EXTRA CODE, so the parameter names the user +// code is written against are the names mxbuild generates, not the declared +// ones. Measured with mxbuild 11.12.5 on an action declaring these parameters. +func TestJavaScriptParameterName_MatchesMxbuild(t *testing.T) { + for _, c := range []struct{ declared, generated string }{ + {"URL", "uRL"}, + {"MyID", "myID"}, + {"Some_Name", "some_Name"}, + {"A", "a"}, + {"XMLData", "xMLData"}, + {"_Lead", "_Lead"}, + {"Obj2Name", "obj2Name"}, + {"Eid", "eid"}, + // Parameters of Studio Pro-authored actions and their generated .js. + {"PhoneNumber", "phoneNumber"}, + {"Address", "address"}, + {"GeocodingProvider", "geocodingProvider"}, + {"ProviderApiKey", "providerApiKey"}, + // Already lower-first, and the degenerate input. + {"latitude", "latitude"}, + {"", ""}, + // First rune, not first byte. Defensive: Mendix names are ASCII, so these + // two rows are not measured against mxbuild. + {"Édith", "édith"}, + {"Ärger", "ärger"}, + } { + if got := JavaScriptParameterName(c.declared); got != c.generated { + t.Errorf("JavaScriptParameterName(%q) = %q, want %q", c.declared, got, c.generated) + } + } +} + +func TestGenerateJavaScriptSource_ParameterNamesAreLowerFirst(t *testing.T) { + src := GenerateJavaScriptSource("JS_Open", "return Url;", + []*JavaActionParameter{param("Url", &StringType{}), param("ReadOnly", &BooleanType{})}, + &StringType{}, nil, "") + + for _, want := range []string{ + " * @param {string} url\n", + " * @param {boolean} readOnly\n", + "export async function JS_Open(url, readOnly) {\n", + } { + if !strings.Contains(src, want) { + t.Errorf("generated source lacks %q:\n%s", want, src) + } + } + for _, bad := range []string{"Url,", " Url\n", "ReadOnly)", " ReadOnly\n"} { + if strings.Contains(src, bad) { + t.Errorf("generated source still declares a capitalised parameter (%q):\n%s", bad, src) + } + } + // The user code is the user's: it is never rewritten. + if !strings.Contains(src, "\treturn Url;\n") { + t.Errorf("user code was altered:\n%s", src) + } +} diff --git a/sdk/javaactions/testdata/mxbuild/JS_Cases.js.golden b/sdk/javaactions/testdata/mxbuild/JS_Cases.js.golden new file mode 100644 index 000000000..0d6670fdf --- /dev/null +++ b/sdk/javaactions/testdata/mxbuild/JS_Cases.js.golden @@ -0,0 +1,28 @@ +// This file was generated by Mendix Studio Pro. +// +// WARNING: Only the following code will be retained when actions are regenerated: +// - the import list +// - the code between BEGIN USER CODE and END USER CODE +// - the code between BEGIN EXTRA CODE and END EXTRA CODE +// Other code you write will be lost the next time you deploy the project. +import { Big } from "big.js"; + +// BEGIN EXTRA CODE +// END EXTRA CODE + +/** + * @param {string} [uRL] + * @param {string} [myID] + * @param {string} [some_Name] + * @param {string} [a] + * @param {string} [xMLData] + * @param {string} [_Lead] + * @param {string} [obj2Name] + * @param {string} [eid] + * @returns {Promise.} + */ +export async function JS_Cases(uRL, myID, some_Name, a, xMLData, _Lead, obj2Name, eid) { + // BEGIN USER CODE + return uRL + myID + some_Name + a + xMLData + _Lead + obj2Name + eid; + // END USER CODE +} diff --git a/sdk/javaactions/testdata/mxbuild/JS_Open.js.golden b/sdk/javaactions/testdata/mxbuild/JS_Open.js.golden new file mode 100644 index 000000000..19d3ea33b --- /dev/null +++ b/sdk/javaactions/testdata/mxbuild/JS_Open.js.golden @@ -0,0 +1,22 @@ +// This file was generated by Mendix Studio Pro. +// +// WARNING: Only the following code will be retained when actions are regenerated: +// - the import list +// - the code between BEGIN USER CODE and END USER CODE +// - the code between BEGIN EXTRA CODE and END EXTRA CODE +// Other code you write will be lost the next time you deploy the project. +import { Big } from "big.js"; + +// BEGIN EXTRA CODE +// END EXTRA CODE + +/** + * @param {string} [url] + * @param {boolean} [readOnly] + * @returns {Promise.} + */ +export async function JS_Open(url, readOnly) { + // BEGIN USER CODE + return Url + (ReadOnly ? " (read only)" : ""); + // END USER CODE +} diff --git a/sdk/pages/pages_widgets.go b/sdk/pages/pages_widgets.go index fcf80d3b3..c99c8f5c8 100644 --- a/sdk/pages/pages_widgets.go +++ b/sdk/pages/pages_widgets.go @@ -183,6 +183,49 @@ func CanonicalEditability(v string) (string, bool) { } } +// StaticEditability resolves a static `Editable:` value, the counterpart of +// StaticVisibleExpression for the property that shares its grammar slot. A +// value is exactly one of: +// +// - an enum word (Always | Never | Conditional, any case), returned canonical +// as enum; +// - a boolean, or the string "true"/"false": `true` is Always and `false` is +// Never. Visible has no plain form and stores a constant condition for +// `false`; editability has one, which is what Studio Pro writes for "Never"; +// - anything else non-empty, which is the client expression of a conditional +// editability, verbatim (the caller roots it in $currentObject if it wants +// that, as for Visible). +// +// ok is false for an empty value and for a type that is none of these. A caller +// must treat "not ok" as "nothing authored", never as "something unreadable": +// every non-empty string and boolean lands in one of the three. +func StaticEditability(v any) (enum, expr string, ok bool) { + switch t := v.(type) { + case bool: + if t { + return "Always", "", true + } + return "Never", "", true + case string: + s := strings.TrimSpace(t) + if s == "" { + return "", "", false + } + if canon, isEnum := CanonicalEditability(s); isEnum { + return canon, "", true + } + switch strings.ToLower(s) { + case "true": + return "Always", "", true + case "false": + return "Never", "", true + } + return "", s, true + default: + return "", "", false + } +} + // WidgetEditability is the value a writer should store for a widget: the // conditional settings element wins, then an explicit `editable:`, then Mendix's // own default. diff --git a/sdk/pages/pages_widgets_editable_test.go b/sdk/pages/pages_widgets_editable_test.go new file mode 100644 index 000000000..ad0315521 --- /dev/null +++ b/sdk/pages/pages_widgets_editable_test.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pages + +import "testing" + +// Editable and Visible share a grammar slot, so a value must mean the same +// thing to both consumers' helpers: a string that is not the property's plain +// value is its condition. +func TestStaticEditability(t *testing.T) { + cases := []struct { + in any + enum string + expr string + ok bool + }{ + {"Always", "Always", "", true}, + {"never", "Never", "", true}, + {" NEVER ", "Never", "", true}, + {"Conditional", "Conditional", "", true}, + {true, "Always", "", true}, + {false, "Never", "", true}, + {"true", "Always", "", true}, + {"False", "Never", "", true}, + {"$currentObject/Locked = false", "", "$currentObject/Locked = false", true}, + {" $currentObject/Code != 'x' ", "", "$currentObject/Code != 'x'", true}, + // Not an enum word: an expression, and the caller's check decides if it is a sensible one. + {"Neever", "", "Neever", true}, + {"", "", "", false}, + {" ", "", "", false}, + {nil, "", "", false}, + {42, "", "", false}, + {[]string{"x"}, "", "", false}, + } + for _, c := range cases { + enum, expr, ok := StaticEditability(c.in) + if enum != c.enum || expr != c.expr || ok != c.ok { + t.Errorf("StaticEditability(%#v) = (%q, %q, %v), want (%q, %q, %v)", c.in, enum, expr, ok, c.enum, c.expr, c.ok) + } + } +} + +// Visible and Editable agree on what a quoted expression is: the string, verbatim. +func TestStaticEditability_ExpressionMatchesStaticVisibleExpression(t *testing.T) { + const e = "$currentObject/Locked = false" + ve, vok := StaticVisibleExpression(e) + _, ee, eok := StaticEditability(e) + if ve != ee || !vok || !eok { + t.Errorf("Visible reads %q (%v), Editable reads %q (%v)", ve, vok, ee, eok) + } +}