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.
207 lines
9.6 KiB
JavaScript
207 lines
9.6 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(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': {},
|
|
'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, /<WorkspaceQaPanel[\s\S]*?agentProvider=\{agentProvider\}/)
|
|
})
|
|
|
|
for (const qaMode of ['ai', 'agent', 'teacher']) {
|
|
test(`${qaMode} history is deleted only after shared modal confirmation`, () => {
|
|
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[^>]*>삭제<\/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, [])
|
|
})
|