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(file, overrides = {}, globals = {}) { const exports = {} const source = fs.readFileSync(require.resolve(file), 'utf8') 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 i18n = { t: key => key, useLocaleRender() {} } const { ConfirmStatusModal } = compile('../src/renderer/src/components/AppStatusModals.tsx', { '../i18n': i18n }) const QaInputBox = () => null const ChatTextContextMenu = () => null function elements(root, predicate) { if (!React.isValidElement(root)) return [] return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))] } function setup(overrides = {}, browser = {}) { const state = [] const effects = [] let index = 0 let cleared = 0 const modeChanges = [] const panel = compile('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx', { '../../i18n': i18n, '../../components/AppStatusModals': { ConfirmStatusModal }, '../../components/ProgressiveChatResponse': { ProgressiveChatResponse: () => null }, '../../components/ChatWidgets': { QaInputBox }, '../../components/ChatTextContextMenu': { ChatTextContextMenu }, '../../components/ChatImageAttachments': {}, '../../components/AgentProgressBubble': {}, '../../components/AgentWorkDuration': {}, '../../components/AgentCodeReferences': {}, '../../components/AgentModelSelector': { AgentModelSelector: () => null }, 'react-dom': { createPortal: child => child }, react: { ...React, useEffect(effect) { effects.push(effect) }, useState(initial) { const slot = index++ if (!(slot in state)) state[slot] = initial return [state[slot], value => { state[slot] = value }] } } }, { document: { body: {} }, window: { getSelection: () => null, ...browser } }).default const props = { dark: true, workspaceActive: true, qaMode: 'ai', agentEnabled: true, visibleAiMessages: [{ id: '1', content: 'hello' }], visibleTeacherMessages: [], agentMessages: [], workspaceChatScrollRef: { current: null }, onModeChange(mode) { modeChanges.push(mode) }, onClearConversation() { cleared++ }, ...overrides } return { render() { index = 0; effects.length = 0; return panel(props) }, cleared: () => cleared, runEffects() { effects.forEach(effect => effect()) }, modeChanges } } for (const agentProvider of ['A_COPILOT', 'QWEN', 'OPENAI', 'ANTHROPIC', 'GEMINI']) { for (const qaMode of ['ai', 'agent', 'teacher']) { test(`attachment controls follow the provider and chat mode (${agentProvider}, ${qaMode})`, () => { const harness = setup({ qaMode, agentProvider }) const input = elements(harness.render(), node => node.type === QaInputBox)[0] assert.equal(input.props.attachmentsEnabled, qaMode === 'agent' && agentProvider !== 'A_COPILOT') assert.equal(typeof input.props.onClearConversation, 'function') }) } } test('the workspace passes the current provider from agent status to the chat panel', () => { const source = fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8') assert.match(source, /provider: agentProvider,[\s\S]*?\} = useAiAgent\(/) assert.match(source, / { const harness = setup({ qaMode, agentMessages: [{ id: 'a', content: 'answer' }], visibleTeacherMessages: [{ id: 't', content: 'answer' }] }) const input = tree => elements(tree, node => node.type === QaInputBox)[0] const modal = tree => elements(tree, node => node.type === ConfirmStatusModal)[0] assert.equal(modal(harness.render()), undefined) input(harness.render()).props.onClearConversation() assert.equal(harness.cleared(), 0) modal(harness.render()).props.onCancel() assert.equal(modal(harness.render()), undefined) assert.equal(harness.cleared(), 0) input(harness.render()).props.onClearConversation() modal(harness.render()).props.onConfirm() assert.equal(harness.cleared(), 1) assert.equal(modal(harness.render()), undefined) }) } test('context menu deletion also requests confirmation', () => { const harness = setup() const root = harness.render() const area = elements(root, node => Boolean(node.props.onContextMenu))[0] area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10 }) const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0] menu.props.onClear() assert.equal(harness.cleared(), 0) assert.equal(elements(harness.render(), node => node.type === ConfirmStatusModal).length, 1) }) for (const [selected, inside] of [[false, true], [true, true], [true, false]]) { test(`themed chat menu copies only a selection inside the chat (selected=${selected}, inside=${inside})`, async () => { const node = {} const harness = setup({}, { getSelection: () => selected ? { anchorNode: node, focusNode: node, toString: () => '선택한 답변' } : null, chatTextMenu: { show: async () => assert.fail('the themed menu must not open an OS menu') } }) const area = elements(harness.render(), element => Boolean(element.props.onContextMenu))[0] area.props.onContextMenu({ currentTarget: { contains: () => inside }, target: { closest: () => ({}) }, preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10 }) await Promise.resolve() const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0] assert.equal(menu.props.menu.kind, 'message') assert.equal(Boolean(menu.props.menu.text), selected && inside) assert.equal(menu.props.canClear, true) assert.equal(harness.cleared(), 0) }) } test('themed menu deletion still goes through the shared confirmation dialog', async () => { const harness = setup() const area = elements(harness.render(), node => Boolean(node.props.onContextMenu))[0] area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {} }) await Promise.resolve() const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0] menu.props.onClear() assert.equal(harness.cleared(), 0) const modal = elements(harness.render(), node => node.type === ConfirmStatusModal)[0] assert.ok(modal) modal.props.onConfirm() assert.equal(harness.cleared(), 1) }) for (const props of [{ inputBusy: true }, { qaMessagesLoading: true }, { visibleAiMessages: [] }]) { test(`does not open deletion confirmation when disabled: ${JSON.stringify(props)}`, () => { const harness = setup(props) const input = elements(harness.render(), node => node.type === QaInputBox)[0] assert.equal(input.props.clearDisabled, true) input.props.onClearConversation() assert.equal(elements(harness.render(), node => node.type === ConfirmStatusModal).length, 0) assert.equal(harness.cleared(), 0) }) } for (const dark of [false, true]) { test(`shared confirmation renders cancel left and delete right (${dark ? 'dark' : 'light'})`, () => { const harness = setup({ dark }) elements(harness.render(), node => node.type === QaInputBox)[0].props.onClearConversation() const modal = elements(harness.render(), node => node.type === ConfirmStatusModal)[0] assert.equal(modal.props.dark, dark) assert.match(modal.props.overlayClassName, /fixed.*z-\[180\]/) const html = renderToStaticMarkup(modal) assert.match(html, /취소<\/button>]*>삭제<\/button>/) assert.match(html, /bg-\[#d14b4b\]/) }) } test('input no longer opens a native confirmation dialog', () => { const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8') assert.doesNotMatch(source, /window\.confirm/) assert.match(source, /onClick=\{onClearConversation\}/) }) for (const dark of [false, true]) { for (const agentEnabled of [false, true]) { test(`agent availability controls tab count and layout (${dark ? 'dark' : 'light'}, enabled=${agentEnabled})`, () => { const harness = setup({ dark, agentEnabled }) const grids = elements(harness.render(), node => node.props.className?.includes('mt-3 grid')) assert.equal(grids.length, 1) const tabs = elements(grids[0], node => node.type === 'button') assert.deepEqual(tabs.map(tab => tab.props.children[1].props.children), agentEnabled ? ['AI', '에이전트', '강사'] : ['AI', '강사']) assert.ok(grids[0].props.className.includes(agentEnabled ? 'grid-cols-3' : 'grid-cols-2')) }) } } test('returns to AI when the selected agent is disabled after status loading', () => { const harness = setup({ qaMode: 'agent', agentEnabled: false, agentStatusLoading: false, minimized: true }) harness.render() harness.runEffects() assert.deepEqual(harness.modeChanges, ['ai']) }) test('does not reset the selected agent while status is still loading', () => { const harness = setup({ qaMode: 'agent', agentEnabled: false, agentStatusLoading: true, minimized: true }) harness.render() harness.runEffects() assert.deepEqual(harness.modeChanges, []) })