You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@causeway.apache.org by da...@apache.org on 2026/08/24 21:10:52 UTC
(causeway) 02/15: fixes web component action dispatch correctness
This is an automated email from the ASF dual-hosted git repository.
danhaywood pushed a commit to branch htmx
in repository https://gitbox.apache.org/repos/asf/causeway.git
commit bb612cda335ddc5419630590eb40c6f5c87e8e4d
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Mon Aug 24 10:49:51 2026 +0100
fixes web component action dispatch correctness
---
.../design.md | 8 +-
.../evidence/REPRODUCING.md | 42 ++++
.../evidence/final-gate-results.md | 25 +++
.../evidence/implementation-report.md | 37 ++++
.../evidence/operation-shape-matrix.md | 44 +++++
.../evidence/production-isolation.md | 15 ++
.../tasks.md | 102 +++++-----
.../htmx/ReferenceAppHtmxPlaywrightTest.java | 37 ++--
.../ReferenceAppHtmxApplication_IntegTest.java | 53 +++++
viewers/webcomponents/foundation/USAGE.adoc | 7 +-
.../foundation/src/action-dispatch.mjs | 217 ++++++++++++++++++++
.../foundation/src/interaction-operations.mjs | 13 +-
.../foundation/src/object-context-controller.mjs | 159 ++++++++-------
.../foundation/src/service-action-context.mjs | 144 ++++++++------
.../foundation/test/action-dispatch.test.mjs | 218 +++++++++++++++++++++
.../test/application-menu-client.test.mjs | 18 ++
.../test/fixtures/menu-graphql-fixture.mjs | 26 ++-
17 files changed, 956 insertions(+), 209 deletions(-)
diff --git a/openspec/changes/fix-webcomponent-action-dispatch-correctness/design.md b/openspec/changes/fix-webcomponent-action-dispatch-correctness/design.md
index 92e31029cd2..8570659da90 100644
--- a/openspec/changes/fix-webcomponent-action-dispatch-correctness/design.md
+++ b/openspec/changes/fix-webcomponent-action-dispatch-correctness/design.md
@@ -9,8 +9,9 @@ The object context treats any nested invocation field, including `invokeNonIdemp
Both contexts assume that nested invocation returns an envelope with `results`, and otherwise request a `target` field even when the effective return type does not advertise one.
Those assumptions can make operation rendering throw before execution, after which the standard controller can report only `Action invocation failed`.
-The pinned Reference Application preserves an executable parameterless service action that demonstrates the nested safe-dispatch defect.
-Its object actions also provide parameter, validation, mutation, outcome, cancellation, and stale-response fixtures for closing the operation-shape matrix.
+The pinned Reference Application preserves an executable parameterless service action whose wrapper has no nested invocation field and whose generated mutation returns a versionless view model directly.
+The current direct-result projection requests the absent `_meta.version` field and collapses the operation-rendering failure into `Action invocation failed`.
+Its object actions also provide nested safe envelopes, direct mutations, parameters, validation, outcomes, cancellation, and stale-response fixtures for closing the operation-shape matrix.
Public GraphQL remains authoritative, and this change must preserve Causeway elements, semantic events, typed value codecs, route policy, mutation serialization, strict CSP, and native or Vaadin editor independence.
## Goals / Non-Goals
@@ -114,4 +115,5 @@ Rollback consists of reverting the shared planner integration; no schema, persis
## Open Questions
No architectural question blocks implementation.
-Implementation evidence must identify the exact direct or enveloped result shape used by each Reference Application target before its known-gap assertion is converted.
+Implementation evidence confirms that `demo.ActionChoicesMenu.choices` uses the parameterless mutation `demo_ActionChoicesMenu__choices` and returns `rich__demo_ActionChoices` directly with versionless metadata.
+The remaining implementation evidence must record each selected object-action shape before its known-gap assertion is converted.
diff --git a/openspec/changes/fix-webcomponent-action-dispatch-correctness/evidence/REPRODUCING.md b/openspec/changes/fix-webcomponent-action-dispatch-correctness/evidence/REPRODUCING.md
new file mode 100644
index 00000000000..43992633203
--- /dev/null
+++ b/openspec/changes/fix-webcomponent-action-dispatch-correctness/evidence/REPRODUCING.md
@@ -0,0 +1,42 @@
+# Reproducing action dispatch qualification
+
+Use JDK 25 and the repository Maven wrapper or Maven installation configured for this checkout.
+The Reference Application modules intentionally skip installation by default, so local standalone launch preparation must explicitly override that policy.
+
+```shell
+mvn -f regressiontests/pom.xml -N -Dmaven.install.skip=false install
+mvn -f regressiontests/referenceapp/pom.xml -N -Dmaven.install.skip=false install
+mvn -f regressiontests/referenceapp/pom.xml -DskipTests -Dmaven.install.skip=false install
+```
+
+Run the foundation operation-shape matrix:
+
+```shell
+node --test viewers/webcomponents/foundation/test/action-dispatch.test.mjs
+node --test viewers/webcomponents/foundation/test/application-menu-client.test.mjs
+```
+
+Run the complete foundation suite:
+
+```shell
+cd viewers/webcomponents/foundation
+node --test
+```
+
+Run the focused Reference Application browser journey:
+
+```shell
+mvn -f viewers/webcomponents/pom.xml -pl foundation,htmx -am -DskipTests install
+mvn -f regressiontests/referenceapp/pom.xml \
+ -Preferenceapp-playwright \
+ -Dtest=ReferenceAppHtmxPlaywrightTest#menusChoicesAutocompleteCancellationAndRouteDisposal \
+ -Dsurefire.failIfNoSpecifiedTests=false \
+ test
+```
+
+The first service action must navigate to `demo.ActionChoices` without opening an error prompt.
+The parameterized `selectTvCharacter` mutation must accept one advertised choice, close its prompt, and retain the authoritative route.
+The shell and unrelated routes must continue to make zero Vaadin candidate-bundle requests.
+
+For targeted introspection, start the application with `run-referenceapp-htmx`, wait until a small `__type` query succeeds, and inspect only the named service, object, action-wrapper, result, and mutation types listed in `operation-shape-matrix.md`.
+Broad schema introspection is neither required nor used by the viewer.
diff --git a/openspec/changes/fix-webcomponent-action-dispatch-correctness/evidence/final-gate-results.md b/openspec/changes/fix-webcomponent-action-dispatch-correctness/evidence/final-gate-results.md
new file mode 100644
index 00000000000..a2affbc6d92
--- /dev/null
+++ b/openspec/changes/fix-webcomponent-action-dispatch-correctness/evidence/final-gate-results.md
@@ -0,0 +1,25 @@
+# Final gate results
+
+## Passed
+
+- Foundation Node suite: 129 tests passed with zero failures.
+- Web-component foundation and HTMX Maven tests: `BUILD SUCCESS`.
+- GraphQL model reactor and tests: `BUILD SUCCESS`.
+- Pinned Reference Application direct service and parameterized object mutation integration: `BUILD SUCCESS`.
+- Petclinic integration and Playwright reactor: `BUILD SUCCESS`.
+- Petclinic Playwright: 4 tests passed with zero failures, errors, or skips.
+- Reference Application ordinary clean package, integration tests, inventory tests, and deterministic fixtures: `BUILD SUCCESS`.
+- Reference Application Playwright: 9 tests passed with zero failures, errors, or skips.
+- Vaadin production-like pilot: 5 scenarios with zero axe, CSP, console, page, external-request, or overflow failures.
+- GraphQL model and Reference Application RAT checks: `BUILD SUCCESS`.
+- New foundation source and test modules are approved as Apache License 2.0 by RAT.
+- Strict OpenSpec validation and `git diff --check`: passed.
+
+## Compatibility results
+
+No dependency manifest changed.
+No public GraphQL name, Causeway element, context method, semantic event name, canonical route, history policy, or browser asset URL changed.
+No Vaadin bundle input, generated bundle, checksum, CSP hash, qualification policy, lazy-loading trigger, native fallback, or default-selection policy changed.
+
+The standalone foundation RAT goal still reports the same pre-existing `vaadin-reference/README.adoc` and generated `vaadin-reference.js` files as unknown-license resources.
+Neither file is introduced or modified by this change, and RAT explicitly approves both newly added action-dispatch files.
diff --git a/openspec/changes/fix-webcomponent-action-dispatch-correctness/evidence/implementation-report.md b/openspec/changes/fix-webcomponent-action-dispatch-correctness/evidence/implementation-report.md
new file mode 100644
index 00000000000..0d9723d937d
--- /dev/null
+++ b/openspec/changes/fix-webcomponent-action-dispatch-correctness/evidence/implementation-report.md
@@ -0,0 +1,37 @@
+# Action dispatch implementation report
+
+## Implemented boundary
+
+Object and service action contexts now share an immutable introspection-driven invocation plan.
+The plan records target kind, placement, mutability, selected field, mutation field, object target argument, declared arguments, result selection, extraction path, and effective outcome type.
+
+Nested `invoke` and `invokeIdempotent` fields remain query placements.
+An advertised generated top-level mutation is preferred over `invokeNonIdempotent`.
+The legacy nested mutating field remains available only when no corresponding mutation exists.
+Object targets are supplied only through a declared compatible target argument, and service actions never receive an object target.
+
+Direct scalar, enum, object, collection, and void results no longer require a fabricated envelope.
+An advertised `results` envelope remains supported and is extracted explicitly.
+Object metadata projection is derived from the effective metadata type, so versionless view models omit `_meta.version` rather than failing operation rendering.
+General union-fragment metadata projection remains deferred.
+
+Expected planning and execution failures return bounded action-scoped results.
+Protected action failures redact errors and operation variables while retaining the executable document shape for safe diagnostics.
+Cancellation and stale controller generations retain their existing behavior, and only successful mutating plans trigger authoritative refresh or service change notification.
+
+## Closed Reference Application failures
+
+`demo.ActionChoicesMenu.choices` now executes its parameterless flat mutation and navigates to the returned `demo.ActionChoices` object without opening an error prompt.
+`demo.ActionChoices.selectTvCharacter` now prepares its reference choices, submits the selected typed identity through the generated mutation, closes the prompt, and keeps the authoritative logical route.
+`demo.ActionSemanticsVm.reportPropertyForSafe` now executes without a version-selection failure and completes through the host result policy.
+`demo.ActionSemanticsVm.updatePropertyForIdempotent` retains invalid-value validation and now accepts a valid value without a version-selection failure.
+
+The remaining known gaps concern general polymorphic union projections, opaque composite routes, and identity/preparation cases not resolved by selecting only advertised result metadata.
+They remain outside this Priority 0 change.
+
+## Automated coverage
+
+The foundation operation matrix covers nested safe and idempotent fields, direct object and service mutations, mutation preference, legacy fallback, declared arguments, object targets, missing targets, unsupported plans, direct results, envelopes, versionless metadata, and protected-value redaction.
+Real GraphQL integration covers the parameterless service mutation and parameterized object mutation against the pinned Reference Application.
+The Reference Application browser suite converts prior visible-failure assertions into successful semantic journeys while retaining invalid, cancellation, route-disposal, accessibility, responsive, CSP, and external-isolation coverage.
+Petclinic continues to cover complete-identity object actions and established public selectors and events.
diff --git a/openspec/changes/fix-webcomponent-action-dispatch-correctness/evidence/operation-shape-matrix.md b/openspec/changes/fix-webcomponent-action-dispatch-correctness/evidence/operation-shape-matrix.md
new file mode 100644
index 00000000000..41ceab9575c
--- /dev/null
+++ b/openspec/changes/fix-webcomponent-action-dispatch-correctness/evidence/operation-shape-matrix.md
@@ -0,0 +1,44 @@
+# Reviewed action operation-shape matrix
+
+## Authoritative Reference Application shapes
+
+Targeted introspection was recorded against the pinned Reference Application with secured user `sven`.
+The public schema was not modified to obtain these shapes.
+
+| Target | Action wrapper | Executable placement | Arguments | Result |
+|---|---|---|---|---|
+| `demo.ActionChoicesMenu.choices` | `rich__demo_ActionChoicesMenu__choices__gqlv_action` advertises `hidden`, `disabled`, and `validate`, but no nested invocation field | Mutation `demo_ActionChoicesMenu__choices` | None | Direct `rich__demo_ActionChoices` |
+| `demo.ActionChoices.selectTvCharacter` | Wrapper advertises parameter state and validation, but no nested invocation field | Mutation `demo_ActionChoices__selectTvCharacter` | `_target: rich__demo_ActionChoices__gqlv_input`, `tvCharacter: rich__demo_DependentArgsDemoItem__gqlv_input` | Direct `rich__demo_ActionChoices` |
+| `demo.ActionChoices.selectTvCharacterByShow` | Wrapper advertises parameter state and validation, but no nested invocation field | Mutation `demo_ActionChoices__selectTvCharacterByShow` | `_target`, `tvShow`, `tvCharacter` | Direct `rich__demo_ActionChoices` |
+| `demo.ActionChoices.selectTvCharacters` | Wrapper advertises parameter state and validation, but no nested invocation field | Mutation `demo_ActionChoices__selectTvCharacters` | `_target`, `tvCharacters` | Direct `rich__demo_ActionChoices` |
+| `demo.ActionChoices.selectTvCharactersByShow` | Wrapper advertises nested `invoke` | Nested safe query | `tvShow`, `tvCharacters` through the nested field; object identity through the enclosing lookup | Envelope with `target`, `args`, and `results`; `results` is `rich__demo_ActionChoices` |
+| `demo.ActionChoices.selectTvCharactersByShows` | Wrapper advertises nested `invoke` | Nested safe query | `tvShows`, `tvCharacters` through the nested field; object identity through the enclosing lookup | Envelope with `target`, `args`, and `results`; `results` is `rich__demo_ActionChoices` |
+
+## Root cause confirmed
+
+`demo.ActionChoicesMenu.choices` is not a nested safe invocation.
+It is a parameterless flat mutation returning a view model directly.
+The returned `rich__demo_ActionChoices` metadata type advertises identity, logical type, and title but not every entity concurrency field.
+The previous generic result selector requested `_meta.version` unconditionally, so operation rendering failed before GraphQL execution and the interaction controller displayed only `Action invocation failed`.
+
+The corrected selector derives metadata children from the effective metadata type.
+The corrected dispatch planner also prevents an advertised legacy nested `invokeNonIdempotent` field from hiding an available top-level mutation.
+
+## Closed foundation matrix
+
+Foundation fixtures cover:
+
+- nested `invoke` and `invokeIdempotent` placement;
+- direct top-level object and service mutation placement;
+- top-level mutation preference over `invokeNonIdempotent`;
+- legacy nested mutation fallback when no root mutation exists;
+- object target argument discovery by generated input type;
+- absence of manufactured service targets;
+- direct scalar and direct versionless object results;
+- `results` envelopes;
+- required-argument and missing-target planning errors;
+- unsupported action plans;
+- omission of unadvertised metadata fields.
+
+General polymorphic union fragments remain outside this change.
+Versionless action preparation cases not solved by effective result selection remain candidates for the next identity-focused change.
diff --git a/openspec/changes/fix-webcomponent-action-dispatch-correctness/evidence/production-isolation.md b/openspec/changes/fix-webcomponent-action-dispatch-correctness/evidence/production-isolation.md
new file mode 100644
index 00000000000..f40e5da578c
--- /dev/null
+++ b/openspec/changes/fix-webcomponent-action-dispatch-correctness/evidence/production-isolation.md
@@ -0,0 +1,15 @@
+# Production isolation verification
+
+No Maven POM, npm package manifest, lock file, dependency version, or third-party package changes are part of this implementation.
+No public GraphQL query, mutation, field, argument, scalar, input, output, or generated-name contract changes.
+The implementation consumes existing targeted introspection and existing executable fields.
+
+The public `<causeway-*>` vocabulary, context methods, semantic event names, event target details, typed result kinds, canonical routes, HTMX history policy, and browser asset URLs remain unchanged.
+The new action dispatch module is an internal same-origin foundation module packaged through the established source-module mechanism.
+
+No Vaadin package, generated bundle, checksum, CSP hash, qualification policy, route-lazy trigger, native fallback, or default-selection property changes.
+Routes that do not instantiate an eligible reference editor continue to request no Vaadin candidate bundle.
+Action parameters that do instantiate the qualified reference adapter continue to use the same Causeway-owned codec, identity, pending-value, validation, cancellation, and semantic event contracts.
+
+The corrected behavior is rollback-safe because it changes only client operation planning and safe result projection.
+Reverting the implementation requires no schema, persisted data, application configuration, route, or asset migration.
diff --git a/openspec/changes/fix-webcomponent-action-dispatch-correctness/tasks.md b/openspec/changes/fix-webcomponent-action-dispatch-correctness/tasks.md
index 3ff67b7ee67..0744ee4c52e 100644
--- a/openspec/changes/fix-webcomponent-action-dispatch-correctness/tasks.md
+++ b/openspec/changes/fix-webcomponent-action-dispatch-correctness/tasks.md
@@ -1,74 +1,74 @@
## 1. Capture the executable operation-shape baseline
-- [ ] 1.1 Record targeted introspection for the Reference Application parameterless `demo.ActionChoicesMenu.choices` service action, including its nested invocation and result shape.
-- [ ] 1.2 Record targeted introspection for representative safe, idempotent, and mutating object actions with and without parameters.
-- [ ] 1.3 Record the generated top-level mutation fields, declared target arguments, parameter arguments, and direct or enveloped result types for those actions.
-- [ ] 1.4 Add a focused failing foundation fixture for the nested executable shape that currently produces `Action invocation failed`.
-- [ ] 1.5 Add focused failing fixtures proving that an advertised flat mutation must be preferred over a legacy nested `invokeNonIdempotent` field.
-- [ ] 1.6 Publish the reviewed object and service operation-shape matrix as reproducible change evidence.
+- [x] 1.1 Record targeted introspection for the Reference Application parameterless `demo.ActionChoicesMenu.choices` service action, including its nested invocation and result shape.
+- [x] 1.2 Record targeted introspection for representative safe, idempotent, and mutating object actions with and without parameters.
+- [x] 1.3 Record the generated top-level mutation fields, declared target arguments, parameter arguments, and direct or enveloped result types for those actions.
+- [x] 1.4 Add a focused foundation fixture for the direct versionless mutation result shape that previously produced `Action invocation failed`.
+- [x] 1.5 Add focused failing fixtures proving that an advertised flat mutation must be preferred over a legacy nested `invokeNonIdempotent` field.
+- [x] 1.6 Publish the reviewed object and service operation-shape matrix as reproducible change evidence.
## 2. Implement shared introspection-driven dispatch planning
-- [ ] 2.1 Add an immutable shared invocation-plan model for target kind, operation placement, mutability, selected field, arguments, result projection, extraction, and outcome type.
-- [ ] 2.2 Select nested `invoke` or `invokeIdempotent` only for advertised safe or idempotent query execution.
-- [ ] 2.3 Inspect the mutation root independently and prefer an advertised generated top-level mutation for mutating actions.
-- [ ] 2.4 Retain nested `invokeNonIdempotent` only as an explicit compatibility fallback when no corresponding mutation field exists.
-- [ ] 2.5 Map pending values only to arguments declared by the selected invocation field.
-- [ ] 2.6 Resolve an object mutation's target argument from its advertised generated input type or target name and reject an unsatisfied required target before execution.
-- [ ] 2.7 Ensure service mutations never receive a manufactured object target.
-- [ ] 2.8 Return a bounded unsupported plan without constructing an operation when no executable placement exists.
+- [x] 2.1 Add an immutable shared invocation-plan model for target kind, operation placement, mutability, selected field, arguments, result projection, extraction, and outcome type.
+- [x] 2.2 Select nested `invoke` or `invokeIdempotent` only for advertised safe or idempotent query execution.
+- [x] 2.3 Inspect the mutation root independently and prefer an advertised generated top-level mutation for mutating actions.
+- [x] 2.4 Retain nested `invokeNonIdempotent` only as an explicit compatibility fallback when no corresponding mutation field exists.
+- [x] 2.5 Map pending values only to arguments declared by the selected invocation field.
+- [x] 2.6 Resolve an object mutation's target argument from its advertised generated input type or target name and reject an unsatisfied required target before execution.
+- [x] 2.7 Ensure service mutations never receive a manufactured object target.
+- [x] 2.8 Return a bounded unsupported plan without constructing an operation when no executable placement exists.
## 3. Derive safe result projection and extraction
-- [ ] 3.1 Detect and select an advertised `results` envelope without assuming that every nested invocation uses one.
-- [ ] 3.2 Support direct scalar, enum, object, collection, and void result fields without requesting child fields for scalar or void values.
-- [ ] 3.3 Select only fields advertised by a described direct object or established result envelope and never synthesize an absent `target` field.
-- [ ] 3.4 Preserve valid `__typename`-only bounded output when general polymorphic metadata projection remains unavailable.
-- [ ] 3.5 Normalize direct and enveloped values through the existing object, collection, scalar, and void semantic result kinds.
-- [ ] 3.6 Reject an unsafe or undescribed required result projection with a bounded planning error before execution.
+- [x] 3.1 Detect and select an advertised `results` envelope without assuming that every nested invocation uses one.
+- [x] 3.2 Support direct scalar, enum, object, collection, and void result fields without requesting child fields for scalar or void values.
+- [x] 3.3 Select only fields advertised by a described direct object or established result envelope and never synthesize an absent `target` field.
+- [x] 3.4 Preserve valid `__typename`-only bounded output when general polymorphic metadata projection remains unavailable.
+- [x] 3.5 Normalize direct and enveloped values through the existing object, collection, scalar, and void semantic result kinds.
+- [x] 3.6 Reject an unsafe or undescribed required result projection with a bounded planning error before execution.
## 4. Integrate object and service action contexts
-- [ ] 4.1 Route object action capability discovery and invocation through the shared dispatch planner.
-- [ ] 4.2 Route service action capability discovery and invocation through the same planner.
-- [ ] 4.3 Extend the GraphQL interaction operation builder only as needed for direct nested results and plan-defined extraction paths.
-- [ ] 4.4 Preserve current public context methods, GraphQL operation names, Causeway semantic events, and interaction result shapes.
-- [ ] 4.5 Preserve per-object and per-service mutation serialization and trigger authoritative refresh or change notification only after successful mutation.
-- [ ] 4.6 Preserve cancellation, disconnect, route replacement, and stale-generation checks so obsolete invocation results cannot update current state.
-- [ ] 4.7 Map expected capability, planning, GraphQL, invocation, and transport failures to bounded action-scoped interaction results before the controller's generic exception fallback.
-- [ ] 4.8 Verify protected action values remain absent from errors, diagnostics, semantic events, operation summaries, and rendered markup.
+- [x] 4.1 Route object action capability discovery and invocation through the shared dispatch planner.
+- [x] 4.2 Route service action capability discovery and invocation through the same planner.
+- [x] 4.3 Extend the GraphQL interaction operation builder only as needed for direct nested results and plan-defined extraction paths.
+- [x] 4.4 Preserve current public context methods, GraphQL operation names, Causeway semantic events, and interaction result shapes.
+- [x] 4.5 Preserve per-object and per-service mutation serialization and trigger authoritative refresh or change notification only after successful mutation.
+- [x] 4.6 Preserve cancellation, disconnect, route replacement, and stale-generation checks so obsolete invocation results cannot update current state.
+- [x] 4.7 Map expected capability, planning, GraphQL, invocation, and transport failures to bounded action-scoped interaction results before the controller's generic exception fallback.
+- [x] 4.8 Verify protected action values remain absent from errors, diagnostics, semantic events, operation summaries, and rendered markup.
## 5. Close the automated operation-shape matrix
-- [ ] 5.1 Add foundation tests for parameterless and parameterized nested safe and idempotent object actions.
-- [ ] 5.2 Add foundation tests for parameterless and parameterized nested safe and idempotent service actions.
-- [ ] 5.3 Add foundation tests for object and service top-level mutations, object target mapping, and mutation preference over a legacy nested field.
-- [ ] 5.4 Add foundation tests for the legacy nested mutating fallback and the no-executable-capability unsupported result.
-- [ ] 5.5 Add foundation tests for direct and enveloped scalar, object, collection, and void outcome normalization.
-- [ ] 5.6 Add negative tests for undeclared arguments, missing required target mapping, absent result fields, bounded errors, cancellation, stale completion, and protected-value redaction.
-- [ ] 5.7 Keep existing Petclinic object and service action interaction tests passing without changing their public selectors or event assertions.
+- [x] 5.1 Add foundation tests for parameterless and parameterized nested safe and idempotent object actions.
+- [x] 5.2 Add foundation tests for parameterless and parameterized nested safe and idempotent service actions.
+- [x] 5.3 Add foundation tests for object and service top-level mutations, object target mapping, and mutation preference over a legacy nested field.
+- [x] 5.4 Add foundation tests for the legacy nested mutating fallback and the no-executable-capability unsupported result.
+- [x] 5.5 Add foundation tests for direct and enveloped scalar, object, collection, and void outcome normalization.
+- [x] 5.6 Add negative tests for undeclared arguments, missing required target mapping, absent result fields, bounded errors, cancellation, stale completion, and protected-value redaction.
+- [x] 5.7 Keep existing Petclinic object and service action interaction tests passing without changing their public selectors or event assertions.
## 6. Qualify against GraphQL and the pinned Reference Application
-- [ ] 6.1 Add GraphQL integration assertions that the recorded nested safe and flat mutation operations execute successfully against authoritative fixtures.
-- [ ] 6.2 Replace the Reference Application `demo.ActionChoicesMenu.choices` visible-failure assertion with a successful parameterless service-action outcome and host navigation or presentation assertion.
-- [ ] 6.3 Add a successful parameterized object-action journey whose identity metadata is already complete, while retaining versionless-action failures for the next focused change.
-- [ ] 6.4 Cover valid, invalid, cancelled, stale, scalar, object, collection, and void action outcomes through representative Reference Application targets.
-- [ ] 6.5 Update the capability inventory and stable target catalogue only where focused executable evidence changes the reviewed classification.
-- [ ] 6.6 Prove clean and incremental inventory generation remain byte-identical after classification updates.
+- [x] 6.1 Add GraphQL integration assertions that the recorded nested safe and flat mutation operations execute successfully against authoritative fixtures.
+- [x] 6.2 Replace the Reference Application `demo.ActionChoicesMenu.choices` visible-failure assertion with a successful parameterless service-action outcome and host navigation or presentation assertion.
+- [x] 6.3 Add a successful parameterized object-action journey whose identity metadata is already complete, while retaining versionless-action failures for the next focused change.
+- [x] 6.4 Cover valid, invalid, cancelled, stale, scalar, object, collection, and void action outcomes through representative Reference Application targets.
+- [x] 6.5 Update the capability inventory and stable target catalogue only where focused executable evidence changes the reviewed classification.
+- [x] 6.6 Prove clean and incremental inventory generation remain byte-identical after classification updates.
## 7. Document compatibility and production isolation
-- [ ] 7.1 Document nested safe-query, top-level mutation, and legacy nested-mutation fallback behavior for semantic action contexts.
-- [ ] 7.2 Publish reproduction steps, operation-shape evidence, before-and-after results, and any intentionally retained identity or union gaps.
-- [ ] 7.3 Verify no Maven or npm dependency, public GraphQL field or operation name, Causeway element, semantic event name, canonical route, or browser asset URL changes.
-- [ ] 7.4 Verify Vaadin bundle inputs, CSP hashes, route-lazy loading, native fallback, and default-selection policy remain unchanged.
+- [x] 7.1 Document nested safe-query, top-level mutation, and legacy nested-mutation fallback behavior for semantic action contexts.
+- [x] 7.2 Publish reproduction steps, operation-shape evidence, before-and-after results, and any intentionally retained identity or union gaps.
+- [x] 7.3 Verify no Maven or npm dependency, public GraphQL field or operation name, Causeway element, semantic event name, canonical route, or browser asset URL changes.
+- [x] 7.4 Verify Vaadin bundle inputs, CSP hashes, route-lazy loading, native fallback, and default-selection policy remain unchanged.
## 8. Run final qualification gates
-- [ ] 8.1 Run the complete foundation Node suite and web-component foundation and HTMX Maven tests.
-- [ ] 8.2 Run GraphQL model and relevant integration tests for nested query and flat mutation action execution.
-- [ ] 8.3 Run the full Petclinic integration and Playwright suites.
-- [ ] 8.4 Run the Reference Application clean package, integration, capability inventory, and Playwright suites.
-- [ ] 8.5 Run strict CSP, accessibility, keyboard, light, dark, narrow, reduced-motion, forced-colors, external-request, console-error, page-error, and overflow gates.
-- [ ] 8.6 Run applicable RAT checks, strict OpenSpec validation, `git diff --check`, and production-isolation verification.
+- [x] 8.1 Run the complete foundation Node suite and web-component foundation and HTMX Maven tests.
+- [x] 8.2 Run GraphQL model and relevant integration tests for nested query and flat mutation action execution.
+- [x] 8.3 Run the full Petclinic integration and Playwright suites.
+- [x] 8.4 Run the Reference Application clean package, integration, capability inventory, and Playwright suites.
+- [x] 8.5 Run strict CSP, accessibility, keyboard, light, dark, narrow, reduced-motion, forced-colors, external-request, console-error, page-error, and overflow gates.
+- [x] 8.6 Run applicable RAT checks, strict OpenSpec validation, `git diff --check`, and production-isolation verification.
diff --git a/regressiontests/referenceapp/htmx/src/playwright/java/org/apache/causeway/regressiontests/referenceapp/htmx/ReferenceAppHtmxPlaywrightTest.java b/regressiontests/referenceapp/htmx/src/playwright/java/org/apache/causeway/regressiontests/referenceapp/htmx/ReferenceAppHtmxPlaywrightTest.java
index 65e6f38c47f..b09623cda59 100644
--- a/regressiontests/referenceapp/htmx/src/playwright/java/org/apache/causeway/regressiontests/referenceapp/htmx/ReferenceAppHtmxPlaywrightTest.java
+++ b/regressiontests/referenceapp/htmx/src/playwright/java/org/apache/causeway/regressiontests/referenceapp/htmx/ReferenceAppHtmxPlaywrightTest.java
@@ -113,25 +113,19 @@ void menusChoicesAutocompleteCancellationAndRouteDisposal() {
openMenu("Prog Model");
serviceAction("demo.ActionChoicesMenu", "choices").click();
- page.locator(PROMPT).waitFor();
- assertThat(page.locator(PROMPT + " .causeway-action-prompt-error").textContent())
- .contains("Action invocation failed");
- page.locator("[data-testid='action-prompt-cancel']").click();
- page.locator(PROMPT).waitFor(new Locator.WaitForOptions()
- .setState(com.microsoft.playwright.options.WaitForSelectorState.DETACHED));
-
- openObject("demo.ActionChoices", invokeViewModel("demo_ActionChoicesMenu", "choices", "rich__demo_ActionChoices"));
+ waitForLogicalType("demo.ActionChoices");
+ page.waitForFunction("() => ['ready', 'fallback', 'partial-error'].includes(document.querySelector('#causeway-route causeway-object')?.dataset.layoutState)");
+ assertThat(page.locator(PROMPT).count()).isZero();
final Locator invokingControl = objectAction("selectTvCharacter");
invokingControl.click();
waitForPrompt("selectTvCharacter");
- assertThat(page.locator(PROMPT + " .causeway-action-prompt-error").textContent())
- .contains("Field 'version' is unavailable");
- assertThat(candidateRequests()).isZero();
- page.locator("[data-testid='action-prompt-cancel']").click();
+ selectFirstAvailableChoice("tvCharacter");
+ submitPrompt();
page.locator(PROMPT).waitFor(new Locator.WaitForOptions()
.setState(com.microsoft.playwright.options.WaitForSelectorState.DETACHED));
- assertThat((Boolean) invokingControl.evaluate("element => element === document.activeElement")).isTrue();
+ waitForLogicalType("demo.ActionChoices");
+ assertThat(candidateRequests()).isZero();
openObject("demo.ActionAutoCompletePage", invokeViewModel(
"demo_ActionAutoCompleteMenu", "autoComplete", "rich__demo_ActionAutoCompletePage"));
@@ -304,16 +298,14 @@ void textMultilineNullableDisabledInvalidCancelledAndStalePropertyStatesRemainVi
}
@Test
- void parameterlessParameterizedDefaultValidationOutcomeAndVersionGapActionStatesRemainVisible() {
+ void parameterlessParameterizedDefaultValidationAndSuccessfulActionStatesRemainVisible() {
openObject("demo.ActionSemanticsVm", invokeViewModel(
"demo_ActionMenu", "semantics", "rich__demo_ActionSemanticsVm"));
+ page.evaluate("() => globalThis.__referenceAppActionContextBeforeInvoke = document.querySelector('#causeway-route causeway-object-context')");
objectAction("reportPropertyForSafe").click();
- page.locator(PROMPT).waitFor();
- assertThat(page.locator(PROMPT + " .causeway-action-prompt-error").textContent())
- .contains("Field 'version' is unavailable");
- page.locator("[data-testid='action-prompt-cancel']").click();
- page.locator(PROMPT).waitFor(new Locator.WaitForOptions()
- .setState(com.microsoft.playwright.options.WaitForSelectorState.DETACHED));
+ page.waitForFunction("() => document.querySelector('#causeway-route causeway-object-context') !== globalThis.__referenceAppActionContextBeforeInvoke");
+ waitForLogicalType("demo.ActionSemanticsVm");
+ assertThat(page.locator(PROMPT).count()).isZero();
final Locator update = objectAction("updatePropertyForIdempotent");
update.click();
@@ -333,12 +325,9 @@ void parameterlessParameterizedDefaultValidationOutcomeAndVersionGapActionStates
waitForPrompt("updatePropertyForIdempotent");
page.locator(parameter("value")).fill("37");
submitPrompt();
- page.waitForFunction("() => document.querySelector(\"dialog[data-testid='action-prompt'] .causeway-action-prompt-error\")?.textContent?.trim().length > 0");
- assertThat(page.locator(PROMPT + " .causeway-action-prompt-error").textContent())
- .contains("Field 'version' is unavailable");
- page.locator("[data-testid='action-prompt-cancel']").click();
page.locator(PROMPT).waitFor(new Locator.WaitForOptions()
.setState(com.microsoft.playwright.options.WaitForSelectorState.DETACHED));
+ waitForLogicalType("demo.ActionSemanticsVm");
}
@Test
diff --git a/regressiontests/referenceapp/htmx/src/test/java/org/apache/causeway/regressiontests/referenceapp/htmx/ReferenceAppHtmxApplication_IntegTest.java b/regressiontests/referenceapp/htmx/src/test/java/org/apache/causeway/regressiontests/referenceapp/htmx/ReferenceAppHtmxApplication_IntegTest.java
index 5683089c06b..767ada113b1 100644
--- a/regressiontests/referenceapp/htmx/src/test/java/org/apache/causeway/regressiontests/referenceapp/htmx/ReferenceAppHtmxApplication_IntegTest.java
+++ b/regressiontests/referenceapp/htmx/src/test/java/org/apache/causeway/regressiontests/referenceapp/htmx/ReferenceAppHtmxApplication_IntegTest.java
@@ -162,6 +162,59 @@ void exposesRepresentativeFixtureIdentityAndEffectiveGrid() throws Exception {
.doesNotContain("<bs:metadataError>");
}
+ @Test
+ void directServiceAndParameterizedObjectActionMutationsUseAdvertisedShapes() throws Exception {
+ final JsonNode opened = graphQL("""
+ mutation {
+ demo_ActionChoicesMenu__choices {
+ _meta { id logicalTypeName title }
+ }
+ }
+ """);
+ assertNoGraphQLErrors(opened);
+ final JsonNode metadata = opened.at("/data/demo_ActionChoicesMenu__choices/_meta");
+ assertThat(metadata.path("logicalTypeName").asText()).isEqualTo("demo.ActionChoices");
+ final String targetId = metadata.path("id").asText();
+ assertThat(targetId).isNotBlank();
+
+ final JsonNode prepared = graphQL("""
+ query {
+ rich {
+ demo_ActionChoices(object: {id: %s}) {
+ selectTvCharacter {
+ params {
+ tvCharacter {
+ choices { _meta { id logicalTypeName title } }
+ }
+ }
+ }
+ }
+ }
+ }
+ """.formatted(OBJECT_MAPPER.writeValueAsString(targetId)));
+ assertNoGraphQLErrors(prepared);
+ final String choiceId = prepared.at(
+ "/data/rich/demo_ActionChoices/selectTvCharacter/params/tvCharacter/choices/0/_meta/id").asText();
+ assertThat(choiceId).isNotBlank();
+
+ final JsonNode invoked = graphQL("""
+ mutation {
+ demo_ActionChoices__selectTvCharacter(
+ _target: {id: %s},
+ tvCharacter: {id: %s}) {
+ _meta { id logicalTypeName title }
+ }
+ }
+ """.formatted(
+ OBJECT_MAPPER.writeValueAsString(targetId),
+ OBJECT_MAPPER.writeValueAsString(choiceId)));
+ assertNoGraphQLErrors(invoked);
+ assertThat(invoked.at("/data/demo_ActionChoices__selectTvCharacter/_meta/id").asText())
+ .isNotBlank();
+ assertThat(invoked.at("/data/demo_ActionChoices__selectTvCharacter/_meta/logicalTypeName").asText())
+ .isEqualTo("demo.ActionChoices");
+ }
+
@Test
void exactDecimalPropertyMutationUsesAdvertisedStringAndRestoresFixture() throws Exception {
final JsonNode open = graphQL("""
diff --git a/viewers/webcomponents/foundation/USAGE.adoc b/viewers/webcomponents/foundation/USAGE.adoc
index 073ca9b47e3..10c39f938bd 100644
--- a/viewers/webcomponents/foundation/USAGE.adoc
+++ b/viewers/webcomponents/foundation/USAGE.adoc
@@ -408,8 +408,11 @@ Custom prompts must preserve labels, validation associations, pending announceme
Call `release()` and `releaseMoneyCodec()` when the overrides are no longer required.
The object context exposes `prepareAction`, `autoCompleteActionParameter`, `validateAction`, and `invokeAction` for those integrations.
-Safe actions use the advertised rich query invocation field.
-Mutating actions use the discovered top-level mutation when available, while a non-compliant query mutation is used only when that is the capability exposed by the configured API variant.
+Safe and idempotent actions use an advertised nested `invoke` or `invokeIdempotent` query field.
+Mutating object and service actions use the discovered top-level mutation when available, while nested `invokeNonIdempotent` remains an explicit compatibility fallback only when no corresponding mutation is advertised.
+The shared dispatch planner emits only declared arguments, resolves an object target through its advertised generated input argument, and never manufactures a service target.
+Nested result envelopes are selected through an advertised `results` field, while direct mutation and invocation results are projected from their effective scalar, enum, object, collection, or void type.
+Object metadata selection includes only fields advertised by the effective metadata type, so a versionless view model does not receive a speculative `_meta.version` selection.
Object, collection, scalar, and void outcomes are normalized and published through `causeway-action-result` without automatic navigation.
The standard controller may render a local result summary; host routing remains responsible for object-result navigation.
diff --git a/viewers/webcomponents/foundation/src/action-dispatch.mjs b/viewers/webcomponents/foundation/src/action-dispatch.mjs
new file mode 100644
index 00000000000..a00c31af4d9
--- /dev/null
+++ b/viewers/webcomponents/foundation/src/action-dispatch.mjs
@@ -0,0 +1,217 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {fieldsByName, namedType} from './introspection.mjs';
+import {argumentsFromValues, resultSelectionForType} from './interaction-operations.mjs';
+
+export const ActionInvocationPlacement = Object.freeze({
+ NESTED_QUERY: 'nested-query',
+ ROOT_MUTATION: 'root-mutation',
+ LEGACY_NESTED_MUTATION: 'legacy-nested-mutation'
+});
+
+export class ActionDispatchPlanError extends Error {
+ constructor(code, message) {
+ super(message);
+ this.name = 'ActionDispatchPlanError';
+ this.code = code;
+ }
+}
+
+export function createActionInvocationPlan({
+ targetKind,
+ description,
+ descriptor,
+ mutationType = null,
+ mutationFieldName = null
+} = {}) {
+ const safeField = ['invoke', 'invokeIdempotent']
+ .map(name => descriptor?.fields?.get(name))
+ .find(Boolean) ?? null;
+ const legacyMutationField = descriptor?.fields?.get('invokeNonIdempotent') ?? null;
+ const rootMutationField = mutationType && mutationFieldName
+ ? fieldsByName(mutationType).get(mutationFieldName) ?? null
+ : null;
+
+ let placement;
+ let field;
+ if (safeField) {
+ placement = ActionInvocationPlacement.NESTED_QUERY;
+ field = safeField;
+ } else if (rootMutationField) {
+ placement = ActionInvocationPlacement.ROOT_MUTATION;
+ field = rootMutationField;
+ } else if (legacyMutationField) {
+ placement = ActionInvocationPlacement.LEGACY_NESTED_MUTATION;
+ field = legacyMutationField;
+ } else {
+ return Object.freeze({
+ supported: false,
+ targetKind,
+ placement: null,
+ mutating: false,
+ field: null,
+ fieldName: null,
+ mutationFieldName: null,
+ targetArgumentName: null
+ });
+ }
+
+ const mutating = placement !== ActionInvocationPlacement.NESTED_QUERY;
+ const targetArgumentName = placement === ActionInvocationPlacement.ROOT_MUTATION && targetKind === 'object'
+ ? findTargetArgument(field, description?.generatedInputTypeName)
+ : null;
+ if (placement === ActionInvocationPlacement.ROOT_MUTATION && targetKind === 'object' && !targetArgumentName) {
+ throw new ActionDispatchPlanError(
+ 'ACTION_TARGET_ARGUMENT_UNAVAILABLE',
+ 'The advertised object action mutation has no compatible target argument.');
+ }
+
+ return Object.freeze({
+ supported: true,
+ targetKind,
+ placement,
+ mutating,
+ field,
+ fieldName: field.name,
+ mutationFieldName: placement === ActionInvocationPlacement.ROOT_MUTATION ? mutationFieldName : null,
+ targetArgumentName
+ });
+}
+
+export function actionInvocationArguments(plan, values = {}, identity = null) {
+ if (!plan?.supported || !plan.field) {
+ throw new ActionDispatchPlanError('ACTION_INVOCATION_UNAVAILABLE', 'No executable action invocation is advertised.');
+ }
+ const args = argumentsFromValues(plan.field, values);
+ if (plan.targetArgumentName) {
+ if (!identity?.id) {
+ throw new ActionDispatchPlanError('ACTION_TARGET_IDENTITY_UNAVAILABLE', 'The object action target identity is unavailable.');
+ }
+ args[plan.targetArgumentName] = {id: identity.id};
+ }
+ for (const argument of plan.field.args ?? []) {
+ if (argument.type?.kind !== 'NON_NULL' || Object.prototype.hasOwnProperty.call(args, argument.name)) {
+ continue;
+ }
+ throw new ActionDispatchPlanError(
+ 'ACTION_REQUIRED_ARGUMENT_UNAVAILABLE',
+ `Required action argument '${argument.name}' is unavailable.`);
+ }
+ return args;
+}
+
+export async function ensureActionInvocationResultTypes(client, description, plan, signal) {
+ await ensureType(client, description, plan?.field?.type, signal);
+ const describedType = description.types.get(namedType(plan?.field?.type)) ?? null;
+ const resultsField = describedType ? fieldsByName(describedType).get('results') ?? null : null;
+ if (resultsField) {
+ await ensureType(client, description, resultsField.type, signal);
+ }
+}
+
+export function actionInvocationResultPlan(plan, types) {
+ if (!plan?.supported || !plan.field) {
+ throw new ActionDispatchPlanError('ACTION_INVOCATION_UNAVAILABLE', 'No executable action invocation is advertised.');
+ }
+ const directType = plan.field.type;
+ const describedType = types.get(namedType(directType)) ?? null;
+ const resultsField = describedType ? fieldsByName(describedType).get('results') ?? null : null;
+ if (resultsField) {
+ return Object.freeze({
+ selection: Object.freeze({results: resultSelectionForType(resultsField.type, types) ?? true}),
+ resultType: resultsField.type,
+ extractionPath: Object.freeze(['results'])
+ });
+ }
+ return Object.freeze({
+ selection: resultSelectionForType(directType, types) ?? true,
+ resultType: directType,
+ extractionPath: Object.freeze([])
+ });
+}
+
+export function extractActionInvocationResult(value, resultPlan) {
+ let current = value;
+ for (const segment of resultPlan?.extractionPath ?? []) {
+ current = current?.[segment] ?? null;
+ }
+ return current;
+}
+
+export function secureActionInvocationResult(result, parameters = []) {
+ if (!parameters.some(parameter => isProtectedType(parameter.inputType))) {
+ return result;
+ }
+ const operation = result?.operation
+ ? Object.freeze({
+ ...result.operation,
+ variables: Object.freeze(Object.fromEntries(
+ Object.keys(result.operation.variables ?? {}).map(name => [name, '<redacted>'])))
+ })
+ : null;
+ const errors = Object.freeze((result?.errors ?? []).map(error => Object.freeze({
+ message: 'Protected action input was not accepted.',
+ path: Object.freeze([...(error?.path ?? [])]),
+ extensions: Object.freeze({...error?.extensions, code: error?.extensions?.code ?? 'PROTECTED_ACTION_INPUT_FAILED'})
+ })));
+ return Object.freeze({...result, data: null, operation, errors});
+}
+
+async function ensureType(client, description, typeRef, signal) {
+ const typeName = namedType(typeRef);
+ if (!typeName || ['SCALAR', 'ENUM'].includes(innermostKind(typeRef))) {
+ return;
+ }
+ if (!description.types.has(typeName)) {
+ const described = await client.describeTypes([typeName], {signal});
+ const type = described.get(typeName);
+ if (type) {
+ description.types.set(typeName, type);
+ }
+ }
+ const type = description.types.get(typeName);
+ const metadataTypeName = namedType(fieldsByName(type).get('_meta')?.type);
+ if (metadataTypeName && !description.types.has(metadataTypeName)) {
+ const described = await client.describeTypes([metadataTypeName], {signal});
+ const metadataType = described.get(metadataTypeName);
+ if (metadataType) {
+ description.types.set(metadataTypeName, metadataType);
+ }
+ }
+}
+
+function innermostKind(typeRef) {
+ let current = typeRef;
+ while (current?.ofType) {
+ current = current.ofType;
+ }
+ return current?.kind ?? null;
+}
+
+function isProtectedType(typeRef) {
+ const typeName = namedType(typeRef)?.toLocaleLowerCase() ?? '';
+ return typeName.includes('password') || typeName.includes('protected');
+}
+
+function findTargetArgument(field, generatedInputTypeName) {
+ return field.args?.find(argument => namedType(argument.type) === generatedInputTypeName)?.name
+ ?? field.args?.find(argument => argument.name === '_target')?.name
+ ?? null;
+}
diff --git a/viewers/webcomponents/foundation/src/interaction-operations.mjs b/viewers/webcomponents/foundation/src/interaction-operations.mjs
index 1b814c2da68..c69c94a0189 100644
--- a/viewers/webcomponents/foundation/src/interaction-operations.mjs
+++ b/viewers/webcomponents/foundation/src/interaction-operations.mjs
@@ -194,8 +194,17 @@ export function resultSelectionForType(typeRef, types) {
return {__typename: true};
}
const fields = fieldsByName(typeDescription);
- if (fields.has('_meta')) {
- return {_meta: {id: true, logicalTypeName: true, title: true, version: true}};
+ const metadataField = fields.get('_meta');
+ const metadataType = metadataField ? types.get(namedType(metadataField.type)) ?? null : null;
+ if (metadataType) {
+ const metadataFields = fieldsByName(metadataType);
+ const metadataSelection = Object.fromEntries(
+ ['id', 'logicalTypeName', 'title', 'version']
+ .filter(fieldName => metadataFields.has(fieldName))
+ .map(fieldName => [fieldName, true]));
+ if (Object.keys(metadataSelection).length > 0) {
+ return {_meta: metadataSelection};
+ }
}
const scalarFields = typeDescription.fields
.filter(field => ['SCALAR', 'ENUM'].includes(innermostType(field.type)?.kind))
diff --git a/viewers/webcomponents/foundation/src/object-context-controller.mjs b/viewers/webcomponents/foundation/src/object-context-controller.mjs
index 2b146526187..541b46f5021 100644
--- a/viewers/webcomponents/foundation/src/object-context-controller.mjs
+++ b/viewers/webcomponents/foundation/src/object-context-controller.mjs
@@ -17,6 +17,14 @@
* under the License.
*/
+import {
+ actionInvocationArguments,
+ actionInvocationResultPlan,
+ createActionInvocationPlan,
+ ensureActionInvocationResultTypes,
+ extractActionInvocationResult,
+ secureActionInvocationResult
+} from './action-dispatch.mjs';
import {createSemanticEvent, OBJECT_CONTEXT_STATE_EVENT} from './context-events.mjs';
import {fieldsByName, namedType} from './introspection.mjs';
import {argumentsFromValues, commandSelection, resultSelectionForType} from './interaction-operations.mjs';
@@ -290,24 +298,45 @@ export class ObjectContextController extends EventTarget {
enumValues: Object.freeze(enumType?.enumValues?.map(value => value.name) ?? [])
});
});
- const invocationField = actionInvocationField(descriptor);
- const mutationType = invocationField ? null : await this.client.describeMutation();
+ const mutationType = await this.client.describeMutation();
const mutationFieldName = mutationFieldNameFor(description, member);
- const mutationField = mutationType ? fieldsByName(mutationType).get(mutationFieldName) ?? null : null;
+ let invocationPlan;
+ let planningError = null;
+ try {
+ invocationPlan = createActionInvocationPlan({
+ targetKind: 'object',
+ description,
+ descriptor,
+ mutationType,
+ mutationFieldName
+ });
+ } catch (error) {
+ invocationPlan = Object.freeze({supported: false});
+ planningError = commandError({
+ message: 'The advertised object action cannot be dispatched safely.',
+ extensions: {code: error?.code ?? 'ACTION_DISPATCH_PLAN_FAILED'}
+ });
+ }
return Object.freeze({
descriptor,
parameters: Object.freeze(parameters),
validate: Boolean(validateField),
- invocationField: invocationField?.name ?? null,
- mutationFieldName: mutationField ? mutationFieldName : null,
- invokable: Boolean(invocationField || mutationField)
+ invocationPlan,
+ invocationField: invocationPlan.placement === 'root-mutation' ? null : invocationPlan.fieldName ?? null,
+ mutationFieldName: invocationPlan.mutationFieldName ?? null,
+ mutating: invocationPlan.mutating ?? false,
+ planningError,
+ invokable: invocationPlan.supported === true
});
}
async prepareAction(member, values = {}, {signal} = {}) {
const capabilities = await this.describeActionInteraction(member);
if (!capabilities.invokable) {
- return interactionResult(InteractionStatus.UNSUPPORTED, {capabilities}, [commandError(`Action '${member}' does not expose an invocation capability.`)]);
+ return interactionResult(
+ capabilities.planningError ? InteractionStatus.FAILED : InteractionStatus.UNSUPPORTED,
+ {capabilities},
+ [capabilities.planningError ?? commandError(`Action '${member}' does not expose an invocation capability.`)]);
}
if (capabilities.parameters.length === 0) {
return interactionResult(InteractionStatus.SUCCESS, {capabilities, parameters: []});
@@ -410,63 +439,65 @@ export class ObjectContextController extends EventTarget {
});
}
- invokeAction(member, values = {}, {signal} = {}) {
- return this.#serializeMutation(async () => {
- const {description, descriptor} = await this.#memberDescriptor(member, 'action');
- const invocationField = actionInvocationField(descriptor);
- let result;
- let resultType;
- if (invocationField) {
- const invokeType = description.types.get(namedType(invocationField.type));
- const resultsField = invokeType ? fieldsByName(invokeType).get('results') : null;
- if (resultsField) {
- await ensureResultTypes(this.client, description, resultsField.type, signal);
+ async invokeAction(member, values = {}, {signal} = {}) {
+ const capabilities = await this.describeActionInteraction(member);
+ if (!capabilities.invokable) {
+ return interactionResult(
+ capabilities.planningError ? InteractionStatus.FAILED : InteractionStatus.UNSUPPORTED,
+ null,
+ [capabilities.planningError ?? commandError(`Action '${member}' does not expose an invocation capability.`)]);
+ }
+ const execute = async () => {
+ try {
+ const {description} = await this.#memberDescriptor(member, 'action');
+ const plan = capabilities.invocationPlan;
+ await ensureActionInvocationResultTypes(this.client, description, plan, signal);
+ const resultPlan = actionInvocationResultPlan(plan, description.types);
+ const args = actionInvocationArguments(plan, values, this.identity);
+ let result;
+ if (plan.placement === 'root-mutation') {
+ const mutationType = await this.client.describeMutation({signal});
+ result = await this.client.executeMutationInteraction({
+ description,
+ mutationType,
+ fieldName: plan.mutationFieldName,
+ args,
+ resultSelection: resultPlan.selection === true ? null : resultPlan.selection,
+ operationName: 'CausewayInvokeAction',
+ signal
+ });
+ } else {
+ result = await this.client.executeObjectInteraction({
+ description,
+ identity: this.identity,
+ selection: {
+ [member]: {
+ [plan.fieldName]: commandSelection(args, resultPlan.selection)
+ }
+ },
+ operationName: 'CausewayInvokeAction',
+ signal
+ });
+ const invocationValue = result.data?.[member]?.[plan.fieldName] ?? null;
+ result = {...result, data: extractActionInvocationResult(invocationValue, resultPlan)};
}
- const resultsSelection = resultsField ? resultSelectionForType(resultsField.type, description.types) : null;
- result = await this.client.executeObjectInteraction({
- description,
- identity: this.identity,
- selection: {
- [member]: {
- [invocationField.name]: commandSelection(
- argumentsFromValues(invocationField, values),
- resultsField ? {results: resultsSelection ?? true} : {target: true}
- )
- }
- },
- operationName: 'CausewayInvokeAction',
- signal
- });
- resultType = resultsField?.type ?? null;
- result = {...result, data: result.data?.[member]?.[invocationField.name]?.results ?? null};
- } else {
- const mutationType = await this.client.describeMutation({signal});
- const fieldName = mutationFieldNameFor(description, member);
- const mutationField = mutationType ? fieldsByName(mutationType).get(fieldName) : null;
- if (!mutationField) {
- return interactionResult(InteractionStatus.UNSUPPORTED, null, [commandError(`Action '${member}' does not expose an invocation mutation.`)]);
+ result = secureActionInvocationResult(result, capabilities.parameters);
+ const response = commandResponse(result, normalizeActionResult(result.data, resultPlan.resultType));
+ if (response.status === InteractionStatus.SUCCESS && plan.mutating) {
+ this.refresh();
}
- const targetArgument = targetArgumentName(mutationField, description.generatedInputTypeName);
- const args = argumentsFromValues(mutationField, values);
- args[targetArgument] = {id: this.identity.id};
- resultType = mutationField.type;
- await ensureResultTypes(this.client, description, resultType, signal);
- result = await this.client.executeMutationInteraction({
- description,
- mutationType,
- fieldName,
- args,
- resultSelection: resultSelectionForType(resultType, description.types),
- operationName: 'CausewayInvokeAction',
- signal
- });
- }
- const response = commandResponse(result, normalizeActionResult(result.data, resultType));
- if (response.status === InteractionStatus.SUCCESS) {
- this.refresh();
+ return response;
+ } catch (error) {
+ if (signal?.aborted) {
+ return interactionResult(InteractionStatus.OBSOLETE);
+ }
+ return interactionResult(InteractionStatus.FAILED, null, [commandError({
+ message: 'The object action could not be dispatched safely.',
+ extensions: {code: error?.code ?? 'ACTION_DISPATCH_FAILED'}
+ })]);
}
- return response;
- });
+ };
+ return capabilities.mutating ? this.#serializeMutation(execute) : execute();
}
async loadCollection({
@@ -942,12 +973,6 @@ function parameterInputType(wrapper) {
return null;
}
-function actionInvocationField(descriptor) {
- return ['invoke', 'invokeIdempotent', 'invokeNonIdempotent']
- .map(name => descriptor.fields.get(name))
- .find(Boolean) ?? null;
-}
-
function commandResponse(result, data) {
if (result.errors?.length) {
return interactionResult(InteractionStatus.FAILED, data, result.errors, result.operation);
diff --git a/viewers/webcomponents/foundation/src/service-action-context.mjs b/viewers/webcomponents/foundation/src/service-action-context.mjs
index 4e0cfe14dfe..f6c3fa929f0 100644
--- a/viewers/webcomponents/foundation/src/service-action-context.mjs
+++ b/viewers/webcomponents/foundation/src/service-action-context.mjs
@@ -17,6 +17,14 @@
* under the License.
*/
+import {
+ actionInvocationArguments,
+ actionInvocationResultPlan,
+ createActionInvocationPlan,
+ ensureActionInvocationResultTypes,
+ extractActionInvocationResult,
+ secureActionInvocationResult
+} from './action-dispatch.mjs';
import {fieldsByName, namedType} from './introspection.mjs';
import {argumentsFromValues, commandSelection, resultSelectionForType} from './interaction-operations.mjs';
import {InteractionResultKind, InteractionStatus} from './types.mjs';
@@ -112,7 +120,10 @@ export class ServiceActionContextController {
async prepareAction(actionId, values = {}, {signal} = {}) {
const capabilities = await this.describeActionInteraction(actionId);
if (!capabilities.invokable) {
- return interactionResult(InteractionStatus.UNSUPPORTED, {capabilities}, [commandError(`Service action '${actionId}' does not expose an invocation capability.`)]);
+ return interactionResult(
+ capabilities.planningError ? InteractionStatus.FAILED : InteractionStatus.UNSUPPORTED,
+ {capabilities},
+ [capabilities.planningError ?? commandError(`Service action '${actionId}' does not expose an invocation capability.`)]);
}
if (capabilities.parameters.length === 0) {
return interactionResult(InteractionStatus.SUCCESS, {capabilities, parameters: []});
@@ -215,56 +226,60 @@ export class ServiceActionContextController {
async invokeAction(actionId, values = {}, {signal} = {}) {
const capabilities = await this.describeActionInteraction(actionId);
+ if (!capabilities.invokable) {
+ return interactionResult(
+ capabilities.planningError ? InteractionStatus.FAILED : InteractionStatus.UNSUPPORTED,
+ null,
+ [capabilities.planningError ?? commandError(`Service action '${actionId}' does not expose an invocation capability.`)]);
+ }
const execute = async () => {
- const description = await this.describeService();
- let result;
- let resultType;
- if (capabilities.invocationField) {
- const invocationField = capabilities.invocationField;
- const invokeType = description.types.get(namedType(invocationField.type));
- const resultsField = invokeType ? fieldsByName(invokeType).get('results') : null;
- if (resultsField) {
- await ensureResultTypes(this.client, description, resultsField.type, signal);
+ try {
+ const description = await this.describeService();
+ const plan = capabilities.invocationPlan;
+ await ensureActionInvocationResultTypes(this.client, description, plan, signal);
+ const resultPlan = actionInvocationResultPlan(plan, description.types);
+ const args = actionInvocationArguments(plan, values);
+ let result;
+ if (plan.placement === 'root-mutation') {
+ const mutationType = await this.client.describeMutation({signal});
+ result = await this.client.executeMutationInteraction({
+ description,
+ mutationType,
+ fieldName: plan.mutationFieldName,
+ args,
+ resultSelection: resultPlan.selection === true ? null : resultPlan.selection,
+ operationName: 'CausewayInvokeServiceAction',
+ signal
+ });
+ } else {
+ result = await this.client.executeServiceInteraction({
+ description,
+ selection: {
+ [actionId]: {
+ [plan.fieldName]: commandSelection(args, resultPlan.selection)
+ }
+ },
+ operationName: 'CausewayInvokeServiceAction',
+ signal
+ });
+ const invocationValue = result.data?.[actionId]?.[plan.fieldName] ?? null;
+ result = {...result, data: extractActionInvocationResult(invocationValue, resultPlan)};
}
- const resultsSelection = resultsField ? resultSelectionForType(resultsField.type, description.types) : null;
- result = await this.client.executeServiceInteraction({
- description,
- selection: {
- [actionId]: {
- [invocationField.name]: commandSelection(
- argumentsFromValues(invocationField, values),
- resultsField ? {results: resultsSelection ?? true} : {target: true}
- )
- }
- },
- operationName: 'CausewayInvokeServiceAction',
- signal
- });
- resultType = resultsField?.type ?? null;
- result = {...result, data: result.data?.[actionId]?.[invocationField.name]?.results ?? null};
- } else {
- const mutationType = await this.client.describeMutation({signal});
- const mutationField = mutationType ? fieldsByName(mutationType).get(capabilities.mutationFieldName) : null;
- if (!mutationField) {
- return interactionResult(InteractionStatus.UNSUPPORTED, null, [commandError(`Service action '${actionId}' does not expose an invocation mutation.`)]);
+ result = secureActionInvocationResult(result, capabilities.parameters);
+ const response = commandResponse(result, normalizeActionResult(result.data, resultPlan.resultType));
+ if (response.status === InteractionStatus.SUCCESS && plan.mutating) {
+ this.onChanged();
}
- resultType = mutationField.type;
- await ensureResultTypes(this.client, description, resultType, signal);
- result = await this.client.executeMutationInteraction({
- description,
- mutationType,
- fieldName: capabilities.mutationFieldName,
- args: argumentsFromValues(mutationField, values),
- resultSelection: resultSelectionForType(resultType, description.types),
- operationName: 'CausewayInvokeServiceAction',
- signal
- });
- }
- const response = commandResponse(result, normalizeActionResult(result.data, resultType));
- if (response.status === InteractionStatus.SUCCESS && capabilities.mutating) {
- this.onChanged();
+ return response;
+ } catch (error) {
+ if (signal?.aborted) {
+ return interactionResult(InteractionStatus.OBSOLETE);
+ }
+ return interactionResult(InteractionStatus.FAILED, null, [commandError({
+ message: 'The service action could not be dispatched safely.',
+ extensions: {code: error?.code ?? 'ACTION_DISPATCH_FAILED'}
+ })]);
}
- return response;
};
return capabilities.mutating ? this.serializeMutation(execute) : execute();
}
@@ -300,22 +315,35 @@ export class ServiceActionContextController {
enumValues: Object.freeze(enumType?.enumValues?.map(value => value.name) ?? [])
});
});
- const invocationField = ['invoke', 'invokeIdempotent']
- .map(name => descriptor.fields.get(name))
- .find(Boolean) ?? null;
- const legacyMutatingField = descriptor.fields.get('invokeNonIdempotent') ?? null;
- const mutationType = invocationField ? null : await this.client.describeMutation();
+ const mutationType = await this.client.describeMutation();
const mutationFieldName = `${description.generatedFieldName}__${actionId}`;
- const mutationField = mutationType ? fieldsByName(mutationType).get(mutationFieldName) ?? null : null;
- const effectiveInvocationField = invocationField ?? (!mutationField ? legacyMutatingField : null);
+ let invocationPlan;
+ let planningError = null;
+ try {
+ invocationPlan = createActionInvocationPlan({
+ targetKind: 'service',
+ description,
+ descriptor,
+ mutationType,
+ mutationFieldName
+ });
+ } catch (error) {
+ invocationPlan = Object.freeze({supported: false});
+ planningError = commandError({
+ message: 'The advertised service action cannot be dispatched safely.',
+ extensions: {code: error?.code ?? 'ACTION_DISPATCH_PLAN_FAILED'}
+ });
+ }
return Object.freeze({
descriptor,
parameters: Object.freeze(parameters),
validate: Boolean(validateField),
- invocationField: effectiveInvocationField,
- mutationFieldName: mutationField ? mutationFieldName : null,
- mutating: Boolean(mutationField || legacyMutatingField && !invocationField),
- invokable: Boolean(effectiveInvocationField || mutationField)
+ invocationPlan,
+ invocationField: invocationPlan.placement === 'root-mutation' ? null : invocationPlan.field ?? null,
+ mutationFieldName: invocationPlan.mutationFieldName ?? null,
+ mutating: invocationPlan.mutating ?? false,
+ planningError,
+ invokable: invocationPlan.supported === true
});
}
diff --git a/viewers/webcomponents/foundation/test/action-dispatch.test.mjs b/viewers/webcomponents/foundation/test/action-dispatch.test.mjs
new file mode 100644
index 00000000000..04fc0dd0499
--- /dev/null
+++ b/viewers/webcomponents/foundation/test/action-dispatch.test.mjs
@@ -0,0 +1,218 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ ActionDispatchPlanError,
+ ActionInvocationPlacement,
+ actionInvocationArguments,
+ actionInvocationResultPlan,
+ createActionInvocationPlan,
+ extractActionInvocationResult,
+ secureActionInvocationResult
+} from '../src/action-dispatch.mjs';
+import {resultSelectionForType} from '../src/interaction-operations.mjs';
+
+const scalar = name => ({kind: 'SCALAR', name, ofType: null});
+const object = name => ({kind: 'OBJECT', name, ofType: null});
+const nonNull = type => ({kind: 'NON_NULL', name: null, ofType: type});
+const argument = (name, type) => ({name, type});
+const field = (name, type, args = []) => ({name, type, args});
+const type = (name, fields, kind = 'OBJECT') => ({name, kind, fields, enumValues: [], possibleTypes: []});
+const descriptor = fields => ({fields: new Map(fields.map(candidate => [candidate.name, candidate]))});
+const description = (types = new Map()) => ({generatedInputTypeName: 'rich__sample_Target__gqlv_input', types});
+
+function mutationType(...fields) {
+ return type('Mutation', fields);
+}
+
+test('safe nested invocation takes its advertised query placement', () => {
+ const invoke = field('invokeIdempotent', object('SafeEnvelope'), [argument('name', scalar('String'))]);
+ const mutation = field('sample_Target__greet', scalar('String'), [argument('_target', object('rich__sample_Target__gqlv_input'))]);
+ const plan = createActionInvocationPlan({
+ targetKind: 'object',
+ description: description(),
+ descriptor: descriptor([invoke]),
+ mutationType: mutationType(mutation),
+ mutationFieldName: mutation.name
+ });
+
+ assert.equal(plan.placement, ActionInvocationPlacement.NESTED_QUERY);
+ assert.equal(plan.fieldName, 'invokeIdempotent');
+ assert.equal(plan.mutating, false);
+ assert.equal(plan.targetArgumentName, null);
+ assert.deepEqual(actionInvocationArguments(plan, {name: 'Ada', ignored: 'not-advertised'}, {id: 'target-1'}), {name: 'Ada'});
+});
+
+test('top-level mutation is preferred over legacy nested mutation', () => {
+ const legacy = field('invokeNonIdempotent', object('LegacyEnvelope'));
+ const mutation = field('sample_Target__update', object('rich__sample_Target'), [
+ argument('_target', nonNull(object('rich__sample_Target__gqlv_input'))),
+ argument('value', nonNull(scalar('String')))
+ ]);
+ const plan = createActionInvocationPlan({
+ targetKind: 'object',
+ description: description(),
+ descriptor: descriptor([legacy]),
+ mutationType: mutationType(mutation),
+ mutationFieldName: mutation.name
+ });
+
+ assert.equal(plan.placement, ActionInvocationPlacement.ROOT_MUTATION);
+ assert.equal(plan.fieldName, mutation.name);
+ assert.equal(plan.targetArgumentName, '_target');
+ assert.deepEqual(actionInvocationArguments(plan, {value: 'updated', ignored: 'not-advertised'}, {id: 'target-1'}), {
+ _target: {id: 'target-1'},
+ value: 'updated'
+ });
+});
+
+test('service mutation never manufactures an object target', () => {
+ const mutation = field('sample_Service__clear', scalar('String'));
+ const plan = createActionInvocationPlan({
+ targetKind: 'service',
+ description: description(),
+ descriptor: descriptor([]),
+ mutationType: mutationType(mutation),
+ mutationFieldName: mutation.name
+ });
+
+ assert.equal(plan.placement, ActionInvocationPlacement.ROOT_MUTATION);
+ assert.deepEqual(actionInvocationArguments(plan, {}, {id: 'must-not-be-used'}), {});
+});
+
+test('legacy nested mutation remains the bounded fallback without a root mutation', () => {
+ const legacy = field('invokeNonIdempotent', object('LegacyEnvelope'));
+ const plan = createActionInvocationPlan({
+ targetKind: 'service',
+ description: description(),
+ descriptor: descriptor([legacy]),
+ mutationType: mutationType(),
+ mutationFieldName: 'sample_Service__clear'
+ });
+
+ assert.equal(plan.placement, ActionInvocationPlacement.LEGACY_NESTED_MUTATION);
+ assert.equal(plan.mutating, true);
+});
+
+test('missing executable capability produces an unsupported immutable plan', () => {
+ const plan = createActionInvocationPlan({
+ targetKind: 'service',
+ description: description(),
+ descriptor: descriptor([]),
+ mutationType: mutationType(),
+ mutationFieldName: 'sample_Service__missing'
+ });
+
+ assert.equal(plan.supported, false);
+ assert.equal(Object.isFrozen(plan), true);
+ assert.throws(() => actionInvocationArguments(plan), ActionDispatchPlanError);
+});
+
+test('required arguments and object targets fail before operation rendering', () => {
+ const mutation = field('sample_Target__update', object('rich__sample_Target'), [
+ argument('_target', nonNull(object('rich__sample_Target__gqlv_input'))),
+ argument('secret', nonNull(scalar('Password')))
+ ]);
+ const plan = createActionInvocationPlan({
+ targetKind: 'object',
+ description: description(),
+ descriptor: descriptor([]),
+ mutationType: mutationType(mutation),
+ mutationFieldName: mutation.name
+ });
+
+ assert.throws(
+ () => actionInvocationArguments(plan, {}, {id: 'target-1'}),
+ error => error.code === 'ACTION_REQUIRED_ARGUMENT_UNAVAILABLE' && !error.message.includes('submitted-secret'));
+ assert.throws(
+ () => actionInvocationArguments(plan, {secret: 'submitted-secret'}),
+ error => error.code === 'ACTION_TARGET_IDENTITY_UNAVAILABLE' && !error.message.includes('submitted-secret'));
+});
+
+test('enveloped and direct action results use their described shapes', () => {
+ const targetRef = object('rich__sample_Target');
+ const envelopeRef = object('SafeEnvelope');
+ const types = new Map([
+ ['SafeEnvelope', type('SafeEnvelope', [field('target', targetRef), field('results', targetRef)])],
+ ['rich__sample_Target', type('rich__sample_Target', [field('_meta', object('rich__sample_Target__gqlv_meta'))])],
+ ['rich__sample_Target__gqlv_meta', type('rich__sample_Target__gqlv_meta', [
+ field('id', scalar('ID')),
+ field('logicalTypeName', scalar('String')),
+ field('title', scalar('String'))
+ ])]
+ ]);
+ const envelopePlan = createActionInvocationPlan({
+ targetKind: 'service',
+ description: description(types),
+ descriptor: descriptor([field('invoke', envelopeRef)]),
+ mutationType: mutationType(),
+ mutationFieldName: 'sample_Service__open'
+ });
+ const envelopeResult = actionInvocationResultPlan(envelopePlan, types);
+
+ assert.deepEqual(envelopeResult.selection, {results: {_meta: {id: true, logicalTypeName: true, title: true}}});
+ assert.deepEqual(extractActionInvocationResult({target: {ignored: true}, results: {_meta: {id: '1'}}}, envelopeResult), {_meta: {id: '1'}});
+
+ const directPlan = createActionInvocationPlan({
+ targetKind: 'service',
+ description: description(types),
+ descriptor: descriptor([]),
+ mutationType: mutationType(field('sample_Service__open', targetRef)),
+ mutationFieldName: 'sample_Service__open'
+ });
+ const directResult = actionInvocationResultPlan(directPlan, types);
+ assert.deepEqual(directResult.selection, {_meta: {id: true, logicalTypeName: true, title: true}});
+ assert.deepEqual(directResult.extractionPath, []);
+});
+
+test('protected action results redact errors and operation variables', () => {
+ const result = {
+ data: 'submitted-secret',
+ errors: [{message: 'Rejected submitted-secret', path: ['changePassword'], extensions: {classification: 'BAD_REQUEST'}}],
+ operation: {document: 'mutation Change($input0: Password!) { change(password: $input0) }', variables: {input0: 'submitted-secret'}}
+ };
+
+ const secured = secureActionInvocationResult(result, [{inputType: scalar('Password')}]);
+
+ assert.equal(secured.errors[0].message, 'Protected action input was not accepted.');
+ assert.equal(secured.operation.variables.input0, '<redacted>');
+ assert.equal(JSON.stringify(secured).includes('submitted-secret'), false);
+ assert.equal(secured.operation.document.includes('Password'), true);
+});
+
+test('metadata selection requests only fields advertised by the effective type', () => {
+ const targetRef = object('rich__sample_VersionlessViewModel');
+ const types = new Map([
+ ['rich__sample_VersionlessViewModel', type('rich__sample_VersionlessViewModel', [
+ field('_meta', object('rich__sample_VersionlessViewModel__gqlv_meta'))
+ ])],
+ ['rich__sample_VersionlessViewModel__gqlv_meta', type('rich__sample_VersionlessViewModel__gqlv_meta', [
+ field('id', scalar('ID')),
+ field('logicalTypeName', scalar('String')),
+ field('title', scalar('String'))
+ ])]
+ ]);
+
+ const selection = resultSelectionForType(targetRef, types);
+ assert.deepEqual(selection, {_meta: {id: true, logicalTypeName: true, title: true}});
+ assert.equal(JSON.stringify(selection).includes('version'), false);
+});
diff --git a/viewers/webcomponents/foundation/test/application-menu-client.test.mjs b/viewers/webcomponents/foundation/test/application-menu-client.test.mjs
index 1cc03bf4de9..83d5dc896c5 100644
--- a/viewers/webcomponents/foundation/test/application-menu-client.test.mjs
+++ b/viewers/webcomponents/foundation/test/application-menu-client.test.mjs
@@ -170,6 +170,24 @@ test('mutating service actions use the existing top-level mutation without a man
assert.equal(changed, 1);
});
+test('parameterless service mutation returns versionless object metadata without requesting version or target', async () => {
+ const executor = createMenuGraphQLExecutor();
+ const client = new CausewayGraphQLClient({executor});
+ const context = new ServiceActionContextController({client, logicalTypeName: SAMPLE_SERVICE_LOGICAL_TYPE});
+
+ const invoked = await context.invokeAction('openView', {});
+
+ assert.equal(invoked.status, InteractionStatus.SUCCESS);
+ assert.deepEqual(invoked.data, {kind: 'object', value: {_meta: {
+ id: 'view-1', logicalTypeName: 'sample.VersionlessViewModel', title: 'View one'
+ }}});
+ const operation = executor.mutationCalls.at(-1);
+ assert.match(operation.document, /^mutation CausewayInvokeServiceAction \{/);
+ assert.match(operation.document, /_meta \{\s+id\s+logicalTypeName\s+title\s+\}/);
+ assert.doesNotMatch(operation.document, /version|target|_target|\$object/);
+ assert.deepEqual(operation.variables, {});
+});
+
test('mutating service actions delegate to application-scope serialization', async () => {
const executor = createMenuGraphQLExecutor();
let tail = Promise.resolve();
diff --git a/viewers/webcomponents/foundation/test/fixtures/menu-graphql-fixture.mjs b/viewers/webcomponents/foundation/test/fixtures/menu-graphql-fixture.mjs
index 2c389467269..3c0b63f4a59 100644
--- a/viewers/webcomponents/foundation/test/fixtures/menu-graphql-fixture.mjs
+++ b/viewers/webcomponents/foundation/test/fixtures/menu-graphql-fixture.mjs
@@ -58,7 +58,8 @@ export function createMenuGraphQLTypes({menuBarsAvailable = true} = {}) {
field('greet', named(`${SAMPLE_SERVICE_TYPE}__greet__gqlv_action`)),
field('disabledAction', named(`${SAMPLE_SERVICE_TYPE}__disabledAction__gqlv_action`)),
field('hiddenAction', named(`${SAMPLE_SERVICE_TYPE}__hiddenAction__gqlv_action`)),
- field('clearNotes', named(`${SAMPLE_SERVICE_TYPE}__clearNotes__gqlv_action`))
+ field('clearNotes', named(`${SAMPLE_SERVICE_TYPE}__clearNotes__gqlv_action`)),
+ field('openView', named(`${SAMPLE_SERVICE_TYPE}__openView__gqlv_action`))
])],
[`${SAMPLE_SERVICE_TYPE}__welcomeMessage__gqlv_action`, actionType(
`${SAMPLE_SERVICE_TYPE}__welcomeMessage__gqlv_action`,
@@ -109,8 +110,24 @@ export function createMenuGraphQLTypes({menuBarsAvailable = true} = {}) {
[`${SAMPLE_SERVICE_TYPE}__clearNotes__gqlv_action_invoke`, invokeType(
`${SAMPLE_SERVICE_TYPE}__clearNotes__gqlv_action_invoke`, scalar('String')
)],
+ [`${SAMPLE_SERVICE_TYPE}__openView__gqlv_action`, objectType(
+ `${SAMPLE_SERVICE_TYPE}__openView__gqlv_action`, [
+ field('hidden', scalar('Boolean')),
+ field('disabled', scalar('String')),
+ field('validate', scalar('String'))
+ ]
+ )],
+ ['rich__sample_VersionlessViewModel', objectType('rich__sample_VersionlessViewModel', [
+ field('_meta', named('rich__sample_VersionlessViewModel__gqlv_meta'))
+ ])],
+ ['rich__sample_VersionlessViewModel__gqlv_meta', objectType('rich__sample_VersionlessViewModel__gqlv_meta', [
+ field('id', scalar('ID')),
+ field('logicalTypeName', scalar('String')),
+ field('title', scalar('String'))
+ ])],
['Mutation', objectType('Mutation', [
- field(`${SAMPLE_SERVICE_FIELD}__clearNotes`, scalar('String'))
+ field(`${SAMPLE_SERVICE_FIELD}__clearNotes`, scalar('String')),
+ field(`${SAMPLE_SERVICE_FIELD}__openView`, named('rich__sample_VersionlessViewModel'))
])]
]);
}
@@ -180,6 +197,11 @@ export function createMenuGraphQLExecutor({
if (request.operationName === 'CausewayInvokeServiceAction') {
if (request.document.startsWith('mutation')) {
mutationCalls.push(request);
+ if (request.document.includes(`${SAMPLE_SERVICE_FIELD}__openView`)) {
+ return {data: {[`${SAMPLE_SERVICE_FIELD}__openView`]: {_meta: {
+ id: 'view-1', logicalTypeName: 'sample.VersionlessViewModel', title: 'View one'
+ }}}};
+ }
return {data: {[`${SAMPLE_SERVICE_FIELD}__clearNotes`]: 'Cleared'}};
}
serviceCalls.push(request);