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(/ { 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에게 질문할내용을 입력하세요/], ['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, / { 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) })