From 605d4fb892f65a5378fb47b8d5a2ba09cec8c3b1 Mon Sep 17 00:00:00 2001 From: stagknee <241821830+stagknee@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:20:31 -0500 Subject: [PATCH] fix(javaactions): write a JavaScript action's parameters the way mxbuild generates them `create javascript action ... (Url: String, ReadOnly: Boolean)` wrote `export async function JS_Open(Url, ReadOnly)`. mxbuild and Studio Pro regenerate the signature and the JSDoc from the model with the first character of each parameter name lower-cased and keep the body, so after a build the signature read `JS_Open(url, readOnly)` and a body using `Url` threw ReferenceError at run time. check, mx check and the build were all clean. Rewriting a Studio Pro-authored action through MDL did the same in reverse: `Geocode(address, ...)` became `Geocode(Address, ...)` over a body that still read `address`. JavaScriptParameterName lower-cases the first rune only, which is what mxbuild 11.12.5 does (URL -> uRL, MyID -> myID, Some_Name -> some_Name, XMLData -> xMLData, _Lead unchanged). GenerateJavaScriptSource uses it for the signature and the @param tags. The model parameter keeps its declared name, so describe prints it as declared and describe -> exec is stable, and bodies are not rewritten. Tests: the measured table, the generated signature, a rewrite over a Studio Pro-style file, and goldens of what mxbuild left for two actions. A check warning for bodies that use the declared case is a separate change. Co-Authored-By: Claude Sonnet 5.5 --- ...ameter-reference-error-signature-case.json | 1 + .../skills/mendix/javascript-actions/SKILL.md | 40 +++++---- CHANGELOG.md | 4 + cmd/mxcli/syntax/features_integration.go | 2 +- .../microflow/create-javascript-action.md | 16 +++- .../1560-js-action-parameter-case.mdl | 68 +++++++++++++++ .../javascript-action-source-dir-case.mdl | 2 +- .../07b-javascript-action-examples.mdl | 10 +-- .../37-theme-switcher-examples.mdl | 10 +-- .../39-expose-as-toolbox-action.mdl | 2 +- .../modelsdk/javascript_write_retain_test.go | 82 +++++++++++++++++++ .../cmd_javascript_actions_mock_test.go | 47 +++++++++++ sdk/javaactions/jssource.go | 31 ++++++- sdk/javaactions/jssource_golden_test.go | 76 +++++++++++++++++ sdk/javaactions/jssource_test.go | 66 +++++++++++++++ .../testdata/mxbuild/JS_Cases.js.golden | 28 +++++++ .../testdata/mxbuild/JS_Open.js.golden | 22 +++++ 17 files changed, 474 insertions(+), 33 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/sdk/2026-10-16-js-action-parameter-reference-error-signature-case.json create mode 100644 mdl-examples/bug-tests/1560-js-action-parameter-case.mdl create mode 100644 sdk/javaactions/jssource_golden_test.go create mode 100644 sdk/javaactions/jssource_test.go create mode 100644 sdk/javaactions/testdata/mxbuild/JS_Cases.js.golden create mode 100644 sdk/javaactions/testdata/mxbuild/JS_Open.js.golden 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 0000000000..68922eee5d --- /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/javascript-actions/SKILL.md b/.claude/skills/mendix/javascript-actions/SKILL.md index a8869513ee..7d893fd9f7 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 0798c1db7b..f3c401d121 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed + +- **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`. + ## [0.27.0] - 2026-10-15 Headline: **`exec` is all or nothing: when a statement fails at write time, the whole run is rolled back, so a script never leaves a project half-changed.** Alongside it: a wave of `describe` → `check` → `exec` fixes from round-trip runs over real apps (the Siemens FSM template, the GenAI showcase, Marketplace modules), Mendix 9 projects can take microflows, nanoflows and pluggable widgets again, `check -p` resolves many more of the references `exec` and the build would refuse, the classic `filemanager` widget and role-based `Visible: roles (…)` are in MDL, and the companion draws a module's domain model. The MongoDB driver is upgraded for the October 2026 BSON advisories. diff --git a/cmd/mxcli/syntax/features_integration.go b/cmd/mxcli/syntax/features_integration.go index 8680b03bc2..233bc305ef 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/docs-site/src/reference/microflow/create-javascript-action.md b/docs-site/src/reference/microflow/create-javascript-action.md index f75cd13ed2..109c9a4afa 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/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 0000000000..ed2b695d4c --- /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/javascript-action-source-dir-case.mdl b/mdl-examples/bug-tests/javascript-action-source-dir-case.mdl index 13d55f78a2..0432c7402c 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 14c39b290f..f6bc1dc31e 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 82248daca5..f8b9b78005 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 e1becf3e9c..b7803003f8 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 1330fff279..d823f230fb 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 8b7e101707..d64dd3c7bc 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/sdk/javaactions/jssource.go b/sdk/javaactions/jssource.go index 3bb3455b18..167cfd6aa0 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 0000000000..db253ae1d6 --- /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 0000000000..bf6f73bf2a --- /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 0000000000..0d6670fdf6 --- /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 0000000000..19d3ea33b9 --- /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 +}