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), '') +})