146 lines
7.4 KiB
JavaScript
146 lines
7.4 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')
|
|
|
|
function compile(source, globals) {
|
|
const exports = {}
|
|
vm.runInNewContext(ts.transpileModule(source, {
|
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
|
}).outputText, { exports, require, ...globals })
|
|
return exports
|
|
}
|
|
const hookSource = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
|
const hook = hookSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent')
|
|
const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && ['applyPlan', 'cancelPlan'].includes(node.declarationList.declarations[0].name.getText(hookSource)))
|
|
const proposal = (extra = {}) => ({
|
|
id: 'proposal', role: 'assistant', content: '로그 추가', applyState: 'ready',
|
|
plan: { summary: '로그 추가', explanation: '시작 로그', requestedModules: [], requestedLogs: [], actionPlan: [], createModule: null,
|
|
changes: [{ moduleName: 'Control', path: 'node.py', content: 'new', reason: 'log' }],
|
|
commands: [{ id: 'build', moduleName: 'Control', command: 'bash build.sh', purpose: 'build' }] },
|
|
commandExecutions: { build: { status: 'pending' } }, ...extra
|
|
})
|
|
function harness(message) {
|
|
const session = {}
|
|
const messageStateRef = { current: { session, messages: [message] } }
|
|
const applyingRef = { current: false }
|
|
const abortControllerRef = { current: null }
|
|
const saved = []
|
|
const api = compile(functions.map(node => `export ${node.getText(hookSource)}`).join('\n'), {
|
|
session, messageStateRef, applyingRef, abortControllerRef,
|
|
updateMessages(update) {
|
|
const next = update(messageStateRef.current.messages)
|
|
messageStateRef.current.messages = next
|
|
saved.push(JSON.parse(JSON.stringify(next)))
|
|
}
|
|
})
|
|
return { ...api, messageStateRef, applyingRef, abortControllerRef, saved, current: () => messageStateRef.current.messages[0] }
|
|
}
|
|
|
|
test('cancel marks the proposal and unfinished commands without applying files', async () => {
|
|
const h = harness(proposal({ applyError: 'old error' }))
|
|
assert.equal(h.cancelPlan('proposal'), true)
|
|
assert.equal(h.current().applyState, 'cancelled')
|
|
assert.equal(h.current().applyError, undefined)
|
|
assert.equal(h.current().commandExecutions.build.status, 'rejected')
|
|
assert.equal(h.saved.at(-1)[0].applyState, 'cancelled')
|
|
let applied = false
|
|
await h.applyPlan('proposal', async () => { applied = true })
|
|
assert.equal(applied, false)
|
|
assert.equal(h.cancelPlan('proposal'), false)
|
|
})
|
|
|
|
test('cancel preserves existing successful command results and rejects failed commands', () => {
|
|
const message = proposal()
|
|
message.plan.commands.push({ id: 'inspect', moduleName: 'Control', command: 'inspect', purpose: 'inspect' })
|
|
message.commandExecutions = { build: { status: 'failed', output: 'build failed' }, inspect: { status: 'completed', output: 'done' } }
|
|
const h = harness(message)
|
|
h.cancelPlan('proposal')
|
|
assert.equal(h.current().commandExecutions.inspect.status, 'completed')
|
|
assert.equal(h.current().commandExecutions.inspect.output, 'done')
|
|
assert.equal(h.current().commandExecutions.build.status, 'rejected')
|
|
assert.equal(h.current().commandExecutions.build.output, 'build failed')
|
|
})
|
|
|
|
for (const applyState of ['applying', 'applied', 'cancelled']) {
|
|
test(`cannot cancel a proposal in ${applyState} state`, () => {
|
|
const h = harness(proposal({ applyState }))
|
|
assert.equal(h.cancelPlan('proposal'), false)
|
|
assert.equal(h.saved.length, 0)
|
|
})
|
|
}
|
|
|
|
test('cannot cancel during a running command, another apply, pending request, or changed conversation', () => {
|
|
for (const setup of [
|
|
h => { h.current().commandExecutions.build.status = 'running' },
|
|
h => { h.applyingRef.current = true },
|
|
h => { h.abortControllerRef.current = {} },
|
|
h => { h.messageStateRef.current.session = {} }
|
|
]) {
|
|
const h = harness(proposal())
|
|
setup(h)
|
|
assert.equal(h.cancelPlan('proposal'), false)
|
|
assert.equal(h.saved.length, 0)
|
|
}
|
|
})
|
|
|
|
test('an apply already started cannot be cancelled and completes normally', async () => {
|
|
const h = harness(proposal())
|
|
let finish
|
|
const applying = h.applyPlan('proposal', () => new Promise(resolve => { finish = resolve }))
|
|
assert.equal(h.current().applyState, 'applying')
|
|
assert.equal(h.cancelPlan('proposal'), false)
|
|
finish()
|
|
await applying
|
|
assert.equal(h.current().applyState, 'applied')
|
|
})
|
|
|
|
test('new module proposals can be cancelled without creating anything', () => {
|
|
const message = proposal()
|
|
message.plan.changes = []
|
|
message.plan.createModule = { name: 'Example' }
|
|
const h = harness(message)
|
|
assert.equal(h.cancelPlan('proposal'), true)
|
|
assert.equal(h.current().applyState, 'cancelled')
|
|
})
|
|
|
|
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 text(root) {
|
|
if (typeof root === 'string') return root
|
|
return React.isValidElement(root) ? React.Children.toArray(root.props.children).map(text).join('') : ''
|
|
}
|
|
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 renderAgent = compile(`export ${panelSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'AgentMessages').getText(panelSource)}`, {
|
|
...require('lucide-react'), t: key => key, useLocaleRender() {},
|
|
ProgressiveChatResponse: () => null, MessageCopyButton: () => null,
|
|
agentMessageCopyText: message => message.content
|
|
}).AgentMessages
|
|
|
|
for (const dark of [true, false]) {
|
|
test(`cancel is left of apply, disabled during work, and absent after cancellation (${dark ? 'dark' : 'light'})`, () => {
|
|
let cancelled
|
|
const props = { dark, enabled: true, statusLoading: false, pending: false,
|
|
messages: [proposal()], onApply() {}, onCancel: id => { cancelled = id }, onOpenChange() {}, onRunCommand() {}, onRejectCommand() {} }
|
|
let tree = renderAgent(props)
|
|
let buttons = elements(tree, node => node.type === 'button')
|
|
const cancel = buttons.find(button => text(button) === '취소')
|
|
const apply = buttons.find(button => text(button) === '변경 적용')
|
|
assert.ok(buttons.indexOf(cancel) < buttons.indexOf(apply))
|
|
const cancelLabel = elements(cancel, node => node.type === 'span' && text(node) === '취소')[0]
|
|
assert.match(cancelLabel.props.className, /top-px leading-none/)
|
|
cancel.props.onClick()
|
|
assert.equal(cancelled, 'proposal')
|
|
const busyCancel = elements(renderAgent({ ...props, busy: true }), node => node.type === 'button' && text(node) === '취소')[0]
|
|
assert.equal(busyCancel.props.disabled, true)
|
|
tree = renderAgent({ ...props, messages: [proposal({ applyState: 'cancelled' })] })
|
|
buttons = elements(tree, node => node.type === 'button')
|
|
assert.equal(buttons.some(button => ['취소', '승인', '다시 적용', '변경 적용'].includes(text(button))), false)
|
|
assert.equal(buttons.find(button => text(button) === '취소됨').props.disabled, true)
|
|
})
|
|
}
|