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) })