diff --git a/package.json b/package.json index 25511df7e..062324ebf 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,9 @@ "private": true, "type": "module", "scripts": { - "test": "npm run test:systemone && npm run test:systemone:fast && node --test test/browser-dialogs.mjs && npm run test:firefox-bidi && npm run test:runtime-lifecycle && npm run test:provider-limits && npm run test:accessibility-tree-benchmark && npm run test:toolbar-guard && npm run test:pdf-read && npm run test:pdf-selection && npm run test:social-contract && npm run test:safesocial && npm run test:build-unpacked && npm run test:attachment-drop && node test/run.js && node test/selection-scope-restoration.mjs && node scripts/benchmark-offline-relevance.mjs && npm run test:security", + "test": "npm run test:markdown && npm run test:systemone && npm run test:systemone:fast && node --test test/browser-dialogs.mjs && npm run test:firefox-bidi && npm run test:runtime-lifecycle && npm run test:provider-limits && npm run test:accessibility-tree-benchmark && npm run test:toolbar-guard && npm run test:pdf-read && npm run test:pdf-selection && npm run test:social-contract && npm run test:safesocial && npm run test:build-unpacked && npm run test:attachment-drop && node test/run.js && node test/selection-scope-restoration.mjs && node scripts/benchmark-offline-relevance.mjs && npm run test:security", "test:provider-limits": "node test/provider-model-limits.mjs", + "test:markdown": "node --test test/markdown-render.mjs", "test:accessibility-tree-benchmark": "node --test test/llm/lib/accessibility-tree-formats.test.mjs && node test/llm/accessibility-tree-benchmark.mjs --no-exact-tokenizer --check", "test:attachment-drop": "node test/attachment-drop.mjs", "test:security": "node test/security/injection-corpus.mjs", diff --git a/src/chrome/src/ui/history-text.js b/src/chrome/src/ui/history-text.js index 074daadec..c72b3b6d6 100644 --- a/src/chrome/src/ui/history-text.js +++ b/src/chrome/src/ui/history-text.js @@ -125,10 +125,23 @@ export function historyTextFromElement(root, { markdown = true } = {}) { if (markdown && tagName === 'PRE') { const language = String(node.parentElement?.querySelector?.('.code-lang')?.textContent || '').trim(); ensureBreak(); - output += `\`\`\`${language}\n`; + const beforeCode = output; + output = ''; for (const child of Array.from(node.childNodes || [])) visit(child, false, true); ensureBreak(); - output += '```'; + const code = output; + // History must not reintroduce ambiguous fences around a Markdown + // document (or any code sample containing literal backtick runs). + const fenceCharacter = language.includes('`') ? '~' : '`'; + let fenceLength = 3; + for (const match of code.matchAll(new RegExp(`${fenceCharacter}{3,}`, 'g'))) { + fenceLength = Math.max(fenceLength, match[0].length + 1); + } + const fence = fenceCharacter.repeat(fenceLength); + // Keep an info string separate from the fence so a label beginning with + // the same marker cannot extend the opener past its matching closer. + const info = language ? ` ${language}` : ''; + output = `${beforeCode}${fence}${info}\n${code}${fence}`; return; } if (markdown && tagName === 'CODE' && !inPre) { diff --git a/src/chrome/src/ui/markdown-render.js b/src/chrome/src/ui/markdown-render.js index f8aa7a878..8fb55931d 100644 --- a/src/chrome/src/ui/markdown-render.js +++ b/src/chrome/src/ui/markdown-render.js @@ -18,6 +18,7 @@ const LANGUAGE_ALIASES = Object.freeze({ go: 'clike', rust: 'clike', rs: 'clike', swift: 'clike', php: 'clike', ruby: 'clike', rb: 'clike', }); +const INTERRUPTING_HTML_BLOCK_TAGS = new Set(('address article aside base basefont blockquote body caption center col colgroup dd details dialog dir div dl dt fieldset figcaption figure footer form frame frameset h1 h2 h3 h4 h5 h6 head header hr html iframe legend li link main menu menuitem nav noframes ol optgroup option p param search section summary table tbody td tfoot th thead title tr track ul').split(' ')); const JS_KEYWORDS = new Set(('abstract as async await break case catch class const continue debugger declare default delete do else enum export extends finally for from function get if implements import in infer instanceof interface keyof let namespace new of private protected public readonly return satisfies set static super switch throw try type typeof var void while with yield').split(' ')); const JS_CONSTANTS = new Set(('true false null undefined NaN Infinity').split(' ')); @@ -57,6 +58,618 @@ export function codeFenceLanguage(infoString) { return String(infoString || '').trim().split(/\s+/, 1)[0] || ''; } +function fenceContainer(prefix, indentation = '') { + const source = String(prefix); + const quotePrefix = quotePrefixAt(source); + let remainder = source; + let listPrefix = ''; + let quoteDepth = 0; + let overIndentedQuote = false; + let lastListMarkerWidth = 0; + let lastListPrefixWidth = 0; + const listIndentGroups = [0]; + while (remainder) { + const containerStartColumn = indentationColumns(source.slice(0, source.length - remainder.length)); + // Preserve a deeply indented quote only long enough to resolve a + // continuation from an enclosing list; it is never a container itself. + const quote = quoteMarkerAt(remainder, containerStartColumn, true); + if (quote) { + quoteDepth += 1; + overIndentedQuote ||= quote.overIndented; + listIndentGroups.push(0); + remainder = remainder.slice(quote.length); + continue; + } + const list = listPrefixAt(remainder, containerStartColumn); + if (!list) break; + listPrefix += list; + lastListPrefixWidth = indentationColumnsAt(list, containerStartColumn) - containerStartColumn; + lastListMarkerWidth = indentationColumnsAt(list.replace(/[ \t]+$/, ''), containerStartColumn) - containerStartColumn; + const implicitListPadding = !/[ \t]$/.test(list) && remainder === list ? 1 : 0; + listIndentGroups[listIndentGroups.length - 1] += indentationColumnsAt(list, containerStartColumn) - containerStartColumn + + implicitListPadding; + remainder = remainder.slice(list.length); + } + return { + containerPrefix: source.slice(0, source.length - remainder.length), + quotePrefix, + quoteDepth, + listPrefix, + lastListMarkerWidth, + lastListPrefixWidth, + listIndentGroups, + overIndentedQuote, + leadingQuoteIndent: indentationColumns(source.match(/^[ \t]*(?=>)/)?.[0] || ''), + rawPrefix: source, + indentation: String(indentation), + }; +} + +function indentationColumnsAt(value, startColumn = 0) { + let columns = startColumn; + for (const character of String(value)) { + columns += character === '\t' ? 4 - (columns % 4) : 1; + } + return columns; +} + +function indentationColumns(value) { + return indentationColumnsAt(value); +} + +function listPrefixAt(value, startColumn = 0) { + const source = String(value); + const marker = source.match(/^[ \t]*(?:[-+*]|\d{1,9}[.)])/); + const leadingIndentation = marker?.[0].match(/^[ \t]*/)?.[0] || ''; + const next = source[marker?.[0].length]; + if (!marker + || indentationColumnsAt(leadingIndentation, startColumn) - startColumn > 3 + || (next && !/^[ \t]$/.test(next))) return null; + let offset = marker[0].length; + let column = indentationColumnsAt(marker[0], startColumn); + let paddingColumns = 0; + while (/^[ \t]$/.test(value[offset] || '')) { + const width = value[offset] === '\t' ? 4 - (column % 4) : 1; + paddingColumns += width; + column += width; + offset += 1; + } + if (paddingColumns > 4 && source[marker[0].length] === '\t') { + return `${source.slice(0, marker[0].length)} `; + } + return source.slice(0, marker[0].length + (paddingColumns <= 4 ? offset - marker[0].length : 1)); +} + +function quoteMarkerAt(value, startColumn = 0, allowOverIndentation = false) { + let offset = 0; + let column = startColumn; + while (/^[ \t]$/.test(value[offset] || '')) { + const width = value[offset] === '\t' ? 4 - (column % 4) : 1; + if (!allowOverIndentation && column + width - startColumn > 3) break; + column += width; + offset += 1; + } + const marker = String(value).slice(offset).match(/^>[ \t]?/); + if (!marker) return null; + return { + length: offset + marker[0].length, + column: indentationColumnsAt(marker[0], column), + overIndented: column - startColumn > 3, + }; +} + +function quotePrefixAt(value, startColumn = 0) { + const source = String(value); + let offset = 0; + let column = startColumn; + while (offset < source.length) { + const quote = quoteMarkerAt(source.slice(offset), column); + if (!quote) break; + offset += quote.length; + column = quote.column; + } + return source.slice(0, offset); +} + +function fenceIndentationColumns(container) { + const startColumn = indentationColumns(container.rawPrefix); + return indentationColumnsAt(container.indentation, startColumn) - startColumn; +} + +function consumeIndentationColumns(line, columns, startColumn = 0) { + let offset = 0; + let consumed = 0; + let column = startColumn; + while (offset < line.length && consumed < columns && /^[ \t]$/.test(line[offset])) { + if (line[offset] === '\t') { + const tabWidth = 4 - (column % 4); + if (consumed + tabWidth > columns) { + return `${' '.repeat(consumed + tabWidth - columns)}${line.slice(offset + 1)}`; + } + consumed += tabWidth; + column += tabWidth; + } else { + consumed += 1; + column += 1; + } + offset += 1; + } + return consumed === columns ? line.slice(offset) : null; +} + +function stripIndentationColumns(line, columns, startColumn = 0) { + const stripped = consumeIndentationColumns(line, columns, startColumn); + if (stripped != null) return stripped; + return String(line).replace(/^[ \t]*/, ''); +} + +function stripContainerPrefix(line, container) { + let remainder = String(line); + let column = 0; + for (let quoteIndex = 0; quoteIndex < container.quoteDepth; quoteIndex += 1) { + if (!remainder.trim()) return ''; + const listIndent = container.listIndentGroups[quoteIndex]; + remainder = consumeIndentationColumns(remainder, listIndent, column); + if (remainder == null) return null; + column += listIndent; + const quote = quoteMarkerAt(remainder, column); + if (!quote) return null; + column = quote.column; + remainder = remainder.slice(quote.length); + } + if (!remainder.trim()) return ''; + return consumeIndentationColumns(remainder, container.listIndentGroups.at(-1), column); +} + +function fenceIndentationInContainer(fence, container) { + const indentation = fenceIndentationColumns(fence); + if (fence.listPrefix) return indentation; + return Math.max(0, indentation - container.listIndentGroups.at(-1)); +} + +function fenceCloserInContainer(opener, candidate) { + if (!opener.quoteDepth && !opener.listPrefix) { + return !candidate.quoteDepth && !candidate.listPrefix + && indentationColumns(candidate.indentation) <= 3; + } + const remainder = stripContainerPrefix(`${candidate.rawPrefix}${candidate.indentation}x`, opener); + const extraIndentation = remainder?.slice(0, -1); + return remainder?.endsWith('x') + && /^[ \t]*$/.test(extraIndentation) + && indentationColumnsAt(extraIndentation, indentationColumns(opener.rawPrefix)) - indentationColumns(opener.rawPrefix) <= 3; +} + +function isFenceCloser(opener, candidate, fence, info) { + return fenceCloserInContainer(opener.container, candidate) + && !info.trim() + && fence[0] === opener.fence[0] + && fence.length >= opener.fence.length; +} + +function nestedFenceCloserIndex(matches, containers, closerIndexes, cache, startIndex, fence, container) { + const key = `${fence[0]}:${fence.length}:${container.quoteDepth}:${container.listIndentGroups.join(',')}`; + let compatible = cache.get(key); + if (!compatible) { + const nested = { fence, container }; + compatible = (closerIndexes.get(fence[0]) || []).filter(index => ( + isFenceCloser(nested, containers[index], matches[index].fence, matches[index].info) + )); + cache.set(key, compatible); + } + let low = 0; + let high = compatible.length; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if (compatible[middle] <= startIndex) low = middle + 1; + else high = middle; + } + return low < compatible.length ? compatible[low] : -1; +} + +function outerFenceCloserAfterNested( + source, + matches, + containers, + closerIndexes, + cache, + startIndex, + outer, + noListScanPositions, +) { + for (let index = startIndex + 1; index < matches.length; index += 1) { + const match = matches[index]; + const container = containers[index]; + if (isFenceCloser(outer, container, match.fence, match.info)) return index; + + const validOpening = match.fence[0] !== '`' || !match.info.includes('`'); + if (!validOpening || !match.info.trim()) continue; + const nestedContainer = (fenceIndentationColumns(container) > 3 || container.leadingQuoteIndent > 3 || container.overIndentedQuote) + ? listContinuationContainer(source, match.index, match.prefix, match.indentation, noListScanPositions) + : container; + if (!nestedContainer) continue; + const nestedCloserIndex = nestedFenceCloserIndex( + matches, + containers, + closerIndexes, + cache, + index, + match.fence, + nestedContainer, + ); + if (nestedCloserIndex >= 0) index = nestedCloserIndex; + } + return -1; +} + +function startsInterruptingHtmlBlock(content) { + // CommonMark HTML block types 1–6 interrupt paragraphs; inline tags do not. + if (/^(?:', '', '', '', '
', '
']) { + const source = `- item\n${interruptingHtml}\n \`\`\`text\n hi\nOutside`; + const blocks = []; + assert.equal(helpers.replaceMarkdownCodeFences(source, (info, code) => { + blocks.push({ info, code }); + return 'BLOCK'; + }), `- item\n${interruptingHtml}\nBLOCK`); + assert.deepEqual(blocks, [{ info: 'text', code: 'hi\nOutside' }]); + } + + for (const continuation of ['lazy continuation', '2. continuation', '2.', '---text']) { + const source = `> - item\n> ${continuation}\n> \`\`\`text\n> hi\n> Outside`; + const blocks = []; + assert.equal(helpers.replaceMarkdownCodeFences(source, (info, code) => { + blocks.push({ info, code }); + return 'BLOCK'; + }), `> - item\n> ${continuation}\n> BLOCK\n> Outside`); + assert.deepEqual(blocks, [{ info: 'text', code: 'hi\n' }]); + } + + const noCloser = '> ```text\n> inside\nOutside'; + const noCloserBlocks = []; + assert.equal(helpers.replaceMarkdownCodeFences(noCloser, (info, code) => { + noCloserBlocks.push({ info, code }); + return 'BLOCK'; + }), '> BLOCK\nOutside'); + assert.deepEqual(noCloserBlocks, [{ info: 'text', code: 'inside\n' }]); + + for (const [source, expected] of [ + ['> ```text\nOutside', '> BLOCK\nOutside'], + ['- ```text\nOutside', '- BLOCK\nOutside'], + ]) { + const emptyBlocks = []; + assert.equal(helpers.replaceMarkdownCodeFences(source, (info, code) => { + emptyBlocks.push({ info, code }); + return 'BLOCK'; + }), expected); + assert.deepEqual(emptyBlocks, [{ info: 'text', code: '' }]); + } + + const emptyListItem = '-\n ```text\n hi\nOutside'; + const emptyListBlocks = []; + assert.equal(helpers.replaceMarkdownCodeFences(emptyListItem, (info, code) => { + emptyListBlocks.push({ info, code }); + return 'BLOCK'; + }), '-\n BLOCK\nOutside'); + assert.deepEqual(emptyListBlocks, [{ info: 'text', code: 'hi\n' }]); + + const emptyListBoundary = '-\n ```text\n Outside'; + const emptyListBoundaryBlocks = []; + assert.equal(helpers.replaceMarkdownCodeFences(emptyListBoundary, (info, code) => { + emptyListBoundaryBlocks.push({ info, code }); + return 'BLOCK'; + }), '-\n BLOCK\n Outside'); + assert.deepEqual(emptyListBoundaryBlocks, [{ info: 'text', code: '' }]); + + const trailingQuoteBlank = '> ```text\n> hello\n>\n'; + const trailingQuoteBlocks = []; + assert.equal(helpers.replaceMarkdownCodeFences(trailingQuoteBlank, (info, code) => { + trailingQuoteBlocks.push({ info, code }); + return 'BLOCK'; + }), '> BLOCK'); + assert.deepEqual(trailingQuoteBlocks, [{ info: 'text', code: 'hello\n\n' }]); + + const unprefixedTrailingBlank = '> ~~~text\n> hello\n\n'; + const unprefixedTrailingBlocks = []; + assert.equal(helpers.replaceMarkdownCodeFences(unprefixedTrailingBlank, (info, code) => { + unprefixedTrailingBlocks.push({ info, code }); + return 'BLOCK'; + }), '> BLOCK\n'); + assert.deepEqual(unprefixedTrailingBlocks, [{ info: 'text', code: 'hello\n' }]); + + const quotedBlankBeforeProse = '> ~~~text\n> hello\n>\nOutside'; + const quotedBlankBlocks = []; + assert.equal(helpers.replaceMarkdownCodeFences(quotedBlankBeforeProse, (info, code) => { + quotedBlankBlocks.push({ info, code }); + return 'BLOCK'; + }), '> BLOCK\nOutside'); + assert.deepEqual(quotedBlankBlocks, [{ info: 'text', code: 'hello\n\n' }]); + + const escapedQuote = '> ```text\n> inside\nOutside\n> ```\nAfter'; + const quoteBlocks = []; + assert.equal(helpers.replaceMarkdownCodeFences(escapedQuote, (info, code) => { + quoteBlocks.push({ info, code }); + return 'BLOCK'; + }), '> BLOCK\nOutside\n> BLOCK\nAfter'); + assert.deepEqual(quoteBlocks, [{ info: 'text', code: 'inside\n' }, { info: '', code: '' }]); + + const laterBlock = '> ```text\n> inside\nOutside\n```js\ncode\n```\nAfter'; + const laterBlocks = []; + assert.equal(helpers.replaceMarkdownCodeFences(laterBlock, (info, code) => { + laterBlocks.push({ info, code }); + return 'BLOCK'; + }), '> BLOCK\nOutside\nBLOCK\nAfter'); + assert.deepEqual(laterBlocks, [ + { info: 'text', code: 'inside\n' }, + { info: 'js', code: 'code\n' }, + ]); + + const adjacentBlock = '> ```text\n> inside\n```js\ncode\n```\nAfter'; + const adjacentBlocks = []; + assert.equal(helpers.replaceMarkdownCodeFences(adjacentBlock, (info, code) => { + adjacentBlocks.push({ info, code }); + return 'BLOCK'; + }), '> BLOCK\nBLOCK\nAfter'); + assert.deepEqual(adjacentBlocks, [ + { info: 'text', code: 'inside\n' }, + { info: 'js', code: 'code\n' }, + ]); + + const quotedList = '> - ```text\n> hello\n>\n> again\n> ```'; + const quotedListBlocks = []; + assert.equal(helpers.replaceMarkdownCodeFences(quotedList, (info, code) => { + quotedListBlocks.push({ info, code }); + return 'BLOCK'; + }), '> - BLOCK'); + assert.deepEqual(quotedListBlocks, [{ info: 'text', code: 'hello\n\nagain\n' }]); + }); + + test(`${build}: tab-indented list fences use visual indentation columns`, () => { + const source = '-\t```text\n\tvalue\n\t```\nAfter'; + const blocks = []; + const remaining = helpers.replaceMarkdownCodeFences(source, (info, code) => { + blocks.push({ info, code }); + return 'BLOCK'; + }); + assert.deepEqual(blocks, [{ info: 'text', code: 'value\n' }]); + assert.equal(remaining, '-\tBLOCK\nAfter'); + assert.equal(helpers.replaceMarkdownCodeFences('\t```js\nIndented example.', () => 'BLOCK'), '\t```js\nIndented example.'); + + const quotedList = '> -\t```text\n> hello\n> ```\nAfter'; + const quotedListBlocks = []; + assert.equal(helpers.replaceMarkdownCodeFences(quotedList, (info, code) => { + quotedListBlocks.push({ info, code }); + return 'BLOCK'; + }), '> -\tBLOCK\nAfter'); + assert.deepEqual(quotedListBlocks, [{ info: 'text', code: 'hello\n' }]); + + const quotedTab = '> ~~~text\n> \thello\n> ~~~'; + const quotedTabBlocks = []; + assert.equal(helpers.replaceMarkdownCodeFences(quotedTab, (info, code) => { + quotedTabBlocks.push({ info, code }); + return 'BLOCK'; + }), '> BLOCK'); + assert.deepEqual(quotedTabBlocks, [{ info: 'text', code: 'hello\n' }]); + + const nestedQuotedList = '- > -\t```text\n > \thello\n > \t```\nAfter'; + const nestedQuotedListBlocks = []; + assert.equal(helpers.replaceMarkdownCodeFences(nestedQuotedList, (info, code) => { + nestedQuotedListBlocks.push({ info, code }); + return 'BLOCK'; + }), '- > -\tBLOCK\nAfter'); + assert.deepEqual(nestedQuotedListBlocks, [{ info: 'text', code: 'hello\n' }]); + + const tabbedNestedQuote = '> \t> ```text\n> \t> hello\n> \t> ```\nAfter'; + const tabbedNestedQuoteBlocks = []; + assert.equal(helpers.replaceMarkdownCodeFences(tabbedNestedQuote, (info, code) => { + tabbedNestedQuoteBlocks.push({ info, code }); + return 'BLOCK'; + }), '> \t> BLOCK\nAfter'); + assert.deepEqual(tabbedNestedQuoteBlocks, [{ info: 'text', code: 'hello\n' }]); + }); + + test(`${build}: saved history uses an outer fence longer than all literal backticks`, () => { + const text = value => ({ nodeType: 3, nodeValue: value }); + const element = (tagName, ...childNodes) => ({ nodeType: 1, tagName, childNodes }); + for (const code of [readme, '````markdown\n```js\nconst x = 1;\n```\n````\n']) { + const pre = element('PRE', element('CODE', text(code))); + pre.parentElement = { querySelector: () => ({ textContent: 'markdown' }) }; + const saved = historyTextFromElement(element('DIV', pre)); + const firstFence = saved.match(/^(`+) markdown/)[1]; + assert.ok([...code.matchAll(/`+/g)].every(match => match[0].length < firstFence.length)); + assert.deepEqual(preContents(renderSkillMarkdown(saved)), [escapeHtml(code)]); + assert.deepEqual(preContents(formatMarkdown(saved)), [helpers.escapeCodeHtml(code)]); + } + }); + + test(`${build}: history uses tilde fences for language labels containing backticks`, () => { + const text = value => ({ nodeType: 3, nodeValue: value }); + const element = (tagName, ...childNodes) => ({ nodeType: 1, tagName, childNodes }); + const code = '~~~\nconst value = true;\n'; + const pre = element('PRE', element('CODE', text(code))); + pre.parentElement = { querySelector: () => ({ textContent: '`javascript`' }) }; + const saved = historyTextFromElement(element('DIV', pre)); + assert.match(saved, /^~~~~ `javascript`\n~~~\nconst value = true;\n~~~~$/); + const source = `${saved}\n## After`; + assert.deepEqual(preContents(formatMarkdown(source)), [helpers.escapeCodeHtml(code)]); + assert.match(formatMarkdown(source), /

After<\/h2>/); + }); + + test(`${build}: history keeps tilde-prefixed labels separate from the fence`, () => { + const text = value => ({ nodeType: 3, nodeValue: value }); + const element = (tagName, ...childNodes) => ({ nodeType: 1, tagName, childNodes }); + const code = 'const value = true;\n'; + const pre = element('PRE', element('CODE', text(code))); + pre.parentElement = { querySelector: () => ({ textContent: '~lang`x' }) }; + const saved = historyTextFromElement(element('DIV', pre)); + assert.match(saved, /^~~~ ~lang`x\nconst value = true;\n~~~$/); + const source = `${saved}\n## After`; + assert.deepEqual(preContents(formatMarkdown(source)), [helpers.escapeCodeHtml(code)]); + assert.match(formatMarkdown(source), /

After<\/h2>/); + }); + + // Opt-in native DOM checks: WEBBRAIN_MARKDOWN_DOM=1 npm run test:markdown. + if (process.env.WEBBRAIN_MARKDOWN_DOM === '1') test(`${build}: browser render, Copy and history round trip`, async () => { + const { chromium, firefox } = await import('playwright'); + const browser = await (build === 'chrome' ? chromium : firefox).launch({ headless: true }); + try { + const page = await browser.newPage({ viewport: { width: 480, height: 1000 } }); + await page.route('http://markdown.test/**', async route => { + const name = new URL(route.request().url()).pathname.slice(1); + if (/^[\w-]+\.js$/.test(name)) { + await route.fulfill({ contentType: 'text/javascript', body: readUi(build, name) }); + } else { + await route.fulfill({ contentType: 'text/html', body: '
' }); + } + }); + await page.goto('http://markdown.test/'); + await page.addStyleTag({ content: fs.readFileSync(new URL(`../src/${build}/styles/sidepanel.css`, import.meta.url), 'utf8') }); + const result = await page.evaluate(async ({ formatter, terminalRenderer, source, expected }) => { + const helpers = await import('/markdown-render.js'); + const { sanitizeMarkdownLinks } = await import('/markdown-link.js'); + const { escapeHtml } = await import('/utils.js'); + const { historyTextFromElement } = await import('/history-text.js'); + const { renderSkillMarkdown } = await import('/skill-markdown.js'); + const dependencies = { ...helpers, sanitizeMarkdownLinks, escapeHtml, t: key => key, scheduleMathRender() {} }; + const format = new Function(...Object.keys(dependencies), `return (${formatter})`)(...Object.values(dependencies)); + let copied = null; + Object.defineProperty(navigator, 'clipboard', { value: { writeText: async text => { copied = text; } } }); + const message = document.querySelector('#message'); + message.innerHTML = format(source, { recoverNestedMarkdown: true }); + const streamed = message.querySelector('pre code').textContent; + const updateDependencies = { + formatMarkdown: format, + isStoppedByUserStatus: () => false, + parseCostAllowanceError: () => false, + renderSubscribeError: () => false, + getStreamedAssistantText: () => source, + hasStreamedAssistantText: () => true, + clearStreamedAssistantText() {}, + streamedAssistantTextByEl: new Map(), + addMessageCopyButton() {}, + verboseMode: false, + document, + }; + const renderTerminal = new Function(...Object.keys(updateDependencies), `return (${terminalRenderer})`)(...Object.values(updateDependencies)); + const assistantEl = { querySelector: selector => selector === '.message-text' ? message : {} }; + renderTerminal(assistantEl, source); + const terminalBlocks = message.querySelectorAll('pre').length; + renderTerminal(assistantEl, source, { replace: true }); + const replacedBlocks = message.querySelectorAll('pre').length; + await new Promise(resolve => setTimeout(resolve, 20)); + message.querySelector('.code-copy-btn').click(); + await Promise.resolve(); + const saved = historyTextFromElement(message); + const history = document.querySelector('#history'); + history.innerHTML = renderSkillMarkdown(saved); + return { + blocks: message.querySelectorAll('pre').length, + terminalBlocks, + replacedBlocks, + streamedMatches: streamed === expected, + copiedMatches: copied === expected, + historyMatches: history.querySelector('pre code').textContent === expected, + historyBlocks: history.querySelectorAll('pre').length, + nextHeading: message.querySelector('h2').textContent, + unsafeElements: message.querySelectorAll('script, img').length, + }; + }, { formatter: panelFormatter(build), terminalRenderer: panelFunction(build, 'renderAssistantTextUpdate'), source: draft, expected: readme }); + assert.deepEqual(result, { blocks: 1, terminalBlocks: 1, replacedBlocks: 1, streamedMatches: true, copiedMatches: true, historyMatches: true, historyBlocks: 1, nextHeading: 'Next steps', unsafeElements: 0 }); + if (process.env.WEBBRAIN_MARKDOWN_SCREENSHOT_DIR) { + await page.screenshot({ path: `${process.env.WEBBRAIN_MARKDOWN_SCREENSHOT_DIR}/${build}-markdown.png`, fullPage: true }); + } + } finally { + await browser.close(); + } + }); +} diff --git a/test/run.js b/test/run.js index 4faa4ce7b..14cf64a86 100644 --- a/test/run.js +++ b/test/run.js @@ -21917,7 +21917,7 @@ test('sidepanels wire highlighting and heading rendering into fenced Markdown', ]) { const panel = fs.readFileSync(path.join(ROOT, panelRel), 'utf8'); const css = fs.readFileSync(path.join(ROOT, cssRel), 'utf8'); - assert.match(panel, /import \{ codeFenceLanguage, highlightCode, renderMarkdownHeadings, renderMarkdownTables \} from '\.\/markdown-render\.js';/, `${label}: renderer helpers should be imported`); + assert.match(panel, /import \{ codeFenceLanguage, highlightCode, renderMarkdownHeadings, renderMarkdownTables, replaceMarkdownCodeFences \} from '\.\/markdown-render\.js';/, `${label}: renderer helpers should be imported`); assert.match(panel, /const lang = codeFenceLanguage\(info\);/, `${label}: fenced code should tolerate metadata after its language token`); assert.match(panel, /const highlighted = enhance \? highlightCode\(block\.code, block\.lang\) : escapeHtml\(block\.code\);/, `${label}: completed fenced code should be highlighted by its language while live code stays lightweight`); assert.match(panel, /text = renderMarkdownTables\(text\);\s*text = renderMarkdownHeadings\(text\);/, `${label}: pipe tables must render before headings swallow the following newline`);