209 lines
11 KiB
JavaScript
209 lines
11 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 AgentCodeReferences = () => null
|
|
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,
|
|
AgentCodeReferences,
|
|
AgentProgressBubble,
|
|
AgentWorkDuration,
|
|
MessageCopyButton: () => null,
|
|
agentMessageCopyText: message => message.content,
|
|
Bot: () => React.createElement('svg'), WandSparkles: () => React.createElement('svg')
|
|
})
|
|
|
|
test('existing code locations stay usable during model and command work but not file application', () => {
|
|
for (const applying of [false, true]) {
|
|
const onOpen = () => {}
|
|
const tree = messages.AgentMessages({
|
|
dark: true, enabled: true, statusLoading: false, pending: true, busy: true,
|
|
messages: [{ id: 'answer', role: 'assistant', content: '코드 위치', applyState: applying ? 'applying' : undefined, plan: { codeReferences: [{ id: 'code-1' }], changes: [], commands: [], actionPlan: [], createModule: null } }],
|
|
onOpenCodeReference: onOpen
|
|
})
|
|
let references
|
|
function visit(element) {
|
|
if (!React.isValidElement(element)) return
|
|
if (element.type === AgentCodeReferences) references = element
|
|
React.Children.toArray(element.props.children).forEach(visit)
|
|
}
|
|
visit(tree)
|
|
assert.ok(references)
|
|
assert.equal(references.props.busy, applying)
|
|
assert.equal(typeof references.props.onOpen, 'function')
|
|
}
|
|
})
|
|
|
|
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)
|
|
})
|