From c936d5c951aa824368a103a7b13906b577d088c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 02:35:00 +0000 Subject: [PATCH] Terminal renderer: an abap2UI5 app in a terminal (renderers/terminal/) - renderers/terminal/: a portable renderer for the terminal, pure Node, no dependencies. session.mjs speaks the protocol to any backend (node runtime, cap2UI5, an SAP ICF node: basic auth, cookies, CSRF token handshake, sap-contextid, terminate HEAD, the URL's query kept), runs the follow-up actions and keeps a hash history synchronised like the UI5 router, so Back restores routes. app.mjs is the state machine (focus, in-place edits committed like a UI5 Input, pick lists, keys, frame() and print()), render.mjs + mapping.mjs map all 65 portable controls onto terminal widgets (unknown controls as reported placeholders), layout.mjs draws them at a width (wrapping, columns, tables, frames), text.mjs handles display width, sanitising, ellipsis, glyphs, ANSI and NO_COLOR, tty.mjs drives a real terminal. CLI abap2ui5-tui (package bin) with --print for logs, CI and screen readers; library export @abap2ui5/protocol/renderers/terminal. The README's mapping table is generated from mapping.mjs (scripts/render-terminal.mjs, npm run generate). - renderers/common/: the render-agnostic half of the Adaptive Cards renderer (view helpers, payload -> request) moved out for both renderers; the card renderer re-exports it, its golden cards unchanged. - Frontend adapter `terminal`, driven with keys through the state machine: 72 pass, 0 fail, 9 skip (DOM, programmatic model edit; UI5 and semantic profiles), the router checks included. Pinned in test/frontend.test.mjs, RESULTS.md and the frontend README follow; CI uploads its report. - portable.box-details no longer asks for a DOM - it reads only the text on screen; the Adaptive Cards renderer now runs and passes it. - test/terminal.test.mjs: golden screens of recorded traffic, the keys down to the exact request body, the transport against a real-system-like backend, width, colors, sanitising, the TTY loop, the CLI, the mapping table; test/backends.test.mjs drives the renderer end to end against the node-runtime host after the backend suite. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Kz1J78phfNKbqUaq3VcP4U --- .github/workflows/ci.yml | 17 +- AGENTS.md | 15 +- CHANGELOG.md | 33 + README.md | 38 +- conformance/RESULTS.md | 65 +- .../backend/bin/abap2ui5-conformance.mjs | 5 +- conformance/frontend/README.md | 7 +- conformance/frontend/adapters/index.mjs | 1 + conformance/frontend/adapters/terminal.mjs | 172 +++++ conformance/frontend/lib/checks/portable.mjs | 2 +- package-lock.json | 3 +- package.json | 12 +- renderers/adaptive-cards/mapping.mjs | 73 +- renderers/adaptive-cards/render.mjs | 203 +----- renderers/adaptive-cards/submit.mjs | 95 +-- renderers/common/request.mjs | 97 +++ renderers/common/view.mjs | 249 +++++++ renderers/terminal/README.md | 319 +++++++++ renderers/terminal/app.mjs | 637 ++++++++++++++++++ renderers/terminal/bin/abap2ui5-tui.mjs | 137 ++++ renderers/terminal/golden/bind.txt | 11 + renderers/terminal/golden/message-box.txt | 9 + renderers/terminal/golden/nav-target.txt | 4 + renderers/terminal/golden/nest.txt | 5 + renderers/terminal/golden/popover.txt | 7 + renderers/terminal/golden/popup-over-main.txt | 9 + renderers/terminal/golden/sampler-40.txt | 39 ++ renderers/terminal/golden/sampler.txt | 36 + renderers/terminal/index.mjs | 44 ++ renderers/terminal/layout.mjs | 463 +++++++++++++ renderers/terminal/mapping.mjs | 557 +++++++++++++++ renderers/terminal/render.mjs | 239 +++++++ renderers/terminal/session.mjs | 594 ++++++++++++++++ renderers/terminal/text.mjs | 189 ++++++ renderers/terminal/tty.mjs | 110 +++ scripts/render-terminal.mjs | 61 ++ test/backends.test.mjs | 78 ++- test/frontend.test.mjs | 24 +- test/runner.test.mjs | 2 +- test/terminal.test.mjs | 440 ++++++++++++ 40 files changed, 4740 insertions(+), 361 deletions(-) create mode 100644 conformance/frontend/adapters/terminal.mjs create mode 100644 renderers/common/request.mjs create mode 100644 renderers/common/view.mjs create mode 100644 renderers/terminal/README.md create mode 100644 renderers/terminal/app.mjs create mode 100755 renderers/terminal/bin/abap2ui5-tui.mjs create mode 100644 renderers/terminal/golden/bind.txt create mode 100644 renderers/terminal/golden/message-box.txt create mode 100644 renderers/terminal/golden/nav-target.txt create mode 100644 renderers/terminal/golden/nest.txt create mode 100644 renderers/terminal/golden/popover.txt create mode 100644 renderers/terminal/golden/popup-over-main.txt create mode 100644 renderers/terminal/golden/sampler-40.txt create mode 100644 renderers/terminal/golden/sampler.txt create mode 100644 renderers/terminal/index.mjs create mode 100644 renderers/terminal/layout.mjs create mode 100644 renderers/terminal/mapping.mjs create mode 100644 renderers/terminal/render.mjs create mode 100644 renderers/terminal/session.mjs create mode 100644 renderers/terminal/text.mjs create mode 100644 renderers/terminal/tty.mjs create mode 100644 scripts/render-terminal.mjs create mode 100644 test/terminal.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d29e9e7..2d5fa9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,14 +5,16 @@ # node-runtime @abap2ui5/node-runtime + the ABAP apps, transpiled in the job # (open-abap-core fetched with git, cached by its commit) # cap2ui5 a CAP project + @cap2ui5/cds-plugin + the JS apps -# plus abaplint over the ABAP apps, and the frontend suite against the two -# in-process frontends: the agent client and the Adaptive Cards renderer -# (renderers/adaptive-cards/, with its golden cards). Each backend run takes -# seconds; the install dominates. +# plus abaplint over the ABAP apps, and the frontend suite against the three +# in-process frontends: the agent client, the Adaptive Cards renderer +# (renderers/adaptive-cards/, with its golden cards) and the terminal +# renderer (renderers/terminal/, golden screens, and end to end against the +# node-runtime host). Each backend run takes seconds; the install dominates. # The frontend job runs the frontend suite against the UI5 SPA of abap2UI5 # (the commit the spec is derived from, app/webapp only) in Chromium - the # test job skips that test, it has no browser and no checkout - and records -# the reports of the UI5 SPA, the agent client and the Adaptive Cards renderer. +# the reports of the UI5 SPA, the agent client and the Adaptive Cards and +# terminal renderers. name: ci on: @@ -58,7 +60,7 @@ jobs: run: npm ci --prefix conformance/hosts/cap2ui5 --no-audit --no-fund - name: abaplint (ABAP conformance apps) run: npm run lint:abap - - name: npm test (both backends, agent client, Adaptive Cards renderer) + - name: npm test (both backends, agent client, Adaptive Cards and terminal renderers) run: npm test - name: generated sections are up to date run: npm run generate && git diff --exit-code @@ -108,6 +110,9 @@ jobs: - name: frontend suite report - Adaptive Cards renderer if: ${{ !cancelled() }} run: node conformance/backend/bin/abap2ui5-conformance.mjs frontend --adapter adaptive-cards --json > report-frontend-adaptive-cards.json || true + - name: frontend suite report - terminal renderer + if: ${{ !cancelled() }} + run: node conformance/backend/bin/abap2ui5-conformance.mjs frontend --adapter terminal --json > report-frontend-terminal.json || true - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: ${{ !cancelled() }} with: diff --git a/AGENTS.md b/AGENTS.md index 296c4fe..5808259 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,8 @@ down, and the spec records what the implementations do. - **Generated sections are generated.** `conformance/backend/README.md` (check list), `profiles/portable.md` (between the `portable:*` markers, from `profiles/portable-v1.json`), `renderers/adaptive-cards/README.md` - (the mapping table, from `mapping.mjs`) - run `npm run generate`. + and `renderers/terminal/README.md` (the mapping tables, from each + renderer's `mapping.mjs`) - run `npm run generate`. - **Known failures of a reference are pinned, not hidden.** A backend check the reference backends fail goes into `test/lib/expected.mjs` with the place of its fix; the tests fail when it starts to pass (unpin it, record @@ -72,8 +73,10 @@ down, and the spec records what the implementations do. | `profiles/` | UI5, portable (v1 + `portable-v1.json` + coverage) and semantic profiles | | `schema/` | JSON Schemas 2020-12 | | `conformance/backend/` | the backend suite - the published package's entry (`index.mjs`, `bin/`) | -| `conformance/frontend/` | the frontend suite - `lib/` (scripted backend `mock.mjs`, runner, response builders, `checks/`), `adapters/` (`ui5` Playwright + the boot page, `agent` + the vendored mcp-server client, `webcomponent`, `adaptive-cards` (in process, the renderer below), `headless` stub) | +| `conformance/frontend/` | the frontend suite - `lib/` (scripted backend `mock.mjs`, runner, response builders, `checks/`), `adapters/` (`ui5` Playwright + the boot page, `agent` + the vendored mcp-server client, `webcomponent`, `adaptive-cards` and `terminal` (in process, the renderers below), `headless` stub) | | `renderers/adaptive-cards/` | a prototype portable renderer: response -> Adaptive Card 1.5 and `Action.Submit` payload -> request (`render.mjs`, `mapping.mjs` - the control table the README's mapping section is generated from, `submit.mjs`, `host.mjs`, `demo.mjs`), golden cards in `golden/` (`UPDATE_GOLDEN=1 node --test test/adaptive-cards.test.mjs` rewrites them) | +| `renderers/terminal/` | a portable renderer for the terminal: keys in, a text screen out, the CLI `abap2ui5-tui` (`bin/`), `session.mjs` (the protocol over HTTP: transport rules, basic auth, cookies, follow-up actions, the hash history), `app.mjs` (the state machine: focus, edits, keys; `frame()`, `print()`), `render.mjs` + `mapping.mjs` (the control table the README's mapping section is generated from), `layout.mjs`, `text.mjs`, `tty.mjs`; golden screens in `golden/` (`UPDATE_GOLDEN=1 node --test test/terminal.test.mjs` rewrites them) | +| `renderers/common/` | what the renderers share: `view.mjs` (bindings, aggregations, list bindings, event wires over the vendored `viewxml`/`snapshot` modules) and `request.mjs` (an action payload -> the next request, the model delta) | | `conformance/apps/` | the conformance apps (ABAP + cap2UI5) and their abaplint config | | `conformance/hosts/` | `node-runtime` (build + serve) and `cap2ui5` (a CAP project) reference hosts | | `traffic/` | recorded traffic per backend: `suite.json`, `ui5-frontend.json`, `agent-client.json` | @@ -88,9 +91,11 @@ recorded traffic, cross-backend equality, docs links and anchors, the portable profile, the conformance apps' abapGit format, the CLI, the full backend suite against both reference backends (`PROTOCOL_SKIP_BACKENDS=1` skips those; the cap2UI5 run skips itself when its host is not installed), -and the frontend suite against the agent client and the Adaptive Cards -renderer (always, golden cards included) and the UI5 SPA -(when an abap2UI5 checkout and a Chromium are there; +and the frontend suite against the agent client, the Adaptive Cards +renderer and the terminal renderer (always, golden cards and screens +included; the terminal also end to end against the node-runtime host, +inside the backend run) and the UI5 SPA (when an abap2UI5 checkout and a +Chromium are there; `PROTOCOL_SKIP_BROWSER=1` skips it, `PROTOCOL_REQUIRE_BROWSER=1` fails without them). CI runs the same on Node 22 and 24, and the UI5 SPA in a job of its own with Chromium. Never `playwright install` in a sandbox that diff --git a/CHANGELOG.md b/CHANGELOG.md index fc9a1da..c6761a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,39 @@ ## Unreleased +- **Terminal renderer** (`renderers/terminal/`, export + `@abap2ui5/protocol/renderers/terminal`, CLI `abap2ui5-tui [--app + ] [--user u --password p | --cookie c] [--print] [--no-color]`): + an abap2UI5 app in a terminal. Its session speaks the protocol to any + backend - the node runtime, cap2UI5, an SAP system's ICF node (basic + auth, cookies, the CSRF token handshake, `sap-contextid`, the terminate + HEAD) - and keeps a hash history synchronised like the UI5 router, so + Back restores routes. All 65 portable controls as keyboard widgets + (fields edited in place, toggles, pick lists, buttons and links, tables + with row selection and row actions, banners, overlay frames for dialogs, + popovers and message boxes), unknown controls as visible placeholders; + edits travel as the UI5 frontend's delta, field events (`change`, + `select`, `submit`, ...) are raised. Respects the width (wrapping, + ellipsis, wide characters), colors only when wanted (`NO_COLOR`, + `--no-color`), `--print` renders once as plain text. Text from the + backend is sanitised of control characters. Pure Node, no dependencies. + The README's mapping table is generated from `mapping.mjs` + (`scripts/render-terminal.mjs`, part of `npm run generate`). +- **Frontend adapter `terminal`** (in process, driven with keys through the + renderer's state machine): 72 pass, 0 fail, 9 skip (a DOM, a programmatic + model edit; the UI5 and semantic profiles) - the router checks included. + Pinned in `test/frontend.test.mjs`; `test/terminal.test.mjs` holds golden + screens, the keys down to the request body, width, colors, sanitising + and the CLI; `test/backends.test.mjs` drives the renderer against the + node-runtime host after the backend suite. +- **`renderers/common/`**: the render-agnostic half of the Adaptive Cards + renderer (view helpers over the vendored `viewxml` / `snapshot` modules, + the payload -> request step) moved out for both renderers; the card + renderer re-exports it unchanged (its golden cards are byte-identical). +- **`portable.box-details` no longer asks for a DOM** - it reads only the + text on screen. The Adaptive Cards renderer now runs and passes it (66 + pass, 15 skip). + - **Specification revision 0.3 - the maintainer's decisions** on the ten open questions ([spec/open-questions.md](spec/open-questions.md), each now "Decided (revision 0.3)" with its rationale): diff --git a/README.md b/README.md index 4835287..a705db3 100644 --- a/README.md +++ b/README.md @@ -34,12 +34,16 @@ repository writes it down. backend the backend suite - plays the frontend over HTTP (implemented) frontend the frontend suite - plays the backend, adapters for (implemented) the UI5 SPA (Playwright), the agent client, the - Web Components frontend, the Adaptive Cards renderer + Web Components frontend, the Adaptive Cards and the + terminal renderer apps the conformance apps: ABAP classes + cap2UI5 twins hosts the two reference backends, started with the apps deployed renderers/ adaptive-cards a portable renderer without a browser: response -> Adaptive Card 1.5, Action.Submit -> request (prototype) + terminal an abap2UI5 app in a terminal: keys in, text screen out, + the CLI abap2ui5-tui (node runtime, cap2UI5, SAP systems) + common what both renderers share: view helpers, payload -> request traffic/ real traffic of both reference backends and three frontends ``` @@ -64,12 +68,20 @@ source file and method it was derived from ([spec/README.md](spec/README.md#sour - **Frontend suite: 81 checks** (66 MUST, 15 SHOULD; 66 core, 8 portable, 4 UI5, 3 semantic), scripted from the recorded traffic. The official UI5 SPA passes every MUST but one - message box details stay empty on OpenUI5 - >= 1.120 (fix under way); the agent client (mcp-server `a4d9f07`) and the - Adaptive Cards renderer pass every check that applies to them (61 and 65); - the Web Components frontend 68 of 68, with one intermittent failure ([conformance/RESULTS.md](conformance/RESULTS.md#frontend-suite)). + >= 1.120 (fix under way); the agent client (mcp-server `a4d9f07`), the + Adaptive Cards renderer and the terminal renderer pass every check that + applies to them (61, 66 and 72 - the terminal's include the router + checks); the Web Components frontend 68 of 68, with one intermittent + failure ([conformance/RESULTS.md](conformance/RESULTS.md#frontend-suite)). - **Adaptive Cards renderer** (prototype, [renderers/adaptive-cards/](renderers/adaptive-cards/README.md)): all 65 portable controls mapped onto Adaptive Cards 1.5, the way back from an `Action.Submit` to the next request, golden cards of recorded traffic. +- **Terminal renderer** ([renderers/terminal/](renderers/terminal/README.md)): + an abap2UI5 app in a terminal - `npx abap2ui5-tui --app ` + against the node runtime, cap2UI5 or an SAP system (basic auth, cookies, + CSRF, `sap-contextid`), all 65 portable controls as keyboard-driven + widgets, a hash history so Back works, `--print` for logs, CI and screen + readers; pure Node, no dependencies. - Not yet run against an ABAP system. ## Run the backend suite @@ -97,8 +109,19 @@ Options, the library API and the check list: npx abap2ui5-conformance frontend --adapter ui5 # the UI5 SPA (ABAP2UI5_HOME=) in Chromium npx abap2ui5-conformance frontend --adapter agent # the agent client of abap2UI5/mcp-server npx abap2ui5-conformance frontend --adapter adaptive-cards # the Adaptive Cards renderer of this package +npx abap2ui5-conformance frontend --adapter terminal # the terminal renderer of this package ``` +## Run an app in the terminal + +```bash +npm run serve:node-runtime & # the conformance apps on http://localhost:3000/ +npx abap2ui5-tui http://localhost:3000/ --app Z2UI5_CL_CONF_BIND +npx abap2ui5-tui "https://host/sap/bc/z2ui5?sap-client=100" --app Z2UI5_CL_MY_APP --user ME --print +``` + +Keys, options and the control mapping: [renderers/terminal/README.md](renderers/terminal/README.md). + Adapters, options and the check list: [conformance/frontend/README.md](conformance/frontend/README.md). @@ -106,13 +129,14 @@ Adapters, options and the check list: ```bash npm test # schemas, traffic, docs, portable profile, CLI, both backends, - # the frontend suite (agent client, Adaptive Cards renderer; - # UI5 SPA with a checkout + Chromium), the golden cards + # the frontend suite (agent client, Adaptive Cards and terminal + # renderer; UI5 SPA with a checkout + Chromium), the golden + # cards and screens, the terminal against the node runtime # (PROTOCOL_SKIP_BACKENDS=1 / PROTOCOL_SKIP_BROWSER=1 skip the slow parts) npm run record # re-record traffic/ from both backends npm run lint:abap # abaplint over the ABAP conformance apps npm run generate # regenerate the check lists, the portable-profile sections and - # the Adaptive Cards mapping table + # the Adaptive Cards and terminal mapping tables npm run demo:adaptive-cards -- slots.popup-destroy 0 1 # a recorded response as card JSON ``` diff --git a/conformance/RESULTS.md b/conformance/RESULTS.md index 7456d9e..e49f641 100644 --- a/conformance/RESULTS.md +++ b/conformance/RESULTS.md @@ -115,7 +115,8 @@ records - as a rule, or as an implementation note: ## Frontend suite Frontend suite, 81 checks (66 MUST, 15 SHOULD; 66 core, 8 portable, 4 UI5, -3 semantic), run 2026-10-03 with +3 semantic), run 2026-10-03 (the terminal renderer and the Adaptive Cards +renderer again 2026-10-04) with `abap2ui5-conformance frontend --adapter ` (each frontend at its widest profile). The scripted backend answers from [`../traffic/node-runtime/`](../traffic/node-runtime/) plus synthetic edge @@ -127,13 +128,14 @@ perform. |---|---|---|---:|---:|---:|---:|---| | UI5 SPA (`ui5`) | abap2UI5 1.146.0 `b812079` `app/webapp` (identical at main `5d7e91f`, which CI pins), OpenUI5 1.144.0 (npm), Chromium 141 | ui5 | 76 | 1 | 0 | 4 | one MUST deviation: `portable.box-details` | | agent client (`agent`) | abap2UI5/mcp-server `lib/appclient.mjs` @ `a4d9f07` (main, PR #44; vendored) | semantic | 61 | 0 | 0 | 20 | conformant (semantic) - the five MUST deviations of `ea4e9fa` fixed | -| Adaptive Cards renderer (`adaptive-cards`) | [`renderers/adaptive-cards/`](../renderers/adaptive-cards/README.md) of this repository (prototype), Adaptive Cards 1.5 | portable | 65 | 0 | 0 | 16 | every check it can be driven through holds | +| Adaptive Cards renderer (`adaptive-cards`) | [`renderers/adaptive-cards/`](../renderers/adaptive-cards/README.md) of this repository (prototype), Adaptive Cards 1.5 | portable | 66 | 0 | 0 | 15 | every check it can be driven through holds | +| terminal renderer (`terminal`) | [`renderers/terminal/`](../renderers/terminal/README.md) of this repository, driven with keys through its state machine | portable | 72 | 0 | 0 | 9 | every check it can be driven through holds, the router checks included | | Web Components (`webcomponent`) | abap2UI5/frontend-webcomponent 0.1.0, `dist/` built from main `410d607` (PR #2) | portable | 68 | 0 | 0 | 13 | conformant (portable) in 3 of 4 runs; `model.number-and-boolean` intermittent (below) | | headless ABAP simulator (`headless`) | - | - | - | - | - | - | not drivable: in-process, no HTTP seam ([frontend/README.md](frontend/README.md#adapters)) | The UI5 SPA run is stable (two consecutive runs, identical results) and -takes about two minutes; `test/frontend.test.mjs` pins the UI5, the agent -and the Adaptive Cards result check by check. The UI5 pin accepts +takes about two minutes; `test/frontend.test.mjs` pins the UI5, the agent, +the Adaptive Cards and the terminal result check by check. The UI5 pin accepts `portable.box-details` passing: the fix of the UI5 frontend is under way (open question 7, decided: expanded), and a local run against an abap2UI5 checkout that carries it (2026-10-03, abap2UI5 `ed8115b` on top of `833b5b8` "Show message box @@ -217,7 +219,7 @@ the card host speaks the protocol to the scripted backend, each answer is rendered into an Adaptive Card 1.5 and the suite reads the card; a "press" submits what a card host submits - the action's data merged with every input value, ids being binding paths - and the renderer turns that back into the -request. All 65 checks that apply pass, two runs identical: the envelope, +request. All 66 checks that apply pass, two runs identical: the envelope, ID continuation, raw event arguments (model arguments re-read after the edits of the same submit), every delta rule including nested `__delta` rows (the edits are found by comparing the submitted values with the model @@ -225,13 +227,16 @@ the card was rendered from), `sap-contextid`, the CSRF handshake, no retry of a 500, one roundtrip at a time (a submit in flight queues the next, open question 8), PROTOCOL 3 refused, the slot rules (popup modal, popover not: a press in MAIN closes it, as UI5 does), the NEST placeholder, toasts, -boxes and their close event, START_TIMER, an unknown and an excluded +boxes (their details shown expanded, as text) and their close event, +START_TIMER, an unknown and an excluded follow-up action skipped and logged, the tolerance rule (an unknown control becomes a placeholder and an `unsupported` entry), and an error body shown verbatim (a `TextRun`, not markdown). Skipped: a URL (routing, Back, the -app-state hash), a DOM (the sanitizer probe, the box-details text probe - -the card shows the details expanded, as text), focus, a document title and +app-state hash), a DOM (the sanitizer probe), focus, a document title and programmatic model edits; the UI5 and semantic profiles are not claimed. +(`portable.box-details` asked for a DOM until 2026-10-04 and was skipped; +it reads only the text on screen, which every adapter reports, and now +runs - see the terminal renderer below.) Every golden card also validates with the Adaptive Cards JavaScript SDK 3.0.6 (`AdaptiveCard.parse` + `validateProperties`, no issue; run by hand, not a dependency). @@ -241,6 +246,50 @@ a browser to be rendered; what a card cannot do (raise `change` events, show a URL) the profile already lets a renderer drop or the edit travels with the next action. +### Findings - the terminal renderer + +A renderer for a text terminal, run in process +([../renderers/terminal/](../renderers/terminal/README.md)): its session +speaks the protocol to the scripted backend, and the adapter drives its +state machine the way a user drives it - `fill` Tabs to the field bound to +the path and types the value (Ctrl+U, the characters, Tab to commit), +`press` Tabs to the action raising the event and presses Enter, `back` is +Alt+Left; the state is read from the screen (a layer per slot, the bound +fields' values). All 72 checks that apply pass, five runs identical: the +envelope, ID continuation, raw event arguments, every delta rule +including nested `__delta` rows (the edits are the committed fields' paths, +as in the UI5 frontend - an edit typed and typed back is none), +`sap-contextid`, the CSRF handshake, no retry of a 500, one roundtrip at a +time (queued), PROTOCOL 3 refused, the slot rules, the NEST placeholder, +toasts, boxes with their details expanded and their close event, +START_TIMER, SET_FOCUS (the focus moves to the widget with that id after +the views are built), SET_TITLE, an unknown and an excluded follow-up +action skipped and named in the status line, the tolerance rule, an error +body shown verbatim - and, unlike the card, the four router checks: the +renderer keeps the hash a browser would show and its history, synchronised +once per response like the UI5 router, sends it as `HASH` and restores the +caller's route on Back with an app-start-shaped request. Skipped: a DOM +(the sanitizer probe - the terminal's own sanitizing, every control +character a backend sends replaced before it reaches the terminal, is +tested in `test/terminal.test.mjs`), a programmatic model edit +(`model.whole-beats-delta`: a user cannot type a whole table); the UI5 and +semantic profiles are not claimed. `test/backends.test.mjs` also drives it +against the node-runtime host (BIND, ROUTE with Back, NAV, `--print`). + +Found on the way: + +1. **`portable.box-details` asked for a DOM but reads only the text on + screen** - *the suite was wrong.* The check needs no capability: the + text is part of every adapter's normalized state. It now runs for every + portable frontend; the Adaptive Cards renderer and the terminal pass it + (the UI5 SPA's result is unchanged - it has a DOM). +2. *The spec held* for a frontend with a hash but no browser: the router + rules (spec/navigation.md) are written against the URL hash, and a + history kept in memory satisfies all of them; nothing in the portable + profile needed a browser. Where a terminal has no counterpart (a URL to + open, a file to download) the profile's MAY-be-a-no-op already covers + it - the terminal names the URL in its status line. + ### Findings - the Web Components frontend Run against `dist/` built from its main `410d607` (PR #2: `ROUTER` and the diff --git a/conformance/backend/bin/abap2ui5-conformance.mjs b/conformance/backend/bin/abap2ui5-conformance.mjs index 3f6204f..6c2cda5 100755 --- a/conformance/backend/bin/abap2ui5-conformance.mjs +++ b/conformance/backend/bin/abap2ui5-conformance.mjs @@ -5,7 +5,7 @@ * abap2ui5-conformance backend --url [--profile core|ui5] * [--header "Name: value"]... [--only ]... * [--app KEY=CLASS]... [--json] [--verbose] - * abap2ui5-conformance frontend --adapter ui5|agent|webcomponent|adaptive-cards|headless + * abap2ui5-conformance frontend --adapter ui5|agent|webcomponent|adaptive-cards|terminal|headless * [--profile core|portable|ui5|semantic] * [--only ]... [--json] [--verbose] * @@ -34,7 +34,8 @@ frontend - plays the backend (a scripted server) for a frontend: --adapter ui5 (the UI5 SPA in Chromium), agent (mcp-server's agent client), webcomponent (frontend-webcomponent), adaptive-cards (the Adaptive Cards renderer of this - package, in process), headless (stub) + package, in process), terminal (the terminal renderer + of this package, in process), headless (stub) --profile core | portable | ui5 | semantic (default: the adapter's widest) --only, --json, --verbose as above diff --git a/conformance/frontend/README.md b/conformance/frontend/README.md index ac7eb25..bc47799 100644 --- a/conformance/frontend/README.md +++ b/conformance/frontend/README.md @@ -24,16 +24,18 @@ npx abap2ui5-conformance frontend --adapter ui5 # the UI5 SPA in Chro npx abap2ui5-conformance frontend --adapter agent # mcp-server's agent client npx abap2ui5-conformance frontend --adapter webcomponent # frontend-webcomponent npx abap2ui5-conformance frontend --adapter adaptive-cards # the Adaptive Cards renderer of this package +npx abap2ui5-conformance frontend --adapter terminal # the terminal renderer of this package # from this repository npm run conformance:frontend:ui5 npm run conformance:frontend:agent npm run conformance:frontend:adaptive-cards +npm run conformance:frontend:terminal ``` | Option | | |---|---| -| `--adapter ` | the frontend: `ui5`, `agent`, `webcomponent`, `adaptive-cards`, `headless` (stub) | +| `--adapter ` | the frontend: `ui5`, `agent`, `webcomponent`, `adaptive-cards`, `terminal`, `headless` (stub) | | `--profile core\|portable\|ui5\|semantic` | the checks to run: `core`; `portable` (core + portable); `ui5` (core + portable + UI5); `semantic` (core + semantic). Default: the widest profile the adapter claims | | `--only ` | run only checks whose id contains it (repeatable) | | `--json` | the report as JSON | @@ -68,6 +70,7 @@ frontend cannot perform throws `Unsupported`. | `agent` | abap2UI5/mcp-server `lib/appclient.mjs` | core, semantic | in process: `start` / `act`, the snapshot is the state. Vendored at the commit [adapters/vendor/mcp-server/source.json](adapters/vendor/mcp-server/source.json) names (`npm run vendor:agent` re-vendors; `MCP_SERVER_HOME` runs a checkout instead) | | `webcomponent` | abap2UI5/frontend-webcomponent `dist/abap2ui5-wc.js` | core, portable | Chromium: `` on its standalone page; needs a built checkout (`WC_FRONTEND_HOME`, else `../frontend-webcomponent`) | | `adaptive-cards` | the Adaptive Cards renderer prototype of this repository ([../../renderers/adaptive-cards/](../../renderers/adaptive-cards/README.md)) | core, portable | in process: its card host speaks HTTP to the scripted backend, every answer is rendered into an Adaptive Card 1.5 and the state is read from the card (a `Container` per slot, inputs with binding paths as ids). `fill` types into a card input, `press` submits what a card host submits for the action - its data and every input value | +| `terminal` | the terminal renderer of this repository ([../../renderers/terminal/](../../renderers/terminal/README.md)) | core, portable | in process, with keys: its session speaks HTTP to the scripted backend, the adapter drives its state machine as a user does - `fill` Tabs to the field bound to the path and types the value, `press` Tabs to the action and presses Enter, `back` is Alt+Left - and reads the state from the screen (a layer per slot, the bound fields, the hash, the title, the focused widget's id). No TTY needed | | `headless` | abap2UI5/headless-frontend (ABAP) | - | **not drivable yet** - see below | What the `ui5` adapter needs: an abap2UI5 checkout (`ABAP2UI5_HOME`, else @@ -195,7 +198,7 @@ check requires. | `portable.unknown-property` | MUST | portable | - | An unknown property of a known control is ignored | [portable.md#conformance](../../profiles/portable.md#conformance) | | `portable.unknown-control` | MUST | portable | - | An element of an unknown control does not fail the view (a placeholder instead) | [portable.md#conformance](../../profiles/portable.md#conformance) | | `portable.excluded-action` | MUST | portable | - | A follow-up action outside the portable list does not fail the response | [portable.md#6-frontend-actions](../../profiles/portable.md#6-frontend-actions) | -| `portable.box-details` | MUST | portable | dom | The details of a message box are shown | [portable.md#6-frontend-actions](../../profiles/portable.md#6-frontend-actions) | +| `portable.box-details` | MUST | portable | - | The details of a message box are shown | [portable.md#6-frontend-actions](../../profiles/portable.md#6-frontend-actions) | | `portable.timer` | MUST | portable | timers | START_TIMER fires its event as an ordinary roundtrip after the delay | [actions.md#vocabulary](../../spec/actions.md#vocabulary) | | `portable.set-title` | MUST | portable | title | SET_TITLE sets the document title | [portable.md#6-frontend-actions](../../profiles/portable.md#6-frontend-actions) | | `portable.view-replaced` | MUST | portable | - | A second MAIN display replaces the first | [response.md#view_slots-display](../../spec/response.md#view_slots-display) | diff --git a/conformance/frontend/adapters/index.mjs b/conformance/frontend/adapters/index.mjs index 5979a3e..9590c57 100644 --- a/conformance/frontend/adapters/index.mjs +++ b/conformance/frontend/adapters/index.mjs @@ -6,6 +6,7 @@ export const ADAPTERS = Object.freeze({ agent: { module: "./agent.mjs", factory: "createAgentAdapter", description: "the agent client of abap2UI5/mcp-server (lib/appclient.mjs)" }, webcomponent: { module: "./webcomponent.mjs", factory: "createWebComponentAdapter", description: "the UI5 Web Components frontend (abap2UI5/frontend-webcomponent dist/abap2ui5-wc.js) in Chromium" }, "adaptive-cards": { module: "./adaptive-cards.mjs", factory: "createAdaptiveCardsAdapter", description: "the Adaptive Cards renderer prototype of this repository (renderers/adaptive-cards/), in process" }, + terminal: { module: "./terminal.mjs", factory: "createTerminalAdapter", description: "the terminal renderer of this repository (renderers/terminal/), in process" }, headless: { module: "./headless.mjs", factory: "createHeadlessAdapter", description: "the headless ABAP simulator (abap2UI5/headless-frontend) - not drivable yet" }, }); diff --git a/conformance/frontend/adapters/terminal.mjs b/conformance/frontend/adapters/terminal.mjs new file mode 100644 index 0000000..49fc3f0 --- /dev/null +++ b/conformance/frontend/adapters/terminal.mjs @@ -0,0 +1,172 @@ +/* + * Adapter: the terminal renderer of this repository (renderers/terminal/) - + * an in-process frontend of the portable profile, driven through its state + * machine (app.mjs) the way a user drives it, with keys, and read from its + * screen as text. No TTY is needed. + * + * fill(t, v) the user Tabs to the field bound to t.path (in t.slot when + * given) and types the value: Ctrl+U clears it, the + * characters go in one by one, Tab commits the edit (and + * moves on); a check box or switch is toggled with Space + * when its value differs, a pick list stepped with Right + * until it shows the value + * press(t) the user Tabs to the action raising t.event (or labelled + * t.text) and presses Enter; t.nav is the page's back button + * closeBox(a) the message box button `a`, the same way + * back() Alt+Left - the renderer's hash history + * state() the screen: a layer per slot and its text, the values of + * the bound fields, the hash, the title set by SET_TITLE, + * the view id of the focused widget + * + * What a terminal cannot do is not claimed: no DOM, no programmatic model + * edit (only what a user types). + */ +import { Unsupported, emptyState } from "./base.mjs"; +import { createSession } from "../../../renderers/terminal/session.mjs"; +import { createTerminalApp } from "../../../renderers/terminal/app.mjs"; +import { LEAVE_EVENT } from "../../../renderers/common/view.mjs"; +import { coerce } from "../../../renderers/common/request.mjs"; +import { getAt } from "./vendor/mcp-server/snapshot.mjs"; + +const WIDTH = 100; + +export function createTerminalAdapter() { + let mock = null; + let session = null; + let app = null; + + /** Tab until `match` has the focus - at most once round the widgets. */ + async function tabTo(match, what) { + const ws = app.widgets(); + for (let i = 0; i <= ws.length; i += 1) { + const f = app.focused(); + if (f && match(f)) return f; + await app.key("tab"); + } + const names = app.widgets().map((w) => w.path || w.label).join(", "); + throw new Error(`no ${what} reachable with Tab (widgets: ${names || "none"})`); + } + + /** The action data a widget would fire, for matching by event name. */ + function eventOf(w) { + if (w.box !== undefined) return null; + if (!w.press) return null; + const d = w.eventData(w.press.name, undefined, w.press.target || w.node); + return d && d.event; + } + + const adapter = { + name: "terminal", + description: "the terminal renderer of this repository (renderers/terminal/), in process", + profiles: ["core", "portable"], + capabilities: new Set(["concurrent", "boxClose", "timers", "url", "title", "focus"]), + + async open({ mock: m }) { + mock = m; + adapter.version = "renderers/terminal (this repository)"; + }, + + async start(run, { app: cls, search } = {}) { + if (!cls && search === undefined) throw new Unsupported("the terminal starts apps by class name"); + session = createSession({ url: run.url, location: (c) => ({ origin: mock.origin, pathname: run.path, search: `?app_start=${encodeURIComponent(c)}` }) }); + app = createTerminalApp({ session, width: WIDTH, height: 40 }); + await session.start(cls, search !== undefined ? { search } : {}); + }, + + async fill(target, value) { + if (!app) throw new Unsupported("no screen"); + if (!target.path) throw new Unsupported("the terminal adapter finds fields by binding path"); + const w = await tabTo((f) => f.path === target.path && (!target.slot || f.slot === target.slot), `field ${target.path}`); + if (w.kind === "toggle") { + if (Boolean(w.value) !== (value === true || value === "true")) await app.key("space"); + return; + } + if (w.kind === "choice") { + for (let i = 0; i <= w.options.length && String(app.focused().value) !== String(value); i += 1) await app.key("right"); + if (String(app.focused().value) !== String(value)) throw new Error(`${target.path} has no option ${value}`); + return; + } + await app.key("ctrl-u"); + await app.type(String(value)); + // Tab commits the edit, as leaving the field does + await app.key("tab"); + }, + + async press(target, { wait = true } = {}) { + if (!app) throw new Unsupported("no screen"); + const event = target.nav ? LEAVE_EVENT : target.event; + // by the event it raises; a frontend-only wire (.eF) raises none - by its label then + const ws = app.widgets(); + const byEvent = (f) => (target.nav ? f.nav : Boolean(event) && eventOf(f) === event); + const byText = (f) => Boolean(target.text) && f.label === target.text && f.box === undefined; + const match = ws.some(byEvent) ? byEvent : byText; + const w = await tabTo(match, `action ${event || target.text}`); + const p = app.key("enter"); + if (wait) await p; + else p.catch(() => {}); + return w; + }, + + async closeBox({ action, text }) { + if (!app || !session.messages.some((m) => m.kind === "box")) throw new Unsupported("no message box on the screen"); + await tabTo((f) => f.box !== undefined && (f.box === action || f.label === text), `box button ${action}`); + await app.key("enter"); + }, + + async back() { + if (!app) throw new Unsupported("no screen"); + await app.key("alt-left"); + }, + + async setModel() { + throw new Unsupported("the terminal has no programmatic model access - only what a user types"); + }, + + async settle() { + if (session) await session.settle(); + }, + + async state() { + const s = emptyState(); + if (!session) return s; + const st = session.state; + const screen = app.screen(); + s.started = Boolean(st.id) || Boolean(session.error); + s.app = st.app || null; + s.id = st.id || null; + for (const slot of Object.keys(s.slots)) { + if (st.slots[slot]) s.slots[slot] = { open: true, text: slot === "NEST" || slot === "NEST2" ? app.layerText("MAIN") : app.layerText(slot) }; + } + const edit = app.edit; + for (const w of screen.widgets) { + if (!w.path || Object.prototype.hasOwnProperty.call(s.values, w.path)) continue; + const m = st.models[w.modelKey]; + const shown = edit && edit.key === w.key ? edit.buffer : w.value; + s.values[w.path] = coerce(shown === undefined ? "" : shown, getAt((m && m.data) || {}, w.path)); + } + for (const [k, m] of Object.entries(st.models)) s.models[k] = m.data; + s.messages = session.messages.map((m) => ({ kind: m.kind, text: m.text, ...(m.type ? { type: m.type } : {}) })); + s.error = session.error; + s.log = [...session.log, ...screen.unsupported.map((u) => `unsupported: ${u.control}${u.id ? ` #${u.id}` : ""} (${u.slot}) - ${u.reason}`)]; + s.hash = session.hash; + s.title = session.title; + const f = app.focused(); + s.focus = f ? f.viewId || null : null; + s.text = app.print({ width: WIDTH, color: false }); + s.screen = app.frame().lines.join("\n"); + return s; + }, + + async stop() { + if (session) { + session.stop(); + await session.settle().catch(() => {}); + } + session = null; + app = null; + }, + + async close() {}, + }; + return adapter; +} diff --git a/conformance/frontend/lib/checks/portable.mjs b/conformance/frontend/lib/checks/portable.mjs index c7aa672..9a72324 100644 --- a/conformance/frontend/lib/checks/portable.mjs +++ b/conformance/frontend/lib/checks/portable.mjs @@ -67,7 +67,7 @@ export default [ title: "The details of a message box are shown", level: "MUST", profile: "portable", - needs: ["dom"], + // reads the text on screen only: every portable frontend reports it, a DOM is not needed spec: `${P}#6-frontend-actions`, async run(t) { await startApp(t, nextButton); diff --git a/package-lock.json b/package-lock.json index 16b5bcc..5011a6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.1.0", "license": "MIT", "bin": { - "abap2ui5-conformance": "conformance/backend/bin/abap2ui5-conformance.mjs" + "abap2ui5-conformance": "conformance/backend/bin/abap2ui5-conformance.mjs", + "abap2ui5-tui": "renderers/terminal/bin/abap2ui5-tui.mjs" }, "devDependencies": { "@abap2ui5/node-runtime": "1.146.0", diff --git a/package.json b/package.json index 26076c5..7270259 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,12 @@ "./schema/*": "./schema/*", "./profiles/portable-v1.json": "./profiles/portable-v1.json", "./renderers/adaptive-cards": "./renderers/adaptive-cards/index.mjs", + "./renderers/terminal": "./renderers/terminal/index.mjs", "./package.json": "./package.json" }, "bin": { - "abap2ui5-conformance": "conformance/backend/bin/abap2ui5-conformance.mjs" + "abap2ui5-conformance": "conformance/backend/bin/abap2ui5-conformance.mjs", + "abap2ui5-tui": "renderers/terminal/bin/abap2ui5-tui.mjs" }, "files": [ "conformance/backend/", @@ -34,13 +36,15 @@ "conformance:frontend:agent": "node conformance/backend/bin/abap2ui5-conformance.mjs frontend --adapter agent", "conformance:frontend:webcomponent": "node conformance/backend/bin/abap2ui5-conformance.mjs frontend --adapter webcomponent", "conformance:frontend:adaptive-cards": "node conformance/backend/bin/abap2ui5-conformance.mjs frontend --adapter adaptive-cards", + "conformance:frontend:terminal": "node conformance/backend/bin/abap2ui5-conformance.mjs frontend --adapter terminal", "demo:adaptive-cards": "node renderers/adaptive-cards/demo.mjs", + "tui": "node renderers/terminal/bin/abap2ui5-tui.mjs", "build:node-runtime": "node conformance/hosts/node-runtime/build.mjs", "serve:node-runtime": "node conformance/hosts/node-runtime/serve.mjs --build", "serve:cap2ui5": "node conformance/hosts/cap2ui5/serve.mjs", "record": "node scripts/record-traffic.mjs node-runtime && node scripts/record-traffic.mjs cap2ui5", "lint:abap": "cd conformance/apps && abaplint abaplint.jsonc", - "generate": "node scripts/gen-check-list.mjs && node scripts/render-portable.mjs && node scripts/render-adaptive-cards.mjs", + "generate": "node scripts/gen-check-list.mjs && node scripts/render-portable.mjs && node scripts/render-adaptive-cards.mjs && node scripts/render-terminal.mjs", "vendor:agent": "node scripts/vendor-agent-client.mjs" }, "engines": { @@ -62,7 +66,9 @@ "specification", "conformance", "server-driven-ui", - "json-schema" + "json-schema", + "terminal", + "tui" ], "devDependencies": { "@abap2ui5/node-runtime": "1.146.0", diff --git a/renderers/adaptive-cards/mapping.mjs b/renderers/adaptive-cards/mapping.mjs index 59f884c..a8f881d 100644 --- a/renderers/adaptive-cards/mapping.mjs +++ b/renderers/adaptive-cards/mapping.mjs @@ -1,10 +1,11 @@ /* * The mapping of portable profile v1 (profiles/portable-v1.json) onto * Adaptive Cards 1.5 - one entry per control: the card element(s) it - * becomes (`card`), what is approximated or left out (`note`), its default - * aggregation, and the render function. README.md's mapping table is - * generated from this table (node scripts/render-adaptive-cards.mjs); a - * control of the profile without an entry fails test/adaptive-cards.test.mjs. + * becomes (`card`), what is approximated or left out (`note`) and the + * render function (the default aggregations are ../common/view.mjs's). + * README.md's mapping table is generated from this table (node + * scripts/render-adaptive-cards.mjs); a control of the profile without an + * entry fails test/adaptive-cards.test.mjs. */ import { resolve, boundPath, text, truthy, falsy, children, aggregation, contentChildren, rowsOf, @@ -12,7 +13,7 @@ import { } from "./render.mjs"; /** Elements that carry no UI (profiles/portable.md section 2). */ -export const TOLERATED = new Set(["sap.ui.core.CustomData", "sap.m.FlexItemData", "sap.ui.layout.GridData", "sap.m.OverflowToolbarLayoutData"]); +export { TOLERATED } from "../common/view.mjs"; const tb = (t, extra = {}) => ({ type: "TextBlock", text: t, wrap: true, ...extra }); const heading = (t, size = "Medium") => tb(t, { size, weight: "Bolder", style: "heading" }); @@ -235,7 +236,7 @@ function datePicker(node, ctx) { const INPUT_STYLE = { Password: "password", Email: "email", Tel: "tel", Url: "url" }; /* - * The table: control -> { card, note, defaultAggregation, render }. + * The table: control -> { card, note, render }. * `card` and `note` are what README.md shows. */ export const CONTROLS = { @@ -243,7 +244,7 @@ export const CONTROLS = { "sap.m.Page": { card: "Container (flattened): title as heading TextBlock, nav button as Action.Submit", note: "`showNavButton` + `navButtonPress` -> an Action.Submit \"Back\" that raises the wired event (the reserved `___ZZZ_NAL` for `_event_nav_app_leave`); header/footer bars render in place", - defaultAggregation: "content", + render(node, ctx) { const out = []; const custom = aggregation(node, "customHeader"); @@ -258,39 +259,39 @@ export const CONTROLS = { return out; }, }, - "sap.m.Shell": { card: "(none) - its app renders in place", note: "", defaultAggregation: "app", render: (node, ctx) => renderList(contentChildren(node), ctx) }, - "sap.m.VBox": { card: "Container", note: "flexbox alignment ignored", defaultAggregation: "items", render: (node, ctx) => container(renderList(contentChildren(node), ctx)) }, + "sap.m.Shell": { card: "(none) - its app renders in place", note: "", render: (node, ctx) => renderList(contentChildren(node), ctx) }, + "sap.m.VBox": { card: "Container", note: "flexbox alignment ignored", render: (node, ctx) => container(renderList(contentChildren(node), ctx)) }, "sap.ui.layout.form.SimpleForm": { card: "Container: each Label becomes the `label` of the input after it", note: "the grid layout properties are ignored; a Label not followed by an input is a bold TextBlock", - defaultAggregation: "content", + render: (node, ctx) => container([ ...(node.attrs.title !== undefined ? [heading(str(node, "title", ctx))] : renderList(aggregation(node, "title"), ctx)), ...renderList(aggregation(node, "content"), ctx), ]), }, - "sap.m.HBox": { card: "ColumnSet (auto-width columns), or one ActionSet when every child is an action", note: "", defaultAggregation: "items", render: (node, ctx) => row(contentChildren(node), ctx) }, + "sap.m.HBox": { card: "ColumnSet (auto-width columns), or one ActionSet when every child is an action", note: "", render: (node, ctx) => row(contentChildren(node), ctx) }, "sap.m.Panel": { card: "Container (style emphasis), headerText as bold TextBlock", note: "`expandable`/`expanded` ignored - the content is always shown; `expand` not raised", - defaultAggregation: "content", + render(node, ctx) { const head = node.attrs.headerText !== undefined ? [tb(str(node, "headerText", ctx), { weight: "Bolder" })] : []; return container([...head, ...renderList(aggregation(node, "headerToolbar"), ctx), ...renderList(aggregation(node, "content"), ctx)], { style: "emphasis" }); }, }, - "sap.ui.layout.Grid": { card: "Container", note: "spans ignored - children stack", defaultAggregation: "content", render: (node, ctx) => container(renderList(contentChildren(node), ctx)) }, + "sap.ui.layout.Grid": { card: "Container", note: "spans ignored - children stack", render: (node, ctx) => container(renderList(contentChildren(node), ctx)) }, "sap.m.FlexBox": { card: "Container (direction Column) or ColumnSet (Row, the default)", note: "alignment, gaps and wrap ignored", - defaultAggregation: "items", + render: (node, ctx) => (/^Column/.test(str(node, "direction", ctx)) ? container(renderList(contentChildren(node), ctx)) : row(contentChildren(node), ctx)), }, - "sap.m.ScrollContainer": { card: "Container", note: "scrolling is the host's", defaultAggregation: "content", render: (node, ctx) => container(renderList(contentChildren(node), ctx)) }, + "sap.m.ScrollContainer": { card: "Container", note: "scrolling is the host's", render: (node, ctx) => container(renderList(contentChildren(node), ctx)) }, "sap.m.IconTabFilter": { card: "Container with the tab text as heading", note: "", - defaultAggregation: "content", + render(node, ctx) { const title = str(node, "text", ctx) || str(node, "key", ctx); const count = str(node, "count", ctx); @@ -300,27 +301,27 @@ export const CONTROLS = { "sap.m.IconTabBar": { card: "Container: every tab stacked, each under its heading", note: "`selectedKey` ignored - all tabs are shown; `select` not raised", - defaultAggregation: "items", + render(node, ctx) { if (node.attrs.select !== undefined && ctx.interactive) ctx.report(node, "event select is not raised by a card - every tab is shown"); return container([...renderList(aggregation(node, "items"), ctx), ...renderList(aggregation(node, "content"), ctx)]); }, }, - "sap.ui.layout.VerticalLayout": { card: "Container", note: "", defaultAggregation: "content", render: (node, ctx) => container(renderList(contentChildren(node), ctx)) }, + "sap.ui.layout.VerticalLayout": { card: "Container", note: "", render: (node, ctx) => container(renderList(contentChildren(node), ctx)) }, "sap.ui.core.Title": { card: "TextBlock (heading)", note: "", render: (node, ctx) => [heading(str(node, "text", ctx))] }, - "sap.ui.layout.HorizontalLayout": { card: "ColumnSet / ActionSet (as HBox)", note: "", defaultAggregation: "content", render: (node, ctx) => row(contentChildren(node), ctx) }, + "sap.ui.layout.HorizontalLayout": { card: "ColumnSet / ActionSet (as HBox)", note: "", render: (node, ctx) => row(contentChildren(node), ctx) }, // ------------------------------------------------------ toolbars & bars - "sap.m.OverflowToolbar": { card: "ActionSet (all buttons) or ColumnSet", note: "no overflow menu - everything is shown", defaultAggregation: "content", render: (node, ctx) => row(contentChildren(node), ctx) }, + "sap.m.OverflowToolbar": { card: "ActionSet (all buttons) or ColumnSet", note: "no overflow menu - everything is shown", render: (node, ctx) => row(contentChildren(node), ctx) }, "sap.m.ToolbarSpacer": { card: "(nothing)", note: "", render: () => [] }, - "sap.m.Toolbar": { card: "ActionSet (all buttons) or ColumnSet", note: "", defaultAggregation: "content", render: (node, ctx) => row(contentChildren(node), ctx) }, + "sap.m.Toolbar": { card: "ActionSet (all buttons) or ColumnSet", note: "", render: (node, ctx) => row(contentChildren(node), ctx) }, "sap.m.Bar": { card: "ActionSet or ColumnSet of contentLeft, contentMiddle, contentRight", note: "", render: (node, ctx) => row(children(node, ["contentLeft", "contentMiddle", "contentRight"]), ctx) }, "sap.m.OverflowToolbarButton": { card: "Action.Submit", note: "as Button", render: button }, // -------------------------------------------------------------- display "sap.m.Text": { card: "TextBlock (wrap)", note: "`maxLines` -> maxLines", render: (node, ctx) => [tb(str(node, "text", ctx), node.attrs.maxLines !== undefined ? { maxLines: Number(attr(node, "maxLines", ctx)) || undefined } : {})] }, "sap.m.Label": { card: "TextBlock (bolder) - or the `label` of the input that follows", note: "", render: (node, ctx) => [tb(str(node, "text", ctx), { weight: "Bolder" })] }, - "sap.m.Title": { card: "TextBlock (heading, bolder, medium)", note: "", defaultAggregation: "content", render: (node, ctx) => [heading(str(node, "text", ctx))] }, + "sap.m.Title": { card: "TextBlock (heading, bolder, medium)", note: "", render: (node, ctx) => [heading(str(node, "text", ctx))] }, "sap.m.ObjectStatus": { card: "TextBlock \"title: text\", colored by `state`", note: "`active` + `press` -> Action.Submit", @@ -403,9 +404,9 @@ export const CONTROLS = { "sap.ui.core.Item": { card: "a choice (`key` -> value, `text` -> title) of its Select/ComboBox", note: "", render: (node, ctx) => [tb(str(node, "text", ctx))] }, "sap.m.CheckBox": { card: "Input.Toggle (`text` -> title, value \"true\"/\"false\")", note: "`select` not raised", render(node, ctx) { unraised(node, ctx, ["select"]); return toggle(node, ctx, "selected", str(node, "text", ctx)); } }, "sap.m.Switch": { card: "Input.Toggle (`state`)", note: "`change` not raised; title = `customTextOn` or \"On\"", render(node, ctx) { unraised(node, ctx, ["change"]); return toggle(node, ctx, "state", str(node, "customTextOn", ctx)); } }, - "sap.m.SegmentedButton": { card: "Input.ChoiceSet (style expanded)", note: "`selectionChange` not raised", defaultAggregation: "items", render(node, ctx) { unraised(node, ctx, ["selectionChange"]); return choiceSet(node, ctx, { prop: "selectedKey", style: "expanded" }); } }, + "sap.m.SegmentedButton": { card: "Input.ChoiceSet (style expanded)", note: "`selectionChange` not raised", render(node, ctx) { unraised(node, ctx, ["selectionChange"]); return choiceSet(node, ctx, { prop: "selectedKey", style: "expanded" }); } }, "sap.m.SegmentedButtonItem": { card: "a choice of its SegmentedButton", note: "", render: (node, ctx) => [tb(str(node, "text", ctx))] }, - "sap.m.Select": { card: "Input.ChoiceSet (style compact), `selectedKey`, choices from the items (list binding or static)", note: "`change` not raised", defaultAggregation: "items", render(node, ctx) { unraised(node, ctx, ["change", "liveChange"]); return choiceSet(node, ctx, { prop: "selectedKey", style: "compact" }); } }, + "sap.m.Select": { card: "Input.ChoiceSet (style compact), `selectedKey`, choices from the items (list binding or static)", note: "`change` not raised", render(node, ctx) { unraised(node, ctx, ["change", "liveChange"]); return choiceSet(node, ctx, { prop: "selectedKey", style: "compact" }); } }, "sap.m.DatePicker": { card: "Input.Date when the value is ISO (yyyy-MM-dd), else Input.Text", note: "Input.Date speaks only yyyy-MM-dd; other `valueFormat`s stay text; `change` not raised", render: datePicker }, "sap.m.SearchField": { card: "Input.Text, `search` -> inlineAction", @@ -420,11 +421,11 @@ export const CONTROLS = { }); }, }, - "sap.m.ComboBox": { card: "Input.ChoiceSet (style filtered)", note: "`change` not raised", defaultAggregation: "items", render(node, ctx) { unraised(node, ctx, ["change"]); return choiceSet(node, ctx, { prop: node.attrs.selectedKey !== undefined ? "selectedKey" : "value", style: "filtered" }); } }, + "sap.m.ComboBox": { card: "Input.ChoiceSet (style filtered)", note: "`change` not raised", render(node, ctx) { unraised(node, ctx, ["change"]); return choiceSet(node, ctx, { prop: node.attrs.selectedKey !== undefined ? "selectedKey" : "value", style: "filtered" }); } }, "sap.m.MultiInput": { card: "Input.Text with the token texts", note: "tokens are shown, not edited as tokens - approximated", - defaultAggregation: "suggestionItems", + render(node, ctx) { const tokens = rowsOf(node, "tokens", ctx).map(({ base, node: t }) => str(t, "text", ctx.with(base)) || str(t, "key", ctx.with(base))); if (tokens.length) ctx.report(node, "tokens shown as text"); @@ -433,7 +434,7 @@ export const CONTROLS = { }, }, "sap.m.Token": { card: "a text of its MultiInput", note: "", render: (node, ctx) => [tb(str(node, "text", ctx))] }, - "sap.m.MultiComboBox": { card: "Input.ChoiceSet (isMultiSelect), `selectedKeys` joined by commas", note: "`selectionChange`/`selectionFinish` not raised", defaultAggregation: "items", render(node, ctx) { unraised(node, ctx, ["selectionChange", "selectionFinish"]); return choiceSet(node, ctx, { prop: "selectedKeys", style: "compact", multi: true }); } }, + "sap.m.MultiComboBox": { card: "Input.ChoiceSet (isMultiSelect), `selectedKeys` joined by commas", note: "`selectionChange`/`selectionFinish` not raised", render(node, ctx) { unraised(node, ctx, ["selectionChange", "selectionFinish"]); return choiceSet(node, ctx, { prop: "selectedKeys", style: "compact", multi: true }); } }, "sap.m.StepInput": { card: "Input.Number (`min`, `max`)", note: "`step` ignored; `change` not raised", render(node, ctx) { unraised(node, ctx, ["change"]); return field(node, ctx, "value", (id, v) => { const el = { type: "Input.Number", id, value: Number.isNaN(Number(v)) || v === "" || v === null || v === undefined ? undefined : Number(v) }; for (const k of ["min", "max"]) if (node.attrs[k] !== undefined) el[k] = Number(attr(node, k, ctx)); return el; }); } }, "sap.ui.core.ListItem": { card: "a choice of its ComboBox/Select", note: "`additionalText` dropped", render: (node, ctx) => [tb(str(node, "text", ctx))] }, "sap.m.DateTimePicker": { card: "Input.Text", note: "no date-time input in 1.5; `change` not raised", render(node, ctx) { unraised(node, ctx, ["change"]); return field(node, ctx, "value", (id, v) => ({ type: "Input.Text", id, value: text(v) })); } }, @@ -443,20 +444,20 @@ export const CONTROLS = { "sap.m.ToggleButton": { card: "Input.Toggle (`pressed`)", note: "`press` not raised - the state travels with the next action", render(node, ctx) { unraised(node, ctx, ["press"]); return toggle(node, ctx, "pressed", str(node, "text", ctx) || iconName(str(node, "icon", ctx))); } }, // ------------------------------------------------- collections & tables - "sap.m.Column": { card: "a column of its Table; `header` -> the header row", note: "popin and widths ignored", defaultAggregation: "header", render: () => [] }, - "sap.m.ColumnListItem": { card: "TableRow; `press` -> selectAction of its cells; `selected` -> a leading Input.Toggle in a selectable table", note: "", defaultAggregation: "cells", render: (node, ctx) => row(aggregation(node, "cells"), ctx) }, - "sap.m.Table": { card: "Table (1.5): header row from the columns, a TableRow per row of the list binding", note: "`itemPress` -> selectAction per row; `mode` *Select + `selected` binding -> Input.Toggle per row; growing ignored (every row is shown); `selectionChange` not raised", defaultAggregation: "items", render: table }, - "sap.m.List": { card: "FactSet (title -> value from description/info) - a Container per row when rows are pressable, selectable or custom", note: "`itemPress`/item `press` -> selectAction; `mode` *Select + `selected` binding -> Input.Toggle; `delete`/`selectionChange` not raised", defaultAggregation: "items", render: (node, ctx) => list(node, ctx) }, - "sap.m.StandardListItem": { card: "a fact (or row Container) of its List", note: "`icon`, `counter`, `highlight` dropped", defaultAggregation: "actions", render: (node, ctx) => [tb(str(node, "title", ctx))] }, - "sap.m.CustomListItem": { card: "a row Container of its List", note: "", defaultAggregation: "content", render: (node, ctx) => container(renderList(contentChildren(node), ctx)) }, - "sap.m.Tree": { card: "FactSet / row Containers, the tree flattened depth first (\"- \" per level)", note: "every node is shown expanded; `toggleOpenState` not raised", defaultAggregation: "items", render: (node, ctx) => list(node, ctx, { tree: true }) }, + "sap.m.Column": { card: "a column of its Table; `header` -> the header row", note: "popin and widths ignored", render: () => [] }, + "sap.m.ColumnListItem": { card: "TableRow; `press` -> selectAction of its cells; `selected` -> a leading Input.Toggle in a selectable table", note: "", render: (node, ctx) => row(aggregation(node, "cells"), ctx) }, + "sap.m.Table": { card: "Table (1.5): header row from the columns, a TableRow per row of the list binding", note: "`itemPress` -> selectAction per row; `mode` *Select + `selected` binding -> Input.Toggle per row; growing ignored (every row is shown); `selectionChange` not raised", render: table }, + "sap.m.List": { card: "FactSet (title -> value from description/info) - a Container per row when rows are pressable, selectable or custom", note: "`itemPress`/item `press` -> selectAction; `mode` *Select + `selected` binding -> Input.Toggle; `delete`/`selectionChange` not raised", render: (node, ctx) => list(node, ctx) }, + "sap.m.StandardListItem": { card: "a fact (or row Container) of its List", note: "`icon`, `counter`, `highlight` dropped", render: (node, ctx) => [tb(str(node, "title", ctx))] }, + "sap.m.CustomListItem": { card: "a row Container of its List", note: "", render: (node, ctx) => container(renderList(contentChildren(node), ctx)) }, + "sap.m.Tree": { card: "FactSet / row Containers, the tree flattened depth first (\"- \" per level)", note: "every node is shown expanded; `toggleOpenState` not raised", render: (node, ctx) => list(node, ctx, { tree: true }) }, "sap.m.StandardTreeItem": { card: "a fact of its Tree", note: "`icon` dropped", render: (node, ctx) => [tb(str(node, "title", ctx))] }, // ---------------------------------------------------- dialogs & popups "sap.m.Dialog": { card: "Container (style emphasis): title heading, content, buttons as one ActionSet", note: "rendered above the page, which turns read-only while it is open (modal); `afterClose` not raised", - defaultAggregation: "content", + render(node, ctx) { const out = []; const custom = aggregation(node, "customHeader"); @@ -470,7 +471,7 @@ export const CONTROLS = { "sap.m.Popover": { card: "Container (style emphasis): title heading, content, footer", note: "no anchor - shown above the page, which turns read-only while it is open; `afterClose` not raised", - defaultAggregation: "content", + render(node, ctx) { const out = []; if (node.attrs.title !== undefined) out.push(heading(str(node, "title", ctx))); diff --git a/renderers/adaptive-cards/render.mjs b/renderers/adaptive-cards/render.mjs index 5300765..a3774ee 100644 --- a/renderers/adaptive-cards/render.mjs +++ b/renderers/adaptive-cards/render.mjs @@ -25,185 +25,20 @@ * of a popup or popover names its slot in its data (`slot`), so the reverse * step knows whose inputs and model it submits. */ -import { parseViewXml, parseBinding, parseWire, evalExpression, isAggregation, controlName } from "../../conformance/frontend/adapters/vendor/mcp-server/viewxml.mjs"; -import { getAt } from "../../conformance/frontend/adapters/vendor/mcp-server/snapshot.mjs"; +import { + LEAVE_EVENT, modelKeyOf, liveSlots, absolutePath, resolve, boundPath, text, truthy, falsy, + children, aggregation, contentChildren, rowsOf, wireData, parseSlot, htmlToText, controlName, isAggregation, +} from "../common/view.mjs"; import { CONTROLS, TOLERATED } from "./mapping.mjs"; export const CARD_SCHEMA = "http://adaptivecards.io/schemas/adaptive-card.json"; export const CARD_VERSION = "1.5"; -/** The reserved leave event (spec/navigation.md#the-reserved-leave-event). */ -export const LEAVE_EVENT = "___ZZZ_NAL"; - -const MODEL_OWNING = { MAIN: "MAIN", NEST: "MAIN", NEST2: "MAIN", POPUP: "POPUP", POPOVER: "POPOVER" }; -export const modelKeyOf = (slot) => MODEL_OWNING[slot] || "MAIN"; - -/** - * The slots whose inputs and actions are live. A message box and a popup are - * modal: only they are. A popover is not (UI5 closes it on a press outside): - * the popover and MAIN are live, a press in MAIN closes the popover first. - */ -export function liveSlots(state, messages = []) { - const slots = (state && state.slots) || {}; - if (messages.some((m) => m.kind === "box")) return ["BOX"]; - if (slots.POPUP) return ["POPUP"]; - if (slots.POPOVER) return ["POPOVER", "MAIN"]; - return ["MAIN"]; -} - -// ------------------------------------------------------------ values ---- - -const join = (base, rel) => `${base.replace(/\/$/, "")}/${rel.replace(/^\//, "")}`; - -/** The absolute model path of a relative or absolute binding path. */ -export function absolutePath(path, base) { - if (path.startsWith("/")) return path; - return join(base || "", path); -} - -/** A model reference of an expression or a composite binding. */ -function refValue(ref, ctx) { - const r = String(ref).trim(); - if (/^[A-Za-z_][\w.-]*>/.test(r)) return undefined; // a named model (device>): not ours - return getAt(ctx.model, absolutePath(r, ctx.base)); -} - -/** A property value, with its bindings resolved against the model. */ -export function resolve(value, ctx) { - const b = parseBinding(value); - switch (b.kind) { - case "literal": - return b.value; - case "path": - return b.model ? undefined : getAt(ctx.model, absolutePath(b.path, ctx.base)); - case "expression": - return evalExpression(b.expression, (r) => refValue(r, ctx)); - default: - return b.parts.map((p) => { - if (p.text !== undefined) return p.text; - if (p.path !== undefined && !p.model) { - const v = getAt(ctx.model, absolutePath(p.path, ctx.base)); - return v === undefined || v === null ? "" : String(v); - } - return ""; - }).join(""); - } -} - -/** The model path a two-way property is bound to, or null. */ -export function boundPath(value, ctx) { - if (value === undefined) return null; - const b = parseBinding(value); - if (b.kind !== "path" || b.model) return null; - return absolutePath(b.path, ctx.base); -} - -export const text = (v) => (v === undefined || v === null ? "" : String(v)); -const truthy = (v) => v === true || v === "true" || v === "X"; -const falsy = (v) => v === false || v === "false" || v === "" || v === null; - -// ------------------------------------------------------------- nodes ---- - -/** The control children of a node: its own, and those of its aggregation - * elements (all of them, or the ones named). */ -export function children(node, names) { - const out = []; - for (const c of node.children) { - if (isAggregation(c)) { - if (!names || names.includes(c.local)) out.push(...c.children.filter((x) => !isAggregation(x))); - } else if (!names || names.includes(defaultAggregationOf(node))) { - out.push(c); - } - } - return out; -} - -/** The children of one named aggregation (the default aggregation also - * takes the children written without its element). */ -export function aggregation(node, name) { - return children(node, [name]); -} - -function defaultAggregationOf(node) { - const m = CONTROLS[controlName(node)]; - return (m && m.defaultAggregation) || "content"; -} - -const SKIP_AGGREGATIONS = new Set(["layoutData", "customData", "dependents", "tooltip"]); - -/** Every control child except the non-visual aggregations. */ -export function contentChildren(node, except = []) { - const out = []; - for (const c of node.children) { - if (isAggregation(c)) { - if (SKIP_AGGREGATIONS.has(c.local) || except.includes(c.local)) continue; - out.push(...c.children.filter((x) => !isAggregation(x))); - } else { - out.push(c); - } - } - return out; -} - -/** The rows of a list binding: [{ base, node }] per row, or the static - * children as rows with the current base. */ -export function rowsOf(node, aggName, ctx) { - const binding = node.attrs[aggName]; - const items = aggregation(node, aggName); - if (binding !== undefined) { - const p = boundPath(binding, ctx) || listPath(binding, ctx); - const template = items[0]; - const data = p ? getAt(ctx.model, p) : undefined; - if (!template || !Array.isArray(data)) return []; - return data.map((_, i) => ({ base: `${p}/${i}`, node: template, index: i })); - } - return items.map((n, i) => ({ base: ctx.base, node: n, index: i })); -} - -function listPath(binding, ctx) { - const b = parseBinding(binding); - if (b.kind === "composite" && b.parts.length === 1 && b.parts[0].path !== undefined && !b.parts[0].model) return absolutePath(b.parts[0].path, ctx.base); - return null; -} - -// ------------------------------------------------------------ events ---- - -/** An event wire of a control as Action.Submit data, or null. */ -export function wireData(node, eventName, ctx) { - const raw = node.attrs[eventName]; - if (raw === undefined) return null; - const w = parseWire(raw); - if (!w) { - ctx.report(node, `event ${eventName}: "${String(raw).slice(0, 60)}" is no abap2UI5 wire`); - return null; - } - const args = []; - const refs = []; - for (const [i, a] of w.args.entries()) { - if (a.static) { - args.push(a.value); - continue; - } - if (a.kind === "model" || a.kind === "row") { - const p = absolutePath(a.path, ctx.base); - args.push(getAt(ctx.model, p) ?? null); - if (a.kind === "model") refs[i] = p; - continue; - } - if (a.kind === "source") { - args.push(resolve(node.attrs[a.prop], ctx) ?? null); - continue; - } - ctx.report(node, `event ${eventName}: argument ${a.describe} needs the UI5 runtime - sent as null`); - args.push(null); - } - const data = w.fn === "eF" ? { client: [w.action, ...args] } : { event: w.event }; - if (w.fn !== "eF" && args.length) data.args = args; - if (refs.some(Boolean)) data.refs = Array.from(refs, (r) => r || null); - // the slot of the action, when it is not MAIN: its inputs and model are the slot's - if (modelKeyOf(ctx.slot) !== "MAIN") data.slot = ctx.slot; - return data; -} +// the view helpers shared with the terminal renderer (../common/view.mjs) +export { + LEAVE_EVENT, modelKeyOf, liveSlots, absolutePath, resolve, boundPath, text, + children, aggregation, contentChildren, rowsOf, wireData, htmlToText, +}; /** An Action.Submit for an event wire (or a disabled one without a wire). */ export function submitAction(title, data, { style, enabled = true, tooltip } = {}) { @@ -313,26 +148,6 @@ export function readOnly(labelText, value) { // -------------------------------------------------------------- card ---- -/** The text of a message box's HTML details, for a TextBlock: tags dropped, - * entities decoded - nothing of the markup is rendered. */ -export function htmlToText(html) { - return String(html || "") - .replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, " ") - .replace(/<(br|\/p|\/li|\/div|\/h[1-6])\b[^>]*>/gi, "\n") - .replace(/]*>/gi, "- ") - .replace(/<[^>]*>/g, "") - .replace(/ /g, " ") - .replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/&/g, "&") - .split("\n").map((l) => l.replace(/\s+/g, " ").trim()).filter(Boolean).join("\n"); -} - -function parseSlot(xml) { - const root = parseViewXml(xml); - // the document element (mvc:View / core:FragmentDefinition) carries no UI - const doc = root.children.find((c) => !isAggregation(c)) || root; - return /^(View|FragmentDefinition)$/.test(doc.local) ? doc.children : [doc]; -} - function renderSlot(state, slot, { interactive, unsupported, ids, typed }) { const s = state.slots[slot]; const key = modelKeyOf(slot); diff --git a/renderers/adaptive-cards/submit.mjs b/renderers/adaptive-cards/submit.mjs index 60c08e4..37e2057 100644 --- a/renderers/adaptive-cards/submit.mjs +++ b/renderers/adaptive-cards/submit.mjs @@ -1,93 +1,8 @@ /* * The way back: an Action.Submit payload of a card rendered by render.mjs -> - * the next protocol request (spec/request.md). Pure, no I/O: - * - * submitToRequest(state, payload, { pending }) -> - * { kind: "event", request, model, edits } a roundtrip to send - * { kind: "client", action, model, edits } a frontend-only wire (.eF) - * { kind: "box", action, model, edits } a message box button - * - * An Adaptive Cards host submits the action's `data` merged with the values - * of the card's inputs, keyed by input id - and the ids are binding paths. - * Every input whose value differs from the model the card was rendered from - * is an edit: it is written into a copy of the slot's model (`model`) and - * travels as the model delta the UI5 frontend builds (the vendored - * buildDelta: a scalar or structure whole, table cells as `__delta` rows). - * Values arrive as strings (that is what a card submits) and are read back in - * the type the model holds there. + * the next protocol request (spec/request.md). An Adaptive Cards host + * submits the action's `data` merged with the values of the card's inputs, + * keyed by input id - and the ids are binding paths. The step itself is + * shared with the terminal renderer and lives in ../common/request.mjs. */ -import { buildDelta } from "../../conformance/frontend/adapters/vendor/mcp-server/appclient.mjs"; -import { getAt, setAt } from "../../conformance/frontend/adapters/vendor/mcp-server/snapshot.mjs"; -import { modelKeyOf } from "./render.mjs"; - -const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v))); - -/** A submitted input value in the type the model holds at that path. */ -export function coerce(value, current) { - if (typeof current === "number") { - const n = Number(value); - return value === "" || value === null || Number.isNaN(n) ? value : n; - } - if (typeof current === "boolean") return value === true || value === "true"; - if (Array.isArray(current)) return String(value ?? "").split(",").map((x) => x.trim()).filter(Boolean); - if (value === null || value === undefined) return ""; - return typeof value === "boolean" || typeof value === "number" ? value : String(value); -} - -const same = (a, b) => JSON.stringify(a ?? "") === JSON.stringify(b ?? ""); - -/** The input values of a payload: the keys that are model paths (`/X`, - * `/T/1/C`; a duplicate id carries `#`) - only those of `ids` when given - * (the inputs of the action's slot: a card submits all of its inputs). */ -export function inputsOf(payload, ids) { - return Object.entries(payload || {}) - .filter(([k]) => k.startsWith("/") && (!ids || ids.includes(k))) - .map(([k, v]) => [k.replace(/#\d+$/, ""), v]); -} - -/** - * state the folded response state the card was rendered from - * payload { ...action.data, "": value, ... } - data.slot names - * the slot of the action (absent: MAIN) - * ids the input ids of that slot on the card (default: every path key) - * pending paths of the slot's model edited earlier and not sent yet - */ -export function submitToRequest(state, payload, { pending = [], ids } = {}) { - const slot = payload.slot || "MAIN"; - const key = modelKeyOf(slot); - const model = clone((state.models[key] && state.models[key].data) || {}); - const edits = []; - for (const [path, raw] of inputsOf(payload, ids)) { - const current = getAt(model, path); - const value = coerce(raw, current); - if (same(value, current)) continue; - setAt(model, path, value); - if (!edits.includes(path)) edits.push(path); - } - const base = { slot, model, edits }; - if (Array.isArray(payload.client)) return { kind: "client", action: payload.client, ...base }; - if (payload.box !== undefined) return { kind: "box", action: String(payload.box), ...base }; - if (!payload.event) throw new Error("the payload names no event - not an action of this renderer"); - const args = Array.isArray(payload.args) ? payload.args.slice() : []; - // an argument read from the model reads it after this submit's own edits - (payload.refs || []).forEach((p, i) => { - if (p) args[i] = getAt(model, p) ?? null; - }); - return { kind: "event", request: eventRequest(state, payload.event, args, buildDelta([...new Set([...pending, ...edits])], model)), ...base }; -} - -/** An event request continuing the state's draft id (spec/request.md#event-requests). */ -export function eventRequest(state, event, args = [], delta = {}) { - const front = { ID: state.id, EVENT: event }; - if (args.length) front.T_EVENT_ARG = args; - const request = { S_FRONT: front }; - if (delta && Object.keys(delta).length) request.MODEL = delta; - return request; -} - -/** The app-start request (spec/request.md#app-start-shaped-requests). */ -export function startRequest({ origin, pathname, search }) { - const front = { ORIGIN: origin, PATHNAME: pathname }; - if (search) front.SEARCH = search; - return { S_FRONT: front }; -} +export { submitToRequest, eventRequest, startRequest, coerce, inputsOf } from "../common/request.mjs"; diff --git a/renderers/common/request.mjs b/renderers/common/request.mjs new file mode 100644 index 0000000..2aa7e80 --- /dev/null +++ b/renderers/common/request.mjs @@ -0,0 +1,97 @@ +/* + * The way back, shared by the portable renderers of this repository: an + * action payload -> the next protocol request (spec/request.md). Pure, no + * I/O: + * + * submitToRequest(state, payload, { pending }) -> + * { kind: "event", request, model, edits } a roundtrip to send + * { kind: "client", action, model, edits } a frontend-only wire (.eF) + * { kind: "box", action, model, edits } a message box button + * + * A payload is the action data of common/view.mjs `wireData` ({ event, args, + * refs, slot } / { client } / { box }) merged with input values keyed by + * their binding path - what an Adaptive Cards host submits (every input of + * the card) and what the terminal renderer submits (nothing: its edits are + * in the model already and travel as `pending`). Every input whose value + * differs from the model the screen was rendered from is an edit: it is + * written into a copy of the slot's model (`model`) and travels as the model + * delta the UI5 frontend builds (the vendored buildDelta: a scalar or + * structure whole, table cells as `__delta` rows). Values may arrive as + * strings (that is what a card submits) and are read back in the type the + * model holds there. + */ +import { buildDelta } from "../../conformance/frontend/adapters/vendor/mcp-server/appclient.mjs"; +import { getAt, setAt } from "../../conformance/frontend/adapters/vendor/mcp-server/snapshot.mjs"; +import { modelKeyOf } from "./view.mjs"; + +const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v))); + +/** A submitted input value in the type the model holds at that path. */ +export function coerce(value, current) { + if (typeof current === "number") { + const n = Number(value); + return value === "" || value === null || Number.isNaN(n) ? value : n; + } + if (typeof current === "boolean") return value === true || value === "true"; + if (Array.isArray(current)) return String(value ?? "").split(",").map((x) => x.trim()).filter(Boolean); + if (value === null || value === undefined) return ""; + return typeof value === "boolean" || typeof value === "number" ? value : String(value); +} + +const same = (a, b) => JSON.stringify(a ?? "") === JSON.stringify(b ?? ""); + +/** The input values of a payload: the keys that are model paths (`/X`, + * `/T/1/C`; a duplicate id carries `#`) - only those of `ids` when given + * (the inputs of the action's slot: a card submits all of its inputs). */ +export function inputsOf(payload, ids) { + return Object.entries(payload || {}) + .filter(([k]) => k.startsWith("/") && (!ids || ids.includes(k))) + .map(([k, v]) => [k.replace(/#\d+$/, ""), v]); +} + +/** + * state the folded response state the screen was rendered from + * payload { ...action.data, "": value, ... } - data.slot names + * the slot of the action (absent: MAIN) + * ids the input ids of that slot on screen (default: every path key) + * pending paths of the slot's model edited earlier and not sent yet + */ +export function submitToRequest(state, payload, { pending = [], ids } = {}) { + const slot = payload.slot || "MAIN"; + const key = modelKeyOf(slot); + const model = clone((state.models[key] && state.models[key].data) || {}); + const edits = []; + for (const [path, raw] of inputsOf(payload, ids)) { + const current = getAt(model, path); + const value = coerce(raw, current); + if (same(value, current)) continue; + setAt(model, path, value); + if (!edits.includes(path)) edits.push(path); + } + const base = { slot, model, edits }; + if (Array.isArray(payload.client)) return { kind: "client", action: payload.client, ...base }; + if (payload.box !== undefined) return { kind: "box", action: String(payload.box), ...base }; + if (!payload.event) throw new Error("the payload names no event - not an action of this renderer"); + const args = Array.isArray(payload.args) ? payload.args.slice() : []; + // an argument read from the model reads it after this submit's own edits + (payload.refs || []).forEach((p, i) => { + if (p) args[i] = getAt(model, p) ?? null; + }); + return { kind: "event", request: eventRequest(state, payload.event, args, buildDelta([...new Set([...pending, ...edits])], model)), ...base }; +} + +/** An event request continuing the state's draft id (spec/request.md#event-requests). */ +export function eventRequest(state, event, args = [], delta = {}) { + const front = { ID: state.id, EVENT: event }; + if (args.length) front.T_EVENT_ARG = args; + const request = { S_FRONT: front }; + if (delta && Object.keys(delta).length) request.MODEL = delta; + return request; +} + +/** The app-start request (spec/request.md#app-start-shaped-requests). */ +export function startRequest({ origin, pathname, search }) { + const front = { ORIGIN: origin, PATHNAME: pathname }; + if (search) front.SEARCH = search; + return { S_FRONT: front }; +} diff --git a/renderers/common/view.mjs b/renderers/common/view.mjs new file mode 100644 index 0000000..03a3bb1 --- /dev/null +++ b/renderers/common/view.mjs @@ -0,0 +1,249 @@ +/* + * What every portable renderer of this repository does with a view before it + * draws anything - shared by the Adaptive Cards renderer + * (../adaptive-cards/) and the terminal renderer (../terminal/). Pure + * functions over the folded response state of the vendored mcp-server + * snapshot module (`applyResponse`: the views in their slots, the models); + * the view XML itself is read by the vendored viewxml module (namespaces, + * bindings, event wires, expression bindings without eval). + * + * values resolve, boundPath, absolutePath - bindings against a model + * nodes children, aggregation, contentChildren, rowsOf - the control + * tree with its aggregations and list bindings + * events wireData - an event wire as the action data both renderers + * submit ({ event, args, refs, slot } / { client: [...] }) + * slots parseSlot, liveSlots, modelKeyOf + * + * A render context `ctx` is { model, base, slot, report(node, reason), + * with(base) } - the model of the slot, the binding context of the current + * row and the sink for what could not be rendered. + */ +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; +import { parseViewXml, parseBinding, parseWire, evalExpression, isAggregation, controlName } from "../../conformance/frontend/adapters/vendor/mcp-server/viewxml.mjs"; +import { getAt } from "../../conformance/frontend/adapters/vendor/mcp-server/snapshot.mjs"; + +export { isAggregation, controlName, getAt }; + +export const PROFILE = JSON.parse(fs.readFileSync(fileURLToPath(new URL("../../profiles/portable-v1.json", import.meta.url)), "utf8")); + +/** The reserved leave event (spec/navigation.md#the-reserved-leave-event). */ +export const LEAVE_EVENT = "___ZZZ_NAL"; + +const MODEL_OWNING = { MAIN: "MAIN", NEST: "MAIN", NEST2: "MAIN", POPUP: "POPUP", POPOVER: "POPOVER" }; +/** The model a slot's bindings read: MAIN, NEST and NEST2 share one. */ +export const modelKeyOf = (slot) => MODEL_OWNING[slot] || "MAIN"; + +/** + * The slots whose inputs and actions are live. A message box and a popup are + * modal: only they are. A popover is not (UI5 closes it on a press outside): + * the popover and MAIN are live, a press in MAIN closes the popover first. + */ +export function liveSlots(state, messages = []) { + const slots = (state && state.slots) || {}; + if (messages.some((m) => m.kind === "box")) return ["BOX"]; + if (slots.POPUP) return ["POPUP"]; + if (slots.POPOVER) return ["POPOVER", "MAIN"]; + return ["MAIN"]; +} + +/** Elements that carry no UI (profiles/portable.md section 2). */ +export const TOLERATED = new Set(Object.entries(PROFILE.controls).filter(([, c]) => c.tolerated).map(([n]) => n)); + +/* + * The aggregation the children of a control go to when they are written + * without an aggregation element (profiles/portable.md section 2) - the + * profile's `defaultAggregation`, plus the two controls whose items views + * write bare though the census records no default; "content" for the rest. + */ +export const DEFAULT_AGGREGATION = { + ...Object.fromEntries(Object.entries(PROFILE.controls).filter(([, c]) => c.defaultAggregation).map(([n, c]) => [n, c.defaultAggregation])), + "sap.m.IconTabBar": "items", + "sap.m.SegmentedButton": "items", +}; + +// ------------------------------------------------------------ values ---- + +const join = (base, rel) => `${base.replace(/\/$/, "")}/${rel.replace(/^\//, "")}`; + +/** The absolute model path of a relative or absolute binding path. */ +export function absolutePath(path, base) { + if (path.startsWith("/")) return path; + return join(base || "", path); +} + +/** A model reference of an expression or a composite binding. */ +function refValue(ref, ctx) { + const r = String(ref).trim(); + if (/^[A-Za-z_][\w.-]*>/.test(r)) return undefined; // a named model (device>): not ours + return getAt(ctx.model, absolutePath(r, ctx.base)); +} + +/** A property value, with its bindings resolved against the model. */ +export function resolve(value, ctx) { + const b = parseBinding(value); + switch (b.kind) { + case "literal": + return b.value; + case "path": + return b.model ? undefined : getAt(ctx.model, absolutePath(b.path, ctx.base)); + case "expression": + return evalExpression(b.expression, (r) => refValue(r, ctx)); + default: + return b.parts.map((p) => { + if (p.text !== undefined) return p.text; + if (p.path !== undefined && !p.model) { + const v = getAt(ctx.model, absolutePath(p.path, ctx.base)); + return v === undefined || v === null ? "" : String(v); + } + return ""; + }).join(""); + } +} + +/** The model path a two-way property is bound to, or null. */ +export function boundPath(value, ctx) { + if (value === undefined) return null; + const b = parseBinding(value); + if (b.kind !== "path" || b.model) return null; + return absolutePath(b.path, ctx.base); +} + +export const text = (v) => (v === undefined || v === null ? "" : String(v)); +export const truthy = (v) => v === true || v === "true" || v === "X"; +export const falsy = (v) => v === false || v === "false" || v === "" || v === null; + +// ------------------------------------------------------------- nodes ---- + +const defaultAggregationOf = (node) => DEFAULT_AGGREGATION[controlName(node)] || "content"; + +/** The control children of a node: its own, and those of its aggregation + * elements (all of them, or the ones named). */ +export function children(node, names) { + const out = []; + for (const c of node.children) { + if (isAggregation(c)) { + if (!names || names.includes(c.local)) out.push(...c.children.filter((x) => !isAggregation(x))); + } else if (!names || names.includes(defaultAggregationOf(node))) { + out.push(c); + } + } + return out; +} + +/** The children of one named aggregation (the default aggregation also + * takes the children written without its element). */ +export function aggregation(node, name) { + return children(node, [name]); +} + +const SKIP_AGGREGATIONS = new Set(["layoutData", "customData", "dependents", "tooltip"]); + +/** Every control child except the non-visual aggregations. */ +export function contentChildren(node, except = []) { + const out = []; + for (const c of node.children) { + if (isAggregation(c)) { + if (SKIP_AGGREGATIONS.has(c.local) || except.includes(c.local)) continue; + out.push(...c.children.filter((x) => !isAggregation(x))); + } else { + out.push(c); + } + } + return out; +} + +/** The rows of a list binding: [{ base, node }] per row, or the static + * children as rows with the current base. */ +export function rowsOf(node, aggName, ctx) { + const binding = node.attrs[aggName]; + const items = aggregation(node, aggName); + if (binding !== undefined) { + const p = boundPath(binding, ctx) || listPath(binding, ctx); + const template = items[0]; + const data = p ? getAt(ctx.model, p) : undefined; + if (!template || !Array.isArray(data)) return []; + return data.map((_, i) => ({ base: `${p}/${i}`, node: template, index: i })); + } + return items.map((n, i) => ({ base: ctx.base, node: n, index: i })); +} + +function listPath(binding, ctx) { + const b = parseBinding(binding); + if (b.kind === "composite" && b.parts.length === 1 && b.parts[0].path !== undefined && !b.parts[0].model) return absolutePath(b.parts[0].path, ctx.base); + return null; +} + +// ------------------------------------------------------------ events ---- + +/** + * An event wire of a control as action data, or null: { event, args, refs, + * slot } for a backend event - args resolved now, `refs` naming the model + * path of an argument read from the model so the request step can read it + * again after the edits that travel with it; { client: [action, args...] } + * for a frontend-only wire (`.eF`). `params` are the event parameters a + * renderer can provide (`${$parameters>/value}`, profiles/portable.md + * section 5); an argument nobody can evaluate is reported and sent as null. + */ +export function wireData(node, eventName, ctx, params) { + const raw = node.attrs[eventName]; + if (raw === undefined) return null; + const w = parseWire(raw); + if (!w) { + ctx.report(node, `event ${eventName}: "${String(raw).slice(0, 60)}" is no abap2UI5 wire`); + return null; + } + const args = []; + const refs = []; + for (const [i, a] of w.args.entries()) { + if (a.static) { + args.push(a.value); + continue; + } + if (a.kind === "model" || a.kind === "row") { + const p = absolutePath(a.path, ctx.base); + args.push(getAt(ctx.model, p) ?? null); + if (a.kind === "model") refs[i] = p; + continue; + } + if (a.kind === "source") { + args.push(resolve(node.attrs[a.prop], ctx) ?? null); + continue; + } + if (a.kind === "parameters" && params && Object.prototype.hasOwnProperty.call(params, a.path)) { + args.push(params[a.path] ?? null); + continue; + } + ctx.report(node, `event ${eventName}: argument ${a.describe} needs the UI5 runtime - sent as null`); + args.push(null); + } + const data = w.fn === "eF" ? { client: [w.action, ...args] } : { event: w.event }; + if (w.fn !== "eF" && args.length) data.args = args; + if (refs.some(Boolean)) data.refs = Array.from(refs, (r) => r || null); + // the slot of the action, when it is not MAIN: its inputs and model are the slot's + if (modelKeyOf(ctx.slot) !== "MAIN") data.slot = ctx.slot; + return data; +} + +// ------------------------------------------------------------- slots ---- + +/** The control nodes of a slot's document: the document element + * (mvc:View / core:FragmentDefinition) carries no UI. */ +export function parseSlot(xml) { + const root = parseViewXml(xml); + const doc = root.children.find((c) => !isAggregation(c)) || root; + return /^(View|FragmentDefinition)$/.test(doc.local) ? doc.children : [doc]; +} + +/** The text of HTML (a message box's details, core:HTML): tags dropped, + * entities decoded - nothing of the markup is rendered. */ +export function htmlToText(html) { + return String(html || "") + .replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, " ") + .replace(/<(br|\/p|\/li|\/div|\/h[1-6])\b[^>]*>/gi, "\n") + .replace(/]*>/gi, "- ") + .replace(/<[^>]*>/g, "") + .replace(/ /g, " ") + .replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/&/g, "&") + .split("\n").map((l) => l.replace(/\s+/g, " ").trim()).filter(Boolean).join("\n"); +} diff --git a/renderers/terminal/README.md b/renderers/terminal/README.md new file mode 100644 index 0000000..3c60714 --- /dev/null +++ b/renderers/terminal/README.md @@ -0,0 +1,319 @@ +# Terminal renderer + +A portable renderer for abap2UI5 that runs in a terminal: an abap2UI5 app - +on the abap2UI5 node runtime, on cap2UI5 or on an SAP system - as a +keyboard-driven text screen. It speaks the roundtrip protocol to the +backend's endpoint, renders the views of the portable profile +([../../profiles/portable.md](../../profiles/portable.md)) as terminal +widgets and turns the keys back into protocol requests. A `--print` mode +renders the first screen once as plain text, for logs, CI and screen +readers. + +Pure Node, no native modules, no dependencies: ANSI escape codes and +`node:readline` are all it needs. The view XML is read by the modules this +repository vendors from abap2UI5/mcp-server +([../../conformance/frontend/adapters/vendor/mcp-server/](../../conformance/frontend/adapters/vendor/mcp-server/source.json)) +through the view helpers it shares with the Adaptive Cards renderer +([../common/](../common/view.mjs): bindings, aggregations, list bindings, +event wires, and the model delta of the next request). + +``` + abap2UI5 - conformance - bind +conformance - bind +================================================================ +>start_______< +[1___________] +[ ] Flag +[Berlin______] +[10115_______] +[ Check ] [ Add row ] +1 | [one_________] | [ ] +2 | [two_________] | [ ] +3 | [three_______] | [ ] + + +type to edit Tab next F1 keys Ctrl+C quit +``` + +The screen of the conformance app BIND, 64 x 15, without colors (with +them, the header and the status line are reversed and the focused field is +highlighted instead of marked with `>` `<`). + +| File | | +|---|---| +| [bin/abap2ui5-tui.mjs](bin/abap2ui5-tui.mjs) | the CLI (`abap2ui5-tui`) | +| [session.mjs](session.mjs) | `createSession`: the protocol over HTTP - the client rules of [spec/transport.md](../../spec/transport.md#client-behaviour), basic auth and cookies for an SAP system, follow-up actions, the hash history | +| [app.mjs](app.mjs) | `createTerminalApp`: the state machine - focus, editing, pick lists, keys; `frame()` (the screen) and `print()` (the text) | +| [render.mjs](render.mjs) | state -> screen document: the layers (MAIN, popup, popover, message box), their blocks and widgets | +| [mapping.mjs](mapping.mjs) | the control table - one entry per portable control, what it becomes and its render function; the table below is generated from it | +| [layout.mjs](layout.mjs) | blocks -> lines at a width: wrapping, columns, tables, frames | +| [text.mjs](text.mjs) | display width, sanitising, truncation, glyphs, ANSI styles, `NO_COLOR` | +| [tty.mjs](tty.mjs) | the real terminal: raw keys in, frames out | +| [index.mjs](index.mjs) | the exports, `printResponses` | +| [golden/](golden/) | golden screens of recorded responses ([../../traffic/](../../traffic/)), held by `test/terminal.test.mjs` (`UPDATE_GOLDEN=1` rewrites them) | + +## Use it + +```bash +npx abap2ui5-tui http://localhost:3000/ --app Z2UI5_CL_MY_APP +npx abap2ui5-tui "https://host/sap/bc/z2ui5?sap-client=100" --app Z2UI5_CL_MY_APP --user DEVELOPER # password: ABAP2UI5_PASSWORD +npx abap2ui5-tui http://localhost:4004/rest/root/z2ui5 --app Z2UI5_CL_MY_APP --print --width 100 +``` + +| Option | | +|---|---| +| `` | the backend endpoint: the node runtime's root, a cap2UI5 service, an SAP ICF node; its query (`sap-client`, `sap-language`) stays on every request | +| `--app ` | the app to start (`?app_start=`); without it the backend's start app | +| `--user`, `--password` | basic authentication; the password may come from `ABAP2UI5_PASSWORD` instead of the command line | +| `--cookie "=; ..."` | cookies to send (a logon ticket); cookies the backend sets are kept, as a browser keeps them | +| `--header ": "` | an extra request header, repeatable | +| `--print` | render the first screen once as text and exit - no keys, no timers; what could not be rendered goes to stderr; exit code 1 when the backend answered with an error | +| `--width ` | the width of `--print` (default: the terminal's, else 80) | +| `--no-color` / `--color` | colors off / on; default: on for a TTY unless `NO_COLOR` is set (or `TERM=dumb`); `FORCE_COLOR` forces them | +| `--ascii` / `--unicode` | the frame glyphs; default: Unicode box drawing when the locale is UTF-8, ASCII for `--print` | + +```js +import { createSession, createTerminalApp, runTui, printResponses } from "@abap2ui5/protocol/renderers/terminal"; + +// a backend, a screen, keys +const session = createSession({ url: "http://localhost:3000/" }); +const app = createTerminalApp({ session, width: 80, height: 24 }); +await session.start("Z2UI5_CL_MY_APP"); +await app.key("tab"); await app.type("Ada"); await app.key("enter"); +app.frame().lines; // the screen, line by line +app.print(); // the whole screen as text, overlays below the page +await runTui({ app }); // or hand it the real terminal + +// pure: recorded responses in, text out +const { text, unsupported } = printResponses([response], { width: 80 }); +``` + +## Keys + +| Key | | +|---|---| +| Tab / Shift+Tab | the next / previous field or action | +| Up / Down | the field or action above / below (nearest line, then nearest column); in a StepInput: step the number; in a TextArea: the line above / below | +| Left / Right | move the cursor in a field; pick the previous / next option of a pick list; elsewhere the previous / next widget | +| Enter | press the focused button, link or row; in a field: commit it (and raise `submit` / `search`); on a pick list: open the list | +| Space | toggle a check box, switch or row selection; press a button | +| Esc | close the open pick list, the key help or a popover | +| Alt+Left, Ctrl+B / Alt+Right | back / forward in the hash history; with no history to go back to, Back presses the page's nav button | +| PageUp / PageDown | scroll the page | +| F4 | the value help of the field (`valueHelpRequest`) | +| Ctrl+U, Home, End | clear the field, start, end | +| F1 | the key help | +| Ctrl+R | restart the app from its start URL | +| Ctrl+C, Ctrl+Q | quit (the stateful session on the server is terminated) | + +The status line shows the keys of the focused widget - and, after a +roundtrip, what the terminal did with follow-up actions it has no +counterpart for (`open https://...`, `copied to the clipboard`, +`outside the portable profile: CONTROL_BY_ID - skipped`) and the controls +it could not render. + +## How a response becomes a screen + +- **Layers.** MAIN is the page; POPUP, POPOVER and the message box are + overlay frames above it (centred on the screen, or printed below the page + with `--print`). A message box and a popup are modal: only their widgets + take the focus. A popover is not: the page stays live, and Esc or an + action of the page closes the popover first, as UI5 does. NEST/NEST2 are + processed and shown as a placeholder - they are not in portable profile v1. +- **Widgets.** Fields `[value____]`, toggles `[x] text`, pick lists + `[Berlin v]`, buttons `[ text ]`, links `[text]`; the focused one is + drawn reversed (with `--no-color`: its brackets become `>` `<`). A + disabled or read-only control is drawn dim and skipped by the focus. +- **Edits** are those of a UI5 Input: written into the model when they are + committed - the focus leaves the field, Enter, an action fires - and only + when the value changed, in the type the model holds there (a number stays + a number). They travel with the next event of that model as the delta the + UI5 frontend builds: only the edited paths, a scalar or a structure + whole, table cells as `__delta` rows + ([spec/request.md](../../spec/request.md#the-model-delta)). Edits made + while a roundtrip is in flight survive its model push. +- **Events.** A press resolves the wire's arguments when it fires + (`${/ABS}`, `${REL}` against the row, `${$source>/prop}`, the event + parameters of profiles/portable.md section 5 such as `${$parameters>/value}`); + a frontend-only wire (`.eF`) runs without a roundtrip. Unlike a card, a + terminal raises the events of fields: `change`, `select`, + `selectionChange` when the value is committed, `submit` and `search` on + Enter. One roundtrip at a time: what fires meanwhile is queued and built + when it leaves. +- **Messages.** A toast is a `>> text` line above the page (its `onClose` + is raised after its duration); a message box an overlay frame with its + text, its details **expanded** as plain text and a button per action; an + error a red `Error:` block with the body verbatim + ([spec/errors.md](../../spec/errors.md#what-a-frontend-does-with-it)). +- **Text is sanitised.** Every control character a backend sends (in a + view, a model value, an error body) is replaced before it reaches the + terminal, so no response can move the cursor, retitle the window or write + the clipboard. Wide characters take two columns; what does not fit is cut + with an ellipsis. +- **Tolerance.** An element outside the profile is a visible placeholder + `[? - not rendered]`, reported in the status line and by + `unsupported` (profiles/portable.md#conformance); an unknown follow-up + action is skipped and named in the status line. +- **Follow-up actions** of `actions.wire` in + [portable-v1.json](../../profiles/portable-v1.json): toasts, boxes, + `START_TIMER`, `SET_FOCUS` (the focus moves to the widget with that id), + `SCROLL_TO`, `SET_TITLE` (the header line and the window title), + `CLIPBOARD_COPY` (OSC 52), `URLHELPER` / `OPEN_NEW_TAB` (the URL in the + status line), `LOCATION_RELOAD`, `HASH_BACK`, `BUSY_INDICATOR`, + `INVISIBLE_MESSAGE` (the status line); the rest is a logged no-op. +- **Routing.** The session keeps the hash a browser would show and its + history, synchronised once per response like the UI5 router (KEEP / + FRESH routes, the caller's entry repointed on a `nav_app_call`, the + app-state hash); every request carries it as `HASH`; Back restores the + route it lands on with an app-start-shaped request + ([spec/navigation.md](../../spec/navigation.md#the-router-action)). +- **Transport** ([spec/transport.md](../../spec/transport.md)): + `sap-contextid-accept: header` on every POST, the last valid + `sap-contextid` kept and sent back, the CSRF token handshake of a token + layer, no retry of a 500, a 120 s timeout, the terminate HEAD on quit. + +## Conformance + +The frontend suite drives it as the in-process adapter `terminal` +(`npx abap2ui5-conformance frontend --adapter terminal`, profile +`portable`) through its state machine, the way a user does: `fill` Tabs to +the field and types the value, `press` Tabs to the action and presses +Enter, `back` is Alt+Left; the state is read from the screen. Result in +[../../conformance/RESULTS.md](../../conformance/RESULTS.md#frontend-suite): +every MUST and SHOULD that applies holds, the router checks included; the +checks that need a DOM or a programmatic model edit are skipped. + +## Limits + +- No calendar, no value-help dialog of its own, no file upload: dates are + typed, F4 raises the app's `valueHelpRequest`. +- `liveChange` is raised once per committed edit, not per keystroke. +- Growing tables and lists show every row (the screen scrolls); tabs + (IconTabBar) are stacked; a panel is always expanded; layout properties + (flex alignment, grid spans, widths) are dropped. +- Typed bindings are shown raw (no number or date formatting); formatters + and `parts` bindings render empty. +- `sap.m.RadioButton` / `RadioButtonGroup` are not in portable profile v1 + and render as placeholders. +- Images are their alt text; a link's `href` is shown, not opened. + +## Mapping + +Generated from [mapping.mjs](mapping.mjs) (`node scripts/render-terminal.mjs`, +part of `npm run generate`; `npm test` fails when it is out of date). + + + +65 of 65 controls of portable profile v1 mapped. + +### Layout & containers + +| Control | Terminal | Notes | +|---|---|---| +| sap.m.Page | a header line (nav button, title, header content on the right) over a rule, then the content and the footer | `showNavButton` + `navButtonPress` -> the button `[ < ]` that raises the wired event (the reserved `___ZZZ_NAL` for `_event_nav_app_leave`); Alt+Left presses it when the history has no entry to go back to | +| sap.m.Shell | (none) - its app renders in place | - | +| sap.m.VBox | a stack | flexbox alignment ignored | +| sap.ui.layout.form.SimpleForm | label-field rows, the labels in one column | a Label followed by a field becomes one row; several fields after one label share the row; the grid layout properties are ignored | +| sap.m.HBox | one line of inline items, or columns side by side (stacked when they do not fit the width) | - | +| sap.m.Panel | a frame titled by `headerText` | `expandable`/`expanded` ignored - the content is always shown; `expand` not raised | +| sap.ui.layout.Grid | a stack | spans ignored - children stack | +| sap.m.FlexBox | a stack (direction Column) or a line / columns (Row, the default) | alignment, gaps and wrap ignored | +| sap.m.ScrollContainer | a stack | the screen scrolls as a whole | +| sap.m.IconTabFilter | a heading with the tab text (and count), its content below | - | +| sap.m.IconTabBar | every tab stacked, each under its heading | `selectedKey` ignored - all tabs are shown; `select` not raised (reported) | +| sap.ui.layout.VerticalLayout | a stack | - | +| sap.ui.core.Title | a heading (bold) | - | +| sap.ui.layout.HorizontalLayout | a line / columns (as HBox) | - | + +### Toolbars & bars + +| Control | Terminal | Notes | +|---|---|---| +| sap.m.OverflowToolbar | one line of inline items (wrapped when too wide) | no overflow menu - everything is shown; a ToolbarSpacer pushes what follows to the right | +| sap.m.ToolbarSpacer | the flexible space of its toolbar line | - | +| sap.m.Toolbar | one line of inline items (wrapped when too wide) | as OverflowToolbar | +| sap.m.Bar | one line: contentLeft, contentMiddle, contentRight | - | +| sap.m.OverflowToolbarButton | a button `[ text ]` | as Button | + +### Display + +| Control | Terminal | Notes | +|---|---|---| +| sap.m.Text | text, wrapped at the width | `maxLines` cuts after that many lines (with an ellipsis) | +| sap.m.Label | bold text - or the label column of the field that follows | - | +| sap.m.Title | a heading (bold) | - | +| sap.m.ObjectStatus | text "title: text", colored by `state` | `active` + `press` -> a link | +| sap.m.Link | a link `[text]` (press wire), else underlined text with the `href` | a terminal does not open URLs - the `href` is shown | +| sap.m.ObjectIdentifier | the title (bold) and the text (dim) below it | `titleActive` + `titlePress` -> a link | +| sap.ui.core.HTML | the text of the HTML | tags, `