From c8b12dd2231f7f312abe019210d046e2437a36e8 Mon Sep 17 00:00:00 2001 From: Alejandro Ramirez Date: Mon, 3 Aug 2026 09:29:22 +0200 Subject: [PATCH 1/5] feat(skills): add Enterprise Context tab with the governance MFE Iframe the Experience Governance MFE's shell-free embed entry and bridge it with a vanilla re-implementation of @assets/microfrontend's MessageRpc, so no Unified Shell dependency is needed. - resolve the qa/stage/prod CDN bundle from da-live's hostname, with an ?egov=local override for a local MFE dev server; the same env picks the MFE's `env` prop so bundle and backend API always match - deep-link via ?egovPath=, so a governance screen is shareable and survives reload. Writes always replace: adding history entries first needs a popstate listener feeding the path back into the MFE, so browser Back/Forward does not navigate within the tab yet - pin postMessage to the embed's origin and validate event.origin inbound, since the props payload carries a live IMS bearer token - unit-test the handshake, props and callback protocol plus the ?egovPath= round trip Co-Authored-By: Claude Opus 5 --- blocks/skills/constants.js | 24 ++++ blocks/skills/editor-panel.css | 16 +++ blocks/skills/nx-skills-editor.js | 49 ++++++- blocks/skills/renderers.js | 28 +++- blocks/skills/utils/egov-bridge.js | 206 ++++++++++++++++++++++++++++ blocks/skills/utils/egov-embed.js | 95 +++++++++++++ test/unit/utils/egov-bridge.test.js | 139 +++++++++++++++++++ test/unit/utils/egov-embed.test.js | 108 +++++++++++++++ 8 files changed, 657 insertions(+), 8 deletions(-) create mode 100644 blocks/skills/utils/egov-bridge.js create mode 100644 blocks/skills/utils/egov-embed.js create mode 100644 test/unit/utils/egov-bridge.test.js create mode 100644 test/unit/utils/egov-embed.test.js diff --git a/blocks/skills/constants.js b/blocks/skills/constants.js index 7ec50f0..93f9358 100644 --- a/blocks/skills/constants.js +++ b/blocks/skills/constants.js @@ -5,6 +5,7 @@ const TAB_PROMPTS = 'prompts'; const TAB_MCPS = 'mcps'; const TAB_MARKETPLACE = 'marketplace'; const TAB_MEMORY = 'memory'; +const TAB_CONTEXT = 'context'; // UI label: "Context"; embeds the Experience Governance MFE /** Per-tab metadata rendered by the catalog tab strip. */ const CATALOG_TABS = [ @@ -14,6 +15,7 @@ const CATALOG_TABS = [ { id: TAB_MCPS, label: 'MCPs' }, // { id: TAB_MARKETPLACE, label: 'Marketplace', disabled: true }, { id: TAB_MEMORY, label: 'Memory' }, + { id: TAB_CONTEXT, label: 'Enterprise Context' }, ]; const TAB_LABEL_MAP = Object.fromEntries(CATALOG_TABS.map((t) => [t.id, t.label])); @@ -24,6 +26,7 @@ const TAB_DESCRIPTIONS = { [TAB_SKILLS]: 'Markdown instructions that guide the assistant\'s behavior.', [TAB_MCPS]: 'Model Context Protocol servers that give the assistant access to external tools.', [TAB_MARKETPLACE]: 'Discover and install 1st and 3rd party plugins.', + [TAB_CONTEXT]: 'Brand governance context and enterprise content checks.', }; /** Per-tab metadata for the "new" button label and the opener method name. */ @@ -126,6 +129,25 @@ const TOOLS_FILTER_THRESHOLD = 6; const CHAT_DRAWER_WIDTH = 380; const AGENT_USAGE_ICON = '\u26A1'; +/** + * Experience Governance MFE embed config. SPA Pipeline serves the built bundle + * at this same fixed path on every environment, and only the host changes. See + * utils/egov-embed.js for how EMBED_URLS is picked at runtime. + */ +const EGOV_EMBED_PATH = '/solutions/Adobe-AEM-Foundation-experience-governance-mfe/static-assets/resources/embed.html'; + +const EGOV_MFE = { + CHANNEL: '@aemsec/experience-governance-mfe', + PROTOCOL: '@assets/microfrontend/MessageRpc', + VERSION: '1.0.0', + EMBED_URLS: { + local: `https://localhost.corp.adobe.com:8443${EGOV_EMBED_PATH}`, + qa: `https://experience-qa.adobe.com${EGOV_EMBED_PATH}`, + stage: `https://experience-stage.adobe.com${EGOV_EMBED_PATH}`, + prod: `https://experience.adobe.com${EGOV_EMBED_PATH}`, + }, +}; + export { AGENT_USAGE_ICON, BUILTIN_AGENTS, @@ -136,6 +158,7 @@ export { CATEGORY_OPTIONS, CHAT_DRAWER_WIDTH, DEP_TREE_MAX_TOOLS, + EGOV_MFE, TAB_DESCRIPTIONS, TAB_LABEL_MAP, FRESH_FORM_STATE, @@ -144,6 +167,7 @@ export { STATUS_TYPE, TAB_ACTIONS, TAB_AGENTS, + TAB_CONTEXT, TAB_MARKETPLACE, TAB_MCPS, TAB_MEMORY, diff --git a/blocks/skills/editor-panel.css b/blocks/skills/editor-panel.css index 0746f32..5083ea0 100644 --- a/blocks/skills/editor-panel.css +++ b/blocks/skills/editor-panel.css @@ -165,6 +165,22 @@ overflow: hidden; } +/* Context tab: fill the panel with the embedded governance MFE */ +.editor-body-context { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.egov-mfe-iframe { + flex: 1; + min-height: 0; + width: 100%; + border: 0; +} + .editor-footer { position: sticky; bottom: 0; diff --git a/blocks/skills/nx-skills-editor.js b/blocks/skills/nx-skills-editor.js index c12ff20..8adcddd 100644 --- a/blocks/skills/nx-skills-editor.js +++ b/blocks/skills/nx-skills-editor.js @@ -53,6 +53,9 @@ import { ensureSkillFrontmatter } from './utils/skill-frontmatter.js'; import { createReadOnlyViewer, createEditor, replaceDoc, destroyEditor, } from './utils/codemirror-loader.js'; +import { setupEgovBridge } from './utils/egov-bridge.js'; +import { resolveEgovMfeEnv, resolveEgovPath, setEgovPath } from './utils/egov-embed.js'; +import { getToken } from './utils/da-fetch.js'; import { onMessage, sendMessage, @@ -273,6 +276,7 @@ class NxSkillsEditor extends LitElement { this._disposeCMModal(); this._disposeMemoryCM(); this._disposeSkillCM(); + this._disposeEgovBridge(); } async updated(changed) { @@ -304,6 +308,16 @@ class NxSkillsEditor extends LitElement { if ((changed?.has('_memory') || changed?.has('_catalogTab')) && this._catalogTab === 'memory' && this._memory) { this.updateComplete.then(() => this._mountMemoryCM()); } + if (changed?.has('_catalogTab') && this._catalogTab === 'context' && !this._egovBridge) { + this.updateComplete.then(() => this._mountEgovBridge()); + } + // The `_egovBridge` check narrows this to a genuine tab exit. No bridge + // exists on the initial tab assignment, where clearing `?egovPath=` would + // wipe an incoming deep link before it is read. + if (changed?.has('_catalogTab') && this._catalogTab !== 'context' && this._egovBridge) { + this._disposeEgovBridge(); + setEgovPath('/'); + } // ── Skill body CM: mount when skill form appears, dispose when it hides ── const skillFormVisible = this._isEditorOpen && this._catalogTab === 'skills' && !(this._viewingSkillId && !this._isFormEdit); @@ -711,7 +725,7 @@ class NxSkillsEditor extends LitElement { this._isFormDirty = true; } else { this._clearForm(); - this._isEditorOpen = newTab === 'memory'; + this._isEditorOpen = newTab === 'memory' || newTab === 'context'; } this._pushTabState(newTab); @@ -1110,6 +1124,39 @@ class NxSkillsEditor extends LitElement { this._memoryCM = null; } + async _mountEgovBridge() { + this._disposeEgovBridge(); + const iframe = this.shadowRoot.querySelector('.egov-mfe-iframe'); + if (!iframe) return; + let imsOrg; + try { imsOrg = (await window.adobeIMS?.getProfile?.())?.ownerOrg; } catch { /* anonymous session */ } + this._egovBridge = setupEgovBridge({ + iframe, + getProps: () => ({ + path: resolveEgovPath(), + env: resolveEgovMfeEnv(), + imsToken: getToken(), + imsOrg, + }), + // The MFE says whether each navigation deserves its own history entry. + // We ignore that and always replace, so Back never lands inside this tab. + // Adding entries first needs a popstate listener that feeds the path back + // into the MFE. Without one, Back would change the URL while the iframe + // kept showing the same screen. + onNavigate: (path) => setEgovPath(path), + }); + } + + /** + * Tears down the bridge only. Clearing `?egovPath=` is the caller's job (see + * `updated`), since this also runs defensively from `_mountEgovBridge`, which + * is about to read that param. + */ + _disposeEgovBridge() { + this._egovBridge?.destroy(); + this._egovBridge = null; + } + async _mountSkillCM() { this._disposeSkillCM(); const host = this.shadowRoot.querySelector('.skill-body-cm-host'); diff --git a/blocks/skills/renderers.js b/blocks/skills/renderers.js index f188a0d..e0d3dd3 100644 --- a/blocks/skills/renderers.js +++ b/blocks/skills/renderers.js @@ -13,6 +13,7 @@ import { STATUS_TYPE, TAB_ACTIONS, TAB_AGENTS, + TAB_CONTEXT, TAB_DESCRIPTIONS, TAB_LABEL_MAP, TAB_MARKETPLACE, @@ -21,6 +22,7 @@ import { TAB_PROMPTS, TAB_SKILLS, } from './constants.js'; +import { resolveEgovEmbedUrl } from './utils/egov-embed.js'; import { isSensitiveHeaderName, skillRowEnabled, @@ -40,6 +42,7 @@ const TAB_ICON_MAP = { [TAB_SKILLS]: html``, [TAB_MCPS]: html``, [TAB_MARKETPLACE]: html``, + [TAB_CONTEXT]: html``, [TAB_MEMORY]: html``, }; /* eslint-enable max-len */ @@ -463,6 +466,7 @@ function renderDetailView(vm) { const isMcp = tab === TAB_MCPS; const isAgent = tab === TAB_AGENTS; const isMemory = tab === TAB_MEMORY; + const isContext = tab === TAB_CONTEXT; const skillDrillDown = isSkill && vm.viewingSkillId && !vm.isFormEdit; if (skillDrillDown) { @@ -489,7 +493,7 @@ function renderDetailView(vm) { } const title = editorTitle(vm, tab); - const showBack = tab !== TAB_MEMORY; + const showBack = tab !== TAB_MEMORY && tab !== TAB_CONTEXT; const backLabel = (isSkill && vm.viewingSkillId) ? vm.viewingSkillId : (TAB_LABEL_MAP[tab] || 'List'); @@ -501,13 +505,15 @@ function renderDetailView(vm) { @click=${() => vm.onCloseEditor()} >${BACK_ARROW_ICON}${backLabel} ` : nothing} -
-

${title}

-
+ ${!isContext ? html` +
+

${title}

+
+ ` : nothing} ${vm.isFormDirty && !isMcp ? html`
Unsaved edits · save to persist
` : nothing} -
+
${isSkill ? renderSkillForm(vm) : nothing} ${isAgent && !vm.isAgentViewTools ? renderAgentForm(vm) : nothing} ${isPrompt ? renderPromptForm(vm) : nothing} @@ -521,6 +527,7 @@ function renderDetailView(vm) {

.da/agent/memory.md

${renderMemoryContent(vm)} ` : nothing} + ${isContext ? renderContextContent(vm) : nothing}
${(isSkill || (isAgent && !vm.isAgentViewTools) || isPrompt || (isMcp && (!vm.viewingMcpServerId || vm.editingMcpKey))) ? html` @@ -579,11 +586,11 @@ function renderCatalogView(vm) { } export function renderListCol(vm) { - const isMemoryDirect = vm.catalogTab === TAB_MEMORY; + const isDirectView = vm.catalogTab === TAB_MEMORY || vm.catalogTab === TAB_CONTEXT; return html`
- ${vm.isEditorOpen || isMemoryDirect + ${vm.isEditorOpen || isDirectView ? renderDetailView(vm) : renderCatalogView(vm)}
@@ -1317,3 +1324,10 @@ export function renderMemoryContent(vm) { } return html`
`; } + +export function renderContextContent(_vm) { + return html` + + `; +} diff --git a/blocks/skills/utils/egov-bridge.js b/blocks/skills/utils/egov-bridge.js new file mode 100644 index 0000000..a79c5a9 --- /dev/null +++ b/blocks/skills/utils/egov-bridge.js @@ -0,0 +1,206 @@ +/** + * Host-side bridge for embedding the Experience Governance MFE's `embed` + * entry via `@assets/microfrontend`'s MessageRpc protocol. + * + * Vanilla re-implementation: no Unified Shell, no React. The protocol, per + * @assets/microfrontend's own rpcBridge: + * - handshake = '__connect'. BOTH sides invoke it unprompted; each answers + * the other's request with an invokeResponse. + * - host->mfe props: fnName 'reactSetProps', params:[{ simple, callbacks }]. + * Function props are sent by NAME only, in `callbacks`. + * - mfe->host events: fnName 'reactCallback', params:[{ callbackName, args }]. + * The host MUST reply or the MFE's promise hangs. + */ + +import { EGOV_MFE } from '../constants.js'; + +const { CHANNEL, PROTOCOL, VERSION } = EGOV_MFE; +const LOCAL_VERSION = { internal: VERSION, consumer: '1.1' }; + +const HANDSHAKE_RETRY_MS = 300; +const HANDSHAKE_WINDOW_MS = 15000; + +/** + * Wire the MessageRpc bridge to an already-mounted iframe pointed at the MFE's + * embed.html. The handshake retries until the MFE's own listener exists, since + * it mounts React asynchronously after load. + * + * @param {object} opts + * @param {HTMLIFrameElement} opts.iframe + * @param {() => { path?: string, env?: string, imsToken?: string, imsOrg?: string }} opts.getProps + * @param {(path: string) => void} [opts.onNavigate] + * @returns {{ destroy: () => void }} + */ +export function setupEgovBridge({ iframe, getProps, onNavigate }) { + let msgId = 0; + const pending = new Map(); + let connected = false; + let disposed = false; + let retryTimer = null; + let retryDeadline = null; + + /** + * The props payload carries a live IMS bearer token, so both directions pin + * to the embed's own origin: `'*'` would leak the token to whatever document + * ends up in the frame, and `event.source` alone doesn't establish who that + * document is. Null for an empty or unparseable src, which leaves the bridge + * inert rather than insecure. + */ + const targetOrigin = (() => { + if (!iframe.src) return null; + try { + const { protocol, origin } = new URL(iframe.src, window.location.href); + // Opaque origins (about:, data:, blob:) serialize to "null", which is not + // a usable targetOrigin. + return protocol === 'https:' || protocol === 'http:' ? origin : null; + } catch { + return null; + } + })(); + + const post = (m) => { + if (!targetOrigin) return; + iframe.contentWindow?.postMessage(m, targetOrigin); + }; + + function invoke(fnName, params = []) { + msgId += 1; + const id = String(msgId); + const message = { + type: 'invokeRequest', + channelId: CHANNEL, + fnName, + params, + id, + protocol: PROTOCOL, + version: VERSION, + }; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + post(message); + }); + } + + function respond(request, value, isError = false) { + post({ + type: isError ? 'invokeResponseError' : 'invokeResponse', + channelId: CHANNEL, + fnName: request.fnName, + params: [value], + id: request.id, + protocol: PROTOCOL, + version: VERSION, + }); + } + + const handlers = { + __connect() { + markConnected(); + return LOCAL_VERSION; + }, + reactCallback({ callbackName, args }) { + if (callbackName === 'onNavigate') onNavigate?.(...(args || [])); + return undefined; // must still respond, or the MFE's promise hangs + }, + }; + + function onWindowMessage(event) { + if (!targetOrigin || event.origin !== targetOrigin) return; + if (event.source !== iframe.contentWindow) return; + const d = event.data; + if (!d || typeof d !== 'object' || d.protocol !== PROTOCOL) return; + if (d.channelId !== CHANNEL) return; + if (typeof d.version !== 'string' || !d.version.startsWith('1.')) return; + + if (d.type === 'invokeResponse' || d.type === 'invokeResponseError') { + const p = pending.get(d.id); + if (!p) return; + pending.delete(d.id); + const [value] = d.params || []; + if (d.type === 'invokeResponse') p.resolve(value); else p.reject(value); + return; + } + + if (d.type === 'invokeRequest') { + const fn = handlers[d.fnName]; + if (!fn) { + respond(d, `Received request to invoke non-existing function: '${d.fnName}'.`, true); + return; + } + Promise.resolve() + .then(() => fn(...(d.params || []))) + .then((v) => respond(d, v, false)) + .catch((e) => respond(d, String(e), true)); + } + } + + function sendProps() { + const { path = '/', env = 'STAGE', imsToken, imsOrg } = getProps() || {}; + // `metrics` is deliberately absent: it isn't an MFE app prop but a + // bridge-level field rpcBridge derives from window.adobeMetrics on the + // sender side. Omitting it makes the guest skip MetricsWrapper.init; + // sending `{}` would only feed it a bogus id. + const simple = { + path, env, optIn: false, colorScheme: 'light', locale: 'en-US', featureFlags: [], + }; + if (imsToken) simple.imsToken = imsToken; + if (imsOrg) simple.imsOrg = imsOrg; + return invoke('reactSetProps', [{ simple, callbacks: ['onNavigate', 'onToast'] }]); + } + + function markConnected() { + if (connected) return; + connected = true; + clearInterval(retryTimer); + retryTimer = null; + sendProps(); + } + + window.addEventListener('message', onWindowMessage); + + /** + * Unanswered attempts are left in `pending` on purpose: only the one that + * lands gets a response, and they're bounded by the retry window and cleared + * on destroy. Evicting the previous attempt could discard a response already + * in flight for it. + */ + const tryConnect = () => { + if (connected || disposed) return; + invoke('__connect', [LOCAL_VERSION]).then(markConnected).catch(() => {}); + }; + + /** Opens (or re-opens) a retry window and connects now. Idempotent. */ + function startHandshake() { + if (connected || disposed) return; + retryDeadline = Date.now() + HANDSHAKE_WINDOW_MS; + tryConnect(); + if (retryTimer) return; + retryTimer = setInterval(() => { + if (connected || disposed || Date.now() > retryDeadline) { + clearInterval(retryTimer); + retryTimer = null; + return; + } + tryConnect(); + }, HANDSHAKE_RETRY_MS); + } + + // Both entry points are needed. The frame may already have loaded by the time + // the bridge is built, in which case `load` never fires and only this call + // starts the handshake; posting early is harmless, since the pinned + // targetOrigin doesn't match about:blank and a retry picks it up. + startHandshake(); + + // And a frame that loads slowly may burn the whole first window before its + // document runs, so `load` re-opens one. + iframe.addEventListener('load', startHandshake, { once: true }); + + return { + destroy() { + disposed = true; + clearInterval(retryTimer); + window.removeEventListener('message', onWindowMessage); + pending.clear(); + }, + }; +} diff --git a/blocks/skills/utils/egov-embed.js b/blocks/skills/utils/egov-embed.js new file mode 100644 index 0000000..14e0ba0 --- /dev/null +++ b/blocks/skills/utils/egov-embed.js @@ -0,0 +1,95 @@ +/** + * Resolves which Experience Governance MFE bundle to embed. + * + * Priority: + * 1. `?egov=`: explicit override (same pattern as + * `?nx=`/`?da-admin=`), for pointing at a local MFE dev server or a + * specific deployed env regardless of where da-live itself is hosted. + * 2. da-live's own hostname, using the same stage/prod split as the IMS tier + * (see da-nx/nx/scripts/nexter.js): localhost / *.aem.page → stage; + * da.live / *.aem.live → prod. + */ + +import { EGOV_MFE } from '../constants.js'; + +const SAFE_EGOV_PARAM = /^(local|qa|stage|prod)$/; + +/** Governance-relative deep-link path (see the MFE's useGovernancePath), e.g. `/brands/123/knowledge/connectors`. */ +const SAFE_EGOV_PATH = /^\/[a-zA-Z0-9\-_/%.]*$/; + +export function resolveEgovEnv(location = window.location) { + const override = new URLSearchParams(location.search).get('egov'); + if (override && SAFE_EGOV_PARAM.test(override)) return override; + + const { hostname } = location; + if (hostname === 'localhost' || hostname.endsWith('.aem.page')) return 'stage'; + return 'prod'; +} + +export function resolveEgovEmbedUrl(location = window.location) { + const env = resolveEgovEnv(location); + return EGOV_MFE.EMBED_URLS[env] || EGOV_MFE.EMBED_URLS.stage; +} + +/** + * Host env → the MFE's own `Env` union (see its src/types/env.ts), which picks + * the backend API host. The MFE does no case normalization: anything outside + * this exact uppercase set silently falls back to its STAGE API. + * + * `local` maps to STAGE, not DEV, because `?egov=local` means "serve the MFE + * *bundle* from a local dev server", not "use a local backend". DEV would + * point the API at https://localhost:8080/api (usually not running) and, per + * the MFE's EnvProvider, force every feature flag on. + */ +const EGOV_MFE_ENVS = { + local: 'STAGE', + qa: 'QA', + stage: 'STAGE', + prod: 'PROD', +}; + +/** + * Resolves the `env` value to hand the MFE, so it targets the backend matching + * the bundle we embedded. Falls back to STAGE, as the MFE itself does. + */ +export function resolveEgovMfeEnv(location = window.location) { + return EGOV_MFE_ENVS[resolveEgovEnv(location)] || 'STAGE'; +} + +/** + * Reads a governance-relative deep-link path from `?egovPath=`, e.g. + * `?egovPath=/brands/123/knowledge/connectors`. Falls back to `/` (brand + * list) if absent or malformed. + */ +export function resolveEgovPath(location = window.location) { + const raw = new URLSearchParams(location.search).get('egovPath'); + return raw && SAFE_EGOV_PATH.test(raw) ? raw : '/'; +} + +/** + * Reflects the MFE's current internal route into the host URL's `?egovPath=`, + * so the deep link is shareable and survives reload. Always replaces rather + * than pushes: the MFE owns its own router and can't be driven from a popstate + * yet, so added history entries would move the URL without moving the MFE. + * + * The query string is built by hand because `URLSearchParams` percent-encodes + * every `/`, turning a readable `/brands/123/knowledge/connectors` into + * `%2Fbrands%2F123%2F...`. RFC 3986 doesn't require escaping `/` in a query, so + * un-escaping just `%2F` keeps the param valid and legible. + * + * `location`/`history` are injectable for tests. + */ +export function setEgovPath(path, { + location = window.location, + history = window.history, +} = {}) { + if (!path || !SAFE_EGOV_PATH.test(path)) return; + const url = new URL(location.href); + const params = new URLSearchParams(url.search); + params.delete('egovPath'); + const query = params.toString(); + const encodedPath = encodeURIComponent(path).replace(/%2F/g, '/'); + const egovPathParam = path === '/' ? '' : `egovPath=${encodedPath}`; + url.search = [query, egovPathParam].filter(Boolean).join('&'); + history.replaceState(history.state, '', url); +} diff --git a/test/unit/utils/egov-bridge.test.js b/test/unit/utils/egov-bridge.test.js new file mode 100644 index 0000000..c4450b4 --- /dev/null +++ b/test/unit/utils/egov-bridge.test.js @@ -0,0 +1,139 @@ +import { expect } from '@esm-bundle/chai'; +import { setupEgovBridge } from '../../../blocks/skills/utils/egov-bridge.js'; +import { EGOV_MFE } from '../../../blocks/skills/constants.js'; + +const { CHANNEL, PROTOCOL } = EGOV_MFE; + +const realPostMessage = window.postMessage.bind(window); + +/** + * The bridge only touches `src`, `contentWindow` and `addEventListener`, so a + * stub is enough, and it lets each test control the `load` event precisely. + * + * `contentWindow` must be the test window itself, because the bridge rejects + * any message whose `event.source` isn't that exact window and `MessageEvent` + * won't carry a plain object as `source`. Outbound posts are therefore captured + * by patching `window.postMessage` (recorded, not dispatched, so the bridge + * never receives its own requests), while `respondTo` impersonates the MFE + * through the unpatched original. + */ +function stubIframe() { + const posted = []; + let loadListener = null; + window.postMessage = (m) => posted.push(m); + return { + posted, + fireLoad() { loadListener?.(); }, + src: window.location.href, + contentWindow: window, + addEventListener(type, fn) { if (type === 'load') loadListener = fn; }, + }; +} + +const connectRequests = (iframe) => iframe.posted.filter((m) => m.fnName === '__connect' && m.type === 'invokeRequest'); +const propsRequests = (iframe) => iframe.posted.filter((m) => m.fnName === 'reactSetProps'); + +/** Impersonate the MFE answering a host invokeRequest. */ +function respondTo(request, value) { + realPostMessage({ + type: 'invokeResponse', + channelId: CHANNEL, + fnName: request.fnName, + params: [value], + id: request.id, + protocol: PROTOCOL, + version: '1.0.0', + }, window.location.origin); +} + +/** Let queued message events and their promise chains settle. */ +const settle = () => new Promise((r) => { setTimeout(r, 50); }); + +describe('setupEgovBridge', () => { + let bridge; + + afterEach(() => { + bridge?.destroy(); + bridge = null; + window.postMessage = realPostMessage; + }); + + it('starts the handshake without waiting for the iframe load event', () => { + const iframe = stubIframe(); + bridge = setupEgovBridge({ iframe, getProps: () => ({}) }); + + // The host mounts the bridge after awaiting the IMS profile, by which point + // the frame may already have loaded. If `load` were the only trigger, the + // handshake would never start and the tab would stay blank. + expect(connectRequests(iframe).length).to.equal(1); + }); + + it('still connects when load fires after mount', async () => { + const iframe = stubIframe(); + bridge = setupEgovBridge({ iframe, getProps: () => ({}) }); + iframe.fireLoad(); + await settle(); + + expect(connectRequests(iframe).length).to.be.greaterThan(1); + }); + + it('sends props once the MFE answers the handshake', async () => { + const iframe = stubIframe(); + bridge = setupEgovBridge({ + iframe, + getProps: () => ({ path: '/brands/1', env: 'QA', imsToken: 't', imsOrg: 'org@AdobeOrg' }), + }); + respondTo(connectRequests(iframe)[0], { internal: '1.0.0', consumer: '1.1' }); + await settle(); + + const [props] = propsRequests(iframe); + expect(props).to.exist; + const [{ simple, callbacks }] = props.params; + expect(simple.path).to.equal('/brands/1'); + expect(simple.env).to.equal('QA'); + expect(simple.imsToken).to.equal('t'); + expect(simple.imsOrg).to.equal('org@AdobeOrg'); + expect(callbacks).to.deep.equal(['onNavigate', 'onToast']); + }); + + it('reports the MFE\'s navigation through onNavigate', async () => { + const iframe = stubIframe(); + const navigated = []; + bridge = setupEgovBridge({ + iframe, + getProps: () => ({}), + onNavigate: (path) => navigated.push(path), + }); + realPostMessage({ + type: 'invokeRequest', + channelId: CHANNEL, + fnName: 'reactCallback', + params: [{ callbackName: 'onNavigate', args: ['/brands/1/knowledge'] }], + id: 'mfe-1', + protocol: PROTOCOL, + version: '1.0.0', + }, window.location.origin); + await settle(); + + expect(navigated).to.deep.equal(['/brands/1/knowledge']); + }); + + it('stops retrying after destroy', async () => { + const iframe = stubIframe(); + bridge = setupEgovBridge({ iframe, getProps: () => ({}) }); + bridge.destroy(); + const atDestroy = connectRequests(iframe).length; + await settle(); + + expect(connectRequests(iframe).length).to.equal(atDestroy); + }); + + it('is inert when the iframe src has no usable origin', () => { + // The props payload carries a live IMS token, so a bridge with no origin to + // pin postMessage to must send nothing rather than fall back to '*'. + const iframe = { ...stubIframe(), src: '' }; + bridge = setupEgovBridge({ iframe, getProps: () => ({}) }); + + expect(iframe.posted.length).to.equal(0); + }); +}); diff --git a/test/unit/utils/egov-embed.test.js b/test/unit/utils/egov-embed.test.js new file mode 100644 index 0000000..d02f0c4 --- /dev/null +++ b/test/unit/utils/egov-embed.test.js @@ -0,0 +1,108 @@ +import { expect } from '@esm-bundle/chai'; +import { resolveEgovPath, setEgovPath } from '../../../blocks/skills/utils/egov-embed.js'; + +/** + * A stand-in for `location` + `history`, injected rather than patched onto + * `window`, whose history is shared with the test runner and can't be rewound + * between cases. + * + * A `URL` serves as `location`, since it exposes `href` and `search`, which is + * all egov-embed reads. Each recorded write advances it as a real history write + * would. Hrefs are recorded as plain strings so chai can serialize them into a + * failure message; a `URL` is expensive for it to deep-inspect. + */ +function recordHistory(startHref = 'https://da.live/skills') { + const location = new URL(startHref); + const calls = []; + const replaceState = (_state, _title, url) => { + const href = new URL(url, location.href).href; + calls.push(href); + location.href = href; + }; + return { location, calls, history: { state: null, replaceState } }; +} + +/** Options for a call under test; `h` carries both injected globals. */ +const at = (h) => ({ location: h.location, history: h.history }); + +const lastUrl = ({ calls }) => new URL(calls[calls.length - 1]); + +describe('setEgovPath', () => { + it('reflects the path into ?egovPath=', () => { + const h = recordHistory(); + setEgovPath('/brands/1', at(h)); + + expect(h.calls.length).to.equal(1); + expect(lastUrl(h).search).to.equal('?egovPath=/brands/1'); + }); + + it('leaves / unencoded rather than %2F', () => { + const h = recordHistory(); + setEgovPath('/brands/123/knowledge/connectors', at(h)); + + expect(lastUrl(h).href).to.contain('egovPath=/brands/123/knowledge/connectors'); + }); + + it('still escapes characters that do need it', () => { + const h = recordHistory(); + // `%` is the one character SAFE_EGOV_PATH allows that isn't literal-safe in + // a query string, so un-escaping only `%2F` must leave it encoded. + setEgovPath('/brands/100%/knowledge', at(h)); + + expect(lastUrl(h).search).to.equal('?egovPath=/brands/100%25/knowledge'); + }); + + it('keeps other params and drops any stale egovPath', () => { + const h = recordHistory('https://da.live/skills?egov=qa&egovPath=/brands/old&tab=context'); + setEgovPath('/brands/new', at(h)); + + const url = lastUrl(h); + expect(url.searchParams.get('egov')).to.equal('qa'); + expect(url.searchParams.get('tab')).to.equal('context'); + expect(url.search.match(/egovPath=/g).length).to.equal(1); + expect(resolveEgovPath(url)).to.equal('/brands/new'); + }); + + it('omits the param entirely for the root path', () => { + // Mirrors the tab-exit reset in nx-skills-editor.js. + const h = recordHistory('https://da.live/skills?egovPath=/brands/1'); + setEgovPath('/', at(h)); + + expect(lastUrl(h).search).to.equal(''); + }); + + it('ignores paths outside SAFE_EGOV_PATH', () => { + const h = recordHistory(); + setEgovPath('', at(h)); + setEgovPath('brands/1', at(h)); + setEgovPath('https://evil.example/', at(h)); + setEgovPath('/brands/