Files
lms-fe/scripts/test-chat-markdown.cjs
T
chyoo 8b501266ff feat: improve agent responses and workspace UI
Add live agent progress, elapsed timing, verified code navigation and rich Markdown rendering. Preserve NanumSquare styling with missing glyph fallback, fix platform availability filtering and unify workspace scrollbar and visualization controls.
2026-10-19 17:06:18 +09:00

112 lines
6.1 KiB
JavaScript

const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const React = require('react')
const { renderToStaticMarkup } = require('react-dom/server')
function compile(file, overrides = {}, globals = {}) {
const exports = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve(file), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText, {
exports, require: name => overrides[name] ?? require(name), clearTimeout, setTimeout, ...globals
})
return exports
}
const i18n = { t: key => key, useLocaleRender() {} }
const block = compile('../src/renderer/src/components/ChatCodeBlock.tsx', { '../i18n': i18n })
const { MarkdownMessage } = compile('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': i18n, './ChatCodeBlock': block, './chatAttachments': {}, './ChatImageAttachments': {}, './ChatTextContextMenu': {}
})
const render = (content, dark = true, extra = {}) => renderToStaticMarkup(React.createElement(MarkdownMessage, { content, dark, ...extra }))
for (const dark of [true, false]) {
test(`renders numbered/nested/task lists without losing the starting number (${dark})`, () => {
const html = render('3. 설정 확인\n - 속도 제한\n - 조향 제한\n4. 결과 확인\n\n- [x] 완료\n- [ ] 대기', dark)
assert.match(html, /<ol start="3" class="[^"]*list-decimal/)
assert.match(html, /<ul class="[^"]*list-disc/)
assert.match(html, /contains-task-list list-none/)
assert.match(html, /task-list-item list-none/)
assert.equal((html.match(/type="checkbox"/g) ?? []).length, 2)
})
test(`renders GFM tables with keyboard-scrollable framing and column alignment (${dark})`, () => {
const html = render('| 모듈 | 속도 |\n| --- | ---: |\n| Control | `0.3 m/s` |', dark)
assert.match(html, /data-chat-table/)
assert.match(html, /tabindex="0" role="region" aria-label="표"/)
assert.match(html, /overflow-x-auto/)
assert.match(html, /<table class="[^"]*table-auto/)
assert.match(html, /text-align:right/)
assert.match(html, /chat-inline-code/)
})
test(`renders fenced Python with language, token colors, copy control and preserved indentation (${dark})`, () => {
const html = render('```python\ndef speed():\n return 0.3\n```', dark)
assert.match(html, /data-chat-code-block/)
assert.ok(html.includes(`data-code-theme="${dark ? 'dark' : 'light'}"`))
assert.match(html, />Python<\/span>/)
assert.match(html, /aria-label="코드 복사"/)
assert.match(html, /hljs-keyword/)
assert.match(html, /hljs-number/)
assert.match(html, /\n <span/)
assert.match(html, /max-h-80 max-w-full overflow-auto/)
assert.match(html, /whitespace-pre font-mono/)
assert.doesNotMatch(html, /chat-inline-code/)
})
}
test('unlabelled and unknown languages remain code blocks rather than inline code', () => {
for (const language of ['', 'unknown-language']) {
const html = render('```' + language + '\n unformatted <value>\n```')
assert.match(html, /data-chat-code-block/)
assert.ok(html.includes(language || '일반 텍스트'))
assert.ok(html.includes(' unformatted &lt;value&gt;'))
assert.doesNotMatch(html, /hljs-/)
}
})
test('HTML-looking code is rendered as escaped text, never live elements', () => {
const html = render('```html\n<script>window.injected = true</script>\n<img src=x onerror="window.injected=true">\n```')
assert.doesNotMatch(html, /<script\b|<img\b/)
assert.match(html, /&lt;/)
assert.match(html, /window.injected/)
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatCodeBlock.tsx'), 'utf8')
assert.doesNotMatch(source, /dangerouslySetInnerHTML/)
})
test('only newly revealed code and tables receive the fade class', () => {
const markdown = '설명\n\n```yaml\nspeed: 0.3\n```\n\n| 키 | 값 |\n| --- | --- |\n| speed | 0.3 |'
const html = render(markdown, true, { fadeFrom: markdown.indexOf('| 키') })
assert.equal((html.match(/agent-response-fade/g) ?? []).length, 1)
assert.match(html, /overflow-x-auto rounded-md agent-response-fade/)
})
test('oversized code skips tokenization but still preserves all content', () => {
const source = '# comment\n'.repeat(6000)
const html = renderToStaticMarkup(React.createElement(block.ChatCodeBlock, { source, language: 'python', dark: true }))
assert.doesNotMatch(html, /hljs-comment/)
assert.equal((html.match(/# comment/g) ?? []).length, 6000)
})
function elements(tree, predicate) {
if (!React.isValidElement(tree)) return []
return [...(predicate(tree) ? [tree] : []), ...React.Children.toArray(tree.props.children).flatMap(child => elements(child, predicate))]
}
for (const fails of [false, true]) test(`code copy preserves the full source and reports clipboard ${fails ? 'failure' : 'success'}`, async () => {
let state = 'idle', copied, timer
const api = compile('../src/renderer/src/components/ChatCodeBlock.tsx', {
'../i18n': i18n,
react: { ...React, useState: () => [state, value => { state = value }], useMemo: fn => fn(), useRef: () => ({ current: null }), useEffect() {} }
}, { navigator: { clipboard: { writeText: async text => { if (fails) throw Error('denied'); copied = text } } }, setTimeout: callback => { timer = callback; return 1 }, clearTimeout() {} })
const source = '\t# 한글 주석\n speed = 0.3\n\n'
const renderBlock = () => api.ChatCodeBlock({ source, language: 'python', dark: true })
const button = elements(renderBlock(), element => element.type === 'button')[0]
let stopped = false
button.props.onClick({ stopPropagation() { stopped = true } })
await new Promise(resolve => setImmediate(resolve))
assert.equal(stopped, true)
assert.equal(state, fails ? 'error' : 'copied')
if (!fails) assert.equal(copied, source)
assert.equal(elements(renderBlock(), element => element.type === 'button')[0].props['aria-label'], fails ? '코드를 복사하지 못했습니다.' : '복사됨')
timer()
assert.equal(state, 'idle')
})