From cb479bae0c8b5ef52a43f4cffbcb73350c58e533 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Tue, 25 Aug 2026 15:23:14 +0100 Subject: [PATCH] fix: stop truncating single-paragraph tooltips to their first inline element render-property-descriptions.js emits a single-paragraph property description as bare inline HTML with no

wrapper -- deliberately, "what a tooltip wants" -- and only wraps richer content (multiple paragraphs, lists, admonitions) in real block markup. truncateDescriptionHtml's "truncate if more than one child" check only ever counts container.children, which is elements only, never text nodes. A single paragraph containing several inline elements (a span, a link, another span) has more than one "child" despite being one block of prose, so it got truncated to blocks[0], silently dropping every text node around it. Confirmed live on docs.redpanda.com: tombstone_retention_ms's tooltip showed only "cloud_storage_enabled" (the first of three spans in its one real paragraph), and kafka_max_message_size_upper_limit_bytes's tooltip showed only a bare link reading "max.message.bytes" (the first inline element in its one real paragraph). Now only truncate when the top level actually contains real block structure (p, div, ul, ol, dl, table, blockquote, pre) to truncate. Added a regression test that runs the real extracted function in a real browser page against the two live descriptions above, plus a genuine multi-paragraph case to confirm truncation still applies where it's supposed to. --- package.json | 3 +- src/js/19-property-tooltips.js | 22 +++- .../truncate-description-html.test.js | 112 ++++++++++++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 tests/property-tooltips/truncate-description-html.test.js diff --git a/package.json b/package.json index b22dfd9c..ee7156b2 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,8 @@ "test:interactive": "node tests/bloblang-interactive/test-runner.js", "test:negative-cache": "node tests/negative-cache/test-runner.js", "test:head-meta": "node --test tests/head-meta/*.test.js", - "test:all": "npm run test:playground && npm run test:interactive && npm run test:negative-cache && npm run test:head-meta", + "test:property-tooltips": "node --test tests/property-tooltips/*.test.js", + "test:all": "npm run test:playground && npm run test:interactive && npm run test:negative-cache && npm run test:head-meta && npm run test:property-tooltips", "build:wasm": "cd blobl-editor/wasm && GOOS=js GOARCH=wasm go build -o ../../src/static/blobl.wasm .", "copy:wasm-exec": "cp \"$(go env GOROOT)/lib/wasm/wasm_exec.js\" src/js/vendor/", "serve:playground": "npx serve ." diff --git a/src/js/19-property-tooltips.js b/src/js/19-property-tooltips.js index 27ed473f..a97de45e 100644 --- a/src/js/19-property-tooltips.js +++ b/src/js/19-property-tooltips.js @@ -338,13 +338,33 @@ * ifdef::env-cloud[] conditionals were evaluated once, correctly, against * the real page that produced it -- not guessed at again client-side. */ + // render-property-descriptions.js emits a single-paragraph description as + // bare inline HTML with no

wrapper -- "what a tooltip wants" per its + // own comment -- and only wraps in real block markup (

, lists, + // admonitions, ...) for something richer. container.children below only + // ever counts elements, never text nodes, so a single paragraph containing + // several inline elements (a span, a link, another span) has + // more than one "child" despite being one block of prose. Truncating to + // blocks[0] in that case grabs one inline span and silently drops every + // text node around it -- e.g. a description built as + // 'The retention time... cloud_storage_enabled, ...` on + // a live tooltip rendering as just "cloud_storage_enabled". Only truncate + // when the top level actually contains real block structure to truncate. + var BLOCK_TAGS = { P: 1, DIV: 1, UL: 1, OL: 1, DL: 1, TABLE: 1, BLOCKQUOTE: 1, PRE: 1 } function truncateDescriptionHtml (html, summaryOnly) { if (!html) return '' if (!summaryOnly) return html var container = document.createElement('div') container.innerHTML = html var blocks = container.children - if (blocks.length <= 1) return html + var hasBlockStructure = false + for (var i = 0; i < blocks.length; i++) { + if (BLOCK_TAGS[blocks[i].tagName]) { + hasBlockStructure = true + break + } + } + if (!hasBlockStructure || blocks.length <= 1) return html return blocks[0].outerHTML + '

' } diff --git a/tests/property-tooltips/truncate-description-html.test.js b/tests/property-tooltips/truncate-description-html.test.js new file mode 100644 index 00000000..d79eede5 --- /dev/null +++ b/tests/property-tooltips/truncate-description-html.test.js @@ -0,0 +1,112 @@ +'use strict' + +// Verifies src/js/19-property-tooltips.js's truncateDescriptionHtml against +// the REAL implementation (extracted from the shipped source, not a +// reimplementation), run inside a real browser page so document.createElement +// behaves exactly as it does on the live site. +// +// The bug this guards: render-property-descriptions.js emits a single +// paragraph's description as bare inline HTML with no

wrapper -- by +// design, "what a tooltip wants". container.children only ever counts +// elements, never text nodes, so a single paragraph containing several +// inline elements (a span, a link, another span) has more than +// one "child" despite being one block of prose. The old implementation read +// that as multiple paragraphs and truncated to blocks[0], silently dropping +// every text node around it. Live examples this actually did in production: +// tombstone_retention_ms's tooltip showed only "cloud_storage_enabled" (the +// first of three spans inside its one real paragraph), and +// kafka_max_message_size_upper_limit_bytes's tooltip showed only a bare link +// reading "max.message.bytes" (the first inline element, a link wrapping a +// , inside its one real paragraph). + +const test = require('node:test') +const assert = require('node:assert/strict') +const path = require('node:path') +const fs = require('node:fs') +const puppeteer = require('puppeteer') + +const ROOT = path.join(__dirname, '..', '..') +const SRC = fs.readFileSync(path.join(ROOT, 'src/js/19-property-tooltips.js'), 'utf8') + +// Extract just the BLOCK_TAGS constant and the function under test out of +// the IIFE -- the file as a whole assumes fetch/localStorage globals this +// test never exercises. +const BLOCK = SRC.slice( + SRC.indexOf('var BLOCK_TAGS ='), + SRC.indexOf('function createPropertyTooltip') +) + +// Real production description_html, captured from docs.redpanda.com's +// topic-properties page (verified live, then fixed here). +const TOMBSTONE_RETENTION_MS = + 'The retention time for tombstone records in a compacted topic. For Tiered ' + + 'Storage v1, cannot be enabled at the same time as any of ' + + 'cloud_storage_enabled, cloud_storage_enable_remote_read, ' + + 'or cloud_storage_enable_remote_write. This restriction does not ' + + 'apply to topics that use Tiered Storage v2, available ' + + 'starting in Redpanda v26.2. A typical default setting is 86400000, ' + + 'or 24 hours.' + +const KAFKA_MAX_MESSAGE_SIZE_UPPER_LIMIT_BYTES = + 'The maximum value you can set for the ' + + 'max.message.bytes topic property. When set to null, ' + + 'no limit is enforced.' + +let browser +let page + +test.before(async () => { + browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], + }) + page = await browser.newPage() + await page.evaluate(BLOCK + '\nwindow.__truncateDescriptionHtml = truncateDescriptionHtml;') +}) + +test.after(async () => { + await browser.close() +}) + +async function truncate (html, summaryOnly) { + return page.evaluate( + (h, s) => window.__truncateDescriptionHtml(h, s), + html, + summaryOnly + ) +} + +test('a single paragraph with several inline elements is not mangled', async () => { + const result = await truncate(TOMBSTONE_RETENTION_MS, true) + assert.equal(result, TOMBSTONE_RETENTION_MS) + assert.match(result, /tombstone records/) +}) + +test('a single paragraph starting with a link is not mangled', async () => { + const result = await truncate(KAFKA_MAX_MESSAGE_SIZE_UPPER_LIMIT_BYTES, true) + assert.equal(result, KAFKA_MAX_MESSAGE_SIZE_UPPER_LIMIT_BYTES) + assert.match(result, /maximum value/) +}) + +test('real multi-paragraph content still truncates to the first paragraph', async () => { + const html = '

First real paragraph of prose.

Second paragraph that should not appear.

' + const result = await truncate(html, true) + assert.equal(result, '

First real paragraph of prose.

') +}) + +test('a list still truncates, since a list is real block structure', async () => { + const html = '

Intro paragraph.

  • one
  • two
' + const result = await truncate(html, true) + assert.equal(result, '

Intro paragraph.

') +}) + +test('summaryOnly=false returns the html untouched regardless of structure', async () => { + const result = await truncate(TOMBSTONE_RETENTION_MS, false) + assert.equal(result, TOMBSTONE_RETENTION_MS) +}) + +test('empty html returns an empty string', async () => { + assert.equal(await truncate('', true), '') +})