diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-page-round-trip-loses-widget-settings.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-page-round-trip-loses-widget-settings.json new file mode 100644 index 000000000..3ba9ff656 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-page-round-trip-loses-widget-settings.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-11-11", "refs": ["ako/mxcli#2106"], "ce": [], "symptom": "describe page -> exec of FactoryManagement pages reported Replaced page and changed three settings: a check box's LabelPosition AfterControl became Default (ConversationalUI.TraceFiltering_Edit), a radio button group's RenderHorizontal false became true (TcConnector.TeamcenterConfiguration_NewEdit), and a show page button's NumberOfPagesToClose2 '1' became '' (SnowflakeRESTSQL.ConnectionDetails_NewEdit_Step2). mx check stays green on both sides; the page behaves differently.", "file": "sdk/pages (CheckBox.LabelPosition, PageClientAction.NumberOfPagesToClose); mdl/executor cmd_pages_builder_v3_widgets.go (LabelPosition, RenderHorizontal), cmd_pages_describe_parse.go + cmd_pages_describe_output.go (read and print both), cmd_pages_describe_action_settings.go (NumberOfPagesToClose in with (…)), page_action_settings.go, widget_native_props.go (MDL-WIDGET52 reads), validate_widgets.go (known props); mdl/visitor/visitor_action_settings.go (showPage kind, numeric value); grammar actionSettingV3 takes NUMBER_LITERAL; mdl/backend/modelsdk/widget_write.go (SetLabelPosition, SetNumberOfPagesToClose2). Tests: cmd_pages_round_trip_settings_2106_test.go, visitor_show_page_close_2106_test.go, widget_write_round_trip_settings_2106_test.go", "cause": "None of the three had an MDL spelling. The writer wrote the default each time: NumberOfPagesToClose2 hard-coded to \"\", LabelPosition never set, and RenderHorizontal derived from a RenderDirection nothing ever filled, so every rewritten radio button group became horizontal.", "insight": "**A value-only BSON diff of the first exec, swept over a whole app, separates real losses from unexpressible formatting.** Of FactoryManagement's 14 'Replaced' pages, these three change behaviour; the other eleven differ in values MDL cannot state (TabIndex, trailing newlines in DynamicClasses, XPath whitespace, condition order, an inherited attribute's declaring entity, a null SourceVariable key, an unused placeholder parameter), which a canon carry handles separately. A model field the writer reads but no builder sets (RadioButtons.RenderDirection) is a silent default: grep each writer input for a builder that assigns it. Controls: reverting each of the ten edits fails its test; a new page using all three builds with mx check 0 errors on the Agent Builder Starter App; TraceFiltering_Edit and ConnectionDetails_NewEdit_Step2 now report Unchanged page."} diff --git a/.claude/skills/fix-issue/findings/modelsdk/2026-11-11-page-round-trip-carry-formatting-and-property-sets.json b/.claude/skills/fix-issue/findings/modelsdk/2026-11-11-page-round-trip-carry-formatting-and-property-sets.json new file mode 100644 index 000000000..8b0ba8d79 --- /dev/null +++ b/.claude/skills/fix-issue/findings/modelsdk/2026-11-11-page-round-trip-carry-formatting-and-property-sets.json @@ -0,0 +1 @@ +{"area": "modelsdk/canon", "date": "2026-11-11", "refs": ["ako/mxcli#2106", "ako/mxcli#2052"], "ce": [], "symptom": "describe page -> exec of 8 FactoryManagement pages reported Replaced page with nothing changed: a DynamicClasses expression lost its trailing newlines (3 pages), an XPath constraint was reformatted (2), an enumeration condition's values were reordered (1), an AssociationSource lost its null SourceVariable key (2), and an inherited createdDate binding was requalified from GenAICommons.Span to GenAICommons.ToolSpan (1).", "file": "modelsdk/canon/unexpressed.go (whitespaceInsensitive for Expression, DynamicClasses, XPathConstraint; sameConditionsReordered; anyIDUsedOutside), mdl/backend/modelsdk/widget_write.go (Forms$AssociationSource NullFields SourceVariable), mdl/executor/cmd_pages_builder_input.go entityAttributeOwners (system members count as the storing entity's own). Tests: modelsdk/canon/unexpressed_2106_test.go, widget_association_source_null_2106_test.go, cmd_pages_builder_system_member_2106_test.go", "cause": "#2052's carry compared only Expression and only by TrimSpace; DynamicClasses and XPathConstraint are expressions too, and Studio Pro keeps the author's interior line breaks. The writer emits enumeration conditions in enumeration order where Studio Pro keeps insertion order. Forms$AssociationSource had no NullFields registration (Studio Pro stores the key on 40 of 40). declaringEntityFor indexed declared attributes only, so a system member a specialization inherits resolved to the context entity.", "insight": "**Census the stored form across a whole app before choosing a carry or a writer fix.** 40 of 40 association sources carry SourceVariable, so it is a property-set gap fixed once in the writer; the condition order differs only by authoring history, so it is a carry. And check a 'formatting' difference before carrying it: the placeholder 'unused parameter' this sweep also showed is the template's only, USED, expression parameter that MDL cannot state - a real loss for new syntax, which the carry rule rightly would not have touched. Controls: each test fails without its change; a full sweep of FactoryManagement's 229 pages turns exactly these 8 Unchanged and regresses none."} diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index de407038f..f91e1986f 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -41,7 +41,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: '1.27.3' + go-version: '1.27.4' - name: Cache ANTLR4 JAR uses: actions/cache@v6 @@ -90,7 +90,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: '1.27.3' + go-version: '1.27.4' # mxbuild 9 draws through System.Drawing; without libgdiplus every mx call # dies with "The type initializer for 'Gdip' threw an exception." @@ -141,7 +141,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: - go-version: '1.27.3' + go-version: '1.27.4' - name: Test the tunnel seam run: bash scripts/test-tunnel-seam.sh @@ -162,7 +162,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: - go-version: '1.27.3' + go-version: '1.27.4' - name: Cache ANTLR4 JAR uses: actions/cache@v6 with: @@ -193,7 +193,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: '1.27.3' + go-version: '1.27.4' - uses: oven-sh/setup-bun@v2 diff --git a/.github/workflows/push-test.yml b/.github/workflows/push-test.yml index b1107ce01..c6a96bf00 100644 --- a/.github/workflows/push-test.yml +++ b/.github/workflows/push-test.yml @@ -49,7 +49,7 @@ jobs: with: # The toolchain matters: gofmt's output differs between Go releases # (the mismatch fixed in #1759), so this must match the other jobs. - go-version: '1.27.3' + go-version: '1.27.4' cache: false - name: Check gofmt run: make fmt-check @@ -100,7 +100,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: - go-version: '1.27.3' + go-version: '1.27.4' - name: Test the tunnel seam shell: bash run: bash scripts/test-tunnel-seam.sh @@ -120,7 +120,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: - go-version: '1.27.3' + go-version: '1.27.4' - name: Test the Windows process helpers shell: bash # Scoped with -run: the rest of the docker package's tests assert POSIX @@ -165,7 +165,7 @@ jobs: submodules: true - uses: actions/setup-go@v7 with: - go-version: '1.27.3' + go-version: '1.27.4' - name: Cache ANTLR4 JAR uses: actions/cache@v6 with: @@ -289,7 +289,7 @@ jobs: submodules: ${{ matrix.suite == 'roundtrip' || matrix.suite == 'parity' }} - uses: actions/setup-go@v7 with: - go-version: '1.27.3' + go-version: '1.27.4' - name: Cache ANTLR4 JAR uses: actions/cache@v6 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5bdcf8eb9..3d36282d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@v7 with: - go-version: '1.27.3' + go-version: '1.27.4' - uses: oven-sh/setup-bun@v2 - name: Cache ANTLR4 JAR uses: actions/cache@v6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a277e5b8..1f78b49c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **`mxcli test --local --reuse-build` skips the injection and the build when nothing changed** (mendixlabs/mxcli#1783, closes mendixlabs/mxcli#1782) — a local test run injects its test microflows, builds and boots; when the project, the test files and mxcli are unchanged, the deployment the previous run left is already this run's build, so `--reuse-build` boots it directly (about 12 s against 60 s, measured by the author). The decision is a content hash of the project folder (outputs, caches, `theme-cache/`, and any `.git`/`node_modules` left out), the generated test MDL, the mxcli binary and the mxbuild override, pinned to the identity of `deployment/model/model.mdp`, so a deployment rebuilt by anything else is never taken for the test build. Not under `--watch` or `--skip-build`. - **A `genai-runtime` skill for the runtime side of Agents Kit 2** (ako/mxcli#2068) — registering a Mendix Cloud GenAI key or an OpenAI-compatible endpoint, seeding AgentCommons agents from JSON (matched by `Title` only, so a changed JSON never reaches a stored agent), resuming Conversational UI chats, loading PgVector and Mendix Cloud knowledge bases, and traces. It collects the gotchas that `check` and `mx check` cannot see: the encryption key both the OpenAI and PgVector setup need, the `vector` extension only the UI's Test button creates, the 60–120 s indexing delay, the chat that collapses outside its page, and `StoreTraces` defaulting to false. Its examples pass `check --references` and build with 0 errors against the Agent Builder Starter App's modules. - **`mxcli test --attach` runs a test without building it, against an app started with `--allow-call`** (mendixlabs/mxcli#1788, mendixlabs/mxcli#1789) — `run --local --test-endpoint --allow-call` switches on an executor in the test endpoint. A `.test.mdl` test whose statements it supports (`declare`, `set`, `create`, `change`, `commit`, `delete`, `call microflow`, database `retrieve` with `where`/`first`; `@expect` with `count`, `@throws`, `@setup`, `@cleanup`) is compiled by mxcli into steps and run in the app with the Mendix runtime API, every expression evaluated by `Core.evaluateExpression`. Nothing is injected or built, so a test edit costs one HTTP round trip: on a small app the suite took 0.26 s against 3.2 s for inject-and-build, with identical verdicts. Retrieves over an association and the list aggregates `count`/`sum`/`average`/`minimum`/`maximum` over an attribute run there too. A test with any other statement is injected and built as before. Only that test is, while the rest of the run still goes straight to the app, and the output names the test and the MDL statement (`the statement if`, `a list operation (find)`). Because the runtime evaluator returns empty for an unknown name, an undeclared `$variable` and a non-existent enumeration value are refused (ERROR) before evaluating, and a model newer than the running build is refused with `mxcli run wait` as the fix under `--watch`. The route takes POST, needs the run's token and a loopback caller, and answers 403 without `--allow-call`, since it calls the microflows a test names in a system context. `.test.mdl` stays the only test language. Design discussed with the authors of #1789; measurements in `mdl-examples/spikes/evaluate-expression.mdl`. - **`flows_for()` and `CATALOG.FLOWS`: a flow's sequence flows in lint rules, and each activity's place on the canvas** (ako/mxcli#1299, mendixlabs/mxcli#1308) — `activities_for()` listed the objects but not which leads to which, so a rule could not tell which log belongs to which custom error handler, which activity an annotation explains, or which branch of a split is `true`. Each sequence and annotation flow is now a row with its origin and destination activity, `is_error_handler`, the branch's `case_value` and the sides it connects; activities gain `x`, `y`, `width` and `height`. Full catalog; the schema moves to version 44, so a cached catalog is rebuilt once. @@ -26,6 +27,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **`describe page` → `exec` no longer rewrites a page over formatting and stored keys MDL cannot state** (ako/mxcli#2106) — on FactoryManagement, 8 pages reported `Replaced page` with nothing changed: a dynamic-classes expression's trailing newlines, an XPath constraint's line breaks and spacing, the order of an enumeration condition's values, an association source's null `SourceVariable` key, and an inherited `createdDate` binding requalified to the specialization. The stored value is now kept where only its form differs, the writer stores the key Studio Pro stores, and a system member resolves to the entity that stores it. A full sweep of the app's 229 pages turns those 8 `Unchanged` and changes no other. +- **`describe page` → `exec` keeps a check box's label position, a radio button group's orientation and a show page action's close count** (ako/mxcli#2106) — none had an MDL spelling, so a round trip of a Studio Pro page reset a check box with its label after the control to the default, turned every vertical radio button group horizontal, and stopped a show page button from closing the page it came from, with `mx check` green. `checkbox` takes `LabelPosition: Default|BeforeControl|AfterControl`, `radiobuttons` takes `RenderHorizontal: false`, and `show page` takes `with (NumberOfPagesToClose: n)`; `describe` prints each when it differs from the default. +- **`mxcli test --local --watch` keeps applying changes on macOS and Linux mxbuild** (mendixlabs/mxcli#1787, refs mendixlabs/mxcli#1786) — the test runner's rebuild called mxbuild's serve process directly, without the recovery `run --local --watch` has, so the second build of a serve process failed on regenerated pages and every later change reported "could not be applied". The rebuild now goes through the same recovery, which also recognises mxbuild's resource-key form of that failure (`IO_PathNotFound_Path`, as macOS and Linux report it) and skips the web-bundle check, which only matters when a browser is served. - **`exec` and `check` no longer rescan an MPR v1 project for every lookup** (ako/mxcli#2094, mendixlabs/mxcli#1795) — each statement cost 0.3–0.9 s on a large single-file project, whatever it changed: every typed listing re-read the metadata of the whole `Unit` table, and every lookup by name read every microflow's contents. The reader now keeps the scan, and each unit's name, until a write or another process's commit says the table may have changed. On a 3,400-unit project a `create microflow` went from 0.30 s to 0.11 s, a one-activity `create or modify` from 0.86 s to 0.35 s, and `check` of 150 such statements from 76 s to 15 s. MPR v2 projects already kept this index. - **`mxcli test --local` beside a `run --local --test-endpoint` dev loop runs, and leaves the project buildable** (ako/mxcli#2119) — the test run read the dev loop's `MxTest.RegisterEndpoint` as the project's after-startup microflow and chained its own endpoint to it, to itself: the scratch runtime failed to boot with a `StackOverflowError` and no test ran. Its cleanup then dropped the dev loop's endpoint while the setting still named it, so the dev loop's next build failed with CE1613. A live host's restore record now says what the endpoint replaced, and the test run chains to that; its cleanup removes only its test microflows and leaves the dev loop's endpoint, Java source and setting as it found them. An endpoint whose run died is removed first, as `run` does, and one with no record of what it replaced is refused rather than chained to. When the test run rebuilt the `deployment/` folder both share, the dev loop builds its own back once the run ends (a reload or a restart), so a following `--attach` is not refused for a restart. - **A test run no longer rebuilds or restarts the dev loop it shares a project with** (ako/mxcli#2063, field test on ako/mxcli#2104) — `mxcli test --local`, and `--attach` with a test that has to be built, write their test microflows into the project a `run --local --watch` dev loop is watching, so the dev loop rebuilt for the injection and its cleanup, and applied it by restarting the runtime, which drops every browser session, `--hub` preview included; on a small app the restart itself failed (`EOFException`). The test run now holds the dev loop's watcher (`.mxcli/watch-hold.json`) while it owns the project, `--attach` now puts the project file back byte for byte as `--local` already did, and on release the watcher compares the model with the one the hold began from: the same model is not built at all, and `run status` / `run wait` read it as covered. An edit made before or during the run is still built once it ends, and a hold whose run died is ignored. diff --git a/cmd/mxcli/cmd_test_run.go b/cmd/mxcli/cmd_test_run.go index eebd11894..06d99326b 100644 --- a/cmd/mxcli/cmd_test_run.go +++ b/cmd/mxcli/cmd_test_run.go @@ -166,6 +166,7 @@ Examples: junitOutput, _ := cmd.Flags().GetString("junit") requireAssertions, _ := cmd.Flags().GetBool("require-assertions") skipBuild, _ := cmd.Flags().GetBool("skip-build") + reuseBuild, _ := cmd.Flags().GetBool("reuse-build") local, _ := cmd.Flags().GetBool("local") legacyRunner, _ := cmd.Flags().GetBool("legacy-runner") watch, _ := cmd.Flags().GetBool("watch") @@ -214,6 +215,7 @@ Examples: ProjectPath: projectPath, TestFiles: resolveTestPaths(args, projectPath), SkipBuild: skipBuild, + ReuseBuild: reuseBuild, Local: local, LegacyRunner: legacyRunner, Watch: watch, diff --git a/cmd/mxcli/docker/localapp.go b/cmd/mxcli/docker/localapp.go index df15bf0c2..e13d426d7 100644 --- a/cmd/mxcli/docker/localapp.go +++ b/cmd/mxcli/docker/localapp.go @@ -108,6 +108,9 @@ type LocalApp struct { RuntimeLogPath string serve *ServeServer + // rec runs Rebuild's builds with the recovery `run --local --watch` has: a + // build whose outcome only a fresh mxbuild --serve fixes is repeated on one. + rec *serveRecovery } func (o *LocalAppOptions) applyDefaults() { @@ -323,6 +326,7 @@ func StartLocalApp(opts LocalAppOptions) (*LocalApp, error) { return nil, fmt.Errorf("starting mxbuild serve: %w", err) } app.serve = serve + app.rec = &serveRecovery{serve: serve, deployDir: opts.DeployDir, out: w, headless: true} build, err := buildRetryingServeTimeout(serve, BuildRequest{Target: TargetDeploy, ProjectFilePath: opts.ProjectPath}, w) if err != nil { @@ -365,7 +369,14 @@ func (a *LocalApp) Rebuild(projectPath string) (ApplyAction, *BuildResult, error if a.Runtime == nil { return ActionReload, nil, fmt.Errorf("the runtime is not running") } - build, err := a.serve.Build(BuildRequest{Target: TargetDeploy, ProjectFilePath: projectPath}) + req := BuildRequest{Target: TargetDeploy, ProjectFilePath: projectPath} + var build *BuildResult + var err error + if a.rec != nil { + build, err = a.rec.Build(req) + } else { + build, err = a.serve.Build(req) + } if err != nil { return ActionReload, nil, err } diff --git a/cmd/mxcli/docker/serve_recovery.go b/cmd/mxcli/docker/serve_recovery.go index cba890248..3e91e948c 100644 --- a/cmd/mxcli/docker/serve_recovery.go +++ b/cmd/mxcli/docker/serve_recovery.go @@ -74,6 +74,10 @@ type serveRecovery struct { // failed. A theme failure then is not trustworthy: the process may be // reporting the earlier error rather than the source on disk. themeFailed bool + + // headless: nothing serves the client (the test runner), so a build that + // succeeds with an incomplete web/dist is a good build and is not repeated. + headless bool } func (r *serveRecovery) Build(req BuildRequest) (*BuildResult, error) { @@ -105,7 +109,7 @@ func (r *serveRecovery) Build(req BuildRequest) (*BuildResult, error) { } if res.OK() { - if !prebuilt { + if !prebuilt || r.headless { return res, nil } after, _ := readPrebuiltBundle(r.deployDir) diff --git a/cmd/mxcli/docker/serve_recovery_test.go b/cmd/mxcli/docker/serve_recovery_test.go index b0d28cb81..b2fb35bb5 100644 --- a/cmd/mxcli/docker/serve_recovery_test.go +++ b/cmd/mxcli/docker/serve_recovery_test.go @@ -174,6 +174,27 @@ func TestServeRecovery_RollupShapeIsNotServesBusiness(t *testing.T) { } } +// headless (mxcli test --watch): nothing serves the client, so an incomplete +// prebuilt bundle after a successful build is not a defect to recover from — +// recovering it would restart mxbuild --serve (and rebuild, and reboot the +// runtime) on every single test-file edit for a symptom the test runner never +// observes. +func TestServeRecovery_HeadlessSkipsPrebuiltRestart(t *testing.T) { + deploy := prebuiltDeployment(t) + f := &scriptedServe{build: func(_, _ int) (*BuildResult, error) { + writeIncompleteBundle(t, deploy) // the same symptom IncompletePrebuiltBundle recovers from + return buildOK(false), nil + }} + r := &serveRecovery{serve: f, deployDir: deploy, out: io.Discard, headless: true} + res, err := r.Build(BuildRequest{}) + if err != nil || !res.OK() { + t.Fatalf("Build = %+v, %v", res, err) + } + if f.restarts != 0 { + t.Fatalf("restarts = %d, want 0: headless must not pay for a restart only a browser-facing bundle needs", f.restarts) + } +} + // The same serve process FAILS a change that regenerates a page: it wants // web/pages/.js, which its own first build did not leave behind. Measured // on 11.12.5 as well as 11.14. A fresh process builds it. diff --git a/cmd/mxcli/docker/webclient_legacy_paths.go b/cmd/mxcli/docker/webclient_legacy_paths.go index c826c0014..95ef90718 100644 --- a/cmd/mxcli/docker/webclient_legacy_paths.go +++ b/cmd/mxcli/docker/webclient_legacy_paths.go @@ -105,7 +105,11 @@ func legacyClientBuildHint(deployDir, message, raw string) string { // missingClientDirs returns the per-document client directories the failure // names and the deployment genuinely lacks. func missingClientDirs(deployDir, message string) []string { - if !strings.Contains(message, "Could not find a part of the path") { + // macOS and Linux builds of mxbuild report the same failure with the resource + // key instead of the English text: "AggregateException_ctor_DefaultMessage + // (IO_PathNotFound_Path, )" (measured on 11.15.0, macOS). + byKey := strings.Contains(message, "IO_PathNotFound_Path") + if !strings.Contains(message, "Could not find a part of the path") && !byKey { return nil } var named []string @@ -115,6 +119,11 @@ func missingClientDirs(deployDir, message string) []string { } if _, err := os.Stat(filepath.Join(deployDir, "web", dir)); os.IsNotExist(err) { named = append(named, "web/"+dir+"/") + } else if byKey { + // The key form names a file, not a directory, and the directory can exist + // (an earlier mxbuild left some pages) while the file the build wants is + // not there: the same first-build-only state. + named = append(named, "web/"+dir+"/ files") } } return named diff --git a/cmd/mxcli/docker/webclient_legacy_paths_test.go b/cmd/mxcli/docker/webclient_legacy_paths_test.go index c65d0f7ac..c92a9e4cf 100644 --- a/cmd/mxcli/docker/webclient_legacy_paths_test.go +++ b/cmd/mxcli/docker/webclient_legacy_paths_test.go @@ -37,6 +37,14 @@ const ( `"status":"Failure","message":"Compilation of the app bundle failed."}` bundleFailedMessage = "Compilation of the app bundle failed." + + // Second serve build after an ALTER PAGE edit, macOS mxbuild 11.15.0: the + // same failure, reported by resource key instead of English text. + real1115MacOSMissingClientDirsByKey = "One or more errors occurred. " + + "(AggregateException_ctor_DefaultMessage (IO_PathNotFound_Path, " + + "/w/W1115/deployment/web/pages/MyFirstModule.Home_Web.js)) " + + "(AggregateException_ctor_DefaultMessage (IO_PathNotFound_Path, " + + "/w/W1115/deployment/web/layouts/Atlas_Core.Atlas_TopBar.js))" ) // deploy1114 is the shape a 11.14 first build leaves: web/dist and nothing @@ -77,6 +85,41 @@ func TestLegacyClientBuildHintExplainsBothRealFailures(t *testing.T) { } } +// macOS (and Linux) mxbuild report the identical first-build-only failure by +// resource key, not English text (measured on 11.15.0). The English-text match +// alone would leave --watch reporting "could not be applied" forever on these +// platforms, since missingClientDirs would never recognize the failure. +func TestLegacyClientBuildHintRecognizesMacOSKeyForm(t *testing.T) { + hint := legacyClientBuildHint(deploy1114(t), real1115MacOSMissingClientDirsByKey, "") + if hint == "" { + t.Fatal("must be explained: this is the same shape as the English-text message") + } + for _, want := range []string{"not a problem with your model", "web/pages/", "run --local --screenshot"} { + if !strings.Contains(hint, want) { + t.Errorf("hint missing %q:\n%s", want, hint) + } + } +} + +// The key form names a FILE ("…/web/pages/X.js"), not the directory. When an +// earlier serve build left the directory behind (e.g. mid-session after one +// successful incremental build), a plain directory-exists check would wrongly +// conclude nothing is missing and the hint would stay silent on a build that +// is failing for exactly this reason. +func TestLegacyClientBuildHintMacOSKeyForm_DirExistsFileMissing(t *testing.T) { + dir := deploy1114(t) + if err := os.MkdirAll(filepath.Join(dir, "web", "pages"), 0o755); err != nil { + t.Fatal(err) + } + hint := legacyClientBuildHint(dir, real1115MacOSMissingClientDirsByKey, "") + if hint == "" { + t.Fatal("must still be explained: the directory exists but the file mxbuild wants does not") + } + if !strings.Contains(hint, "web/pages/ files") { + t.Errorf("hint must name the missing files, not just the directory:\n%s", hint) + } +} + func TestLegacyClientBuildHintStaysSilentOnOtherFailures(t *testing.T) { dir := deploy1114(t) // Every one of these is a real failure with a different cause. A hint on diff --git a/cmd/mxcli/main.go b/cmd/mxcli/main.go index 1e3cc1cf4..f314b1586 100644 --- a/cmd/mxcli/main.go +++ b/cmd/mxcli/main.go @@ -432,6 +432,7 @@ func init() { testRunCmd.Flags().StringP("junit", "j", "", "Write JUnit XML results to file") testRunCmd.Flags().Bool("require-assertions", false, "Report a test that asserts nothing as an ERROR instead of a pass") + testRunCmd.Flags().Bool("reuse-build", false, "With --local, skip the injection and the build when the previous run's deployment is the build this run would make (same project files, tests and mxcli); any difference builds as usual") testRunCmd.Flags().BoolP("skip-build", "s", false, "Skip build step (reuse existing deployment)") testRunCmd.Flags().Bool("local", false, "Run on mxcli's local runtime instead of Docker (no daemon needed)") testRunCmd.Flags().Bool("legacy-runner", false, "With --local, run tests from the after-startup microflow and parse the log, instead of over the test endpoint") diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index 8d32b0c7a..5b5556575 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -146,6 +146,8 @@ LIST IMPACT OF htmlelement; " DROPDOWNFILTER ddf } }\n" + "-- ReadOnlyStyle (Inherit | Control | Text) is what makes a read-only check\n" + "-- box render as the checkbox glyph instead of the text Yes/No.\n" + + "-- A CHECKBOX takes LabelPosition (Default | BeforeControl | AfterControl), and\n" + + "-- RADIOBUTTONS RenderHorizontal: false for a vertical list (default true).\n" + "-- The grid-wide filter bar is CONTROLBAR; a GALLERY spells that same slot\n" + "-- FILTER, so `FILTER f { ... }` belongs to a gallery and not to a datagrid:\n" + "GALLERY g (...) { FILTER f { TEXTFILTER tf (Attribute: A) } }\n" + @@ -316,7 +318,7 @@ LIST IMPACT OF htmlelement; "confirmation", "confirm", "are you sure", "progress bar", "progress message", "disabled during execution", "disabled during action", "asynchronous", "form validations", }, - Syntax: "Action: NOTHING -- deliberately no action (Forms$NoAction)\nAction: SAVE CHANGES\nAction: SAVE CHANGES CLOSE PAGE -- save, then close the pop-up\nAction: CANCEL CHANGES\nAction: CANCEL CHANGES CLOSE PAGE\nAction: CLOSE PAGE\nAction: DELETE\nAction: DELETE CLOSE PAGE\nAction: CALL NANOFLOW Module.NF\nAction: CALL NANOFLOW Module.NF(Param = $val)\nAction: OPEN LINK 'https://example.com'\nAction: OPEN LINK $currentObject/URL -- address read from an attribute\nAction: SIGN OUT\nAction: COMPLETE TASK 'OutcomeName'\nAction: OPEN USER TASK -- Show user task page\nAction: OPEN WORKFLOW with (DefaultPage: Module.Page) -- Show workflow admin page\nAction: CALL WORKFLOW Module.Workflow -- the data container's object is its context\nAction: SHOW PAGE Module.Page\nAction: SHOW PAGE Module.Page(Param = $currentObject)\nAction: CALL MICROFLOW Module.MF\nAction: CALL MICROFLOW Module.MF(Param = $val)\nAction: CREATE OBJECT Module.Entity THEN SHOW PAGE Module.Page\n\nThe list above is exhaustive. Anything else in an action slot is an\nERROR: `Action: OPEN LINK` without a URL or `Action: SHOW PAGE` without a\npage is a syntax error, and a value that is not an action at all\n(or a snake-case keyword short its argument) is MDL-WIDGET28.\nSuch a widget used to be written with NO action at all and rendered as a\ndead control, with check, exec and mxbuild all clean, because a\nno-action widget is legal Mendix (mendixlabs/mxcli#1062). Write NOTHING\nwhen a control really is meant to be inert.\n\nThe actions are the words a microflow uses (R8). The snake-case spellings\n(SAVE_CHANGES, SHOW_PAGE, CLOSE_PAGE, DELETE_OBJECT, OPEN_LINK, SIGN_OUT, ...)\nand a flow call without CALL still parse, but are deprecated (MDL-DEPR020);\n`mxcli fmt --upgrade` rewrites them.\n\nThe same forms serve `OnClick:` (an alias of `Action:`) and `OnChange:`.\n\nA call to a flow or page with no parameters may end in an empty `()`,\nas a flow body writes it. It stores the same action as no list at all,\nand DESCRIBE leaves it out (ako/mxcli#1516).\n\nSETTINGS. Any action but NOTHING takes a `with ( … )` list of the\nsettings Studio Pro shows under the event (ako/mxcli#721):\n\n DisabledDuringExecution: true|false -- every action; default true\n ProgressBar: None|NonBlocking|Blocking -- flow calls; default None\n ProgressMessage: 'text' -- flow calls\n Confirmation: 'question' -- flow calls, call workflow: ask before running\n ProceedCaption: 'text', CancelCaption: 'text' -- its buttons; default Proceed / Cancel\n Asynchronous: true|false -- call microflow only; default false\n FormValidations: All|Widget|None -- call microflow only; default All\n ClosePage: true|false -- complete task, call workflow; default true\n Commit: true|false -- complete task; default true\n AssignOnOpen: true|false -- open user task (\"Auto-assign\"); default true\n OpenWhenAssigned: true|false -- open user task: true is \"Who can open:\n -- Users with access\"; default false\n DefaultPage: Module.Page -- open workflow: REQUIRED (CE1834)\n\nThe workflow events (ako/mxcli#832) use the words of their microflow\nactions. OPEN USER TASK must sit in a data container of\nSystem.WorkflowUserTask and OPEN WORKFLOW in one of System.Workflow, or\nmx check reports CE1843. OPEN WORKFLOW opens the workflow's own admin page\nwhen it overrides one, and DefaultPage otherwise.\n\nAction: CALL MICROFLOW M.Delete(Order = $currentObject) with (\n ProgressBar: Blocking, ProgressMessage: 'Deleting…',\n Confirmation: 'Delete this order?', ProceedCaption: 'Delete', CancelCaption: 'Keep')\n\nA setting left out keeps its default. DESCRIBE prints only the settings\nthat differ from it. An unknown key, or one the action does not have, is an\nerror. For CREATE OBJECT … THEN SHOW PAGE …, the list goes at the end and\nbelongs to the create.\n\nRETURN VALUE MAPPINGS. A flow call can map what the flow returns back onto\nthe page -- Studio Pro's \"Return value mappings\" (Mendix 11.5). The list\ngoes after the arguments and before `with ( … )`, target = source:\n\nAction: CALL MICROFLOW M.ACT_Vehicle_Get returns (\n Brand = $ActionReturnValue/Brand, -- an attribute of the returned object\n $stringResult = $ActionReturnValue, -- the return value itself\n Brand = 'Demo brand') -- any expression\n\nA bare target is an attribute of the enclosing data container's object;\n`$name` is a page variable. $ActionReturnValue/Attr needs a flow that\nreturns an object. A mapping into an attribute needs Mendix 11.15: mx\n11.13 and 11.14 report CE7006 on it. Leave the list out and the call maps\nnothing; DESCRIBE prints the stored mappings, and re-running a statement\nwithout them removes them (ako/mxcli#977).\n\nA microflow or nanoflow action is a CALL: it needs an argument for every\nparameter the flow declares, or Mendix rejects the page with CE1571. The\nargument list is the same on every widget that takes an action -- a\nCONTAINER (which is clickable) as much as an ACTIONBUTTON. An enclosing\ndata container of the right type supplies it without an argument; a data\ngrid's CONTROL BAR does not, because it is not row-scoped -- pass the\ngrid's selection there (`$dgOrders`).\n\nA SHOW PAGE argument must be the enclosing widget's context object --\neither $currentObject or the name of the variable the enclosing data\nwidget is bound to. Mendix infers it from that widget, so naming any\nother variable is refused (MDL-PAGEARG01); call a microflow instead.\nThe exception is a parameter of the page or snippet itself: it is bound\nby name (Studio Pro stores an explicit mapping), so `(Item = $Item)` works\nanywhere, outside every data widget too (mendixlabs/mxcli#1403).\nOutside any data widget there is no context object to infer, so such a\nbutton takes no OTHER argument -- not $currentObject, not a literal.\nmxcli used to drop it in silence and mxbuild then reported CE1571 per\nparameter of the target page (mendixlabs/mxcli#1029). Route that\nnavigation through a microflow.\n\nOPEN LINK takes a web address, stored as a Forms$StaticOrDynamicString:\neither a literal, or $currentObject/Attr to read it from an attribute of\nthe enclosing data container's object at runtime (Studio Pro's \"Address:\nattribute\"). An address over an association path is not supported yet.\n\nButton styles: Default, Primary, Success, Info, Warning, Danger\n\nMendix has THREE icon elements and the keyword picks which one:\n\nIcon: 'Atlas_Core.Atlas_Filled.pencil' -- an icon collection\nIcon: image MyModule.Images.logo -- an IMAGE collection\nIcon: glyph 57377 -- a font code point\n\nThe bare form is the icon-collection icon and any collection in the\nproject works, third-party ones included. The image form points into a\ndifferent document, and is spelled the same way apart from the keyword\n-- write it without `image` and mxcli stores a custom-icon reference,\nwhich fails the build with CE1613 (mendixlabs/mxcli#1059).\n`mxcli check -p … --references` resolves each kind against its own\ncollection and names the remedy when the kind is wrong.\n\nA glyph carries a code and no name. Codes are sparse, and an undefined\none fails only at `mxbuild --target=deploy`, naming the PAGE rather\nthan the icon -- so MDL078 refuses it (an error) against the font's own\ntable, naming the defined glyphs either side of the code.\nList them with `list glyphs`.\n\nA name may be quoted or bare; a hyphenated segment is double-quoted on\nits own: Atlas_Core.Atlas.\"align-center\".\n\nUse `linkbutton` instead of `actionbutton` for link render mode (same properties).", + Syntax: "Action: NOTHING -- deliberately no action (Forms$NoAction)\nAction: SAVE CHANGES\nAction: SAVE CHANGES CLOSE PAGE -- save, then close the pop-up\nAction: CANCEL CHANGES\nAction: CANCEL CHANGES CLOSE PAGE\nAction: CLOSE PAGE\nAction: DELETE\nAction: DELETE CLOSE PAGE\nAction: CALL NANOFLOW Module.NF\nAction: CALL NANOFLOW Module.NF(Param = $val)\nAction: OPEN LINK 'https://example.com'\nAction: OPEN LINK $currentObject/URL -- address read from an attribute\nAction: SIGN OUT\nAction: COMPLETE TASK 'OutcomeName'\nAction: OPEN USER TASK -- Show user task page\nAction: OPEN WORKFLOW with (DefaultPage: Module.Page) -- Show workflow admin page\nAction: CALL WORKFLOW Module.Workflow -- the data container's object is its context\nAction: SHOW PAGE Module.Page\nAction: SHOW PAGE Module.Page(Param = $currentObject)\nAction: CALL MICROFLOW Module.MF\nAction: CALL MICROFLOW Module.MF(Param = $val)\nAction: CREATE OBJECT Module.Entity THEN SHOW PAGE Module.Page\n\nThe list above is exhaustive. Anything else in an action slot is an\nERROR: `Action: OPEN LINK` without a URL or `Action: SHOW PAGE` without a\npage is a syntax error, and a value that is not an action at all\n(or a snake-case keyword short its argument) is MDL-WIDGET28.\nSuch a widget used to be written with NO action at all and rendered as a\ndead control, with check, exec and mxbuild all clean, because a\nno-action widget is legal Mendix (mendixlabs/mxcli#1062). Write NOTHING\nwhen a control really is meant to be inert.\n\nThe actions are the words a microflow uses (R8). The snake-case spellings\n(SAVE_CHANGES, SHOW_PAGE, CLOSE_PAGE, DELETE_OBJECT, OPEN_LINK, SIGN_OUT, ...)\nand a flow call without CALL still parse, but are deprecated (MDL-DEPR020);\n`mxcli fmt --upgrade` rewrites them.\n\nThe same forms serve `OnClick:` (an alias of `Action:`) and `OnChange:`.\n\nA call to a flow or page with no parameters may end in an empty `()`,\nas a flow body writes it. It stores the same action as no list at all,\nand DESCRIBE leaves it out (ako/mxcli#1516).\n\nSETTINGS. Any action but NOTHING takes a `with ( … )` list of the\nsettings Studio Pro shows under the event (ako/mxcli#721):\n\n DisabledDuringExecution: true|false -- every action; default true\n ProgressBar: None|NonBlocking|Blocking -- flow calls; default None\n ProgressMessage: 'text' -- flow calls\n Confirmation: 'question' -- flow calls, call workflow: ask before running\n ProceedCaption: 'text', CancelCaption: 'text' -- its buttons; default Proceed / Cancel\n Asynchronous: true|false -- call microflow only; default false\n FormValidations: All|Widget|None -- call microflow only; default All\n ClosePage: true|false -- complete task, call workflow; default true\n Commit: true|false -- complete task; default true\n AssignOnOpen: true|false -- open user task (\"Auto-assign\"); default true\n OpenWhenAssigned: true|false -- open user task: true is \"Who can open:\n -- Users with access\"; default false\n DefaultPage: Module.Page -- open workflow: REQUIRED (CE1834)\n NumberOfPagesToClose: 1 -- show page: close this many pages as it opens; default none\n\nThe workflow events (ako/mxcli#832) use the words of their microflow\nactions. OPEN USER TASK must sit in a data container of\nSystem.WorkflowUserTask and OPEN WORKFLOW in one of System.Workflow, or\nmx check reports CE1843. OPEN WORKFLOW opens the workflow's own admin page\nwhen it overrides one, and DefaultPage otherwise.\n\nAction: CALL MICROFLOW M.Delete(Order = $currentObject) with (\n ProgressBar: Blocking, ProgressMessage: 'Deleting…',\n Confirmation: 'Delete this order?', ProceedCaption: 'Delete', CancelCaption: 'Keep')\n\nA setting left out keeps its default. DESCRIBE prints only the settings\nthat differ from it. An unknown key, or one the action does not have, is an\nerror. For CREATE OBJECT … THEN SHOW PAGE …, the list goes at the end and\nbelongs to the create.\n\nRETURN VALUE MAPPINGS. A flow call can map what the flow returns back onto\nthe page -- Studio Pro's \"Return value mappings\" (Mendix 11.5). The list\ngoes after the arguments and before `with ( … )`, target = source:\n\nAction: CALL MICROFLOW M.ACT_Vehicle_Get returns (\n Brand = $ActionReturnValue/Brand, -- an attribute of the returned object\n $stringResult = $ActionReturnValue, -- the return value itself\n Brand = 'Demo brand') -- any expression\n\nA bare target is an attribute of the enclosing data container's object;\n`$name` is a page variable. $ActionReturnValue/Attr needs a flow that\nreturns an object. A mapping into an attribute needs Mendix 11.15: mx\n11.13 and 11.14 report CE7006 on it. Leave the list out and the call maps\nnothing; DESCRIBE prints the stored mappings, and re-running a statement\nwithout them removes them (ako/mxcli#977).\n\nA microflow or nanoflow action is a CALL: it needs an argument for every\nparameter the flow declares, or Mendix rejects the page with CE1571. The\nargument list is the same on every widget that takes an action -- a\nCONTAINER (which is clickable) as much as an ACTIONBUTTON. An enclosing\ndata container of the right type supplies it without an argument; a data\ngrid's CONTROL BAR does not, because it is not row-scoped -- pass the\ngrid's selection there (`$dgOrders`).\n\nA SHOW PAGE argument must be the enclosing widget's context object --\neither $currentObject or the name of the variable the enclosing data\nwidget is bound to. Mendix infers it from that widget, so naming any\nother variable is refused (MDL-PAGEARG01); call a microflow instead.\nThe exception is a parameter of the page or snippet itself: it is bound\nby name (Studio Pro stores an explicit mapping), so `(Item = $Item)` works\nanywhere, outside every data widget too (mendixlabs/mxcli#1403).\nOutside any data widget there is no context object to infer, so such a\nbutton takes no OTHER argument -- not $currentObject, not a literal.\nmxcli used to drop it in silence and mxbuild then reported CE1571 per\nparameter of the target page (mendixlabs/mxcli#1029). Route that\nnavigation through a microflow.\n\nOPEN LINK takes a web address, stored as a Forms$StaticOrDynamicString:\neither a literal, or $currentObject/Attr to read it from an attribute of\nthe enclosing data container's object at runtime (Studio Pro's \"Address:\nattribute\"). An address over an association path is not supported yet.\n\nButton styles: Default, Primary, Success, Info, Warning, Danger\n\nMendix has THREE icon elements and the keyword picks which one:\n\nIcon: 'Atlas_Core.Atlas_Filled.pencil' -- an icon collection\nIcon: image MyModule.Images.logo -- an IMAGE collection\nIcon: glyph 57377 -- a font code point\n\nThe bare form is the icon-collection icon and any collection in the\nproject works, third-party ones included. The image form points into a\ndifferent document, and is spelled the same way apart from the keyword\n-- write it without `image` and mxcli stores a custom-icon reference,\nwhich fails the build with CE1613 (mendixlabs/mxcli#1059).\n`mxcli check -p … --references` resolves each kind against its own\ncollection and names the remedy when the kind is wrong.\n\nA glyph carries a code and no name. Codes are sparse, and an undefined\none fails only at `mxbuild --target=deploy`, naming the PAGE rather\nthan the icon -- so MDL078 refuses it (an error) against the font's own\ntable, naming the defined glyphs either side of the code.\nList them with `list glyphs`.\n\nA name may be quoted or bare; a hyphenated segment is double-quoted on\nits own: Atlas_Core.Atlas.\"align-center\".\n\nUse `linkbutton` instead of `actionbutton` for link render mode (same properties).", Example: "ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)\nACTIONBUTTON btnEdit (Caption: 'Edit',\n Action: SHOW PAGE Module.EditPage(Item = $currentObject))\nLINKBUTTON btnDelete (Caption: 'Delete', Action: DELETE,\n Icon: 'Atlas_Core.Atlas_Filled.pencil')\n\n-- A clickable CONTAINER in a data grid's control bar, calling a nanoflow\n-- with the grid's selection as its argument.\nDATAGRID dgOrders (DataSource: DATABASE FROM Sales.Order, Selection: Single) {\n COLUMN (Attribute: Number, Caption: 'Order #')\n CONTROLBAR {\n CONTAINER cShip (Class: 'command',\n Action: CALL NANOFLOW Sales.ACT_Ship(Order = $dgOrders)) {\n ACTIONBUTTON btnShip (Caption: 'Ship')\n }\n }\n}", SeeAlso: []string{"page.widgets"}, }) diff --git a/cmd/mxcli/testrunner/reusebuild.go b/cmd/mxcli/testrunner/reusebuild.go new file mode 100644 index 000000000..9590c8a53 --- /dev/null +++ b/cmd/mxcli/testrunner/reusebuild.go @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: Apache-2.0 + +package testrunner + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "runtime" + "sort" + "sync" +) + +// Build reuse (--reuse-build). +// +// A local test run injects the test microflows, builds with mxbuild and boots. +// For an unchanged project, unchanged test files and an unchanged mxcli the +// injected model is the same as last time, so the deployment that run left in +// /deployment is already the build this run would produce. The +// constants of a run (--constant) are runtime configuration, not model, so they +// are not part of the key: a second run with other constants reuses the build too. +// +// The key is a content hash, never a timestamp: every file of the project folder +// (minus the folders that are outputs or caches), the generated endpoint and test +// microflow text, the mxcli executable and the mxbuild override. The record also +// pins the identity of deployment/model/model.mdp, so a build made by anything +// else in that folder (run --local, mxbuild by hand) is never taken for ours. +// Any doubt means a normal build. + +const reuseRecordName = "test-build.json" + +// reuseSkipDirs are not inputs of the build: outputs, caches and VCS state. +// +// theme-cache is a build output, not a source: mxbuild rewrites +// theme-cache/web/theme.compiled.css on every build with a fresh +// cache-busting query string on each font/asset url() even when no theme or +// model content changed (e.g. "Atlas_Styling.ttf?"). Without +// this exclusion the key never repeats across two separate 'mxcli test' +// invocations — only within one process's --watch loop, which never re-reads +// the tree from disk — so --reuse-build would silently rebuild every time on +// any app using the Atlas Core theme (effectively all of them) and never pay +// for itself. Found by running --reuse-build twice in a row against a real +// app and seeing it rebuild both times. +var reuseSkipDirs = map[string]bool{ + ".git": true, ".mxcli": true, "deployment": true, ".mpr-snapshots": true, + ".mendix-cache": true, "node_modules": true, "theme-cache": true, +} + +// reuseSkipAnywhere are skipped at any depth, not only at the top: a +// node_modules under themesource/ or a nested .git is tooling state, never a +// build input, and hashing it on every run costs time for nothing. +var reuseSkipAnywhere = map[string]bool{".git": true, "node_modules": true} + +type reuseRecord struct { + Key string `json:"key"` + Model string `json:"model"` // identity of deployment/model/model.mdp when recorded +} + +func reuseRecordPath(projectPath string) string { + return filepath.Join(filepath.Dir(projectPath), ".mxcli", reuseRecordName) +} + +func deploymentModelIdentity(projectPath string) string { + fi, err := os.Stat(filepath.Join(filepath.Dir(projectPath), "deployment", "model", "model.mdp")) + if err != nil || fi.Size() == 0 { + return "" + } + return fmt.Sprintf("%d:%d", fi.Size(), fi.ModTime().UnixNano()) +} + +// hashProjectTree hashes the content of every file under dir except reuseSkipDirs +// at the top level. Files are hashed in parallel; the combination is ordered by path. +func hashProjectTree(dir string) (string, error) { + type item struct{ rel, sum string } + var paths []string + err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(dir, p) + if d.IsDir() { + if rel != "." && (reuseSkipDirs[rel] || reuseSkipAnywhere[d.Name()]) { + return filepath.SkipDir + } + return nil + } + if d.Type()&fs.ModeSymlink != 0 { + t, lerr := os.Readlink(p) + if lerr != nil { + return lerr + } + paths = append(paths, rel+"\x00->"+t) + return nil + } + paths = append(paths, rel) + return nil + }) + if err != nil { + return "", err + } + sort.Strings(paths) + sums := make([]string, len(paths)) + var wg sync.WaitGroup + var mu sync.Mutex + var firstErr error + work := make(chan int) + for i := 0; i < runtime.NumCPU(); i++ { + wg.Add(1) + go func() { + defer wg.Done() + for idx := range work { + p := paths[idx] + if len(p) > 0 && containsNul(p) { + sums[idx] = p + continue + } + f, oerr := os.Open(filepath.Join(dir, p)) + if oerr == nil { + h := sha256.New() + _, oerr = io.Copy(h, f) + f.Close() + if oerr == nil { + sums[idx] = hex.EncodeToString(h.Sum(nil)) + continue + } + } + mu.Lock() + if firstErr == nil { + firstErr = oerr + } + mu.Unlock() + } + }() + } + for i := range paths { + work <- i + } + close(work) + wg.Wait() + if firstErr != nil { + return "", firstErr + } + all := sha256.New() + for i, p := range paths { + fmt.Fprintf(all, "%s\x00%s\n", p, sums[i]) + } + return hex.EncodeToString(all.Sum(nil)), nil +} + +func containsNul(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] == 0 { + return true + } + } + return false +} + +// testBuildKey is the key of the build a run with these generated microflows +// would produce from this project. +func testBuildKey(opts RunOptions, endpointMDL, flowsMDL string) (string, error) { + tree, err := hashProjectTree(filepath.Dir(opts.ProjectPath)) + if err != nil { + return "", err + } + exe, _ := os.Executable() + exeID := "" + if fi, err := os.Stat(exe); err == nil { + exeID = fmt.Sprintf("%s:%d:%d", exe, fi.Size(), fi.ModTime().UnixNano()) + } + h := sha256.New() + fmt.Fprintf(h, "tree=%s\nexe=%s\nmxbuild=%s\nskipapp=%v\nendpoint=%s\nflows=%s\n", + tree, exeID, opts.MxBuildPath, opts.SkipAppStartup, endpointMDL, flowsMDL) + return hex.EncodeToString(h.Sum(nil)), nil +} + +// reusableBuild reports whether the deployment on disk is the build for key. +func reusableBuild(projectPath, key string) bool { + data, err := os.ReadFile(reuseRecordPath(projectPath)) + if err != nil { + return false + } + var rec reuseRecord + if json.Unmarshal(data, &rec) != nil || rec.Key == "" || rec.Key != key { + return false + } + id := deploymentModelIdentity(projectPath) + return id != "" && id == rec.Model +} + +// recordBuild notes that the deployment on disk is the build for key. Called +// after a boot that built it; failures are ignored (the next run just builds). +func recordBuild(projectPath, key string) { + id := deploymentModelIdentity(projectPath) + if id == "" || key == "" { + return + } + data, _ := json.Marshal(reuseRecord{Key: key, Model: id}) + _ = os.MkdirAll(filepath.Dir(reuseRecordPath(projectPath)), 0o755) + _ = os.WriteFile(reuseRecordPath(projectPath), data, 0o644) +} + +// forgetBuild drops the record: the next run builds. +func forgetBuild(projectPath string) { _ = os.Remove(reuseRecordPath(projectPath)) } diff --git a/cmd/mxcli/testrunner/reusebuild_test.go b/cmd/mxcli/testrunner/reusebuild_test.go new file mode 100644 index 000000000..407203c04 --- /dev/null +++ b/cmd/mxcli/testrunner/reusebuild_test.go @@ -0,0 +1,290 @@ +// SPDX-License-Identifier: Apache-2.0 + +package testrunner + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// newTestProject lays out /App.mpr, /somefile.txt and +// /deployment/model/model.mdp, and returns the .mpr path (what callers +// pass as projectPath). +func newTestProject(t *testing.T) string { + t.Helper() + dir := t.TempDir() + mpr := filepath.Join(dir, "App.mpr") + if err := os.WriteFile(mpr, []byte("mpr"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "somefile.txt"), []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + modelDir := filepath.Join(dir, "deployment", "model") + if err := os.MkdirAll(modelDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(modelDir, "model.mdp"), []byte("model"), 0o644); err != nil { + t.Fatal(err) + } + return mpr +} + +func TestHashProjectTree_StableWhenUnchanged(t *testing.T) { + mpr := newTestProject(t) + dir := filepath.Dir(mpr) + h1, err := hashProjectTree(dir) + if err != nil { + t.Fatal(err) + } + h2, err := hashProjectTree(dir) + if err != nil { + t.Fatal(err) + } + if h1 != h2 { + t.Fatalf("hash changed with no file change: %s vs %s", h1, h2) + } +} + +func TestHashProjectTree_ChangesOnContentEdit(t *testing.T) { + mpr := newTestProject(t) + dir := filepath.Dir(mpr) + before, err := hashProjectTree(dir) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "somefile.txt"), []byte("changed"), 0o644); err != nil { + t.Fatal(err) + } + after, err := hashProjectTree(dir) + if err != nil { + t.Fatal(err) + } + if before == after { + t.Fatal("hash did not change after editing a tracked file") + } +} + +func TestHashProjectTree_IgnoresSkipDirs(t *testing.T) { + mpr := newTestProject(t) + dir := filepath.Dir(mpr) + before, err := hashProjectTree(dir) + if err != nil { + t.Fatal(err) + } + // Changing the deployment output (a build artifact) must not change the key: + // otherwise reuse would never hit, because a build always rewrites deployment/. + if err := os.WriteFile(filepath.Join(dir, "deployment", "model", "model.mdp"), []byte("rebuilt"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, ".mxcli"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".mxcli", "test-build.json"), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + after, err := hashProjectTree(dir) + if err != nil { + t.Fatal(err) + } + if before != after { + t.Fatal("hash changed from edits inside a skip dir (deployment/.mxcli)") + } +} + +// TestHashProjectTree_IgnoresThemeCache guards the real-app finding: mxbuild +// rewrites theme-cache/web/theme.compiled.css with a fresh cache-busting +// token on every build even with no theme/model change, so counting it as an +// input would make the key never repeat across two separate 'mxcli test' +// invocations and defeat --reuse-build entirely. +func TestHashProjectTree_IgnoresThemeCache(t *testing.T) { + mpr := newTestProject(t) + dir := filepath.Dir(mpr) + before, err := hashProjectTree(dir) + if err != nil { + t.Fatal(err) + } + cacheDir := filepath.Join(dir, "theme-cache", "web") + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cacheDir, "theme.compiled.css"), []byte("src: url(a.ttf?1)"), 0o644); err != nil { + t.Fatal(err) + } + after1, err := hashProjectTree(dir) + if err != nil { + t.Fatal(err) + } + if before != after1 { + t.Fatal("hash changed when theme-cache/ was created") + } + if err := os.WriteFile(filepath.Join(cacheDir, "theme.compiled.css"), []byte("src: url(a.ttf?2)"), 0o644); err != nil { + t.Fatal(err) + } + after2, err := hashProjectTree(dir) + if err != nil { + t.Fatal(err) + } + if after1 != after2 { + t.Fatal("hash changed when theme-cache content changed (cache-busting token) — this is the bug that defeats --reuse-build on every real app") + } +} + +func TestTestBuildKey_ChangesWithGeneratedMDL(t *testing.T) { + mpr := newTestProject(t) + opts := RunOptions{ProjectPath: mpr} + k1, err := testBuildKey(opts, "endpoint-v1", "flows-v1") + if err != nil { + t.Fatal(err) + } + k2, err := testBuildKey(opts, "endpoint-v1", "flows-v2") + if err != nil { + t.Fatal(err) + } + if k1 == k2 { + t.Fatal("key did not change when the generated test flows changed (e.g. a test file edit)") + } +} + +func TestTestBuildKey_ChangesWithMxBuildPath(t *testing.T) { + mpr := newTestProject(t) + k1, err := testBuildKey(RunOptions{ProjectPath: mpr}, "ep", "fl") + if err != nil { + t.Fatal(err) + } + k2, err := testBuildKey(RunOptions{ProjectPath: mpr, MxBuildPath: "/other/mxbuild"}, "ep", "fl") + if err != nil { + t.Fatal(err) + } + if k1 == k2 { + t.Fatal("key did not change when MxBuildPath changed: a different mxbuild could produce a different build") + } +} + +func TestReusableBuild_FalseWithNoRecord(t *testing.T) { + mpr := newTestProject(t) + if reusableBuild(mpr, "anykey") { + t.Fatal("reusableBuild true with no record on disk") + } +} + +func TestReusableBuild_RoundTrip(t *testing.T) { + mpr := newTestProject(t) + key := "abc123" + recordBuild(mpr, key) + if !reusableBuild(mpr, key) { + t.Fatal("reusableBuild false right after recordBuild with the same key") + } + if reusableBuild(mpr, "different-key") { + t.Fatal("reusableBuild true for a key that was never recorded") + } +} + +// TestReusableBuild_StaleAfterIndependentRebuild is the core safety property: +// if anything else (run --local, mxbuild by hand) rebuilds deployment/ after +// we recorded a key, the recorded key must no longer be honored even though +// its string value is unchanged, because the model identity moved. +func TestReusableBuild_StaleAfterIndependentRebuild(t *testing.T) { + mpr := newTestProject(t) + dir := filepath.Dir(mpr) + key := "abc123" + recordBuild(mpr, key) + if !reusableBuild(mpr, key) { + t.Fatal("expected reusable right after recording") + } + + // Simulate an independent rebuild: new content, and make sure the mtime + // actually advances (some filesystems have 1s mtime resolution). + mdp := filepath.Join(dir, "deployment", "model", "model.mdp") + future := time.Now().Add(2 * time.Second) + if err := os.WriteFile(mdp, []byte("rebuilt-by-something-else"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(mdp, future, future); err != nil { + t.Fatal(err) + } + + if reusableBuild(mpr, key) { + t.Fatal("reusableBuild true after deployment/model/model.mdp changed independently — a stale build would be reused") + } +} + +func TestReusableBuild_FalseWhenModelMissing(t *testing.T) { + mpr := newTestProject(t) + dir := filepath.Dir(mpr) + key := "abc123" + recordBuild(mpr, key) + if err := os.Remove(filepath.Join(dir, "deployment", "model", "model.mdp")); err != nil { + t.Fatal(err) + } + if reusableBuild(mpr, key) { + t.Fatal("reusableBuild true with no deployment/model/model.mdp on disk") + } +} + +func TestForgetBuild_ClearsRecord(t *testing.T) { + mpr := newTestProject(t) + key := "abc123" + recordBuild(mpr, key) + if !reusableBuild(mpr, key) { + t.Fatal("expected reusable right after recording") + } + forgetBuild(mpr) + if reusableBuild(mpr, key) { + t.Fatal("reusableBuild true after forgetBuild") + } +} + +func TestRecordBuild_NoopWithEmptyKeyOrMissingModel(t *testing.T) { + mpr := newTestProject(t) + dir := filepath.Dir(mpr) + recordBuild(mpr, "") // must not create a record for an empty key + if _, err := os.Stat(reuseRecordPath(mpr)); err == nil { + t.Fatal("recordBuild wrote a record for an empty key") + } + + emptyMpr := filepath.Join(t.TempDir(), "Empty.mpr") + if err := os.WriteFile(emptyMpr, []byte("mpr"), 0o644); err != nil { + t.Fatal(err) + } + recordBuild(emptyMpr, "somekey") // no deployment/model/model.mdp at all + if _, err := os.Stat(reuseRecordPath(emptyMpr)); err == nil { + t.Fatal("recordBuild wrote a record when deploymentModelIdentity was empty") + } + _ = dir +} + +// A node_modules or .git below the top is tooling state, not a build input: it +// neither changes the key nor costs a hash. +func TestHashProjectTree_IgnoresNestedToolingDirs(t *testing.T) { + mpr := newTestProject(t) + dir := filepath.Dir(mpr) + before, err := hashProjectTree(dir) + if err != nil { + t.Fatal(err) + } + for _, sub := range []string{"themesource/mymodule/node_modules/pkg", "widgets/src/.git"} { + if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, sub, "f"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + after, err := hashProjectTree(dir) + if err != nil { + t.Fatal(err) + } + if before != after { + t.Error("a nested node_modules or .git changed the reuse key") + } + // Control: an ordinary nested file still does. + if err := os.WriteFile(filepath.Join(dir, "themesource", "mymodule", "main.scss"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if again, _ := hashProjectTree(dir); again == after { + t.Error("an ordinary nested file did not change the reuse key") + } +} diff --git a/cmd/mxcli/testrunner/runner.go b/cmd/mxcli/testrunner/runner.go index c4fd2b378..c8052c59a 100644 --- a/cmd/mxcli/testrunner/runner.go +++ b/cmd/mxcli/testrunner/runner.go @@ -44,6 +44,13 @@ type RunOptions struct { // an attach to an app started with --allow-call. execPrograms map[string]*execProgram + // ReuseBuild (--reuse-build, local runs) skips the injection and the build when + // the deployment left by the previous run is the build this run would make: + // same project files, same test microflows, same mxcli (see reusebuild.go). + // Any difference builds as usual. + ReuseBuild bool + recordKey string // set by runEndpoint on a reuse miss: the key to record once the build booted + // Local runs the app with mxcli's own local runtime (`run --local`) instead // of a Docker container, and drives the tests over the test endpoint. Local bool @@ -290,16 +297,16 @@ func runEndpoint(opts RunOptions, suite *TestSuite, timeout time.Duration, w io. return nil, err } - // Capture what cleanup will need to restore, before touching anything. This - // must succeed: without it cleanup cannot tell an existing MxTest module from - // the one it is about to create, nor restore the original after-startup — and - // the generated startup flow needs to know what to chain. // An endpoint a dead run left behind would be captured as the project's own // after-startup and chained to (ako/mxcli#2119); take it out first, as `run` // does. if _, err := RestoreAbandonedEndpoint(opts.ProjectPath, w); err != nil { return nil, err } + // Capture what cleanup will need to restore, before touching anything. This + // must succeed: without it cleanup cannot tell an existing MxTest module from + // the one it is about to create, nor restore the original after-startup — and + // the generated startup flow needs to know what to chain. state, err := captureProjectState(opts.ProjectPath) if err != nil { return nil, fmt.Errorf("capturing project state: %w", err) @@ -329,6 +336,32 @@ func runEndpoint(opts RunOptions, suite *TestSuite, timeout time.Duration, w io. fmt.Fprintln(w, "--- End MDL ---") } + // Build reuse: nothing to inject and nothing to build when the deployment on + // disk is already this run's build. Not for --watch (it exists to rebuild). + if opts.ReuseBuild && !opts.Watch && !opts.SkipBuild { + if k, kerr := testBuildKey(opts, endpointMDL, flowsMDL); kerr != nil { + fmt.Fprintf(w, " build reuse off (%v)\n", kerr) + } else if reusableBuild(opts.ProjectPath, k) { + fmt.Fprintln(w, "Reusing the previous build: project, tests and mxcli unchanged; no injection, no mxbuild.") + ropts := opts + ropts.SkipBuild = true + forgetBuild(opts.ProjectPath) // re-recorded only after a clean run + result, rerr := runViaEndpoint(ropts, suite, token, timeout, w) + if rerr != nil { + return nil, rerr + } + recordBuild(opts.ProjectPath, k) + PrintResults(w, result, opts.Color) + if jerr := writeJUnit(opts, result, w); jerr != nil { + return result, jerr + } + return result, nil + } else { + forgetBuild(opts.ProjectPath) // a build that fails half way must not leave a record behind + opts.recordKey = k + } + } + fmt.Fprintln(w, "Injecting test endpoint into project...") // From here on the project is modified, so every exit runs cleanup. diff --git a/cmd/mxcli/testrunner/runner_endpoint.go b/cmd/mxcli/testrunner/runner_endpoint.go index d774d58b8..4332f1b87 100644 --- a/cmd/mxcli/testrunner/runner_endpoint.go +++ b/cmd/mxcli/testrunner/runner_endpoint.go @@ -112,6 +112,9 @@ func runViaEndpoint(opts RunOptions, suite *TestSuite, token string, timeout tim return resultsForBuildFailure(err, suite) } defer sess.stop() + if opts.recordKey != "" { + recordBuild(opts.ProjectPath, opts.recordKey) + } return runSuite(sess.client, sess.adminOptions(), suite, opts, w) } diff --git a/docs-site/src/language/widget-types.md b/docs-site/src/language/widget-types.md index bb5efac42..e3adc58ff 100644 --- a/docs-site/src/language/widget-types.md +++ b/docs-site/src/language/widget-types.md @@ -486,7 +486,8 @@ workflow` takes `ClosePage` (default `true`) and a `Confirmation`, `open user task` takes `AssignOnOpen` (Studio Pro's "Auto-assign", default `true`) and `OpenWhenAssigned` (`true` is "Who can open: Users with access", default `false`), and `open workflow` requires `DefaultPage` (without it Mendix reports -CE1834). `open user task` must sit inside a data container of +CE1834). `show page` takes `NumberOfPagesToClose`, the pages to close as it opens +(a whole number, default none). `open user task` must sit inside a data container of `System.WorkflowUserTask` and `open workflow` inside one of `System.Workflow`, or mx check reports CE1843. A task inbox opens each task from its row: diff --git a/docs-site/src/tools/running-tests.md b/docs-site/src/tools/running-tests.md index f977397f6..2700263d8 100644 --- a/docs-site/src/tools/running-tests.md +++ b/docs-site/src/tools/running-tests.md @@ -174,6 +174,11 @@ wins when both are set. mxcli test tests/ -p app.mpr --local --mxbuild-path "C:\Program Files\Mendix\11.11.0\modeler\mxbuild.exe" ``` +`--reuse-build` (with `--local`, not `--watch`) skips the injection and the build +when the project, the test files and mxcli are unchanged since the last run: the +deployment that run left is booted as it is. Any difference — a changed file, a +deployment something else rebuilt — builds as usual. + `--watch` is the everyday loop: edit a test *or* the microflow under test, and the verdict lands in about two seconds. diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 222aa7621..7916b540a 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -1509,6 +1509,9 @@ MDL uses explicit property declarations for pages: | Text area lines / autocomplete | `NumberOfLines: n, Autocomplete: true\|false` | `textarea t (Attribute: Notes, NumberOfLines: 2, Autocomplete: false)` — defaults 5 and true; `0` lines is a value Studio Pro stores. Also settable with `alter page … set` | | Input read-only style | `ReadOnlyStyle: Inherit\|Control\|Text` | `textbox t (Attribute: Name, ReadOnlyStyle: Text)` — on a text box, text area, date picker, check box and radio buttons. **Inherit** is the default; also `alter page … set (ReadOnlyStyle: Text) on t` | | DataView read-only style | `ReadOnlyStyle: Inherit\|Control\|Text` | `dataview dv (datasource: $O, ReadOnlyStyle: Text)` — a DataView's own, distinct from a checkbox's. **Control** is Studio Pro's default here, not Inherit | +| Check box label position | `LabelPosition: Default\|BeforeControl\|AfterControl` | `checkbox c (Attribute: Done, LabelPosition: AfterControl)` | +| Radio buttons orientation | `RenderHorizontal: true\|false` | `radiobuttons r (Attribute: Kind, RenderHorizontal: false)` — vertical; default horizontal | +| Show page closes pages | `with (NumberOfPagesToClose: n)` | `Action: show page M.Next(Item = $currentObject) with (NumberOfPagesToClose: 1)` | | Page CSS class / style | `Class: 'css-class', Style: 'css: rule'` | `(Title: 'Home', Class: 'container-fluid bg-light', Style: 'min-height: 100vh')` — the page's Appearance | | Page variables | `variables: ( $name: type = )` | `variables: ( $show: boolean = true )` | | Page parameters | `params: ( $name: type, … )` | `params: ( $Order: Shop.Order )` — a map, in `( )`; `params: { … }` is the deprecated spelling (MDL-DEPR123) | diff --git a/go.mod b/go.mod index 89650664a..54b9b01e9 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/mendixlabs/mxcli go 1.26.0 -toolchain go1.27.3 +toolchain go1.27.4 require ( github.com/alecthomas/chroma/v2 v2.26.1 diff --git a/mdl/ast/ast_page_v3.go b/mdl/ast/ast_page_v3.go index 2a512cce0..644710236 100644 --- a/mdl/ast/ast_page_v3.go +++ b/mdl/ast/ast_page_v3.go @@ -382,6 +382,9 @@ type ActionSettingsV3 struct { ClosePage *bool // complete task, call workflow Commit *bool // complete task DefaultPage string // open workflow: a page qualified name + // NumberOfPagesToClose is show page's "number of pages to close", a whole + // number of at least one; "" leaves Studio Pro's default (ako/mxcli#2106). + NumberOfPagesToClose string } // ColumnV3 represents a V3 datagrid column. diff --git a/mdl/backend/modelsdk/widget_association_source_null_2106_test.go b/mdl/backend/modelsdk/widget_association_source_null_2106_test.go new file mode 100644 index 000000000..8457b6b18 --- /dev/null +++ b/mdl/backend/modelsdk/widget_association_source_null_2106_test.go @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "github.com/mendixlabs/mxcli/modelsdk/codec" + "github.com/mendixlabs/mxcli/sdk/pages" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// ako/mxcli#2106: Studio Pro stores a Forms$AssociationSource's SourceVariable +// key on every one, null when the source starts from $currentObject (40 of 40 +// in FactoryManagement, 30 null). The writer left the key out, so describe -> +// exec of DigitalTwin.InvestigationTask_Done rewrote the page. +func TestWriteAssociationSource_NullSourceVariable(t *testing.T) { + out, err := (&codec.Encoder{}).Encode(associationSourceToGen(&pages.AssociationSource{EntityPath: "M.Item_Order/M.Item"})) + if err != nil { + t.Fatal(err) + } + var d bson.D + if err := bson.Unmarshal(out, &d); err != nil { + t.Fatal(err) + } + for _, e := range d { + if e.Key == "SourceVariable" { + if e.Value != nil { + t.Errorf("SourceVariable = %v, want null for a $currentObject source", e.Value) + } + return + } + } + t.Error("SourceVariable key absent; Studio Pro stores it null") +} diff --git a/mdl/backend/modelsdk/widget_write.go b/mdl/backend/modelsdk/widget_write.go index 5b252369b..8b8f87a6d 100644 --- a/mdl/backend/modelsdk/widget_write.go +++ b/mdl/backend/modelsdk/widget_write.go @@ -122,6 +122,11 @@ func init() { codec.RegisterTypeDefaults("Forms$ListViewXPathSource", codec.TypeDefaults{ NullFields: []string{"SourceVariable"}, }) + // An association source likewise: 40 of 40 in FactoryManagement carry the + // key, null (30) when the source starts from $currentObject (ako/mxcli#2106). + codec.RegisterTypeDefaults("Forms$AssociationSource", codec.TypeDefaults{ + NullFields: []string{"SourceVariable"}, + }) // LayoutGrid and its rows carry a null ConditionalVisibilitySettings; the grid, // rows, and columns all use the typed-array marker 2 in their parent lists. for _, t := range []string{"Forms$LayoutGrid", "Forms$LayoutGridRow"} { @@ -679,6 +684,9 @@ func widgetToGen(w pages.Widget) (element.Element, error) { if x.Label != "" { g.SetLabelTemplate(textAsClientTemplate(textFromString(x.Label))) } + if x.LabelPosition != "" { + g.SetLabelPosition(x.LabelPosition) + } onChangeCB, err := clientActionToGen(x.OnChangeAction) if err != nil { return nil, err @@ -2356,7 +2364,7 @@ func clientActionToGen(a pages.ClientAction) (element.Element, error) { } assignID(g) g.SetDisabledDuringExecution(actionDisabledDuringExecution(x.ActionExecution)) - g.SetNumberOfPagesToClose2("") + g.SetNumberOfPagesToClose2(x.NumberOfPagesToClose) g.SetPageSettings(pageSettingsWithMappings(x.PageName, x.ParameterMappings)) return g, nil case *pages.LinkClientAction: diff --git a/mdl/backend/modelsdk/widget_write_round_trip_settings_2106_test.go b/mdl/backend/modelsdk/widget_write_round_trip_settings_2106_test.go new file mode 100644 index 000000000..a769c3ed5 --- /dev/null +++ b/mdl/backend/modelsdk/widget_write_round_trip_settings_2106_test.go @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// ako/mxcli#2106: the writer stored Studio Pro's defaults over a check box's +// label position and a show page action's close count. +func TestWriteCheckBox_LabelPosition(t *testing.T) { + d := encodeWidget(t, &pages.CheckBox{ + BaseWidget: pages.BaseWidget{BaseElement: model.BaseElement{ID: "cb"}, Name: "cb"}, + LabelPosition: "AfterControl", + }) + if got := bsonnav.DGetString(d, "LabelPosition"); got != "AfterControl" { + t.Errorf("LabelPosition = %q, want AfterControl", got) + } + // Control: unset writes what it always did, nothing. + d = encodeWidget(t, &pages.CheckBox{BaseWidget: pages.BaseWidget{BaseElement: model.BaseElement{ID: "cb"}, Name: "cb"}}) + if got := bsonnav.DGetString(d, "LabelPosition"); got != "" { + t.Errorf("unset LabelPosition = %q, want it left unwritten as before", got) + } +} + +func TestWriteShowPageAction_NumberOfPagesToClose(t *testing.T) { + d := encodeWidget(t, &pages.ActionButton{ + BaseWidget: pages.BaseWidget{BaseElement: model.BaseElement{ID: "b"}, Name: "b"}, + Action: &pages.PageClientAction{PageName: "M.Next", NumberOfPagesToClose: "1"}, + }) + if got := bsonnav.DGetString(bsonnav.DGetDoc(d, "Action"), "NumberOfPagesToClose2"); got != "1" { + t.Errorf("NumberOfPagesToClose2 = %q, want 1", got) + } +} diff --git a/mdl/executor/cmd_pages_builder_input.go b/mdl/executor/cmd_pages_builder_input.go index c77ab1b2b..1b279871b 100644 --- a/mdl/executor/cmd_pages_builder_input.go +++ b/mdl/executor/cmd_pages_builder_input.go @@ -108,7 +108,10 @@ func (pb *pageBuilder) attributeIndex() attributeIndex { } // entityAttributeOwners indexes, for every entity in the project, the set of -// attribute names it declares itself, plus each entity's direct parent. +// attribute names it declares itself, plus each entity's direct parent. The +// system members an entity stores (createdDate, changedDate, owner, +// changedBy) count as its own: Studio Pro stores a binding of one that a +// specialization inherits against the entity that stores it (ako/mxcli#2106). func (pb *pageBuilder) entityAttributeOwners() (owners map[string]map[string]bool, parents map[string]string, err error) { dms, err := pb.getDomainModels() if err != nil { @@ -128,6 +131,14 @@ func (pb *pageBuilder) entityAttributeOwners() (owners map[string]map[string]boo for _, a := range e.Attributes { attrs[strings.ToLower(a.Name)] = true } + for name, stored := range map[string]bool{ + "createddate": e.HasCreatedDate, "changeddate": e.HasChangedDate, + "owner": e.HasOwner, "changedby": e.HasChangedBy, + } { + if stored { + attrs[name] = true + } + } owners[qn] = attrs if e.GeneralizationRef != "" { parents[qn] = e.GeneralizationRef diff --git a/mdl/executor/cmd_pages_builder_system_member_2106_test.go b/mdl/executor/cmd_pages_builder_system_member_2106_test.go new file mode 100644 index 000000000..5e4cb9b68 --- /dev/null +++ b/mdl/executor/cmd_pages_builder_system_member_2106_test.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// ako/mxcli#2106: Studio Pro stores a system member a specialization inherits +// against the entity that stores it. ConversationalUI.Span_ToolCallUserConfirmation_SlideIn +// binds createdDate in a GenAICommons.ToolSpan context and stores +// 'GenAICommons.Span.createdDate' (Span has "Store created date"); the rebuild +// wrote 'GenAICommons.ToolSpan.createdDate'. +func TestResolveAttributePath_InheritedSystemMember(t *testing.T) { + const mod = model.ID("mod-genai") + pb := &pageBuilder{ + entityContext: "GenAICommons.ToolSpan", + execCache: &executorCache{ + hierarchy: &ContainerHierarchy{moduleNames: map[model.ID]string{mod: "GenAICommons"}}, + domainModels: []*domainmodel.DomainModel{{ + ContainerID: mod, + Entities: []*domainmodel.Entity{ + {BaseElement: model.BaseElement{ID: "e-span"}, Name: "Span", HasCreatedDate: true, HasOwner: true, + Attributes: []*domainmodel.Attribute{{Name: "Output"}}}, + {BaseElement: model.BaseElement{ID: "e-tool"}, Name: "ToolSpan", GeneralizationRef: "GenAICommons.Span", + Attributes: []*domainmodel.Attribute{{Name: "ToolName"}}}, + }, + }}, + }, + } + for attr, want := range map[string]string{ + "createdDate": "GenAICommons.Span.createdDate", + "owner": "GenAICommons.Span.owner", + // Controls: an inherited attribute and an own one, as before. + "Output": "GenAICommons.Span.Output", + "ToolName": "GenAICommons.ToolSpan.ToolName", + // A system member no entity in the chain stores keeps the context. + "changedDate": "GenAICommons.ToolSpan.changedDate", + } { + if got := pb.resolveAttributePath(attr); got != want { + t.Errorf("%s: resolved %q, want %q", attr, got, want) + } + } +} diff --git a/mdl/executor/cmd_pages_builder_v3_widgets.go b/mdl/executor/cmd_pages_builder_v3_widgets.go index 28a232459..3a7d255d7 100644 --- a/mdl/executor/cmd_pages_builder_v3_widgets.go +++ b/mdl/executor/cmd_pages_builder_v3_widgets.go @@ -901,6 +901,15 @@ func (pb *pageBuilder) buildCheckBoxV3(w *ast.WidgetV3) (*pages.CheckBox, error) // omitted property stays empty and the writer keeps the stored default — // what decides whether a read-only check box renders as "Yes"/"No" text or // as the checkbox glyph (ako/mxcli#490). + // LabelPosition (ako/mxcli#2106): Default, BeforeControl or AfterControl. + if raw := w.GetStringProp("LabelPosition"); raw != "" { + pos, ok := map[string]string{"default": "Default", "beforecontrol": "BeforeControl", "aftercontrol": "AfterControl"}[strings.ToLower(raw)] + if !ok { + return nil, mdlerrors.NewValidationf("checkbox %q: LabelPosition %q is not a Mendix label position — use Default, BeforeControl or AfterControl", w.Name, raw) + } + cb.LabelPosition = pos + } + style, err := readOnlyStyleValue(w.GetStringProp("ReadOnlyStyle"), "checkbox", w.Name) if err != nil { return nil, err @@ -972,6 +981,19 @@ func (pb *pageBuilder) buildRadioButtonsV3(w *ast.WidgetV3) (*pages.RadioButtons } // ReadOnlyStyle, as on a checkbox; unset keeps Inherit (ako/mxcli#977). + // RenderHorizontal (ako/mxcli#2106): Studio Pro's "Orientation". Unset + // keeps the writer's default, horizontal. + if v, ok := w.Properties["RenderHorizontal"]; ok { + switch strings.ToLower(fmt.Sprint(v)) { + case "true": + rb.RenderDirection = pages.RenderDirectionHorizontal + case "false": + rb.RenderDirection = pages.RenderDirectionVertical + default: + return nil, mdlerrors.NewValidationf("radiobuttons %q: RenderHorizontal takes true or false, not %v", w.Name, v) + } + } + rbStyle, err := readOnlyStyleValue(w.GetStringProp("ReadOnlyStyle"), "radiobuttons", w.Name) if err != nil { return nil, err diff --git a/mdl/executor/cmd_pages_describe.go b/mdl/executor/cmd_pages_describe.go index 5ef9207e8..783a450cd 100644 --- a/mdl/executor/cmd_pages_describe.go +++ b/mdl/executor/cmd_pages_describe.go @@ -914,8 +914,11 @@ type rawWidget struct { Editable string // "Always", "Never", "Conditional" ReadOnlyStyle string // "Inherit", "Control", "Text" ShowLabel bool // Whether label is shown (from LabelTemplate visibility) - LabelPosition string // "Left", "Top", etc. - Placeholder string // Placeholder hint text (from PlaceholderTemplate) + LabelPosition string // a check box's: "Default", "BeforeControl", "AfterControl" + // RenderVertical is a radio button group stored RenderHorizontal false + // (ako/mxcli#2106). + RenderVertical bool + Placeholder string // Placeholder hint text (from PlaceholderTemplate) // IsPassword is Forms$TextBox.IsPasswordBox. Security-relevant: a text box // that round-trips without it renders the value in plaintext (ako/mxcli#550). IsPassword bool diff --git a/mdl/executor/cmd_pages_describe_action_settings.go b/mdl/executor/cmd_pages_describe_action_settings.go index 3c5647771..838cf1d10 100644 --- a/mdl/executor/cmd_pages_describe_action_settings.go +++ b/mdl/executor/cmd_pages_describe_action_settings.go @@ -57,6 +57,10 @@ func renderClientActionSettingsMDL(ctx *ExecContext, action map[string]any) stri if page, _ := action["DefaultPage"].(string); page != "" { props = append(props, "DefaultPage: "+page) } + case "Forms$FormAction", "Pages$PageClientAction": + if n, _ := action["NumberOfPagesToClose2"].(string); strings.TrimSpace(n) != "" { + props = append(props, "NumberOfPagesToClose: "+strings.TrimSpace(n)) + } } if flow != nil { if pb, _ := flow["ProgressBar"].(string); pb != "" && pb != "None" { @@ -145,6 +149,9 @@ func formatActionSettingsV3(s *ast.ActionSettingsV3) string { if s.DefaultPage != "" { props = append(props, "DefaultPage: "+s.DefaultPage) } + if s.NumberOfPagesToClose != "" { + props = append(props, "NumberOfPagesToClose: "+s.NumberOfPagesToClose) + } if len(props) == 0 { return "" } diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index 982644d75..55eb7b07e 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -772,6 +772,9 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { props = append(props, fmt.Sprintf("Attribute: %s", w.Content)) } props = appendReadOnlyStyle(props, w) + if w.RenderVertical { + props = append(props, "RenderHorizontal: false") + } if w.OnChange != "" { props = append(props, actionProp("OnChange", w.OnChange)) } @@ -793,6 +796,9 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { if !w.ShowLabel { props = append(props, "ShowLabel: No") } + if w.LabelPosition != "" && w.LabelPosition != "Default" { + props = append(props, "LabelPosition: "+w.LabelPosition) + } if w.OnChange != "" { props = append(props, actionProp("OnChange", w.OnChange)) } diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index fc59a1cd5..4cadc4a8a 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -470,6 +470,9 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s case "Forms$RadioButtons", "Pages$RadioButtons", "Forms$RadioButtonGroup", "Pages$RadioButtonGroup": widget.Type = "Forms$RadioButtons" // Normalize type + if h, ok := w["RenderHorizontal"].(bool); ok && !h { + widget.RenderVertical = true // ako/mxcli#2106 + } widget.Caption = extractLabelText(ctx, w) widget.Content = extractInputAttribute(ctx, w) widget.Editable = extractEditable(ctx, w) @@ -484,6 +487,7 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s widget.Editable = extractEditable(ctx, w) widget.ReadOnlyStyle = extractReadOnlyStyle(ctx, w) widget.ShowLabel = extractShowLabel(ctx, w) + widget.LabelPosition, _ = w["LabelPosition"].(string) // ako/mxcli#2106 widget.OnChange = extractOnChangeAction(ctx, w) widget.OnEnter, widget.OnLeave = extractFocusEventActions(ctx, w) return []rawWidget{widget} diff --git a/mdl/executor/cmd_pages_round_trip_settings_2106_test.go b/mdl/executor/cmd_pages_round_trip_settings_2106_test.go new file mode 100644 index 000000000..136723540 --- /dev/null +++ b/mdl/executor/cmd_pages_round_trip_settings_2106_test.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 + +// ako/mxcli#2106: three widget settings Studio Pro stores had no MDL spelling, +// so `describe page` → `exec` of FactoryManagement's pages changed them +// (Mendix 11.16, value-only diff of the unit before and after): +// +// TraceFiltering_Edit checkBox1.LabelPosition AfterControl → Default +// TeamcenterConfiguration… radioButtons1.RenderHorizontal false → true +// ConnectionDetails_…Step2 actionButton1.Action.NumberOfPagesToClose2 "1" → "" +// +// Each now reads back from the stored widget, describes, builds and writes. +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +func TestDescribeCheckBox_LabelPosition(t *testing.T) { + got := describeStoredWidget(t, map[string]any{"$Type": "Forms$CheckBox", "Name": "checkBox1", "LabelPosition": "AfterControl"}) + if !strings.Contains(got, "LabelPosition: AfterControl") { + t.Errorf("describe dropped the label position:\n%s", got) + } + // Control: Studio Pro's default is not printed. + got = describeStoredWidget(t, map[string]any{"$Type": "Forms$CheckBox", "Name": "checkBox1", "LabelPosition": "Default"}) + if strings.Contains(got, "LabelPosition") { + t.Errorf("describe printed the default label position:\n%s", got) + } +} + +func TestDescribeRadioButtons_RenderHorizontal(t *testing.T) { + got := describeStoredWidget(t, map[string]any{"$Type": "Forms$RadioButtonGroup", "Name": "rb", "RenderHorizontal": false}) + if !strings.Contains(got, "RenderHorizontal: false") { + t.Errorf("describe dropped a vertical radio button group:\n%s", got) + } + // Control: horizontal is what the writer stores by default. + got = describeStoredWidget(t, map[string]any{"$Type": "Forms$RadioButtonGroup", "Name": "rb", "RenderHorizontal": true}) + if strings.Contains(got, "RenderHorizontal") { + t.Errorf("describe printed the default orientation:\n%s", got) + } +} + +func TestDescribeShowPageAction_NumberOfPagesToClose(t *testing.T) { + action := map[string]any{"$Type": "Forms$FormAction", "DisabledDuringExecution": true, "NumberOfPagesToClose2": "1"} + if got := renderClientActionSettingsMDL(nil, action); got != " with (NumberOfPagesToClose: 1)" { + t.Errorf("settings = %q, want the close count", got) + } + action["NumberOfPagesToClose2"] = "" + if got := renderClientActionSettingsMDL(nil, action); got != "" { + t.Errorf("settings = %q, want none for the default", got) + } +} + +func TestBuildCheckBox_LabelPosition(t *testing.T) { + pb := newPropsBuilder() + cb, err := pb.buildCheckBoxV3(&ast.WidgetV3{Name: "cb", Type: "checkbox", Properties: map[string]any{"LabelPosition": "aftercontrol"}}) + if err != nil { + t.Fatal(err) + } + if cb.LabelPosition != "AfterControl" { + t.Errorf("LabelPosition = %q, want AfterControl", cb.LabelPosition) + } + if _, err := newPropsBuilder().buildCheckBoxV3(&ast.WidgetV3{Name: "cb", Type: "checkbox", Properties: map[string]any{"LabelPosition": "Left"}}); err == nil { + t.Error("LabelPosition Left accepted, want a refusal naming the values") + } +} + +func TestBuildRadioButtons_RenderHorizontal(t *testing.T) { + for _, tc := range []struct { + value any + want pages.RenderDirection + }{{nil, ""}, {false, pages.RenderDirectionVertical}, {"false", pages.RenderDirectionVertical}, {true, pages.RenderDirectionHorizontal}} { + w := &ast.WidgetV3{Name: "rb", Type: "radiobuttons", Properties: map[string]any{}} + if tc.value != nil { + w.Properties["RenderHorizontal"] = tc.value + } + rb, err := newPropsBuilder().buildRadioButtonsV3(w) + if err != nil { + t.Fatal(err) + } + if rb.RenderDirection != tc.want { + t.Errorf("RenderHorizontal %v: RenderDirection = %q, want %q", tc.value, rb.RenderDirection, tc.want) + } + } +} + +func TestApplyActionSettings_NumberOfPagesToClose(t *testing.T) { + a := &pages.PageClientAction{PageName: "M.Next"} + if err := applyActionSettings(a, &ast.ActionSettingsV3{NumberOfPagesToClose: "2"}); err != nil { + t.Fatal(err) + } + if a.NumberOfPagesToClose != "2" { + t.Errorf("NumberOfPagesToClose = %q, want 2", a.NumberOfPagesToClose) + } +} diff --git a/mdl/executor/page_action_settings.go b/mdl/executor/page_action_settings.go index cd4305977..04de5daea 100644 --- a/mdl/executor/page_action_settings.go +++ b/mdl/executor/page_action_settings.go @@ -139,6 +139,13 @@ func applyWorkflowActionSettings(a pages.ClientAction, s *ast.ActionSettingsV3) if _, ok := a.(*pages.OpenWorkflowClientAction); s.DefaultPage != "" && !ok { return mdlerrors.NewValidation("DefaultPage is a setting of `open workflow` only") } + if s.NumberOfPagesToClose != "" { + x, ok := a.(*pages.PageClientAction) + if !ok { + return mdlerrors.NewValidation("NumberOfPagesToClose is a setting of `show page` only") + } + x.NumberOfPagesToClose = s.NumberOfPagesToClose + } return nil } diff --git a/mdl/executor/validate_widgets.go b/mdl/executor/validate_widgets.go index 189caad47..be10a172e 100644 --- a/mdl/executor/validate_widgets.go +++ b/mdl/executor/validate_widgets.go @@ -1233,7 +1233,7 @@ var staticWidgetKnownProps = func() map[string]bool { // vocabulary describe page emits (native widgets + datagrid columns) "Alignment", "AlternativeText", "ColumnClass", "ColumnWidth", "DesktopColumns", "DisplayAs", "Draggable", "DynamicCellClass", "HeightUnit", "Hidable", "ImageType", - "ImageUrl", "LabelPosition", "PageSize", "Pagination", "PagingPosition", + "ImageUrl", "LabelPosition", "RenderHorizontal", "PageSize", "Pagination", "PagingPosition", "PhoneColumns", "ReadOnlyStyle", "Resizable", "Responsive", "ShowPagingButtons", "Size", "Sortable", "TabletColumns", "WidthUnit", "WrapText", "Name", // input-widget properties describe page emits (ako/mxcli#550): a text diff --git a/mdl/executor/widget_native_props.go b/mdl/executor/widget_native_props.go index a3c98fc89..8763b82aa 100644 --- a/mdl/executor/widget_native_props.go +++ b/mdl/executor/widget_native_props.go @@ -102,8 +102,8 @@ var nativeWidgetKinds = map[string]nativeWidgetKind{ "datepicker": {stored: "Forms$DatePicker", tail: true, visible: true, reads: withReads(inputReads, "ReadOnlyStyle")}, "dropdown": {stored: "Forms$DropDown", tail: true, visible: true, reads: withReads(inputReads)}, - "checkbox": {stored: "Forms$CheckBox", tail: true, visible: true, reads: withReads(inputReads, "ReadOnlyStyle")}, - "radiobuttons": {stored: "Forms$RadioButtonGroup", tail: true, visible: true, reads: withReads(inputReads, "ReadOnlyStyle")}, + "checkbox": {stored: "Forms$CheckBox", tail: true, visible: true, reads: withReads(inputReads, "ReadOnlyStyle", "LabelPosition")}, + "radiobuttons": {stored: "Forms$RadioButtonGroup", tail: true, visible: true, reads: withReads(inputReads, "ReadOnlyStyle", "RenderHorizontal")}, "filemanager": {stored: "Forms$FileManager", tail: true, visible: true, reads: []string{"Label", "ShowLabel", "Type", "MaxFileSize", "AllowedExtensions", "ShowFileInBrowser"}}, diff --git a/mdl/grammar/domains/MDLPage.g4 b/mdl/grammar/domains/MDLPage.g4 index 15e09a2dc..e5c872e3f 100644 --- a/mdl/grammar/domains/MDLPage.g4 +++ b/mdl/grammar/domains/MDLPage.g4 @@ -786,7 +786,7 @@ actionReturnV3 // workflow with (DefaultPage: Module.Page)`); a single segment is the // identifierOrKeyword every other setting takes. actionSettingV3 - : identifierOrKeyword COLON (STRING_LITERAL | NONE | qualifiedName) + : identifierOrKeyword COLON (STRING_LITERAL | NUMBER_LITERAL | NONE | qualifiedName) ; // The page actions are the words a microflow uses (R8, ako/mxcli#752): diff --git a/mdl/roundtrip/testapp_allowlist_test.go b/mdl/roundtrip/testapp_allowlist_test.go index 2c19395f3..30a0e511f 100644 --- a/mdl/roundtrip/testapp_allowlist_test.go +++ b/mdl/roundtrip/testapp_allowlist_test.go @@ -64,90 +64,82 @@ package roundtrip // PageVariable naming only the widget, which describe dropped and exec could // not write back — the rebuilt button called its microflow unbound (CE1571). var testAppKnownFailures = map[string]knownFailure{ - "building block Atlas_Web_Content.Alert": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.AlertIcon": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.AlertIcon_WithAction": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Alert_WithAction": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Breadcrumb": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Breadcrumb_Underline": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Card": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Card_Action": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Card_ActionWithImage": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Card_Background": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Card_WithImage": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Form_Horizontal": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Form_Horizontal_WithAction": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Form_Horizontal_WithTitle": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Form_Vertical": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Form_Vertical_WithAction": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Form_Vertical_WithTitle": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Heroheader": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Heroheader_Background": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Heroheader_WithAction": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.ListItem_DoubleLine": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.ListItem_SingleLine": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.ListItem_WithImage": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.List_Cards": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.List_WithImage": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Master_Detail": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Pageheader": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.PageheaderImage": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.PageheaderImage_WithBack": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.PageheaderImage_WithControls": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Pageheader_WithBack": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Pageheader_WithControls": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Pageheader_WithSearch": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Timeline": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Timeline_WithImage": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Wizard_Arrow": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Wizard_Arrow_Step": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Wizard_Circle": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Wizard_Circle_Step": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block FeedbackModule.FeedbackWidget": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "entity WorkflowCommons.ConflictedWorkflowView": {laws: []law{lawExec}, issue: "#721", why: "entity: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "entity WorkflowCommons.MyInitiatedWorkflowView": {laws: []law{lawExec}, issue: "#721", why: "entity: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "entity WorkflowCommons.UserTaskView": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "entity: executes since a Studio Pro index keeps its GUID on CREATE OR MODIFY (mendixlabs/mxcli#1470); re-executing adds a fourth access rule (the XPath-scoped grant is not matched to its stored rule) and the documentation's trailing whitespace drifts — not yet triaged into #721's classes"}, - "entity WorkflowCommons.WorkflowAttachment": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "entity: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "entity WorkflowCommons.WorkflowComment": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "entity: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "entity WorkflowCommons.WorkflowGroupView": {laws: []law{lawParse}, issue: "#721", why: "entity: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "entity WorkflowCommons.WorkflowUserTaskView": {laws: []law{lawExec}, issue: "#721", why: "entity: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "entity WorkflowCommons.WorkflowView": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "entity: executes since a Studio Pro index keeps its GUID on CREATE OR MODIFY (mendixlabs/mxcli#1470); re-executing adds a fourth access rule (the XPath-scoped grant is not matched to its stored rule) and the documentation's trailing whitespace drifts — not yet triaged into #721's classes"}, - "enumeration WorkflowCommons.Enum_NotificationArea_RenderAs": {laws: []law{lawGetPut}, issue: "#721", why: "enumeration: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "export mapping Services.OrdersExportMapping": {laws: []law{lawGetPut}, issue: "#721", why: "export mapping: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.WorkflowAttachment_View": {laws: []law{lawExec}, issue: "#721", why: "page: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.WorkflowDefinition_View": {laws: []law{lawExec}, issue: "#721", why: "page: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.WorkflowSubProcess_View": {laws: []law{lawExec}, issue: "#721", why: "page: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "rest client Clients.OrdersRestClient": {laws: []law{lawParse}, issue: "#721", why: "rest client: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "rule Rules.Rule1": {laws: []law{lawGetPut}, issue: "#721", why: "rule: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "rule Rules.Rule2": {laws: []law{lawGetPut}, issue: "#721", why: "rule: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "scheduled event WorkflowCommons.SE_WorkflowAuditTrailRecord_CleanUp": {laws: []law{lawGetPut}, issue: "#721", why: "scheduled event: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_Assignee_Search": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes; executes since a widget bound to a snippet parameter is described as $Param.Attr — getput not yet triaged"}, - "snippet WorkflowCommons.Snip_AuditTrailViewer_Toolbar": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_MyInitiatedWorkflowView_NotificationArea": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_MyInitiatedWorkflowView_StateCircleOnly": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_TaskAssignmentHelper_Assignee_Search": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes; executes since a widget bound to a snippet parameter is described as $Param.Attr — getput not yet triaged"}, - "snippet WorkflowCommons.Snip_TaskAssignmentHelper_TargetUser_Search": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes; executes since a widget bound to a snippet parameter is described as $Param.Attr — getput not yet triaged"}, - "snippet WorkflowCommons.Snip_TaskAssignment_UserTask_Assignees": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_TaskAssignment_UserTask_TargetUsers": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_TimelineViewer_Timeline_Full": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_TimelineViewer_Timeline_Tasks": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_UserTask_NameColumn": {laws: []law{lawGetPut, lawPutGet}, issue: "#721 #826", why: "executes since describe prints snippet call Params (#826); an association-path template parameter read from a snippet parameter loses its AttributeRef and SourceVariable (#721)"}, - "snippet WorkflowCommons.Snip_UserTask_NotificationArea": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_WorkflowActivityRecord_ActivityIcon": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_WorkflowActivityRecord_Details": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes; executes since a widget bound to a snippet parameter is described as $Param.Attr — getput not yet triaged"}, - "snippet WorkflowCommons.Snip_WorkflowActivityRecord_Header": {laws: []law{lawGetPut, lawPutGet}, issue: "#721 #826", why: "executes since describe prints snippet call Params (#826); association-path template parameters and an expression parameter drift (#721)"}, - "snippet WorkflowCommons.Snip_WorkflowActivity_Details_ActivityType": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_WorkflowSubProcess_Details": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_WorkflowSubProcess_IncompatibleWarning": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_WorkflowSubProcess_State": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_WorkflowUserTaskView_Details": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_WorkflowUserTaskView_NameColumn": {laws: []law{lawGetPut, lawPutGet}, issue: "#721 #826", why: "executes since describe prints snippet call Params (#826); an association-path template parameter read from a snippet parameter loses its AttributeRef and SourceVariable (#721)"}, - "snippet WorkflowCommons.Snip_Workflow_CommentsAndAttachments": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_Workflow_CommentsAndAttachments_Admin": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_Workflow_Header": {laws: []law{lawGetPut, lawPutGet}, issue: "#721 #826", why: "executes since describe prints snippet call Params (#826); an association-path caption parameter read from a snippet parameter loses its AttributeRef and SourceVariable (#721)"}, - "snippet WorkflowCommons.Snip_Workflow_NotificationArea": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_Workflow_StateCircleOnly": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_Workflow_SubProcessDetails": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Alert": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.AlertIcon": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.AlertIcon_WithAction": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Alert_WithAction": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Breadcrumb": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Breadcrumb_Underline": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Card": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Card_Action": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Card_ActionWithImage": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Card_Background": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Card_WithImage": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Form_Horizontal": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Form_Horizontal_WithAction": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Form_Horizontal_WithTitle": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Form_Vertical": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Form_Vertical_WithAction": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Form_Vertical_WithTitle": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Heroheader": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Heroheader_Background": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Heroheader_WithAction": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.ListItem_DoubleLine": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.ListItem_SingleLine": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.ListItem_WithImage": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.List_Cards": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.List_WithImage": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Master_Detail": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Pageheader": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.PageheaderImage": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.PageheaderImage_WithBack": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.PageheaderImage_WithControls": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Pageheader_WithBack": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Pageheader_WithControls": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Pageheader_WithSearch": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Timeline": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Timeline_WithImage": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Wizard_Arrow": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Wizard_Arrow_Step": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Wizard_Circle": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Wizard_Circle_Step": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block FeedbackModule.FeedbackWidget": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "entity WorkflowCommons.ConflictedWorkflowView": {laws: []law{lawExec}, issue: "#721", why: "entity: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "entity WorkflowCommons.MyInitiatedWorkflowView": {laws: []law{lawExec}, issue: "#721", why: "entity: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "entity WorkflowCommons.UserTaskView": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "entity: executes since a Studio Pro index keeps its GUID on CREATE OR MODIFY (mendixlabs/mxcli#1470); re-executing adds a fourth access rule (the XPath-scoped grant is not matched to its stored rule) and the documentation's trailing whitespace drifts — not yet triaged into #721's classes"}, + "entity WorkflowCommons.WorkflowAttachment": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "entity: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "entity WorkflowCommons.WorkflowComment": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "entity: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "entity WorkflowCommons.WorkflowGroupView": {laws: []law{lawParse}, issue: "#721", why: "entity: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "entity WorkflowCommons.WorkflowUserTaskView": {laws: []law{lawExec}, issue: "#721", why: "entity: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "entity WorkflowCommons.WorkflowView": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "entity: executes since a Studio Pro index keeps its GUID on CREATE OR MODIFY (mendixlabs/mxcli#1470); re-executing adds a fourth access rule (the XPath-scoped grant is not matched to its stored rule) and the documentation's trailing whitespace drifts — not yet triaged into #721's classes"}, + "enumeration WorkflowCommons.Enum_NotificationArea_RenderAs": {laws: []law{lawGetPut}, issue: "#721", why: "enumeration: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "export mapping Services.OrdersExportMapping": {laws: []law{lawGetPut}, issue: "#721", why: "export mapping: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.WorkflowAttachment_View": {laws: []law{lawExec}, issue: "#721", why: "page: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.WorkflowDefinition_View": {laws: []law{lawExec}, issue: "#721", why: "page: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.WorkflowSubProcess_View": {laws: []law{lawExec}, issue: "#721", why: "page: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "rest client Clients.OrdersRestClient": {laws: []law{lawParse}, issue: "#721", why: "rest client: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "rule Rules.Rule1": {laws: []law{lawGetPut}, issue: "#721", why: "rule: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "rule Rules.Rule2": {laws: []law{lawGetPut}, issue: "#721", why: "rule: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "scheduled event WorkflowCommons.SE_WorkflowAuditTrailRecord_CleanUp": {laws: []law{lawGetPut}, issue: "#721", why: "scheduled event: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_Assignee_Search": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes; executes since a widget bound to a snippet parameter is described as $Param.Attr — getput not yet triaged"}, + "snippet WorkflowCommons.Snip_TaskAssignmentHelper_Assignee_Search": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes; executes since a widget bound to a snippet parameter is described as $Param.Attr — getput not yet triaged"}, + "snippet WorkflowCommons.Snip_TaskAssignmentHelper_TargetUser_Search": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes; executes since a widget bound to a snippet parameter is described as $Param.Attr — getput not yet triaged"}, + "snippet WorkflowCommons.Snip_TimelineViewer_Timeline_Full": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_TimelineViewer_Timeline_Tasks": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_UserTask_NameColumn": {laws: []law{lawGetPut, lawPutGet}, issue: "#721 #826", why: "executes since describe prints snippet call Params (#826); an association-path template parameter read from a snippet parameter loses its AttributeRef and SourceVariable (#721)"}, + "snippet WorkflowCommons.Snip_WorkflowActivityRecord_ActivityIcon": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_WorkflowActivityRecord_Details": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes; executes since a widget bound to a snippet parameter is described as $Param.Attr — getput not yet triaged"}, + "snippet WorkflowCommons.Snip_WorkflowActivityRecord_Header": {laws: []law{lawGetPut, lawPutGet}, issue: "#721 #826", why: "executes since describe prints snippet call Params (#826); association-path template parameters and an expression parameter drift (#721)"}, + "snippet WorkflowCommons.Snip_WorkflowActivity_Details_ActivityType": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_WorkflowSubProcess_Details": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_WorkflowSubProcess_IncompatibleWarning": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_WorkflowSubProcess_State": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_WorkflowUserTaskView_Details": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_WorkflowUserTaskView_NameColumn": {laws: []law{lawGetPut, lawPutGet}, issue: "#721 #826", why: "executes since describe prints snippet call Params (#826); an association-path template parameter read from a snippet parameter loses its AttributeRef and SourceVariable (#721)"}, + "snippet WorkflowCommons.Snip_Workflow_CommentsAndAttachments": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_Workflow_CommentsAndAttachments_Admin": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_Workflow_Header": {laws: []law{lawGetPut, lawPutGet}, issue: "#721 #826", why: "executes since describe prints snippet call Params (#826); an association-path caption parameter read from a snippet parameter loses its AttributeRef and SourceVariable (#721)"}, + "snippet WorkflowCommons.Snip_Workflow_SubProcessDetails": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, } // createOrModifyProbeKnownFailures is the allowlist of diff --git a/mdl/visitor/visitor_action_settings.go b/mdl/visitor/visitor_action_settings.go index bdfa04853..9dec216c3 100644 --- a/mdl/visitor/visitor_action_settings.go +++ b/mdl/visitor/visitor_action_settings.go @@ -5,6 +5,7 @@ package visitor import ( "fmt" "slices" + "strconv" "strings" "github.com/mendixlabs/mxcli/mdl/ast" @@ -45,6 +46,8 @@ var actionSettingKeys = map[string]struct { "closepage": {canonical: "ClosePage", kinds: []string{"completeTask", "callWorkflow"}, what: "`complete task` and `call workflow`"}, "commit": {canonical: "Commit", kinds: []string{"completeTask"}, what: "`complete task`"}, "defaultpage": {canonical: "DefaultPage", kinds: []string{"openWorkflow"}, what: "`open workflow`"}, + // Show page's "number of pages to close" (ako/mxcli#2106). + "numberofpagestoclose": {canonical: "NumberOfPagesToClose", kinds: []string{"showPage"}, what: "`show page`"}, } var ( @@ -71,6 +74,8 @@ func actionSettingsKind(act *parser.ActionExprV3Context) string { return "openWorkflow" case act.CALL() != nil && act.WORKFLOW() != nil: return "callWorkflow" + case (act.SHOW() != nil && act.PAGE() != nil) || act.SHOW_PAGE() != nil: + return "showPage" } return "other" } @@ -91,6 +96,9 @@ func actionSettingValue(s *parser.ActionSettingV3Context) (string, bool) { if str := s.STRING_LITERAL(); str != nil { return unquoteStringLit(str), true } + if n := s.NUMBER_LITERAL(); n != nil { + return n.GetText(), false + } if n := s.NONE(); n != nil { return n.GetText(), false } @@ -168,6 +176,10 @@ func buildActionSettingsV3(ctx parser.IActionSettingsV3Context) *ast.ActionSetti if !isString { out.DefaultPage = val } + case "numberofpagestoclose": + if n, err := strconv.Atoi(val); !isString && err == nil && n >= 1 { + out.NumberOfPagesToClose = val + } } } return out @@ -213,7 +225,8 @@ func (b *Builder) ExitActionSettingsV3(ctx *parser.ActionSettingsV3Context) { "Confirmation, ProceedCaption and CancelCaption, a microflow call also "+ "Asynchronous and FormValidations, `call workflow` Confirmation and ClosePage, "+ "`complete task` ClosePage and Commit, `open user task` AssignOnOpen and "+ - "OpenWhenAssigned, and `open workflow` DefaultPage", pos, written)) + "OpenWhenAssigned, `open workflow` DefaultPage, and `show page` "+ + "NumberOfPagesToClose", pos, written)) continue } if seen[key] { @@ -258,6 +271,10 @@ func (b *Builder) ExitActionSettingsV3(ctx *parser.ActionSettingsV3Context) { if _, ok := canonicalFormValidations[strings.ToLower(val)]; isString || !ok { b.addError(fmt.Errorf("%s: FormValidations takes All, Widget or None, not %s", pos, valText)) } + case "numberofpagestoclose": + if n, err := strconv.Atoi(val); isString || err != nil || n < 1 { + b.addError(fmt.Errorf("%s: NumberOfPagesToClose takes a whole number of at least 1, not %s", pos, valText)) + } default: // the text settings if !isString { b.addError(fmt.Errorf("%s: %s takes a string, not %s", pos, spec.canonical, valText)) diff --git a/mdl/visitor/visitor_show_page_close_2106_test.go b/mdl/visitor/visitor_show_page_close_2106_test.go new file mode 100644 index 000000000..44e32bc9e --- /dev/null +++ b/mdl/visitor/visitor_show_page_close_2106_test.go @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "strings" + "testing" +) + +// ako/mxcli#2106: a show page action's "number of pages to close" +// (Forms$FormAction.NumberOfPagesToClose2) had no spelling, so describe → exec +// reset SnowflakeRESTSQL.ConnectionDetails_NewEdit_Step2's buttons from closing +// one page to closing none. +func TestActionSettings_ShowPageNumberOfPagesToClose(t *testing.T) { + a, errs := buttonAction(t, "show page M.Next(Order = $currentObject) with (NumberOfPagesToClose: 1)") + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + if a.Settings == nil || a.Settings.NumberOfPagesToClose != "1" { + t.Fatalf("settings = %+v, want NumberOfPagesToClose 1", a.Settings) + } +} + +// It is the show page action's setting only, and a whole number of at least one. +func TestActionSettings_NumberOfPagesToCloseRefusals(t *testing.T) { + for _, action := range []string{ + "call microflow M.Go() with (NumberOfPagesToClose: 1)", + "show page M.Next with (NumberOfPagesToClose: 0)", + "show page M.Next with (NumberOfPagesToClose: 'x')", + } { + _, errs := buttonAction(t, action) + if len(errs) == 0 { + t.Errorf("%s: accepted, want a refusal", action) + continue + } + if !strings.Contains(errs[0].Error(), "NumberOfPagesToClose") { + t.Errorf("%s: refusal %q does not name the setting", action, errs[0]) + } + } +} diff --git a/modelsdk/canon/unexpressed.go b/modelsdk/canon/unexpressed.go index 837877627..beea58893 100644 --- a/modelsdk/canon/unexpressed.go +++ b/modelsdk/canon/unexpressed.go @@ -3,6 +3,7 @@ package canon import ( + "fmt" "strings" "go.mongodb.org/mongo-driver/v2/bson" @@ -21,6 +22,15 @@ import ( // Pro stores a widget's unset screen-reader label that way; the writer // writes null. Both mean "no label". // +// and, measured on FactoryManagement's pages (ako/mxcli#2106): +// +// - A DynamicClasses expression or an XPathConstraint that differs only in +// whitespace outside its string literals. Studio Pro keeps the author's +// line breaks and spacing; MDL prints and rebuilds them normalised. +// - An enumeration condition's values in another order, each with the same +// flag. The writer lists them in enumeration order, Studio Pro in the +// order they were added. +// // Only elements whose $ID the stored document already has take anything, which // after TransplantIDs means the elements that correspond; a real change — other // expression text, a label with text — is not touched and still lands. @@ -55,10 +65,19 @@ func CarryUnexpressed(contents, stored []byte) []byte { switch v := e.Value.(type) { case string: s, isStr := ov.(string) - if e.Key == "Expression" && isStr && s != v && strings.TrimSpace(s) == strings.TrimSpace(v) { + if isStr && s != v && whitespaceInsensitive[e.Key] && withoutWhitespace(s) == withoutWhitespace(v) { d[i].Value = s changed = true } + case bson.A: + stored, isList := ov.(bson.A) + if !isList { + continue + } + if e.Key == "Conditions" && sameConditionsReordered(v, stored) && !anyIDUsedOutside(stored, used, v) { + d[i].Value = stored + changed = true + } case nil: if t, isDoc := ov.(bson.D); isDoc && isEmptyClientTemplate(t) && !anyIDUsed(t, used) { d[i].Value = t @@ -128,3 +147,79 @@ func anyIDUsed(d bson.D, used map[string]bool) bool { }) return clash } + +// whitespaceInsensitive names the expression-valued properties whose stored +// whitespace MDL does not reproduce. +var whitespaceInsensitive = map[string]bool{"Expression": true, "DynamicClasses": true, "XPathConstraint": true} + +// withoutWhitespace drops whitespace outside single-quoted string literals, so +// two spellings of one expression compare equal while a changed literal does +// not. A doubled quote inside a literal closes and reopens it, which leaves +// the literal's text intact. +func withoutWhitespace(expr string) string { + var b strings.Builder + inLiteral := false + for _, r := range expr { + if r == '\'' { + inLiteral = !inLiteral + } + if !inLiteral && (r == ' ' || r == '\t' || r == '\n' || r == '\r') { + continue + } + b.WriteRune(r) + } + return b.String() +} + +// sameConditionsReordered reports whether two Enumerations$Condition lists hold +// the same values with the same flags in a different order. +func sameConditionsReordered(written, stored bson.A) bool { + key := func(a bson.A) ([]string, bool) { + var out []string + for _, item := range a[min(1, len(a)):] { + c, ok := item.(bson.D) + if !ok || docLookup(c, "$Type") != "Enumerations$Condition" { + return nil, false + } + out = append(out, fmt.Sprint(docLookup(c, "AttributeValue"), "|", docLookup(c, "EditableVisible"))) + } + return out, true + } + w, okW := key(written) + s, okS := key(stored) + if !okW || !okS || len(w) != len(s) || len(w) == 0 || strings.Join(w, ",") == strings.Join(s, ",") { + return false + } + count := map[string]int{} + for _, k := range w { + count[k]++ + } + for _, k := range s { + count[k]-- + } + for _, n := range count { + if n != 0 { + return false + } + } + return true +} + +// anyIDUsedOutside reports whether an element id inside items is in the +// document anywhere other than inside replaced, the list items take the place +// of: carrying it in would make two elements share an identity. +func anyIDUsedOutside(items bson.A, used map[string]bool, replaced bson.A) bool { + own := map[string]bool{} + walkElements(replaced, func(e bson.D) { + if id, ok := docLookup(e, "$ID").(bson.Binary); ok { + own[string(id.Data)] = true + } + }) + clash := false + walkElements(items, func(e bson.D) { + if id, ok := docLookup(e, "$ID").(bson.Binary); ok && used[string(id.Data)] && !own[string(id.Data)] { + clash = true + } + }) + return clash +} diff --git a/modelsdk/canon/unexpressed_2106_test.go b/modelsdk/canon/unexpressed_2106_test.go new file mode 100644 index 000000000..a60180c5b --- /dev/null +++ b/modelsdk/canon/unexpressed_2106_test.go @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 + +package canon + +import ( + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// ako/mxcli#2106: values describe → exec of FactoryManagement's pages rewrote +// although nothing changed, measured with a value-only diff of each page's +// unit before and after the first exec (Mendix 11.16). + +func pageWithElement(el bson.D) []byte { + b, _ := bson.Marshal(bson.D{{Key: "$ID", Value: id16(0xAA)}, {Key: "$Type", Value: "Forms$Page"}, {Key: "Widgets", Value: bson.A{int32(2), el}}}) + return b +} + +func appearance(dynamicClasses string) []byte { + return pageWithElement(bson.D{{Key: "$ID", Value: id16(0x10)}, {Key: "$Type", Value: "Forms$Appearance"}, {Key: "DynamicClasses", Value: dynamicClasses}}) +} + +func xpathSource(constraint string) []byte { + return pageWithElement(bson.D{{Key: "$ID", Value: id16(0x10)}, {Key: "$Type", Value: "Forms$ListViewXPathSource"}, {Key: "XPathConstraint", Value: constraint}}) +} + +func assertElided(t *testing.T, what string, written, stored []byte) { + t.Helper() + if eq, _ := Equal(written, stored); eq { + t.Fatalf("%s: control: the documents should differ before the carries", what) + } + if _, unchanged := Reconcile(written, stored); !unchanged { + t.Errorf("%s: a round trip that changed nothing was not elided", what) + } +} + +func assertLands(t *testing.T, what string, written, stored []byte) { + t.Helper() + if _, unchanged := Reconcile(written, stored); unchanged { + t.Errorf("%s: a real change was elided", what) + } +} + +// DynamicClasses is an expression: MDL writes it bare, so a stored trailing +// newline (DigitalTwin.TechnicianTicket_Overview, 3 pages) was dropped. +func TestUnexpressed_DynamicClassesWhitespace(t *testing.T) { + stored := "if $currentObject/Status = M.S.A\nthen 'badge-danger'\nelse 'badge-info'\n\n" + assertElided(t, "trailing newlines", appearance("if $currentObject/Status = M.S.A\nthen 'badge-danger'\nelse 'badge-info'"), appearance(stored)) + assertLands(t, "another class", appearance("if $currentObject/Status = M.S.A\nthen 'badge-warning'\nelse 'badge-info'"), appearance(stored)) +} + +// An XPath constraint with a newline between predicates and no space before +// `=` came back reformatted (AgentCommons.TestCase_Overview, Version_Overview). +func TestUnexpressed_XPathWhitespace(t *testing.T) { + stored := "[AgentCommons.TestCase_Agent= $currentObject]\n[not(IsDraftTestCase)]" + assertElided(t, "reformatted", xpathSource("[AgentCommons.TestCase_Agent = $currentObject][not(IsDraftTestCase)]"), xpathSource(stored)) + assertLands(t, "a predicate removed", xpathSource("[AgentCommons.TestCase_Agent = $currentObject]"), xpathSource(stored)) + // Whitespace inside a string literal is part of the value. + assertLands(t, "a literal changed", xpathSource("[Name = 'ab']"), xpathSource("[Name = 'a b']")) +} + +func condition(id byte, value string, visible bool) bson.D { + return bson.D{{Key: "$ID", Value: id16(id)}, {Key: "$Type", Value: "Enumerations$Condition"}, {Key: "AttributeValue", Value: value}, {Key: "EditableVisible", Value: visible}} +} + +func visibility(conds ...bson.D) []byte { + a := bson.A{int32(2)} + for _, c := range conds { + a = append(a, c) + } + return pageWithElement(bson.D{{Key: "$ID", Value: id16(0x10)}, {Key: "$Type", Value: "Forms$ConditionalVisibilitySettings"}, + {Key: "Attribute", Value: "M.E.Kind"}, {Key: "Conditions", Value: a}}) +} + +// The writer lists an enumeration condition's values in enumeration order; +// Studio Pro keeps the order they were added in +// (FactoryManagement.ConversationalUI_PopUp_Custom). Each value keeps its flag, +// so only the order differs. +func TestUnexpressed_ConditionOrder(t *testing.T) { + stored := visibility(condition(0x20, "AWS", false), condition(0x21, "(empty)", false), condition(0x22, "GraphStudio", true)) + // The rebuild takes the stored ids by position, as TransplantIDs pairs a list. + written := visibility(condition(0x20, "AWS", false), condition(0x21, "GraphStudio", true), condition(0x22, "(empty)", false)) + assertElided(t, "reordered", written, stored) + // Control: a flag moved to another value is a different condition. + assertLands(t, "flag moved", visibility(condition(0x20, "AWS", true), condition(0x21, "GraphStudio", false), condition(0x22, "(empty)", false)), stored) +} diff --git a/sdk/pages/pages_widgets_action.go b/sdk/pages/pages_widgets_action.go index da2dc37a6..c61f79832 100644 --- a/sdk/pages/pages_widgets_action.go +++ b/sdk/pages/pages_widgets_action.go @@ -229,6 +229,9 @@ type PageClientAction struct { PageName string `json:"pageName,omitempty"` // Qualified name for by-name reference PageSettings *PageSettings `json:"pageSettings,omitempty"` ParameterMappings []*PageClientParameterMapping `json:"parameterMappings,omitempty"` + // NumberOfPagesToClose is Forms$FormAction.NumberOfPagesToClose2: how many + // pages to close as the page opens, "" for none (ako/mxcli#2106). + NumberOfPagesToClose string `json:"numberOfPagesToClose,omitempty"` } func (PageClientAction) isClientAction() {} diff --git a/sdk/pages/pages_widgets_input.go b/sdk/pages/pages_widgets_input.go index ae186a7ed..7ccef6179 100644 --- a/sdk/pages/pages_widgets_input.go +++ b/sdk/pages/pages_widgets_input.go @@ -237,6 +237,9 @@ type CheckBox struct { OnChangeAction ClientAction `json:"onChangeAction,omitempty"` OnEnterAction ClientAction `json:"onEnterAction,omitempty"` // as on TextBox OnLeaveAction ClientAction `json:"onLeaveAction,omitempty"` // as on TextBox + // LabelPosition is Forms$CheckBox.LabelPosition: Default, BeforeControl or + // AfterControl. Empty keeps the stored default (ako/mxcli#2106). + LabelPosition string `json:"labelPosition,omitempty"` } // RadioButtons represents a radio button group widget.