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 … 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 First real paragraph of prose. Second paragraph that should not appear. First real paragraph of prose. … Intro paragraph. Intro paragraph. … 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 + ' 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 = '
'
+ const result = await truncate(html, true)
+ assert.equal(result, '