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, / { 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) })