135 lines
9.9 KiB
TypeScript
135 lines
9.9 KiB
TypeScript
import React from 'react'
|
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
import MarkdownContent, {
|
|
getCodeBlockLanguage,
|
|
getCodeBlockText,
|
|
getMappedSource,
|
|
parseCodeBlockMeta,
|
|
resolveCodeBlockFenceInfo
|
|
} from '../src/renderer/src/components/MarkdownContent'
|
|
import MarkdownIt from 'markdown-it'
|
|
import { getPrismLanguage, prismLanguageLoaders } from '../src/renderer/src/workbuddy/markdown/languages'
|
|
|
|
let failures = 0
|
|
|
|
function check(label: string, condition: boolean, detail = ''): void {
|
|
if (condition) console.log(` ✓ ${label}`)
|
|
else {
|
|
failures++
|
|
console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`)
|
|
}
|
|
}
|
|
|
|
function render(content: string, options: { streaming?: boolean; onQuote?: (markdown: string) => void } = {}): string {
|
|
// Original OpacityText retains plain span wrappers after completion; compare textual structure without those wrappers.
|
|
return renderToStaticMarkup(<MarkdownContent content={content} {...options} />).replace(/<span>([^<]*)<\/span>/g, '$1')
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
await prismLanguageLoaders.typescript()
|
|
await prismLanguageLoaders.json()
|
|
console.log('\n[1] Markdown structure and raw HTML fallback')
|
|
const structure = render(
|
|
'# Heading\n\nParagraph with **bold**, _italic_, ~~strike~~, and `inline`.\nLine two.\n\n> quoted\n\n---\n\n#### Small heading'
|
|
)
|
|
check('renders headings, paragraphs, emphasis, strike, inline code, quote, break, and rule',
|
|
structure.includes('<h1>') && structure.includes('<p>') && structure.includes('<strong>bold</strong>') &&
|
|
structure.includes('<em>italic</em>') && structure.includes('<s>strike</s>') &&
|
|
structure.includes('markdown-content__inline-code') && structure.includes('<blockquote') &&
|
|
structure.includes('<br') && structure.includes('<hr') && structure.includes('<h4>'))
|
|
|
|
const ordinaryHtml = render('<b onclick="alert(1)">bold text</b>')
|
|
check('ordinary raw HTML tags are stripped while their text remains', ordinaryHtml.includes('bold text') && !ordinaryHtml.includes('<b'))
|
|
const dangerousHtml = render('before<script>alert(1)</script>after <style>hidden</style><iframe>frame text</iframe>')
|
|
check('dangerous raw HTML containers suppress their contents', dangerousHtml.includes('before') && dangerousHtml.includes('after') &&
|
|
!dangerousHtml.includes('alert(1)') && !dangerousHtml.includes('hidden') && !dangerousHtml.includes('frame text'))
|
|
const unclosedDangerousHtml = render('before<svg><text>hidden to end')
|
|
check('unclosed dangerous raw HTML suppresses content through the end', unclosedDangerousHtml.includes('before') && !unclosedDangerousHtml.includes('hidden to end'))
|
|
const fallbackTags = render('line<br>next <u>underlined</u>')
|
|
check('br becomes a hard break and u becomes underline markup', fallbackTags.includes('<br') && fallbackTags.includes('<u>underlined</u>'))
|
|
const htmlInCode = render('`<b>inline</b>`\n\n```html\n<b>fenced</b>\n```')
|
|
check('HTML inside inline and fenced code remains literal code', htmlInCode.includes('<b>inline</b>') &&
|
|
htmlInCode.includes('<') && htmlInCode.includes('</') && htmlInCode.includes('fenced'), htmlInCode)
|
|
|
|
console.log('\n[2] Lists, tasks, and tables')
|
|
const lists = render('- [x] complete\n- [ ] pending\n - nested item')
|
|
check('task checkboxes are disabled and preserve checked state',
|
|
lists.includes('type="checkbox"') && lists.includes('disabled=""') && lists.includes('checked=""'))
|
|
check('nested lists render as nested list elements', (lists.match(/<ul(?:\s|>)/g) ?? []).length === 2 && lists.includes('<li>nested item</li>'))
|
|
|
|
const tableSource = '| Name | Value |\n| --- | ---: |\n| alpha | 2 |'
|
|
const table = render(tableSource, { onQuote: () => undefined })
|
|
check('GFM table renders with a horizontal-scroll wrapper', table.includes('<table ') && table.includes('markdown-content__table-scroll'))
|
|
check('table quote action only appears when a callback is supplied', table.includes('添加到对话') && !render(tableSource).includes('添加到对话'))
|
|
check('table cells retain alignment', table.includes('text-align:right'))
|
|
check('source-map slice normalizes CRLF and omits trailing newline',
|
|
getMappedSource('a\r\nb\r\nc\r\n', [1, 2]) === 'b' && getMappedSource('a\r\nb\r\nc\r\n', [0, 3]) === 'a\nb\nc')
|
|
|
|
console.log('\n[3] Code blocks and copy source')
|
|
const rawCode = ' const value = 1\n\n'
|
|
const parser = new MarkdownIt()
|
|
const codeToken = parser.parse(`\`\`\`js\n${rawCode}\`\`\``, {}).find((token) => token.type === 'fence')
|
|
check('visible language labels retain js, ts, py, and txt while verified aliases normalize',
|
|
getCodeBlockLanguage('js title=demo') === 'js' && getCodeBlockLanguage('ts') === 'ts' &&
|
|
getCodeBlockLanguage('py') === 'py' && getCodeBlockLanguage('txt') === 'txt' &&
|
|
getCodeBlockLanguage('c++') === 'cpp' && getCodeBlockLanguage('c#') === 'csharp' &&
|
|
getCodeBlockLanguage('echarts-data') === 'json' && getCodeBlockLanguage('shell') === 'bash' &&
|
|
getCodeBlockLanguage('html') === 'markup' && getCodeBlockLanguage('svg') === 'markup' &&
|
|
getCodeBlockLanguage('') === 'plaintext' && getCodeBlockLanguage('custom') === 'custom')
|
|
check('short visible labels still select their Prism grammars',
|
|
!!getPrismLanguage('js') && !!getPrismLanguage('ts'))
|
|
check('copy text preserves leading spaces and trailing blank lines', !!codeToken && getCodeBlockText(codeToken) === rawCode)
|
|
|
|
const fenced = render(`\`\`\`ts\n${rawCode}\`\`\``)
|
|
check('fenced code preserves visible language label and uses Prism tokens',
|
|
fenced.includes('>ts</span>') && fenced.includes('language-ts') && fenced.includes('token') && fenced.includes('aria-label="复制"'))
|
|
const metadata = parseCodeBlockMeta('ts:4:7:C:\\project\\src\\App.ts')
|
|
check('language metadata parses Windows paths and file basenames',
|
|
metadata?.metaLanguage === 'ts' && metadata.filePath === 'C:\\project\\src\\App.ts' && metadata.fileName === 'App.ts')
|
|
const metadataFence = render('```ts:4:7:C:\\project\\src\\App.ts\nconst value: number = 1\n```')
|
|
check('metadata fence shows filename and line range with the integrated host click handler',
|
|
metadataFence.includes('markdown-content__code-file-title') && metadataFence.includes('title="4:7:C:\\project\\src\\App.ts"') &&
|
|
metadataFence.includes('App.ts') && metadataFence.includes('L4-L7') && metadataFence.includes('cr-code-block__file-title--clickable'))
|
|
const inferredFenceInfo = resolveCodeBlockFenceInfo('1:0:C:\\project\\data\\payload.json')
|
|
const inferredFence = render('```1:0:C:\\project\\data\\payload.json\n{"ok": true}\n```')
|
|
check('metadata without a language infers Prism grammar from the file extension and clamps lines',
|
|
inferredFenceInfo.language === 'json' &&
|
|
inferredFence.includes('title="1:1:C:\\project\\data\\payload.json"') && inferredFence.includes('language-json') && inferredFence.includes('token property'))
|
|
const rangeMetadataFence = render('```echarts-data:2-4:charts/series.json\n{"series": []}\n```')
|
|
check('language-range metadata format renders file context and normalized json language',
|
|
rangeMetadataFence.includes('title="2:4:charts/series.json"') && rangeMetadataFence.includes('series.json') && rangeMetadataFence.includes('language-json'))
|
|
const indented = render(' print("hello")')
|
|
check('indented code uses the same plaintext code block renderer', indented.includes('markdown-content__code-block') && indented.includes('plaintext'))
|
|
const streaming = render('```ts\nconst pending: number = 1', { streaming: true })
|
|
check('an incomplete streaming fence remains renderable', streaming.includes('data-streaming="true"') && streaming.includes('markdown-content__code-block'))
|
|
check('streaming cues use six UTF-16 units before converting to characters, outside fenced code',
|
|
!streaming.includes('markdown-content__loading-char') &&
|
|
(render('Prefix abcde🙂', { streaming: true }).match(/class="markdown-content__loading-char"/g) ?? []).length === 5)
|
|
|
|
console.log('\n[4] Safe links and linkify settings')
|
|
const links = render('[https](https://example.com) [mail](mailto:user@example.com) [bad](javascript:alert(1)) www.example.com user@example.com')
|
|
check('HTTP(S) and mailto links open safely in a new tab',
|
|
(links.match(/target="_blank" rel="noreferrer"/g) ?? []).length === 2)
|
|
check('unsafe schemes never receive a runnable href', !/href="(?:javascript|data|file):/i.test(links))
|
|
const fuzzy = render('www.example.com user@example.com')
|
|
check('fuzzy bare domains and email addresses stay unlinked', !fuzzy.includes('<a') && !fuzzy.includes('href='), fuzzy)
|
|
const cjkHostname = render('https://example.com中文/path')
|
|
check('autolink host repair leaves the CJK suffix and path outside the link',
|
|
cjkHostname.includes('href="https://example.com/"') && cjkHostname.includes('>https://example.com</a>中文/path'), cjkHostname)
|
|
const cjkPath = render('https://example.com/path中文')
|
|
check('autolink host repair does not split non-ASCII path text',
|
|
cjkPath.includes('href="https://example.com/path%E4%B8%AD%E6%96%87"') && cjkPath.includes('>https://example.com/path中文</a>'))
|
|
const cjkEmphasis = render('**中文:**继续中文 and **😀文字**继续')
|
|
check('CJK-friendly emphasis handles punctuation and astral characters',
|
|
cjkEmphasis.includes('<strong>中文:</strong>继续中文') && cjkEmphasis.includes('<strong>😀文字</strong>继续'), cjkEmphasis)
|
|
const localImage = render('')
|
|
const remoteImage = render('')
|
|
check('images initially show the source-derived loading fallback and do not expose local file URLs',
|
|
remoteImage.includes('aria-busy="true"') && remoteImage.includes('remote') && !localImage.includes('src="file:'))
|
|
|
|
console.log(failures === 0 ? '\nMarkdown regression checks passed.\n' : `\n${failures} Markdown regression check(s) failed.\n`)
|
|
process.exitCode = failures === 0 ? 0 : 1
|
|
}
|
|
|
|
void main().catch((error) => { console.error(error); process.exitCode = 1 })
|