8b501266ff
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.
186 lines
10 KiB
JavaScript
186 lines
10 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(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': {}, './ChatCodeBlock': {}
|
|
})
|
|
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 { AgentWorkDuration } = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentWorkDuration.tsx'), 'utf8'), {
|
|
'../i18n': { t: (key, values = {}) => key.replace(/\{\{(\w+)\}\}/g, (_, name) => values[name] ?? ''), useLocaleRender() {} }
|
|
})
|
|
const { AgentProgressBubble } = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentProgressBubble.tsx'), 'utf8'), {
|
|
'../i18n': { t: (key, values = {}) => key.replace(/\{\{(\w+)\}\}/g, (_, name) => values[name] ?? ''), useLocaleRender() {} },
|
|
react: { ...React, useEffect() {}, useRef: () => ({ current: null }) },
|
|
'./AgentWorkDuration': { AgentWorkDuration }
|
|
})
|
|
const messages = compile(declarations.map(node => `export ${node.getText(panelSource)}`).join('\n'), {}, {
|
|
t: key => key, useLocaleRender() {},
|
|
ChatResponseSkeleton: widgets.ChatResponseSkeleton,
|
|
ChatEmptyState: widgets.ChatEmptyState,
|
|
ProgressiveChatResponse,
|
|
ChatImageAttachments,
|
|
AgentProgressBubble,
|
|
AgentWorkDuration,
|
|
MessageCopyButton: () => null,
|
|
agentMessageCopyText: message => message.content,
|
|
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(/<span class="h-2 /g) ?? []).length, 3)
|
|
for (const width of ['w-full', 'w-[76%]', 'w-[52%]']) assert.ok(html.includes(width))
|
|
assert.doesNotMatch(html, /animate-spin/)
|
|
assert.match(html, /motion-reduce:animate-none/)
|
|
})
|
|
}
|
|
}
|
|
|
|
test('AI answer forwards reveal timing and collapse settings to the same presentation component', () => {
|
|
const html = renderToStaticMarkup(messages.AiMessages({
|
|
dark: true, pending: false, collapseSignal: 2,
|
|
messages: [{ id: 'answer', role: 'assistant', content: '전체 답변입니다.', revealStartedAt: 1234, keepExpanded: true }]
|
|
}))
|
|
assert.match(html, /data-reveal-start="1234"/)
|
|
assert.match(html, /data-collapsed="false"/)
|
|
assert.match(html, /전체 답변/)
|
|
})
|
|
|
|
for (const dark of [true, false]) {
|
|
test(`agent progress accumulates only during the request and is replaced by the final reply (${dark ? 'dark' : 'light'})`, () => {
|
|
const progressSteps = [
|
|
{ id: 'analysis-0', kind: 'analysis', status: 'ok', round: 1, requests: 1 },
|
|
{ id: 'tool-0-0', kind: 'tool', name: 'files.list', moduleName: 'Camera', status: 'ok', count: 3 },
|
|
{ id: 'tool-1-0', kind: 'tool', name: 'files.read', moduleName: 'Camera', path: 'config/camera.yaml', status: 'missing' },
|
|
{ id: 'tool-1-1', kind: 'tool', name: 'logs.read', moduleName: 'Camera', status: 'error' },
|
|
{ id: 'tool-1-2', kind: 'tool', name: 'files.search', moduleName: 'Camera', status: 'partial', count: 2 }
|
|
]
|
|
const props = { dark, enabled: true, statusLoading: false, pending: true, activity: '조회 결과 분석 중', progressSteps, messages: [] }
|
|
const html = renderToStaticMarkup(messages.AgentMessages(props))
|
|
assert.equal((html.match(/data-agent-progress="true"/g) ?? []).length, 1)
|
|
assert.match(html, /1차 분석 완료 · 추가 조회 1건/)
|
|
assert.match(html, /Camera · 파일 목록 · 조회 완료 · 3건/)
|
|
assert.match(html, /Camera · 파일 읽기 · config\/camera.yaml · 대상 없음/)
|
|
assert.match(html, /Camera · 로그 조회 · 조회 실패/)
|
|
assert.match(html, /Camera · 파일 검색 · 일부 결과 · 2건/)
|
|
assert.match(html, /조회 결과 분석 중/)
|
|
assert.equal((html.match(/agent-response-fade/g) ?? []).length, 5)
|
|
const final = renderToStaticMarkup(messages.AgentMessages({ ...props, pending: false, messages: [{ id: 'answer', role: 'assistant', content: '최종 답변입니다.', durationMs: 20_900 }] }))
|
|
assert.doesNotMatch(final, /data-agent-progress|config\/camera.yaml|진행 상황/)
|
|
assert.match(final, /최종 답변입니다/)
|
|
assert.match(final, /20초 동안 작업함/)
|
|
assert.ok(final.indexOf('20초 동안 작업함') < final.indexOf('최종 답변입니다'))
|
|
})
|
|
}
|
|
|
|
for (const dark of [false, true]) {
|
|
for (const [mode, expected] of [
|
|
['AiMessages', /AI에게 질문할<br\/>내용을 입력하세요/],
|
|
['AgentMessages', /현재 모듈의 코드 수정이나 새 모듈 생성을 요청하세요\.<br\/>변경 내용은 확인 후 직접 적용합니다\./],
|
|
['TeacherMessages', /강사에게 문의할<br\/>내용을 입력하세요/]
|
|
]) {
|
|
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, /<button|role="status"/)
|
|
})
|
|
}
|
|
}
|
|
|
|
function chatHarness(revealInView) {
|
|
const states = []
|
|
const refs = []
|
|
let stateIndex = 0
|
|
let refIndex = 0
|
|
let waits = 0
|
|
const content = '첫 번째 문단입니다.\n\n두 번째 문단입니다.\n\n마지막 문단입니다.'
|
|
const { useQaChat } = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useQaChat.ts'), 'utf8'), {
|
|
react: {
|
|
useEffect() {},
|
|
useState(initial) {
|
|
const index = stateIndex++
|
|
if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial
|
|
return [states[index], value => { states[index] = typeof value === 'function' ? value(states[index]) : value }]
|
|
},
|
|
useRef(initial) { const index = refIndex++; return refs[index] ??= { current: initial } }
|
|
},
|
|
'socket.io-client': {}
|
|
}, {
|
|
URLSearchParams, AbortController, DOMException,
|
|
fetch: async () => ({ ok: true, json: async () => ({ threadId: 'thread', content, message: { id: 'answer', content, createdAt: '2026-10-19T03:00:00Z' } }) }),
|
|
window: { setTimeout(fn) { waits++; fn() } }
|
|
})
|
|
return {
|
|
render() {
|
|
stateIndex = 0; refIndex = 0
|
|
return useQaChat({ accessToken: 'test', courseId: 'course', weekNumber: 1, revealInView, getFreshAccessToken: async () => 'test', onTitlebarNotice() {} })
|
|
},
|
|
content, waits: () => waits
|
|
}
|
|
}
|
|
|
|
test('workspace AI stores the complete answer immediately and leaves animation to the view', async () => {
|
|
const harness = chatHarness(true)
|
|
await harness.render().sendQaMessage('조향 설정을 알려줘')
|
|
const state = harness.render()
|
|
const answer = state.visibleAiMessages.find(message => message.id === 'answer')
|
|
assert.equal(answer.content, harness.content)
|
|
assert.equal(typeof answer.revealStartedAt, 'number')
|
|
assert.equal(answer.keepExpanded, true)
|
|
assert.equal(state.aiPending, false)
|
|
assert.equal(state.aiTypingMessageId, null)
|
|
assert.equal(harness.waits(), 0)
|
|
})
|
|
|
|
test('the separate AI guide keeps its existing reveal mode unless explicitly opted in', async () => {
|
|
const harness = chatHarness(false)
|
|
await harness.render().sendQaMessage('질문')
|
|
const state = harness.render()
|
|
assert.equal(state.visibleAiMessages.find(message => message.id === 'answer').content, harness.content)
|
|
assert.ok(harness.waits() > 0)
|
|
assert.equal(state.aiTypingMessageId, null)
|
|
})
|