From d1136bdf57ab306ccb61d25d8ae31fd61837b1f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:00:04 +0000 Subject: [PATCH 1/3] Spec revision 0.3 (maintainer decisions), error.no-reflection, Adaptive Cards renderer - spec/open-questions.md: all ten questions decided, each with decision and rationale; the decisions applied to spec/errors.md, transport.md, actions.md and profiles/portable.md (NEST tolerated by renderers, forbidden in portable apps - the abap2UI5 linter's portable rule) - new backend check error.no-reflection (MUST): request data the backend did not validate is not reflected into the error body; both reference backends fail it (abap2UI5 1.146.0 [H] request_context_info), pinned in test/lib/expected.mjs, suite traffic recorded again - portable-v1.json: actions.api (client API) apart from actions.wire (what a renderer receives: ROUTER + options, folded names, T_CUSTOM / .eF); additive, frontendActions kept; schema, generators, generated table - renderers/adaptive-cards: response -> Adaptive Card 1.5, Action.Submit payload -> request, all 65 portable controls mapped (README table generated from mapping.mjs), card host, demo, golden cards - frontend adapter adaptive-cards: 65 pass / 0 fail / 16 skip, pinned; UI5 pin accepts portable.box-details passing (fix under way) - CI: generate check, adaptive-cards report; README, AGENTS, RESULTS, CHANGELOG Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Kz1J78phfNKbqUaq3VcP4U --- .github/workflows/ci.yml | 15 +- AGENTS.md | 27 +- CHANGELOG.md | 44 + README.md | 40 +- conformance/README.md | 4 +- conformance/RESULTS.md | 87 +- conformance/backend/README.md | 1 + .../backend/bin/abap2ui5-conformance.mjs | 6 +- conformance/backend/lib/checks/errors.mjs | 30 +- conformance/frontend/README.md | 5 +- .../frontend/adapters/adaptive-cards.mjs | 172 ++++ conformance/frontend/adapters/index.mjs | 1 + package.json | 6 +- profiles/portable-v1.json | 108 ++- profiles/portable.md | 68 +- renderers/adaptive-cards/README.md | 245 +++++ renderers/adaptive-cards/demo.mjs | 50 + .../adaptive-cards/golden/bind.card.json | 207 ++++ .../golden/message-box.card.json | 60 ++ .../golden/nav-target.card.json | 58 ++ .../adaptive-cards/golden/nest.card.json | 75 ++ .../adaptive-cards/golden/popover.card.json | 85 ++ .../golden/popup-over-main.card.json | 70 ++ .../adaptive-cards/golden/sampler.card.json | 497 ++++++++++ .../golden/sampler.response.json | 43 + renderers/adaptive-cards/host.mjs | 321 ++++++ renderers/adaptive-cards/index.mjs | 24 + renderers/adaptive-cards/mapping.mjs | 492 ++++++++++ renderers/adaptive-cards/render.mjs | 443 +++++++++ renderers/adaptive-cards/submit.mjs | 93 ++ schema/portable-profile.schema.json | 80 ++ scripts/gen-portable-profile.mjs | 31 +- scripts/render-adaptive-cards.mjs | 62 ++ scripts/render-portable.mjs | 14 +- spec/README.md | 2 +- spec/actions.md | 4 +- spec/errors.md | 27 +- spec/open-questions.md | 322 +++--- spec/transport.md | 10 +- spec/versioning.md | 2 +- test/adaptive-cards.test.mjs | 218 +++++ test/backends.test.mjs | 15 +- test/frontend.test.mjs | 35 +- test/lib/expected.mjs | 17 + test/portable.test.mjs | 15 + test/runner.test.mjs | 2 +- test/traffic.test.mjs | 10 +- traffic/cap2ui5/suite.json | 915 +++++++++--------- traffic/node-runtime/suite.json | 624 ++++++------ 49 files changed, 4855 insertions(+), 927 deletions(-) create mode 100644 conformance/frontend/adapters/adaptive-cards.mjs create mode 100644 renderers/adaptive-cards/README.md create mode 100644 renderers/adaptive-cards/demo.mjs create mode 100644 renderers/adaptive-cards/golden/bind.card.json create mode 100644 renderers/adaptive-cards/golden/message-box.card.json create mode 100644 renderers/adaptive-cards/golden/nav-target.card.json create mode 100644 renderers/adaptive-cards/golden/nest.card.json create mode 100644 renderers/adaptive-cards/golden/popover.card.json create mode 100644 renderers/adaptive-cards/golden/popup-over-main.card.json create mode 100644 renderers/adaptive-cards/golden/sampler.card.json create mode 100644 renderers/adaptive-cards/golden/sampler.response.json create mode 100644 renderers/adaptive-cards/host.mjs create mode 100644 renderers/adaptive-cards/index.mjs create mode 100644 renderers/adaptive-cards/mapping.mjs create mode 100644 renderers/adaptive-cards/render.mjs create mode 100644 renderers/adaptive-cards/submit.mjs create mode 100644 scripts/render-adaptive-cards.mjs create mode 100644 test/adaptive-cards.test.mjs create mode 100644 test/lib/expected.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1fd6ba..3dfcdc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,12 +5,14 @@ # 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. Each backend run takes seconds; the -# install dominates. +# 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. # 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 and the agent client. +# the reports of the UI5 SPA, the agent client and the Adaptive Cards renderer. name: ci on: @@ -56,8 +58,10 @@ 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) + - name: npm test (both backends, agent client, Adaptive Cards renderer) run: npm test + - name: generated sections are up to date + run: npm run generate && git diff --exit-code - name: backend suite report - node-runtime if: ${{ !cancelled() }} run: node scripts/run-conformance.mjs node-runtime --json report-node-runtime.json @@ -101,6 +105,9 @@ jobs: - name: frontend suite report - agent client if: ${{ !cancelled() }} run: node conformance/backend/bin/abap2ui5-conformance.mjs frontend --adapter agent --json > report-frontend-agent.json || true + - 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 - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: ${{ !cancelled() }} with: diff --git a/AGENTS.md b/AGENTS.md index 3c62973..296c4fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,9 +17,11 @@ down, and the spec records what the implementations do. `spec/README.md#sources`. A statement you cannot trace to code is a proposal - say so, or leave it out. - **Accidental behaviour is an implementation note, not a rule.** When the - reference does something odd (draft ids that repeat after a leave, the URL - reflected into a 500 body), write it under *Implementation note* - another - implementation must not have to copy it. + reference does something odd (draft ids that repeat after a leave), write + it under *Implementation note* - another implementation must not have to + copy it. When the maintainer turns such a note into a rule the reference + breaks (the URL reflected into a 500 body, revision 0.3), the rule gets its + check and the reference's failure is pinned (below). - **A rule a backend can break gets a check.** New MUST/SHOULD for backends -> a check in `conformance/backend/lib/checks/`, its id on the spec's *Checked by* line, `node scripts/gen-check-list.mjs`. `npm test` fails when @@ -48,7 +50,12 @@ down, and the spec records what the implementations do. `traffic/`; record again after changing a check or an app. - **Generated sections are generated.** `conformance/backend/README.md` (check list), `profiles/portable.md` (between the `portable:*` markers, - from `profiles/portable-v1.json`) - run `npm run generate`. + from `profiles/portable-v1.json`), `renderers/adaptive-cards/README.md` + (the mapping table, from `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 + again) as well as when another check fails. - **`profiles/semantic.md` is the normative snapshot v1** (moved from abap2UI5/mcp-server `docs/agent-snapshot.md`). Changes to the snapshot shape are made here first; implementations follow. @@ -65,7 +72,8 @@ 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`, `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` (in process, the renderer 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) | | `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` | @@ -80,7 +88,8 @@ 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 (always) and the UI5 SPA +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; `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 @@ -89,8 +98,8 @@ has browsers already - point `CHROMIUM_BIN` at one. ## Style -ES modules, Node 22+, no runtime dependencies in `conformance/backend/` -and `conformance/frontend/` (the browser adapters import `playwright-core` -lazily). English, ASCII in source files - the vendored copies under +ES modules, Node 22+, no runtime dependencies in `conformance/backend/`, +`conformance/frontend/` and `renderers/` (the browser adapters import +`playwright-core` lazily). English, ASCII in source files - the vendored copies under `conformance/frontend/adapters/vendor/` are byte-equal to upstream and exempt. Markdown wrapped at ~78 columns. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0706f27..6aadbfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,50 @@ ## Unreleased +- **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): + - NEST/NEST2 stay tolerated-not-rendered by portable renderers; portable + *apps* must not use them - the abap2UI5 linter's portable rule + (`profiles/portable.md` section 2). + - A backend MUST NOT reflect request data it did not validate into the + error body (`spec/errors.md`); new backend check **`error.no-reflection`** + (MUST). Both reference backends (abap2UI5 1.146.0 via + `@abap2ui5/node-runtime`, cap2UI5 on it) fail it - the URL is reflected + verbatim by [H] `request_context_info`; the fix is made in abap2UI5 + core. Pinned as an expected failure in `test/lib/expected.mjs`; the + suite traffic is recorded again (`counts.fail` 1 on both). + - Message box details shown expanded (the UI5 frontend is being fixed), + one roundtrip at a time with the client queueing (`spec/transport.md`), + `sap-contextid` kept by every HTTP frontend - as they were. + - **`portable-v1.json` separates the client API from the wire**: a new + `actions` object with `actions.api` (the `follow_up_action( )` names a + portable app may call) and `actions.wire` (what a renderer receives: + `VIEW_SLOTS`, `ROUTER` with its `routerOptions`, the names folded into + `ROUTER` - `foldedIntoRouter` - and the `T_CUSTOM` / `.eF` names). + Additive: `frontendActions` stays as it was (`allowed` = `actions.api`), + the profile stays version 1; consumers that copy the file (the Web + Components frontend) keep working and re-copy it to read the new key. + Schema, `scripts/render-portable.mjs` (a generated table in + `profiles/portable.md` section 6) and `scripts/gen-portable-profile.mjs` + follow. +- **Adaptive Cards renderer prototype** (`renderers/adaptive-cards/`, + export `@abap2ui5/protocol/renderers/adaptive-cards`): an abap2UI5 + response (portable profile) -> an Adaptive Card 1.5, and an + `Action.Submit` payload -> the next protocol request (event, arguments, + the model delta of the changed inputs, whose ids are binding paths). All + 65 controls of portable profile v1 mapped (the README's mapping table is + generated from `mapping.mjs`), unknown controls as placeholders listed in + `unsupported`, a minimal card host speaking the protocol over HTTP, a demo + for the designer, golden cards of recorded traffic. Pure Node, built on + the vendored mcp-server `viewxml` / `snapshot` modules. +- **Frontend adapter `adaptive-cards`** (in process): the renderer passes + every one of the 65 checks of the portable profile it can be driven + through (16 skipped: URL, DOM, focus, title, model edits; UI5 and semantic + profiles). Pinned in `test/frontend.test.mjs`; CI uploads its report. +- The UI5 SPA pin accepts `portable.box-details` passing (its fix is under + way); CI checks that `npm run generate` leaves no diff. + - **Frontend conformance suite** (`abap2ui5-conformance frontend --adapter ui5|agent|webcomponent [--profile core|portable|ui5|semantic]`, library `runFrontendSuite`, also at `@abap2ui5/protocol/frontend`): a scripted diff --git a/README.md b/README.md index 3643108..138b1ed 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,12 @@ 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 + Web Components frontend, the Adaptive Cards 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) traffic/ real traffic of both reference backends and three frontends ``` @@ -45,22 +48,29 @@ source file and method it was derived from ([spec/README.md](spec/README.md#sour ## Status -- **Protocol 2**, specification revision 0.2, derived from abap2UI5 1.146.0 - (commit `b812079`). -- **Backend suite: 75 checks** (61 MUST, 14 SHOULD; 68 core, 7 UI5 profile). - Green against both reference backends - `node-runtime` 75/75, - `cap2ui5` 74/75 with one SHOULD warning ([conformance/RESULTS.md](conformance/RESULTS.md)). -- **Schemas** validate all 334 recorded requests, 320 responses and 18 agent +- **Protocol 2**, specification revision 0.3, derived from abap2UI5 1.146.0 + (commit `b812079`); the maintainer decided the ten open questions + ([spec/open-questions.md](spec/open-questions.md)). +- **Backend suite: 76 checks** (62 MUST, 14 SHOULD; 69 core, 7 UI5 profile). + Both reference backends pass every check but `error.no-reflection`, new in + revision 0.3 (the request URL reflected into the 500 body; fixed in + abap2UI5 core, pinned until the runtime release carries it) - + `node-runtime` 75/76, `cap2ui5` 74/76 with one SHOULD warning + ([conformance/RESULTS.md](conformance/RESULTS.md)). +- **Schemas** validate all 336 recorded requests, 320 responses and 18 agent snapshots, checked by the shipped validator and by ajv. - **Portable profile v1** filled from a census of 247 core apps (73.7 % by controls, 64.8 % run unchanged). - **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; the agent client fails 5 MUSTs (no PROTOCOL check, no - `sap-contextid`, overlapping acts, popups kept across an app change, error - markup stripped) ([conformance/RESULTS.md](conformance/RESULTS.md#frontend-suite)). -- Decisions still open for the maintainer: [spec/open-questions.md](spec/open-questions.md). + >= 1.120 (fix under way); the agent client fails 5 MUSTs (no PROTOCOL + check, no `sap-contextid`, overlapping acts, popups kept across an app + change, error markup stripped); the Adaptive Cards renderer passes all 65 + checks that apply to it ([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. - Not yet run against an ABAP system. ## Run the backend suite @@ -87,6 +97,7 @@ Options, the library API and the check list: ```bash 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 ``` Adapters, options and the check list: @@ -96,11 +107,14 @@ Adapters, options and the check list: ```bash npm test # schemas, traffic, docs, portable profile, CLI, both backends, - # the frontend suite (agent client; UI5 SPA with a checkout + Chromium) + # the frontend suite (agent client, Adaptive Cards renderer; + # UI5 SPA with a checkout + Chromium), the golden cards # (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 list and the portable-profile sections +npm run generate # regenerate the check lists, the portable-profile sections and + # the Adaptive Cards mapping table +npm run demo:adaptive-cards -- slots.popup-destroy 0 1 # a recorded response as card JSON ``` Node 22 or later. The package has no runtime dependencies; the dev diff --git a/conformance/README.md b/conformance/README.md index f6866b8..7b16cb6 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -5,8 +5,8 @@ swapped independently: | Suite | Plays | Tests | Status | |---|---|---|---| -| [backend/](backend/README.md) | the frontend | a backend, over HTTP, against the conformance apps | implemented - 75 checks, `abap2ui5-conformance backend` | -| [frontend/](frontend/README.md) | the backend | a frontend, by scripted responses (recorded traffic + synthetic edge cases), through an adapter per frontend | implemented - 81 checks, `abap2ui5-conformance frontend --adapter ui5\|agent\|webcomponent` | +| [backend/](backend/README.md) | the frontend | a backend, over HTTP, against the conformance apps | implemented - 76 checks, `abap2ui5-conformance backend` | +| [frontend/](frontend/README.md) | the backend | a frontend, by scripted responses (recorded traffic + synthetic edge cases), through an adapter per frontend | implemented - 81 checks, `abap2ui5-conformance frontend --adapter ui5\|agent\|webcomponent\|adaptive-cards` | The **conformance apps** ([apps/](apps/README.md)) are what a backend serves for the backend suite: small apps with exactly specified behaviour, shipped diff --git a/conformance/RESULTS.md b/conformance/RESULTS.md index b7cc5a3..bc872b1 100644 --- a/conformance/RESULTS.md +++ b/conformance/RESULTS.md @@ -1,13 +1,15 @@ # Results -Backend suite, profile `ui5` (75 checks: 68 core, 7 UI5-profile; 61 MUST, +Backend suite, profile `ui5` (76 checks: 69 core, 7 UI5-profile; 62 MUST, 14 SHOULD), run 2026-10-03 with `npm run conformance:`. The traffic -of these runs is recorded in [`../traffic/`](../traffic/). +of these runs is recorded in [`../traffic/`](../traffic/). Revision 0.3 added +`error.no-reflection` (MUST, [open question 3](../spec/open-questions.md#3-the-request-url-reflected-into-the-error-body)); +both reference backends fail it - see [below](#the-request-url-reflected-into-the-error-body). | Backend | Version | Apps | Pass | Fail | Warn | Skip | Verdict | |---|---|---|---:|---:|---:|---:|---| -| `node-runtime` | `@abap2ui5/node-runtime` 1.146.0 (abap2UI5 1.146.0, transpiled) | the ABAP apps, transpiled with `@abaplint/transpiler-cli` 2.13.93 | 75 | 0 | 0 | 0 | conformant, `core` and `ui5` | -| `cap2ui5` | `@cap2ui5/cds-plugin` 0.4.0 on `@abap2ui5/node-runtime` 1.146.0, `@sap/cds` 10 | the JavaScript apps | 74 | 0 | 1 | 0 | conformant, `core` and `ui5` | +| `node-runtime` | `@abap2ui5/node-runtime` 1.146.0 (abap2UI5 1.146.0, transpiled) | the ABAP apps, transpiled with `@abaplint/transpiler-cli` 2.13.93 | 75 | 1 | 0 | 0 | one MUST failed: `error.no-reflection` (fix in abap2UI5 core, pinned) | +| `cap2ui5` | `@cap2ui5/cds-plugin` 0.4.0 on `@abap2ui5/node-runtime` 1.146.0, `@sap/cds` 10 | the JavaScript apps | 74 | 1 | 1 | 0 | one MUST failed: `error.no-reflection` (inherited from the runtime, pinned) | Per area (both backends identical except `errors`): @@ -22,13 +24,37 @@ Per area (both backends identical except `errors`): | action | 3 | 3 pass | 3 pass | | nav / route | 14 | 14 pass | 14 pass | | session | 5 | 5 pass | 5 pass | -| error | 4 | 4 pass | 3 pass, 1 warn (`error.details`) | +| error | 5 | 4 pass, 1 fail (`error.no-reflection`) | 3 pass, 1 fail (`error.no-reflection`), 1 warn (`error.details`) | | ui5 | 7 | 7 pass | 7 pass | The ABAP system itself (abap2UI5 on NetWeaver / ABAP Cloud) has not been run yet - it needs a system with the apps pulled in by abapGit; the transpiled runtime is the same framework source. +## The request URL reflected into the error body + +`error.no-reflection` (MUST since revision 0.3) starts an unknown app with +` `X`, `null`/`false` -> empty, @@ -100,12 +127,18 @@ 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` @ `ea4e9fa` (vendored) | semantic | 53 | 5 | 3 | 20 | not conformant: 5 MUSTs | +| 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 | | Web Components (`webcomponent`) | abap2UI5/frontend-webcomponent 0.1.0, `dist/` built from `6997c40` (in development) | portable | 63 | 4 | 1 | 13 | work in progress: router not implemented, error markup stripped | | 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 both the UI5 and the -agent result check by check. +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 +`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 +details on UI5 1.120 and later", on its working branch, not on main yet) +passed it - every MUST held, 77 pass, 4 skip. ### Findings - the UI5 SPA @@ -182,6 +215,38 @@ pending edits of the model after its response, not only the ones it sent Skipped: the URL, DOM and focus checks (no browser), a nested-table cell (the snapshot does not describe nested tables), a programmatic model edit. +### Findings - the Adaptive Cards renderer + +A renderer that is not a browser, run in process +([../renderers/adaptive-cards/](../renderers/adaptive-cards/README.md)): +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, +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 +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 +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 +programmatic model edits; the UI5 and semantic profiles are not claimed. +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). + +Found on the way, *the spec held*: nothing in the portable profile needed +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 Web Components frontend (in development) Run against the build of its development branch at the time; recorded for diff --git a/conformance/backend/README.md b/conformance/backend/README.md index abd6a51..e7cbc2f 100644 --- a/conformance/backend/README.md +++ b/conformance/backend/README.md @@ -141,6 +141,7 @@ fails when it is out of date). | `error.details` | SHOULD | core | The error body names the app or the failure, so a developer can find the cause | [errors.md#the-error-response](../../spec/errors.md#the-error-response) | | `error.unknown-app` | MUST | core | An app start naming a class that is no app is refused with an error status | [errors.md#the-error-response](../../spec/errors.md#the-error-response) | | `error.not-sniffable` | SHOULD | core | An error body is served as text/plain with X-Content-Type-Options: nosniff | [errors.md#the-error-response](../../spec/errors.md#the-error-response) | +| `error.no-reflection` | MUST | core | Request data the backend did not validate is not reflected into the error body | [errors.md#the-error-response](../../spec/errors.md#the-error-response) | | `ui5.page` | MUST | ui5 | GET of the endpoint answers the HTML page that boots the UI5 frontend | [ui5.md#the-page](../../profiles/ui5.md#the-page) | | `ui5.page-revalidation` | SHOULD | ui5 | The page carries an ETag and answers a matching If-None-Match with 304 | [ui5.md#the-page](../../profiles/ui5.md#the-page) | | `ui5.view-roots` | MUST | ui5 | MAIN and nested views are sap.ui.core.mvc.View XML, popups and popovers core:FragmentDefinition | [ui5.md#views](../../profiles/ui5.md#views) | diff --git a/conformance/backend/bin/abap2ui5-conformance.mjs b/conformance/backend/bin/abap2ui5-conformance.mjs index 165416d..3f6204f 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|headless + * abap2ui5-conformance frontend --adapter ui5|agent|webcomponent|adaptive-cards|headless * [--profile core|portable|ui5|semantic] * [--only ]... [--json] [--verbose] * @@ -32,7 +32,9 @@ The backend has to serve the conformance apps - conformance/apps/README.md. 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), headless (stub) + client), webcomponent (frontend-webcomponent), + adaptive-cards (the Adaptive Cards 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/backend/lib/checks/errors.mjs b/conformance/backend/lib/checks/errors.mjs index c4cc545..9764f0f 100644 --- a/conformance/backend/lib/checks/errors.mjs +++ b/conformance/backend/lib/checks/errors.mjs @@ -49,12 +49,38 @@ export default [ profile: "core", spec: `${E}#the-error-response`, async run(t) { - // the reference reflects the request URL into the body verbatim - safe - // only because nothing renders the body as markup + // a body that names the request is still never to be read as markup - + // the headers say so even where a backend reflects more than it should const r = await t.client.start("Z2UI5_x"); t.ok(r.status >= 400, `expected an error status, got ${r.status}`); t.ok(/^text\/plain\b/i.test(r.headers["content-type"] || ""), `Content-Type should be text/plain, is "${r.headers["content-type"]}"`); t.equal((r.headers["x-content-type-options"] || "").toLowerCase(), "nosniff", "X-Content-Type-Options"); }, }, + { + id: "error.no-reflection", + title: "Request data the backend did not validate is not reflected into the error body", + level: "MUST", + profile: "core", + spec: `${E}#the-error-response`, + async run(t) { + // A crafted request (not a browser: location.search would arrive + // percent-encoded) whose URL carries markup and quotes next to an app + // start that fails. Whatever the body says about the URL must have been + // reduced to characters that cannot be read as markup (spec/errors.md; + // open question 3, decided in revision 0.3). + const r = await t.client.start("Z2UI5_CL_CONF_DOES_NOT_EXIST", { + search: `?app_start=Z2UI5_CL_CONF_DOES_NOT_EXIST&conformance=${REFLECTION_PROBE}`, + }); + t.ok(r.status >= 400, `expected an error status, got ${r.status}`); + const hit = REFLECTED.filter((part) => r.text.includes(part)); + const line = r.text.split("\n").find((l) => hit.some((part) => l.includes(part))) || ""; + t.ok(!hit.length, `the error body reflects ${hit.map((h) => JSON.stringify(h)).join(", ")} from the request URL verbatim: ${line.slice(0, 200)}`); + }, + }, ]; + +/** What error.no-reflection puts into the request URL, and the parts of it + * an error body must not contain. */ +export const REFLECTION_PROBE = ``; +const REFLECTED = ["", "` | the frontend: `ui5`, `agent`, `webcomponent`, `headless` (stub) | +| `--adapter ` | the frontend: `ui5`, `agent`, `webcomponent`, `adaptive-cards`, `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 | @@ -65,6 +67,7 @@ frontend cannot perform throws `Unsupported`. | `ui5` | the official UI5 SPA, abap2UI5 `app/webapp` | core, portable, ui5 | Chromium via Playwright: the page is the UI5 profile's boot page ([adapters/ui5-page.mjs](adapters/ui5-page.mjs)) at the run's endpoint, UI5 comes from the `@openui5/*` npm packages (no CDN), the frontend from the checkout's webapp folder. Interactions type into real `Input`s and click real `Button`s | | `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 | | `headless` | abap2UI5/headless-frontend (ABAP) | - | **not drivable yet** - see below | What the `ui5` adapter needs: an abap2UI5 checkout (`ABAP2UI5_HOME`, else diff --git a/conformance/frontend/adapters/adaptive-cards.mjs b/conformance/frontend/adapters/adaptive-cards.mjs new file mode 100644 index 0000000..5d80948 --- /dev/null +++ b/conformance/frontend/adapters/adaptive-cards.mjs @@ -0,0 +1,172 @@ +/* + * Adapter: the Adaptive Cards renderer prototype of this repository + * (renderers/adaptive-cards/) - an in-process frontend of the portable + * profile. Its host speaks the protocol over HTTP to the scripted backend; + * every response is rendered into an Adaptive Card 1.5, and the suite looks + * at that card: + * + * state() the card: a Container per slot (`slot-MAIN`, ...), its text, + * the values of its inputs (ids are binding paths) + * fill(t, v) the user types into the card input whose id is t.path - + * held as a typed value, as a card host holds it, until a + * submit carries it + * press(t) the user presses the Action.Submit raising t.event (or + * titled t.text): the host gets the action's data merged + * with the values of every input of the card - exactly what + * a card host submits - and builds the next request from it + * closeBox(a) the message box button `a` + * + * What a card cannot do is not claimed: no URL, no DOM, no focus, no + * document title, no programmatic model edit. It runs START_TIMER (the host + * does) and queues a submit while a roundtrip is in flight. + */ +import { Unsupported, emptyState } from "./base.mjs"; +import { createCardHost } from "../../../renderers/adaptive-cards/host.mjs"; +import { walk, cardText, liveSlots, modelKeyOf, LEAVE_EVENT } from "../../../renderers/adaptive-cards/render.mjs"; +import { coerce } from "../../../renderers/adaptive-cards/submit.mjs"; +import { getAt } from "./vendor/mcp-server/snapshot.mjs"; + +const INPUT = /^Input\./; + +export function createAdaptiveCardsAdapter() { + let mock = null; + let host = null; + let typed = new Map(); + + const render = () => host.render({ typed }); + + /** The interactive layers of the card: the box, else the live slots. */ + function liveItems(card) { + return liveSlots(host.state, host.messages) + .map((s) => card.body.find((e) => e.id === (s === "BOX" ? "message-box" : `slot-${s}`))) + .filter(Boolean); + } + + function inputs(items) { + const out = []; + walk(items, (e) => { + if (INPUT.test(e.type) && e.id) out.push(e); + }); + return out; + } + + function actions(items) { + const out = []; + walk(items, (e) => { + if (e.type === "Action.Submit" && e.data) out.push(e); + }); + return out; + } + + /** What a card host submits for an action: its data and every input value. */ + function payloadFor(action) { + const { card } = render(); + const values = {}; + for (const i of inputs(liveItems(card))) values[i.id] = i.value === undefined || i.value === null ? "" : String(i.value); + typed = new Map(); + return { ...action.data, ...values }; + } + + function findAction(target) { + const all = actions(liveItems(render().card)).filter((a) => a.isEnabled !== false); + const event = target.nav ? LEAVE_EVENT : target.event; + return (event && all.find((a) => a.data.event === event)) || (target.text && all.find((a) => a.title === target.text)) || null; + } + + const adapter = { + name: "adaptive-cards", + description: "the Adaptive Cards renderer prototype (renderers/adaptive-cards/), in process", + profiles: ["core", "portable"], + capabilities: new Set(["concurrent", "boxClose", "timers"]), + + async open({ mock: m }) { + mock = m; + adapter.version = "renderers/adaptive-cards (this repository), Adaptive Cards 1.5"; + }, + + async start(run, { app, search } = {}) { + if (!app && search === undefined) throw new Unsupported("the card host starts apps by class name"); + typed = new Map(); + host = createCardHost({ url: run.url, location: (cls) => ({ origin: mock.origin, pathname: run.path, search: `?app_start=${encodeURIComponent(cls)}` }) }); + await host.start(app, search !== undefined ? { search } : {}); + }, + + async fill(target, value) { + if (!host) throw new Unsupported("no card"); + if (!target.path) throw new Unsupported("card inputs are addressed by binding path"); + const input = inputs(liveItems(render().card)).find((i) => i.id === target.path); + if (!input) throw new Error(`no input "${target.path}" on the card (inputs: ${inputs(liveItems(render().card)).map((i) => i.id).join(", ") || "none"})`); + typed.set(input.id, typeof value === "boolean" ? String(value) : value); + }, + + async press(target, { wait = true } = {}) { + if (!host) throw new Unsupported("no card"); + const action = findAction(target); + if (!action) throw new Error(`no action ${target.event || target.text} on the card`); + // built when it leaves the host's queue - the card it reads is the one + // on screen then + const p = host.submit(() => payloadFor(action)); + if (wait) await p; + else p.catch(() => {}); + }, + + async closeBox({ action, text }) { + const box = render().card.body.find((e) => e.id === "message-box"); + const a = box && actions([box]).find((x) => x.data.box === action || x.title === text); + if (!a) throw new Unsupported("no message box with that action on the card"); + await host.submit(() => payloadFor(a)); + }, + + async back() { + throw new Unsupported("a card has no browser history"); + }, + + async setModel() { + throw new Unsupported("a card host has no programmatic model access"); + }, + + async settle() { + if (host) await host.settle(); + }, + + async state() { + const s = emptyState(); + if (!host) return s; + const { card, unsupported } = render(); + const st = host.state; + s.started = Boolean(st.id) || Boolean(host.error); + s.app = st.app || null; + s.id = st.id || null; + s.card = card; + for (const slot of Object.keys(s.slots)) { + const c = card.body.find((e) => e.id === `slot-${slot}`); + if (c) s.slots[slot] = { open: true, text: cardText(c) }; + } + for (const slot of liveSlots(st, host.messages)) { + const c = card.body.find((e) => e.id === `slot-${slot}`); + const m = st.models[modelKeyOf(slot)]; + for (const i of inputs(c ? [c] : [])) { + if (!i.id.startsWith("/") || Object.prototype.hasOwnProperty.call(s.values, i.id)) continue; + s.values[i.id] = coerce(i.value === undefined ? "" : i.value, getAt((m && m.data) || {}, i.id)); + } + } + for (const [k, m] of Object.entries(st.models)) s.models[k] = m.data; + s.messages = host.messages.map((m) => ({ kind: m.kind, text: m.text, ...(m.type ? { type: m.type } : {}) })); + s.error = host.error; + s.log = [...host.log, ...unsupported.map((u) => `unsupported: ${u.control}${u.id ? ` #${u.id}` : ""} (${u.slot}) - ${u.reason}`)]; + s.text = cardText(card.body); + return s; + }, + + async stop() { + if (host) { + host.stop(); + await host.settle().catch(() => {}); + } + host = null; + }, + + async close() {}, + }; + return adapter; +} diff --git a/conformance/frontend/adapters/index.mjs b/conformance/frontend/adapters/index.mjs index d6bf860..5979a3e 100644 --- a/conformance/frontend/adapters/index.mjs +++ b/conformance/frontend/adapters/index.mjs @@ -5,6 +5,7 @@ export const ADAPTERS = Object.freeze({ ui5: { module: "./ui5.mjs", factory: "createUi5Adapter", description: "the UI5 SPA (abap2UI5 app/webapp) in Chromium via Playwright" }, 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" }, headless: { module: "./headless.mjs", factory: "createHeadlessAdapter", description: "the headless ABAP simulator (abap2UI5/headless-frontend) - not drivable yet" }, }); diff --git a/package.json b/package.json index b1a96c3..26076c5 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "./frontend": "./conformance/frontend/index.mjs", "./schema/*": "./schema/*", "./profiles/portable-v1.json": "./profiles/portable-v1.json", + "./renderers/adaptive-cards": "./renderers/adaptive-cards/index.mjs", "./package.json": "./package.json" }, "bin": { @@ -19,6 +20,7 @@ "schema/", "spec/", "profiles/", + "renderers/", "traffic/node-runtime/", "README.md", "CHANGELOG.md", @@ -31,12 +33,14 @@ "conformance:frontend:ui5": "node conformance/backend/bin/abap2ui5-conformance.mjs frontend --adapter ui5", "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", + "demo:adaptive-cards": "node renderers/adaptive-cards/demo.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", + "generate": "node scripts/gen-check-list.mjs && node scripts/render-portable.mjs && node scripts/render-adaptive-cards.mjs", "vendor:agent": "node scripts/vendor-agent-client.mjs" }, "engines": { diff --git a/profiles/portable-v1.json b/profiles/portable-v1.json index 1838502..995a272 100644 --- a/profiles/portable-v1.json +++ b/profiles/portable-v1.json @@ -4633,7 +4633,7 @@ { "wire": ".eF('ACTION', arg...)", "from": "client->_event_client( ) / a wired follow_up_action( )", - "note": "only the frontend actions of frontendActions.allowed" + "note": "only the frontend actions of actions.wire.custom and actions.wire.customGlobals" }, { "wire": ".eBP($event, true, ['EVENT'], arg...)", @@ -4865,6 +4865,112 @@ "CONTROL_GLOBAL FORMATTING" ] }, + "actions": { + "note": "api: the follow_up_action( ) names (cs_event values) a portable app may call - frontendActions.allowed is the same list, kept for consumers of revision 0.2. wire: what a portable renderer actually receives - T_SYSTEM actions with their ROUTER options, and the T_CUSTOM / .eF names. The navigation family of api is folded by the backend into the one ROUTER system action (foldedIntoRouter) and never arrives under its own name.", + "api": [ + "SET_FOCUS", + "START_TIMER", + "DOWNLOAD_B64_FILE", + "CLIPBOARD_COPY", + "URLHELPER", + "OPEN_NEW_TAB", + "SCROLL_TO", + "SCROLL_INTO_VIEW", + "SET_TITLE", + "SET_FAVICON", + "LOCATION_RELOAD", + "SYSTEM_LOGOUT", + "STORE_DATA", + "KEYBOARD_SHORTCUT", + "PLAY_AUDIO", + "SET_PUSH_STATE", + "HASH_REPLACE", + "HASH_BACK", + "HASH_ATTACH_CHANGED", + "SET_NAV_ROUTING", + "SET_APP_STATE_ACTIVE", + "SET_SIZE_LIMIT" + ], + "wire": { + "system": { + "VIEW_SLOTS": [ + "display", + "destroy" + ], + "ROUTER": [ + "sync" + ] + }, + "routerOptions": [ + "setNavRouting", + "checkNavAppCall", + "navAppCallPrevApp", + "navAppCallPrevId", + "setPushState", + "setHashReplace", + "setHashEvent", + "setAppStateActive" + ], + "foldedIntoRouter": { + "SET_NAV_ROUTING": "setNavRouting", + "SET_PUSH_STATE": "setPushState", + "HASH_REPLACE": "setHashReplace", + "HASH_ATTACH_CHANGED": "setHashEvent", + "SET_APP_STATE_ACTIVE": "setAppStateActive" + }, + "custom": [ + "SET_FOCUS", + "START_TIMER", + "DOWNLOAD_B64_FILE", + "CLIPBOARD_COPY", + "URLHELPER", + "OPEN_NEW_TAB", + "SCROLL_TO", + "SCROLL_INTO_VIEW", + "SET_TITLE", + "SET_FAVICON", + "LOCATION_RELOAD", + "SYSTEM_LOGOUT", + "STORE_DATA", + "KEYBOARD_SHORTCUT", + "PLAY_AUDIO", + "HASH_BACK", + "SET_SIZE_LIMIT" + ], + "customGlobals": { + "MESSAGE_TOAST": [ + "show" + ], + "MESSAGE_BOX": [ + "show", + "alert", + "confirm", + "information", + "warning", + "error", + "success" + ], + "BUSY_INDICATOR": [ + "show", + "hide" + ], + "INVISIBLE_MESSAGE": [ + "announce" + ], + "THEMING": [ + "setTheme" + ], + "VIEW_SLOTS": [ + "destroy" + ] + }, + "noOpAllowed": [ + "ROUTER", + "HASH_BACK", + "SET_SIZE_LIMIT" + ] + } + }, "clientApi": { "allowed": [ "view_display", diff --git a/profiles/portable.md b/profiles/portable.md index 89d937b..5982aca 100644 --- a/profiles/portable.md +++ b/profiles/portable.md @@ -69,11 +69,17 @@ unchanged ([section 10](#10-method-and-caveats), [portable-coverage.md](portable - **Slots.** A portable app uses the slots MAIN (`view_display`), POPUP (`popup_display`, `popup_destroy`) and POPOVER (`popover_display( xml by_id )`, `popover_destroy`). The nested slots NEST and NEST2 are not in v1 - (6 core apps use them; [section 9](#9-v11-candidates-informative)): a - portable frontend that receives a NEST/NEST2 display MAY show a - placeholder for it, but MUST process the action (it is core protocol, - [../spec/response.md](../spec/response.md#view-slots)) without failing the - roundtrip. + (6 core apps use them; [section 9](#9-v11-candidates-informative)), and + the two sides of that are separate rules (decided in revision 0.3, + [open question 1](../spec/open-questions.md#1-the-nest-slots-in-portable-renderers)): + - A portable **app** MUST NOT use them (`nest_view_display`, + `nest2_view_display` and their destroys are outside the client API of + [section 6](#6-frontend-actions)). The abap2UI5 linter's portable rule + reports the calls; an app that needs nested views is a UI5-profile app. + - A portable **renderer** tolerates them: one that receives a NEST/NEST2 + display MAY show a placeholder for it, but MUST process the action (it + is core protocol, [../spec/response.md](../spec/response.md#view-slots)) + without failing the roundtrip. *Frontend check:* `slots.nest-processed`. - **Namespaces.** `sap.m`, `sap.ui.core`, `sap.ui.core.mvc`, `sap.ui.layout`, `sap.ui.layout.form`, `sap.tnt`. - **Elements.** An element whose local name starts with an upper-case letter @@ -342,6 +348,58 @@ frontend MUST perform: | `SET_PUSH_STATE`, `HASH_REPLACE`, `HASH_BACK`, `HASH_ATTACH_CHANGED`, `SET_NAV_ROUTING`, `SET_APP_STATE_ACTIVE` | 1-3 each | URL hash handling ([../spec/navigation.md](../spec/navigation.md)); MAY be a no-op without a URL (native) | | `SET_SIZE_LIMIT` | 1 | a UI5 list limit - MAY be a no-op | +**Names on the client API and on the wire are not the same list** +(decided in revision 0.3, +[open question 10](../spec/open-questions.md#10-navigation-actions-in-the-portable-action-list)). +The table above names what a portable app may *call*. The navigation family +among them is folded by the backend into the one `ROUTER` system action +([../spec/actions.md](../spec/actions.md#vocabulary), +[../spec/navigation.md](../spec/navigation.md#the-router-action), [CL] +`follow_up_action`, [FE] `check_on_event`): a renderer never receives +`SET_PUSH_STATE`, `HASH_REPLACE`, `HASH_ATTACH_CHANGED`, `SET_NAV_ROUTING` +or `SET_APP_STATE_ACTIVE` - it receives `ROUTER` with options, and +`HASH_BACK` as a follow-up action. `portable-v1.json` says both: +`actions.api` (what an app may call; `frontendActions.allowed` is the same +list, kept for readers of revision 0.2) and `actions.wire` (what a renderer +implements): + + + +| Client API (`actions.api`) | What the renderer receives (`actions.wire`) | +|---|---| +| `SET_FOCUS` | `SET_FOCUS` (`T_CUSTOM`, `.eF`) | +| `START_TIMER` | `START_TIMER` (`T_CUSTOM`, `.eF`) | +| `DOWNLOAD_B64_FILE` | `DOWNLOAD_B64_FILE` (`T_CUSTOM`, `.eF`) | +| `CLIPBOARD_COPY` | `CLIPBOARD_COPY` (`T_CUSTOM`, `.eF`) | +| `URLHELPER` | `URLHELPER` (`T_CUSTOM`, `.eF`) | +| `OPEN_NEW_TAB` | `OPEN_NEW_TAB` (`T_CUSTOM`, `.eF`) | +| `SCROLL_TO` | `SCROLL_TO` (`T_CUSTOM`, `.eF`) | +| `SCROLL_INTO_VIEW` | `SCROLL_INTO_VIEW` (`T_CUSTOM`, `.eF`) | +| `SET_TITLE` | `SET_TITLE` (`T_CUSTOM`, `.eF`) | +| `SET_FAVICON` | `SET_FAVICON` (`T_CUSTOM`, `.eF`) | +| `LOCATION_RELOAD` | `LOCATION_RELOAD` (`T_CUSTOM`, `.eF`) | +| `SYSTEM_LOGOUT` | `SYSTEM_LOGOUT` (`T_CUSTOM`, `.eF`) | +| `STORE_DATA` | `STORE_DATA` (`T_CUSTOM`, `.eF`) | +| `KEYBOARD_SHORTCUT` | `KEYBOARD_SHORTCUT` (`T_CUSTOM`, `.eF`) | +| `PLAY_AUDIO` | `PLAY_AUDIO` (`T_CUSTOM`, `.eF`) | +| `SET_PUSH_STATE` | the `ROUTER` option `setPushState` - never `SET_PUSH_STATE` itself | +| `HASH_REPLACE` | the `ROUTER` option `setHashReplace` - never `HASH_REPLACE` itself | +| `HASH_BACK` | `HASH_BACK` (`T_CUSTOM`, `.eF`) | +| `HASH_ATTACH_CHANGED` | the `ROUTER` option `setHashEvent` - never `HASH_ATTACH_CHANGED` itself | +| `SET_NAV_ROUTING` | the `ROUTER` option `setNavRouting` - never `SET_NAV_ROUTING` itself | +| `SET_APP_STATE_ACTIVE` | the `ROUTER` option `setAppStateActive` - never `SET_APP_STATE_ACTIVE` itself | +| `SET_SIZE_LIMIT` | `SET_SIZE_LIMIT` (`T_CUSTOM`, `.eF`) | +| (`MESSAGE_TOAST`) | `MESSAGE_TOAST` show (`T_CUSTOM` or `.eF`, directly or as a `CONTROL_GLOBAL` target) | +| (`MESSAGE_BOX`) | `MESSAGE_BOX` show / alert / confirm / information / warning / error / success (`T_CUSTOM` or `.eF`, directly or as a `CONTROL_GLOBAL` target) | +| (`BUSY_INDICATOR`) | `BUSY_INDICATOR` show / hide (`T_CUSTOM` or `.eF`, directly or as a `CONTROL_GLOBAL` target) | +| (`INVISIBLE_MESSAGE`) | `INVISIBLE_MESSAGE` announce (`T_CUSTOM` or `.eF`, directly or as a `CONTROL_GLOBAL` target) | +| (`THEMING`) | `THEMING` setTheme (`T_CUSTOM` or `.eF`, directly or as a `CONTROL_GLOBAL` target) | +| (`VIEW_SLOTS`) | `VIEW_SLOTS` destroy (`T_CUSTOM` or `.eF`, directly or as a `CONTROL_GLOBAL` target) | + +System actions (`T_SYSTEM`): `VIEW_SLOTS` display / destroy, `ROUTER` sync; the `ROUTER` options: `setNavRouting`, `checkNavAppCall`, `navAppCallPrevApp`, `navAppCallPrevId`, `setPushState`, `setHashReplace`, `setHashEvent`, `setAppStateActive`. MAY be a no-op where the platform has no counterpart: `ROUTER`, `HASH_BACK`, `SET_SIZE_LIMIT`. + + + **Excluded:** `CONTROL_BY_ID` (open-ended UI5 method calls, 16 core apps; a closed whitelist is a v1.1 candidate), `BINDING_CALL` (v1.1 candidate), `BIND_ELEMENT`, `SET_ODATA_MODEL`, `SMART_VARIANT_INIT`, diff --git a/renderers/adaptive-cards/README.md b/renderers/adaptive-cards/README.md new file mode 100644 index 0000000..92c34b9 --- /dev/null +++ b/renderers/adaptive-cards/README.md @@ -0,0 +1,245 @@ +# Adaptive Cards renderer (prototype) + +A portable renderer for abap2UI5 that is not a browser: it turns an +abap2UI5 response - the view XML of the portable profile and its JSON +model ([../../profiles/portable.md](../../profiles/portable.md)) - into an +[Adaptive Card](https://adaptivecards.io/) (schema 1.5), and an +`Action.Submit` payload of that card back into the next protocol request. +A bot, a chat integration or an Outlook actionable message could host an +abap2UI5 app with it. + +Pure Node, no browser, no dependencies. The view XML is read by the +modules this repository already vendors from abap2UI5/mcp-server +([../../conformance/frontend/adapters/vendor/mcp-server/](../../conformance/frontend/adapters/vendor/mcp-server/source.json)): +`viewxml.mjs` (namespaces, bindings, event wires, expression bindings +without `eval`), `snapshot.mjs` (folding responses into slots and models, +`applyResponse`) and `appclient.mjs` (the model delta, `buildDelta`). + +| File | | +|---|---| +| [render.mjs](render.mjs) | state -> card: `renderCard`, `cardText`, `liveSlots` | +| [mapping.mjs](mapping.mjs) | the control table - one entry per portable control, its card element and render function; the table below is generated from it | +| [submit.mjs](submit.mjs) | `Action.Submit` payload -> request: `submitToRequest`, `eventRequest`, `startRequest` | +| [host.mjs](host.mjs) | `createCardHost`: a minimal card host that speaks the protocol over HTTP (the client rules of [spec/transport.md](../../spec/transport.md#client-behaviour)) | +| [index.mjs](index.mjs) | the exports, `renderResponses` | +| [demo.mjs](demo.mjs) | a recorded response as card JSON, for the [designer](https://adaptivecards.io/designer) | +| [golden/](golden/) | golden cards of recorded responses ([../../traffic/](../../traffic/)) and of a synthetic sampler, held by `test/adaptive-cards.test.mjs` | + +## Use it + +```js +import { renderResponses, submitToRequest, createCardHost } from "@abap2ui5/protocol/renderers/adaptive-cards"; + +// pure: responses in, card out +const { card, unsupported, state } = renderResponses([response]); + +// the way back: what the card host submitted -> the next request body +const r = submitToRequest(state, { event: "SAVE", "/NAME": "Ada" }); +r.request; // { S_FRONT: { ID, EVENT: "SAVE" }, MODEL: { NAME: "Ada" } } - post it as { value: r.request } + +// or let the host do the HTTP +const host = createCardHost({ url: "https://host/sap/bc/z2ui5" }); +await host.start("Z2UI5_CL_MY_APP"); +host.render().card; +await host.submit({ event: "SAVE", "/NAME": "Ada" }); +``` + +```bash +node renderers/adaptive-cards/demo.mjs # the BIND conformance app +node renderers/adaptive-cards/demo.mjs slots.popup-destroy 0 1 # two responses of a recorded check, folded +node renderers/adaptive-cards/demo.mjs --file response.json # a response of your own +``` + +## How a response becomes a card + +- **Slots.** Each open slot is a `Container` with the id `slot-MAIN`, + `slot-POPUP`, `slot-POPOVER` (popup and popover above the page, style + `emphasis`). NEST/NEST2 are processed and shown as a placeholder - they are + not in portable profile v1 ([../../profiles/portable.md](../../profiles/portable.md#2-documents-slots-and-namespaces)). +- **Layers.** A message box and a popup are modal: while one is open it is + the only interactive layer, and the layers below render read-only (texts, + no inputs, no actions). A popover is not modal: it and MAIN are live, and a + press in MAIN closes it first, as UI5 does. +- **Inputs carry their binding path as id** (`/NAME`, `/T_ITEMS/1/TEXT` for + a cell of a table row). A card submits every input value with the + action's data; `submitToRequest` compares each with the model the card was + rendered from, writes the differing ones into the model in the type it + holds there (a card submits strings) and sends them as the delta the UI5 + frontend builds - a scalar or a structure whole, table cells as `__delta` + rows ([spec/request.md](../../spec/request.md#the-model-delta)). +- **Actions.** A backend event wire (`.eB(['SAVE'], ${/NAME}, 'x')`) is an + `Action.Submit` with `data: { event: "SAVE", args: ["Ada", "x"], refs: + ["/NAME"] }` - arguments resolved at render time, `refs` naming those read + from the model so the reverse step reads them again after the edits of the + same submit; an action of a popup or popover names its `slot`. A + frontend-only wire (`.eF(...)`) is `data: { client: [...] }` and runs in + the host without a roundtrip; the leave wire of a page's nav button is the + reserved `___ZZZ_NAL`. A row event (`itemPress` with `${ID}`) is a + `selectAction` per row, its arguments resolved against the row. +- **Messages.** A toast is a subtle `TextBlock` (its `onClose` event is + raised by the host after the toast's duration); a message box a + `Container` (`message-box`) with its text, its details **expanded** as + plain text ([spec/actions.md](../../spec/actions.md#messages)) and one + `Action.Submit` per box action (`data: { box: "CANCEL" }`), which raises + the box's `onClose` event with the action as first argument. +- **Errors** are shown verbatim in a `RichTextBlock` `TextRun` - not + markdown, so nothing of an error body is interpreted + ([spec/errors.md](../../spec/errors.md#what-a-frontend-does-with-it)). +- **Tolerance.** An element outside the profile becomes a placeholder + `TextBlock` and an entry of `unsupported` (`{ slot, control, id?, reason + }`), as do event wires a card cannot raise (`change`, `liveChange`, + `selectionChange`: the edit travels with the next action instead) and + event arguments only a UI5 runtime can evaluate (`$event`, + `${$parameters>/...}`, sent as `null`). An unknown follow-up action is + skipped and logged by the host ([../../profiles/portable.md](../../profiles/portable.md#conformance)). +- **Follow-up actions** the host knows from `actions.wire` of + [portable-v1.json](../../profiles/portable-v1.json): toasts, boxes, + `START_TIMER` (it fires the event), `VIEW_SLOTS destroy`; the rest of the + portable list has no counterpart in a card and is a logged no-op (focus, + scroll, title, downloads, the `ROUTER` action - a card has no URL). + +## Conformance + +The frontend suite drives it as the in-process adapter `adaptive-cards` +(`npx abap2ui5-conformance frontend --adapter adaptive-cards`, profile +`portable`): a fresh host per check against the scripted backend; "fill" +types into the card input whose id is the path, "press" submits what a card +host submits for the action (its data merged with every input value). +Result in [../../conformance/RESULTS.md](../../conformance/RESULTS.md#frontend-suite): +every MUST and SHOULD that applies holds; the checks that need a URL, a +DOM, focus, a document title or a programmatic model edit are skipped. + +## Limits of the prototype + +- Events of inputs are not raised (cards have no change events in 1.5); + `submit`/`search` become an `inlineAction`. +- Growing tables and lists show every row; tabs (IconTabBar) are stacked; + a panel is always expanded; layout properties (flex alignment, grid spans, + widths) are dropped. +- `DatePicker` is an `Input.Date` only for ISO values (`yyyy-MM-dd`), other + formats stay text; `DateTimePicker` is text. +- Typed bindings are shown raw (no number or date formatting); formatters + and `parts` bindings render empty. +- No URL: routing (`ROUTER`, the hash) is ignored, as a frontend without a + URL may ([spec/navigation.md](../../spec/navigation.md#the-router-action)). + +## Mapping + +Generated from [mapping.mjs](mapping.mjs) (`node scripts/render-adaptive-cards.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 | Adaptive Card | Notes | +|---|---|---| +| sap.m.Page | Container (flattened): title as heading TextBlock, nav button as Action.Submit | `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 | +| sap.m.Shell | (none) - its app renders in place | - | +| sap.m.VBox | Container | flexbox alignment ignored | +| sap.ui.layout.form.SimpleForm | Container: each Label becomes the `label` of the input after it | the grid layout properties are ignored; a Label not followed by an input is a bold TextBlock | +| sap.m.HBox | ColumnSet (auto-width columns), or one ActionSet when every child is an action | - | +| sap.m.Panel | Container (style emphasis), headerText as bold TextBlock | `expandable`/`expanded` ignored - the content is always shown; `expand` not raised | +| sap.ui.layout.Grid | Container | spans ignored - children stack | +| sap.m.FlexBox | Container (direction Column) or ColumnSet (Row, the default) | alignment, gaps and wrap ignored | +| sap.m.ScrollContainer | Container | scrolling is the host's | +| sap.m.IconTabFilter | Container with the tab text as heading | - | +| sap.m.IconTabBar | Container: every tab stacked, each under its heading | `selectedKey` ignored - all tabs are shown; `select` not raised | +| sap.ui.layout.VerticalLayout | Container | - | +| sap.ui.core.Title | TextBlock (heading) | - | +| sap.ui.layout.HorizontalLayout | ColumnSet / ActionSet (as HBox) | - | + +### Toolbars & bars + +| Control | Adaptive Card | Notes | +|---|---|---| +| sap.m.OverflowToolbar | ActionSet (all buttons) or ColumnSet | no overflow menu - everything is shown | +| sap.m.ToolbarSpacer | (nothing) | - | +| sap.m.Toolbar | ActionSet (all buttons) or ColumnSet | - | +| sap.m.Bar | ActionSet or ColumnSet of contentLeft, contentMiddle, contentRight | - | +| sap.m.OverflowToolbarButton | Action.Submit | as Button | + +### Display + +| Control | Adaptive Card | Notes | +|---|---|---| +| sap.m.Text | TextBlock (wrap) | `maxLines` -> maxLines | +| sap.m.Label | TextBlock (bolder) - or the `label` of the input that follows | - | +| sap.m.Title | TextBlock (heading, bolder, medium) | - | +| sap.m.ObjectStatus | TextBlock "title: text", colored by `state` | `active` + `press` -> Action.Submit | +| sap.m.Link | Action.Submit (press wire) or Action.OpenUrl (href), else TextBlock | - | +| sap.m.ObjectIdentifier | TextBlock title (bolder) + TextBlock text (subtle) | `titleActive` + `titlePress` -> Action.Submit | +| sap.ui.core.HTML | TextBlock with the text of the HTML | tags, `