diff --git a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/form/ReservedProperties.java b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/form/ReservedProperties.java index b8e7a7b723..bd9ee8f4b7 100644 --- a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/form/ReservedProperties.java +++ b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/form/ReservedProperties.java @@ -177,6 +177,7 @@ private ReservedProperties() { public static final String FD_DRAFT_ID = "fd:draftId"; public static final String FD_CHANGE_EVENT_BEHAVIOUR = "fd:changeEventBehaviour"; public static final String FD_SET_PROPERTY_BEHAVIOUR = "fd:setPropertyBehaviour"; + public static final String FD_WEB_MCP_ENABLED = "fd:webMcpEnabled"; public static final String PN_CQ_ANNOTATIONS = "cq:annotations"; diff --git a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/models/v2/form/FormContainerImpl.java b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/models/v2/form/FormContainerImpl.java index 67b2025fa5..e1437bd7d3 100644 --- a/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/models/v2/form/FormContainerImpl.java +++ b/bundles/af-core/src/main/java/com/adobe/cq/forms/core/components/internal/models/v2/form/FormContainerImpl.java @@ -154,6 +154,10 @@ public class FormContainerImpl extends AbstractContainerImpl implements FormCont @Nullable private String setPropertyBehaviour; + @ValueMapValue(name = ReservedProperties.FD_WEB_MCP_ENABLED, injectionStrategy = InjectionStrategy.OPTIONAL) + @Nullable + private Boolean webMcpEnabled; + @ValueMapValue(injectionStrategy = InjectionStrategy.OPTIONAL, name = ReservedProperties.PN_DATA) @Nullable private String data; @@ -437,6 +441,9 @@ public String getLanguageDirection() { if (StringUtils.isNotBlank(setPropertyBehaviour)) { properties.put(ReservedProperties.FD_SET_PROPERTY_BEHAVIOUR, setPropertyBehaviour); } + if (webMcpEnabled != null) { + properties.put(ReservedProperties.FD_WEB_MCP_ENABLED, webMcpEnabled); + } properties.put(FD_FORM_DATA_ENABLED, formDataEnabled); if (this.autoSaveConfig != null && this.autoSaveConfig.isEnableAutoSave()) { properties.put(ReservedProperties.FD_AUTO_SAVE_PROPERTY_WRAPPER, this.autoSaveConfig); diff --git a/bundles/af-core/src/test/java/com/adobe/cq/forms/core/components/internal/models/v2/form/FormContainerImplTest.java b/bundles/af-core/src/test/java/com/adobe/cq/forms/core/components/internal/models/v2/form/FormContainerImplTest.java index 0be68a8040..08bf087560 100644 --- a/bundles/af-core/src/test/java/com/adobe/cq/forms/core/components/internal/models/v2/form/FormContainerImplTest.java +++ b/bundles/af-core/src/test/java/com/adobe/cq/forms/core/components/internal/models/v2/form/FormContainerImplTest.java @@ -693,6 +693,46 @@ void testSetPropertyBehaviourAbsentWhenNotAuthored() throws Exception { assertNull(formContainer.getProperties().get(ReservedProperties.FD_SET_PROPERTY_BEHAVIOUR)); } + @Test + void testWebMcpEnabledAbsentWhenNotAuthored() throws Exception { + FormContainer formContainer = Utils.getComponentUnderTest(PATH_FORM_1, FormContainer.class, context); + assertFalse(formContainer.getProperties().containsKey(ReservedProperties.FD_WEB_MCP_ENABLED)); + JsonNode properties = new ObjectMapper().readTree(Utils.getJson(formContainer, Views.Publish.class)).get("properties"); + assertFalse(properties.has(ReservedProperties.FD_WEB_MCP_ENABLED)); + } + + @Test + void testWebMcpEnabledFromNode() throws Exception { + assertWebMcpEnabledExport(true, true); + } + + @Test + void testWebMcpDisabledFromNode() throws Exception { + assertWebMcpEnabledExport(false, false); + } + + @Test + void testWebMcpEnabledFromStringNode() throws Exception { + assertWebMcpEnabledExport("true", true); + } + + @Test + void testWebMcpDisabledFromStringNode() throws Exception { + assertWebMcpEnabledExport("false", false); + } + + private void assertWebMcpEnabledExport(Object authoredValue, boolean enabled) throws Exception { + Resource resource = context.resourceResolver().getResource(PATH_FORM_1); + resource.adaptTo(ModifiableValueMap.class).put(ReservedProperties.FD_WEB_MCP_ENABLED, authoredValue); + FormContainer formContainer = Utils.getComponentUnderTest(PATH_FORM_1, FormContainer.class, context); + assertEquals(enabled, formContainer.getProperties().get(ReservedProperties.FD_WEB_MCP_ENABLED)); + JsonNode property = new ObjectMapper().readTree(Utils.getJson(formContainer, Views.Publish.class)) + .get("properties").get(ReservedProperties.FD_WEB_MCP_ENABLED); + assertNotNull(property); + assertTrue(property.isBoolean()); + assertEquals(enabled, property.booleanValue()); + } + @Test void testCustomFunctionUrl() throws Exception { FormContainer formContainer = Utils.getComponentUnderTest(PATH_FORM_1, FormContainer.class, context); diff --git a/docs/e2e-testing/feature-toggles.md b/docs/e2e-testing/feature-toggles.md index 641d26b5f0..d04e6a2649 100644 --- a/docs/e2e-testing/feature-toggles.md +++ b/docs/e2e-testing/feature-toggles.md @@ -37,7 +37,7 @@ it/config/src/main/content/jcr_root/apps/system/config/ com.adobe.granite.toggle.impl.dev.DynamicToggleProviderImpl.cfg.json ``` -Add the toggle name to the `enabled` array in that file to turn it on in IT. +Add the toggle name to the `enabledToggles` array in that file to turn it on in IT. ### Layer 2 — JVM System Property (server-side) @@ -77,7 +77,7 @@ When adding a new toggle, the only config file that requires an update is: | File | Change | |------|--------| -| `com.adobe.granite.toggle.impl.dev.DynamicToggleProviderImpl.cfg.json` | Add toggle name to `enabled` array | +| `com.adobe.granite.toggle.impl.dev.DynamicToggleProviderImpl.cfg.json` | Add toggle name to `enabledToggles` array | --- @@ -178,6 +178,11 @@ it/content/src/main/content/jcr_root/content/forms/af/core-components-it/samples |------|----------------| | `ui.tests/test-module/specs/fragment/fragment.featuretoggles.cy.js` | FT_FORMS-24087, FT_FORMS-24343, FT_FORMS-24358 | | `ui.tests/test-module/specs/panelcontainer/panelcontainer.featuretoggles.cy.js` | FT_FORMS-24358 | +| `ui.tests/test-module/specs/webmcp/webmcp.authoring.cy.js` | FT_FORMS-28233 (latest addon) | + +The enabled WebMCP authoring suite runs on the latest addon, uses the existing accessibility sample, enables **Enable AI assistant access (WebMCP)** in the container dialog, and opens the saved form in preview. A preview-scoped response interceptor installs a test-only `document.modelContext` host before runtime scripts load; the real runtime must register `list_forms` plus all 12 form tools and return the accessibility form's field summary. Authenticated browser cleanup restores the original opt-in property and its type, or deletes it when originally absent. + +Running the authoring flow requires matching Java models and standard/XFA runtime clientlibs, IT content, the examples page component package, and the IT toggle configuration. The instance must have the development toggle provider installed and expose `FT_FORMS-28233` in `/etc.clientlibs/toggles.json`; configuring `enabledToggles` without an active provider does not enable the flag. When the flag is absent, including in without-FT jobs, the suite is reported as skipped without changing the opt-in property. With the flag enabled, the full authoring and preview assertions run. --- @@ -219,7 +224,7 @@ curl -s -u admin:admin "http://localhost:4502/content/forms/af/core-components-i 1. **Define the constant** — add `public static final String FT_FORMS_XXXXX = "FT_FORMS-XXXXX";` to `FeatureToggleConstants.java`. -2. **Enable in Granite config** — add `"FT_FORMS-XXXXX"` to the `enabled` array in `DynamicToggleProviderImpl.cfg.json`. +2. **Enable in Granite config** — add `"FT_FORMS-XXXXX"` to the `enabledToggles` array in `DynamicToggleProviderImpl.cfg.json`. 3. **Provision test content** — decide whether to reuse an existing IT sample page or create a new one under `it/content/.../samples/`. Create new JCR content only when the toggled behavior requires it. diff --git a/it/config/src/main/content/jcr_root/apps/system/config/com.adobe.granite.toggle.impl.dev.DynamicToggleProviderImpl.cfg.json b/it/config/src/main/content/jcr_root/apps/system/config/com.adobe.granite.toggle.impl.dev.DynamicToggleProviderImpl.cfg.json index 4b8c218bf4..f7b302323f 100644 --- a/it/config/src/main/content/jcr_root/apps/system/config/com.adobe.granite.toggle.impl.dev.DynamicToggleProviderImpl.cfg.json +++ b/it/config/src/main/content/jcr_root/apps/system/config/com.adobe.granite.toggle.impl.dev.DynamicToggleProviderImpl.cfg.json @@ -28,7 +28,8 @@ "FT_FORMS-24087", "FT_FORMS-24343", "FT_FORMS-25252", - "FT_FORMS-27648" + "FT_FORMS-27648", + "FT_FORMS-28233" ], "disabledToggles": [ ] diff --git a/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/_cq_dialog/.content.xml b/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/_cq_dialog/.content.xml index 7ce0ab65c4..2636bc1e13 100644 --- a/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/_cq_dialog/.content.xml +++ b/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/container/v2/container/_cq_dialog/.content.xml @@ -92,6 +92,19 @@ text="Enable the hamburger menu for mobile view" fieldDescription="Select the option to display the hamburger menu for efficient navigation on mobile devices." value="{Boolean}true"/> + + + ({registerFormWebMCP: jest.fn()})); + import {Constants} from "../src/index"; import FormContainer from "../src/view/FormContainer"; import formJson from './resources/form.json'; @@ -53,14 +55,18 @@ test('GuideBridge test', () => { const resetButton = resultModel.getElement("reset-c7cccb1a5e"); expect(resetButton).not.toBeNull(); expect(resetButton.visible).toBeFalsy(); + guideBridge.unloadAdaptiveForm("/a/b/c"); + expect(unregisterWebMcp).toHaveBeenCalledTimes(1); }}); }); + const unregisterWebMcp = jest.fn(); var formContainer = new FormContainer({ _formJson: formJson, _prefillData: {data: {"textinput1662370110841" : "initialData"}}, _path: "/a/b/c" }); + formContainer._setWebMcpUnregister(unregisterWebMcp); const event = new CustomEvent(Constants.FORM_CONTAINER_INITIALISED, { "detail": formContainer }); document.dispatchEvent(event); }); diff --git a/ui.frontend/__tests__/webmcp.integration.test.js b/ui.frontend/__tests__/webmcp.integration.test.js new file mode 100644 index 0000000000..53bb0ac15f --- /dev/null +++ b/ui.frontend/__tests__/webmcp.integration.test.js @@ -0,0 +1,91 @@ +/******************************************************************************* + * Copyright 2026 Adobe + * + * Licensed 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 + * + * http://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 {createFormInstance} from '@aemforms/af-core'; +import {registerFormWebMCP} from '@aemforms/af-webmcp'; +import Utils from '../src/utils'; + +describe('published WebMCP runtime integration', () => { + let unregister; + let tools; + let handles; + let host; + let rum; + + beforeEach(() => { + tools = new Map(); + handles = []; + host = {registerTool: jest.fn(tool => { + tools.set(tool.name, tool); + const handle = {unregister: jest.fn(() => tools.delete(tool.name))}; + handles.push(handle); + return handle; + })}; + document.modelContext = host; + rum = jest.fn(); + window.hlx = {sampleRUM: rum}; + }); + + afterEach(() => { + if (unregister) { unregister(); } + unregister = undefined; + delete document.modelContext; + delete window.hlx; + document.body.innerHTML = ''; + }); + + function form(enabled) { + return createFormInstance({ + id: 'published-runtime', + properties: {'fd:webMcpEnabled': enabled}, + items: [{id: 'name', name: 'name', type: 'string', fieldType: 'text-input', value: 'before'}] + }); + } + + test('registers actual tools, changes values, bridges DOM focus, and cleans up', async () => { + const model = form(true); + const input = document.createElement('input'); + document.body.appendChild(input); + const view = {setFocus: jest.fn(() => input.focus())}; + unregister = registerFormWebMCP(model, Utils.getWebMcpOptions(view)); + + expect(tools.get('validate_form_completeness').annotations) + .toEqual(expect.objectContaining({readOnlyHint: true, untrustedContentHint: true})); + expect(await tools.get('set_field_value').execute({field: 'name', value: 'after'})) + .toEqual(expect.objectContaining({success: true, changed: true})); + expect(model.getElement('name').value).toBe('after'); + expect(rum).toHaveBeenCalledWith('fill', {source: 'af-webmcp', target: 'set_field_value'}); + rum.mockClear(); + await tools.get('set_field_value').execute({field: 'name', value: 'after'}); + expect(rum).not.toHaveBeenCalled(); + + expect((await tools.get('focus_field').execute({field: 'name'})).success).toBe(true); + expect(view.setFocus).toHaveBeenCalledWith('name'); + expect(document.activeElement).toBe(input); + expect(rum).toHaveBeenCalledWith('click', {source: 'af-webmcp', target: 'focus_field'}); + + unregister(); + expect(tools.size).toBe(0); + handles.forEach(handle => expect(handle.unregister).toHaveBeenCalledTimes(1)); + unregister = undefined; + }); + + test('does not expose tools for a form that did not opt in', () => { + unregister = registerFormWebMCP(form(false)); + expect(host.registerTool).not.toHaveBeenCalled(); + expect(tools.size).toBe(0); + }); +}); diff --git a/ui.frontend/__tests__/webmcp.test.js b/ui.frontend/__tests__/webmcp.test.js new file mode 100644 index 0000000000..6a59a947ca --- /dev/null +++ b/ui.frontend/__tests__/webmcp.test.js @@ -0,0 +1,164 @@ +/******************************************************************************* + * Copyright 2022 Adobe + * + * Licensed 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 + * + * http://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. + ******************************************************************************/ + +// Adapter behavior lives in af-webmcp's suite; these tests cover bootstrap and scoped forwarding. +jest.mock('@aemforms/af-webmcp', () => ({registerFormWebMCP: jest.fn()})); +jest.mock('../src/HTTPAPILayer.js', () => ({ + __esModule: true, + default: {getFormDefinition: jest.fn(), getJson: jest.fn()} +})); +jest.mock('../src/RuleUtils.js', () => ({ + __esModule: true, + default: {registerCustomFunctionsV2: jest.fn(), registerCustomFunctionsByUrl: jest.fn()} +})); + +import Utils from '../src/utils'; +import FormContainer from '../src/view/FormContainer'; +import {registerFormWebMCP} from '@aemforms/af-webmcp'; +import HTTPAPILayer from '../src/HTTPAPILayer.js'; +import formJson from './resources/form.json'; +import GuideBridge from '../src/GuideBridge'; +import {Constants} from '../src/constants'; + +afterEach(() => { + document.body.innerHTML = ''; + jest.clearAllMocks(); +}); + +test('setupFormContainer registers the WebMCP catalog once with the form model', async () => { + HTTPAPILayer.getFormDefinition.mockResolvedValue(formJson); + const unregister = jest.fn(); + registerFormWebMCP.mockReturnValue(unregister); + + const el = document.createElement('div'); + el.classList.add('cmp-adaptiveform-container'); + el.dataset.cmpPath = '/content/a/b/c'; + document.body.appendChild(el); + + let container; + const createFormContainer = (params) => { + container = new FormContainer(params); + return container; + }; + + await Utils.setupFormContainer(createFormContainer, '.cmp-adaptiveform-container', 'adaptiveFormContainer'); + + expect(registerFormWebMCP).toHaveBeenCalledTimes(1); + expect(registerFormWebMCP).toHaveBeenCalledWith(container.getModel(), {onFocusRequest: expect.any(Function)}); + expect(container.getModel().id).toBe('/content/a/b/c'); + container._unregisterWebMcp(); + expect(unregister).toHaveBeenCalledTimes(1); +}); + +test('setupFormContainer preserves pre-initialization and connect callback tools', async () => { + HTTPAPILayer.getFormDefinition.mockResolvedValue(formJson); + const unregister = jest.fn(); + const additionalTools = jest.fn(); + const bridge = new GuideBridge(); + const path = '/content/a/b/domain-form'; + bridge.registerWebMcpAdditionalTools(path, additionalTools); + const replacement = jest.fn(); + bridge.connect(() => bridge.registerWebMcpAdditionalTools(path, replacement), null, path); + registerFormWebMCP.mockReturnValue(unregister); + + const el = document.createElement('div'); + el.classList.add('cmp-adaptiveform-container'); + el.dataset.cmpPath = '/content/a/b/domain-form'; + document.body.appendChild(el); + + let container; + const createFormContainer = (params) => { + container = new FormContainer(params); + return container; + }; + await Utils.setupFormContainer(createFormContainer, '.cmp-adaptiveform-container', 'adaptiveFormContainer'); + expect(registerFormWebMCP).toHaveBeenCalledWith(container.getModel(), {additionalTools, onFocusRequest: expect.any(Function)}); + expect(registerFormWebMCP).toHaveBeenLastCalledWith(container.getModel(), {additionalTools: replacement, onFocusRequest: expect.any(Function)}); + bridge.unloadAdaptiveForm(path); +}); + +test('explicit paths isolate two forms and stale disposers cannot remove replacements', () => { + const bridge = new GuideBridge(); + const first = new FormContainer({_formJson: formJson, _prefillData: {}, _path: '/first', _element: document.createElement('div')}); + const second = new FormContainer({_formJson: formJson, _prefillData: {}, _path: '/second', _element: document.createElement('div')}); + const factory = jest.fn(); + const replacement = jest.fn(); + const dispose = bridge.registerWebMcpAdditionalTools('/first', factory); + [first, second].forEach(container => { + container._setWebMcpUnregister(jest.fn()); + document.dispatchEvent(new CustomEvent(Constants.FORM_CONTAINER_INITIALISED, {detail: container})); + }); + expect(registerFormWebMCP).toHaveBeenCalledTimes(1); + expect(registerFormWebMCP).toHaveBeenCalledWith(first.getModel(), {additionalTools: factory, onFocusRequest: expect.any(Function)}); + const remove = bridge.registerWebMcpAdditionalTools('/first', replacement); + dispose(); + expect(registerFormWebMCP).toHaveBeenCalledTimes(2); + remove(); + expect(registerFormWebMCP).toHaveBeenLastCalledWith(first.getModel(), {onFocusRequest: expect.any(Function)}); + bridge.registerWebMcpAdditionalTools('/second', factory); + const unregister = jest.fn(); + second._setWebMcpUnregister(unregister); + bridge.unloadAdaptiveForm('/second'); + expect(unregister).toHaveBeenCalledTimes(1); + bridge.unloadAdaptiveForm('/first'); + registerFormWebMCP.mockClear(); + document.dispatchEvent(new CustomEvent(Constants.FORM_CONTAINER_INITIALISED, {detail: second})); + expect(registerFormWebMCP).not.toHaveBeenCalled(); + bridge.unloadAdaptiveForm('/second'); +}); + +test('registration rejects invalid factories and missing explicit targets before changing tools', () => { + const bridge = new GuideBridge(); + expect(() => bridge.registerWebMcpAdditionalTools('', jest.fn())).toThrow(TypeError); + expect(() => bridge.registerWebMcpAdditionalTools('/first', {})).toThrow(TypeError); + expect(() => bridge.registerWebMcpAdditionalTools('/first', undefined)).toThrow(TypeError); + expect(registerFormWebMCP).not.toHaveBeenCalled(); +}); + +test('renderer focus options restore focus through the owning container and detect no-ops', () => { + const input = document.createElement('input'); + const outside = document.createElement('button'); + document.body.append(input, outside); + const first = {setFocus: jest.fn(() => input.focus())}; + const options = Utils.getWebMcpOptions(first); + expect(options.onFocusRequest('city')).toBe(true); + expect(document.activeElement).toBe(input); + expect(options.onFocusRequest('city')).toBe(false); + outside.focus(); + expect(options.onFocusRequest('city')).toBe(true); + expect(document.activeElement).toBe(input); + expect(first.setFocus).toHaveBeenCalledWith('city'); +}); + +test('scoped configuration preserves the adapter opt-out gate and does not invoke the factory itself', () => { + const bridge = new GuideBridge(); + const factory = jest.fn(); + const optedOutJson = {...formJson, properties: {...formJson.properties, 'fd:webMcpEnabled': false}}; + const view = new FormContainer({ + _formJson: optedOutJson, _prefillData: {}, _path: '/opted-out', _element: document.createElement('div') + }); + registerFormWebMCP.mockImplementation((model, options) => { + if (model.properties['fd:webMcpEnabled'] !== true) { return undefined; } + options.additionalTools(model); + return jest.fn(); + }); + bridge.registerWebMcpAdditionalTools('/opted-out', factory); + document.dispatchEvent(new CustomEvent(Constants.FORM_CONTAINER_INITIALISED, {detail: view})); + expect(factory).not.toHaveBeenCalled(); + expect(view._webMcpUnregister).toBeNull(); + bridge.unloadAdaptiveForm('/opted-out'); + registerFormWebMCP.mockReset(); +}); diff --git a/ui.frontend/jest.config.js b/ui.frontend/jest.config.js index 0795e79e53..69992d5600 100644 --- a/ui.frontend/jest.config.js +++ b/ui.frontend/jest.config.js @@ -14,7 +14,7 @@ * limitations under the License. ******************************************************************************/ -const esModules = ['@adobe/json-formula', '@aemforms/af-custom-functions'].join('|'); +const esModules = ['@adobe/json-formula', '@aemforms/af-custom-functions', '@aemforms/af-webmcp'].join('|'); module.exports = { transformIgnorePatterns: [`node_modules/(?!${esModules})`], diff --git a/ui.frontend/package-lock.json b/ui.frontend/package-lock.json index 40bf8a7f1f..964dbef627 100644 --- a/ui.frontend/package-lock.json +++ b/ui.frontend/package-lock.json @@ -9,10 +9,11 @@ "version": "1.0.0", "license": "Apache-2.0", "dependencies": { - "@aemforms/af-core": "^1.0.4", + "@aemforms/af-core": "^1.0.7", "@aemforms/af-core-xfa": "^0.1.6", "@aemforms/af-custom-functions": "1.0.18", - "@aemforms/af-formatters": "^1.0.4" + "@aemforms/af-formatters": "^1.0.7", + "@aemforms/af-webmcp": "^1.0.7" }, "devDependencies": { "@babel/preset-env": "^7.18.2", @@ -108,13 +109,13 @@ } }, "node_modules/@aemforms/af-core": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@aemforms/af-core/-/af-core-1.0.4.tgz", - "integrity": "sha512-CYxtPZNFNvRbkOnCGlvwuUXWVmC2I0EmoOf0QgCW2na+GaJHcIA9CkxmzeRhSAui2LomxVkYvQNQBJRVAwTclQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@aemforms/af-core/-/af-core-1.0.7.tgz", + "integrity": "sha512-hw6xDFlFwvRcySvmvqb5Xl9sHB6Q5Fz7POURfGFzSfxf+HFc2OkO7sk9mGwPKD22Og3lqC8jxnEr/uL47aa4pA==", "license": "Adobe Proprietary", "dependencies": { "@adobe/json-formula": "0.1.50", - "@aemforms/af-formatters": "^1.0.4" + "@aemforms/af-formatters": "^1.0.7" } }, "node_modules/@aemforms/af-core-xfa": { @@ -137,11 +138,20 @@ "integrity": "sha512-Hg7dkONjmR3aH/Iec+/HRY/lCzVvP3GI6H9wD5FNGsE9zSxPcCzMtnAA0P7n/RKMjcnqYBMt4pFQ6MVb+YvIGg==" }, "node_modules/@aemforms/af-formatters": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@aemforms/af-formatters/-/af-formatters-1.0.4.tgz", - "integrity": "sha512-ShqnAHnwTN6JpEZidP1jqZCy5rku/H+2M1oqC7OwDLenQv55LUQasfvINyhG5H7jVKrocyHpijR5BDKlPdewMw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@aemforms/af-formatters/-/af-formatters-1.0.7.tgz", + "integrity": "sha512-2dEAOmYIH8FfYiiLzPSWqBDmdorrBFCzc+YsQ4JfDoZNH7qMPebnyujZsaFG3grhxUJcz8npMnKdPzDCqUjiqg==", "license": "Adobe Proprietary" }, + "node_modules/@aemforms/af-webmcp": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@aemforms/af-webmcp/-/af-webmcp-1.0.7.tgz", + "integrity": "sha512-YvX/BSwnw7/YBXuhtqaKCPoseglR9n9k/1KJKSNxWUWxD9G8JXTkYmN7y04TdDg4GeEAUxzSFaxmgdKf2fXsYQ==", + "license": "Adobe Proprietary", + "dependencies": { + "@aemforms/af-core": "^1.0.7" + } + }, "node_modules/@ampproject/remapping": { "version": "2.2.1", "dev": true, @@ -11139,12 +11149,12 @@ "integrity": "sha512-dmlLYfbty8NPVIdxvI9cJ+ZdXsrRCFrCdmL1+aR2auEzXJ86rD0bm1qu+S4NOpFiZLKIyx0zvUTykms40vNjsA==" }, "@aemforms/af-core": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@aemforms/af-core/-/af-core-1.0.4.tgz", - "integrity": "sha512-CYxtPZNFNvRbkOnCGlvwuUXWVmC2I0EmoOf0QgCW2na+GaJHcIA9CkxmzeRhSAui2LomxVkYvQNQBJRVAwTclQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@aemforms/af-core/-/af-core-1.0.7.tgz", + "integrity": "sha512-hw6xDFlFwvRcySvmvqb5Xl9sHB6Q5Fz7POURfGFzSfxf+HFc2OkO7sk9mGwPKD22Og3lqC8jxnEr/uL47aa4pA==", "requires": { "@adobe/json-formula": "0.1.50", - "@aemforms/af-formatters": "^1.0.4" + "@aemforms/af-formatters": "^1.0.7" } }, "@aemforms/af-core-xfa": { @@ -11169,9 +11179,17 @@ "integrity": "sha512-Hg7dkONjmR3aH/Iec+/HRY/lCzVvP3GI6H9wD5FNGsE9zSxPcCzMtnAA0P7n/RKMjcnqYBMt4pFQ6MVb+YvIGg==" }, "@aemforms/af-formatters": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@aemforms/af-formatters/-/af-formatters-1.0.4.tgz", - "integrity": "sha512-ShqnAHnwTN6JpEZidP1jqZCy5rku/H+2M1oqC7OwDLenQv55LUQasfvINyhG5H7jVKrocyHpijR5BDKlPdewMw==" + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@aemforms/af-formatters/-/af-formatters-1.0.7.tgz", + "integrity": "sha512-2dEAOmYIH8FfYiiLzPSWqBDmdorrBFCzc+YsQ4JfDoZNH7qMPebnyujZsaFG3grhxUJcz8npMnKdPzDCqUjiqg==" + }, + "@aemforms/af-webmcp": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@aemforms/af-webmcp/-/af-webmcp-1.0.7.tgz", + "integrity": "sha512-YvX/BSwnw7/YBXuhtqaKCPoseglR9n9k/1KJKSNxWUWxD9G8JXTkYmN7y04TdDg4GeEAUxzSFaxmgdKf2fXsYQ==", + "requires": { + "@aemforms/af-core": "^1.0.7" + } }, "@ampproject/remapping": { "version": "2.2.1", diff --git a/ui.frontend/package.json b/ui.frontend/package.json index d7a6f6f6b2..dc896810c5 100644 --- a/ui.frontend/package.json +++ b/ui.frontend/package.json @@ -25,9 +25,10 @@ "webpack-merge": "^5.8.0" }, "dependencies": { - "@aemforms/af-core": "^1.0.4", + "@aemforms/af-core": "^1.0.7", "@aemforms/af-core-xfa": "^0.1.6", - "@aemforms/af-formatters": "^1.0.4", - "@aemforms/af-custom-functions": "1.0.18" + "@aemforms/af-formatters": "^1.0.7", + "@aemforms/af-custom-functions": "1.0.18", + "@aemforms/af-webmcp": "^1.0.7" } } diff --git a/ui.frontend/src/GuideBridge.js b/ui.frontend/src/GuideBridge.js index 2648d7171e..a0f069bbe6 100644 --- a/ui.frontend/src/GuideBridge.js +++ b/ui.frontend/src/GuideBridge.js @@ -19,6 +19,7 @@ import Response from "./Response.js"; import AfFormData from "./FormData.js"; import {readAttachments} from "@aemforms/af-core"; import Utils from "./utils.js"; +import {registerFormWebMCP} from "@aemforms/af-webmcp"; /** * The GuideBridge class represents the bridge between an adaptive form and JavaScript APIs. @@ -33,6 +34,7 @@ class GuideBridge { * @instance */ #formContainerViewMap = {}; + #webMcpAdditionalTools = new Map(); /** * Array to store the guide bridge connect handlers. * @member {Array} @@ -96,6 +98,11 @@ class GuideBridge { function onFormContainerInitialised(e) { let formContainer = e.detail; self.#formContainerViewMap[formContainer.getPath()] = formContainer; + const config = self.#webMcpAdditionalTools.get(formContainer.getPath()); + if (config) { + formContainer._unregisterWebMcp(); + formContainer._setWebMcpUnregister(registerFormWebMCP(formContainer.getModel(), Utils.getWebMcpOptions(formContainer, config.factory))); + } self.#invokeConnectHandlers(formContainer.getPath()); } document.addEventListener(Constants.FORM_CONTAINER_INITIALISED, onFormContainerInitialised); @@ -114,6 +121,35 @@ class GuideBridge { } + /** + * Experimental/internal supported seam for form-scoped WebMCP tools. + * @param {string} formContainerPath Explicit target container path. + * @param {Function|null} factory Receives the form model and returns tools; null removes it. + * @returns {Function} Disposer that removes only this registration. + */ + registerWebMcpAdditionalTools(formContainerPath, factory) { + if (typeof formContainerPath !== 'string' || !formContainerPath.trim() || + (factory !== null && typeof factory !== 'function')) { + throw new TypeError("An explicit form container path and a function or null are required"); + } + const config = factory === null ? null : {factory}; + if (config) { + this.#webMcpAdditionalTools.set(formContainerPath, config); + } else { + this.#webMcpAdditionalTools.delete(formContainerPath); + } + const view = this.#formContainerViewMap[formContainerPath]; + if (view) { + view._unregisterWebMcp(); + view._setWebMcpUnregister(registerFormWebMCP(view.getModel(), Utils.getWebMcpOptions(view, factory))); + } + return () => { + if (config && this.#webMcpAdditionalTools.get(formContainerPath) === config) { + this.registerWebMcpAdditionalTools(formContainerPath, null); + } + }; + } + /** * Returns the string representation of the form data. * @@ -481,12 +517,17 @@ class GuideBridge { console.warn("No form container path specified or available to unload."); return; } + this.#webMcpAdditionalTools.delete(pathToUnload); // Get the container element from the view and disconnect mutation observers let container = null; if (this.#formContainerViewMap[pathToUnload]) { const formContainerView = this.#formContainerViewMap[pathToUnload]; container = formContainerView.getFormElement(); + + if (typeof formContainerView._unregisterWebMcp === 'function') { + formContainerView._unregisterWebMcp(); + } // Disconnect all mutation observers for this form if (typeof formContainerView._disconnectMutationObservers === 'function') { @@ -552,5 +593,3 @@ class GuideBridge { }; export default GuideBridge; - - diff --git a/ui.frontend/src/utils.js b/ui.frontend/src/utils.js index 6c8f122a26..62bb0f9164 100644 --- a/ui.frontend/src/utils.js +++ b/ui.frontend/src/utils.js @@ -18,6 +18,7 @@ import {Constants} from "./constants.js"; import HTTPAPILayer from "./HTTPAPILayer.js"; import {customFunctions} from "./customFunctions.js"; import {FunctionRuntime} from '@aemforms/af-core'; +import {registerFormWebMCP} from '@aemforms/af-webmcp'; import {loadXfa} from "./handleXfa"; import RuleUtils from "./RuleUtils.js"; @@ -341,6 +342,9 @@ class Utils { } else { _formJson = await HTTPAPILayer.getFormDefinition(_path, _pageLang); } + if (!_formJson.id) { + _formJson = {..._formJson, id: elements[i].id || _path}; + } console.debug("fetched model json", _formJson); await RuleUtils.registerCustomFunctionsV2( _formJson); await RuleUtils.registerCustomFunctionsByUrl(customFunctionUrl); @@ -371,6 +375,11 @@ class Utils { callback(formContainer.getModel()); } Utils.initializeAllFields(formContainer); + // Expose the form's WebMCP tool catalog to in-browser AI agents. No-ops unless the + // form opted in via fd:webMcpEnabled and a browser modelContext is available. + formContainer._setWebMcpUnregister( + registerFormWebMCP(formContainer.getModel(), Utils.getWebMcpOptions(formContainer)) + ); const event = new CustomEvent(Constants.FORM_CONTAINER_INITIALISED, { "detail": formContainer }); document.dispatchEvent(event); } @@ -378,6 +387,18 @@ class Utils { } + /** Builds form-scoped renderer-focus and optional additional-tool options for WebMCP. */ + static getWebMcpOptions(formContainer, additionalTools) { + return { + ...(additionalTools ? {additionalTools} : {}), + onFocusRequest: (fieldId) => { + const previous = document.activeElement; + formContainer.setFocus(fieldId); + return document.activeElement !== previous; + } + }; + } + /** * For backward compatibility with older data formats of prefill services like FDM. * @param {object} prefillJson - The prefill JSON object. diff --git a/ui.frontend/src/view/FormContainer.js b/ui.frontend/src/view/FormContainer.js index 6053e3a571..aa00ee685e 100644 --- a/ui.frontend/src/view/FormContainer.js +++ b/ui.frontend/src/view/FormContainer.js @@ -36,6 +36,7 @@ class FormContainer { this._deferredParents = {}; this._element = params._element; this._mutationObservers = []; // Store mutation observers for cleanup + this._webMcpUnregister = null; // Prevent default behaviour on form container. this.#preventDefaultSubmit(); @@ -85,6 +86,27 @@ class FormContainer { this._mutationObservers = []; } + /** + * Replaces the active WebMCP registration cleanup for this form. + * @param {Function} unregister - Function that unregisters this form's WebMCP tools. + * @private + */ + _setWebMcpUnregister(unregister) { + this._unregisterWebMcp(); + this._webMcpUnregister = typeof unregister === 'function' ? unregister : null; + } + + /** + * Unregisters this form's WebMCP tools. + * @private + */ + _unregisterWebMcp() { + if (this._webMcpUnregister) { + this._webMcpUnregister(); + this._webMcpUnregister = null; + } + } + /** * Adds an instance manager view to the fields of the formContainer. * @param {object} instanceManager - The instance manager view to be added. diff --git a/ui.tests/test-module/specs/ruleeditor/authoring/ruleEditor.authoring.cy.js b/ui.tests/test-module/specs/ruleeditor/authoring/ruleEditor.authoring.cy.js index f35e9c1b2c..0e26f56830 100644 --- a/ui.tests/test-module/specs/ruleeditor/authoring/ruleEditor.authoring.cy.js +++ b/ui.tests/test-module/specs/ruleeditor/authoring/ruleEditor.authoring.cy.js @@ -141,7 +141,10 @@ describe('Rule editor authoring sanity for core-components',function(){ cy.wait('@ruleEditorRequest').then((interception) => { expect(interception.response.statusCode).to.equal(201); const submittedData = Object.fromEntries(new URLSearchParams(interception.request.body)); - expect(submittedData[":content"]).contains("\"fd:events\":{\"change\":[\"if(contains($event.payload.changes[].propertyName, 'value'), if($field.$value == 'abc', {visible : false()}, {}), {})\"]}"); + const submittedContent = JSON.parse(submittedData[":content"]); + expect(submittedContent["fd:events"].change).to.deep.equal([ + "if(contains($event.payload.changes[].propertyName, 'value'), if($field.$value == 'abc', {visible : false()}, {}), {})" + ]); }); // check and close rule editor @@ -466,27 +469,55 @@ describe('Rule editor authoring sanity for core-components',function(){ cy.deleteComponentByPath(datePickerEditPath); }) - it('should add rule on texbox equality operator to hide a text box', function () { - cy.openAuthoring(formPath); - cy.selectLayer("Edit"); - cy.get(sitesSelectors.overlays.overlay.component + "[data-path='" + formContainerPath + "/*']").should("exist"); + context('Text box equality rule', function () { + const contextPath = Cypress.env('crx.contextPath') || ''; + const resetTextInput = () => { + // Retries must not reuse a persisted field whose saved rules survived a failed assertion. + const deleteProperty = textinputEditPath.split('/').pop() + '@Delete'; + return cy.request(contextPath + '/libs/granite/csrf/token.json').its('body.token').then(token => { + return cy.window().then(win => win.fetch(contextPath + formContainerPath, { + method: 'POST', + credentials: 'same-origin', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: new win.URLSearchParams({ + [deleteProperty]: '', + ':cq_csrf_token': token + }).toString() + }).then(response => { + if (!response.ok) { + throw new Error('Unable to clean rule-editor text input: HTTP ' + response.status); + } + })); + }).then(() => { + return cy.request({ + url: contextPath + textinputEditPath + '.json', + failOnStatusCode: false + }).its('status').should('equal', 404); + }); + }; + + beforeEach(() => { + cy.openAuthoring(formPath); + resetTextInput(); + cy.openSiteAuthoring(formPath); + cy.selectLayer("Edit"); + }); - cy.insertComponent(sitesSelectors.overlays.overlay.component + "[data-path='" + formContainerPath + "/*']", - "Adaptive Form Text Box", afConstants.components.forms.resourceType.formtextinput); - // cy.openEditableToolbar(sitesSelectors.overlays.overlay.component + textinputEditPathSelector); - // cy.get(formsSelectors.ruleEditor.action.configure).should("exist"); - // cy.get(formsSelectors.ruleEditor.action.configure).click(); - // cy.get(".cmp-adaptiveform-base__editdialogbasic [name='./name']").clear().type("textinput"); - // cy.get(".cq-dialog-actions.cq-dialog-submit").click(); + afterEach(() => { + resetTextInput(); + }); - cy.openEditableToolbar(sitesSelectors.overlays.overlay.component + textinputEditPathSelector); + it('should add rule on texbox equality operator to hide a text box', function () { + cy.get(sitesSelectors.overlays.overlay.component + "[data-path='" + formContainerPath + "/*']").should("exist"); - createRuleToHideTextInputOnEqualityOperator(); - cy.get(sitesSelectors.overlays.overlay.component + textinputEditPathSelector).should("exist"); + cy.insertComponent(sitesSelectors.overlays.overlay.component + "[data-path='" + formContainerPath + "/*']", + "Adaptive Form Text Box", afConstants.components.forms.resourceType.formtextinput); + cy.openEditableToolbar(sitesSelectors.overlays.overlay.component + textinputEditPathSelector); - cy.selectLayer("Edit"); - cy.deleteComponentByPath(textinputEditPath); - }) + createRuleToHideTextInputOnEqualityOperator(); + cy.get(sitesSelectors.overlays.overlay.component + textinputEditPathSelector).should("exist"); + }); + }); it('should add submission handler rules on form', function () { if (toggle_array.includes("FT_FORMS-13209")) { diff --git a/ui.tests/test-module/specs/webmcp/webmcp.authoring.cy.js b/ui.tests/test-module/specs/webmcp/webmcp.authoring.cy.js new file mode 100644 index 0000000000..413499b6c1 --- /dev/null +++ b/ui.tests/test-module/specs/webmcp/webmcp.authoring.cy.js @@ -0,0 +1,125 @@ +/* + * Copyright 2026 Adobe + * + * Licensed 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 + * + * http://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. + */ + +/// +/* global cy, Cypress, expect, window, document */ + +const sitesSelectors = require('../../libs/commons/sitesSelectors'); +const afConstants = require('../../libs/commons/formsConstants'); + +describe('WebMCP form authoring and preview', () => { + if (!cy.af.isLatestAddon()) { + return; + } + const pagePath = '/content/forms/af/core-components-it/samples/accessibility'; + const containerPath = pagePath + afConstants.FORM_EDITOR_FORM_CONTAINER_SUFFIX; + const contextPath = Cypress.env('crx.contextPath') || ''; + const resourcePath = contextPath + containerPath; + const toolNames = [ + 'list_forms', 'get_form_summary', 'get_field_value', 'explain_field', 'validate_form_completeness', + 'list_repeatable_instances', 'focus_field', 'navigate_to_panel', 'set_field_value', + 'apply_prefill', 'add_repeatable_instance', 'remove_repeatable_instance', 'submit_form' + ]; + let originalProperties; + + before(function () { + cy.openAuthoring(pagePath); + cy.fetchFeatureToggles().its('body.enabled').should('be.an', 'array').then(enabled => { + if (!enabled.includes('FT_FORMS-28233')) { + return this.skip(); + } + cy.request(resourcePath + '.json').its('body').then(properties => { + originalProperties = properties; + }); + }); + }); + + after(() => { + if (!originalProperties) { + return; + } + const originalValue = originalProperties['fd:webMcpEnabled']; + const properties = originalValue === undefined ? {'fd:webMcpEnabled@Delete': ''} : { + 'fd:webMcpEnabled': originalValue, + 'fd:webMcpEnabled@TypeHint': typeof originalValue === 'boolean' ? 'Boolean' : 'String' + }; + cy.request(contextPath + '/libs/granite/csrf/token.json').its('body.token').then(token => { + return cy.window().then(win => win.fetch(resourcePath, { + method: 'POST', + credentials: 'same-origin', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: new win.URLSearchParams(Object.assign(properties, {':cq_csrf_token': token})).toString() + }).then(response => { + if (!response.ok) { + throw new Error('Unable to restore WebMCP opt-in: HTTP ' + response.status); + } + })); + }); + }); + + it('enables AI assistant access in the container dialog and registers the preview catalog', () => { + const installHost = () => { + const tools = new Map(); + window.__webMcpTestTools = tools; + Object.defineProperty(document, 'modelContext', { + configurable: true, + value: { + registerTool(tool) { + tools.set(tool.name, tool); + return {unregister() { tools.delete(tool.name); }}; + } + } + }); + }; + cy.openEditableToolbar(sitesSelectors.overlays.overlay.component + '[data-path="' + containerPath + '"]'); + cy.intercept('GET', '**/container/_cq_dialog.html/**').as('containerDialog'); + cy.invokeEditableAction('[data-action="CONFIGURE"]'); + cy.wait('@containerDialog').its('response.statusCode').should('eq', 200); + cy.get('.cmp-adaptiveform-container__editdialog', {timeout: 60000}).contains('Basic').click({force: true}); + cy.get('coral-checkbox[name="./fd:webMcpEnabled"]').should('be.visible') + .find('input[type="checkbox"]').check({force: true}).should('be.checked'); + cy.submitConfigureDialog(); + + // Inject into the preview document so editor navigation cannot discard the test host. + cy.intercept('GET', '**' + pagePath + '.html*', request => { + request.continue(response => { + expect(response.body).to.match(/]*)?>/i); + response.body = response.body.replace(/]*)?>/i, + '$&'); + }); + }); + cy.previewForm(pagePath + '.html').then(formContainer => { + expect(formContainer.getModel().webMcpEnabled).to.equal(true); + }); + cy.window().its('__webMcpTestTools').as('tools').should(registered => { + expect(Array.from(registered.keys())).to.have.members(toolNames); + registered.forEach(tool => { + expect(tool.execute, tool.name).to.be.a('function'); + expect(tool.inputSchema.type, tool.name).to.equal('object'); + }); + expect(registered.get('validate_form_completeness').annotations) + .to.include({readOnlyHint: true, untrustedContentHint: true}); + }); + cy.get('@tools').then(tools => tools.get('list_forms').execute({})).then(result => { + expect(result.success).to.equal(true); + expect(result.forms).to.have.length(1); + }); + cy.get('@tools').then(tools => tools.get('get_form_summary').execute({})).then(summary => { + expect(summary.success).to.equal(true); + expect(summary.fields.length).to.be.greaterThan(10); + }); + }); +});