diff --git a/bin/mxrb b/bin/mxrb index 17344b0..63eb2b8 100755 --- a/bin/mxrb +++ b/bin/mxrb @@ -1588,7 +1588,10 @@ else functional-test new Module.Flow Scaffold a runtime functional test evaluation new Name Scaffold static model evaluations presentation init Module Initialize presentation directories - page new Module.Name Scaffold a page + page templates [--json] List audited page patterns as a tree + page new|generate|g Module.Name Scaffold a page + [--template NAME] Apply starter/blank/dashboard/form-vertical + [--chain CHAIN] Compose page/nanoflow/microflow actions nanoflow new Module.Name Scaffold a client nanoflow repository new Module.Name Scaffold a repository port and adapter security init Module Scaffold module roles diff --git a/docs/de-DE/native-runtime-quality-report.md b/docs/de-DE/native-runtime-quality-report.md index 4904505..36e93e3 100644 --- a/docs/de-DE/native-runtime-quality-report.md +++ b/docs/de-DE/native-runtime-quality-report.md @@ -1,6 +1,6 @@ # Qualitätsbericht für nativen Build und Runtime -Datum: 4. August 2026. +Datum: 5. August 2026. ## Bestätigtes Ergebnis @@ -13,7 +13,17 @@ Datum: 4. August 2026. - Projects API inventarisierte 130 Apps. Drei echte Git-Projekte (`MyFirstModule`, `LearnNow Trainning Management`, `SLATaskApp`) bestanden validate → export → generate → validate → compare; - `MyFirstModule` wurde ohne proprietäre Builder als exaktes 11.12.1 erzeugt. Runtime synchronisierte 655 Operationen, erstellte den aktiven Admin `mx`, lieferte React-Shell und gestaltetes Login auf `127.0.0.1:18080` und zeigte die erwarteten Tabellen; - eine neu erstellte 10.24.0.73019-Anwendung mit optimiertem Client bestand offizielles `mx check` und `mxbuild`, natives Modell/Java/Rollup/Portable-Paket, 460 Datenbanksynchronisierungen, Readiness-Probe und HTTP 200; -- finale QA: 1.002 Beispiele, null Fehler, 100,00% Zeilen (16.917/16.917), 100,00% Branches (6.659/6.659) und RuboCop ohne Verstöße in 238 Dateien. +- authentifizierte Chromium-QA bestand wiederholt für Home und Orders mit + deterministischen DOM/Layout/Style/ARIA-Snapshots und Screenshots, null + Konsolenfehlern und einer expliziten SHA-256-Baseline; +- `mxrb page new|generate|g Modul.Seite --chain ...` materialisiert gültige + MPRs für `page:microflow`, `page:nanoflow` und + `page:nanoflow:microflow`; ohne Option bleibt das minimale Seiten-Scaffold + unverändert; +- `mxrb page templates` zeigt den Baum starter/blank/dashboard/form-vertical; + `--template` kombiniert Vorlagen mit Chains, und dashboard/form-vertical + bestanden in einer echten Runtime mit kompiliertem Theme und null Konsolenfehlern; +- finale QA: 1.039 Beispiele, null Fehler, 100,00% Zeilen (17.224/17.224), 100,00% Branches (6.811/6.811) und RuboCop ohne Verstöße in 244 Dateien. ## Korrekturen aus dem Verbesserungsbericht @@ -39,6 +49,10 @@ Datum: 4. August 2026. kompiliert. Der auditierte Graph-Teilumfang umfasst Entscheidungen, Fehlerpfade, verschachtelte Nanoflows, JavaScript-Aktionen und serverseitige Microflow-Aufrufe; nicht unterstützte Clientknoten schlagen geschlossen fehl; +- der PostgreSQL-Workspace injiziert eine konsistente JDBC-URL und die Runtime- + SSL-Option; Navigationsrollen bei `CheckNothing`, Scroll-Modi, SidebarToggle + und Rückgabekonstanten von Microflow-Data-Sources folgen den im + authentifizierten 11.12.1-Client beobachteten Verträgen; - echte Browserklicks bestätigten Home → Courses → Add → Save. Parametergestützte DataView/TextBox-Formulare rendern und speichern Werte; Create übergibt die GUID über `openForm2`, während die Commit-/Rollback-Autorisierung aus den Modulrollen diff --git a/docs/de-DE/platform-operations.md b/docs/de-DE/platform-operations.md index 333b3a6..d44196c 100644 --- a/docs/de-DE/platform-operations.md +++ b/docs/de-DE/platform-operations.md @@ -27,6 +27,11 @@ script/frontend_acceptance App.mpr --mxbuild /pfad/mxbuild -o frontend.json script/frontend_acceptance App.mpr --mx /pfad/mx -o frontend-diagnostics.json script/frontend_lifecycle_acceptance --version 11.12.1 --mx /pfad/mx \ --mxbuild /pfad/mxbuild --strict-warnings -o frontend-lifecycle.json +script/frontend_browser_acceptance --scenario examples/frontend-browser-scenario.json \ + --url http://127.0.0.1:18080 --password-file /pfad/credentials.json \ + --baseline examples/frontend-browser-baseline-11.12.1.json +script/frontend_browser_acceptance --scenario examples/page-chain-browser-scenario.json \ + --url http://127.0.0.1:18080 --password-file /pfad/credentials.json ``` `mxrb frontend migrate` ist standardmäßig eine unveränderliche, fail-closed @@ -66,6 +71,20 @@ In der offiziellen Matrix 10.24.0.73019 und 11.12.1 endeten Quelle und Rebuild mit null Fehlern, Warnungen, Deprecations oder Empfehlungen in `mx check` sowie null MxBuild-Fehlern. +`script/frontend_browser_acceptance` steuert Chromium über lokales CDP, +authentifiziert ohne Passwort in der Kommandozeile und vergleicht +deterministische Snapshots von Struktur, Geometrie, Styles und ARIA-Zustand. +Browser-Ausnahmen, defekte Widgets und der generische Runtime-Fehlerdialog +lassen das Gate fehlschlagen. Nur `--update-baseline` ersetzt die Baseline; +normale Läufe vergleichen schreibgeschützt. `mx.logout()` hält das Gate auch +unter einer Trial-Lizenz wiederholbar. + +`page-chain-browser-scenario.json` verwendet eine `PageChainQa`-App mit +`DirectOrder`, `ClientOrder` und `HybridOrder`, erzeugt durch die drei Werte von +`page --chain`. Das Szenario klickt auf jeder Seite `Refresh` und verlangt auch +berechnetes CSS — Dokumentrand, Header-Gradient und Card-Hintergrund —, sodass +funktionales HTML ohne Theme das Gate nicht besteht. + `--mx` führt den offiziellen schreibgeschützten Checker mit Warnungen, Deprecations und Best-Practice-Empfehlungen aus. Das Gate validiert die Exit-Bitmaske und vergleicht normalisierte Signaturen von Quelle und Rebuild. diff --git a/docs/de-DE/scaffolds.md b/docs/de-DE/scaffolds.md index 22ebd89..4b7ce90 100644 --- a/docs/de-DE/scaffolds.md +++ b/docs/de-DE/scaffolds.md @@ -11,7 +11,46 @@ Verfügbar sind Projekt/Modul (`init`, `module new`), Domäne (`entity`, `repository`, `scheduled-event`), Präsentation (`presentation init`, `page`, `nanoflow`), Sicherheit, Infrastruktur (`integration`, `published-rest`, `consumed-rest`, `java-action`), Prüfung (`functional-test`, `evaluation`), -Design und GitHub-CI. +Design und GitHub-CI. `mxrb page new Modul.Seite` erzeugt weiterhin eine +minimale Seite. Mit `--chain` entsteht ein ausführbarer vertikaler Schnitt mit +Beispielentität, Data-Source-Microflow, editierbarer Seite, Aktionen und +Responsive-Menüeintrag. Drei echte Mendix-Aktionsketten stehen zur Verfügung: + +```sh +mxrb page new App.Order --chain page:microflow +mxrb page generate App.Order --chain page:nanoflow +mxrb page g App.Order --chain page:nanoflow:microflow +``` + +`page:microflow` ruft die Runtime direkt auf; `page:nanoflow` hält die Aktion +auf dem Client; `page:nanoflow:microflow` verwendet den Client-Flow zur +Orchestrierung eines Runtime-Aufrufs. Alle Modi erzeugen `ACT_LoadOrder` als +Data Source. Ketten, die mit einem Microflow enden, erzeugen zusätzlich +`ACT_RefreshOrder`. `page generate` und `page g` sind Aliase von `page new`. +Jede erzeugte Kette wird als gültiges MPR materialisiert und durch den +Compiler-Preflight geprüft. + +## Seitenvorlagen + +In Mendix sind Page Templates Ausgangspunkte, deren Struktur zu einer normalen, +editierbaren Seite wird. MXRB listet nur durch Compiler und Runtime auditierte +Muster: + +```sh +mxrb page templates +mxrb page templates --json +mxrb page new App.Landing --template starter +mxrb page new App.Empty --template blank +mxrb page new App.Operations --template dashboard +mxrb page new App.Order_NewEdit --template form-vertical +``` + +`form-vertical` erzeugt zusätzlich eine Beispielentität und eine +`ACT_Load...`-DataView-Quelle. Jede Vorlage lässt sich mit `--chain` +kombinieren; `--template dashboard --chain page:nanoflow` ergänzt zum Beispiel +eine Client-Aktion. Diese Namen sind stabile, von Mendix-Mustern inspirierte +MXRB-Verträge; sie behaupten nicht, alle installierten Atlas- oder Marketplace- +Vorlagen still zu importieren. Siehe die [offizielle Seitendokumentation](https://docs.mendix.com/refguide/pages/). Artefaktbefehle verwenden `new Modul.Name`; Projektbefehle verwenden `design init` und `ci init github`. Alle unterstützten Entitätstypen und diff --git a/docs/de-DE/validation-matrix.md b/docs/de-DE/validation-matrix.md index 45148da..e40e6a9 100644 --- a/docs/de-DE/validation-matrix.md +++ b/docs/de-DE/validation-matrix.md @@ -2,7 +2,7 @@ [Português](../pt-BR/validation-matrix.md) · [English](../en-US/validation-matrix.md) · **Deutsch** -Stand: 29. Juli 2026. +Stand: 5. August 2026. ```text Original-MPR → validate → export → generate → validate → compare @@ -37,9 +37,9 @@ Gate. ## Semantik, Tests und Runtime - 1.778 Artefakte und 3.387 Referenzen; -- 510 Beispiele, keine Fehler; -- 100 % Zeilenabdeckung (7.062/7.062); -- 100 % Branch-Abdeckung (2.626/2.626); +- 1.039 Beispiele, keine Fehler; +- 100 % Zeilenabdeckung (17.224/17.224); +- 100 % Branch-Abdeckung (6.811/6.811); - Sudoku-Modellbewertung: 7/7; - funktionale Runtime-Tests: 3/3 lokal und 3/3 in Docker. @@ -47,6 +47,14 @@ Ruby-Assertions prüfen Rückgabewerte und persistierte XPath-Anzahlen. Der Docker-Lauf bestätigte Games 1/2/3 und Cells 81/162/243; JUnit XML ist nur ein in Ruby erzeugtes CI-Format. +Das 11.12.1-Gate enthält nun zusätzlich ein authentifiziertes Chromium-Szenario +mit Login, Home-/Orders-Navigation, deterministischen DOM/Layout/Style/ARIA- +Snapshots, Screenshots, explizitem SHA-256-Baseline-Vergleich, Fehlererkennung +und echtem Logout. Alle drei `page --chain`-Pfade werden als gültiges MPR +materialisiert und durch den Compiler-Preflight geprüft. Die mit +`page --template` erzeugten Dashboard- und vertikalen Formularseiten wurden +zusätzlich in der Runtime mit geprüftem berechnetem CSS ausgeführt. + `script/validate_matrix` prüfte 1.506 Units in sechs Round-Trips in 14,760 s. `script/benchmark` maß 6,8463 s für die vollständige Sudoku-Pipeline. Deterministisches Fuzzing deckt 250 BSON-Dokumente und 50 atomare diff --git a/docs/en-US/native-runtime-quality-report.md b/docs/en-US/native-runtime-quality-report.md index 5f34599..de25bc5 100644 --- a/docs/en-US/native-runtime-quality-report.md +++ b/docs/en-US/native-runtime-quality-report.md @@ -1,6 +1,6 @@ # Native build and Runtime quality report -Date: August 4, 2026. +Date: August 5, 2026. ## Confirmed result @@ -13,7 +13,16 @@ Date: August 4, 2026. - Projects API inventoried 130 accessible apps. Three real Git projects (`MyFirstModule`, `LearnNow Trainning Management`, and `SLATaskApp`) passed validate → export → generate → validate → compare; - `MyFirstModule` was regenerated as exact 11.12.1 without proprietary builders. Runtime synchronized 655 database operations, created the active `mx` administrator, served the React shell and styled login publicly on `127.0.0.1:18080`, and exposed the expected domain tables; - a from-scratch optimized-client 10.24.0.73019 application passed official `mx check` and `mxbuild`, native model/Java/Rollup/portable packaging, 460 database synchronization operations, readiness probe and HTTP 200; -- final QA: 1,002 examples, zero failures, 100.00% lines (16,917/16,917), 100.00% branches (6,659/6,659), and clean RuboCop across 238 files. +- authenticated Chromium QA passed repeatedly across Home and Orders with + deterministic DOM/layout/style/ARIA snapshots and screenshots, zero console + errors, and an explicit SHA-256 baseline; +- `mxrb page new|generate|g Module.Page --chain ...` materializes valid MPRs + for `page:microflow`, `page:nanoflow`, and `page:nanoflow:microflow`; without + the option, the minimal page scaffold remains unchanged; +- `mxrb page templates` exposes the starter/blank/dashboard/form-vertical tree; + `--template` composes those templates with chains, and dashboard/form-vertical + passed in a real Runtime with compiled theme CSS and zero console errors; +- final QA: 1,039 examples, zero failures, 100.00% lines (17,224/17,224), 100.00% branches (6,811/6,811), and clean RuboCop across 244 files. ## Corrections from the improvement report @@ -39,6 +48,10 @@ Date: August 4, 2026. The audited graph subset includes decisions, error paths, nested nanoflows, JavaScript actions and server microflow calls; unsupported client nodes fail closed instead of being mislabeled as microflows; +- the PostgreSQL workspace injects a coherent JDBC URL and the Runtime SSL + option; `CheckNothing` navigation roles, scroll modes, SidebarToggle, and + microflow data-source return constants follow the real 11.12.1 client + contracts observed during authenticated testing; - real browser clicks verified Home → Courses → Add → Save. Parameter-backed DataView/TextBox forms render and persist values; create passes GUIDs through `openForm2`, while commit/rollback authorization is derived from page module diff --git a/docs/en-US/platform-operations.md b/docs/en-US/platform-operations.md index 89e7157..2f13853 100644 --- a/docs/en-US/platform-operations.md +++ b/docs/en-US/platform-operations.md @@ -27,6 +27,11 @@ script/frontend_acceptance App.mpr --mxbuild /path/to/mxbuild -o frontend.json script/frontend_acceptance App.mpr --mx /path/to/mx -o frontend-diagnostics.json script/frontend_lifecycle_acceptance --version 11.12.1 --mx /path/to/mx \ --mxbuild /path/to/mxbuild --strict-warnings -o frontend-lifecycle.json +script/frontend_browser_acceptance --scenario examples/frontend-browser-scenario.json \ + --url http://127.0.0.1:18080 --password-file /path/to/credentials.json \ + --baseline examples/frontend-browser-baseline-11.12.1.json +script/frontend_browser_acceptance --scenario examples/page-chain-browser-scenario.json \ + --url http://127.0.0.1:18080 --password-file /path/to/credentials.json ``` `mxrb frontend migrate` is an immutable, fail-closed preview by default. For @@ -65,6 +70,20 @@ and 11.12.1 matrix, source and rebuilt projects completed with zero errors, warnings, deprecations, or recommendations in `mx check`, plus zero MxBuild errors. +`script/frontend_browser_acceptance` drives Chromium over local CDP, +authenticates without placing the password on the command line, traverses +pages, and compares deterministic structure, geometry, style, and ARIA-state +snapshots. Browser exceptions, broken widgets, and the generic Runtime error +dialog fail the gate. Baseline replacement requires explicit +`--update-baseline`; normal runs are read-only comparisons. The gate signs out +through `mx.logout()` so it remains repeatable under a trial license. + +`page-chain-browser-scenario.json` uses a `PageChainQa` app with `DirectOrder`, +`ClientOrder`, and `HybridOrder`, created by the three `page --chain` values. +It clicks `Refresh` on each page and also requires computed CSS — document +margin, header gradient, and card background — so functional but unthemed HTML +fails the gate. + `--mx` runs the official read-only checker with warnings, deprecations, and best-practice recommendations enabled. It validates the checker's exit bitmask and compares normalized source/rebuilt signatures. The accepted 10.24 fixture diff --git a/docs/en-US/scaffolds.md b/docs/en-US/scaffolds.md index 0506247..b4941de 100644 --- a/docs/en-US/scaffolds.md +++ b/docs/en-US/scaffolds.md @@ -11,7 +11,44 @@ The available families are project/module (`init`, `module new`), domain `query`, `repository`, `scheduled-event`), presentation (`presentation init`, `page`, `nanoflow`), security, infrastructure (`integration`, `published-rest`, `consumed-rest`, `java-action`), verification (`functional-test`, `evaluation`), -design, and GitHub CI. +design, and GitHub CI. `mxrb page new Module.Page` keeps creating a minimal +page. Adding `--chain` creates an executable vertical slice with a sample +entity, data-source microflow, editable page, actions, and a Responsive +navigation item. Three real Mendix action chains are available: + +```sh +mxrb page new App.Order --chain page:microflow +mxrb page generate App.Order --chain page:nanoflow +mxrb page g App.Order --chain page:nanoflow:microflow +``` + +`page:microflow` calls the Runtime directly; `page:nanoflow` keeps the action +client-side; and `page:nanoflow:microflow` uses the client flow to orchestrate a +Runtime call. All modes create `ACT_LoadOrder` for the data source. Chains that +end in a microflow also create `ACT_RefreshOrder`. `page generate` and `page g` +are aliases of `page new`. Every generated chain is materialized as a valid MPR +and checked by compiler preflight. + +## Page templates + +In Mendix, page templates are starting points whose structure becomes a normal, +editable page. MXRB lists only patterns audited by its compiler and Runtime: + +```sh +mxrb page templates +mxrb page templates --json +mxrb page new App.Landing --template starter +mxrb page new App.Empty --template blank +mxrb page new App.Operations --template dashboard +mxrb page new App.Order_NewEdit --template form-vertical +``` + +`form-vertical` also creates a sample entity and `ACT_Load...` DataView source. +Every template can be combined with `--chain`; for example, +`--template dashboard --chain page:nanoflow` adds a client action to the +dashboard. These names are stable MXRB contracts inspired by Mendix patterns, +not a claim that every installed Atlas or Marketplace template is silently +imported. See the [official pages documentation](https://docs.mendix.com/refguide/pages/). Artifact commands use `new Module.Name`; project commands use `design init` and `ci init github`. See the [entity DSL](entity-dsl.md) for all supported entity diff --git a/docs/en-US/validation-matrix.md b/docs/en-US/validation-matrix.md index 2bfe5e5..1f16867 100644 --- a/docs/en-US/validation-matrix.md +++ b/docs/en-US/validation-matrix.md @@ -2,7 +2,7 @@ [Português](../pt-BR/validation-matrix.md) · **English** · [Deutsch](../de-DE/validation-matrix.md) -Last updated: 2026-07-29. +Last updated: 2026-08-05. The matrix exercises this pipeline using only MXRB: @@ -202,9 +202,9 @@ instead of dumping the complete removed and added flow bodies. ## Ruby evaluations and coverage gate -The current suite contains 510 examples and passes with 100.00% line coverage -(7,062/7,062 executable library lines) and 100.00% branch coverage -(2,626/2,626 branches). +The current suite contains 1,039 examples and passes with 100.00% line coverage +(17,224/17,224 executable library lines) and 100.00% branch coverage +(6,811/6,811 branches). Run the enforced gate with: ```sh @@ -235,8 +235,15 @@ MPR was copied before instrumentation and remained unchanged. Ruby assertions now verify return expressions and persisted XPath counts. The Docker run confirmed Game counts 1/2/3 and Cell counts 81/162/243 after the three cases. JUnit XML remains an optional Ruby-written CI report, not a Java -test dependency. End-to-end browser behavior remains outside this runtime -suite. +test dependency. + +The 11.12.1 gate now also includes an authenticated Chromium scenario: login, +Home/Orders navigation, deterministic DOM/layout/style/ARIA snapshots, +screenshots, explicit SHA-256 baseline comparison, widget/Runtime error +detection, and real logout. The three supported `page --chain` paths are each +materialized as a valid MPR and checked by compiler preflight. Dashboard and +vertical-form `page --template` outputs were also exercised in Runtime with +audited computed CSS. ## Reproducibility and performance diff --git a/docs/pt-BR/native-runtime-quality-report.md b/docs/pt-BR/native-runtime-quality-report.md index 01bc80a..3375fdf 100644 --- a/docs/pt-BR/native-runtime-quality-report.md +++ b/docs/pt-BR/native-runtime-quality-report.md @@ -1,6 +1,6 @@ # Relatório de qualidade do build e Runtime nativos -Data: 4 de agosto de 2026. +Data: 5 de agosto de 2026. ## Resultado confirmado @@ -13,7 +13,16 @@ Data: 4 de agosto de 2026. - a Projects API inventariou 130 apps. Três projetos Git reais (`MyFirstModule`, `LearnNow Trainning Management` e `SLATaskApp`) passaram validate → export → generate → validate → compare; - `MyFirstModule` foi regenerado como 11.12.1 exato sem builders proprietários. A Runtime sincronizou 655 operações, criou o administrador `mx` ativo, serviu shell React e login estilizado em `127.0.0.1:18080` e expôs as tabelas esperadas; - uma aplicação 10.24.0.73019 com cliente otimizado, criada do zero, passou no `mx check` e `mxbuild` oficiais, modelo/Java/Rollup/pacote portátil nativos, 460 operações de sincronização, probe de prontidão e HTTP 200; -- QA final: 1.002 exemplos, zero falhas, 100,00% de linhas (16.917/16.917), 100,00% de branches (6.659/6.659) e RuboCop limpo em 238 arquivos. +- QA autenticado em Chromium passou repetidamente em Home e Orders, com + snapshots DOM/layout/estilo/ARIA e screenshots determinísticos, zero erros de + console e baseline SHA-256 explícito; +- `mxrb page new|generate|g Module.Page --chain ...` materializa MPRs válidos + para `page:microflow`, `page:nanoflow` e `page:nanoflow:microflow`; sem a + opção, o scaffold mínimo de página permanece inalterado; +- `mxrb page templates` expõe a árvore starter/blank/dashboard/form-vertical; + `--template` combina os modelos com as chains, e dashboard/form-vertical + passaram em Runtime real com tema compilado e zero erros de console; +- QA final: 1.039 exemplos, zero falhas, 100,00% de linhas (17.224/17.224), 100,00% de branches (6.811/6.811) e RuboCop limpo em 244 arquivos. ## Correções do relatório de melhorias @@ -39,6 +48,10 @@ Data: 4 de agosto de 2026. função. O subconjunto auditado do grafo inclui decisões, caminhos de erro, nanoflows aninhados, ações JavaScript e chamadas de microflow servidor; nós não suportados falham fechado em vez de serem tratados como microflows; +- o workspace PostgreSQL injeta a JDBC URL coerente e a opção de SSL no Runtime; + papéis de navegação em `CheckNothing`, modos de scroll, SidebarToggle e as + constantes de retorno de data sources de microflow seguem os contratos reais + observados no cliente 11.12.1; - cliques reais no navegador validaram Home → Courses → Add → Save. Formulários DataView/TextBox por parâmetro renderizam e persistem valores; create passa o GUID por `openForm2`, enquanto a autorização de commit/rollback deriva dos diff --git a/docs/pt-BR/platform-operations.md b/docs/pt-BR/platform-operations.md index 8bc51e9..5147b88 100644 --- a/docs/pt-BR/platform-operations.md +++ b/docs/pt-BR/platform-operations.md @@ -29,6 +29,11 @@ script/frontend_acceptance App.mpr --mxbuild /caminho/mxbuild -o frontend.json script/frontend_acceptance App.mpr --mx /caminho/mx -o frontend-diagnostics.json script/frontend_lifecycle_acceptance --version 11.12.1 --mx /caminho/mx \ --mxbuild /caminho/mxbuild --strict-warnings -o frontend-lifecycle.json +script/frontend_browser_acceptance --scenario examples/frontend-browser-scenario.json \ + --url http://127.0.0.1:18080 --password-file /caminho/credentials.json \ + --baseline examples/frontend-browser-baseline-11.12.1.json +script/frontend_browser_acceptance --scenario examples/page-chain-browser-scenario.json \ + --url http://127.0.0.1:18080 --password-file /caminho/credentials.json ``` `mxrb frontend migrate` é uma prévia imutável e fail-closed por padrão. Para @@ -67,6 +72,19 @@ e exige reconstrução estruturalmente idêntica. Na matriz oficial 10.24.0.7301 e 11.12.1, origem e reconstrução concluíram com zero erros, warnings, depreciações e recomendações no `mx check`, além de zero erros no MxBuild. +`script/frontend_browser_acceptance` controla Chromium por CDP local, autentica +sem colocar senha na linha de comando, percorre páginas e compara snapshots +determinísticos de estrutura, geometria, estilos e estado ARIA. Exceções do +navegador, widgets quebrados e o diálogo genérico de erro do Runtime reprovam o +gate. `--update-baseline` é explícito; execuções normais apenas comparam. O gate +faz logout pela API `mx.logout()` e pode ser repetido sob licença trial. + +O cenário `page-chain-browser-scenario.json` usa uma app `PageChainQa` com +`DirectOrder`, `ClientOrder` e `HybridOrder`, criadas pelos três valores de +`page --chain`. Ele aciona `Refresh` em cada página e exige também CSS +computado — margem do documento, gradiente do cabeçalho e fundo dos cards —, +portanto HTML funcional sem tema reprova o gate. + `--mx` executa o checker oficial somente leitura com warnings, depreciações e recomendações de boas práticas. O gate valida o bitmask de saída e compara as assinaturas normalizadas de origem e reconstrução. A fixture 10.24 aceita tem diff --git a/docs/pt-BR/scaffolds.md b/docs/pt-BR/scaffolds.md index 55d17b5..c516a9b 100644 --- a/docs/pt-BR/scaffolds.md +++ b/docs/pt-BR/scaffolds.md @@ -24,7 +24,10 @@ o hash criado pelo gerador, protegendo edições posteriores. | `mxrb repository new App.CustomerRepository` | Contrato e adaptador inicial | | `mxrb scheduled-event new App.Cleanup` | Evento agendado e microflow | | `mxrb presentation init App` | Pastas e agregador de apresentação | -| `mxrb page new App.CustomerOverview` | Página com layout, título e roles comentados | +| `mxrb page new App.CustomerOverview` | Página mínima com layout, título e roles comentados | +| `mxrb page templates` | Árvore de modelos de página auditados | +| `mxrb page new App.Dashboard --template dashboard` | Página baseada em modelo | +| `mxrb page new App.Order --chain page:nanoflow:microflow` | Fatia vertical executável | | `mxrb nanoflow new App.NAN_OpenCustomer` | Nanoflow cliente | | `mxrb security init App` | Papéis, `CheckEverything` no projeto e orientação de acesso | | `mxrb integration new App.PetApi` | Adaptador de integração | @@ -46,8 +49,49 @@ fluxos, widgets, permissões efetivas, URLs e credenciais continuam sendo decisões do projeto. Páginas são esqueletos nativos mínimos; roles e conteúdo ficam comentados até serem definidos. `init` cria o perfil Responsive e uma página Home mínima para que o scaffold possa ser verificado e compilado -oficialmente. Outras páginas e itens de menu podem ser escritos no `project.rb`, -pois ainda não há um subcomando específico para inserir menus. +oficialmente. Outras páginas e itens de menu podem ser escritos na DSL Ruby. + +`mxrb page new App.Order --chain ...` transforma o scaffold mínimo de página +em uma fatia vertical executável: gera entidade de amostra, microflow de data +source, formulário com campos e ações, página e item no perfil Responsive. Há +três cadeias de ação Mendix explícitas: + +```sh +mxrb page new App.Order --chain page:microflow +mxrb page generate App.Order --chain page:nanoflow +mxrb page g App.Order --chain page:nanoflow:microflow +``` + +`page:microflow` chama o Runtime diretamente; `page:nanoflow` mantém a ação no +cliente; `page:nanoflow:microflow` usa o nanoflow como orquestrador cliente e +chama o microflow no Runtime. Todas criam `ACT_LoadOrder` para o data source; +as cadeias que terminam em microflow criam também `ACT_RefreshOrder`. Sem +`--chain`, `page new` preserva o esqueleto mínimo. `page generate` e `page g` +são aliases de `page new`. Cada cadeia é materializada em um MPR válido, serve +como ponto de partida removível e exercita o preflight do compilador; arquivos +existentes nunca são sobrescritos. + +## Modelos de página + +No Mendix, page templates são pontos de partida: sua estrutura é copiada para +uma página comum e editável. O catálogo do MXRB expõe somente padrões que o +compilador e o Runtime já auditam: + +```sh +mxrb page templates +mxrb page templates --json +mxrb page new App.Landing --template starter +mxrb page new App.Empty --template blank +mxrb page new App.Operations --template dashboard +mxrb page new App.Order_NewEdit --template form-vertical +``` + +`form-vertical` cria também entidade de amostra e `ACT_Load...` para o DataView. +Qualquer modelo pode receber `--chain`; por exemplo, +`--template dashboard --chain page:nanoflow` adiciona a ação cliente ao +dashboard. Esses nomes são contratos estáveis do MXRB inspirados nos padrões +Mendix, não uma promessa de importar silenciosamente todo template instalado +por Atlas ou Marketplace. Veja a [documentação oficial de páginas](https://docs.mendix.com/refguide/pages/). Para um checkout local durante desenvolvimento: diff --git a/docs/pt-BR/validation-matrix.md b/docs/pt-BR/validation-matrix.md index cfc08ee..923a733 100644 --- a/docs/pt-BR/validation-matrix.md +++ b/docs/pt-BR/validation-matrix.md @@ -2,7 +2,7 @@ **Português** · [English](../en-US/validation-matrix.md) · [Deutsch](../de-DE/validation-matrix.md) -Última atualização: 29 de julho de 2026. +Última atualização: 5 de agosto de 2026. O pipeline validado é: @@ -63,9 +63,9 @@ sem MDL. ## Avaliações, cobertura e runtime -- 510 exemplos, zero falhas; -- 100% das linhas: 7.062/7.062; -- 100% dos branches: 2.626/2.626; +- 1.039 exemplos, zero falhas; +- 100% das linhas: 17.224/17.224; +- 100% dos branches: 6.811/6.811; - avaliação Sudoku: 7/7 checks; - testes funcionais Sudoku: 3/3 localmente em 34,16 s; - testes funcionais Sudoku: 3/3 no Docker em 39,52 s. @@ -73,6 +73,13 @@ sem MDL. Asserções Ruby verificam retorno e contagens XPath persistidas: Games 1/2/3 e Cells 81/162/243. JUnit XML é apenas relatório opcional gerado em Ruby. +O gate 11.12.1 agora inclui navegador Chromium autenticado: login, navegação +Home/Orders, captura determinística de DOM/layout/estilo/ARIA, screenshots, +comparação SHA-256 com baseline explícito, detecção de erros de widget/Runtime +e logout real. Os scaffolds `page --chain` também materializam e passam +preflight nos três caminhos suportados; `page --template` foi exercitado em +Runtime para dashboard e formulário vertical com CSS computado auditado. + `script/validate_matrix` repetiu os seis round-trips, 1.506 units, em 14,760 s. `script/benchmark` mediu o pipeline Sudoku completo em 6,8463 s. O fuzzing determinístico cobre 250 documentos BSON aninhados e 50 arquivos `.mxunit` diff --git a/examples/frontend-browser-baseline-11.12.1.json b/examples/frontend-browser-baseline-11.12.1.json new file mode 100644 index 0000000..cacd430 --- /dev/null +++ b/examples/frontend-browser-baseline-11.12.1.json @@ -0,0 +1,2065 @@ +{ + "scenario": "frontend-cli-lifecycle", + "viewport": { + "width": 1440, + "height": 900 + }, + "pages": [ + { + "name": "home", + "snapshot_sha256": "bd89ab89acb77366f56dd579e3431d15d16f9b7e11e3deeae9a0b3254e89f7a3", + "snapshot": { + "title": "Frontend lifecycle - FrontendCycle", + "path": "/", + "viewport": { + "width": 1440, + "height": 900 + }, + "elements": [ + { + "tag": "div", + "id": "root", + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 8, + 1424, + 143 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 8, + 1424, + 143 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 8, + 1424, + 143 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 8, + 1424, + 21 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 8, + 1424, + 21 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": "l.FrontendCycle.ApplicationLayout.topbarContent", + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 8, + 1424, + 21 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "button", + "id": null, + "role": null, + "widget": "l.FrontendCycle.ApplicationLayout.sidebarToggle1", + "state": [ + "true", + "false", + false + ], + "text": "Menu", + "box": [ + 8, + 8, + 49, + 21 + ], + "style": [ + "inline-block", + "static", + "13.3333px", + "400", + "rgb(0, 0, 0)", + "rgb(239, 239, 239)" + ] + }, + { + "tag": "span", + "id": null, + "role": null, + "widget": "l.FrontendCycle.ApplicationLayout.applicationTitle", + "state": [ + null, + null, + false + ], + "text": "FrontendCycle", + "box": [ + 57, + 9, + 95, + 17 + ], + "style": [ + "inline", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 45, + 1424, + 106 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 45, + 1424, + 106 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 45, + 1424, + 106 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": "scrollcontainer_dynamic-toggleable", + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 45, + 1424, + 18 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 45, + 1424, + 18 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 45, + 1424, + 18 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 45, + 1424, + 18 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "ul", + "id": null, + "role": "menu", + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 45, + 1424, + 18 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "li", + "id": null, + "role": "none", + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 48, + 45, + 1384, + 18 + ], + "style": [ + "list-item", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "a", + "id": null, + "role": "menuitem", + "widget": null, + "state": [ + null, + null, + false + ], + "text": "Orders", + "box": [ + 48, + 45, + 44, + 17 + ], + "style": [ + "inline", + "static", + "16px", + "400", + "rgb(0, 0, 238)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 79, + 1424, + 72 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 79, + 1424, + 72 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 79, + 1424, + 72 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": "p.FrontendCycle.Home.pageHeader", + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 79, + 1424, + 18 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "span", + "id": null, + "role": null, + "widget": "p.FrontendCycle.Home.pageTitle", + "state": [ + null, + null, + false + ], + "text": "Frontend lifecycle verified", + "box": [ + 8, + 79, + 171, + 17 + ], + "style": [ + "inline", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "span", + "id": null, + "role": null, + "widget": "p.FrontendCycle.Home.pageSubtitle", + "state": [ + null, + null, + false + ], + "text": "A Ruby-first Mendix application, ready to grow.", + "box": [ + 179, + 79, + 309, + 17 + ], + "style": [ + "inline", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": "p.FrontendCycle.Home.dashboard", + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 97, + 1424, + 54 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": "p.FrontendCycle.Home.domainCard", + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 97, + 1424, + 18 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "span", + "id": null, + "role": null, + "widget": "p.FrontendCycle.Home.domainEyebrow", + "state": [ + null, + null, + false + ], + "text": "DOMAIN", + "box": [ + 8, + 97, + 66, + 17 + ], + "style": [ + "inline", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "span", + "id": null, + "role": null, + "widget": "p.FrontendCycle.Home.domainTitle", + "state": [ + null, + null, + false + ], + "text": "Model your business", + "box": [ + 74, + 97, + 133, + 17 + ], + "style": [ + "inline", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "span", + "id": null, + "role": null, + "widget": "p.FrontendCycle.Home.domainCopy", + "state": [ + null, + null, + false + ], + "text": "Add entities, enumerations and associations using the mxrb DSL.", + "box": [ + 207, + 97, + 419, + 17 + ], + "style": [ + "inline", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": "p.FrontendCycle.Home.logicCard", + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 115, + 1424, + 18 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "span", + "id": null, + "role": null, + "widget": "p.FrontendCycle.Home.logicEyebrow", + "state": [ + null, + null, + false + ], + "text": "LOGIC", + "box": [ + 8, + 115, + 49, + 17 + ], + "style": [ + "inline", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "span", + "id": null, + "role": null, + "widget": "p.FrontendCycle.Home.logicTitle", + "state": [ + null, + null, + false + ], + "text": "Build application flows", + "box": [ + 57, + 115, + 150, + 17 + ], + "style": [ + "inline", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "span", + "id": null, + "role": null, + "widget": "p.FrontendCycle.Home.logicCopy", + "state": [ + null, + null, + false + ], + "text": "Keep use cases, validations and queries organized by convention.", + "box": [ + 207, + 115, + 418, + 17 + ], + "style": [ + "inline", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": "p.FrontendCycle.Home.experienceCard", + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 133, + 1424, + 18 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "span", + "id": null, + "role": null, + "widget": "p.FrontendCycle.Home.experienceEyebrow", + "state": [ + null, + null, + false + ], + "text": "EXPERIENCE", + "box": [ + 8, + 133, + 98, + 17 + ], + "style": [ + "inline", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "span", + "id": null, + "role": null, + "widget": "p.FrontendCycle.Home.experienceTitle", + "state": [ + null, + null, + false + ], + "text": "Ship a polished UI", + "box": [ + 106, + 133, + 120, + 17 + ], + "style": [ + "inline", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "span", + "id": null, + "role": null, + "widget": "p.FrontendCycle.Home.experienceCopy", + "state": [ + null, + null, + false + ], + "text": "Create pages, snippets and client actions without manual wiring.", + "box": [ + 225, + 133, + 412, + 17 + ], + "style": [ + "inline", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + } + ] + } + }, + { + "name": "orders", + "snapshot_sha256": "e810c423d8d8cacdbf108a71ec52d6292d93ef295fc3c25fa8931b471330c423", + "snapshot": { + "title": "Frontend lifecycle - Orders", + "path": "/", + "viewport": { + "width": 1440, + "height": 900 + }, + "elements": [ + { + "tag": "div", + "id": "root", + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 8, + 1424, + 239 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 8, + 1424, + 239 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 8, + 1424, + 239 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 8, + 1424, + 21 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 8, + 1424, + 21 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": "l.FrontendCycle.ApplicationLayout.topbarContent", + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 8, + 1424, + 21 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "button", + "id": null, + "role": null, + "widget": "l.FrontendCycle.ApplicationLayout.sidebarToggle1", + "state": [ + "true", + "false", + false + ], + "text": "Menu", + "box": [ + 8, + 8, + 49, + 21 + ], + "style": [ + "inline-block", + "static", + "13.3333px", + "400", + "rgb(0, 0, 0)", + "rgb(239, 239, 239)" + ] + }, + { + "tag": "span", + "id": null, + "role": null, + "widget": "l.FrontendCycle.ApplicationLayout.applicationTitle", + "state": [ + null, + null, + false + ], + "text": "FrontendCycle", + "box": [ + 57, + 9, + 95, + 17 + ], + "style": [ + "inline", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 45, + 1424, + 202 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 45, + 1424, + 202 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 45, + 1424, + 202 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": "scrollcontainer_dynamic-toggleable", + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 45, + 1424, + 18 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 45, + 1424, + 18 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 45, + 1424, + 18 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 45, + 1424, + 18 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "ul", + "id": null, + "role": "menu", + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 45, + 1424, + 18 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "li", + "id": null, + "role": "none", + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 48, + 45, + 1384, + 18 + ], + "style": [ + "list-item", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "a", + "id": null, + "role": "menuitem", + "widget": null, + "state": [ + null, + null, + false + ], + "text": "Orders", + "box": [ + 48, + 45, + 44, + 17 + ], + "style": [ + "inline", + "static", + "16px", + "400", + "rgb(0, 0, 238)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 79, + 1424, + 168 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 79, + 1424, + 168 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 79, + 1424, + 168 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 79, + 1424, + 168 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 79, + 1424, + 168 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": "p.FrontendCycle.Orders.pageHeader", + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 79, + 1424, + 18 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "span", + "id": null, + "role": null, + "widget": "p.FrontendCycle.Orders.pageTitle", + "state": [ + null, + null, + false + ], + "text": "Orders created from exported Ruby", + "box": [ + 8, + 79, + 227, + 17 + ], + "style": [ + "inline", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": "p.FrontendCycle.Orders.orderForm", + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 97, + 1424, + 129 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 97, + 1424, + 36 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "label", + "id": "p.FrontendCycle.Orders.reference-label", + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "Reference", + "box": [ + 8, + 97, + 65, + 17 + ], + "style": [ + "inline", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 115, + 1424, + 18 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "NEW", + "box": [ + 8, + 115, + 1424, + 18 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 133, + 1424, + 36 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "label", + "id": "p.FrontendCycle.Orders.total-label", + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "Total", + "box": [ + 8, + 133, + 33, + 17 + ], + "style": [ + "inline", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 151, + 1424, + 18 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "0", + "box": [ + 8, + 151, + 1424, + 18 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 169, + 1424, + 36 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "label", + "id": "p.FrontendCycle.Orders.active-label", + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "Active", + "box": [ + 8, + 169, + 43, + 17 + ], + "style": [ + "inline", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "", + "box": [ + 8, + 187, + 1424, + 18 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "div", + "id": null, + "role": null, + "widget": null, + "state": [ + null, + null, + false + ], + "text": "Yes", + "box": [ + 8, + 187, + 1424, + 18 + ], + "style": [ + "block", + "static", + "16px", + "400", + "rgb(0, 0, 0)", + "rgba(0, 0, 0, 0)" + ] + }, + { + "tag": "button", + "id": null, + "role": null, + "widget": "p.FrontendCycle.Orders.saveOrder", + "state": [ + null, + "false", + false + ], + "text": "Save order", + "box": [ + 8, + 205, + 81, + 21 + ], + "style": [ + "inline-block", + "static", + "13.3333px", + "400", + "rgb(0, 0, 0)", + "rgb(239, 239, 239)" + ] + }, + { + "tag": "button", + "id": null, + "role": null, + "widget": "p.FrontendCycle.Orders.cancelOrder", + "state": [ + null, + "false", + false + ], + "text": "Cancel", + "box": [ + 89, + 205, + 58, + 21 + ], + "style": [ + "inline-block", + "static", + "13.3333px", + "400", + "rgb(0, 0, 0)", + "rgb(239, 239, 239)" + ] + }, + { + "tag": "button", + "id": null, + "role": null, + "widget": "p.FrontendCycle.Orders.refreshOrders", + "state": [ + null, + "false", + false + ], + "text": "Refresh orders", + "box": [ + 8, + 226, + 104, + 21 + ], + "style": [ + "inline-block", + "static", + "13.3333px", + "400", + "rgb(0, 0, 0)", + "rgb(239, 239, 239)" + ] + } + ] + } + } + ] +} diff --git a/examples/frontend-browser-scenario.json b/examples/frontend-browser-scenario.json new file mode 100644 index 0000000..fdf239e --- /dev/null +++ b/examples/frontend-browser-scenario.json @@ -0,0 +1,50 @@ +{ + "name": "frontend-cli-lifecycle", + "viewport": { + "width": 1440, + "height": 900 + }, + "pages": [ + { + "name": "home", + "path": "/", + "click_text": "Menu", + "text": [ + "Frontend lifecycle verified", + "Orders" + ], + "styles": [ + { + "selector": "body", + "property": "margin-top", + "equals": "0px" + }, + { + "selector": ".mxrb-page-header", + "property": "background-image", + "not": "none" + } + ] + }, + { + "name": "orders", + "click_text": "Orders", + "text": [ + "Orders created from exported Ruby", + "Reference", + "Total", + "Active", + "Save order", + "Cancel", + "Refresh orders" + ], + "styles": [ + { + "selector": ".mxrb-card", + "property": "background-color", + "equals": "rgb(255, 255, 255)" + } + ] + } + ] +} diff --git a/examples/page-chain-browser-scenario.json b/examples/page-chain-browser-scenario.json new file mode 100644 index 0000000..2bff4a7 --- /dev/null +++ b/examples/page-chain-browser-scenario.json @@ -0,0 +1,61 @@ +{ + "name": "generated-page-chains", + "viewport": { "width": 1440, "height": 900 }, + "pages": [ + { + "name": "home-menu-direct", + "path": "/", + "click_text": "Menu", + "text": ["Direct Order", "Client Order", "Hybrid Order"], + "styles": [ + { "selector": "body", "property": "margin-top", "equals": "0px" }, + { "selector": ".mxrb-page-header", "property": "background-image", "not": "none" } + ] + }, + { + "name": "direct-order", + "click_text": "Direct Order", + "text": ["Direct Order", "Reference", "Total", "Active", "Refresh"], + "styles": [ + { "selector": ".mxrb-card", "property": "background-color", "equals": "rgb(255, 255, 255)" } + ] + }, + { + "name": "direct-order-refreshed", + "click_text": "Refresh", + "text": ["Direct Order", "Reference", "Total", "Active"] + }, + { + "name": "home-menu-client", + "path": "/", + "click_text": "Menu", + "text": ["Direct Order", "Client Order", "Hybrid Order"] + }, + { + "name": "client-order", + "click_text": "Client Order", + "text": ["Client Order", "Reference", "Total", "Active", "Refresh"] + }, + { + "name": "client-order-refreshed", + "click_text": "Refresh", + "text": ["Client Order refreshed"] + }, + { + "name": "home-menu-hybrid", + "path": "/", + "click_text": "Menu", + "text": ["Direct Order", "Client Order", "Hybrid Order"] + }, + { + "name": "hybrid-order", + "click_text": "Hybrid Order", + "text": ["Hybrid Order", "Reference", "Total", "Active", "Refresh"] + }, + { + "name": "hybrid-order-refreshed", + "click_text": "Refresh", + "text": ["Hybrid Order refreshed"] + } + ] +} diff --git a/lib/mxrb.rb b/lib/mxrb.rb index 845156a..283f0fd 100644 --- a/lib/mxrb.rb +++ b/lib/mxrb.rb @@ -5,11 +5,12 @@ require_relative "mxrb/doctor" require_relative "mxrb/benchmark" require_relative "mxrb/project_lifecycle" +require_relative "mxrb/scaffold/templates" +require_relative "mxrb/scaffold/page_templates" require_relative "mxrb/initializer" require_relative "mxrb/module_initializer" require_relative "mxrb/scaffold/transaction" require_relative "mxrb/scaffold/registry" -require_relative "mxrb/scaffold/templates" require_relative "mxrb/scaffold/recipes" require_relative "mxrb/scaffold/generator" require_relative "mxrb/scaffold/help" diff --git a/lib/mxrb/compiler/page_bundle_compiler.rb b/lib/mxrb/compiler/page_bundle_compiler.rb index 34e1187..19270fc 100644 --- a/lib/mxrb/compiler/page_bundle_compiler.rb +++ b/lib/mxrb/compiler/page_bundle_compiler.rb @@ -186,15 +186,25 @@ def render_scroll_container(widget) def scroll_region(region) return { enabled: false } unless region + toggle_mode = region['ToggleMode'].to_s { enabled: true, content: raw_js(children(array(region['Widgets']))), sizeMode: region['SizeMode'].to_s.downcase, sizeValue: region['Size'], class: region.dig('Appearance', 'Class').to_s, - toggleMode: region['ToggleMode'].to_s.downcase, - initiallyOpen: !region['ToggleMode'].to_s.include?('InitiallyClosed') + toggleMode: scroll_toggle_mode(toggle_mode), + initiallyOpen: !toggle_mode.include?('InitiallyClosed') } end + def scroll_toggle_mode(value) + return 'none' if value.empty? || value == 'None' + return 'shrink' if value.start_with?('ShrinkContent') + return 'push' if value.start_with?('PushContent') + return 'slide' if value.start_with?('SlideOverContent') + + raise CompilationError, "unsupported scroll container toggle mode: #{value}" + end + def render_placeholder(widget) @uses_layout_widgets = true key = widget_key(widget) @@ -208,11 +218,16 @@ def render_placeholder(widget) def render_sidebar_toggle(widget) @uses_layout_widgets = true key = widget_key(widget) + caption = translated_text(widget.dig('CaptionTemplate', 'Template')) + tooltip = translated_text(widget['Tooltip']) props = common_props(widget).merge( '$widgetId': key, buttonId: key, class: css_class(widget), renderType: 'button', buttonClass: button_style(widget) ) - "React.createElement($SidebarToggle, #{js_props(props)})" + "React.createElement($SidebarToggle, #{js_props(props, expressions: { + caption: "TextProperty({ value: #{JSON.generate(caption)} })", + tooltip: "TextProperty({ value: #{JSON.generate(tooltip)} })" + })})" end def render_header(widget) @@ -231,7 +246,7 @@ def render_menu(widget, component) @uses_layout_widgets = true props = common_props(widget).merge( - '$widgetId': widget_key(widget), class: css_class(widget), + '$widgetId': widget_key(widget), name: widget['Name'].to_s, class: css_class(widget), menu: items.map.with_index { |item, index| compile_menu_item(item, widget, [index]) } ) props[:orientation] = widget['Orientation'].to_s.downcase if component == 'SimpleMenuBar' @@ -819,11 +834,18 @@ def allowed_roles_for(type, qualified_name) unit = @source.units_of(type).find do |candidate| "#{candidate.module_name}.#{candidate.document['Name']}" == qualified_name end - module_roles = array(unit&.document&.fetch('AllowedModuleRoles', nil)).map(&:to_s) - security = @source.documents('Security$ProjectSecurity').first - array(security&.fetch('UserRoles', nil)).filter_map do |role| - role['Name'].to_s if (array(role['ModuleRoles']).map(&:to_s) & module_roles).any? + return [] unless unit + + module_roles = array(unit.document.fetch('AllowedModuleRoles', nil)).map(&:to_s) + security = @source.documents('Security$ProjectSecurity').first || {} + user_roles = array(security.fetch('UserRoles', nil)) + if security.fetch('SecurityLevel', nil) == 'CheckNothing' + return user_roles.map { _1['Name'].to_s }.reject(&:empty?) end + + user_roles.select do |role| + (array(role['ModuleRoles']).map(&:to_s) & module_roles).any? + end.map { _1['Name'].to_s } end def close_page_config(action) diff --git a/lib/mxrb/compiler/portable_packager.rb b/lib/mxrb/compiler/portable_packager.rb index 2d133e9..a648ec2 100644 --- a/lib/mxrb/compiler/portable_packager.rb +++ b/lib/mxrb/compiler/portable_packager.rb @@ -163,6 +163,7 @@ module PortableFallbackAssets DatabaseUserName = ${?RUNTIME_PARAMS_DATABASEUSERNAME} DatabasePassword = ${?RUNTIME_PARAMS_DATABASEPASSWORD} DatabaseType = ${?RUNTIME_PARAMS_DATABASETYPE} + DatabaseUseSsl = ${?RUNTIME_PARAMS_DATABASEUSESSL} ApplicationRootUrl = ${?RUNTIME_PARAMS_APPLICATIONROOTURL} } runtime.adminUser.password = ${?RUNTIME_ADMINUSER_PASSWORD} diff --git a/lib/mxrb/compiler/web_operation_compiler.rb b/lib/mxrb/compiler/web_operation_compiler.rb index 1691a93..1c46fd7 100644 --- a/lib/mxrb/compiler/web_operation_compiler.rb +++ b/lib/mxrb/compiler/web_operation_compiler.rb @@ -357,9 +357,12 @@ def retrieve_by_microflow_operation(page_name, widget, role_sets, name) end def data_source_constants(page_name, widget, microflow_name) + entity = microflow_return_entity('MicroflowSettings' => { 'Microflow' => microflow_name }) { 'MicroflowName' => microflow_name, 'PageName' => page_name, - 'WidgetName' => "#{page_name}.#{widget['Name']}" + 'WidgetName' => "#{page_name}.#{widget['Name']}", + 'UsedAssociations' => entity ? used_associations(widget, entity) : [], + 'UsedAttributes' => entity ? used_attributes(widget, entity) : [] } end diff --git a/lib/mxrb/dsl/builder.rb b/lib/mxrb/dsl/builder.rb index a0963d1..47b11ad 100644 --- a/lib/mxrb/dsl/builder.rb +++ b/lib/mxrb/dsl/builder.rb @@ -497,6 +497,16 @@ def item(caption, page: nil, microflow: nil, icon: nil, translations: {}, &block @items << builder.to_h end + def evaluate(path) + instance_eval(File.read(path), path, 1) + end + + def evaluate_dir(dir) + return unless File.directory?(dir) + + Dir[File.join(dir, '*.rb')].sort.each { |path| evaluate(path) } + end + def to_h { name: @name, home_page: @home_page, home_microflow: @home_microflow, diff --git a/lib/mxrb/initializer.rb b/lib/mxrb/initializer.rb index 768b59d..9dfefb9 100644 --- a/lib/mxrb/initializer.rb +++ b/lib/mxrb/initializer.rb @@ -42,10 +42,29 @@ def write_scaffold(root) write(root, '.gitignore', gitignore) write(root, '.env.example', env_example) write(root, 'project.rb', project_rb) + write(File.join(root, 'app', 'navigation', 'responsive'), '.keep', '') + write_design_scaffold(root) module_root = File.join(root, 'modules', @module_name) write_module_scaffold(module_root) end + def write_design_scaffold(root) + design_files.each do |parts, template| + write(File.join(root, *parts[0...-1]), parts.last, Scaffold::Templates.render(template)) + end + end + + def design_files + { + %w[app design_system design_system.rb] => :design_system, + %w[theme web custom-variables.scss] => :theme_custom_variables, + %w[theme web main.scss] => :theme_main, + %w[theme web exclusion-variables.scss] => :theme_exclusion_variables, + %w[theme web settings.json] => :theme_settings, + %w[theme-cache web theme.compiled.css] => :theme_compiled + } + end + def write_module_scaffold(module_root) write(module_root, 'module.rb', module_rb) write(File.join(module_root, 'domain'), 'model.rb', model_rb) @@ -58,7 +77,9 @@ def write_module_scaffold(module_root) def relative_files module_prefix = File.join('modules', @module_name) ['Gemfile', '.gitignore', '.env.example', 'project.rb', - *relative_module_files.map { File.join(module_prefix, _1) }] + *relative_module_files.map { File.join(module_prefix, _1) }, + File.join('app', 'navigation', 'responsive', '.keep'), + *design_files.keys.map { File.join(*_1) }] end def relative_module_files @@ -141,9 +162,12 @@ def project_rb navigation do profile :Responsive, home_page: "#{@module_name}.Home", - app_title: "#{@module_name}" + app_title: "#{@module_name}" do + evaluate_dir File.join(__dir__, "app", "navigation", "responsive") + end end + evaluate File.join(__dir__, "app", "design_system", "design_system.rb") evaluate File.join(__dir__, "modules", "#{@module_name}", "module.rb") end RUBY diff --git a/lib/mxrb/runtime/database_workspace.rb b/lib/mxrb/runtime/database_workspace.rb index 4cb342b..77d1f10 100644 --- a/lib/mxrb/runtime/database_workspace.rb +++ b/lib/mxrb/runtime/database_workspace.rb @@ -560,6 +560,7 @@ def restart_runtime! '-e', "RUNTIME_ADMINUSER_PASSWORD=#{secret.fetch('admin_password')}", '-e', 'RUNTIME_PARAMS_DATABASETYPE=POSTGRESQL', '-e', "RUNTIME_PARAMS_DATABASEHOST=#{database_container}:5432", + '-e', "RUNTIME_PARAMS_DATABASEJDBCURL=jdbc:postgresql://#{database_container}:5432/#{DATABASE}", '-e', "RUNTIME_PARAMS_DATABASENAME=#{DATABASE}", '-e', "RUNTIME_PARAMS_DATABASEUSERNAME=#{OWNER}", '-e', "RUNTIME_PARAMS_DATABASEPASSWORD=#{secret.fetch('owner_password')}", diff --git a/lib/mxrb/scaffold/cli.rb b/lib/mxrb/scaffold/cli.rb index 5ed31fb..1cecd87 100644 --- a/lib/mxrb/scaffold/cli.rb +++ b/lib/mxrb/scaffold/cli.rb @@ -15,11 +15,12 @@ def initialize(command, argv, output: $stdout) def run return show_help if help? + return render_page_templates if page_templates? validate_action! @dry_run = @argv.delete('--dry-run') ? true : false @json = @argv.delete('--json') ? true : false - result = Generator.new(kind, scaffold_name, target: target, dry_run: @dry_run).scaffold + result = generator.scaffold render(result) rescue ArgumentError => e abort "[mxrb] error: #{e.message}" @@ -27,6 +28,25 @@ def run private + def generator + chain = extract_option('--chain') if @command == 'page' + template = extract_option('--template') if @command == 'page' + Generator.new( + kind, scaffold_name, target: target, dry_run: @dry_run, + page_chain: chain, page_template: template + ) + end + + def page_templates? = @command == 'page' && @argv.first == 'templates' + + def render_page_templates + @argv.shift + json = @argv.delete('--json') + abort "Unknown arguments: #{@argv.join(' ')}" unless @argv.empty? + + @output.puts(json ? JSON.pretty_generate(PageTemplates.payload) : PageTemplates.tree) + end + def help? @argv.first == '--help' || @argv.delete('--help') end @@ -37,7 +57,13 @@ def show_help def validate_action! action = @argv.shift - abort Help.text(@command) unless action == expected_action + abort Help.text(@command) unless accepted_actions.include?(action) + end + + def accepted_actions + return %w[new generate g] if @command == 'page' + + [expected_action] end def expected_action diff --git a/lib/mxrb/scaffold/generator.rb b/lib/mxrb/scaffold/generator.rb index 52d5e9c..21c96a4 100644 --- a/lib/mxrb/scaffold/generator.rb +++ b/lib/mxrb/scaffold/generator.rb @@ -10,6 +10,7 @@ class Generator Result = Data.define(:root, :files, :updated, :kind, :name, :dry_run) IDENTIFIER = /\A[A-Za-z][A-Za-z0-9_]*\z/ RESERVED_ENTITY_NAMES = %w[Owner ChangedBy CreatedDate ChangedDate].freeze + PAGE_CHAINS = %w[page:microflow page:nanoflow page:nanoflow:microflow].freeze LAYER_AGGREGATORS = { 'domain' => 'model.rb', 'application' => 'application.rb', @@ -17,12 +18,16 @@ class Generator 'infrastructure' => 'infrastructure.rb' }.freeze - def initialize(kind, name = nil, target: Dir.pwd, dry_run: false) + def initialize(kind, name = nil, target: Dir.pwd, dry_run: false, **page_options) @kind = kind.to_sym @name = name.to_s @root = File.expand_path(target) @transaction = Transaction.new @dry_run = dry_run + @page_chain = page_options.delete(:page_chain) + @page_template = page_options.delete(:page_template) + raise ArgumentError, "unknown generator options: #{page_options.keys.join(', ')}" unless + page_options.empty? end def scaffold @@ -37,6 +42,18 @@ def scaffold private + def page_chain + return unless @page_chain + return @page_chain if PAGE_CHAINS.include?(@page_chain) + + raise ArgumentError, + "page chain must be one of: #{PAGE_CHAINS.join(', ')}" + end + + def selected_page_template + PageTemplates.fetch(@page_template)&.name + end + def stage_registry Registry.stage( @transaction, root: @root, key: "#{@kind}:#{@name}", files: @transaction.created.dup @@ -136,6 +153,49 @@ def create_connected_module_file(module_name, directories, artifact_name, templa filename end + def create_connected_module_content(module_name, directories, artifact_name, content) + ensure_module(module_name) + filename = "#{Templates.snake_case(artifact_name)}.rb" + path = module_path(module_name, *directories, filename) + @transaction.create(path, content) + layer, *relative = directories + aggregator = module_path(module_name, layer, LAYER_AGGREGATORS.fetch(layer)) + connect_artifact(aggregator, *relative, filename) + filename + end + + def create_page_template(module_name, artifact_name, template:, chain:) + content = Templates.render( + :page_from_template, module_name:, name: artifact_name, + template:, refresh_action: page_refresh_action(module_name, artifact_name, chain) + ) + create_connected_module_content( + module_name, %w[presentation pages], artifact_name, content + ) + end + + def page_refresh_action(module_name, artifact_name, chain) + return unless chain + return "microflow: \"#{module_name}.ACT_Refresh#{artifact_name}\"" if + chain == 'page:microflow' + + "nanoflow: \"#{module_name}.NAN_Refresh#{artifact_name}\"" + end + + def create_page_navigation(module_name, artifact_name) + project = @transaction.content(project_path('project.rb')).to_s + unless project.include?('evaluate_dir File.join(__dir__, "app", "navigation", "responsive")') + raise ArgumentError, + 'page chain requires the generated Responsive navigation aggregator' + end + + filename = "#{Templates.snake_case(module_name)}_#{Templates.snake_case(artifact_name)}.rb" + @transaction.create( + project_path('app', 'navigation', 'responsive', filename), + Templates.render(:page_chain_navigation, module_name:, name: artifact_name) + ) + end + def create_project_file(directories, artifact_name, content) path = project_path(*directories, "#{Templates.snake_case(artifact_name)}.rb") @transaction.create(path, content) diff --git a/lib/mxrb/scaffold/help.rb b/lib/mxrb/scaffold/help.rb index 3053ad5..f76874f 100644 --- a/lib/mxrb/scaffold/help.rb +++ b/lib/mxrb/scaffold/help.rb @@ -15,7 +15,11 @@ module Help 'functional-test' => ['new Module.Flow', 'Create a runtime test', 'functional_tests'], 'evaluation' => ['new Name', 'Create static model checks', 'evaluations'], 'presentation' => ['init Module', 'Initialize presentation directories', 'presentation'], - 'page' => ['new Module.Page', 'Create a page', 'presentation/pages'], + 'page' => [ + 'new|generate|g Module.Page [--template NAME] [--chain CHAIN] | templates [--json]', + 'Create a page or an executable page-led vertical slice', + 'presentation or domain/application/presentation/navigation' + ], 'nanoflow' => ['new Module.Flow', 'Create a client nanoflow', 'presentation/client_actions'], 'repository' => ['new Module.Repository', 'Create repository port and adapter', 'application/infrastructure'], 'security' => ['init Module', 'Create module roles template', 'security'], @@ -38,6 +42,18 @@ module Help Documentation: https://github.com/lucamykael/mxrb/blob/main/docs/pt-BR/scaffolds.md HELP + PAGE_OPTIONS = <<~HELP + Page chains: + --chain page:microflow Page calls a microflow directly + --chain page:nanoflow Page calls a client nanoflow + --chain page:nanoflow:microflow Page calls a nanoflow that calls a microflow + + Without --chain, creates the minimal page scaffold. + + Page templates: + mxrb page templates [--json] + --template starter|blank|dashboard|form-vertical + HELP def text(command) usage, description, destination = COMMANDS.fetch(command) @@ -48,9 +64,17 @@ def text(command) Destination: #{destination} Existing files are never overwritten. Aggregators are connected automatically. + #{page_options(command)} + #{OPTIONS} HELP end + + def page_options(command) + return '' unless command == 'page' + + PAGE_OPTIONS + end end end end diff --git a/lib/mxrb/scaffold/page_templates.rb b/lib/mxrb/scaffold/page_templates.rb new file mode 100644 index 0000000..fe63cde --- /dev/null +++ b/lib/mxrb/scaffold/page_templates.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Mxrb + module Scaffold + # Audited page patterns that become ordinary editable Mendix pages. + module PageTemplates + module_function + + Entry = Data.define(:category, :name, :description, :data_backed) + ENTRIES = [ + Entry.new('General', 'starter', 'Title and page header', false), + Entry.new('General', 'blank', 'Empty responsive content area', false), + Entry.new('Dashboards', 'dashboard', 'Header and three responsive cards', false), + Entry.new('Forms', 'form-vertical', 'DataView with vertical inputs and actions', true) + ].freeze + + def fetch(name) + return unless name + + ENTRIES.find { _1.name == name.to_s } || raise( + ArgumentError, "page template must be one of: #{ENTRIES.map(&:name).join(', ')}" + ) + end + + def tree + groups = ENTRIES.group_by(&:category).to_a + lines = ['Page templates'] + groups.flat_map.with_index do |group, index| + category_lines(group, last: index == groups.size - 1) + end + lines.join("\n") + end + + def category_lines(group, last:) + category, entries = group + heading = "#{last ? '└──' : '├──'} #{category}" + children = entries.map.with_index do |entry, index| + branch = index == entries.size - 1 ? '└──' : '├──' + "#{last ? ' ' : '│ '}#{branch} #{entry.name} — #{entry.description}" + end + [heading, *children] + end + + def payload + ENTRIES.group_by(&:category).map do |category, entries| + { + category:, templates: entries.map do |entry| + { name: entry.name, description: entry.description, data_backed: entry.data_backed } + end + } + end + end + end + end +end diff --git a/lib/mxrb/scaffold/recipes.rb b/lib/mxrb/scaffold/recipes.rb index 2f682aa..58d1b14 100644 --- a/lib/mxrb/scaffold/recipes.rb +++ b/lib/mxrb/scaffold/recipes.rb @@ -32,10 +32,48 @@ def scaffold_constant def scaffold_page module_name, artifact_name = qualified_name + chain = page_chain + template = selected_page_template + return scaffold_basic_page(module_name, artifact_name) unless chain || template + + scaffold_templated_page(module_name, artifact_name, template || 'form-vertical', chain) + end + + def scaffold_templated_page(module_name, artifact_name, template, chain) ensure_presentation(module_name) - create_connected_module_file( - module_name, %w[presentation pages], artifact_name, :page - ) + page_support_specs(artifact_name, chain, template).each do |directories, name, source| + create_connected_module_file(module_name, directories, name, source) + end + create_page_template(module_name, artifact_name, template:, chain:) + create_page_navigation(module_name, artifact_name) + end + + def scaffold_basic_page(module_name, artifact_name) + ensure_presentation(module_name) + create_connected_module_file(module_name, %w[presentation pages], artifact_name, :page) + end + + def page_support_specs(name, chain, template) + specs = PageTemplates.fetch(template).data_backed ? page_chain_base_specs(name) : [] + specs << page_chain_microflow_spec(name) if chain&.end_with?('microflow') + specs << page_chain_nanoflow_spec(name, chain) if chain&.include?('nanoflow') + specs + end + + def page_chain_base_specs(name) + [ + [%w[domain entities], name, :page_chain_entity], + [%w[application use_cases], "ACT_Load#{name}", :page_chain_loader] + ] + end + + def page_chain_microflow_spec(name) + [%w[application use_cases], "ACT_Refresh#{name}", :page_chain_action] + end + + def page_chain_nanoflow_spec(name, chain) + template = chain.end_with?('microflow') ? :page_chain_client_server : :page_chain_nanoflow + [%w[presentation client_actions], "NAN_Refresh#{name}", template] end def scaffold_nanoflow @@ -114,9 +152,7 @@ def scaffold_design abort 'Usage: mxrb design init' unless @name.empty? design_assets.each do |parts, template| - @transaction.create( - project_path(*parts), Templates.render(template, module_name: nil, name: nil) - ) + ensure_file(project_path(*parts), Templates.render(template, module_name: nil, name: nil)) end connect_project_file('app', 'design_system', 'design_system.rb') end @@ -127,7 +163,8 @@ def design_assets %w[theme web custom-variables.scss] => :theme_custom_variables, %w[theme web main.scss] => :theme_main, %w[theme web exclusion-variables.scss] => :theme_exclusion_variables, - %w[theme web settings.json] => :theme_settings + %w[theme web settings.json] => :theme_settings, + %w[theme-cache web theme.compiled.css] => :theme_compiled } end diff --git a/lib/mxrb/scaffold/templates.rb b/lib/mxrb/scaffold/templates.rb index b48955f..44e0180 100644 --- a/lib/mxrb/scaffold/templates.rb +++ b/lib/mxrb/scaffold/templates.rb @@ -9,8 +9,8 @@ module Templates ENTITY_GUIDE = 'https://github.com/lucamykael/mxrb/blob/main/docs/pt-BR/entity-dsl.md' - def render(kind, module_name: nil, name: nil) - public_send(kind, module_name, name) + def render(kind, module_name: nil, name: nil, **options) + public_send(kind, module_name, name, **options) end def entity(_module_name, name) @@ -72,6 +72,192 @@ def nanoflow(_module_name, name) RUBY end + def page_chain_entity(_module_name, name) + <<~RUBY + # frozen_string_literal: true + + # Executable vertical slice generated by `mxrb page --chain`. + entity :#{name} do + string :Reference, length: 80 + decimal :Total, default: 0 + boolean :Active, default: true + end + RUBY + end + + def page_chain_loader(module_name, name) + feature = name.to_s.delete_prefix('ACT_Load') + <<~RUBY + # frozen_string_literal: true + + microflow :#{name} do + mark_as_used + return_type :#{feature} + create_object "#{module_name}.#{feature}", as: :record, + set: { Reference: "'NEW'", Total: 0, Active: true } + return_value :record + end + RUBY + end + + def page_chain_action(_module_name, name) + feature = name.to_s.delete_prefix('ACT_Refresh') + <<~RUBY + # frozen_string_literal: true + + microflow :#{name} do + mark_as_used + log_message "#{humanize(feature)} refreshed from the page scaffold" + end + RUBY + end + + def page_chain_nanoflow(_module_name, name) + feature = name.to_s.delete_prefix('NAN_Refresh') + <<~RUBY + # frozen_string_literal: true + + nanoflow :#{name} do + mark_as_used + show_message "#{humanize(feature)} refreshed", type: :information + end + RUBY + end + + def page_chain_client_server(module_name, name) + feature = name.to_s.delete_prefix('NAN_Refresh') + <<~RUBY + # frozen_string_literal: true + + nanoflow :#{name} do + mark_as_used + call_microflow "#{module_name}.ACT_Refresh#{feature}" + show_message "#{humanize(feature)} refreshed", type: :information + end + RUBY + end + + def page_from_template(module_name, name, template:, refresh_action: nil) + public_send("page_template_#{template.tr('-', '_')}", module_name, name, refresh_action) + end + + def page_template_starter(module_name, name, refresh_action) + <<~RUBY + # frozen_string_literal: true + + page :#{name} do + title "#{humanize(name)}" + layout "#{module_name}.ApplicationLayout" + + container :pageHeader, class_name: "mxrb-page-header" do + text :pageTitle, caption: "#{humanize(name)}" + text :pageSubtitle, caption: "Page generated from the starter template" + end + #{refresh_button(refresh_action)} + end + RUBY + end + + def page_template_blank(module_name, name, refresh_action) + <<~RUBY + # frozen_string_literal: true + + page :#{name} do + title "#{humanize(name)}" + layout "#{module_name}.ApplicationLayout" + + container :content, class_name: "mxrb-page-content" do + #{refresh_button(refresh_action, indentation: 4)} + end + end + RUBY + end + + def page_template_dashboard(module_name, name, refresh_action) + <<~RUBY + # frozen_string_literal: true + + page :#{name} do + title "#{humanize(name)}" + layout "#{module_name}.ApplicationLayout" + + container :pageHeader, class_name: "mxrb-page-header" do + text :pageTitle, caption: "#{humanize(name)}" + text :pageSubtitle, caption: "Dashboard overview" + end + + container :dashboard, class_name: "mxrb-dashboard-grid" do + container(:primaryMetric, class_name: "mxrb-card") do + text :primaryLabel, caption: "PRIMARY" + text :primaryValue, caption: "0" + text :primaryHelp, caption: "Connect this card to your domain data." + end + container(:secondaryMetric, class_name: "mxrb-card") do + text :secondaryLabel, caption: "SECONDARY" + text :secondaryValue, caption: "0" + text :secondaryHelp, caption: "Replace this metric with a business signal." + end + container(:activityMetric, class_name: "mxrb-card") do + text :activityLabel, caption: "ACTIVITY" + text :activityValue, caption: "Ready" + text :activityHelp, caption: "Add charts, lists or actions here." + end + end + #{refresh_button(refresh_action)} + end + RUBY + end + + def page_template_form_vertical(module_name, name, refresh_action) + <<~RUBY + # frozen_string_literal: true + + page :#{name} do + title "#{humanize(name)}" + layout "#{module_name}.ApplicationLayout" + data_source microflow: "#{module_name}.ACT_Load#{name}" + + container :pageHeader, class_name: "mxrb-page-header" do + text :pageTitle, caption: "#{humanize(name)}" + text :pageSubtitle, caption: "Executable page scaffold" + end + + container :form, class_name: "mxrb-card" do + text_box :reference, attribute: :Reference, caption: "Reference" + number_input :total, attribute: :Total, caption: "Total" + check_box :active, attribute: :Active, caption: "Active" + + button :save, caption: "Save" do + on_click action: :save_changes + end + button :cancel, caption: "Cancel" do + on_click action: :cancel_changes + end + #{refresh_button(refresh_action, indentation: 4)} + end + end + RUBY + end + + def refresh_button(action, indentation: 2) + return '' unless action + + indent = ' ' * indentation + <<~RUBY.lines.map { "#{indent}#{_1}" }.join.rstrip + button :refresh, caption: "Refresh" do + on_click #{action} + end + RUBY + end + + def page_chain_navigation(module_name, name) + <<~RUBY + # frozen_string_literal: true + + item "#{humanize(name)}", page: "#{module_name}.#{name}", icon: "file" + RUBY + end + def repository(module_name, name) <<~RUBY # frozen_string_literal: true @@ -202,9 +388,14 @@ def design_system(_module_name, _name) end def theme_main(_module_name, _name) - <<~SCSS + <<~SCSS + theme_compiled(nil, nil) @import "custom-variables"; + SCSS + end + + def theme_compiled(_module_name, _name) + <<~SCSS :root { color: #17324d; background: #f4f7fb; @@ -216,6 +407,55 @@ def theme_main(_module_name, _name) background: #f4f7fb; } + *, *::before, *::after { + box-sizing: border-box; + } + + button, + input, + select, + textarea { + font: inherit; + } + + .btn, + .mx-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 42px; + margin: 8px 8px 0 0; + padding: 10px 18px; + border: 0; + border-radius: 10px; + color: #fff; + background: #087f8c; + font-weight: 700; + cursor: pointer; + } + + input, + select, + textarea, + .form-control { + width: 100%; + min-height: 42px; + margin-top: 6px; + padding: 10px 12px; + border: 1px solid #cbd8e2; + border-radius: 10px; + color: #17324d; + background: #fff; + } + + label, + .control-label { + display: block; + margin-top: 14px; + color: #36546c; + font-weight: 650; + } + .mxrb-application-shell { min-height: 100vh; } @@ -260,7 +500,14 @@ def theme_main(_module_name, _name) padding: 20px 14px; } + .mxrb-navigation ul { + margin: 0; + padding: 0; + list-style: none; + } + .mxrb-navigation .navbar-inner > ul > li > a { + display: block; margin: 4px 0; padding: 12px 16px; color: #dceaf0; @@ -303,17 +550,20 @@ def theme_main(_module_name, _name) box-shadow: 0 16px 40px rgb(20 73 94 / 18%); } - .mxrb-page-header .mx-text { + .mxrb-page-header .mx-text, + .mxrb-page-header > span { display: block; } - .mxrb-page-header .mx-text:first-child { + .mxrb-page-header .mx-text:first-child, + .mxrb-page-header > span:first-of-type { font-size: clamp(2rem, 5vw, 3rem); font-weight: 750; letter-spacing: -0.04em; } - .mxrb-page-header .mx-text + .mx-text { + .mxrb-page-header .mx-text + .mx-text, + .mxrb-page-header > span + span { max-width: 680px; margin-top: 8px; color: rgb(255 255 255 / 82%); @@ -336,11 +586,13 @@ def theme_main(_module_name, _name) box-shadow: 0 12px 32px rgb(23 50 77 / 8%); } - .mxrb-card .mx-text { + .mxrb-card .mx-text, + .mxrb-card > span { display: block; } - .mxrb-card .mx-text:first-child { + .mxrb-card .mx-text:first-child, + .mxrb-card > span:first-of-type { margin-bottom: 14px; color: #087f8c; font-size: 0.75rem; @@ -348,14 +600,16 @@ def theme_main(_module_name, _name) letter-spacing: 0.12em; } - .mxrb-card .mx-text:nth-child(2) { + .mxrb-card .mx-text:nth-child(2), + .mxrb-card > span:nth-of-type(2) { margin-bottom: 10px; color: #17324d; font-size: 1.25rem; font-weight: 750; } - .mxrb-card .mx-text:nth-child(3) { + .mxrb-card .mx-text:nth-child(3), + .mxrb-card > span:nth-of-type(3) { color: #60768a; line-height: 1.6; } diff --git a/lib/mxrb/writer.rb b/lib/mxrb/writer.rb index a215b24..1c3daf6 100644 --- a/lib/mxrb/writer.rb +++ b/lib/mxrb/writer.rb @@ -3187,6 +3187,7 @@ def activity_action_doc(activity) }, "Type" => activity[:message_type].to_s.capitalize } when :log_message + major = @definition.fetch(:version).to_s.split('.').first.to_i { "$ID" => SecureRandom.uuid, "$Type" => "Microflows$LogMessageAction", "ErrorHandlingType" => "Rollback", "IncludeLatestStackTrace" => activity[:include_stack] == true, @@ -3198,8 +3199,9 @@ def activity_action_doc(activity) ), "Text" => activity[:message] }, - "Node" => activity[:node].to_s, - "NodeModel" => no_expression_doc } + "Node" => activity[:node] || "'MXRB'" }.tap do |document| + document['NodeModel'] = no_expression_doc if major.between?(6, 10) + end when :show_page show_form_action_doc(activity) when :close_page diff --git a/mxrb.gemspec b/mxrb.gemspec index 8cf1516..025e157 100644 --- a/mxrb.gemspec +++ b/mxrb.gemspec @@ -10,15 +10,16 @@ Gem::Specification.new do |s| s.homepage = "https://github.com/lucamykael/mxrb" s.metadata = { "source_code_uri" => s.homepage, - "documentation_uri" => "#{s.homepage}/tree/main/docs" + "documentation_uri" => "#{s.homepage}/tree/main/docs", + "rubygems_mfa_required" => "true" } s.required_ruby_version = ">= 4.0" s.files = Dir[ - "lib/**/*.rb", "bin/*", "docker/**/*", "docs/**/*.md", "marketplace/**/*", + "lib/**/*", "bin/*", "docker/**/*", "docs/**/*.md", "marketplace/**/*", "examples/**/*.rb", "README*.md", "LICENSE" - ] + ].select { File.file?(_1) } s.executables = ["mxrb"] s.require_paths = ["lib"] diff --git a/script/frontend_browser_acceptance b/script/frontend_browser_acceptance new file mode 100755 index 0000000..0d708df --- /dev/null +++ b/script/frontend_browser_acceptance @@ -0,0 +1,643 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# rubocop:disable Metrics + +require 'base64' +require 'digest' +require 'fileutils' +require 'json' +require 'open3' +require 'optparse' +require 'securerandom' +require 'socket' +require 'tempfile' +require 'timeout' +require 'uri' + +# Authenticated Chromium acceptance with deterministic layout snapshots. The +# browser is controlled through Chrome DevTools Protocol over a loopback socket, +# so the gate adds no Ruby/browser-driver dependency and never places a password +# on the process command line. +module MxrbFrontendBrowserAcceptance + class Failure < StandardError; end + + # Loopback WebSocket transport for Chrome DevTools Protocol. It implements + # only the RFC 6455 client surface needed for text CDP messages. + class ChromiumTransport + def initialize(chromium:, width:, height:) + @stderr = Tempfile.new('mxrb-browser-stderr') + @profile = Dir.mktmpdir('mxrb-browser-profile-') + command = [ + chromium, '--headless=new', '--no-sandbox', '--disable-gpu', + '--disable-background-networking', '--disable-component-update', + '--disable-crash-reporter', '--no-first-run', '--remote-debugging-port=0', + "--user-data-dir=#{@profile}", "--window-size=#{width},#{height}", 'about:blank' + ] + @pid = Process.spawn(*command, out: File::NULL, err: @stderr.path) + connect(wait_for_endpoint) + end + + def write(message) + write_frame(0x1, JSON.generate(message)) + rescue Errno::EPIPE, IOError + raise Failure, "Chromium DevTools socket closed: #{stderr_text}" + end + + def read(timeout: 15) + fragments = +'' + loop do + final, opcode, payload = read_frame(timeout:) + case opcode + when 0x0 + fragments << payload + return fragments if final + when 0x1 + fragments = payload + return fragments if final + when 0x8 + raise Failure, "Chromium DevTools WebSocket closed: #{stderr_text}" + when 0x9 + write_frame(0xA, payload) + end + end + end + + def close + write_frame(0x8, '') if @socket && !@socket.closed? + @socket&.close + terminate + @stderr.close! + FileUtils.remove_entry_secure(@profile) if File.directory?(@profile) + end + + private + + def wait_for_endpoint + path = File.join(@profile, 'DevToolsActivePort') + 100.times do + if File.file?(path) + port, endpoint = File.readlines(path, chomp: true) + return URI("ws://127.0.0.1:#{Integer(port)}#{endpoint}") + end + raise Failure, "Chromium stopped before DevTools started: #{stderr_text}" unless process_alive? + + sleep 0.05 + end + raise Failure, "Chromium DevTools endpoint timed out: #{stderr_text}" + end + + def connect(endpoint) + @socket = TCPSocket.new(endpoint.host, endpoint.port) + key = Base64.strict_encode64(SecureRandom.random_bytes(16)) + request = [ + "GET #{endpoint.request_uri} HTTP/1.1", "Host: #{endpoint.host}:#{endpoint.port}", + 'Upgrade: websocket', 'Connection: Upgrade', "Sec-WebSocket-Key: #{key}", + 'Sec-WebSocket-Version: 13', '', '' + ].join("\r\n") + @socket.write(request) + response = read_headers + raise Failure, "Chromium DevTools WebSocket handshake failed: #{response.lines.first}" unless + response.start_with?('HTTP/1.1 101') + + expected = Base64.strict_encode64( + Digest::SHA1.digest("#{key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11") + ) + accept = response[/^Sec-WebSocket-Accept:\s*(.+?)\r?$/i, 1] + raise Failure, 'Chromium DevTools WebSocket handshake signature is invalid' unless accept == expected + end + + def read_headers + content = +'' + content << @socket.readpartial(1024) until content.include?("\r\n\r\n") + content + end + + def write_frame(opcode, payload) + bytes = payload.b + header = [0x80 | opcode] + length = bytes.bytesize + if length < 126 + header << (0x80 | length) + elsif length <= 0xFFFF + header << (0x80 | 126) + header.concat([length].pack('n').bytes) + else + header << (0x80 | 127) + header.concat([length].pack('Q>').bytes) + end + mask = SecureRandom.random_bytes(4) + masked = bytes.bytes.each_with_index.map { |byte, index| byte ^ mask.getbyte(index % 4) }.pack('C*') + @socket.write(header.pack('C*') + mask + masked) + end + + def read_frame(timeout:) + header = read_exact(2, timeout:).bytes + final = (header[0] & 0x80).positive? + opcode = header[0] & 0x0F + masked = (header[1] & 0x80).positive? + length = header[1] & 0x7F + length = read_exact(2, timeout:).unpack1('n') if length == 126 + length = read_exact(8, timeout:).unpack1('Q>') if length == 127 + mask = read_exact(4, timeout:) if masked + payload = read_exact(length, timeout:) + if masked + payload = payload.bytes.each_with_index.map do |byte, index| + byte ^ mask.getbyte(index % 4) + end.pack('C*') + end + [final, opcode, payload] + end + + def read_exact(length, timeout:) + bytes = +'' + while bytes.bytesize < length + raise Failure, "Chromium DevTools response timed out: #{stderr_text}" unless + IO.select([@socket], nil, nil, timeout) + + bytes << @socket.readpartial(length - bytes.bytesize) + end + bytes + rescue EOFError + raise Failure, "Chromium DevTools socket reached EOF: #{stderr_text}" + end + + def process_alive? + Process.kill(0, @pid) + true + rescue Errno::ESRCH + false + end + + def terminate + return unless process_alive? + + Process.kill('TERM', @pid) + Timeout.timeout(5) { Process.wait(@pid) } + rescue Errno::ESRCH, Errno::ECHILD + nil + rescue Timeout::Error + Process.kill('KILL', @pid) + Process.wait(@pid) + end + + def stderr_text + @stderr.flush + File.read(@stderr.path).strip + end + end + + # Correlates CDP commands and responses while retaining browser events for + # diagnostics. Commands are deliberately synchronous for deterministic gates. + class CdpClient + attr_reader :events + + def initialize(transport) + @transport = transport + @next_id = 0 + @events = [] + end + + def call(method, params = {}, session_id: nil) + identifier = @next_id += 1 + message = { id: identifier, method:, params: } + message[:sessionId] = session_id if session_id + @transport.write(message) + loop do + response = JSON.parse(@transport.read) + if response['id'] == identifier + raise Failure, "CDP #{method} failed: #{response.fetch('error').inspect}" if response['error'] + + return response.fetch('result', {}) + end + @events << response + end + end + end + + # Small high-level browser surface used by the acceptance runner. + class Browser + def initialize(base_url:, chromium:, width:, height:, transport: nil) + @base_url = base_url.end_with?('/') ? base_url : "#{base_url}/" + @transport = transport || ChromiumTransport.new(chromium:, width:, height:) + @client = CdpClient.new(@transport) + target = @client.call('Target.createTarget', { url: 'about:blank' }).fetch('targetId') + @session = @client.call( + 'Target.attachToTarget', { targetId: target, flatten: true } + ).fetch('sessionId') + call('Page.enable') + call('Runtime.enable') + call('Console.enable') + call('Emulation.setDeviceMetricsOverride', { + width:, height:, deviceScaleFactor: 1, mobile: false + }) + @console_errors = [] + @console_event_index = 0 + end + + def login(username, password) + navigate('login.html') + wait('document.querySelector("#loginForm") !== null', 'login form') + evaluate(<<~JS) + (() => { + const username = document.querySelector("#usernameInput"); + const password = document.querySelector("#passwordInput"); + const form = document.querySelector("#loginForm"); + if (!username || !password || !form) return false; + username.value = #{JSON.generate(username)}; + password.value = #{JSON.generate(password)}; + username.dispatchEvent(new Event("input", { bubbles: true })); + password.dispatchEvent(new Event("input", { bubbles: true })); + form.requestSubmit(); + return true; + })() + JS + wait('!location.pathname.endsWith("/login.html")', 'authenticated redirect', timeout: 30) + wait('document.readyState === "complete"', 'authenticated page load', timeout: 30) + raise Failure, 'login returned to the Mendix login form' if evaluate( + 'document.querySelector("#loginForm") !== null' + ) + end + + def navigate(path) + url = URI.join(@base_url, path).to_s + call('Page.navigate', { url: }) + wait('document.readyState === "complete"', "page load #{path}", timeout: 30) + url + end + + def click_text(text) + expression = <<~JS + (() => { + const expected = #{JSON.generate(text)}; + const visible = element => { + const style = getComputedStyle(element); + const box = element.getBoundingClientRect(); + return style.display !== "none" && style.visibility !== "hidden" && + box.width > 0 && box.height > 0; + }; + const elements = [...document.querySelectorAll("a,button,[role=button],[role=menuitem]")]; + const target = elements.find(element => visible(element) && + element.textContent.replace(/\\s+/g, " ").trim().includes(expected)); + if (!target) return false; + target.click(); + return true; + })() + JS + wait(expression, "visible clickable text #{text.inspect}") + + wait('document.readyState === "complete"', "navigation after #{text.inspect}") + end + + def click_selector(selector) + expression = <<~JS + (() => { + const target = document.querySelector(#{JSON.generate(selector)}); + if (!target) return false; + const style = getComputedStyle(target); + const box = target.getBoundingClientRect(); + if (style.display === "none" || style.visibility === "hidden" || + box.width <= 0 || box.height <= 0) return false; + target.click(); + return true; + })() + JS + wait(expression, "visible clickable selector #{selector.inspect}") + end + + def wait_for_text(text) + expression = <<~JS + (() => { + const expected = #{JSON.generate(text)}; + return document.body && document.body.innerText.includes(expected); + })() + JS + wait(expression, "text #{text.inspect}") + end + + def wait_for_selector(selector) + wait( + "document.querySelector(#{JSON.generate(selector)}) !== null", + "selector #{selector.inspect}" + ) + end + + def wait_for_style(expectation) + selector = expectation.fetch('selector') + property = expectation.fetch('property') + expected = expectation['equals'] + rejected = expectation['not'] + condition = if expected + "value === #{JSON.generate(expected)}" + else + "value !== #{JSON.generate(rejected.to_s)}" + end + expression = <<~JS + (() => { + const element = document.querySelector(#{JSON.generate(selector)}); + if (!element) return false; + const value = getComputedStyle(element).getPropertyValue(#{JSON.generate(property)}).trim(); + return #{condition}; + })() + JS + wait(expression, "style #{selector} #{property}") + end + + def snapshot + evaluate(<<~'JS') + (() => { + const normalize = value => (value || "").replace(/\s+/g, " ").trim(); + const normalizeId = value => value ? + value.replace(/^scrollcontainer_[^-]+/, "scrollcontainer_dynamic") : null; + const nodes = [...document.querySelectorAll("body *")].filter(element => { + if (["SCRIPT", "STYLE", "NOSCRIPT"].includes(element.tagName)) return false; + const style = getComputedStyle(element); + const box = element.getBoundingClientRect(); + if (style.display === "none" || style.visibility === "hidden" || + box.width <= 0 || box.height <= 0) return false; + return normalize(element.innerText) || + ["INPUT", "SELECT", "TEXTAREA", "BUTTON", "IMG"].includes(element.tagName); + }); + return { + title: document.title, + path: location.pathname, + viewport: { width: innerWidth, height: innerHeight }, + elements: nodes.map(element => { + const style = getComputedStyle(element); + const box = element.getBoundingClientRect(); + const ownText = [...element.childNodes] + .filter(node => node.nodeType === Node.TEXT_NODE) + .map(node => node.textContent).join(" "); + return { + tag: element.tagName.toLowerCase(), + id: normalizeId(element.id), + role: element.getAttribute("role"), + widget: element.getAttribute("data-widget-id") || + element.getAttribute("data-button-id"), + state: [element.getAttribute("aria-expanded"), + element.getAttribute("data-disabled"), element.disabled || false], + text: normalize(ownText || element.getAttribute("aria-label") || + element.getAttribute("placeholder")).slice(0, 200), + box: [box.x, box.y, box.width, box.height].map(value => Math.round(value)), + style: [style.display, style.position, style.fontSize, style.fontWeight, + style.color, style.backgroundColor] + }; + }) + }; + })() + JS + end + + def screenshot(path) + result = call('Page.captureScreenshot', { format: 'png', fromSurface: true }) + File.binwrite(path, Base64.strict_decode64(result.fetch('data'))) + path + end + + def console_errors + collect_console_errors + @console_errors.uniq + end + + def close + logout + ensure + collect_console_errors + @transport.close + end + + private + + def call(method, params = {}) + @client.call(method, params, session_id: @session) + end + + def evaluate(expression) + response = call('Runtime.evaluate', { + expression:, returnByValue: true, awaitPromise: true + }) + raise Failure, "browser JavaScript failed: #{response.fetch('exceptionDetails').inspect}" if + response['exceptionDetails'] + + response.dig('result', 'value') + end + + def wait(expression, label, timeout: 15) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + loop do + return true if evaluate(expression) + raise Failure, "browser timed out waiting for #{label}" if + Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline + + sleep 0.1 + end + end + + def logout + started = evaluate(<<~'JS') + (() => { + if (typeof mx === "undefined" || typeof mx.logout !== "function") return false; + mx.logout(); + return true; + })() + JS + call('Page.navigate', { url: URI.join(@base_url, 'logout.html').to_s }) unless started + wait( + 'location.pathname.endsWith("/login.html") || document.querySelector("#loginForm") !== null', + 'logout', timeout: 10 + ) + rescue Failure + nil + end + + def collect_console_errors + events = @client.events.drop(@console_event_index) + @console_event_index = @client.events.length + @console_errors.concat(events.filter_map do |event| + next unless event['method'] == 'Runtime.exceptionThrown' + + event.dig('params', 'exceptionDetails', 'text') || 'browser exception' + end) + end + end + + # Applies one declarative scenario and compares stable visual/layout snapshots. + class Runner + def initialize(config:, base_url:, username:, password:, output_dir:, baseline: nil, + update_baseline: false, chromium: nil, browser: nil) + @config = config + @base_url = base_url + @username = username + @password = password + @output_dir = File.expand_path(output_dir) + @baseline = baseline && File.expand_path(baseline) + @update_baseline = update_baseline + viewport = config.fetch('viewport', {}) + @width = Integer(viewport.fetch('width', 1440)) + @height = Integer(viewport.fetch('height', 900)) + @browser = browser || Browser.new( + base_url:, chromium: chromium || self.class.chromium!, width: @width, height: @height + ) + end + + def self.chromium! + candidates = [ENV['MXRB_CHROMIUM'], '/usr/bin/chromium', '/usr/bin/chromium-browser', + '/usr/bin/google-chrome'].compact + candidates.find { File.executable?(_1) } || raise(Failure, 'Chromium is not installed') + end + + def run + validate! + FileUtils.mkdir_p(@output_dir) + @browser.login(@username, @password) + pages = @config.fetch('pages').map { capture_page(_1) } + console_errors = @browser.console_errors + raise Failure, "browser exceptions: #{console_errors.join('; ')}" unless console_errors.empty? + + current = { + 'scenario' => @config.fetch('name'), + 'viewport' => { 'width' => @width, 'height' => @height }, + 'pages' => pages.map { _1.slice('name', 'snapshot_sha256', 'snapshot') } + } + baseline = verify_or_update_baseline(current) + { + passed: true, scenario: @config.fetch('name'), base_url: @base_url, + authenticated: true, baseline:, pages:, + console_errors: + } + rescue StandardError => e + { passed: false, scenario: @config['name'], base_url: @base_url, error: e.message } + ensure + @browser&.close + end + + private + + def validate! + raise Failure, 'browser scenario needs a name' if @config['name'].to_s.empty? + raise Failure, 'browser scenario needs at least one page' if Array(@config['pages']).empty? + raise Failure, 'browser username is empty' if @username.to_s.empty? + raise Failure, 'browser password is empty' if @password.to_s.empty? + raise Failure, '--update-baseline requires --baseline' if @update_baseline && !@baseline + end + + def capture_page(page) + @browser.navigate(page['path']) if page['path'] + @browser.click_selector(page.fetch('click_selector')) if page['click_selector'] + @browser.click_text(page.fetch('click_text')) if page['click_text'] + Array(page['text']).each { @browser.wait_for_text(_1) } + Array(page['selectors']).each { @browser.wait_for_selector(_1) } + Array(page['styles']).each { @browser.wait_for_style(_1) } + snapshot = @browser.snapshot + failure_markers = ['Could not render widget', + 'An error occurred, please contact your system administrator.'] + render_errors = Array(snapshot['elements']).filter_map do |element| + text = element['text'].to_s + text if failure_markers.any? { text.include?(_1) } + end + raise Failure, "visible widget render failure: #{render_errors.uniq.join('; ')}" unless + render_errors.empty? + + digest = Digest::SHA256.hexdigest(JSON.generate(snapshot)) + name = page.fetch('name') + screenshot = File.join(@output_dir, "#{safe_name(name)}.png") + @browser.screenshot(screenshot) + { + 'name' => name, 'url' => page['path'], 'snapshot' => snapshot, + 'snapshot_sha256' => digest, 'screenshot' => screenshot, + 'screenshot_sha256' => Digest::SHA256.file(screenshot).hexdigest + } + end + + def verify_or_update_baseline(current) + return { 'mode' => 'evidence_only' } unless @baseline + + if @update_baseline + FileUtils.mkdir_p(File.dirname(@baseline)) + File.write(@baseline, "#{JSON.pretty_generate(current)}\n") + return { 'mode' => 'updated', 'path' => @baseline } + end + + expected = JSON.parse(File.read(@baseline)) + return { 'mode' => 'matched', 'path' => @baseline } if expected == current + + changed = changed_pages(expected, current) + raise Failure, "visual baseline differs for: #{changed.join(', ')}" + rescue Errno::ENOENT + raise Failure, "visual baseline does not exist: #{@baseline}; use --update-baseline" + end + + def changed_pages(expected, current) + expected_pages = Array(expected['pages']).to_h { [_1['name'], _1['snapshot_sha256']] } + current_pages = Array(current['pages']).to_h { [_1['name'], _1['snapshot_sha256']] } + (expected_pages.keys | current_pages.keys).reject do |name| + expected_pages[name] == current_pages[name] + end + end + + def safe_name(name) + normalized = name.to_s.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-+|-+\z/, '') + raise Failure, "invalid page name: #{name.inspect}" if normalized.empty? + + normalized + end + end + + # Command-line boundary and secret-file parsing. + module CLI + module_function + + def run(argv) + options = { username: 'MxAdmin', output_dir: 'tmp/frontend-browser' } + parser = OptionParser.new do |cli| + cli.banner = 'Usage: script/frontend_browser_acceptance --scenario FILE --url URL [options]' + cli.on('--scenario FILE', 'Declarative browser scenario JSON') { options[:scenario] = _1 } + cli.on('--url URL', 'Running Mendix Runtime base URL') { options[:base_url] = _1 } + cli.on('--username NAME', 'Runtime user name (default: MxAdmin)') { options[:username] = _1 } + cli.on('--password-file FILE', 'Plain password or credentials.json; never logged') do + options[:password_file] = _1 + end + cli.on('--chromium FILE', 'Chromium/Chrome executable') { options[:chromium] = _1 } + cli.on('--baseline FILE', 'Committed deterministic visual snapshot') { options[:baseline] = _1 } + cli.on('--update-baseline', 'Explicitly create or replace the baseline') do + options[:update_baseline] = true + end + cli.on('--output-dir DIR', 'Screenshots and JSON evidence directory') do + options[:output_dir] = _1 + end + cli.on('-o', '--output FILE', 'JSON evidence path') { options[:output] = _1 } + end + parser.parse!(argv) + raise OptionParser::MissingArgument, '--scenario' unless options[:scenario] + raise OptionParser::MissingArgument, '--url' unless options[:base_url] + + config = JSON.parse(File.read(options.fetch(:scenario))) + password = password(options[:password_file]) + report = Runner.new( + config:, base_url: options.fetch(:base_url), username: options.fetch(:username), + password:, output_dir: options.fetch(:output_dir), baseline: options[:baseline], + update_baseline: options.fetch(:update_baseline, false), chromium: options[:chromium] + ).run + json = "#{JSON.pretty_generate(report)}\n" + options[:output] ? File.write(options[:output], json) : puts(json) + report.fetch(:passed) ? 0 : 1 + rescue OptionParser::ParseError, JSON::ParserError, Errno::ENOENT, Failure => e + warn JSON.pretty_generate(passed: false, error: e.message) + 1 + end + + def password(path) + return ENV.fetch('MXRB_BROWSER_PASSWORD', '') unless path + + content = File.read(path) + parsed = JSON.parse(content) + parsed.fetch('admin_password', parsed['password']).to_s + rescue JSON::ParserError + content.strip + end + end +end + +exit MxrbFrontendBrowserAcceptance::CLI.run(ARGV) if $PROGRAM_NAME == __FILE__ + +# rubocop:enable Metrics diff --git a/spec/compiler_spec.rb b/spec/compiler_spec.rb index cc7503e..f24d409 100644 --- a/spec/compiler_spec.rb +++ b/spec/compiler_spec.rb @@ -335,6 +335,9 @@ def create_runtime(root) expect(zip.get_entry('bin/start').unix_perms).to eq(0o755) expect(zip.read('etc/variables.conf')).to include('${?M2EE_ADMIN_PASS}') expect(zip.read('etc/variables.conf')).to include('${?RUNTIME_ADMINUSER_PASSWORD}') + expect(zip.read('etc/variables.conf')).to include( + '${?RUNTIME_PARAMS_DATABASEJDBCURL}', '${?RUNTIME_PARAMS_DATABASEUSESSL}' + ) expect(zip.read('etc/example.conf')).to include('DatabaseType = HSQLDB') end end diff --git a/spec/database_workspace_spec.rb b/spec/database_workspace_spec.rb index 5bf1e91..188370c 100644 --- a/spec/database_workspace_spec.rb +++ b/spec/database_workspace_spec.rb @@ -131,6 +131,10 @@ def workspace(path, state, &runner) expect(commands.flatten).to include( a_string_starting_with('POSTGRES_PASSWORD='), 'RUNTIME_PARAMS_DATABASETYPE=POSTGRESQL', + a_string_matching( + %r{\ARUNTIME_PARAMS_DATABASEJDBCURL=jdbc:postgresql://mxrb-[0-9a-f]{12}-postgres:5432/mxrb\z} + ), + 'RUNTIME_PARAMS_DATABASEUSESSL=false', a_string_including( "pg_advisory_xact_lock(hashtext('mxrb.configure_reader'))", 'GRANT pg_read_all_stats TO mxrb_reader' diff --git a/spec/frontend_browser_acceptance_spec.rb b/spec/frontend_browser_acceptance_spec.rb new file mode 100644 index 0000000..92e5b7d --- /dev/null +++ b/spec/frontend_browser_acceptance_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +load File.expand_path('../script/frontend_browser_acceptance', __dir__) + +# rubocop:disable Metrics/BlockLength +RSpec.describe MxrbFrontendBrowserAcceptance::Runner do + let(:config) do + { + 'name' => 'authenticated-smoke', + 'viewport' => { 'width' => 1280, 'height' => 720 }, + 'pages' => [{ + 'name' => 'home', 'path' => '/', 'click_selector' => '#menu', + 'click_text' => 'Orders', 'text' => ['Welcome'], 'selectors' => ['main'], + 'styles' => [{ 'selector' => 'body', 'property' => 'margin-top', 'equals' => '0px' }] + }] + } + end + let(:snapshot) do + { + 'title' => 'App', 'path' => '/', + 'viewport' => { 'width' => 1280, 'height' => 720 }, + 'elements' => [{ 'tag' => 'main', 'text' => 'Welcome' }] + } + end + + def fake_browser(snapshot:, console_errors: []) + instance_double(MxrbFrontendBrowserAcceptance::Browser).tap do |browser| + allow(browser).to receive_messages( + login: nil, navigate: nil, click_selector: nil, click_text: nil, + wait_for_text: nil, wait_for_selector: nil, wait_for_style: nil, snapshot:, + console_errors:, close: nil + ) + allow(browser).to receive(:screenshot) do |path| + File.binwrite(path, 'stable png') + end + end + end + + def run(browser, root, baseline: nil, update_baseline: false) + described_class.new( + config:, base_url: 'http://127.0.0.1:8080/', username: 'MxAdmin', password: 'secret', + output_dir: File.join(root, 'evidence'), baseline:, update_baseline:, browser: + ).run + end + + it 'updates and then matches deterministic authenticated visual evidence' do + Dir.mktmpdir do |root| + baseline = File.join(root, 'baseline.json') + browser = fake_browser(snapshot:) + updated = run(browser, root, baseline:, update_baseline: true) + matched = run(fake_browser(snapshot:), root, baseline:) + + expect(updated.dig(:baseline, 'mode')).to eq('updated') + expect(matched.dig(:baseline, 'mode')).to eq('matched') + expect(matched).to include(passed: true, authenticated: true, console_errors: []) + expect(File).to exist(File.join(root, 'evidence', 'home.png')) + expect(browser).to have_received(:wait_for_style).with( + 'selector' => 'body', 'property' => 'margin-top', 'equals' => '0px' + ) + end + end + + it 'reports changed snapshots without replacing the baseline' do + Dir.mktmpdir do |root| + baseline = File.join(root, 'baseline.json') + run(fake_browser(snapshot:), root, baseline:, update_baseline: true) + changed = snapshot.merge('title' => 'Changed') + report = run(fake_browser(snapshot: changed), root, baseline:) + + expect(report).to include(passed: false) + expect(report.fetch(:error)).to include('visual baseline differs for: home') + end + end + + it 'rejects visible widget failures and browser exceptions' do + Dir.mktmpdir do |root| + broken = snapshot.merge( + 'elements' => [{ 'tag' => 'div', 'text' => "Could not render widget 'Demo.bad'" }] + ) + visible_failure = run(fake_browser(snapshot: broken), root) + browser_failure = run(fake_browser(snapshot:, console_errors: ['Uncaught Error']), root) + + expect(visible_failure.fetch(:error)).to include('visible widget render failure') + expect(browser_failure.fetch(:error)).to include('browser exceptions: Uncaught Error') + end + end + + it 'rejects the generic Mendix runtime error dialog' do + Dir.mktmpdir do |root| + broken = snapshot.merge('elements' => [{ + 'tag' => 'p', 'text' => 'An error occurred, please contact your system administrator.' + }]) + + expect(run(fake_browser(snapshot: broken), root).fetch(:error)) + .to include('visible widget render failure') + end + end +end + +RSpec.describe MxrbFrontendBrowserAcceptance::CdpClient do + it 'correlates responses while retaining intervening browser events' do + transport = instance_double(MxrbFrontendBrowserAcceptance::ChromiumTransport) + event = { method: 'Runtime.exceptionThrown', params: { exceptionDetails: { text: 'boom' } } } + allow(transport).to receive(:write) + allow(transport).to receive(:read).and_return( + JSON.generate(event), JSON.generate(id: 1, result: { value: true }) + ) + + client = described_class.new(transport) + expect(client.call('Runtime.evaluate')).to eq('value' => true) + expect(client.events).to eq([JSON.parse(JSON.generate(event))]) + expect(transport).to have_received(:write).with(id: 1, method: 'Runtime.evaluate', params: {}) + end + + it 'turns CDP error responses into acceptance failures' do + transport = instance_double(MxrbFrontendBrowserAcceptance::ChromiumTransport) + allow(transport).to receive(:write) + allow(transport).to receive(:read).and_return(JSON.generate(id: 1, error: { message: 'bad' })) + + expect { described_class.new(transport).call('Page.navigate') } + .to raise_error(MxrbFrontendBrowserAcceptance::Failure, /CDP Page.navigate failed/) + end +end + +RSpec.describe MxrbFrontendBrowserAcceptance::Browser do + it 'waits for positive and negative computed-style contracts' do + browser = described_class.allocate + allow(browser).to receive(:wait).and_return(true) + + browser.wait_for_style( + 'selector' => 'body', 'property' => 'margin-top', 'equals' => '0px' + ) + browser.wait_for_style( + 'selector' => '.mxrb-page-header', 'property' => 'background-image', 'not' => 'none' + ) + + expect(browser).to have_received(:wait).twice + end +end + +RSpec.describe MxrbFrontendBrowserAcceptance::CLI do + it 'reads plain and JSON credential files without putting secrets in options' do + Dir.mktmpdir do |root| + plain = File.join(root, 'plain') + json = File.join(root, 'credentials.json') + File.write(plain, "plain-secret\n") + File.write(json, JSON.generate(admin_password: 'json-secret')) + + expect(described_class.password(plain)).to eq('plain-secret') + expect(described_class.password(json)).to eq('json-secret') + end + end + + it 'uses the dedicated environment variable when no password file is supplied' do + previous = ENV.fetch('MXRB_BROWSER_PASSWORD', nil) + ENV['MXRB_BROWSER_PASSWORD'] = 'environment-secret' + expect(described_class.password(nil)).to eq('environment-secret') + ensure + previous ? ENV['MXRB_BROWSER_PASSWORD'] = previous : ENV.delete('MXRB_BROWSER_PASSWORD') + end +end +# rubocop:enable Metrics/BlockLength diff --git a/spec/initializer_spec.rb b/spec/initializer_spec.rb index ec44515..03bf259 100644 --- a/spec/initializer_spec.rb +++ b/spec/initializer_spec.rb @@ -16,7 +16,14 @@ 'modules/VetClinic/application/application.rb', 'modules/VetClinic/domain/entities/.keep', 'modules/VetClinic/presentation/presentation.rb', - 'modules/VetClinic/presentation/pages/home.rb' + 'modules/VetClinic/presentation/pages/home.rb', + 'app/navigation/responsive/.keep', + 'app/design_system/design_system.rb', + 'theme/web/custom-variables.scss', + 'theme/web/main.scss', + 'theme/web/exclusion-variables.scss', + 'theme/web/settings.json', + 'theme-cache/web/theme.compiled.css' ].map { File.join(result.root, _1) } expect(result.root).to eq(File.join(dir, 'vet_clinic')) expect(result.files).to eq(expected) @@ -28,6 +35,8 @@ 'require "dotenv/load"', 'Mxrb.define', 'mendix_version "11.12.1"', 'home_page: "VetClinic.Home"', 'app_title: "VetClinic"', + 'evaluate_dir File.join(__dir__, "app", "navigation", "responsive")', + 'app", "design_system", "design_system.rb"', 'modules", "VetClinic", "module.rb"' ) expect(File.read(expected[4])).to include( @@ -53,6 +62,9 @@ 'page :Home', 'title "VetClinic"', 'layout "VetClinic.ApplicationLayout"', 'class_name: "mxrb-dashboard-grid"', 'caption: "Model your business"' ) + expect(File.read(expected[16])).to include( + '.mxrb-page-header', 'background: linear-gradient', '.mxrb-card' + ) load expected[3] mpr = File.join(result.root, 'VetClinic.mpr') diff --git a/spec/navigation_design_spec.rb b/spec/navigation_design_spec.rb index da15bc3..54dc2d7 100644 --- a/spec/navigation_design_spec.rb +++ b/spec/navigation_design_spec.rb @@ -6,6 +6,12 @@ require 'tmpdir' RSpec.describe 'native navigation and design systems' do + it 'silently skips a missing Responsive navigation fragment directory' do + builder = Mxrb::Dsl::NavigationProfileBuilder.new(:Responsive) + + expect(builder.evaluate_dir('/path/that/does/not/exist')).to be_nil + end + def define_native_project(path) Mxrb.define(path) do mendix_version '10.18.0' diff --git a/spec/page_bundle_compiler_spec.rb b/spec/page_bundle_compiler_spec.rb index 961babb..bc1e7ac 100644 --- a/spec/page_bundle_compiler_spec.rb +++ b/spec/page_bundle_compiler_spec.rb @@ -99,6 +99,51 @@ expect(bundle.unsupported_widgets).to be_empty end + it 'supplies complete Runtime contracts for sidebar toggles and navigation menus' do + compiler = described_class.new(Mxrb::Compiler::SourceModel.read(@mpr)) + compiler.instance_variable_set(:@qualified_name, 'Demo.Shell') + compiler.instance_variable_set(:@scope_prefix, 'l') + toggle = compiler.send( + :render_sidebar_toggle, + '$Type' => 'Forms$SidebarToggleButton', 'Name' => 'sidebarToggle', + 'ButtonStyle' => 'Primary', + 'CaptionTemplate' => { 'Template' => { 'Items' => [ + { 'LanguageCode' => 'en_US', 'Text' => 'Menu' } + ] } }, + 'Tooltip' => { 'Items' => [{ 'LanguageCode' => 'en_US', 'Text' => 'Open navigation' }] } + ) + + expect(toggle).to include( + 'React.createElement($SidebarToggle', '"buttonClass": "btn-primary"', + '"caption": TextProperty({ value: "Menu" })', + '"tooltip": TextProperty({ value: "Open navigation" })' + ) + allow(compiler).to receive(:menu_items).and_return([]) + expect(compiler.send(:render_menu, { + '$Type' => 'Forms$NavigationTree', 'Name' => 'navigation', + 'MenuSource' => { '$Type' => 'Forms$NavigationSource', 'NavigationProfile' => 'Responsive' } + }, 'NavigationTree')).to include('"name": "navigation"') + end + + it 'maps Mendix scroll-region enums to the Runtime toggle contract' do + compiler = described_class.new(Mxrb::Compiler::SourceModel.read(@mpr)) + + expect(compiler.send(:scroll_toggle_mode, '')).to eq('none') + expect(compiler.send(:scroll_toggle_mode, 'None')).to eq('none') + expect(compiler.send(:scroll_toggle_mode, 'ShrinkContentInitiallyClosed')).to eq('shrink') + expect(compiler.send(:scroll_toggle_mode, 'PushContentInitiallyOpen')).to eq('push') + expect(compiler.send(:scroll_toggle_mode, 'SlideOverContentInitiallyClosed')).to eq('slide') + expect { compiler.send(:scroll_toggle_mode, 'FutureMode') } + .to raise_error(Mxrb::CompilationError, /unsupported scroll container toggle mode/) + end + + it 'authorizes every project role when Mendix security checks are disabled' do + compiler = described_class.new(Mxrb::Compiler::SourceModel.read(@mpr)) + + expect(compiler.send(:allowed_roles_for, 'Forms$Page', 'Demo.Home')) + .to eq(['Administrator']) + end + it 'normalizes explicit render modes and translation fallbacks' do compiler = described_class.new(Mxrb::Compiler::SourceModel.read(@mpr)) expect(compiler.send(:render_mode, 'RenderMode' => 'Section')).to eq('section') @@ -941,13 +986,20 @@ popup_page = Struct.new(:document).new({ 'FormCall' => { 'Form' => 'Demo.Popup' } }) expect(compiler.send(:page_location, popup_page)).to eq('modal') allow(source).to receive(:documents).with('Security$ProjectSecurity').and_return([{ - 'UserRoles' => [2, { 'Name' => 'Admin', 'ModuleRoles' => [1, 'Demo.Admin'] }] + 'UserRoles' => [2, + { 'Name' => 'Admin', 'ModuleRoles' => [1, 'Demo.Admin'] }, + { 'Name' => 'User', 'ModuleRoles' => [1, 'Demo.User'] }] }]) role_unit = Struct.new(:module_name, :document).new('Demo', { 'Name' => 'Secure', 'AllowedModuleRoles' => [1, 'Demo.Admin'] }) allow(source).to receive(:units_of).with('Forms$Page').and_return([role_unit]) expect(compiler.send(:allowed_roles_for, 'Forms$Page', 'Demo.Secure')).to eq(['Admin']) + allow(source).to receive(:documents).with('Security$ProjectSecurity').and_return([{ + 'SecurityLevel' => 'CheckNothing', + 'UserRoles' => [2, { 'Name' => 'Admin' }, { 'Name' => '' }] + }]) + expect(compiler.send(:allowed_roles_for, 'Forms$Page', 'Demo.Secure')).to eq(['Admin']) allow(source).to receive(:documents).with('Security$ProjectSecurity').and_return([]) expect(compiler.send(:allowed_roles_for, 'Forms$Page', 'Demo.Secure')).to eq([]) diff --git a/spec/scaffold_spec.rb b/spec/scaffold_spec.rb index 0518256..853628a 100644 --- a/spec/scaffold_spec.rb +++ b/spec/scaffold_spec.rb @@ -85,6 +85,222 @@ def scaffold(root, kind, name = nil) end end + it 'scaffolds an executable page to nanoflow to microflow chain' do + Dir.mktmpdir do |dir| + root = project_in(dir) + result = described_class.new( + :page, 'ScaffoldApp.Order', target: root, page_chain: 'page:nanoflow:microflow' + ).scaffold + + expected = %w[ + modules/ScaffoldApp/domain/entities/order.rb + modules/ScaffoldApp/application/use_cases/act_load_order.rb + modules/ScaffoldApp/application/use_cases/act_refresh_order.rb + modules/ScaffoldApp/presentation/client_actions/nan_refresh_order.rb + modules/ScaffoldApp/presentation/pages/order.rb + app/navigation/responsive/scaffold_app_order.rb + ].map { File.join(root, _1) } + expect(result.files).to include(*expected) + expect(File.read(expected[0])).to include( + 'entity :Order', 'string :Reference', 'decimal :Total', 'boolean :Active' + ) + expect(File.read(expected[1])).to include( + 'microflow :ACT_LoadOrder', 'create_object "ScaffoldApp.Order"', + 'return_value :record' + ) + expect(File.read(expected[2])).to include( + 'microflow :ACT_RefreshOrder', + 'log_message "Order refreshed from the page scaffold"' + ) + expect(File.read(expected[3])).to include( + 'nanoflow :NAN_RefreshOrder', 'show_message "Order refreshed"', + 'call_microflow "ScaffoldApp.ACT_RefreshOrder"' + ) + expect(File.read(expected[4])).to include( + 'page :Order', 'data_source microflow: "ScaffoldApp.ACT_LoadOrder"', + 'number_input :total', 'action: :save_changes', + 'nanoflow: "ScaffoldApp.NAN_RefreshOrder"' + ) + expect(File.read(expected[5])).to include( + 'item "Order", page: "ScaffoldApp.Order", icon: "file"' + ) + + load File.join(root, 'project.rb') + mpr = File.join(root, 'ScaffoldApp.mpr') + expect(Mxrb.validate(mpr)).to be_valid + source = Mxrb::Compiler::SourceModel.read(mpr) + expect(Mxrb::Compiler::CompatibilityAnalyzer.new(mpr, source:).analyze).to be_compatible + Mxrb.open(mpr) do |project| + mod = project.modules.first + expect(mod.entities.map(&:name)).to eq(['Order']) + expect(mod.pages.map(&:name)).to contain_exactly('Home', 'Order') + expect(mod.microflows.map(&:name)).to include('ACT_LoadOrder', 'ACT_RefreshOrder') + expect(mod.nanoflows.map(&:name)).to include('NAN_RefreshOrder') + expect(project.navigation.profiles.first.menu_items.first[:page]).to eq('ScaffoldApp.Order') + end + end + end + + it 'scaffolds a page that calls a microflow directly when requested' do + Dir.mktmpdir do |dir| + root = project_in(dir) + result = described_class.new( + :page, 'ScaffoldApp.Order', target: root, page_chain: 'page:microflow' + ).scaffold + page = File.join(root, 'modules/ScaffoldApp/presentation/pages/order.rb') + nanoflow = File.join( + root, 'modules/ScaffoldApp/presentation/client_actions/nan_refresh_order.rb' + ) + + expect(File.read(page)).to include( + 'on_click microflow: "ScaffoldApp.ACT_RefreshOrder"' + ) + expect(result.files).not_to include(nanoflow) + expect(File).not_to exist(nanoflow) + + load File.join(root, 'project.rb') + mpr = File.join(root, 'ScaffoldApp.mpr') + expect(Mxrb.validate(mpr)).to be_valid + source = Mxrb::Compiler::SourceModel.read(mpr) + expect(Mxrb::Compiler::CompatibilityAnalyzer.new(mpr, source:).analyze).to be_compatible + Mxrb.open(mpr) do |project| + mod = project.modules.first + expect(mod.microflows.map(&:name)).to include('ACT_LoadOrder', 'ACT_RefreshOrder') + expect(mod.nanoflows).to be_empty + end + end + end + + it 'scaffolds a page that calls a client nanoflow without a server action' do + Dir.mktmpdir do |dir| + root = project_in(dir) + result = described_class.new( + :page, 'ScaffoldApp.Order', target: root, page_chain: 'page:nanoflow' + ).scaffold + nanoflow = File.join( + root, 'modules/ScaffoldApp/presentation/client_actions/nan_refresh_order.rb' + ) + action = File.join(root, 'modules/ScaffoldApp/application/use_cases/act_refresh_order.rb') + page = File.join(root, 'modules/ScaffoldApp/presentation/pages/order.rb') + + expect(File.read(nanoflow)).to include( + 'nanoflow :NAN_RefreshOrder', 'show_message "Order refreshed"' + ) + expect(File.read(nanoflow)).not_to include('call_microflow') + expect(File.read(page)).to include('on_click nanoflow: "ScaffoldApp.NAN_RefreshOrder"') + expect(result.files).not_to include(action) + expect(File).not_to exist(action) + + load File.join(root, 'project.rb') + mpr = File.join(root, 'ScaffoldApp.mpr') + expect(Mxrb.validate(mpr)).to be_valid + source = Mxrb::Compiler::SourceModel.read(mpr) + expect(Mxrb::Compiler::CompatibilityAnalyzer.new(mpr, source:).analyze).to be_compatible + Mxrb.open(mpr) do |project| + mod = project.modules.first + expect(mod.microflows.map(&:name)).to eq(['ACT_LoadOrder']) + expect(mod.nanoflows.map(&:name)).to eq(['NAN_RefreshOrder']) + end + end + end + + it 'lists and materializes every audited page template as editable Mendix pages' do + Dir.mktmpdir do |dir| + root = project_in(dir) + templates = { + 'starter' => 'StarterPage', 'blank' => 'BlankPage', + 'dashboard' => 'DashboardPage', 'form-vertical' => 'FormPage' + } + templates.each do |template, name| + described_class.new( + :page, "ScaffoldApp.#{name}", target: root, page_template: template + ).scaffold + end + + dashboard = File.join(root, 'modules/ScaffoldApp/presentation/pages/dashboard_page.rb') + form = File.join(root, 'modules/ScaffoldApp/presentation/pages/form_page.rb') + expect(File.read(dashboard)).to include( + 'mxrb-dashboard-grid', 'PRIMARY', 'SECONDARY', 'ACTIVITY' + ) + expect(File.read(form)).to include( + 'data_source microflow: "ScaffoldApp.ACT_LoadFormPage"' + ) + expect(File).to exist(File.join(root, 'modules/ScaffoldApp/domain/entities/form_page.rb')) + + load File.join(root, 'project.rb') + mpr = File.join(root, 'ScaffoldApp.mpr') + expect(Mxrb.validate(mpr)).to be_valid + source = Mxrb::Compiler::SourceModel.read(mpr) + expect(Mxrb::Compiler::CompatibilityAnalyzer.new(mpr, source:).analyze).to be_compatible + Mxrb.open(mpr) do |project| + expect(project.modules.first.pages.map(&:name)).to include(*templates.values) + end + end + end + + it 'combines a dashboard template with a page to nanoflow chain' do + Dir.mktmpdir do |dir| + root = project_in(dir) + described_class.new( + :page, 'ScaffoldApp.Operations', target: root, + page_template: 'dashboard', page_chain: 'page:nanoflow' + ).scaffold + page = File.join(root, 'modules/ScaffoldApp/presentation/pages/operations.rb') + + expect(File.read(page)).to include( + 'mxrb-dashboard-grid', 'on_click nanoflow: "ScaffoldApp.NAN_RefreshOperations"' + ) + expect(File).not_to exist( + File.join(root, 'modules/ScaffoldApp/domain/entities/operations.rb') + ) + load File.join(root, 'project.rb') + expect(Mxrb.validate(File.join(root, 'ScaffoldApp.mpr'))).to be_valid + end + end + + it 'rejects unsupported page chains before creating files' do + Dir.mktmpdir do |dir| + root = project_in(dir) + + expect do + described_class.new( + :page, 'ScaffoldApp.Order', target: root, page_chain: 'page:microflow:nanoflow' + ).scaffold + end.to raise_error(ArgumentError, /page chain must be one of/) + expect do + described_class.new( + :page, 'ScaffoldApp.Other', target: root, page_template: 'unknown' + ).scaffold + end.to raise_error(ArgumentError, /page template must be one of/) + expect do + described_class.new(:entity, 'ScaffoldApp.Other', target: root, unsupported: true) + end.to raise_error(ArgumentError, /unknown generator options/) + expect(File).not_to exist(File.join(root, 'modules/ScaffoldApp/domain/entities/order.rb')) + end + end + + it 'fails atomically when page-chain navigation is not connected' do + Dir.mktmpdir do |dir| + root = project_in(dir) + project = File.join(root, 'project.rb') + File.write( + project, + File.read(project).sub( + 'evaluate_dir File.join(__dir__, "app", "navigation", "responsive")', + '# navigation aggregator intentionally absent' + ) + ) + + expect do + described_class.new( + :page, 'ScaffoldApp.Order', target: root, page_chain: 'page:nanoflow:microflow' + ).scaffold + end + .to raise_error(ArgumentError, /generated Responsive navigation aggregator/) + expect(File).not_to exist(File.join(root, 'modules/ScaffoldApp/domain/entities/order.rb')) + end + end + it 'does not duplicate project security and diagnoses malformed project definitions' do Dir.mktmpdir do |dir| root = project_in(dir) @@ -143,10 +359,7 @@ def scaffold(root, kind, name = nil) expect(definition.tests.first.target).to eq('ScaffoldApp.ACT_CreateAnimal') expect(File.read(evaluation.files.fetch(0))).to include('no_call_cycles') expect(File.read(ci.files.fetch(0))).to include('ruby/setup-ruby@v1', 'bundle exec rspec') - expect(design.files.map { File.basename(_1) }).to include( - 'design_system.rb', 'custom-variables.scss', 'main.scss', - 'exclusion-variables.scss', 'settings.json' - ) + expect(design.files).to be_empty expect(File.read(File.join(root, 'theme', 'web', 'settings.json'))) .to include('theme.compiled.css') @@ -267,6 +480,26 @@ def scaffold(root, kind, name = nil) expect(File.binread(project)).to eq(project_before) end end + + it 'stages a missing project-level aggregator connection' do + Dir.mktmpdir do |dir| + root = project_in(dir) + project = File.join(root, 'project.rb') + source = File.binread(project).sub( + /^\s*evaluate File\.join\(__dir__, "app", "design_system", "design_system\.rb"\)\n/, + '' + ) + File.binwrite(project, source) + generator = described_class.new(:design, target: root) + + generator.send(:connect_project_file, 'app', 'design_system', 'design_system.rb') + + transaction = generator.instance_variable_get(:@transaction) + expect(transaction.updated).to eq([project]) + expect(transaction.content(project)) + .to include('evaluate File.join(__dir__, "app", "design_system", "design_system.rb")') + end + end end RSpec.describe Mxrb::Scaffold::Transaction do @@ -340,6 +573,66 @@ def scaffold(root, kind, name = nil) end end + it 'accepts new, generate, and g plus every chain for the page scaffold' do + Dir.mktmpdir do |dir| + root = Mxrb::Initializer.new('cli_app').scaffold(into: dir).root + { + 'new' => 'page:microflow', + 'generate' => 'page:nanoflow', + 'g' => 'page:nanoflow:microflow' + }.each do |action, chain| + output = StringIO.new + described_class.new( + 'page', [ + action, 'CliApp.Order', '--chain', chain, '--target', root, '--dry-run' + ], output: + ).run + expect(output.string).to include('would create', 'order.rb') + end + + minimal = StringIO.new + described_class.new( + 'page', ['new', 'CliApp.Order', '--target', root, '--dry-run'], output: minimal + ).run + expect(minimal.string).to include('would create', 'order.rb') + expect(minimal.string).not_to include('act_refresh_order.rb') + end + end + + it 'renders the page-template catalog as a tree and JSON' do + tree = StringIO.new + described_class.new('page', ['templates'], output: tree).run + expect(tree.string).to include( + 'Page templates', 'General', 'Dashboards', 'Forms', 'form-vertical' + ) + + json = StringIO.new + described_class.new('page', %w[templates --json], output: json).run + payload = JSON.parse(json.string) + expect(payload.flat_map { _1.fetch('templates') }.map { _1.fetch('name') }).to contain_exactly( + 'starter', 'blank', 'dashboard', 'form-vertical' + ) + + expect { described_class.new('page', %w[templates --unknown]).run } + .to raise_error(SystemExit, /Unknown arguments/) + end + + it 'routes the public page alias and chain option through bin/mxrb' do + Dir.mktmpdir do |dir| + root = Mxrb::Initializer.new('cli_app').scaffold(into: dir).root + executable = [RbConfig.ruby, File.expand_path('../bin/mxrb', __dir__), 'page'] + stdout, stderr, status = Open3.capture3( + *executable, 'g', 'CliApp.Order', '--chain', 'page:microflow', '--target', root + ) + + expect(status).to be_success + expect(stderr).to be_empty + expect(stdout).to include('act_refresh_order.rb', 'order.rb') + page = File.join(root, 'modules/CliApp/presentation/pages/order.rb') + expect(File.read(page)).to include('on_click microflow: "CliApp.ACT_RefreshOrder"') + end + end + it 'covers help placement and reports invalid command arguments' do output = StringIO.new described_class.new('entity', ['new', '--help'], output:).run @@ -361,7 +654,7 @@ def scaffold(root, kind, name = nil) Dir.chdir(root) do output = StringIO.new described_class.new('design', ['init'], output:).run - expect(output.string).to include('design_system.rb', 'Done. Run:') + expect(output.string).to include('Done. Run:') quiet = StringIO.new described_class.new('evaluation', %w[new architecture], output: quiet).run diff --git a/spec/web_operation_compiler_spec.rb b/spec/web_operation_compiler_spec.rb index 81c6f29..985ce83 100644 --- a/spec/web_operation_compiler_spec.rb +++ b/spec/web_operation_compiler_spec.rb @@ -143,7 +143,9 @@ def widget(source: true) expect(described_class.new(source).send(:page_operations, page)).to contain_exactly( include( 'operationType' => 'retrieveByMicroflow', 'parameters' => {}, - 'constants' => include('MicroflowName' => 'Demo.LoadItems') + 'constants' => include( + 'MicroflowName' => 'Demo.LoadItems', 'UsedAssociations' => [], 'UsedAttributes' => [] + ) ) ) end diff --git a/spec/writer_regression_spec.rb b/spec/writer_regression_spec.rb index b3290bd..040739a 100644 --- a/spec/writer_regression_spec.rb +++ b/spec/writer_regression_spec.rb @@ -51,6 +51,22 @@ expect(mappings.map { _1['Parameter'] }).to eq(%w[Clinic.Create.Name Clinic.Create.Age]) end + it 'writes a valid default log node for the Mendix 11 Runtime' do + activity = { + type: :log_message, message: 'ready', level: :info, node: nil, + include_stack: false, parameters: [] + } + modern = Mxrb::Writer.new('/tmp/log-modern.mpr', version: '11.12.1', modules: []) + legacy = Mxrb::Writer.new('/tmp/log-legacy.mpr', version: '10.24.0', modules: []) + + modern_action = modern.send(:activity_action_doc, activity) + legacy_action = legacy.send(:activity_action_doc, activity) + + expect(modern_action).to include('Node' => "'MXRB'") + expect(modern_action).not_to have_key('NodeModel') + expect(legacy_action.dig('NodeModel', '$Type')).to eq('Expressions$NoExpression') + end + # rubocop:disable Metrics/BlockLength it 'uses Abort instead of Rollback throughout generated nanoflow graphs' do Dir.mktmpdir do |dir|