Files
lms-fe/scripts/test-agent-code-navigation.cjs
T

228 lines
13 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 { createHash, webcrypto } = require('node:crypto')
function compile(source, globals = {}, requireModule = require) {
const exports = {}
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText, {
exports, require: requireModule, Error, crypto: webcrypto, TextEncoder, window: { setTimeout: callback => setTimeout(callback, 1) }, ...globals
})
return exports
}
const tools = compile(fs.readFileSync(require.resolve('../src/shared/agentTools.ts'), 'utf8'))
const navigation = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentCodeNavigation.ts'), 'utf8'), {}, name => name.endsWith('shared/agentTools') ? tools : require(name))
const content = 'def configure():\n speed = 0.4\n return speed\n'
const hash = value => createHash('sha256').update(value).digest('hex')
const reference = { id: 'location', moduleName: 'Control', path: 'src/node.py', startLine: 2, endLine: 3, reason: 'speed config', snippet: ' speed = 0.4\n return speed', expectedHash: hash(content) }
test('unchanged source reveals the verified line range, while unsaved insertions relocate it', async () => {
assert.deepEqual(JSON.parse(JSON.stringify(await navigation.resolveCodeReferenceRange(content, reference))), { startLine: 2, endLine: 3 })
assert.deepEqual(JSON.parse(JSON.stringify(await navigation.resolveCodeReferenceRange('# unsaved\n' + content, reference))), { startLine: 3, endLine: 4 })
assert.deepEqual(JSON.parse(JSON.stringify(await navigation.resolveCodeReferenceRange(content.replace(/\n/g, '\r\n'), reference))), { startLine: 2, endLine: 3 })
})
test('rejects missing, ambiguous or altered code rather than highlighting a guessed line', async () => {
for (const changed of [content.replace('0.4', '0.8'), `${content}\n${content}`]) await assert.rejects(navigation.resolveCodeReferenceRange(changed, reference), /위치를 확정/)
for (const patch of [{ expectedHash: undefined }, { snippet: '' }, { snippet: '[REDACTED]' }, { snippet: 'one line' }, { path: '../etc/passwd' }, { path: '.env' }, { startLine: 0 }, { endLine: 500 }]) {
await assert.rejects(navigation.resolveCodeReferenceRange(content, { ...reference, ...patch }))
}
})
function editorHarness(text = content) {
const calls = []
const collection = { clear: () => calls.push('clear') }
const model = { uri: { path: '/modules/Control/src/node.py' }, getValue: () => text, isDisposed: () => false, getLineMaxColumn: line => text.split('\n')[line - 1].length + 1 }
const editor = { getModel: () => model, getDomNode: () => ({ clientWidth: 800, clientHeight: 600 }), layout: () => calls.push('layout'), createDecorationsCollection: value => { calls.push(['decorate', value]); return collection }, revealRangeInCenter: value => calls.push(['reveal', value]), setPosition: value => calls.push(['position', value]), focus: () => calls.push('focus') }
const options = { file: 'modules/Control/src/node.py', reference, isCurrent: () => true, getEditor: () => editor, getActiveFile: () => 'modules/Control/src/node.py', isReady: () => true, attempts: 4 }
return { model, editor, options, calls, collection }
}
test('focuses and highlights only after the correct Monaco model has loaded', async () => {
const h = editorHarness()
let reads = 0
h.options.getEditor = () => ++reads < 3 ? null : h.editor
assert.equal(await navigation.revealAgentCodeReference(h.options), h.collection)
assert.deepEqual(h.calls.map(call => Array.isArray(call) ? call[0] : call), ['layout', 'decorate', 'position', 'reveal', 'focus'])
const selection = h.calls.find(call => call[0] === 'reveal')[1]
assert.equal(selection.startLineNumber, 2)
assert.equal(selection.endLineNumber, 3)
assert.equal(h.calls[1][1][0].options.isWholeLine, true)
})
test('does not decorate another file, a hidden/diff editor, stale scope or unready model', async () => {
for (const setup of [
h => { h.model.uri.path = '/modules/Other/src/node.py' },
h => { h.options.getActiveFile = () => 'other.py' },
h => { h.options.isReady = () => false },
h => { h.options.isCurrent = () => false },
h => { h.editor.getDomNode = () => ({ clientWidth: 0, clientHeight: 0 }) },
h => { h.model.isDisposed = () => true }
]) {
const h = editorHarness()
setup(h)
await assert.rejects(navigation.revealAgentCodeReference(h.options))
assert.equal(h.calls.length, 0)
}
})
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) && node.declarationList.declarations[0].name.getText(hookSource) === 'openCodeReference')
function consentHarness() {
const session = {}
const messageStateRef = { current: { session, messages: [{ id: 'answer', role: 'assistant', plan: { codeReferences: [reference] } }] } }
const globals = { session, messageStateRef, navigatingRef: { current: false }, applyingRef: { current: false }, abortControllerRef: { current: null }, updateMessages: update => { messageStateRef.current.messages = update(messageStateRef.current.messages) } }
return { ...compile(functions.map(node => `export ${node.getText(hookSource)}`).join('\n'), globals), globals, message: () => messageStateRef.current.messages[0] }
}
test('code navigation opens only when the user selects View in editor', async () => {
const h = consentHarness()
let opened = 0
const open = async () => { opened++ }
assert.equal(opened, 0)
await h.openCodeReference('answer', 'location', open)
assert.equal(opened, 1)
assert.equal(h.message().codeReferenceStates.location.status, 'opened')
})
test('legacy cancelled references can be opened with the same read-only navigation action', async () => {
const h = consentHarness()
h.message().codeReferenceStates = { location: { status: 'cancelled' } }
let opened = 0
await h.openCodeReference('answer', 'location', async ref => { assert.equal(ref.path, reference.path); opened++ })
assert.equal(opened, 1)
assert.equal(h.message().codeReferenceStates.location.status, 'opened')
})
test('code navigation does not block or abort a pending model response', async () => {
const h = consentHarness()
const controller = new AbortController()
h.globals.abortControllerRef.current = controller
let opened = 0
await h.openCodeReference('answer', 'location', async (_reference, isCurrent) => {
assert.equal(isCurrent(), true)
opened++
})
assert.equal(opened, 1)
assert.equal(controller.signal.aborted, false)
assert.equal(h.globals.abortControllerRef.current, controller)
assert.equal(h.message().codeReferenceStates.location.status, 'opened')
})
test('an arriving model response does not cancel an in-flight code navigation', async () => {
const h = consentHarness()
h.globals.abortControllerRef.current = new AbortController()
let finish, current
const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => {
current = isCurrent
return new Promise(resolve => { finish = resolve })
})
h.globals.messageStateRef.current.messages.push({ id: 'new-answer', role: 'assistant' })
h.globals.abortControllerRef.current = null
assert.equal(current(), true)
finish()
await pending
assert.equal(h.globals.messageStateRef.current.messages[0].codeReferenceStates.location.status, 'opened')
})
test('starting file application invalidates a navigation that is still reading', async () => {
const h = consentHarness()
let finish, current
const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => {
current = isCurrent
return new Promise((_resolve, reject) => { finish = () => reject(Error('applying')) })
})
h.globals.applyingRef.current = true
assert.equal(current(), false)
finish()
await pending
assert.equal(h.message().codeReferenceStates.location.status, 'error')
})
test('duplicate clicks, invalid IDs and busy tasks do not open more files', async () => {
const h = consentHarness()
let finish, opened = 0
const first = h.openCodeReference('answer', 'location', () => { opened++; return new Promise(resolve => { finish = resolve }) })
assert.equal(h.message().codeReferenceStates.location.status, 'opening')
await h.openCodeReference('answer', 'location', async () => { opened++ })
finish()
await first
assert.equal(opened, 1)
await h.openCodeReference('answer', 'invented', async () => { opened++ })
h.globals.applyingRef.current = true
await h.openCodeReference('answer', 'location', async () => { opened++ })
assert.equal(opened, 1)
})
test('errors remain visible and late navigation cannot update another conversation', async () => {
const h = consentHarness()
await h.openCodeReference('answer', 'location', async () => { throw Error('missing file') })
assert.equal(h.message().codeReferenceStates.location.status, 'error')
assert.equal(h.message().codeReferenceStates.location.error, 'missing file')
let finish
const pending = h.openCodeReference('answer', 'location', () => new Promise(resolve => { finish = resolve }))
const other = { id: 'other' }
h.globals.messageStateRef.current = { session: {}, messages: [other] }
finish()
await pending
assert.equal(h.message(), other)
})
test('a new question cancels pending navigation without leaving its old state stuck opening', async () => {
const h = consentHarness()
let finish, current
const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => {
current = isCurrent
return new Promise((_resolve, reject) => { finish = () => reject(Error('cancelled')) })
})
h.globals.messageStateRef.current.messages.push({ id: 'new-question', role: 'user' })
assert.equal(current(), false)
finish()
await pending
assert.equal(h.globals.messageStateRef.current.messages[0].codeReferenceStates.location.status, 'error')
assert.equal(h.globals.messageStateRef.current.messages.at(-1).id, 'new-question')
})
test('restored interrupted navigation becomes retryable without reopening any file', () => {
const { recoverInterruptedAgentMessages } = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'))
const message = { id: 'answer', role: 'assistant', codeReferenceStates: { pending: { status: 'opening' }, approved: { status: 'opened' }, declined: { status: 'cancelled' } } }
const restored = recoverInterruptedAgentMessages([message])[0]
assert.equal(restored.codeReferenceStates.pending.status, 'error')
assert.equal(restored.codeReferenceStates.approved.status, 'opened')
assert.equal(restored.codeReferenceStates.declined.status, 'cancelled')
assert.equal(message.codeReferenceStates.pending.status, 'opening')
})
function elements(tree, predicate) {
if (!React.isValidElement(tree)) return []
return [...(predicate(tree) ? [tree] : []), ...React.Children.toArray(tree.props.children).flatMap(child => elements(child, predicate))]
}
function text(tree) {
return typeof tree === 'string' ? tree : React.isValidElement(tree) ? React.Children.toArray(tree.props.children).map(text).join('') : ''
}
const render = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentCodeReferences.tsx'), 'utf8'), {}, name => name.endsWith('/i18n') ? { t: key => key, useLocaleRender() {} } : require(name)).AgentCodeReferences
for (const dark of [true, false]) test(`code navigation has only a vertically aligned View in editor button (${dark})`, () => {
const clicked = []
const props = { dark, references: [reference], onOpen: id => clicked.push(`open:${id}`) }
const buttons = elements(render(props), element => element.type === 'button')
assert.equal(clicked.length, 0)
assert.deepEqual(buttons.map(text), ['코드에서 보기'])
const label = elements(buttons[0], element => element.type === 'span')[0]
assert.match(buttons[0].props.className, /items-center.*leading-none/)
assert.match(label.props.className, /top-px leading-none/)
buttons[0].props.onClick()
assert.deepEqual(clicked, ['open:location'])
for (const button of elements(render({ ...props, busy: true }), element => element.type === 'button')) assert.equal(button.props.disabled, true)
for (const status of ['cancelled', 'opened', 'error']) {
assert.deepEqual(elements(render({ ...props, states: { location: { status } } }), element => element.type === 'button').map(text), ['코드에서 보기'])
}
const opening = elements(render({ ...props, states: { location: { status: 'opening' } } }), element => element.type === 'button')[0]
assert.equal(opening.props.disabled, true)
assert.equal(text(opening), '파일 여는 중...')
assert.equal(elements(render({ ...props, states: { location: { status: 'error', error: 'missing' } } }), element => element.props.role === 'alert').length, 1)
})