diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-navigation-targets-not-resolved-by-check-or-exec.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-navigation-targets-not-resolved-by-check-or-exec.json new file mode 100644 index 000000000..71a4583a7 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-17-navigation-targets-not-resolved-by-check-or-exec.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-17", "symptom": "`create or modify navigation` (or `create or replace`, deprecated or `mdl 1` spelling) naming a home page, role home, login page, not-found page or a menu item's page/microflow/nanoflow that does not exist passed `check -p` (\"Check passed!\") and `exec` (\"Navigation profile 'Responsive' updated.\"); only `mx check` objected: CE1613 \"The selected page 'Shop.NoSuchHome' no longer exists.\" at Navigation profile, and one per bad menu item. A page's own show page / call microflow and an unknown icon were refused. Same for `create menu`.", "cause": "The reference-resolution passes were added one reference kind at a time (validateWidgetReferences for widget trees, validateIconRefs, validateNavigationRoles for the role only), so navigation targets were in none of them. The exec handler built each menu action with a pageBuilder whose tolerateDanglingRefs is true for menus (so a target stored by name never failed), and read home/login/not-found pages straight into the spec; CREATE OR REPLACE NAVIGATION rewrites the whole profile.", "file": "`mdl/executor/validate_navigation_targets.go` (`navigationTargets`, `unresolvedNavigationTargets`, `validateNavigationTargets` called from `validateProgramWithWarnings`; `validateNavigationTargetsForExec` called first in `execAlterNavigation` and `execCreateMenu`); forward references via `navigationDefRefs` in `eagerDefRefs` and `Executor.enterScriptFlows` (`scriptTargets`)", "insight": "A reference pass written per kind leaves every kind it does not list silently unchecked, and the escape is silent both ways (check passes, exec writes, the build reports it one build later) — when adding a statement that names documents, grep every place a page/microflow name is resolved and ask which statements are NOT in the list. Navigation targets are written by a statement that replaces the whole profile, so the refusal has to come before the write, in the handler as well as in the pre-flight (`exec --no-check` skips the pre-flight). Reuse the same lookups (buildPageQualifiedNames and friends). A target stored by name and created further down the script must follow the flow-call rule (forwardFlowCalls, #1405) rather than statement order: a first version that made it an MDL-ORDER01 error broke a working script shape (navigation written before its pages: upstream execs it and mx check reports 0 errors) — measure what upstream does with the other order before enforcing one. Measured on a Mendix 11.12.5 app: 4 CE1613 before for the reported script, 0 errors after a valid navigation; an empty listing from the backend is silence, not a finding (as in validateForwardDefRefs)", "refs": ["mendixlabs/mxcli#1569"]} diff --git a/.claude/skills/mendix/check-syntax/reference/references-resolved.md b/.claude/skills/mendix/check-syntax/reference/references-resolved.md index e454edd39..fcbd25ef1 100644 --- a/.claude/skills/mendix/check-syntax/reference/references-resolved.md +++ b/.claude/skills/mendix/check-syntax/reference/references-resolved.md @@ -10,7 +10,13 @@ Beyond the page, microflow, entity and icon names a statement names directly, - an access rule's `where […]` members; - an entity event handler's microflow; - `Module.Enum.Key` in an expression; -- a view entity's FROM / JOIN entities. +- a view entity's FROM / JOIN entities; +- the pages and flows a navigation profile or a menu document names: the home + page (and one per user role), a home flow, the login and not-found pages, and + every menu item's page, microflow or nanoflow at any depth. The target must + exist, or be created by the script (further down only in a run that rolls + back, as for a flow call); `exec` refuses the same statement + before it rewrites the profile or menu. ## The project's Mendix version diff --git a/CHANGELOG.md b/CHANGELOG.md index 0798c1db7..5e7a85fcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed + +- **`check -p` and `exec` refuse a navigation profile or menu document that names a page or flow that does not exist** (mendixlabs/mxcli#1569) — `create or modify navigation` with a home page, role home, login page, not-found page or menu item (at any depth, in both the deprecated and the `mdl 1` spelling) pointing at a missing page, microflow or nanoflow passed `check -p` ("Check passed!") and `exec` ("Navigation profile 'Responsive' updated."), and only `mx check` objected: CE1613 "The selected page 'Shop.NoSuchHome' no longer exists.", once for the home page and once per menu item. A page's own `show page` and `call microflow` and an icon name were already resolved; a navigation statement was not, and it rewrites the whole profile. `check -p` now reports each unresolved target with the profile or menu document, the menu item path (`menu item 'Admin' > 'Users'`) and the missing name, and `exec` refuses the statement before it writes anything. A target the script creates resolves, wherever it stands in a run that rolls back (plain `exec`, `check`), as a call to a flow created further down does (mendixlabs/mxcli#1405); `exec --continue-on-error`, the REPL and `-c` refuse one created further down. A standalone `create menu` document is covered, and one stored as excluded only warns, as an excluded page does. + ## [0.27.0] - 2026-10-15 Headline: **`exec` is all or nothing: when a statement fails at write time, the whole run is rolled back, so a script never leaves a project half-changed.** Alongside it: a wave of `describe` → `check` → `exec` fixes from round-trip runs over real apps (the Siemens FSM template, the GenAI showcase, Marketplace modules), Mendix 9 projects can take microflows, nanoflows and pluggable widgets again, `check -p` resolves many more of the references `exec` and the build would refuse, the classic `filemanager` widget and role-based `Visible: roles (…)` are in MDL, and the companion draws a module's domain model. The MongoDB driver is upgraded for the October 2026 BSON advisories. diff --git a/mdl-examples/bug-tests/1569-navigation-target-not-found.mdl b/mdl-examples/bug-tests/1569-navigation-target-not-found.mdl new file mode 100644 index 000000000..7aa80de34 --- /dev/null +++ b/mdl-examples/bug-tests/1569-navigation-target-not-found.mdl @@ -0,0 +1,57 @@ +mdl 1; +-- A navigation profile and a menu document name pages and microflows +-- (mendixlabs/mxcli#1569). +-- +-- A navigation statement points at documents in six places: the home page +-- (also one per user role), a home flow, the login page, the not-found page and +-- every menu item at any depth. None of them was resolved: `check -p` said +-- "Check passed!", `exec` wrote the profile, and `mx check` was the first to +-- object: +-- +-- [CE1613] "The selected page 'Shop.NoSuchHome' no longer exists." +-- +-- once for the home page and once per menu item with a missing page or flow. +-- +-- This file is the shape that must PASS: every target exists by the time it is +-- needed, or is created by this script (a navigation written BEFORE the page it +-- names is accepted too, as a call to a flow created further down is: the target +-- is stored by name and a run that rolls back leaves nothing dangling). A target +-- that exists nowhere is only visible with a project, and `make check-mdl` runs +-- without one, so the refused cases are Go tests: +-- mdl/executor/validate_navigation_targets_test.go. + +create module Zz2; + +create page Zz2.Landing ( + Title: 'Landing', + Layout: Atlas_Core.Atlas_Default +) { + dynamictext heading (Content: 'Landing', RenderMode: H1) +}; + +create microflow Zz2.ACT_Open () +begin + log info 'open'; +end; + +-- Created further down than the navigation that opens it. +create or modify navigation Responsive + home page Zz2.Landing +{ + menu item 'Landing' ( OnClick: show page Zz2.Landing, Icon: glyph 57377 ) + menu 'Tools' ( Icon: glyph 57377 ) { + menu item 'Open' ( OnClick: call microflow Zz2.ACT_Open, Icon: glyph 57377 ) + menu item 'Later' ( OnClick: show page Zz2.Later, Icon: glyph 57377 ) + } +}; + +create page Zz2.Later ( + Title: 'Later', + Layout: Atlas_Core.Atlas_Default +) { + dynamictext heading (Content: 'Later', RenderMode: H1) +}; + +create or modify menu Zz2.Main_Menu { + menu item 'Landing' ( OnClick: show page Zz2.Landing, Icon: glyph 57377 ) +}; diff --git a/mdl/executor/cmd_menus.go b/mdl/executor/cmd_menus.go index a6b912ff6..78db228a3 100644 --- a/mdl/executor/cmd_menus.go +++ b/mdl/executor/cmd_menus.go @@ -70,6 +70,15 @@ func execCreateMenu(ctx *ExecContext, s *ast.CreateMenuStmt) error { return mdlerrors.NewAlreadyExists("menu", s.Name.String()) } + // Resolve the pages and flows its items name before anything is written, a + // folder included. An excluded document is not validated by Mendix and keeps + // its dangling targets, as an excluded page does. + if existing == nil || !existing.Excluded { + if err := validateNavigationTargetsForExec(ctx, s); err != nil { + return err + } + } + var existingContainer model.ID if existing != nil { existingContainer = existing.ContainerID diff --git a/mdl/executor/cmd_navigation.go b/mdl/executor/cmd_navigation.go index d41bf366d..ecf455ecf 100644 --- a/mdl/executor/cmd_navigation.go +++ b/mdl/executor/cmd_navigation.go @@ -31,6 +31,13 @@ func execAlterNavigation(ctx *ExecContext, s *ast.AlterNavigationStmt) error { return err } + // Resolve the pages and flows it names, for the same reason: this statement + // replaces the whole profile, so a target that does not exist would cost the + // stored menu and be reported only by the next build (CE1613). + if err := validateNavigationTargetsForExec(ctx, s); err != nil { + return err + } + nav, err := ctx.Backend.GetNavigation() if err != nil { return mdlerrors.NewBackend("get navigation", err) diff --git a/mdl/executor/exec_context.go b/mdl/executor/exec_context.go index 65ed39287..c813e1e3c 100644 --- a/mdl/executor/exec_context.go +++ b/mdl/executor/exec_context.go @@ -64,6 +64,12 @@ type ExecContext struct { // flow the script creates further down is accepted (mendixlabs/mxcli#1405). forwardFlowCalls bool + // scriptTargets are the pages, microflows and nanoflows the program being + // run declares, set under the same all-or-nothing run as scriptFlows + // (Executor.enterScriptFlows). A navigation target is stored by name, so one + // the script creates further down resolves there, as a flow call does. + scriptTargets map[defKey]bool + // Format controls output formatting (table, json, etc.). Format OutputFormat diff --git a/mdl/executor/executor.go b/mdl/executor/executor.go index f7e913792..f5c6c6bc4 100644 --- a/mdl/executor/executor.go +++ b/mdl/executor/executor.go @@ -261,6 +261,7 @@ type Executor struct { // scriptFlows then holds every flow the program being run declares. forwardFlowCalls bool scriptFlows map[string]*selfFlow + scriptTargets map[defKey]bool // pages and flows the program declares, same run settings map[string]any cache *executorCache catalog *catalog.Catalog @@ -539,11 +540,15 @@ func (e *Executor) enterScriptFlows(prog *ast.Program) func() { if !e.forwardFlowCalls { return func() {} } - prev := e.scriptFlows + prev, prevTargets := e.scriptFlows, e.scriptTargets flows := make(map[string]*selfFlow, len(prev)) for k, v := range prev { flows[k] = v } + targets := make(map[defKey]bool, len(prevTargets)) + for k, v := range prevTargets { + targets[k] = v + } for _, stmt := range prog.Statements { switch s := stmt.(type) { case *ast.CreateMicroflowStmt: @@ -551,9 +556,16 @@ func (e *Executor) enterScriptFlows(prog *ast.Program) func() { case *ast.CreateNanoflowStmt: declareScriptFlow(flows, true, s.Name, s.ReturnType) } + // What a navigation target may name, wherever the declaration stands. + if key, _ := definedBy(stmt); key.name != "" { + switch key.kind { + case defPage, defMicroflow, defNanoflow: + targets[key] = true + } + } } - e.scriptFlows = flows - return func() { e.scriptFlows = prev } + e.scriptFlows, e.scriptTargets = flows, targets + return func() { e.scriptFlows, e.scriptTargets = prev, prevTargets } } // enterLanguage runs the following statements under a program's language diff --git a/mdl/executor/executor_dispatch.go b/mdl/executor/executor_dispatch.go index bed4aee28..8b05cd15d 100644 --- a/mdl/executor/executor_dispatch.go +++ b/mdl/executor/executor_dispatch.go @@ -105,6 +105,7 @@ func (e *Executor) newExecContext(ctx context.Context) *ExecContext { LanguageVersion: e.langVersion, forwardFlowCalls: e.forwardFlowCalls, scriptFlows: e.scriptFlows, + scriptTargets: e.scriptTargets, describeLang: e.describeLang, SqlMgr: e.sqlMgr, ThemeRegistry: e.themeRegistry, diff --git a/mdl/executor/validate.go b/mdl/executor/validate.go index 42cd7fe3e..7a281f40e 100644 --- a/mdl/executor/validate.go +++ b/mdl/executor/validate.go @@ -450,6 +450,11 @@ func validateProgramWithWarnings(ctx *ExecContext, prog *ast.Program) ([]error, // worse than the two above: a module-qualified role here makes the project // unloadable rather than merely failing the build (mendixlabs/mxcli#1001). errors = append(errors, validateNavigationRoles(ctx, prog)...) + // Resolve the pages and flows a navigation profile or menu document names: + // home, login and not-found pages and every menu item. Written by name and + // unchecked, so a typo was CE1613 at build time, after the whole menu had + // been rewritten. + errors = append(errors, validateNavigationTargets(ctx, prog, sc)...) // Resolve what a GRANT / REVOKE, a user role and a demo user name: the // entity, document or member, and the module and user roles. exec refuses // the grants after the statements before them are written, and writes the diff --git a/mdl/executor/validate_definition_order.go b/mdl/executor/validate_definition_order.go index 4636458b4..9c02a16bc 100644 --- a/mdl/executor/validate_definition_order.go +++ b/mdl/executor/validate_definition_order.go @@ -66,8 +66,20 @@ type defRef struct { // stored by name, so a run that rolls back on failure resolves it against // the whole script (mendixlabs/mxcli#1405); see flowCallRefs. flowCall bool + // byName marks a reference stored by name that is only resolved by looking + // it up when the statement runs: a navigation profile's or menu document's + // page, microflow or nanoflow. Like a flow call, one the script creates + // further down is accepted by a run that rolls back (forwardFlowCalls) and + // refused by one that cannot; unlike a call, it is not a call, so + // forwardByNameRefusal words the refusal. + byName bool } +// forwardByNameRefusal is why a run that cannot roll back refuses a navigation +// target the script creates further down. +const forwardByNameRefusal = "the target is stored by name and looked up when the statement runs, and this run cannot be rolled back (--continue-on-error, the REPL, -c), so a later failure would leave it dangling. " + + "Create the target first; plain `mxcli exec`, which rolls back, accepts the script as it is" + // flowCallRefs marks refs as flow-call targets. func flowCallRefs(refs []defRef) []defRef { for i := range refs { @@ -124,7 +136,7 @@ func ValidateScriptDefinitionOrder(prog *ast.Program) []linter.Violation { // Whether a flow call may run ahead of its callee depends on how // the script runs, which this tier cannot know: the project tier // (validateForwardDefRefs) and exec decide (mendixlabs/mxcli#1405). - if ref.flowCall { + if ref.flowCall || ref.byName { continue } kind, at, ok := laterPlainCreate(plain, created, ref) @@ -274,6 +286,11 @@ func eagerDefRefs(stmt ast.Statement) []defRef { return oneDefRef(s.Nanoflow, defNanoflow, "grant execute on nanoflow "+s.Nanoflow.String()) case *ast.GrantPageAccessStmt: return oneDefRef(s.Page, defPage, "grant view on page "+s.Page.String()) + + case *ast.AlterNavigationStmt, *ast.CreateMenuStmt: + // The home, login and not-found pages and every menu item's target, + // which the handler resolves before it writes the profile or menu. + return navigationDefRefs(stmt) } return nil } @@ -495,7 +512,7 @@ func validateForwardDefRefs(ctx *ExecContext, prog *ast.Program) []error { break } } - if resolved || (ref.flowCall && ctx.forwardFlowCalls) { + if resolved || ((ref.flowCall || ref.byName) && ctx.forwardFlowCalls) { continue } for _, k := range ref.kinds { @@ -509,6 +526,11 @@ func validateForwardDefRefs(ctx *ExecContext, prog *ast.Program) []error { i+1, ref.site, k, ref.name, at+1, forwardFlowCallRefusal)) break } + if ref.byName { + errs = append(errs, fmt.Errorf("statement %d: %s references %s %s before it is created (statement %d): %s", + i+1, ref.site, k, ref.name, at+1, forwardByNameRefusal)) + break + } if _, isPlain := plain[key]; isPlain { break // MDL-ORDER01 reports it, without a project } diff --git a/mdl/executor/validate_navigation_targets.go b/mdl/executor/validate_navigation_targets.go new file mode 100644 index 000000000..a0b04a81a --- /dev/null +++ b/mdl/executor/validate_navigation_targets.go @@ -0,0 +1,322 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Reference validation for the targets a navigation statement names. +// +// A navigation profile points at documents in six places: its home page (and +// one per user role), a home flow, the login page, the not-found page, and +// every menu item at any depth. A standalone menu document carries the same +// menu items. Nothing resolved any of them: the names were written to the model +// as they stood, `check -p` said "Check passed!", and the first sign of a typo +// was MxBuild, one build later: +// +// [CE1613] "The selected page 'Shop.NoSuchHome' no longer exists." +// +// Measured on a Mendix 11.12.5 app, one such error for the home page and one +// for each menu item whose page or microflow was missing. +// +// A reference-resolution pass added per reference kind leaves every kind it does +// not list silently unchecked, and this was one of them: a page's `show page` +// and `call microflow` actions and an icon name were refused, while the same +// action on a menu item was not. +// +// # Why exec refuses too, and before it writes +// +// CREATE OR REPLACE NAVIGATION rewrites the whole profile — home pages, login +// page, menu tree — so a target that does not exist replaces the stored menu +// with one the build rejects. The handler therefore runs the same check as +// `check -p` before it reads or writes anything (the menu builder stores a +// target by name and never fails on it), and `exec`'s own pre-flight refuses the +// script before any statement is written. `exec --no-check` skips the +// pre-flight, which is why the handler cannot rely on it. +// +// # Script-aware, forward references as for a flow call +// +// A page or flow the script creates BEFORE the navigation resolves. One it +// creates further down resolves too in a run that rolls back, which is what +// plain `mxcli exec` and `mxcli check` are: the target is stored by name, and a +// later failure undoes the whole run, so nothing is left dangling. That is the +// rule a call to a flow created further down already follows +// (mendixlabs/mxcli#1405, forwardFlowCalls), and it keeps a script that writes +// the navigation first working — upstream built and checked such a script clean +// (measured on 11.12.5, 0 errors). A run that cannot roll back +// (--continue-on-error, the REPL, -c) refuses it, from validateForwardDefRefs, +// because a later failure would leave the target dangling; the references are in +// eagerDefRefs for that pass. This pass resolves against the project plus what +// the script creates anywhere, so a forward name is reported once, there. +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" +) + +// Target kinds a navigation statement can name. They are the document kinds the +// definition-order passes use, so one list serves both. +const ( + navPage = defPage + navMicroflow = defMicroflow + navNanoflow = defNanoflow + navEntity = defEntity +) + +// navTarget is one reference a navigation statement or menu document makes. +type navTarget struct { + kind string // navPage, navMicroflow, navNanoflow or navEntity + name string // the qualified name as written + where string // where it sits, e.g. `menu item 'Admin' > 'Users'` +} + +// navigationTargets collects every reference a navigation statement or a menu +// document names, with where it was written. label is "navigation Responsive" or +// "menu Shop.Main_Menu"; ok is false for any other statement. +// +// native reports whether a navigation profile is a native one. It matters for +// exactly one clause: a native profile's `home microflow` is stored as the +// nanoflow it is (see checkProfileClauses), so the name is looked up as one. +func navigationTargets(stmt ast.Statement, native func(profile string) bool) (label string, out []navTarget, ok bool) { + switch s := stmt.(type) { + case *ast.AlterNavigationStmt: + label = "navigation " + s.ProfileName + for _, hp := range s.HomePages { + where, kind := "home page", navPage + switch { + case hp.IsNanoflow: + where, kind = "home nanoflow", navNanoflow + case !hp.IsPage: + where, kind = "home microflow", navMicroflow + if native != nil && native(s.ProfileName) { + kind = navNanoflow + } + } + if hp.ForRole != nil { + where += " for " + hp.ForRole.Name + } + out = append(out, navTarget{kind: kind, name: hp.Target.String(), where: where}) + } + if s.LoginPage != nil { + out = append(out, navTarget{kind: navPage, name: s.LoginPage.String(), where: "login page"}) + } + if s.NotFoundPage != nil { + out = append(out, navTarget{kind: navPage, name: s.NotFoundPage.String(), where: "not found page"}) + } + out = append(out, menuItemTargets(s.MenuItems, "")...) + return label, out, true + case *ast.CreateMenuStmt: + return "menu " + s.Name.String(), menuItemTargets(s.Items, ""), true + } + return "", nil, false +} + +// menuItemTargets walks a menu's items at any depth. path is the captions of the +// groups an item sits in, so a message names the item the way the author wrote +// it: `menu item 'Admin' > 'Users'`. +func menuItemTargets(items []ast.NavMenuItemDef, path string) []navTarget { + var out []navTarget + for _, it := range items { + where := "menu item " + path + "'" + it.Caption + "'" + add := func(kind, name string) { + if name = unquoteQualifiedName(strings.TrimSpace(name)); name != "" { + out = append(out, navTarget{kind: kind, name: name, where: where}) + } + } + // The item's action is the whole truth (every kind a menu item stores); + // Page and Microflow are the old clause spelling of the same two, set only + // for a hand-built AST or alongside Action. + if it.Action == nil { + if it.Page != nil { + add(navPage, it.Page.String()) + } + if it.Microflow != nil { + add(navMicroflow, it.Microflow.String()) + } + } + for a := it.Action; a != nil; a = a.ThenAction { + switch a.Type { + case "showPage": + add(navPage, a.Target) + case "microflow": + add(navMicroflow, a.Target) + case "nanoflow": + add(navNanoflow, a.Target) + case "create": + add(navEntity, a.Target) + } + } + out = append(out, menuItemTargets(it.Items, path+"'"+it.Caption+"' > ")...) + } + return out +} + +// navProfileIsNative reports whether the stored profile of that name is a native +// one. A project that cannot be read has no native profile as far as this goes. +func navProfileIsNative(ctx *ExecContext, profile string) bool { + nav, err := ctx.Backend.GetNavigation() + if err != nil || nav == nil { + return false + } + for _, p := range nav.Profiles { + if strings.EqualFold(p.Name, profile) { + return p.IsNative + } + } + return false +} + +// unresolvedNavigationTargets resolves the targets against the project and what +// the script creates (sc), and returns one line per target that resolves to +// nothing, in the order they were written. +// +// The lookups are the ones every other page, microflow, nanoflow and entity +// reference is resolved with (validateWidgetReferences), and so are the words: +// "page not found: Shop.X". sc may be empty; exec passes an empty one because by +// the time a statement runs, what the script created earlier is in the project. +// +// A listing that comes back EMPTY is a backend that could not answer, as often +// as a project with none, and reporting every reference on that basis is the +// false error validateForwardDefRefs also declines to make: that kind is skipped. +func unresolvedNavigationTargets(ctx *ExecContext, targets []navTarget, sc *scriptContext) []string { + if len(targets) == 0 || !ctx.Connected() { + return nil + } + if sc == nil { + sc = newScriptContext() + } + stored := map[string]map[string]bool{} + storedOf := func(kind string) map[string]bool { + if m, done := stored[kind]; done { + return m + } + var m map[string]bool + switch kind { + case navPage: + m = buildPageQualifiedNames(ctx) + case navMicroflow: + m = buildMicroflowQualifiedNames(ctx) + case navNanoflow: + m = buildNanoflowQualifiedNames(ctx) + case navEntity: + m = buildEntityQualifiedNames(ctx) + } + stored[kind] = m + return m + } + inScript := func(kind, name string) bool { + switch kind { + case navPage: + return sc.pages[name] + case navMicroflow: + return sc.microflows[name] + case navNanoflow: + return sc.nanoflows[name] + case navEntity: + return sc.entities[name] + } + return false + } + + var out []string + for _, t := range targets { + known := storedOf(t.kind) + if len(known) == 0 || known[t.name] || inScript(t.kind, t.name) { + continue + } + out = append(out, fmt.Sprintf("%s: %s not found: %s", t.where, t.kind, t.name)) + } + return out +} + +// navigationTargetError is the one error a statement's unresolved targets make. +func navigationTargetError(label string, unresolved []string) error { + return mdlerrors.NewValidationf("%s has reference errors:\n - %s", label, strings.Join(unresolved, "\n - ")) +} + +// validateNavigationTargets resolves the navigation targets of every statement +// in the program (`check -p`, and exec's pre-flight). A menu document that is +// stored as excluded keeps its dangling targets and reports them as warnings: +// Mendix does not validate an excluded document. +func validateNavigationTargets(ctx *ExecContext, prog *ast.Program, sc *scriptContext) []error { + if !ctx.Connected() { + return nil + } + native := func(profile string) bool { return navProfileIsNative(ctx, profile) } + + var errs []error + for i, stmt := range prog.Statements { + label, targets, ok := navigationTargets(stmt, native) + if !ok { + continue + } + unresolved := unresolvedNavigationTargets(ctx, targets, sc) + if len(unresolved) == 0 { + continue + } + if m, isMenu := stmt.(*ast.CreateMenuStmt); isMenu && storedMenuExcluded(ctx, m.Name) { + sc.warnExcluded("menu", m.Name.String(), unresolved) + continue + } + errs = append(errs, fmt.Errorf("statement %d: %w", i+1, navigationTargetError(label, unresolved))) + } + return errs +} + +// validateNavigationTargetsForExec is the same check when the statement runs. +// The project holds everything the script created before it. In a run that +// rolls back (forwardFlowCalls) a target the script creates further down counts +// too, as a flow call's callee does (mendixlabs/mxcli#1405): it is stored by +// name, and a later failure undoes the whole run, so nothing dangles. +func validateNavigationTargetsForExec(ctx *ExecContext, stmt ast.Statement) error { + label, targets, ok := navigationTargets(stmt, func(profile string) bool { return navProfileIsNative(ctx, profile) }) + if !ok { + return nil + } + var sc *scriptContext + if ctx.forwardFlowCalls && len(ctx.scriptTargets) > 0 { + sc = newScriptContext() + for key := range ctx.scriptTargets { + switch key.kind { + case defPage: + sc.pages[key.name] = true + case defMicroflow: + sc.microflows[key.name] = true + case defNanoflow: + sc.nanoflows[key.name] = true + } + } + } + if unresolved := unresolvedNavigationTargets(ctx, targets, sc); len(unresolved) > 0 { + return navigationTargetError(label, unresolved) + } + return nil +} + +// storedMenuExcluded reports whether the project already holds the menu +// document as excluded, which exec keeps on a rewrite. +func storedMenuExcluded(ctx *ExecContext, name ast.QualifiedName) bool { + md, err := ctx.Backend.GetMenuDocumentByQualifiedName(name.Module, name.Name) + return err == nil && md != nil && md.Excluded +} + +// navigationDefRefs are the references a navigation statement or a menu document +// looks up when it runs, for the definition-order passes. A page or flow is +// stored by name (byName), so a run that rolls back accepts one created further +// down; a create-object action's entity is resolved by the page builder and is +// not. +func navigationDefRefs(stmt ast.Statement) []defRef { + label, targets, ok := navigationTargets(stmt, nil) + if !ok { + return nil + } + var out []defRef + for _, t := range targets { + if !strings.Contains(t.name, ".") { + continue // an unqualified name cannot be placed in a module + } + // A create-object action's entity is resolved by the page builder when it + // runs; a page or flow is only stored by name. + out = append(out, defRef{name: t.name, kinds: []string{t.kind}, site: label + ": " + t.where, byName: t.kind != navEntity}) + } + return out +} diff --git a/mdl/executor/validate_navigation_targets_test.go b/mdl/executor/validate_navigation_targets_test.go new file mode 100644 index 000000000..20356ace4 --- /dev/null +++ b/mdl/executor/validate_navigation_targets_test.go @@ -0,0 +1,500 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" + "github.com/mendixlabs/mxcli/sdk/pages" + "github.com/mendixlabs/mxcli/sdk/security" +) + +// A navigation statement names pages and microflows in six places: the home +// page (also per role), a home flow, the login page, the not-found page, and +// every menu item at any depth. `check -p` resolved none of them, and `exec` +// wrote them by name, so a typo reached the model and was first reported by +// `mx check` as CE1613 "The selected page 'X' no longer exists." — after exec +// had rewritten the whole menu (CREATE OR REPLACE NAVIGATION replaces it). +// +// The project here has Shop (a user module), Administration (stands for a +// Marketplace module) and System. + +type navFixture struct { + mb *mock.MockBackend + ctx *ExecContext + updates int // UpdateNavigationProfile calls + added int // AddNavigationProfile calls + menuSets int // CreateMenuDocument + UpdateMenuDocument calls +} + +func newNavFixture(t *testing.T) *navFixture { + t.Helper() + shop, admin, system := mkModule("Shop"), mkModule("Administration"), mkModule("System") + h := mkHierarchy(shop, admin, system) + + f := &navFixture{} + f.mb = &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListPagesFunc: func() ([]*pages.Page, error) { + return []*pages.Page{ + mkPage(shop.ID, "Home"), + mkPage(shop.ID, "Orders"), + mkPage(admin.ID, "Account_Overview"), + mkPage(system.ID, "Login"), + }, nil + }, + ListMicroflowsFunc: func() ([]*microflows.Microflow, error) { + return []*microflows.Microflow{ + mkMicroflow(shop.ID, "ACT_Open"), + mkMicroflow(admin.ID, "Rebuild"), + mkMicroflow(system.ID, "Logout"), + }, nil + }, + ListNanoflowsFunc: func() ([]*microflows.Nanoflow, error) { + return []*microflows.Nanoflow{mkNanoflow(shop.ID, "NF_Go")}, nil + }, + GetNavigationFunc: func() (*types.NavigationDocument, error) { + return &types.NavigationDocument{Profiles: []*types.NavigationProfile{{ + Name: "Responsive", + Kind: "Responsive", + HomePage: &types.NavHomePage{Page: "Shop.Home"}, + MenuItems: []*types.NavMenuItem{{Caption: "Stored", Page: "Shop.Home"}}, + }}}, nil + }, + UpdateNavigationProfileFunc: func(model.ID, string, types.NavigationProfileSpec) error { + f.updates++ + return nil + }, + AddNavigationProfileFunc: func(model.ID, string) error { f.added++; return nil }, + GetProjectSecurityFunc: func() (*security.ProjectSecurity, error) { + return &security.ProjectSecurity{UserRoles: []*security.UserRole{{Name: "Administrator"}}}, nil + }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{shop, admin, system}, nil }, + GetModuleByNameFunc: func(name string) (*model.Module, error) { + for _, m := range []*model.Module{shop, admin, system} { + if m.Name == name { + return m, nil + } + } + return nil, nil + }, + CreateMenuDocumentFunc: func(*types.MenuDocument) error { f.menuSets++; return nil }, + UpdateMenuDocumentFunc: func(*types.MenuDocument) error { f.menuSets++; return nil }, + } + f.ctx, _ = newMockCtx(t, withBackend(f.mb), withHierarchy(h)) + return f +} + +func buildProg(t *testing.T, script string) *ast.Program { + t.Helper() + prog, errs := visitor.Build(script) + if len(errs) > 0 { + t.Fatalf("does not parse: %v\n%s", errs[0], script) + } + return prog +} + +// check is `mxcli check -p`: the program-level reference validation. +func (f *navFixture) check(t *testing.T, script string) (string, []string) { + t.Helper() + errs, warnings := validateProgramWithWarnings(f.ctx, buildProg(t, script)) + var msgs []string + for _, e := range errs { + msgs = append(msgs, e.Error()) + } + return strings.Join(msgs, "\n"), warnings +} + +// exec runs every statement through its handler, as `exec` does after its +// pre-flight, and returns the first refusal. +func (f *navFixture) exec(t *testing.T, script string) error { + t.Helper() + for _, stmt := range buildProg(t, script).Statements { + var err error + switch s := stmt.(type) { + case *ast.AlterNavigationStmt: + err = execAlterNavigation(f.ctx, s) + case *ast.CreateMenuStmt: + err = execCreateMenu(f.ctx, s) + default: + t.Fatalf("unexpected statement %T", stmt) + } + if err != nil { + return err + } + } + return nil +} + +// The two spellings of the reported navigation, with the same four bad targets: +// the home page, a page and a microflow inside a group, and a top-level page. +const bogusNavDeprecated = ` +create or replace navigation Responsive + home page Shop.NoSuchHome + menu ( + menu 'G' ( + menu item 'Bogus' page Shop.NoSuchPage; + menu item 'Bogus2' microflow Shop.ACT_NoSuchFlow; + ); + menu item 'Bogus3' page Shop.NoSuchPage3; + ); +` + +const bogusNavV1 = `mdl 1; +create or modify navigation Responsive + home page Shop.NoSuchHome +{ + menu item 'Bogus3' ( OnClick: show page Shop.NoSuchPage3 ) + menu 'G' { + menu item 'Bogus' ( OnClick: show page Shop.NoSuchPage ) + menu item 'Bogus2' ( OnClick: call microflow Shop.ACT_NoSuchFlow ) + } +}; +` + +func TestNavigationTargets_CheckRefusesUnresolvedTargets(t *testing.T) { + for name, script := range map[string]string{"deprecated": bogusNavDeprecated, "mdl 1": bogusNavV1} { + t.Run(name, func(t *testing.T) { + f := newNavFixture(t) + got, _ := f.check(t, script) + for _, want := range []string{ + "navigation Responsive", + "home page: page not found: Shop.NoSuchHome", + "menu item 'G' > 'Bogus': page not found: Shop.NoSuchPage", + "menu item 'G' > 'Bogus2': microflow not found: Shop.ACT_NoSuchFlow", + "menu item 'Bogus3': page not found: Shop.NoSuchPage3", + } { + if !strings.Contains(got, want) { + t.Errorf("check -p lacks %q in:\n%s", want, got) + } + } + // The group a nested item sits in is part of where it is. + if !strings.Contains(got, "'G' > 'Bogus'") { + t.Errorf("a nested item does not name its group:\n%s", got) + } + }) + } +} + +func TestNavigationTargets_CheckAcceptsTargetsThatExist(t *testing.T) { + // Existing pages and flows, a Marketplace-module page and microflow, System + // elements, a nanoflow, a role-based home, the login page, and a sign-out + // item (which has no target). + script := `mdl 1; +create or modify navigation Responsive + home page Shop.Home + home microflow Shop.ACT_Open for Administrator + login page System.Login + not found page Shop.Orders +{ + menu item 'Home' ( OnClick: show page Shop.Home ) + menu 'Admin' { + menu item 'Accounts' ( OnClick: show page Administration.Account_Overview ) + menu item 'Rebuild' ( OnClick: call microflow Administration.Rebuild ) + menu 'Deeper' { + menu item 'Go' ( OnClick: call nanoflow Shop.NF_Go ) + menu item 'Out' ( OnClick: call microflow System.Logout ) + } + } + menu item 'Sign out' ( OnClick: sign out ) +}; +` + f := newNavFixture(t) + if got, _ := f.check(t, script); got != "" { + t.Errorf("check -p refused a navigation whose targets all exist:\n%s", got) + } +} + +// A page or microflow the SAME script creates earlier resolves. This is the +// control for the scriptContext exemption: it is what makes a project that +// does not have the page yet checkable. +func TestNavigationTargets_CheckResolvesTargetsCreatedEarlierInTheScript(t *testing.T) { + script := `mdl 1; +create page Shop.NewPage ( Title: 'New', Layout: Atlas_Core.Atlas_Default ) { }; +create microflow Shop.ACT_New () begin end; +create or modify navigation Responsive + home page Shop.NewPage +{ + menu item 'Flow' ( OnClick: call microflow Shop.ACT_New ) +}; +` + f := newNavFixture(t) + if got, _ := f.check(t, script); got != "" { + t.Errorf("a target created earlier in the script was refused:\n%s", got) + } +} + +func TestNavigationTargets_CheckCoversEveryTargetKind(t *testing.T) { + cases := map[string]struct{ clause, want string }{ + "home page": {"home page Shop.Gone", "home page: page not found: Shop.Gone"}, + "home microflow": {"home microflow Shop.Gone", "home microflow: microflow not found: Shop.Gone"}, + "role-based home page": {"home page Shop.Gone for Administrator", "home page for Administrator: page not found: Shop.Gone"}, + "role-based home flow": {"home microflow Shop.Gone for Administrator", "home microflow for Administrator: microflow not found: Shop.Gone"}, + "login page": {"login page Shop.Gone", "login page: page not found: Shop.Gone"}, + "not found page": {"not found page Shop.Gone", "not found page: page not found: Shop.Gone"}, + "menu item nanoflow": {"{ menu item 'X' ( OnClick: call nanoflow Shop.Gone ) }", "menu item 'X': nanoflow not found: Shop.Gone"}, + "menu item create+show": {"{ menu item 'X' ( OnClick: create object Shop.Order then show page Shop.Gone ) }", "menu item 'X': page not found: Shop.Gone"}, + "three deep sub-menus": {"{ menu 'A' { menu 'B' { menu 'C' { menu item 'X' ( OnClick: show page Shop.Gone ) } } } }", "'A' > 'B' > 'C' > 'X': page not found: Shop.Gone"}, + "old clause, top level": {"menu ( menu item 'X' page Shop.Gone; )", "menu item 'X': page not found: Shop.Gone"}, + "old clause, in a group": {"menu ( menu 'G' ( menu item 'X' microflow Shop.Gone; ); )", "'G' > 'X': microflow not found: Shop.Gone"}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + f := newNavFixture(t) + // "create object" needs the entity to exist for the builder; the + // reference check reports the page, which is what the case is about. + got, _ := f.check(t, "create or modify navigation Responsive "+c.clause+";") + if !strings.Contains(got, c.want) { + t.Errorf("check -p lacks %q in:\n%s", c.want, got) + } + }) + } +} + +func TestNavigationTargets_CheckMenuDocuments(t *testing.T) { + f := newNavFixture(t) + got, _ := f.check(t, `create or modify menu Shop.Main_Menu { + menu item 'Ok' ( OnClick: show page Shop.Home ) + menu 'G' { menu item 'Bad' ( OnClick: show page Shop.Gone ) } +};`) + for _, want := range []string{"menu Shop.Main_Menu", "'G' > 'Bad': page not found: Shop.Gone"} { + if !strings.Contains(got, want) { + t.Errorf("check -p lacks %q in:\n%s", want, got) + } + } + if strings.Contains(got, "'Ok'") { + t.Errorf("a resolving item was reported:\n%s", got) + } +} + +// Mendix does not validate an excluded document, so a menu document stored as +// excluded keeps its dangling targets and says so as a warning, as an excluded +// page does. +func TestNavigationTargets_ExcludedMenuDocumentWarns(t *testing.T) { + f := newNavFixture(t) + shop, _ := f.mb.GetModuleByName("Shop") + f.mb.ListMenuDocumentsFunc = func() ([]*types.MenuDocument, error) { + return []*types.MenuDocument{{ID: "md1", ContainerID: shop.ID, Name: "Old_Menu", Excluded: true}}, nil + } + f.mb.GetMenuDocumentByQualifiedNameFunc = func(_, name string) (*types.MenuDocument, error) { + return &types.MenuDocument{ID: "md1", ContainerID: shop.ID, Name: name, Excluded: true}, nil + } + got, warnings := f.check(t, `create or modify menu Shop.Old_Menu { menu item 'Bad' ( OnClick: show page Shop.Gone ) };`) + if got != "" { + t.Errorf("an excluded menu document blocked check:\n%s", got) + } + if len(warnings) != 1 || !strings.Contains(warnings[0], "Shop.Gone") { + t.Errorf("warnings = %v, want one naming Shop.Gone", warnings) + } +} + +// exec refuses BEFORE it writes: CREATE OR REPLACE NAVIGATION rewrites the +// whole menu, so a partial write is the stored menu lost for a typo. +func TestNavigationTargets_ExecRefusesBeforeWriting(t *testing.T) { + for name, script := range map[string]string{"deprecated": bogusNavDeprecated, "mdl 1": bogusNavV1} { + t.Run(name, func(t *testing.T) { + f := newNavFixture(t) + before, _ := f.mb.GetNavigation() + err := f.exec(t, script) + if err == nil { + t.Fatal("exec accepted a navigation whose targets do not exist") + } + for _, want := range []string{"Shop.NoSuchHome", "Shop.NoSuchPage", "Shop.ACT_NoSuchFlow", "'Bogus3'"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("refusal lacks %q: %v", want, err) + } + } + if f.updates != 0 || f.added != 0 { + t.Errorf("the profile was written: %d updates, %d added", f.updates, f.added) + } + after, _ := f.mb.GetNavigation() + if len(before.Profiles[0].MenuItems) != len(after.Profiles[0].MenuItems) || + before.Profiles[0].HomePage.Page != after.Profiles[0].HomePage.Page { + t.Errorf("the stored profile changed: %+v -> %+v", before.Profiles[0], after.Profiles[0]) + } + }) + } +} + +// check and exec decide alike: what check -p passes, exec writes. +func TestNavigationTargets_ExecWritesWhatCheckPasses(t *testing.T) { + script := `mdl 1; +create or modify navigation Responsive + home page Shop.Home +{ + menu item 'Orders' ( OnClick: show page Shop.Orders ) + menu 'Admin' { menu item 'Accounts' ( OnClick: show page Administration.Account_Overview ) } + menu item 'Flow' ( OnClick: call microflow Shop.ACT_Open ) +}; +` + f := newNavFixture(t) + if got, _ := f.check(t, script); got != "" { + t.Fatalf("check refused a valid navigation:\n%s", got) + } + if err := f.exec(t, script); err != nil { + t.Fatalf("exec refused what check passed: %v", err) + } + if f.updates != 1 { + t.Errorf("updates = %d, want 1", f.updates) + } +} + +func TestNavigationTargets_ExecRefusesMenuDocumentBeforeWriting(t *testing.T) { + f := newNavFixture(t) + f.mb.GetMenuDocumentByQualifiedNameFunc = func(_, _ string) (*types.MenuDocument, error) { return nil, nil } + err := f.exec(t, `create or modify menu Shop.Main_Menu { menu item 'Bad' ( OnClick: show page Shop.Gone ) };`) + if err == nil || !strings.Contains(err.Error(), "Shop.Gone") { + t.Fatalf("err = %v, want a refusal naming Shop.Gone", err) + } + if f.menuSets != 0 { + t.Errorf("the menu document was written (%d writes)", f.menuSets) + } + // CONTROL: a resolving target is written. + if err := f.exec(t, `create or modify menu Shop.Main_Menu { menu item 'Ok' ( OnClick: show page Shop.Home ) };`); err != nil { + t.Fatalf("a resolving menu was refused: %v", err) + } + if f.menuSets != 1 { + t.Errorf("menu writes = %d, want 1", f.menuSets) + } +} + +// A navigation first and its page or flow created further down: the target is +// stored by name, so a run that rolls back (plain `exec`, and `check`) accepts +// it, as it accepts a call to a flow created further down (mendixlabs/mxcli#1405). +// Measured on upstream/main, Mendix 11.12.5: such a script execs and `mx check` +// reports 0 errors, so refusing it would break a working script. +const forwardNavScript = `mdl 1; +create or modify navigation Responsive + home page Shop.Later +{ + menu item 'Flow' ( OnClick: call microflow Shop.ACT_Later ) +}; +create page Shop.Later ( Title: 'Later', Layout: Atlas_Core.Atlas_Default ) { }; +create microflow Shop.ACT_Later () begin end; +` + +func TestNavigationTargets_ForwardReferenceIsAcceptedByARunThatRollsBack(t *testing.T) { + // No project: the order rule has nothing to say about a stored-by-name target. + for _, v := range ValidateScriptDefinitionOrder(buildProg(t, forwardNavScript)) { + t.Errorf("project-free order check reported %s: %s", v.RuleID, v.Message) + } + f := newNavFixture(t) + f.ctx.forwardFlowCalls = true + if got, _ := f.check(t, forwardNavScript); got != "" { + t.Errorf("check -p refused a navigation written before its targets:\n%s", got) + } +} + +// A run that cannot roll back would leave the target dangling if a later +// statement failed, so it refuses, with the reason. +func TestNavigationTargets_ForwardReferenceIsRefusedByARunThatCannotRollBack(t *testing.T) { + f := newNavFixture(t) + f.ctx.forwardFlowCalls = false + got, _ := f.check(t, forwardNavScript) + for _, want := range []string{ + "navigation Responsive: home page references page Shop.Later before it is created", + "navigation Responsive: menu item 'Flow' references microflow Shop.ACT_Later before it is created", + "cannot be rolled back", + } { + if !strings.Contains(got, want) { + t.Errorf("check -p lacks %q in:\n%s", want, got) + } + } +} + +// The handler resolves a target the program declares further down only in a +// run that rolls back, and only the kinds that are stored by name. +func TestNavigationTargets_ExecResolvesForwardTargetsOnlyInARunThatRollsBack(t *testing.T) { + script := "create or modify navigation Responsive home page Shop.Later;" + declared := map[defKey]bool{{kind: defPage, name: "Shop.Later"}: true} + + f := newNavFixture(t) + f.ctx.forwardFlowCalls, f.ctx.scriptTargets = true, declared + if err := f.exec(t, script); err != nil { + t.Fatalf("a target declared further down was refused in a run that rolls back: %v", err) + } + if f.updates != 1 { + t.Errorf("updates = %d, want 1", f.updates) + } + + for name, setup := range map[string]func(*ExecContext){ + "run that cannot roll back": func(c *ExecContext) { c.forwardFlowCalls, c.scriptTargets = false, declared }, + "target the script lacks": func(c *ExecContext) { c.forwardFlowCalls, c.scriptTargets = true, map[defKey]bool{} }, + "declared as another kind": func(c *ExecContext) { + c.forwardFlowCalls, c.scriptTargets = true, map[defKey]bool{{kind: defMicroflow, name: "Shop.Later"}: true} + }, + } { + f := newNavFixture(t) + setup(f.ctx) + if err := f.exec(t, script); err == nil || !strings.Contains(err.Error(), "page not found: Shop.Later") { + t.Errorf("%s: err = %v, want a refusal naming Shop.Later", name, err) + } + if f.updates != 0 { + t.Errorf("%s: the profile was written", name) + } + } +} + +// The executor declares what the program creates before it runs the first +// statement, for the pages, microflows and nanoflows a navigation may name. +func TestNavigationTargets_EnterScriptFlowsDeclaresPagesAndFlows(t *testing.T) { + e := &Executor{forwardFlowCalls: true} + restore := e.enterScriptFlows(buildProg(t, forwardNavScript+"create nanoflow Shop.NF_Later () begin end;\n")) + for _, k := range []defKey{{defPage, "Shop.Later"}, {defMicroflow, "Shop.ACT_Later"}, {defNanoflow, "Shop.NF_Later"}} { + if !e.scriptTargets[k] { + t.Errorf("scriptTargets lacks %v: %v", k, e.scriptTargets) + } + } + restore() + if len(e.scriptTargets) != 0 { + t.Errorf("scriptTargets not restored: %v", e.scriptTargets) + } + // A run that cannot roll back declares nothing. + e = &Executor{} + e.enterScriptFlows(buildProg(t, forwardNavScript)) + if len(e.scriptTargets) != 0 { + t.Errorf("a run that cannot roll back declared %v", e.scriptTargets) + } +} + +// Without a rollback, a target created further down by `create or modify` is +// judged against what the project holds: it does not, so exec would stop at the +// navigation. +func TestNavigationTargets_ForwardOrModifyIsRefusedWithoutRollback(t *testing.T) { + f := newNavFixture(t) + f.ctx.forwardFlowCalls = false + got, _ := f.check(t, `mdl 1; +create or modify navigation Responsive home page Shop.Later; +create or modify page Shop.Later ( Title: 'Later', Layout: Atlas_Core.Atlas_Default ) { }; +`) + for _, want := range []string{"navigation Responsive: home page", "Shop.Later", "before it is created"} { + if !strings.Contains(got, want) { + t.Errorf("check -p lacks %q in:\n%s", want, got) + } + } +} + +// A native profile's `home microflow` is stored as the nanoflow it is, so the +// name is looked up among the nanoflows. +func TestNavigationTargets_NativeHomeMicroflowIsLookedUpAsNanoflow(t *testing.T) { + f := newNavFixture(t) + f.mb.GetNavigationFunc = func() (*types.NavigationDocument, error) { + return &types.NavigationDocument{Profiles: []*types.NavigationProfile{ + {Name: "NativePhone", IsNative: true}, + }}, nil + } + if got, _ := f.check(t, "create or modify navigation NativePhone home microflow Shop.NF_Go;"); got != "" { + t.Errorf("a native home naming an existing nanoflow was refused:\n%s", got) + } + got, _ := f.check(t, "create or modify navigation NativePhone home nanoflow Shop.Gone;") + if !strings.Contains(got, "home nanoflow: nanoflow not found: Shop.Gone") { + t.Errorf("check -p lacks the native home nanoflow in:\n%s", got) + } +}