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(source, overrides = {}, globals = {}) {
const exports = {}
const code = ts.transpileModule(source, { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX
} }).outputText
vm.runInNewContext(code, { exports, require: name => overrides[name] ?? require(name), ...globals })
return exports
}
const widgets = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8'), {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}
})
const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const declarations = panelSource.statements.filter(node => ts.isFunctionDeclaration(node) && ['AiMessages', 'AgentMessages', 'TeacherMessages'].includes(node.name?.text))
const ProgressiveChatResponse = props => React.createElement('div', { 'data-reveal-start': props.message.revealStartedAt, 'data-collapsed': props.defaultCollapsed }, props.message.content)
const ChatImageAttachments = props => React.createElement('div', { 'data-chat-image-attachments': props.variant }, props.images.map(image => React.createElement('img', { key: image.url, src: image.url, alt: image.name })))
const messages = compile(declarations.map(node => `export ${node.getText(panelSource)}`).join('\n'), {}, {
t: key => key, useLocaleRender() {},
ChatResponseSkeleton: widgets.ChatResponseSkeleton,
ChatEmptyState: widgets.ChatEmptyState,
ProgressiveChatResponse,
ChatImageAttachments,
MessageCopyButton: () => null,
Bot: () => React.createElement('svg'), WandSparkles: () => React.createElement('svg')
})
test('agent image thumbnails are siblings below the user bubble rather than bubble contents', () => {
const tree = messages.AgentMessages({
dark: true, enabled: true, statusLoading: false, pending: false,
messages: [{ id: 'user', role: 'user', content: '이 화면을 확인해줘\n두 번째 줄', imageAttachments: [{ name: '스크린샷.png', url: 'https://files.test/image.png' }] }]
})
let row
function visit(element) {
if (!React.isValidElement(element)) return
const children = React.Children.toArray(element.props.children)
if (children.some(child => React.isValidElement(child) && child.props['data-agent-user-bubble'])) row = children
children.forEach(visit)
}
visit(tree)
assert.ok(row)
assert.equal(row[0].props.children, '이 화면을 확인해줘\n두 번째 줄')
assert.match(row[0].props.className, /whitespace-pre-wrap/)
assert.equal(row[1].type, ChatImageAttachments)
assert.equal(row[1].props.variant, 'sent')
assert.equal(row[1].props.images.length, 1)
})
for (const dark of [false, true]) {
for (const mode of ['AiMessages', 'AgentMessages']) {
test(`${mode} uses a three-line skeleton instead of a spinner (${dark ? 'dark' : 'light'})`, () => {
const html = renderToStaticMarkup(messages[mode]({ dark, enabled: true, statusLoading: false, pending: true, messages: [] }))
assert.match(html, /role="status"/)
assert.equal((html.match(/내용을 입력하세요/],
['AgentMessages', /현재 모듈의 코드 수정이나 새 모듈 생성을 요청하세요\.
변경 내용은 확인 후 직접 적용합니다\./],
['TeacherMessages', /강사에게 문의할
내용을 입력하세요/]
]) {
test(`empty ${mode} view uses the shared speech bubble (${dark ? 'dark' : 'light'})`, () => {
const html = renderToStaticMarkup(messages[mode]({ dark, enabled: true, pending: false, messages: [] }))
assert.match(html, expected)
assert.match(html, /rounded-lg border px-8 py-5/)
assert.match(html, /aria-hidden="true"[^>]*rotate-45 border-b border-r/)
assert.doesNotMatch(html, /