00a83ce4ac
Remove redundant code-view cancellation and align button labels. Restore module scrollbar thickness while preserving workspace colors, and support config, SDF and world text files locally and remotely.
183 lines
11 KiB
JavaScript
183 lines
11 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('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)
|
|
})
|