diff --git a/app/scripts/devtools/panel/ui5/main.js b/app/scripts/devtools/panel/ui5/main.js index 6a3f791c..b010b819 100644 --- a/app/scripts/devtools/panel/ui5/main.js +++ b/app/scripts/devtools/panel/ui5/main.js @@ -765,6 +765,16 @@ // current selection before applying its data. var token = ++controlSelectSeq; + var own = message.controlProperties && message.controlProperties.own; + var webcTree = frameData[frameId].controlTreeWebC; + // Prefer the per-component runtime version (multi-runtime pages can + // mix versions); fall back to the tree's primary runtime version + // when the element didn't report one. Resolved once here and used for + // both the AI context block and the enum lookup below, so the version + // shown to the assistant always matches the primed manifest. + var resolvedVersion = (own && own.meta && own.meta.version) || + (webcTree && webcTree.versionInfo && webcTree.versionInfo.version); + var render = function () { // A newer selection (or a frame switch) has superseded this // one while its manifest fetch was in flight — drop the stale @@ -794,33 +804,27 @@ } } + var ownMeta = (message.controlProperties.own && message.controlProperties.own.meta) || {}; + aiChat.updateContext({ control: { type: controlType, id: controlId, + isWebComponent: ownMeta.isWebComponent === true, + tag: ownMeta.tag, + version: resolvedVersion, properties: message.controlProperties, bindings: message.controlBindings, - aggregations: message.controlAggregations + aggregations: message.controlAggregations, + events: message.controlEvents }, appInfo: frameData[frameId].applicationInformation }); }; - // For UI5 Web Components, resolve enum properties to dropdown values - // from the CDN manifest before rendering. The manifest fetch is - // async (cached after the first hit per version); on any failure we - // just render with plain-string values. Classic UI5 renders - // synchronously as before. - var own = message.controlProperties && message.controlProperties.own; if (own && own.meta && own.meta.isWebComponent) { - var webcTree = frameData[frameId].controlTreeWebC; - // Prefer the per-component runtime version (multi-runtime pages - // can mix versions); fall back to the tree's primary runtime - // version when the element didn't report one. - var version = (own.meta && own.meta.version) || - (webcTree && webcTree.versionInfo && webcTree.versionInfo.version); - webcEnumRegistry.prime(version).then(function () { - _enrichWebcEnumTypes(own, version); + webcEnumRegistry.prime(resolvedVersion).then(function () { + _enrichWebcEnumTypes(own, resolvedVersion); }, function () { /* keep plain strings */ }).then(render); } else { render(); diff --git a/app/scripts/modules/ai/PromptBuilder.js b/app/scripts/modules/ai/PromptBuilder.js index e3ee8594..3fcd4706 100644 --- a/app/scripts/modules/ai/PromptBuilder.js +++ b/app/scripts/modules/ai/PromptBuilder.js @@ -4,6 +4,7 @@ const PROPERTIES_CAP = 800; const BINDINGS_CAP = 800; const AGGREGATIONS_CAP = 400; +const EVENTS_CAP = 400; const CONSOLE_ERRORS_CAP = 400; const BINDING_VALUE_CAP = 100; @@ -47,36 +48,53 @@ function _stringifyBindingValue(value) { /** * @private */ -function _renderAggregationLine(name, children) { - if (!Array.isArray(children) || children.length === 0) { +// Extract the children array from either a raw array (test fixtures) or the +// DataView envelope that both _formatAggregationValues and webcMain's +// _formatAggregations produce: { options, data: { 'content (id)': Array|DataView|null } }. +// At runtime the innermost content is an array of element-ID strings (see +// ToolsAPI._getAggregationContent and WebComponentsToolsAPI._getSlottedContent), +// not objects. +function _extractAggregationChildren(entry) { + if (Array.isArray(entry)) { + return entry; + } + if (entry && entry.data) { + const contentId = entry.data['content (id)']; + // Classic controls wrap a populated array in a nested DataView object. + if (contentId && contentId.data && Array.isArray(contentId.data)) { + return contentId.data; + } + // Web component slots store the raw array directly. + if (Array.isArray(contentId)) { + return contentId; + } + } + return []; +} + +// Normalize a child entry to its display ID. Runtime data is an array of ID +// strings; the object form ({ id, type }) is tolerated for forward safety. +function _childId(child) { + if (typeof child === 'string') { + return child; + } + return (child && child.id) || '?'; +} + +function _renderAggregationLine(name, entry) { + const children = _extractAggregationChildren(entry); + if (children.length === 0) { return '- ' + name + ': empty'; } const count = children.length; if (count <= 3) { - const ids = children.map(function (child) { - return (child && child.id) || '?'; - }); + const ids = children.map(_childId); return '- ' + name + ': ' + count + ' children — ' + ids.join(', '); } - // Preserve insertion order. - const histogram = Object.create(null); - const order = []; - for (let i = 0; i < children.length; i++) { - const type = (children[i] && children[i].type) || '?'; - if (!Object.prototype.hasOwnProperty.call(histogram, type)) { - histogram[type] = 0; - order.push(type); - } - histogram[type] += 1; - } - const parts = order.map(function (type) { - return type + ' × ' + histogram[type]; - }); - - return '- ' + name + ': ' + count + ' children (' + parts.join(', ') + ')'; + return '- ' + name + ': ' + count + ' children'; } /** @@ -100,10 +118,11 @@ PromptBuilder.prototype.buildSystemPrompt = function (appInfo) { const rules = 'Rules:\n' + '1. Always reply in English, regardless of the language of the user\'s message.\n' + - '2. When a Current UI5 Control Context section is present in the user prompt, only reference property, aggregation, event, and binding names that appear in it. If asked about a name not listed there, say so plainly.\n' + + '2. When a Current UI5 Control Context or Current UI5 Web Component Context section is present, treat it as the authoritative list of that control\'s property, aggregation, slot, event, and binding names. Only reference names that appear in it. If asked about a name not listed there, do not describe it from general knowledge — say it is not in the context, or use the rule 3 uncertainty phrase.\n' + '3. When naming a specific property, event, method, or enum value on a UI5 control, only state it if confident it exists. Otherwise use this exact phrase and add no further disclaimers:\n' + 'I\'m not certain exists on — verify in the API reference.\n' + - '4. Prefer runtime data (resolved binding values, console errors, application metadata shown above) over general assumptions when answering.'; + '4. Prefer runtime data (resolved binding values, console errors, application metadata shown above) over general assumptions when answering.\n' + + '5. UI5 Web Components are custom elements (e.g. ui5-button). Reason about them via their tag name, attributes/properties, slots, and events — not sap.* class APIs. Slots are the web-component equivalent of aggregations, and web components have no data bindings or models.'; const style = 'Style:\n' + 'Neutral, direct, and developer-focused. Use code snippets for code. No marketing filler, no generic disclaimers.'; @@ -217,6 +236,10 @@ PromptBuilder.prototype.buildUserPrompt = function (userMessage, inspectionConte * @returns {string} */ PromptBuilder.prototype._buildControlContextBlock = function (control) { + if (control.isWebComponent) { + return this._buildWebComponentContextBlock(control); + } + const identityLines = [ '- Type: ' + (control.type || 'Unknown'), '- ID: ' + (control.id || 'None') @@ -235,11 +258,49 @@ PromptBuilder.prototype._buildControlContextBlock = function (control) { if (aggregationsSection) { sections.push(aggregationsSection); } + const eventsSection = this._renderEventsSection(control.events); + if (eventsSection) { + sections.push(eventsSection); + } const contextBody = identityLines.concat(sections).join('\n'); return 'Current UI5 Control Context:\n' + contextBody; }; +/** + * Build the control-context block for a UI5 Web Component: tag/version identity, + * properties (enum types resolved from the manifest), and slots. Web components + * have no data bindings, so no Bindings section is emitted. + * @private + * @param {Object} control + * @returns {string} + */ +PromptBuilder.prototype._buildWebComponentContextBlock = function (control) { + const identityLines = [ + '- Tag: ' + (control.tag || control.type || 'Unknown'), + '- Version: ' + (control.version || 'Unknown'), + '- ID: ' + (control.id || 'None'), + '- Data bindings: none (web components do not use data binding)' + ]; + + const sections = []; + const propertiesSection = this._renderPropertiesSection(control.properties); + if (propertiesSection) { + sections.push(propertiesSection); + } + const slotsSection = this._renderAggregationsSection(control.aggregations, 'Slots:'); + if (slotsSection) { + sections.push(slotsSection); + } + const eventsSection = this._renderEventsSection(control.events); + if (eventsSection) { + sections.push(eventsSection); + } + + const contextBody = identityLines.concat(sections).join('\n'); + return 'Current UI5 Web Component Context:\n' + contextBody; +}; + /** * Render the Recent Console Errors block, newest-first. * @private @@ -452,9 +513,12 @@ PromptBuilder.prototype._renderBindingLine = function (propertyName, binding) { /** * @private * @param {Object} aggregations + * @param {string} [headerLabel] - Optional section header. Defaults to `'Aggregations:'`. + * Pass `'Slots:'` when rendering web-component slot data. * @returns {string} */ -PromptBuilder.prototype._renderAggregationsSection = function (aggregations) { +PromptBuilder.prototype._renderAggregationsSection = function (aggregations, headerLabel) { + headerLabel = headerLabel || 'Aggregations:'; if (!aggregations || !aggregations.own || !aggregations.own.data) { return ''; } @@ -468,7 +532,28 @@ PromptBuilder.prototype._renderAggregationsSection = function (aggregations) { return _renderAggregationLine(name, data[name]); }); - return this._capSection('Aggregations:', lines.join('\n'), AGGREGATIONS_CAP); + return this._capSection(headerLabel, lines.join('\n'), AGGREGATIONS_CAP); +}; + +/** + * Render the Events section as a list of event names. Values are omitted — only + * the event names are useful to the assistant. Returns '' when there are none. + * @private + * @param {Object} events - `{ own: { data: { : ... } } }` + * @returns {string} + */ +PromptBuilder.prototype._renderEventsSection = function (events) { + if (!events || !events.own || !events.own.data) { + return ''; + } + const names = Object.keys(events.own.data); + if (names.length === 0) { + return ''; + } + const body = names.map(function (name) { + return '- ' + name; + }).join('\n'); + return this._capSection('Events:', body, EVENTS_CAP); }; /** diff --git a/app/scripts/modules/webc/WebCEnumRegistry.js b/app/scripts/modules/webc/WebCEnumRegistry.js index d8d286f4..4b0bfc27 100644 --- a/app/scripts/modules/webc/WebCEnumRegistry.js +++ b/app/scripts/modules/webc/WebCEnumRegistry.js @@ -123,6 +123,25 @@ WebCEnumRegistry.storageKey = function (version) { return 'webc_enums_' + version; }; +/** + * Normalize a runtime-reported version to its leading semver core. Page runtime + * metadata is not always a clean version — e.g. React wrappers can report + * "2.0.0/../../react@18.2.0". Extract the leading major.minor.patch(-prerelease) + * and drop any trailing path/package junk. Returns '' when the string does not + * start with a semver core, so malformed input can never reach a CDN URL or a + * storage key. + * + * @param {string} version + * @returns {string} + */ +WebCEnumRegistry.normalizeVersion = function (version) { + if (typeof version !== 'string') { + return ''; + } + var match = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.+-]+)?/.exec(version.trim()); + return match ? match[0] : ''; +}; + /** * Parse a single attribute `type.text` into an enum value map, or null if it * is not an enum (i.e. not a union of two-or-more quoted string literals). @@ -360,6 +379,14 @@ WebCEnumRegistry.prototype.prime = function (version) { if (!version || !this._fetch) { return Promise.resolve(Object.create(null)); } + // Treat the page-reported version as untrusted: strip any trailing + // path/package junk down to a clean semver core before it is pattern-checked, + // fetched, memoized or stored. An unusable value normalizes to '' and falls + // into the guard below (no fetch). + version = WebCEnumRegistry.normalizeVersion(version); + if (!version) { + return Promise.resolve(Object.create(null)); + } if (!WebCEnumRegistry.VERSION_PATTERN.test(version)) { return Promise.resolve(Object.create(null)); } @@ -424,6 +451,7 @@ WebCEnumRegistry.prototype.prime = function (version) { * @returns {Object|null} `{label: value}` map or null */ WebCEnumRegistry.prototype.getEnumValues = function (version, tag, property) { + version = WebCEnumRegistry.normalizeVersion(version); var map = this._memory[version]; if (!map || !map[tag]) { return null; diff --git a/app/vendor/WebComponentsToolsAPI.js b/app/vendor/WebComponentsToolsAPI.js index 08969ae7..5c3e3541 100644 --- a/app/vendor/WebComponentsToolsAPI.js +++ b/app/vendor/WebComponentsToolsAPI.js @@ -10,6 +10,19 @@ return element && element.isUI5Element === true; } + // Normalize a runtime-reported version to its leading semver core. Page + // runtime metadata is not always a clean version — e.g. React wrappers can + // report "2.0.0/../../react@18.2.0". Extract the leading + // major.minor.patch(-prerelease) and drop any trailing path/package junk. + // Returns '' when the string does not start with a semver core. + function _normalizeVersion(version) { + if (typeof version !== 'string') { + return ''; + } + var match = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.+-]+)?/.exec(version.trim()); + return match ? match[0] : ''; + } + function _getVersion() { if (cachedVersion !== null) { return cachedVersion; @@ -25,7 +38,7 @@ // on the meta element (an Array of runtime objects). // See packages/base/src/Runtimes.ts. if (meta.Runtimes && meta.Runtimes.length > 0 && meta.Runtimes[0].version) { - cachedVersion = meta.Runtimes[0].version; + cachedVersion = _normalizeVersion(meta.Runtimes[0].version); } } catch (e) { // best-effort @@ -259,7 +272,10 @@ _getRuntimes().forEach(function (rt) { var rtSuffix = _safe(function () { return rt.scopingSuffix; }, ''); if (rtSuffix && rtSuffix === suffix) { - version = _safe(function () { return rt.version; }, version) || version; + var candidate = _normalizeVersion(_safe(function () { return rt.version; }, '')); + if (candidate) { + version = candidate; + } } }); return version; diff --git a/tests/modules/ai/PromptBuilder.spec.js b/tests/modules/ai/PromptBuilder.spec.js index 38b3d6f3..ba677603 100644 --- a/tests/modules/ai/PromptBuilder.spec.js +++ b/tests/modules/ai/PromptBuilder.spec.js @@ -117,6 +117,7 @@ describe('PromptBuilder', function () { rule1.should.match(/English/); rule2.should.contain('Current UI5 Control Context'); + rule2.should.contain('Current UI5 Web Component Context'); rule3.should.contain('confident'); rule4.should.match(/runtime data/i); rule4.should.contain('shown above'); @@ -128,6 +129,14 @@ describe('PromptBuilder', function () { prompt.should.contain('I\'m not certain exists on — verify in the API reference.'); }); + it('should include a rule teaching UI5 Web Component vocabulary (tag, slots, no bindings)', function () { + const prompt = promptBuilder.buildSystemPrompt(); + + prompt.should.contain('custom elements'); + prompt.should.contain('Slots are the web-component equivalent of aggregations'); + prompt.should.contain('no data bindings or models'); + }); + it('should include a bad/good copy-me example demonstrating the uncertainty phrase', function () { const prompt = promptBuilder.buildSystemPrompt(); @@ -141,6 +150,13 @@ describe('PromptBuilder', function () { const goodSection = prompt.substring(goodIdx); goodSection.should.match(/I'm not certain .+ exists on .+ — verify in the API reference\./); }); + + it('should ground rule 2 in both the classic and web component context sections', function () { + const prompt = promptBuilder.buildSystemPrompt(); + + prompt.should.contain('Current UI5 Control Context or Current UI5 Web Component Context'); + prompt.should.contain('do not describe it from general knowledge'); + }); }); describe('Current Application Context (with app info)', function () { @@ -344,10 +360,11 @@ describe('PromptBuilder', function () { const RULES = 'Rules:\n' + '1. Always reply in English, regardless of the language of the user\'s message.\n' + - '2. When a Current UI5 Control Context section is present in the user prompt, only reference property, aggregation, event, and binding names that appear in it. If asked about a name not listed there, say so plainly.\n' + + '2. When a Current UI5 Control Context or Current UI5 Web Component Context section is present, treat it as the authoritative list of that control\'s property, aggregation, slot, event, and binding names. Only reference names that appear in it. If asked about a name not listed there, do not describe it from general knowledge — say it is not in the context, or use the rule 3 uncertainty phrase.\n' + '3. When naming a specific property, event, method, or enum value on a UI5 control, only state it if confident it exists. Otherwise use this exact phrase and add no further disclaimers:\n' + 'I\'m not certain exists on — verify in the API reference.\n' + - '4. Prefer runtime data (resolved binding values, console errors, application metadata shown above) over general assumptions when answering.'; + '4. Prefer runtime data (resolved binding values, console errors, application metadata shown above) over general assumptions when answering.\n' + + '5. UI5 Web Components are custom elements (e.g. ui5-button). Reason about them via their tag name, attributes/properties, slots, and events — not sap.* class APIs. Slots are the web-component equivalent of aggregations, and web components have no data bindings or models.'; const STYLE = 'Style:\n' + @@ -488,6 +505,26 @@ describe('PromptBuilder', function () { result.should.contain('- Type: sap.m.Button'); result.should.contain('- ID: myButton'); }); + + it('should use the classic "Current UI5 Control Context:" header and no Slots header for a classic control', function () { + const inspectionContext = { + control: { type: 'sap.m.Button', id: 'btn1' } + }; + + const result = promptBuilder.buildUserPrompt('Q', inspectionContext); + + result.should.contain('Current UI5 Control Context:'); + result.should.not.contain('Current UI5 Web Component Context:'); + result.should.not.contain('Slots:'); + }); + + it('should not add the web-component no-bindings line to a classic control', function () { + const result = promptBuilder.buildUserPrompt('Q', { + control: { type: 'sap.m.Button', id: 'btn1' } + }); + + result.should.not.contain('Data bindings: none'); + }); }); describe('properties rendering', function () { @@ -942,10 +979,7 @@ describe('PromptBuilder', function () { aggregations: { own: { data: { - content: [ - { id: 'btn1', type: 'sap.m.Button' }, - { id: 'btn2', type: 'sap.m.Button' } - ] + content: ['btn1', 'btn2'] } } } @@ -957,13 +991,10 @@ describe('PromptBuilder', function () { result.should.contain('- content: 2 children — btn1, btn2'); }); - it('should render a type histogram when the child count is > 3', function () { + it('should render a plain count when the child count is > 3', function () { const children = []; - for (let i = 0; i < 20; i++) { - children.push({ id: 't' + i, type: 'sap.m.Text' }); - } - for (let i = 0; i < 4; i++) { - children.push({ id: 'b' + i, type: 'sap.m.Button' }); + for (let i = 0; i < 24; i++) { + children.push('c' + i); } const inspectionContext = { control: { @@ -978,7 +1009,7 @@ describe('PromptBuilder', function () { const result = promptBuilder.buildUserPrompt('Q', inspectionContext); - result.should.contain('- content: 24 children (sap.m.Text × 20, sap.m.Button × 4)'); + result.should.contain('- content: 24 children'); }); it('should omit the Aggregations section entirely when own aggregations are empty', function () { @@ -999,7 +1030,7 @@ describe('PromptBuilder', function () { for (let i = 0; i < 100; i++) { const children = []; for (let j = 0; j < 200; j++) { - children.push({ id: 'child' + i + '_' + j, type: 'sap.m.SomeReallyLongTypeName' }); + children.push('child' + i + '_' + j); } data['aggr' + i] = children; } @@ -1099,16 +1130,13 @@ describe('PromptBuilder', function () { own: { data: { customHeader: [], - content: [ - { id: 'btn1', type: 'sap.m.Button' }, - { id: 'btn2', type: 'sap.m.Button' } - ], + content: ['btn1', 'btn2'], footer: (function () { const arr = []; for (let i = 0; i < 5; i++) { - arr.push({ id: 't' + i, type: 'sap.m.Text' }); + arr.push('t' + i); } - arr.push({ id: 'b1', type: 'sap.m.Button' }); + arr.push('b1'); return arr; }()) } @@ -1125,7 +1153,7 @@ describe('PromptBuilder', function () { 'Aggregations:\n' + '- customHeader: empty\n' + '- content: 2 children — btn1, btn2\n' + - '- footer: 6 children (sap.m.Text × 5, sap.m.Button × 1)\n\n' + + '- footer: 6 children\n\n' + 'Now answer: Q'; promptBuilder.buildUserPrompt('Q', inspectionContext).should.equal(expected); @@ -1357,6 +1385,237 @@ describe('PromptBuilder', function () { result.should.equal(expected); }); }); + + describe('events rendering', function () { + it('should render an Events section with event names for a classic control', function () { + const inspectionContext = { + control: { + type: 'sap.m.Button', + id: 'btn1', + events: { own: { data: { press: {}, tap: {} } } } + } + }; + + const result = promptBuilder.buildUserPrompt('Q', inspectionContext); + const events = _extractSection(result, 'Events:'); + + result.should.contain('Events:'); + events.should.contain('press'); + events.should.contain('tap'); + }); + + it('should render an Events section for a web component', function () { + const inspectionContext = { + control: { + isWebComponent: true, + tag: 'ui5-button', + version: '2.4.0', + events: { own: { data: { click: {}, 'active-state-change': {} } } } + } + }; + + const result = promptBuilder.buildUserPrompt('Q', inspectionContext); + const events = _extractSection(result, 'Events:'); + + result.should.contain('Events:'); + events.should.contain('click'); + events.should.contain('active-state-change'); + }); + + it('should omit the Events section when there are no events', function () { + const inspectionContext = { + control: { type: 'sap.m.Button', id: 'btn1' } + }; + + const result = promptBuilder.buildUserPrompt('Q', inspectionContext); + + result.should.not.contain('Events:'); + }); + + it('should omit the Events section when events.own.data is empty', function () { + const inspectionContext = { + control: { isWebComponent: true, tag: 'ui5-button', version: '2.4.0', events: { own: { data: {} } } } + }; + + const result = promptBuilder.buildUserPrompt('Q', inspectionContext); + + result.should.not.contain('Events:'); + }); + }); + + describe('web component control block', function () { + function _webcContext() { + return { + control: { + isWebComponent: true, + tag: 'ui5-button', + version: '2.4.0', + properties: { + own: { + data: { design: { value: 'Emphasized' } }, + types: { design: { Default: 'Default', Emphasized: 'Emphasized' } } + } + }, + aggregations: { + own: { data: { icon: ['ico1'] } } + }, + bindings: { text: { path: '/label', value: 'Save', model: 'default' } } + } + }; + } + + it('should use the "Current UI5 Web Component Context:" header', function () { + const result = promptBuilder.buildUserPrompt('Q', _webcContext()); + + result.should.contain('Current UI5 Web Component Context:'); + result.should.not.contain('Current UI5 Control Context:'); + }); + + it('should render Tag and Version identity lines', function () { + const result = promptBuilder.buildUserPrompt('Q', _webcContext()); + + result.should.contain('- Tag: ui5-button'); + result.should.contain('- Version: 2.4.0'); + }); + + it('should render aggregations under a "Slots:" header, not "Aggregations:"', function () { + const result = promptBuilder.buildUserPrompt('Q', _webcContext()); + + result.should.contain('Slots:'); + result.should.not.contain('Aggregations:'); + const slots = _extractSection(result, 'Slots:'); + slots.should.contain('icon'); + }); + + it('should omit the Bindings section for a web component even when bindings are present', function () { + const result = promptBuilder.buildUserPrompt('Q', _webcContext()); + + result.should.not.contain('Bindings:'); + }); + + it('should still render enum property values inline', function () { + const result = promptBuilder.buildUserPrompt('Q', _webcContext()); + const props = _extractPropertiesSection(result); + + props.should.contain('design'); + props.should.contain('(enum: Default, Emphasized)'); + }); + + it('should omit the Slots section when the web component has no aggregations', function () { + const ctx = { + control: { + isWebComponent: true, + tag: 'ui5-busy-indicator', + version: '2.4.0', + properties: { own: { data: { active: { value: true } }, types: {} } } + } + }; + + const result = promptBuilder.buildUserPrompt('Q', ctx); + + result.should.contain('Current UI5 Web Component Context:'); + result.should.not.contain('Slots:'); + }); + + it('should render Slots when aggregations have data', function () { + const ctx = { + control: { + isWebComponent: true, + tag: 'ui5-list', + version: '2.4.0', + aggregations: { own: { data: { items: ['i1'] } } } + } + }; + + const result = promptBuilder.buildUserPrompt('Q', ctx); + + result.should.contain('Slots:'); + result.should.not.contain('Aggregations:'); + }); + + it('should render slot child IDs from the real webcMain DataView envelope shape', function () { + // webcMain._formatAggregations wraps each slot in + // { options, data: { 'content (id)': , 'slot type': str } }. + // The content array holds element-ID strings (WebComponentsToolsAPI._getSlottedContent). + const ctx = { + control: { + isWebComponent: true, + tag: 'ui5-list', + version: '2.4.0', + aggregations: { + own: { + data: { + items: { + options: { title: 'items', expandable: true, expanded: true, editableValues: false, showTypeInfo: true }, + data: { 'content (id)': ['li1', 'li2'], 'slot type': 'HTMLElement' } + } + } + } + } + } + }; + + const result = promptBuilder.buildUserPrompt('Q', ctx); + const slots = _extractSection(result, 'Slots:'); + + slots.should.contain('items'); + slots.should.contain('li1'); + slots.should.contain('li2'); + }); + + it('should render classic aggregation child IDs from the real controlUtils DataView envelope shape', function () { + // controlUtils._formatAggregationValues wraps a populated array in a nested DataView: + // { options, data: { 'content (id)': { options, data: }, 'aggregation type': str } }. + // The content array holds element-ID strings (ToolsAPI._getAggregationContent). + const ctx = { + control: { + type: 'sap.m.Page', + aggregations: { + own: { + data: { + content: { + options: { title: 'content', expandable: true, expanded: true, editableValues: false, showTypeInfo: true }, + data: { + 'content (id)': { + options: { expandable: true, expanded: false, editableValues: false, showTypeInfo: true }, + data: ['btn1', 'btn2'] + }, + 'aggregation type': 'sap.ui.core.Control' + } + } + } + } + } + } + }; + + const result = promptBuilder.buildUserPrompt('Q', ctx); + const agg = _extractSection(result, 'Aggregations:'); + + agg.should.contain('content'); + agg.should.contain('btn1'); + agg.should.contain('btn2'); + }); + + it('should fall back to Unknown for missing tag and version', function () { + const ctx = { + control: { isWebComponent: true } + }; + + const result = promptBuilder.buildUserPrompt('Q', ctx); + + result.should.contain('- Tag: Unknown'); + result.should.contain('- Version: Unknown'); + }); + + it('should state explicitly that a web component has no data bindings', function () { + const result = promptBuilder.buildUserPrompt('Q', { + control: { isWebComponent: true, tag: 'ui5-button', version: '2.4.0' } + }); + + result.should.contain('Data bindings: none'); + }); + }); }); // ---- helpers for section-extraction assertions ----------------------------- diff --git a/tests/modules/webc/WebCEnumRegistry.spec.js b/tests/modules/webc/WebCEnumRegistry.spec.js index 2bc3d940..bf3971e2 100644 --- a/tests/modules/webc/WebCEnumRegistry.spec.js +++ b/tests/modules/webc/WebCEnumRegistry.spec.js @@ -73,6 +73,34 @@ const BUTTON_MANIFEST = { describe('WebCEnumRegistry', function () { + describe('normalizeVersion', function () { + it('extracts the leading semver core, dropping trailing path/package junk', function () { + expect(WebCEnumRegistry.normalizeVersion('2.0.0/../../react@18.2.0')).to.equal('2.0.0'); + }); + + it('returns a clean semver unchanged', function () { + expect(WebCEnumRegistry.normalizeVersion('2.26.0')).to.equal('2.26.0'); + }); + + it('keeps a prerelease suffix', function () { + expect(WebCEnumRegistry.normalizeVersion('1.120.3-rc.1')).to.equal('1.120.3-rc.1'); + }); + + it('trims surrounding whitespace', function () { + expect(WebCEnumRegistry.normalizeVersion(' 2.4.0 ')).to.equal('2.4.0'); + }); + + it('returns empty string when there is no leading semver core', function () { + expect(WebCEnumRegistry.normalizeVersion('garbage')).to.equal(''); + expect(WebCEnumRegistry.normalizeVersion('/../../etc')).to.equal(''); + }); + + it('returns empty string for non-string input', function () { + expect(WebCEnumRegistry.normalizeVersion(undefined)).to.equal(''); + expect(WebCEnumRegistry.normalizeVersion(null)).to.equal(''); + }); + }); + describe('parseEnumUnion', function () { it('parses a union of quoted string literals into a label->value map', function () { const values = WebCEnumRegistry.parseEnumUnion('"Default" | "Emphasized" | "Positive"'); @@ -274,7 +302,35 @@ describe('WebCEnumRegistry', function () { }); }); - it('resolves to an empty map without fetching when version contains path-traversal characters', function () { + it('normalizes a malformed runtime version before fetch, storage and lookup', function () { + const fakeStorage = createFakeStorage(); + const fake = createFakeFetch([{ + match: function (url) { return url.indexOf('@ui5/webcomponents@2.0.0') !== -1; }, + manifest: BUTTON_MANIFEST + }]); + const registry = new WebCEnumRegistry({ + fetch: fake.fetch, + storage: fakeStorage.storage, + packages: ['@ui5/webcomponents'] + }); + + return registry.prime('2.0.0/../../react@18.2.0').then(function (map) { + // The malformed string never reaches a storage key. + expect(fakeStorage.data[WebCEnumRegistry.storageKey('2.0.0')]).to.not.equal(undefined); + expect(fakeStorage.data['webc_enums_2.0.0/../../react@18.2.0']).to.equal(undefined); + // No fetched URL carries the junk tail. + fake.calls.forEach(function (url) { + expect(url.indexOf('react@18.2.0')).to.equal(-1); + }); + expect(map['ui5-button'].design) + .to.deep.equal({ Default: 'Default', Emphasized: 'Emphasized', Positive: 'Positive' }); + // Lookups by the malformed and the normalized version agree. + expect(registry.getEnumValues('2.0.0/../../react@18.2.0', 'ui5-button', 'design')) + .to.deep.equal(registry.getEnumValues('2.0.0', 'ui5-button', 'design')); + }); + }); + + it('strips a traversal/package tail to the leading semver core before fetching', function () { const fake = createFakeFetch([{ match: function () { return true; }, manifest: BUTTON_MANIFEST @@ -285,9 +341,15 @@ describe('WebCEnumRegistry', function () { packages: ['@ui5/webcomponents'] }); - return registry.prime('2.0.0/../../@evil/pkg@1.0.0').then(function (map) { - expect(fake.calls).to.have.length(0); - expect(Object.keys(map)).to.have.length(0); + return registry.prime('2.0.0/../../@evil/pkg@1.0.0').then(function () { + // The traversal/evil-package tail never reaches any CDN URL; + // only the clean @2.0.0 specifier is requested. + expect(fake.calls.length).to.be.above(0); + fake.calls.forEach(function (url) { + expect(url.indexOf('..')).to.equal(-1); + expect(url.indexOf('@evil')).to.equal(-1); + expect(url.indexOf('@ui5/webcomponents@2.0.0')).to.not.equal(-1); + }); }); });