diff --git a/packages/amazonq/test/unit/codewhisperer/commands/invokeRecommendation.test.ts b/packages/amazonq/test/unit/codewhisperer/commands/invokeRecommendation.test.ts new file mode 100644 index 00000000000..68cebe37bb1 --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/commands/invokeRecommendation.test.ts @@ -0,0 +1,43 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' +import { resetCodeWhispererGlobalVariables, createMockTextEditor } from 'aws-core-vscode/test' +import { + ConfigurationEntry, + invokeRecommendation, + InlineCompletionService, + isInlineCompletionEnabled, + DefaultCodeWhispererClient, +} from 'aws-core-vscode/codewhisperer' + +describe('invokeRecommendation', function () { + describe('invokeRecommendation', function () { + let getRecommendationStub: sinon.SinonStub + let mockClient: DefaultCodeWhispererClient + + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + getRecommendationStub = sinon.stub(InlineCompletionService.instance, 'getPaginatedRecommendation') + }) + + afterEach(function () { + sinon.restore() + }) + + it('Should call getPaginatedRecommendation with OnDemand as trigger type when inline completion is enabled', async function () { + const mockEditor = createMockTextEditor() + const config: ConfigurationEntry = { + isShowMethodsEnabled: true, + isManualTriggerEnabled: true, + isAutomatedTriggerEnabled: true, + isSuggestionsWithCodeReferencesEnabled: true, + } + await invokeRecommendation(mockEditor, mockClient, config) + assert.strictEqual(getRecommendationStub.called, isInlineCompletionEnabled()) + }) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/commands/onAcceptance.test.ts b/packages/amazonq/test/unit/codewhisperer/commands/onAcceptance.test.ts new file mode 100644 index 00000000000..0471aaa3601 --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/commands/onAcceptance.test.ts @@ -0,0 +1,64 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as vscode from 'vscode' +import * as sinon from 'sinon' +import { onAcceptance, AcceptedSuggestionEntry, session, CodeWhispererTracker } from 'aws-core-vscode/codewhisperer' +import { resetCodeWhispererGlobalVariables, createMockTextEditor } from 'aws-core-vscode/test' + +describe('onAcceptance', function () { + describe('onAcceptance', function () { + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + session.reset() + }) + + afterEach(function () { + sinon.restore() + session.reset() + }) + + it('Should enqueue an event object to tracker', async function () { + const mockEditor = createMockTextEditor() + const trackerSpy = sinon.spy(CodeWhispererTracker.prototype, 'enqueue') + const fakeReferences = [ + { + message: '', + licenseName: 'MIT', + repository: 'http://github.com/fake', + recommendationContentSpan: { + start: 0, + end: 10, + }, + }, + ] + await onAcceptance({ + editor: mockEditor, + range: new vscode.Range(new vscode.Position(1, 0), new vscode.Position(1, 26)), + effectiveRange: new vscode.Range(new vscode.Position(1, 0), new vscode.Position(1, 26)), + acceptIndex: 0, + recommendation: "print('Hello World!')", + requestId: '', + sessionId: '', + triggerType: 'OnDemand', + completionType: 'Line', + language: 'python', + references: fakeReferences, + }) + const actualArg = trackerSpy.getCall(0).args[0] as AcceptedSuggestionEntry + assert.ok(trackerSpy.calledOnce) + assert.strictEqual(actualArg.originalString, 'def two_sum(nums, target):') + assert.strictEqual(actualArg.requestId, '') + assert.strictEqual(actualArg.sessionId, '') + assert.strictEqual(actualArg.triggerType, 'OnDemand') + assert.strictEqual(actualArg.completionType, 'Line') + assert.strictEqual(actualArg.language, 'python') + assert.deepStrictEqual(actualArg.startPosition, new vscode.Position(1, 0)) + assert.deepStrictEqual(actualArg.endPosition, new vscode.Position(1, 26)) + assert.strictEqual(actualArg.index, 0) + }) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/commands/onInlineAcceptance.test.ts b/packages/amazonq/test/unit/codewhisperer/commands/onInlineAcceptance.test.ts new file mode 100644 index 00000000000..ed3bc99fa34 --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/commands/onInlineAcceptance.test.ts @@ -0,0 +1,43 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as vscode from 'vscode' +import * as sinon from 'sinon' +import { resetCodeWhispererGlobalVariables, createMockTextEditor } from 'aws-core-vscode/test' +import { onInlineAcceptance, RecommendationHandler, session } from 'aws-core-vscode/codewhisperer' + +describe('onInlineAcceptance', function () { + describe('onInlineAcceptance', function () { + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + session.reset() + }) + + afterEach(function () { + sinon.restore() + session.reset() + }) + + it('Should dispose inline completion provider', async function () { + const mockEditor = createMockTextEditor() + const spy = sinon.spy(RecommendationHandler.instance, 'disposeInlineCompletion') + await onInlineAcceptance({ + editor: mockEditor, + range: new vscode.Range(new vscode.Position(1, 0), new vscode.Position(1, 21)), + effectiveRange: new vscode.Range(new vscode.Position(1, 0), new vscode.Position(1, 21)), + acceptIndex: 0, + recommendation: "print('Hello World!')", + requestId: '', + sessionId: '', + triggerType: 'OnDemand', + completionType: 'Line', + language: 'python', + references: undefined, + }) + assert.ok(spy.calledWith()) + }) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/service/inlineCompletionService.test.ts b/packages/amazonq/test/unit/codewhisperer/service/inlineCompletionService.test.ts new file mode 100644 index 00000000000..a35677408c4 --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/service/inlineCompletionService.test.ts @@ -0,0 +1,173 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import assert from 'assert' +import * as sinon from 'sinon' +import { + InlineCompletionService, + ReferenceInlineProvider, + RecommendationHandler, + ConfigurationEntry, + CWInlineCompletionItemProvider, + session, + DefaultCodeWhispererClient, +} from 'aws-core-vscode/codewhisperer' +import { createMockTextEditor, resetCodeWhispererGlobalVariables, createMockDocument } from 'aws-core-vscode/test' + +describe('inlineCompletionService', function () { + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + }) + + describe('getPaginatedRecommendation', function () { + const config: ConfigurationEntry = { + isShowMethodsEnabled: true, + isManualTriggerEnabled: true, + isAutomatedTriggerEnabled: true, + isSuggestionsWithCodeReferencesEnabled: true, + } + + let mockClient: DefaultCodeWhispererClient + + beforeEach(async function () { + mockClient = new DefaultCodeWhispererClient() + await resetCodeWhispererGlobalVariables() + }) + + afterEach(function () { + sinon.restore() + }) + + it('should call checkAndResetCancellationTokens before showing inline and next token to be null', async function () { + const mockEditor = createMockTextEditor() + sinon.stub(RecommendationHandler.instance, 'getRecommendations').resolves({ + result: 'Succeeded', + errorMessage: undefined, + recommendationCount: 1, + }) + const checkAndResetCancellationTokensStub = sinon.stub( + RecommendationHandler.instance, + 'checkAndResetCancellationTokens' + ) + session.recommendations = [{ content: "\n\t\tconsole.log('Hello world!');\n\t}" }, { content: '' }] + await InlineCompletionService.instance.getPaginatedRecommendation( + mockClient, + mockEditor, + 'OnDemand', + config + ) + assert.ok(checkAndResetCancellationTokensStub.called) + assert.strictEqual(RecommendationHandler.instance.hasNextToken(), false) + }) + }) + + describe('clearInlineCompletionStates', function () { + it('should remove inline reference and recommendations', async function () { + const fakeReferences = [ + { + message: '', + licenseName: 'MIT', + repository: 'http://github.com/fake', + recommendationContentSpan: { + start: 0, + end: 10, + }, + }, + ] + ReferenceInlineProvider.instance.setInlineReference(1, 'test', fakeReferences) + session.recommendations = [{ content: "\n\t\tconsole.log('Hello world!');\n\t}" }, { content: '' }] + session.language = 'python' + + assert.ok(session.recommendations.length > 0) + await RecommendationHandler.instance.clearInlineCompletionStates() + assert.strictEqual(ReferenceInlineProvider.instance.refs.length, 0) + assert.strictEqual(session.recommendations.length, 0) + }) + }) + + describe('truncateOverlapWithRightContext', function () { + const fileName = 'test.py' + const language = 'python' + const rightContext = 'return target\n' + const doc = `import math\ndef two_sum(nums, target):\n` + const provider = new CWInlineCompletionItemProvider(0, 0, [], '', new vscode.Position(0, 0), '') + + it('removes overlap with right context from suggestion', async function () { + const mockSuggestion = 'return target\n' + const mockEditor = createMockTextEditor(`${doc}${rightContext}`, fileName, language) + const cursorPosition = new vscode.Position(2, 0) + const result = provider.truncateOverlapWithRightContext(mockEditor.document, mockSuggestion, cursorPosition) + assert.strictEqual(result, '') + }) + + it('only removes the overlap part from suggestion', async function () { + const mockSuggestion = 'print(nums)\nreturn target\n' + const mockEditor = createMockTextEditor(`${doc}${rightContext}`, fileName, language) + const cursorPosition = new vscode.Position(2, 0) + const result = provider.truncateOverlapWithRightContext(mockEditor.document, mockSuggestion, cursorPosition) + assert.strictEqual(result, 'print(nums)\n') + }) + + it('only removes the last overlap pattern from suggestion', async function () { + const mockSuggestion = 'return target\nprint(nums)\nreturn target\n' + const mockEditor = createMockTextEditor(`${doc}${rightContext}`, fileName, language) + const cursorPosition = new vscode.Position(2, 0) + const result = provider.truncateOverlapWithRightContext(mockEditor.document, mockSuggestion, cursorPosition) + assert.strictEqual(result, 'return target\nprint(nums)\n') + }) + + it('returns empty string if the remaining suggestion only contains white space', async function () { + const mockSuggestion = 'return target\n ' + const mockEditor = createMockTextEditor(`${doc}${rightContext}`, fileName, language) + const cursorPosition = new vscode.Position(2, 0) + const result = provider.truncateOverlapWithRightContext(mockEditor.document, mockSuggestion, cursorPosition) + assert.strictEqual(result, '') + }) + + it('returns the original suggestion if no match found', async function () { + const mockSuggestion = 'import numpy\n' + const mockEditor = createMockTextEditor(`${doc}${rightContext}`, fileName, language) + const cursorPosition = new vscode.Position(2, 0) + const result = provider.truncateOverlapWithRightContext(mockEditor.document, mockSuggestion, cursorPosition) + assert.strictEqual(result, 'import numpy\n') + }) + + it('ignores the space at the end of recommendation', async function () { + const mockSuggestion = 'return target\n\n\n\n\n' + const mockEditor = createMockTextEditor(`${doc}${rightContext}`, fileName, language) + const cursorPosition = new vscode.Position(2, 0) + const result = provider.truncateOverlapWithRightContext(mockEditor.document, mockSuggestion, cursorPosition) + assert.strictEqual(result, '') + }) + }) +}) + +describe('CWInlineCompletionProvider', function () { + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + }) + + describe('provideInlineCompletionItems', function () { + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + }) + + afterEach(function () { + sinon.restore() + }) + + it('should return undefined if position is before RecommendationHandler start pos', async function () { + const position = new vscode.Position(0, 0) + const document = createMockDocument() + const fakeContext = { triggerKind: 0, selectedCompletionInfo: undefined } + const token = new vscode.CancellationTokenSource().token + const provider = new CWInlineCompletionItemProvider(0, 0, [], '', new vscode.Position(1, 1), '') + const result = await provider.provideInlineCompletionItems(document, position, fakeContext, token) + + assert.ok(result === undefined) + }) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/service/keyStrokeHandler.test.ts b/packages/amazonq/test/unit/codewhisperer/service/keyStrokeHandler.test.ts new file mode 100644 index 00000000000..4b6a5291f22 --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/service/keyStrokeHandler.test.ts @@ -0,0 +1,237 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as vscode from 'vscode' +import * as sinon from 'sinon' +import * as codewhispererSdkClient from 'aws-core-vscode/codewhisperer' +import { + createMockTextEditor, + createTextDocumentChangeEvent, + resetCodeWhispererGlobalVariables, +} from 'aws-core-vscode/test' +import * as EditorContext from 'aws-core-vscode/codewhisperer' +import { + ConfigurationEntry, + DocumentChangedSource, + KeyStrokeHandler, + DefaultDocumentChangedType, + RecommendationService, + ClassifierTrigger, + isInlineCompletionEnabled, + RecommendationHandler, + InlineCompletionService, +} from 'aws-core-vscode/codewhisperer' + +describe('keyStrokeHandler', function () { + const config: ConfigurationEntry = { + isShowMethodsEnabled: true, + isManualTriggerEnabled: true, + isAutomatedTriggerEnabled: true, + isSuggestionsWithCodeReferencesEnabled: true, + } + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + }) + describe('processKeyStroke', async function () { + let invokeSpy: sinon.SinonStub + let startTimerSpy: sinon.SinonStub + let mockClient: codewhispererSdkClient.DefaultCodeWhispererClient + beforeEach(async function () { + invokeSpy = sinon.stub(KeyStrokeHandler.instance, 'invokeAutomatedTrigger') + startTimerSpy = sinon.stub(KeyStrokeHandler.instance, 'startIdleTimeTriggerTimer') + sinon.spy(RecommendationHandler.instance, 'getRecommendations') + mockClient = new codewhispererSdkClient.DefaultCodeWhispererClient() + await resetCodeWhispererGlobalVariables() + sinon.stub(mockClient, 'listRecommendations') + sinon.stub(mockClient, 'generateRecommendations') + }) + afterEach(function () { + sinon.restore() + }) + + it('Whatever the input is, should skip when automatic trigger is turned off, should not call invokeAutomatedTrigger', async function () { + const mockEditor = createMockTextEditor() + const mockEvent: vscode.TextDocumentChangeEvent = createTextDocumentChangeEvent( + mockEditor.document, + new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 1)), + ' ' + ) + const cfg: ConfigurationEntry = { + isShowMethodsEnabled: true, + isManualTriggerEnabled: true, + isAutomatedTriggerEnabled: false, + isSuggestionsWithCodeReferencesEnabled: true, + } + const keyStrokeHandler = new KeyStrokeHandler() + await keyStrokeHandler.processKeyStroke(mockEvent, mockEditor, mockClient, cfg) + assert.ok(!invokeSpy.called) + assert.ok(!startTimerSpy.called) + }) + + it('Should not call invokeAutomatedTrigger when changed text across multiple lines', async function () { + await testShouldInvoke('\nprint(n', false) + }) + + it('Should not call invokeAutomatedTrigger when doing delete or undo (empty changed text)', async function () { + await testShouldInvoke('', false) + }) + + it('Should call invokeAutomatedTrigger with Enter when inputing \n', async function () { + await testShouldInvoke('\n', true) + }) + + it('Should call invokeAutomatedTrigger with Enter when inputing \r\n', async function () { + await testShouldInvoke('\r\n', true) + }) + + it('Should call invokeAutomatedTrigger with SpecialCharacter when inputing {', async function () { + await testShouldInvoke('{', true) + }) + + it('Should not call invokeAutomatedTrigger for non-special characters for classifier language if classifier says no', async function () { + sinon.stub(ClassifierTrigger.instance, 'shouldTriggerFromClassifier').returns(false) + await testShouldInvoke('a', false) + }) + + it('Should call invokeAutomatedTrigger for non-special characters for classifier language if classifier says yes', async function () { + sinon.stub(ClassifierTrigger.instance, 'shouldTriggerFromClassifier').returns(true) + await testShouldInvoke('a', true) + }) + + it('Should skip invoking if there is immediate right context on the same line and not a single }', async function () { + const casesForSuppressTokenFilling = [ + { + rightContext: 'add', + shouldInvoke: false, + }, + { + rightContext: '}', + shouldInvoke: true, + }, + { + rightContext: '} ', + shouldInvoke: true, + }, + { + rightContext: ')', + shouldInvoke: true, + }, + { + rightContext: ') ', + shouldInvoke: true, + }, + { + rightContext: ' add', + shouldInvoke: true, + }, + { + rightContext: ' ', + shouldInvoke: true, + }, + { + rightContext: '\naddTwo', + shouldInvoke: true, + }, + ] + + for (const o of casesForSuppressTokenFilling) { + await testShouldInvoke('{', o.shouldInvoke, o.rightContext) + } + }) + + async function testShouldInvoke(input: string, shouldTrigger: boolean, rightContext: string = '') { + const mockEditor = createMockTextEditor(rightContext, 'test.js', 'javascript', 0, 0) + const mockEvent: vscode.TextDocumentChangeEvent = createTextDocumentChangeEvent( + mockEditor.document, + new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 1)), + input + ) + await KeyStrokeHandler.instance.processKeyStroke(mockEvent, mockEditor, mockClient, config) + assert.strictEqual( + invokeSpy.called, + shouldTrigger, + `invokeAutomatedTrigger ${shouldTrigger ? 'NOT' : 'WAS'} called for rightContext: "${rightContext}"` + ) + } + }) + + describe('invokeAutomatedTrigger', function () { + let mockClient: codewhispererSdkClient.DefaultCodeWhispererClient + beforeEach(async function () { + sinon.restore() + mockClient = new codewhispererSdkClient.DefaultCodeWhispererClient() + await resetCodeWhispererGlobalVariables() + sinon.stub(mockClient, 'listRecommendations') + sinon.stub(mockClient, 'generateRecommendations') + }) + afterEach(function () { + sinon.restore() + }) + + it('should call getPaginatedRecommendation when inline completion is enabled', async function () { + const mockEditor = createMockTextEditor() + const keyStrokeHandler = new KeyStrokeHandler() + const mockEvent: vscode.TextDocumentChangeEvent = createTextDocumentChangeEvent( + mockEditor.document, + new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 1)), + ' ' + ) + const getRecommendationsStub = sinon.stub(InlineCompletionService.instance, 'getPaginatedRecommendation') + await keyStrokeHandler.invokeAutomatedTrigger('Enter', mockEditor, mockClient, config, mockEvent) + assert.strictEqual(getRecommendationsStub.called, isInlineCompletionEnabled()) + }) + }) + + describe('shouldTriggerIdleTime', function () { + it('should return false when inline is enabled and inline completion is in progress ', function () { + const keyStrokeHandler = new KeyStrokeHandler() + sinon.stub(RecommendationService.instance, 'isRunning').get(() => true) + const result = keyStrokeHandler.shouldTriggerIdleTime() + assert.strictEqual(result, !isInlineCompletionEnabled()) + }) + }) + + describe('test checkChangeSource', function () { + const tabStr = ' '.repeat(EditorContext.getTabSize()) + + const cases: [string, DocumentChangedSource][] = [ + ['\n ', DocumentChangedSource.EnterKey], + ['\n', DocumentChangedSource.EnterKey], + ['(', DocumentChangedSource.SpecialCharsKey], + ['()', DocumentChangedSource.SpecialCharsKey], + ['{}', DocumentChangedSource.SpecialCharsKey], + ['(a, b):', DocumentChangedSource.Unknown], + [':', DocumentChangedSource.SpecialCharsKey], + ['a', DocumentChangedSource.RegularKey], + [tabStr, DocumentChangedSource.TabKey], + [' ', DocumentChangedSource.Reformatting], + ['def add(a,b):\n return a + b\n', DocumentChangedSource.Unknown], + ['function suggestedByIntelliSense():', DocumentChangedSource.Unknown], + ] + + for (const tuple of cases) { + const input = tuple[0] + const expected = tuple[1] + it(`test input ${input} should return ${expected}`, function () { + const actual = new DefaultDocumentChangedType( + createFakeDocumentChangeEvent(tuple[0]) + ).checkChangeSource() + assert.strictEqual(actual, expected) + }) + } + + function createFakeDocumentChangeEvent(str: string): ReadonlyArray { + return [ + { + range: new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 5)), + rangeOffset: 0, + rangeLength: 0, + text: str, + }, + ] + } + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/service/recommendationHandler.test.ts b/packages/amazonq/test/unit/codewhisperer/service/recommendationHandler.test.ts new file mode 100644 index 00000000000..86dfc5e514c --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/service/recommendationHandler.test.ts @@ -0,0 +1,271 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as vscode from 'vscode' +import * as sinon from 'sinon' +import { + ReferenceInlineProvider, + session, + AuthUtil, + DefaultCodeWhispererClient, + RecommendationsList, + ConfigurationEntry, + RecommendationHandler, + CodeWhispererCodeCoverageTracker, + supplementalContextUtil, +} from 'aws-core-vscode/codewhisperer' +import { + assertTelemetryCurried, + stub, + createMockTextEditor, + resetCodeWhispererGlobalVariables, +} from 'aws-core-vscode/test' +// import * as supplementalContextUtil from 'aws-core-vscode/codewhisperer' + +describe('recommendationHandler', function () { + const config: ConfigurationEntry = { + isShowMethodsEnabled: true, + isManualTriggerEnabled: true, + isAutomatedTriggerEnabled: true, + isSuggestionsWithCodeReferencesEnabled: true, + } + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + }) + + describe('getRecommendations', async function () { + const mockClient = stub(DefaultCodeWhispererClient) + const mockEditor = createMockTextEditor() + const testStartUrl = 'testStartUrl' + + beforeEach(async function () { + sinon.restore() + await resetCodeWhispererGlobalVariables() + mockClient.listRecommendations.resolves({}) + mockClient.generateRecommendations.resolves({}) + RecommendationHandler.instance.clearRecommendations() + sinon.stub(AuthUtil.instance, 'startUrl').value(testStartUrl) + }) + + afterEach(function () { + sinon.restore() + }) + + it('should assign correct recommendations given input', async function () { + assert.strictEqual(CodeWhispererCodeCoverageTracker.instances.size, 0) + assert.strictEqual( + CodeWhispererCodeCoverageTracker.getTracker(mockEditor.document.languageId)?.serviceInvocationCount, + 0 + ) + + const mockServerResult = { + recommendations: [{ content: "print('Hello World!')" }, { content: '' }], + $response: { + requestId: 'test_request', + httpResponse: { + headers: { + 'x-amzn-sessionid': 'test_request', + }, + }, + }, + } + const handler = new RecommendationHandler() + sinon.stub(handler, 'getServerResponse').resolves(mockServerResult) + await handler.getRecommendations(mockClient, mockEditor, 'AutoTrigger', config, 'Enter', false) + const actual = session.recommendations + const expected: RecommendationsList = [{ content: "print('Hello World!')" }, { content: '' }] + assert.deepStrictEqual(actual, expected) + assert.strictEqual( + CodeWhispererCodeCoverageTracker.getTracker(mockEditor.document.languageId)?.serviceInvocationCount, + 1 + ) + }) + + it('should assign request id correctly', async function () { + const mockServerResult = { + recommendations: [{ content: "print('Hello World!')" }, { content: '' }], + $response: { + requestId: 'test_request', + httpResponse: { + headers: { + 'x-amzn-sessionid': 'test_request', + }, + }, + }, + } + const handler = new RecommendationHandler() + sinon.stub(handler, 'getServerResponse').resolves(mockServerResult) + sinon.stub(handler, 'isCancellationRequested').returns(false) + await handler.getRecommendations(mockClient, mockEditor, 'AutoTrigger', config, 'Enter', false) + assert.strictEqual(handler.requestId, 'test_request') + assert.strictEqual(session.sessionId, 'test_request') + assert.strictEqual(session.triggerType, 'AutoTrigger') + }) + + it('should call telemetry function that records a CodeWhisperer service invocation', async function () { + const mockServerResult = { + recommendations: [{ content: "print('Hello World!')" }, { content: '' }], + $response: { + requestId: 'test_request', + httpResponse: { + headers: { + 'x-amzn-sessionid': 'test_request', + }, + }, + }, + } + const handler = new RecommendationHandler() + sinon.stub(handler, 'getServerResponse').resolves(mockServerResult) + sinon.stub(supplementalContextUtil, 'fetchSupplementalContext').resolves({ + isUtg: false, + isProcessTimeout: false, + supplementalContextItems: [], + contentsLength: 100, + latency: 0, + strategy: 'empty', + }) + sinon.stub(performance, 'now').returns(0.0) + session.startPos = new vscode.Position(1, 0) + session.startCursorOffset = 2 + await handler.getRecommendations(mockClient, mockEditor, 'AutoTrigger', config, 'Enter') + const assertTelemetry = assertTelemetryCurried('codewhisperer_serviceInvocation') + assertTelemetry({ + codewhispererRequestId: 'test_request', + codewhispererSessionId: 'test_request', + codewhispererLastSuggestionIndex: 1, + codewhispererTriggerType: 'AutoTrigger', + codewhispererAutomatedTriggerType: 'Enter', + codewhispererImportRecommendationEnabled: true, + result: 'Succeeded', + codewhispererLineNumber: 1, + codewhispererCursorOffset: 38, + codewhispererLanguage: 'python', + credentialStartUrl: testStartUrl, + codewhispererSupplementalContextIsUtg: false, + codewhispererSupplementalContextTimeout: false, + codewhispererSupplementalContextLatency: 0, + codewhispererSupplementalContextLength: 100, + }) + }) + }) + + describe('isValidResponse', function () { + afterEach(function () { + sinon.restore() + }) + it('should return true if any response is not empty', function () { + const handler = new RecommendationHandler() + session.recommendations = [ + { + content: + '\n // Use the console to output debug info…n of the command with the "command" variable', + }, + { content: '' }, + ] + assert.ok(handler.isValidResponse()) + }) + + it('should return false if response is empty', function () { + const handler = new RecommendationHandler() + session.recommendations = [] + assert.ok(!handler.isValidResponse()) + }) + + it('should return false if all response has no string length', function () { + const handler = new RecommendationHandler() + session.recommendations = [{ content: '' }, { content: '' }] + assert.ok(!handler.isValidResponse()) + }) + }) + + describe('setCompletionType/getCompletionType', function () { + beforeEach(function () { + sinon.restore() + }) + + it('should set the completion type to block given a multi-line suggestion', function () { + session.setCompletionType(0, { content: 'test\n\n \t\r\nanother test' }) + assert.strictEqual(session.getCompletionType(0), 'Block') + + session.setCompletionType(0, { content: 'test\ntest\n' }) + assert.strictEqual(session.getCompletionType(0), 'Block') + + session.setCompletionType(0, { content: '\n \t\r\ntest\ntest' }) + assert.strictEqual(session.getCompletionType(0), 'Block') + }) + + it('should set the completion type to line given a single-line suggestion', function () { + session.setCompletionType(0, { content: 'test' }) + assert.strictEqual(session.getCompletionType(0), 'Line') + + session.setCompletionType(0, { content: 'test\r\t ' }) + assert.strictEqual(session.getCompletionType(0), 'Line') + }) + + it('should set the completion type to line given a multi-line completion but only one-lien of non-blank sequence', function () { + session.setCompletionType(0, { content: 'test\n\t' }) + assert.strictEqual(session.getCompletionType(0), 'Line') + + session.setCompletionType(0, { content: 'test\n ' }) + assert.strictEqual(session.getCompletionType(0), 'Line') + + session.setCompletionType(0, { content: 'test\n\r' }) + assert.strictEqual(session.getCompletionType(0), 'Line') + + session.setCompletionType(0, { content: '\n\n\n\ntest' }) + assert.strictEqual(session.getCompletionType(0), 'Line') + }) + }) + + describe('on event change', async function () { + beforeEach(function () { + const fakeReferences = [ + { + message: '', + licenseName: 'MIT', + repository: 'http://github.com/fake', + recommendationContentSpan: { + start: 0, + end: 10, + }, + }, + ] + ReferenceInlineProvider.instance.setInlineReference(1, 'test', fakeReferences) + session.sessionId = '' + RecommendationHandler.instance.requestId = '' + }) + + it('should remove inline reference onEditorChange', async function () { + session.sessionId = 'aSessionId' + RecommendationHandler.instance.requestId = 'aRequestId' + await RecommendationHandler.instance.onEditorChange() + assert.strictEqual(ReferenceInlineProvider.instance.refs.length, 0) + }) + it('should remove inline reference onFocusChange', async function () { + session.sessionId = 'aSessionId' + RecommendationHandler.instance.requestId = 'aRequestId' + await RecommendationHandler.instance.onFocusChange() + assert.strictEqual(ReferenceInlineProvider.instance.refs.length, 0) + }) + it('should not remove inline reference on cursor change from typing', async function () { + await RecommendationHandler.instance.onCursorChange({ + textEditor: createMockTextEditor(), + selections: [], + kind: vscode.TextEditorSelectionChangeKind.Keyboard, + }) + assert.strictEqual(ReferenceInlineProvider.instance.refs.length, 1) + }) + + it('should remove inline reference on cursor change from mouse movement', async function () { + await RecommendationHandler.instance.onCursorChange({ + textEditor: vscode.window.activeTextEditor!, + selections: [], + kind: vscode.TextEditorSelectionChangeKind.Mouse, + }) + assert.strictEqual(ReferenceInlineProvider.instance.refs.length, 0) + }) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/tracker/codewhispererCodeCoverageTracker.test.ts b/packages/amazonq/test/unit/codewhisperer/tracker/codewhispererCodeCoverageTracker.test.ts new file mode 100644 index 00000000000..ee001b3328d --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/tracker/codewhispererCodeCoverageTracker.test.ts @@ -0,0 +1,560 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' +import * as vscode from 'vscode' +import { + CodeWhispererCodeCoverageTracker, + vsCodeState, + TelemetryHelper, + AuthUtil, + getUnmodifiedAcceptedTokens, +} from 'aws-core-vscode/codewhisperer' +import { createMockDocument, createMockTextEditor, resetCodeWhispererGlobalVariables } from 'aws-core-vscode/test' +import { globals } from 'aws-core-vscode/shared' +import { assertTelemetryCurried } from 'aws-core-vscode/test' + +describe('codewhispererCodecoverageTracker', function () { + const language = 'python' + + describe('test getTracker', function () { + afterEach(async function () { + await resetCodeWhispererGlobalVariables() + CodeWhispererCodeCoverageTracker.instances.clear() + }) + + it('unsupported language', function () { + assert.strictEqual(CodeWhispererCodeCoverageTracker.getTracker('vb'), undefined) + assert.strictEqual(CodeWhispererCodeCoverageTracker.getTracker('ipynb'), undefined) + }) + + it('supported language', function () { + assert.notStrictEqual(CodeWhispererCodeCoverageTracker.getTracker('python'), undefined) + assert.notStrictEqual(CodeWhispererCodeCoverageTracker.getTracker('javascriptreact'), undefined) + assert.notStrictEqual(CodeWhispererCodeCoverageTracker.getTracker('java'), undefined) + assert.notStrictEqual(CodeWhispererCodeCoverageTracker.getTracker('javascript'), undefined) + assert.notStrictEqual(CodeWhispererCodeCoverageTracker.getTracker('cpp'), undefined) + assert.notStrictEqual(CodeWhispererCodeCoverageTracker.getTracker('ruby'), undefined) + assert.notStrictEqual(CodeWhispererCodeCoverageTracker.getTracker('go'), undefined) + }) + + it('supported language and should return singleton object per language', function () { + let instance1: CodeWhispererCodeCoverageTracker | undefined + let instance2: CodeWhispererCodeCoverageTracker | undefined + instance1 = CodeWhispererCodeCoverageTracker.getTracker('java') + instance2 = CodeWhispererCodeCoverageTracker.getTracker('java') + assert.notStrictEqual(instance1, undefined) + assert.strictEqual(Object.is(instance1, instance2), true) + + instance1 = CodeWhispererCodeCoverageTracker.getTracker('python') + instance2 = CodeWhispererCodeCoverageTracker.getTracker('python') + assert.notStrictEqual(instance1, undefined) + assert.strictEqual(Object.is(instance1, instance2), true) + + instance1 = CodeWhispererCodeCoverageTracker.getTracker('javascriptreact') + instance2 = CodeWhispererCodeCoverageTracker.getTracker('javascriptreact') + assert.notStrictEqual(instance1, undefined) + assert.strictEqual(Object.is(instance1, instance2), true) + }) + }) + + describe('test isActive', function () { + let tracker: CodeWhispererCodeCoverageTracker | undefined + + afterEach(async function () { + await resetCodeWhispererGlobalVariables() + CodeWhispererCodeCoverageTracker.instances.clear() + sinon.restore() + }) + + it('inactive case: telemetryEnable = true, isConnected = false', function () { + sinon.stub(TelemetryHelper.instance, 'isTelemetryEnabled').returns(true) + sinon.stub(AuthUtil.instance, 'isConnected').returns(false) + + tracker = CodeWhispererCodeCoverageTracker.getTracker('python') + if (!tracker) { + assert.fail() + } + + assert.strictEqual(tracker.isActive(), false) + }) + + it('inactive case: telemetryEnabled = false, isConnected = false', function () { + sinon.stub(TelemetryHelper.instance, 'isTelemetryEnabled').returns(false) + sinon.stub(AuthUtil.instance, 'isConnected').returns(false) + + tracker = CodeWhispererCodeCoverageTracker.getTracker('java') + if (!tracker) { + assert.fail() + } + + assert.strictEqual(tracker.isActive(), false) + }) + + it('active case: telemetryEnabled = true, isConnected = true', function () { + sinon.stub(TelemetryHelper.instance, 'isTelemetryEnabled').returns(true) + sinon.stub(AuthUtil.instance, 'isConnected').returns(true) + + tracker = CodeWhispererCodeCoverageTracker.getTracker('javascript') + if (!tracker) { + assert.fail() + } + assert.strictEqual(tracker.isActive(), true) + }) + }) + + describe('updateAcceptedTokensCount', function () { + let tracker: CodeWhispererCodeCoverageTracker | undefined + + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + tracker = CodeWhispererCodeCoverageTracker.getTracker(language) + if (tracker) { + sinon.stub(tracker, 'isActive').returns(true) + } + }) + + afterEach(function () { + sinon.restore() + CodeWhispererCodeCoverageTracker.instances.clear() + }) + + it('Should compute edit distance to update the accepted tokens', function () { + if (!tracker) { + assert.fail() + } + const editor = createMockTextEditor('def addTwoNumbers(a, b):\n') + + tracker.addAcceptedTokens(editor.document.fileName, { + range: new vscode.Range(0, 0, 0, 25), + text: `def addTwoNumbers(x, y):\n`, + accepted: 25, + }) + tracker.addTotalTokens(editor.document.fileName, 100) + tracker.updateAcceptedTokensCount(editor) + assert.strictEqual(tracker?.acceptedTokens[editor.document.fileName][0].accepted, 23) + }) + }) + + describe('getUnmodifiedAcceptedTokens', function () { + let tracker: CodeWhispererCodeCoverageTracker | undefined + + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + tracker = CodeWhispererCodeCoverageTracker.getTracker(language) + if (tracker) { + sinon.stub(tracker, 'isActive').returns(true) + } + }) + + afterEach(function () { + sinon.restore() + CodeWhispererCodeCoverageTracker.instances.clear() + }) + + it('Should return correct unmodified accepted tokens count', function () { + assert.strictEqual(getUnmodifiedAcceptedTokens('foo', 'fou'), 2) + assert.strictEqual(getUnmodifiedAcceptedTokens('foo', 'f11111oo'), 3) + assert.strictEqual(getUnmodifiedAcceptedTokens('foo', 'fo'), 2) + assert.strictEqual(getUnmodifiedAcceptedTokens('helloworld', 'HelloWorld'), 8) + assert.strictEqual(getUnmodifiedAcceptedTokens('helloworld', 'World'), 4) + assert.strictEqual(getUnmodifiedAcceptedTokens('CodeWhisperer', 'CODE'), 1) + assert.strictEqual(getUnmodifiedAcceptedTokens('CodeWhisperer', 'CodeWhispererGood'), 13) + }) + }) + + describe('countAcceptedTokens', function () { + let tracker: CodeWhispererCodeCoverageTracker | undefined + + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + tracker = CodeWhispererCodeCoverageTracker.getTracker(language) + if (tracker) { + sinon.stub(tracker, 'isActive').returns(true) + } + }) + + afterEach(function () { + sinon.restore() + CodeWhispererCodeCoverageTracker.instances.clear() + }) + + it('Should skip when tracker is not active', function () { + if (!tracker) { + assert.fail() + } + tracker.countAcceptedTokens(new vscode.Range(0, 0, 0, 1), 'a', 'test.py') + const spy = sinon.spy(CodeWhispererCodeCoverageTracker.prototype, 'addAcceptedTokens') + assert.ok(!spy.called) + }) + + it('Should increase AcceptedTokens', function () { + if (!tracker) { + assert.fail() + } + tracker.countAcceptedTokens(new vscode.Range(0, 0, 0, 1), 'a', 'test.py') + assert.deepStrictEqual(tracker.acceptedTokens['test.py'][0], { + range: new vscode.Range(0, 0, 0, 1), + text: 'a', + accepted: 1, + }) + }) + it('Should increase TotalTokens', function () { + if (!tracker) { + assert.fail() + } + tracker.countAcceptedTokens(new vscode.Range(0, 0, 0, 1), 'a', 'test.py') + tracker.countAcceptedTokens(new vscode.Range(0, 0, 0, 1), 'b', 'test.py') + assert.deepStrictEqual(tracker.totalTokens['test.py'], 2) + }) + }) + + describe('countTotalTokens', function () { + let tracker: CodeWhispererCodeCoverageTracker | undefined + + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + tracker = CodeWhispererCodeCoverageTracker.getTracker(language) + if (tracker) { + sinon.stub(tracker, 'isActive').returns(true) + } + }) + + afterEach(function () { + sinon.restore() + CodeWhispererCodeCoverageTracker.instances.clear() + }) + + it('Should skip when content change size is more than 50', function () { + if (!tracker) { + assert.fail() + } + tracker.countTotalTokens({ + reason: undefined, + document: createMockDocument(), + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 600), + rangeOffset: 0, + rangeLength: 600, + text: 'def twoSum(nums, target):\nfor '.repeat(20), + }, + ], + }) + assert.strictEqual(Object.keys(tracker.totalTokens).length, 0) + }) + + it('Should not skip when content change size is less than 50', function () { + if (!tracker) { + assert.fail() + } + tracker.countTotalTokens({ + reason: undefined, + document: createMockDocument(), + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 49), + rangeOffset: 0, + rangeLength: 49, + text: 'a = 123'.repeat(7), + }, + ], + }) + assert.strictEqual(Object.keys(tracker.totalTokens).length, 1) + assert.strictEqual(Object.values(tracker.totalTokens)[0], 49) + }) + + it('Should skip when CodeWhisperer is editing', function () { + if (!tracker) { + assert.fail() + } + vsCodeState.isCodeWhispererEditing = true + tracker.countTotalTokens({ + reason: undefined, + document: createMockDocument(), + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 30), + rangeOffset: 0, + rangeLength: 30, + text: 'def twoSum(nums, target):\nfor', + }, + ], + }) + const startedSpy = sinon.spy(CodeWhispererCodeCoverageTracker.prototype, 'addTotalTokens') + assert.ok(!startedSpy.called) + }) + + it('Should not reduce tokens when delete', function () { + if (!tracker) { + assert.fail() + } + const doc = createMockDocument('import math', 'test.py', 'python') + tracker.countTotalTokens({ + reason: undefined, + document: doc, + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 1), + rangeOffset: 0, + rangeLength: 0, + text: 'a', + }, + ], + }) + tracker.countTotalTokens({ + reason: undefined, + document: doc, + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 1), + rangeOffset: 0, + rangeLength: 0, + text: 'b', + }, + ], + }) + assert.strictEqual(tracker?.totalTokens[doc.fileName], 2) + tracker.countTotalTokens({ + reason: undefined, + document: doc, + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 1), + rangeOffset: 1, + rangeLength: 1, + text: '', + }, + ], + }) + assert.strictEqual(tracker?.totalTokens[doc.fileName], 2) + }) + + it('Should add tokens when type', function () { + if (!tracker) { + assert.fail() + } + const doc = createMockDocument('import math', 'test.py', 'python') + tracker.countTotalTokens({ + reason: undefined, + document: doc, + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 1), + rangeOffset: 0, + rangeLength: 0, + text: 'a', + }, + ], + }) + assert.strictEqual(tracker?.totalTokens[doc.fileName], 1) + }) + + it('Should add tokens when hitting enter with indentation', function () { + if (!tracker) { + assert.fail() + } + const doc = createMockDocument('def h():', 'test.py', 'python') + tracker.countTotalTokens({ + reason: undefined, + document: doc, + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 8), + rangeOffset: 0, + rangeLength: 0, + text: '\n ', + }, + ], + }) + assert.strictEqual(tracker?.totalTokens[doc.fileName], 1) + }) + + it('Should add tokens when hitting enter with indentation in Windows', function () { + if (!tracker) { + assert.fail() + } + const doc = createMockDocument('def h():', 'test.py', 'python') + tracker.countTotalTokens({ + reason: undefined, + document: doc, + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 8), + rangeOffset: 0, + rangeLength: 0, + text: '\r\n ', + }, + ], + }) + assert.strictEqual(tracker?.totalTokens[doc.fileName], 1) + }) + + it('Should add tokens when hitting enter with indentation in Java', function () { + if (!tracker) { + assert.fail() + } + const doc = createMockDocument('class A() {', 'test.java', 'java') + tracker.countTotalTokens({ + reason: undefined, + document: doc, + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 11), + rangeOffset: 0, + rangeLength: 0, + text: '', + }, + { + range: new vscode.Range(0, 0, 0, 11), + rangeOffset: 0, + rangeLength: 0, + text: '\n\t\t', + }, + ], + }) + assert.strictEqual(tracker?.totalTokens[doc.fileName], 1) + }) + + it('Should add tokens when inserting closing brackets', function () { + if (!tracker) { + assert.fail() + } + const doc = createMockDocument('a=', 'test.py', 'python') + tracker.countTotalTokens({ + reason: undefined, + document: doc, + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 3), + rangeOffset: 0, + rangeLength: 0, + text: '[]', + }, + ], + }) + assert.strictEqual(tracker?.totalTokens[doc.fileName], 2) + }) + + it('Should add tokens when inserting closing brackets in Java', function () { + if (!tracker) { + assert.fail() + } + const doc = createMockDocument('class A ', 'test.java', 'java') + tracker.countTotalTokens({ + reason: undefined, + document: doc, + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 8), + rangeOffset: 0, + rangeLength: 0, + text: '{}', + }, + { + range: new vscode.Range(0, 0, 0, 8), + rangeOffset: 0, + rangeLength: 0, + text: '', + }, + ], + }) + assert.strictEqual(tracker?.totalTokens[doc.fileName], 2) + }) + }) + + describe('flush', function () { + let tracker: CodeWhispererCodeCoverageTracker | undefined + + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + tracker = CodeWhispererCodeCoverageTracker.getTracker(language) + if (tracker) { + sinon.stub(tracker, 'isActive').returns(true) + } + }) + + afterEach(function () { + sinon.restore() + CodeWhispererCodeCoverageTracker.instances.clear() + }) + + it('Should not send codecoverage telemetry if tracker is not active', function () { + if (!tracker) { + assert.fail() + } + sinon.restore() + sinon.stub(tracker, 'isActive').returns(false) + + tracker.addAcceptedTokens(`test.py`, { range: new vscode.Range(0, 0, 0, 7), text: `print()`, accepted: 7 }) + tracker.addTotalTokens(`test.py`, 100) + tracker.flush() + const data = globals.telemetry.logger.query({ + metricName: 'codewhisperer_codePercentage', + excludeKeys: ['awsAccount'], + }) + assert.strictEqual(data.length, 0) + }) + }) + + describe('emitCodeWhispererCodeContribution', function () { + let tracker: CodeWhispererCodeCoverageTracker | undefined + + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + tracker = CodeWhispererCodeCoverageTracker.getTracker(language) + if (tracker) { + sinon.stub(tracker, 'isActive').returns(true) + } + }) + + afterEach(function () { + sinon.restore() + CodeWhispererCodeCoverageTracker.instances.clear() + }) + + it('should emit correct code coverage telemetry in python file', async function () { + const tracker = CodeWhispererCodeCoverageTracker.getTracker(language) + + const assertTelemetry = assertTelemetryCurried('codewhisperer_codePercentage') + tracker?.incrementServiceInvocationCount() + tracker?.addAcceptedTokens(`test.py`, { range: new vscode.Range(0, 0, 0, 7), text: `print()`, accepted: 7 }) + tracker?.addTotalTokens(`test.py`, 100) + tracker?.emitCodeWhispererCodeContribution() + assertTelemetry({ + codewhispererTotalTokens: 100, + codewhispererLanguage: language, + codewhispererAcceptedTokens: 7, + codewhispererSuggestedTokens: 7, + codewhispererPercentage: 7, + successCount: 1, + }) + }) + + it('should emit correct code coverage telemetry when success count = 0', async function () { + const tracker = CodeWhispererCodeCoverageTracker.getTracker('java') + + const assertTelemetry = assertTelemetryCurried('codewhisperer_codePercentage') + tracker?.addAcceptedTokens(`test.java`, { + range: new vscode.Range(0, 0, 0, 18), + text: `public static main`, + accepted: 18, + }) + tracker?.incrementServiceInvocationCount() + tracker?.incrementServiceInvocationCount() + tracker?.addTotalTokens(`test.java`, 30) + tracker?.emitCodeWhispererCodeContribution() + assertTelemetry({ + codewhispererTotalTokens: 30, + codewhispererLanguage: 'java', + codewhispererAcceptedTokens: 18, + codewhispererSuggestedTokens: 18, + codewhispererPercentage: 60, + successCount: 2, + }) + }) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/util/bm25.test.ts b/packages/amazonq/test/unit/codewhisperer/util/bm25.test.ts new file mode 100644 index 00000000000..0a3c4b17d60 --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/util/bm25.test.ts @@ -0,0 +1,117 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { BM25Okapi } from 'aws-core-vscode/codewhisperer' + +describe('bm25', function () { + it('simple case 1', function () { + const query = 'windy London' + const corpus = ['Hello there good man!', 'It is quite windy in London', 'How is the weather today?'] + + const sut = new BM25Okapi(corpus) + const actual = sut.score(query) + + assert.deepStrictEqual(actual, [ + { + content: 'Hello there good man!', + index: 0, + score: 0, + }, + { + content: 'It is quite windy in London', + index: 1, + score: 0.937294722506405, + }, + { + content: 'How is the weather today?', + index: 2, + score: 0, + }, + ]) + + assert.deepStrictEqual(sut.topN(query, 1), [ + { + content: 'It is quite windy in London', + index: 1, + score: 0.937294722506405, + }, + ]) + }) + + it('simple case 2', function () { + const query = 'codewhisperer is a machine learning powered code generator' + const corpus = [ + 'codewhisperer goes GA at April 2023', + 'machine learning tool is the trending topic!!! :)', + 'codewhisperer is good =))))', + 'codewhisperer vs. copilot, which code generator better?', + 'copilot is a AI code generator too', + 'it is so amazing!!', + ] + + const sut = new BM25Okapi(corpus) + const actual = sut.score(query) + + assert.deepStrictEqual(actual, [ + { + content: 'codewhisperer goes GA at April 2023', + index: 0, + score: 0, + }, + { + content: 'machine learning tool is the trending topic!!! :)', + index: 1, + score: 2.597224531416621, + }, + { + content: 'codewhisperer is good =))))', + index: 2, + score: 0.3471790843435529, + }, + { + content: 'codewhisperer vs. copilot, which code generator better?', + index: 3, + score: 1.063018436525109, + }, + { + content: 'copilot is a AI code generator too', + index: 4, + score: 2.485359418462239, + }, + { + content: 'it is so amazing!!', + index: 5, + score: 0.3154033715392277, + }, + ]) + + assert.deepStrictEqual(sut.topN(query, 1), [ + { + content: 'machine learning tool is the trending topic!!! :)', + index: 1, + score: 2.597224531416621, + }, + ]) + + assert.deepStrictEqual(sut.topN(query, 3), [ + { + content: 'machine learning tool is the trending topic!!! :)', + index: 1, + score: 2.597224531416621, + }, + { + content: 'copilot is a AI code generator too', + index: 4, + score: 2.485359418462239, + }, + { + content: 'codewhisperer vs. copilot, which code generator better?', + index: 3, + score: 1.063018436525109, + }, + ]) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/util/codeParsingUtil.test.ts b/packages/amazonq/test/unit/codewhisperer/util/codeParsingUtil.test.ts new file mode 100644 index 00000000000..2a2ad8bb34e --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/util/codeParsingUtil.test.ts @@ -0,0 +1,327 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + PlatformLanguageId, + extractClasses, + extractFunctions, + isTestFile, + utgLanguageConfigs, +} from 'aws-core-vscode/codewhisperer' +import assert from 'assert' +import { createTestWorkspaceFolder, toTextDocument } from 'aws-core-vscode/test' + +describe('RegexValidationForPython', () => { + it('should extract all function names from a python file content', () => { + // TODO: Replace this variable based testing to read content from File. + // const filePath = vscode.Uri.file('./testData/samplePython.py').fsPath; + // const fileContent = fs.readFileSync('./testData/samplePython.py' , 'utf-8'); + // const regex = /function\s+(\w+)/g; + + const result = extractFunctions(pythonFileContent, utgLanguageConfigs['python'].functionExtractionPattern) + assert.strictEqual(result.length, 13) + assert.deepStrictEqual(result, [ + 'hello_world', + 'add_numbers', + 'multiply_numbers', + 'sum_numbers', + 'divide_numbers', + '__init__', + 'add', + 'multiply', + 'square', + 'from_sum', + '__init__', + 'triple', + 'main', + ]) + }) + + it('should extract all class names from a file content', () => { + const result = extractClasses(pythonFileContent, utgLanguageConfigs['python'].classExtractionPattern) + assert.deepStrictEqual(result, ['Calculator']) + }) +}) + +describe('RegexValidationForJava', () => { + it('should extract all function names from a java file content', () => { + // TODO: Replace this variable based testing to read content from File. + // const filePath = vscode.Uri.file('./testData/samplePython.py').fsPath; + // const fileContent = fs.readFileSync('./testData/samplePython.py' , 'utf-8'); + // const regex = /function\s+(\w+)/g; + + const result = extractFunctions(javaFileContent, utgLanguageConfigs['java'].functionExtractionPattern) + assert.strictEqual(result.length, 5) + assert.deepStrictEqual(result, ['sayHello', 'doSomething', 'square', 'manager', 'ABCFUNCTION']) + }) + + it('should extract all class names from a java file content', () => { + const result = extractClasses(javaFileContent, utgLanguageConfigs['java'].classExtractionPattern) + assert.deepStrictEqual(result, ['Test']) + }) +}) + +describe('isTestFile', () => { + let testWsFolder: string + beforeEach(async function () { + testWsFolder = (await createTestWorkspaceFolder()).uri.fsPath + }) + + it('validate by file path', async function () { + const langs = new Map([ + ['java', '.java'], + ['python', '.py'], + ['typescript', '.ts'], + ['javascript', '.js'], + ['typescriptreact', '.tsx'], + ['javascriptreact', '.jsx'], + ]) + const testFilePathsWithoutExt = [ + '/test/MyClass', + '/test/my_class', + '/tst/MyClass', + '/tst/my_class', + '/tests/MyClass', + '/tests/my_class', + ] + + const srcFilePathsWithoutExt = [ + '/src/MyClass', + 'MyClass', + 'foo/bar/MyClass', + 'foo/my_class', + 'my_class', + 'anyFolderOtherThanTest/foo/myClass', + ] + + for (const [languageId, ext] of langs) { + const testFilePaths = testFilePathsWithoutExt.map((it) => it + ext) + for (const testFilePath of testFilePaths) { + const actual = await isTestFile(testFilePath, { languageId: languageId }) + assert.strictEqual(actual, true) + } + + const srcFilePaths = srcFilePathsWithoutExt.map((it) => it + ext) + for (const srcFilePath of srcFilePaths) { + const actual = await isTestFile(srcFilePath, { languageId: languageId }) + assert.strictEqual(actual, false) + } + } + }) + + async function assertIsTestFile( + fileNames: string[], + config: { languageId: PlatformLanguageId }, + expected: boolean + ) { + for (const fileName of fileNames) { + const document = await toTextDocument('', fileName, testWsFolder) + const actual = await isTestFile(document.uri.fsPath, { languageId: config.languageId }) + assert.strictEqual(actual, expected) + } + } + + it('validate by file name', async function () { + const camelCaseSrc = ['Foo.java', 'Bar.java', 'Baz.java'] + await assertIsTestFile(camelCaseSrc, { languageId: 'java' }, false) + + const camelCaseTst = ['FooTest.java', 'BarTests.java'] + await assertIsTestFile(camelCaseTst, { languageId: 'java' }, true) + + const snakeCaseSrc = ['foo.py', 'bar.py'] + await assertIsTestFile(snakeCaseSrc, { languageId: 'python' }, false) + + const snakeCaseTst = ['test_foo.py', 'bar_test.py'] + await assertIsTestFile(snakeCaseTst, { languageId: 'python' }, true) + + const javascriptSrc = ['Foo.js', 'bar.js'] + await assertIsTestFile(javascriptSrc, { languageId: 'javascript' }, false) + + const javascriptTst = ['Foo.test.js', 'Bar.spec.js'] + await assertIsTestFile(javascriptTst, { languageId: 'javascript' }, true) + + const typescriptSrc = ['Foo.ts', 'bar.ts'] + await assertIsTestFile(typescriptSrc, { languageId: 'typescript' }, false) + + const typescriptTst = ['Foo.test.ts', 'Bar.spec.ts'] + await assertIsTestFile(typescriptTst, { languageId: 'typescript' }, true) + + const jsxSrc = ['Foo.jsx', 'Bar.jsx'] + await assertIsTestFile(jsxSrc, { languageId: 'javascriptreact' }, false) + + const jsxTst = ['Foo.test.jsx', 'Bar.spec.jsx'] + await assertIsTestFile(jsxTst, { languageId: 'javascriptreact' }, true) + }) + + it('should return true if the file name matches the test filename pattern - Java', async () => { + const filePaths = ['/path/to/MyClassTest.java', '/path/to/TestMyClass.java', '/path/to/MyClassTests.java'] + const language = 'java' + + for (const filePath of filePaths) { + const result = await isTestFile(filePath, { languageId: language }) + assert.strictEqual(result, true) + } + }) + + it('should return false if the file name does not match the test filename pattern - Java', async () => { + const filePaths = ['/path/to/MyClass.java', '/path/to/MyClass_test.java', '/path/to/test_MyClass.java'] + const language = 'java' + + for (const filePath of filePaths) { + const result = await isTestFile(filePath, { languageId: language }) + assert.strictEqual(result, false) + } + }) + + it('should return true if the file name does not match the test filename pattern - Python', async () => { + const filePaths = ['/path/to/util_test.py', '/path/to/test_util.py'] + const language = 'python' + + for (const filePath of filePaths) { + const result = await isTestFile(filePath, { languageId: language }) + assert.strictEqual(result, true) + } + }) + + it('should return false if the file name does not match the test filename pattern - Python', async () => { + const filePaths = ['/path/to/util.py', '/path/to/utilTest.java', '/path/to/Testutil.java'] + const language = 'python' + + for (const filePath of filePaths) { + const result = await isTestFile(filePath, { languageId: language }) + assert.strictEqual(result, false) + } + }) + + it('should return false if the language is not supported', async () => { + const filePath = '/path/to/MyClass.cpp' + const language = 'c++' + const result = await isTestFile(filePath, { languageId: language }) + assert.strictEqual(result, false) + }) +}) + +const pythonFileContent = ` +# Single-line import statements +import os +import numpy as np +from typing import List, Tuple + +# Multi-line import statements +from collections import ( + defaultdict, + Counter +) + +# Relative imports +from . import module1 +from ..subpackage import module2 + +# Wildcard imports +from mypackage import * +from mypackage.module import * + +# Aliased imports +import pandas as pd +from mypackage import module1 as m1, module2 as m2 + +def hello_world(): + print("Hello, world!") + +def add_numbers(x, y): + return x + y + +def multiply_numbers(x=1, y=1): + return x * y + +def sum_numbers(*args): + total = 0 + for num in args: + total += num + return total + +def divide_numbers(x, y=1, *args, **kwargs): + result = x / y + for arg in args: + result /= arg + for _, value in kwargs.items(): + result /= value + return result + +class Calculator: + def __init__(self, x, y): + self.x = x + self.y = y + + def add(self): + return self.x + self.y + + def multiply(self): + return self.x * self.y + + @staticmethod + def square(x): + return x ** 2 + + @classmethod + def from_sum(cls, x, y): + return cls(x+y, 0) + + class InnerClass: + def __init__(self, z): + self.z = z + + def triple(self): + return self.z * 3 + +def main(): + print(hello_world()) + print(add_numbers(3, 5)) + print(multiply_numbers(3, 5)) + print(sum_numbers(1, 2, 3, 4, 5)) + print(divide_numbers(10, 2, 5, 2, a=2, b=3)) + + calc = Calculator(3, 5) + print(calc.add()) + print(calc.multiply()) + print(Calculator.square(3)) + print(Calculator.from_sum(2, 3).add()) + + inner = Calculator.InnerClass(5) + print(inner.triple()) + +if __name__ == "__main__": + main() +` + +const javaFileContent = ` +@Annotation +public class Test { + Test() { + // Do something here + } + + //Additional commenting + public static void sayHello() { + System.out.println("Hello, World!"); + } + + private void doSomething(int x, int y) throws Exception { + int z = x + y; + System.out.println("The sum of " + x + " and " + y + " is " + z); + } + + protected static int square(int x) { + return x * x; + } + + private static void manager(int a, int b) { + return a+b; + } + + public int ABCFUNCTION( int ABC, int PQR) { + return ABC + PQR; + } +}` diff --git a/packages/amazonq/test/unit/codewhisperer/util/commonUtil.test.ts b/packages/amazonq/test/unit/codewhisperer/util/commonUtil.test.ts new file mode 100644 index 00000000000..5694b33365d --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/util/commonUtil.test.ts @@ -0,0 +1,81 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { + JsonConfigFileNamingConvention, + checkLeftContextKeywordsForJson, + getPrefixSuffixOverlap, +} from 'aws-core-vscode/codewhisperer' + +describe('commonUtil', function () { + describe('getPrefixSuffixOverlap', function () { + it('Should return correct overlap', async function () { + assert.strictEqual(getPrefixSuffixOverlap('32rasdgvdsg', 'sg462ydfgbs'), `sg`) + assert.strictEqual(getPrefixSuffixOverlap('32rasdgbreh', 'brehsega'), `breh`) + assert.strictEqual(getPrefixSuffixOverlap('42y24hsd', '42y24hsdzqq23'), `42y24hsd`) + assert.strictEqual(getPrefixSuffixOverlap('ge23yt1', 'ge23yt1'), `ge23yt1`) + assert.strictEqual(getPrefixSuffixOverlap('1sgdbsfbwsergsa', 'a1sgdbsfbwsergs'), `a`) + assert.strictEqual(getPrefixSuffixOverlap('xxa', 'xa'), `xa`) + }) + + it('Should return empty overlap for prefix suffix not matching cases', async function () { + assert.strictEqual(getPrefixSuffixOverlap('1sgdbsfbwsergsa', '1sgdbsfbwsergs'), ``) + assert.strictEqual(getPrefixSuffixOverlap('1sgdbsfbwsergsab', '1sgdbsfbwsergs'), ``) + assert.strictEqual(getPrefixSuffixOverlap('2135t12', 'v2135t12'), ``) + assert.strictEqual(getPrefixSuffixOverlap('2135t12', 'zv2135t12'), ``) + assert.strictEqual(getPrefixSuffixOverlap('xa', 'xxa'), ``) + }) + + it('Should return empty overlap for empty string input', async function () { + assert.strictEqual(getPrefixSuffixOverlap('ergwsghws', ''), ``) + assert.strictEqual(getPrefixSuffixOverlap('', 'asfegw4eh'), ``) + }) + }) + + describe('checkLeftContextKeywordsForJson', function () { + it('Should return true for valid left context keywords', async function () { + assert.strictEqual( + checkLeftContextKeywordsForJson('foo.json', 'Create an S3 Bucket named CodeWhisperer', 'json'), + true + ) + }) + it('Should return false for invalid left context keywords', async function () { + assert.strictEqual( + checkLeftContextKeywordsForJson( + 'foo.json', + 'Create an S3 Bucket named CodeWhisperer in Cloudformation', + 'json' + ), + false + ) + }) + + for (const jsonConfigFile of JsonConfigFileNamingConvention) { + it(`should evalute by filename ${jsonConfigFile}`, function () { + assert.strictEqual(checkLeftContextKeywordsForJson(jsonConfigFile, 'foo', 'json'), false) + + assert.strictEqual(checkLeftContextKeywordsForJson(jsonConfigFile.toUpperCase(), 'bar', 'json'), false) + + assert.strictEqual(checkLeftContextKeywordsForJson(jsonConfigFile.toUpperCase(), 'baz', 'json'), false) + }) + + const upperCaseFilename = jsonConfigFile.toUpperCase() + it(`should evalute by filename and case insensitive ${upperCaseFilename}`, function () { + assert.strictEqual(checkLeftContextKeywordsForJson(upperCaseFilename, 'foo', 'json'), false) + + assert.strictEqual( + checkLeftContextKeywordsForJson(upperCaseFilename.toUpperCase(), 'bar', 'json'), + false + ) + + assert.strictEqual( + checkLeftContextKeywordsForJson(upperCaseFilename.toUpperCase(), 'baz', 'json'), + false + ) + }) + } + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/util/crossFileContextUtil.test.ts b/packages/amazonq/test/unit/codewhisperer/util/crossFileContextUtil.test.ts new file mode 100644 index 00000000000..4c2ca1190ca --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/util/crossFileContextUtil.test.ts @@ -0,0 +1,417 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as FakeTimers from '@sinonjs/fake-timers' +import * as vscode from 'vscode' +import * as sinon from 'sinon' +import * as crossFile from 'aws-core-vscode/codewhisperer' +import { + aLongStringWithLineCount, + aStringWithLineCount, + createMockTextEditor, + installFakeClock, +} from 'aws-core-vscode/test' +import { FeatureConfigProvider, crossFileContextConfig } from 'aws-core-vscode/codewhisperer' +import { + assertTabCount, + closeAllEditors, + createTestWorkspaceFolder, + toTextEditor, + shuffleList, + toFile, +} from 'aws-core-vscode/test' +import { areEqual, normalize } from 'aws-core-vscode/shared' +import * as path from 'path' + +let tempFolder: string + +describe('crossFileContextUtil', function () { + const fakeCancellationToken: vscode.CancellationToken = { + isCancellationRequested: false, + onCancellationRequested: sinon.spy(), + } + + let mockEditor: vscode.TextEditor + let clock: FakeTimers.InstalledClock + + before(function () { + clock = installFakeClock() + }) + + after(function () { + clock.uninstall() + }) + + afterEach(function () { + sinon.restore() + }) + + describe('fetchSupplementalContextForSrc', function () { + beforeEach(async function () { + tempFolder = (await createTestWorkspaceFolder()).uri.fsPath + }) + + afterEach(async function () { + sinon.restore() + }) + + it.skip('for control group, should return opentabs context where there will be 3 chunks and each chunk should contains 50 lines', async function () { + sinon.stub(FeatureConfigProvider.instance, 'getProjectContextGroup').returns('control') + await toTextEditor(aStringWithLineCount(200), 'CrossFile.java', tempFolder, { preview: false }) + const myCurrentEditor = await toTextEditor('', 'TargetFile.java', tempFolder, { + preview: false, + }) + + await assertTabCount(2) + + const actual = await crossFile.fetchSupplementalContextForSrc(myCurrentEditor, fakeCancellationToken) + assert.ok(actual) + assert.strictEqual(actual.supplementalContextItems.length, 3) + assert.strictEqual(actual.supplementalContextItems[0].content.split('\n').length, 50) + assert.strictEqual(actual.supplementalContextItems[1].content.split('\n').length, 50) + assert.strictEqual(actual.supplementalContextItems[2].content.split('\n').length, 50) + }) + + it('for t1 group, should return repomap + opentabs context, should not exceed 20k total length', async function () { + await toTextEditor(aLongStringWithLineCount(200), 'CrossFile.java', tempFolder, { preview: false }) + const myCurrentEditor = await toTextEditor('', 'TargetFile.java', tempFolder, { + preview: false, + }) + + await assertTabCount(2) + + sinon.stub(FeatureConfigProvider.instance, 'getProjectContextGroup').returns('t1') + + const mockLanguageClient = { + sendRequest: sinon.stub().resolves([ + { + content: 'foo'.repeat(3000), + score: 0, + filePath: 'q-inline', + }, + ]), + } as any + + const actual = await crossFile.fetchSupplementalContextForSrc( + myCurrentEditor, + fakeCancellationToken, + mockLanguageClient + ) + assert.ok(actual) + assert.strictEqual(actual.supplementalContextItems.length, 3) + assert.strictEqual(actual?.strategy, 'codemap') + assert.deepEqual(actual?.supplementalContextItems[0], { + content: 'foo'.repeat(3000), + score: 0, + filePath: 'q-inline', + }) + assert.strictEqual(actual.supplementalContextItems[1].content.split('\n').length, 50) + assert.strictEqual(actual.supplementalContextItems[2].content.split('\n').length, 50) + }) + + it.skip('for t2 group, should return global bm25 context and no repomap', async function () { + await toTextEditor(aStringWithLineCount(200), 'CrossFile.java', tempFolder, { preview: false }) + const myCurrentEditor = await toTextEditor('', 'TargetFile.java', tempFolder, { + preview: false, + }) + + await assertTabCount(2) + + sinon.stub(FeatureConfigProvider.instance, 'getProjectContextGroup').returns('t2') + + const actual = await crossFile.fetchSupplementalContextForSrc(myCurrentEditor, fakeCancellationToken) + assert.ok(actual) + assert.strictEqual(actual.supplementalContextItems.length, 5) + assert.strictEqual(actual?.strategy, 'bm25') + + assert.deepEqual(actual?.supplementalContextItems[0], { + content: 'foo', + score: 5, + filePath: 'foo.java', + }) + + assert.deepEqual(actual?.supplementalContextItems[1], { + content: 'bar', + score: 4, + filePath: 'bar.java', + }) + assert.deepEqual(actual?.supplementalContextItems[2], { + content: 'baz', + score: 3, + filePath: 'baz.java', + }) + + assert.deepEqual(actual?.supplementalContextItems[3], { + content: 'qux', + score: 2, + filePath: 'qux.java', + }) + + assert.deepEqual(actual?.supplementalContextItems[4], { + content: 'quux', + score: 1, + filePath: 'quux.java', + }) + }) + }) + + describe('non supported language should return undefined', function () { + it('c++', async function () { + mockEditor = createMockTextEditor('content', 'fileName', 'cpp') + const actual = await crossFile.fetchSupplementalContextForSrc(mockEditor, fakeCancellationToken) + assert.strictEqual(actual, undefined) + }) + + it('ruby', async function () { + mockEditor = createMockTextEditor('content', 'fileName', 'ruby') + + const actual = await crossFile.fetchSupplementalContextForSrc(mockEditor, fakeCancellationToken) + + assert.strictEqual(actual, undefined) + }) + }) + + describe('getCrossFileCandidate', function () { + before(async function () { + this.timeout(60000) + }) + + beforeEach(async function () { + tempFolder = (await createTestWorkspaceFolder()).uri.fsPath + }) + + afterEach(async function () { + await closeAllEditors() + }) + + it('should return opened files, exclude test files and sorted ascendingly by file distance', async function () { + const targetFile = path.join('src', 'service', 'microService', 'CodeWhispererFileContextProvider.java') + const fileWithDistance3 = path.join('src', 'service', 'CodewhispererRecommendationService.java') + const fileWithDistance5 = path.join('src', 'util', 'CodeWhispererConstants.java') + const fileWithDistance6 = path.join('src', 'ui', 'popup', 'CodeWhispererPopupManager.java') + const fileWithDistance7 = path.join('src', 'ui', 'popup', 'components', 'CodeWhispererPopup.java') + const fileWithDistance8 = path.join( + 'src', + 'ui', + 'popup', + 'components', + 'actions', + 'AcceptRecommendationAction.java' + ) + const testFile1 = path.join('test', 'service', 'CodeWhispererFileContextProviderTest.java') + const testFile2 = path.join('test', 'ui', 'CodeWhispererPopupManagerTest.java') + + const expectedFilePaths = [ + fileWithDistance3, + fileWithDistance5, + fileWithDistance6, + fileWithDistance7, + fileWithDistance8, + ] + + const shuffledFilePaths = shuffleList(expectedFilePaths) + + for (const filePath of shuffledFilePaths) { + await toTextEditor('', filePath, tempFolder, { preview: false }) + } + + await toTextEditor('', testFile1, tempFolder, { preview: false }) + await toTextEditor('', testFile2, tempFolder, { preview: false }) + const editor = await toTextEditor('', targetFile, tempFolder, { preview: false }) + + await assertTabCount(shuffledFilePaths.length + 3) + + const actual = await crossFile.getCrossFileCandidates(editor) + + assert.ok(actual.length === 5) + for (const [index, actualFile] of actual.entries()) { + const expectedFile = path.join(tempFolder, expectedFilePaths[index]) + assert.strictEqual(normalize(expectedFile), normalize(actualFile)) + assert.ok(areEqual(tempFolder, actualFile, expectedFile)) + } + }) + }) + + describe.skip('partial support - control group', function () { + const fileExtLists: string[] = [] + + before(async function () { + this.timeout(60000) + }) + + beforeEach(async function () { + tempFolder = (await createTestWorkspaceFolder()).uri.fsPath + }) + + afterEach(async function () { + await closeAllEditors() + }) + + for (const fileExt of fileExtLists) { + it('should be empty if userGroup is control', async function () { + const editor = await toTextEditor('content-1', `file-1.${fileExt}`, tempFolder) + await toTextEditor('content-2', `file-2.${fileExt}`, tempFolder, { preview: false }) + await toTextEditor('content-3', `file-3.${fileExt}`, tempFolder, { preview: false }) + await toTextEditor('content-4', `file-4.${fileExt}`, tempFolder, { preview: false }) + + await assertTabCount(4) + + const actual = await crossFile.fetchSupplementalContextForSrc(editor, fakeCancellationToken) + + assert.ok(actual && actual.supplementalContextItems.length === 0) + }) + } + }) + + describe.skip('partial support - crossfile group', function () { + const fileExtLists: string[] = [] + + before(async function () { + this.timeout(60000) + }) + + beforeEach(async function () { + tempFolder = (await createTestWorkspaceFolder()).uri.fsPath + }) + + afterEach(async function () { + await closeAllEditors() + }) + + for (const fileExt of fileExtLists) { + it('should be non empty if usergroup is Crossfile', async function () { + const editor = await toTextEditor('content-1', `file-1.${fileExt}`, tempFolder) + await toTextEditor('content-2', `file-2.${fileExt}`, tempFolder, { preview: false }) + await toTextEditor('content-3', `file-3.${fileExt}`, tempFolder, { preview: false }) + await toTextEditor('content-4', `file-4.${fileExt}`, tempFolder, { preview: false }) + + await assertTabCount(4) + + const actual = await crossFile.fetchSupplementalContextForSrc(editor, fakeCancellationToken) + + assert.ok(actual && actual.supplementalContextItems.length !== 0) + }) + } + }) + + describe('full support', function () { + const fileExtLists = ['java', 'js', 'ts', 'py', 'tsx', 'jsx'] + + before(async function () { + this.timeout(60000) + }) + + beforeEach(async function () { + tempFolder = (await createTestWorkspaceFolder()).uri.fsPath + }) + + afterEach(async function () { + sinon.restore() + await closeAllEditors() + }) + + for (const fileExt of fileExtLists) { + it(`supplemental context for file ${fileExt} should be non empty`, async function () { + sinon.stub(FeatureConfigProvider.instance, 'getProjectContextGroup').returns('control') + const editor = await toTextEditor('content-1', `file-1.${fileExt}`, tempFolder) + await toTextEditor('content-2', `file-2.${fileExt}`, tempFolder, { preview: false }) + await toTextEditor('content-3', `file-3.${fileExt}`, tempFolder, { preview: false }) + await toTextEditor('content-4', `file-4.${fileExt}`, tempFolder, { preview: false }) + + await assertTabCount(4) + + const actual = await crossFile.fetchSupplementalContextForSrc(editor, fakeCancellationToken) + + assert.ok(actual && actual.supplementalContextItems.length !== 0) + }) + } + }) + + describe('splitFileToChunks', function () { + beforeEach(async function () { + tempFolder = (await createTestWorkspaceFolder()).uri.fsPath + }) + + it('should split file to a chunk of 2 lines', async function () { + const filePath = path.join(tempFolder, 'file.txt') + await toFile('line_1\nline_2\nline_3\nline_4\nline_5\nline_6\nline_7', filePath) + + const chunks = await crossFile.splitFileToChunks(filePath, 2) + + assert.strictEqual(chunks.length, 4) + assert.strictEqual(chunks[0].content, 'line_1\nline_2') + assert.strictEqual(chunks[1].content, 'line_3\nline_4') + assert.strictEqual(chunks[2].content, 'line_5\nline_6') + assert.strictEqual(chunks[3].content, 'line_7') + }) + + it('should split file to a chunk of 5 lines', async function () { + const filePath = path.join(tempFolder, 'file.txt') + await toFile('line_1\nline_2\nline_3\nline_4\nline_5\nline_6\nline_7', filePath) + + const chunks = await crossFile.splitFileToChunks(filePath, 5) + + assert.strictEqual(chunks.length, 2) + assert.strictEqual(chunks[0].content, 'line_1\nline_2\nline_3\nline_4\nline_5') + assert.strictEqual(chunks[1].content, 'line_6\nline_7') + }) + + it('codewhisperer crossfile config should use 50 lines', async function () { + const filePath = path.join(tempFolder, 'file.txt') + await toFile(aStringWithLineCount(210), filePath) + + const chunks = await crossFile.splitFileToChunks(filePath, crossFileContextConfig.numberOfLinesEachChunk) + + // (210 / 50) + 1 + assert.strictEqual(chunks.length, 5) + // line0 -> line49 + assert.strictEqual(chunks[0].content, aStringWithLineCount(50, 0)) + // line50 -> line99 + assert.strictEqual(chunks[1].content, aStringWithLineCount(50, 50)) + // line100 -> line149 + assert.strictEqual(chunks[2].content, aStringWithLineCount(50, 100)) + // line150 -> line199 + assert.strictEqual(chunks[3].content, aStringWithLineCount(50, 150)) + // line 200 -> line209 + assert.strictEqual(chunks[4].content, aStringWithLineCount(10, 200)) + }) + + it('linkChunks should add another chunk which will link to the first chunk and chunk.nextContent should reflect correct value', async function () { + const filePath = path.join(tempFolder, 'file.txt') + await toFile(aStringWithLineCount(210), filePath) + + const chunks = await crossFile.splitFileToChunks(filePath, crossFileContextConfig.numberOfLinesEachChunk) + const linkedChunks = crossFile.linkChunks(chunks) + + // 210 / 50 + 2 + assert.strictEqual(linkedChunks.length, 6) + + // 0th + assert.strictEqual(linkedChunks[0].content, aStringWithLineCount(3, 0)) + assert.strictEqual(linkedChunks[0].nextContent, aStringWithLineCount(50, 0)) + + // 1st + assert.strictEqual(linkedChunks[1].content, aStringWithLineCount(50, 0)) + assert.strictEqual(linkedChunks[1].nextContent, aStringWithLineCount(50, 50)) + + // 2nd + assert.strictEqual(linkedChunks[2].content, aStringWithLineCount(50, 50)) + assert.strictEqual(linkedChunks[2].nextContent, aStringWithLineCount(50, 100)) + + // 3rd + assert.strictEqual(linkedChunks[3].content, aStringWithLineCount(50, 100)) + assert.strictEqual(linkedChunks[3].nextContent, aStringWithLineCount(50, 150)) + + // 4th + assert.strictEqual(linkedChunks[4].content, aStringWithLineCount(50, 150)) + assert.strictEqual(linkedChunks[4].nextContent, aStringWithLineCount(10, 200)) + + // 5th + assert.strictEqual(linkedChunks[5].content, aStringWithLineCount(10, 200)) + assert.strictEqual(linkedChunks[5].nextContent, aStringWithLineCount(10, 200)) + }) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/util/editorContext.test.ts b/packages/amazonq/test/unit/codewhisperer/util/editorContext.test.ts new file mode 100644 index 00000000000..3875dbbd0f2 --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/util/editorContext.test.ts @@ -0,0 +1,392 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import assert from 'assert' +import * as codewhispererClient from 'aws-core-vscode/codewhisperer' +import * as EditorContext from 'aws-core-vscode/codewhisperer' +import { + createMockDocument, + createMockTextEditor, + createMockClientRequest, + resetCodeWhispererGlobalVariables, + toTextEditor, + createTestWorkspaceFolder, + closeAllEditors, +} from 'aws-core-vscode/test' +import { globals } from 'aws-core-vscode/shared' +import { GenerateCompletionsRequest } from 'aws-core-vscode/codewhisperer' +import * as vscode from 'vscode' + +export function createNotebookCell( + document: vscode.TextDocument = createMockDocument('def example():\n return "test"'), + kind: vscode.NotebookCellKind = vscode.NotebookCellKind.Code, + notebook: vscode.NotebookDocument = {} as any, + index: number = 0, + outputs: vscode.NotebookCellOutput[] = [], + metadata: { readonly [key: string]: any } = {}, + executionSummary?: vscode.NotebookCellExecutionSummary +): vscode.NotebookCell { + return { + document, + kind, + notebook, + index, + outputs, + metadata, + executionSummary, + } +} + +describe('editorContext', function () { + let telemetryEnabledDefault: boolean + let tempFolder: string + + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + telemetryEnabledDefault = globals.telemetry.telemetryEnabled + }) + + afterEach(async function () { + await globals.telemetry.setTelemetryEnabled(telemetryEnabledDefault) + }) + + describe('extractContextForCodeWhisperer', function () { + it('Should return expected context', function () { + const editor = createMockTextEditor('import math\ndef two_sum(nums, target):\n', 'test.py', 'python', 1, 17) + const actual = EditorContext.extractContextForCodeWhisperer(editor) + const expected: codewhispererClient.FileContext = { + fileUri: 'file:///test.py', + filename: 'test.py', + programmingLanguage: { + languageName: 'python', + }, + leftFileContent: 'import math\ndef two_sum(nums,', + rightFileContent: ' target):\n', + } + assert.deepStrictEqual(actual, expected) + }) + + it('Should return expected context within max char limit', function () { + const editor = createMockTextEditor( + 'import math\ndef ' + 'a'.repeat(10340) + 'two_sum(nums, target):\n', + 'test.py', + 'python', + 1, + 17 + ) + const actual = EditorContext.extractContextForCodeWhisperer(editor) + const expected: codewhispererClient.FileContext = { + fileUri: 'file:///test.py', + filename: 'test.py', + programmingLanguage: { + languageName: 'python', + }, + leftFileContent: 'import math\ndef aaaaaaaaaaaaa', + rightFileContent: 'a'.repeat(10240), + } + assert.deepStrictEqual(actual, expected) + }) + + it('in a notebook, includes context from other cells', async function () { + const cells: vscode.NotebookCellData[] = [ + new vscode.NotebookCellData(vscode.NotebookCellKind.Markup, 'Previous cell', 'python'), + new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + 'import numpy as np\nimport pandas as pd\n\ndef analyze_data(df):\n # Current cell with cursor here', + 'python' + ), + new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + '# Process the data\nresult = analyze_data(df)\nprint(result)', + 'python' + ), + ] + + const document = await vscode.workspace.openNotebookDocument( + 'jupyter-notebook', + new vscode.NotebookData(cells) + ) + const editor: any = { + document: document.cellAt(1).document, + selection: { active: new vscode.Position(4, 13) }, + } + + const actual = EditorContext.extractContextForCodeWhisperer(editor) + const expected: codewhispererClient.FileContext = { + fileUri: editor.document.uri.toString(), + filename: 'Untitled-1.py', + programmingLanguage: { + languageName: 'python', + }, + leftFileContent: + '# Previous cell\nimport numpy as np\nimport pandas as pd\n\ndef analyze_data(df):\n # Current', + rightFileContent: + ' cell with cursor here\n# Process the data\nresult = analyze_data(df)\nprint(result)\n', + } + assert.deepStrictEqual(actual, expected) + }) + }) + + describe('getFileName', function () { + it('Should return expected filename given a document reading test.py', function () { + const editor = createMockTextEditor('', 'test.py', 'python', 1, 17) + const actual = EditorContext.getFileName(editor) + const expected = 'test.py' + assert.strictEqual(actual, expected) + }) + + it('Should return expected filename for a long filename', async function () { + const editor = createMockTextEditor('', 'a'.repeat(1500), 'python', 1, 17) + const actual = EditorContext.getFileName(editor) + const expected = 'a'.repeat(1024) + assert.strictEqual(actual, expected) + }) + }) + + describe('getFileRelativePath', function () { + this.beforeEach(async function () { + tempFolder = (await createTestWorkspaceFolder()).uri.fsPath + }) + + it('Should return a new filename with correct extension given a .ipynb file', function () { + const languageToExtension = new Map([ + ['python', 'py'], + ['rust', 'rs'], + ['javascript', 'js'], + ['typescript', 'ts'], + ['c', 'c'], + ]) + + for (const [language, extension] of languageToExtension.entries()) { + const editor = createMockTextEditor('', 'test.ipynb', language, 1, 17) + const actual = EditorContext.getFileRelativePath(editor) + const expected = 'test.' + extension + assert.strictEqual(actual, expected) + } + }) + + it('Should return relative path', async function () { + const editor = await toTextEditor('tttt', 'test.py', tempFolder) + const actual = EditorContext.getFileRelativePath(editor) + const expected = 'test.py' + assert.strictEqual(actual, expected) + }) + + afterEach(async function () { + await closeAllEditors() + }) + }) + + describe('getNotebookCellContext', function () { + it('Should return cell text for python code cells when language is python', function () { + const mockCodeCell = createNotebookCell(createMockDocument('def example():\n return "test"')) + const result = EditorContext.getNotebookCellContext(mockCodeCell, 'python') + assert.strictEqual(result, 'def example():\n return "test"') + }) + + it('Should return java comments for python code cells when language is java', function () { + const mockCodeCell = createNotebookCell(createMockDocument('def example():\n return "test"')) + const result = EditorContext.getNotebookCellContext(mockCodeCell, 'java') + assert.strictEqual(result, '// def example():\n// return "test"') + }) + + it('Should return python comments for java code cells when language is python', function () { + const mockCodeCell = createNotebookCell(createMockDocument('println(1 + 1);', 'somefile.ipynb', 'java')) + const result = EditorContext.getNotebookCellContext(mockCodeCell, 'python') + assert.strictEqual(result, '# println(1 + 1);') + }) + + it('Should add python comment prefixes for markdown cells when language is python', function () { + const mockMarkdownCell = createNotebookCell( + createMockDocument('# Heading\nThis is a markdown cell'), + vscode.NotebookCellKind.Markup + ) + const result = EditorContext.getNotebookCellContext(mockMarkdownCell, 'python') + assert.strictEqual(result, '# # Heading\n# This is a markdown cell') + }) + + it('Should add java comment prefixes for markdown cells when language is java', function () { + const mockMarkdownCell = createNotebookCell( + createMockDocument('# Heading\nThis is a markdown cell'), + vscode.NotebookCellKind.Markup + ) + const result = EditorContext.getNotebookCellContext(mockMarkdownCell, 'java') + assert.strictEqual(result, '// # Heading\n// This is a markdown cell') + }) + }) + + describe('getNotebookCellsSliceContext', function () { + it('Should extract content from cells in reverse order up to maxLength from prefix cells', function () { + const mockCells = [ + createNotebookCell(createMockDocument('First cell content')), + createNotebookCell(createMockDocument('Second cell content')), + createNotebookCell(createMockDocument('Third cell content')), + ] + + const result = EditorContext.getNotebookCellsSliceContext(mockCells, 100, 'python', false) + assert.strictEqual(result, 'First cell content\nSecond cell content\nThird cell content\n') + }) + + it('Should extract content from cells in reverse order up to maxLength from suffix cells', function () { + const mockCells = [ + createNotebookCell(createMockDocument('First cell content')), + createNotebookCell(createMockDocument('Second cell content')), + createNotebookCell(createMockDocument('Third cell content')), + ] + + const result = EditorContext.getNotebookCellsSliceContext(mockCells, 100, 'python', true) + assert.strictEqual(result, 'First cell content\nSecond cell content\nThird cell content\n') + }) + + it('Should respect maxLength parameter from prefix cells', function () { + const mockCells = [ + createNotebookCell(createMockDocument('First')), + createNotebookCell(createMockDocument('Second')), + createNotebookCell(createMockDocument('Third')), + createNotebookCell(createMockDocument('Fourth')), + ] + // Should only include part of second cell and the last two cells + const result = EditorContext.getNotebookCellsSliceContext(mockCells, 15, 'python', false) + assert.strictEqual(result, 'd\nThird\nFourth\n') + }) + + it('Should respect maxLength parameter from suffix cells', function () { + const mockCells = [ + createNotebookCell(createMockDocument('First')), + createNotebookCell(createMockDocument('Second')), + createNotebookCell(createMockDocument('Third')), + createNotebookCell(createMockDocument('Fourth')), + ] + + // Should only include first cell and part of second cell + const result = EditorContext.getNotebookCellsSliceContext(mockCells, 15, 'python', true) + assert.strictEqual(result, 'First\nSecond\nTh') + }) + + it('Should handle empty cells array from prefix cells', function () { + const result = EditorContext.getNotebookCellsSliceContext([], 100, 'python', false) + assert.strictEqual(result, '') + }) + + it('Should handle empty cells array from suffix cells', function () { + const result = EditorContext.getNotebookCellsSliceContext([], 100, 'python', true) + assert.strictEqual(result, '') + }) + + it('Should add python comments to markdown prefix cells', function () { + const mockCells = [ + createNotebookCell(createMockDocument('# Heading\nThis is markdown'), vscode.NotebookCellKind.Markup), + createNotebookCell(createMockDocument('def example():\n return "test"')), + ] + const result = EditorContext.getNotebookCellsSliceContext(mockCells, 100, 'python', false) + assert.strictEqual(result, '# # Heading\n# This is markdown\ndef example():\n return "test"\n') + }) + + it('Should add python comments to markdown suffix cells', function () { + const mockCells = [ + createNotebookCell(createMockDocument('# Heading\nThis is markdown'), vscode.NotebookCellKind.Markup), + createNotebookCell(createMockDocument('def example():\n return "test"')), + ] + + const result = EditorContext.getNotebookCellsSliceContext(mockCells, 100, 'python', true) + assert.strictEqual(result, '# # Heading\n# This is markdown\ndef example():\n return "test"\n') + }) + + it('Should add java comments to markdown and python prefix cells when language is java', function () { + const mockCells = [ + createNotebookCell(createMockDocument('# Heading\nThis is markdown'), vscode.NotebookCellKind.Markup), + createNotebookCell(createMockDocument('def example():\n return "test"')), + ] + const result = EditorContext.getNotebookCellsSliceContext(mockCells, 100, 'java', false) + assert.strictEqual(result, '// # Heading\n// This is markdown\n// def example():\n// return "test"\n') + }) + + it('Should add java comments to markdown and python suffix cells when language is java', function () { + const mockCells = [ + createNotebookCell(createMockDocument('# Heading\nThis is markdown'), vscode.NotebookCellKind.Markup), + createNotebookCell(createMockDocument('println(1 + 1);', 'somefile.ipynb', 'java')), + ] + + const result = EditorContext.getNotebookCellsSliceContext(mockCells, 100, 'java', true) + assert.strictEqual(result, '// # Heading\n// This is markdown\nprintln(1 + 1);\n') + }) + + it('Should handle code prefix cells with different languages', function () { + const mockCells = [ + createNotebookCell( + createMockDocument('println(1 + 1);', 'somefile.ipynb', 'java'), + vscode.NotebookCellKind.Code + ), + createNotebookCell(createMockDocument('def example():\n return "test"')), + ] + const result = EditorContext.getNotebookCellsSliceContext(mockCells, 100, 'python', false) + assert.strictEqual(result, '# println(1 + 1);\ndef example():\n return "test"\n') + }) + + it('Should handle code suffix cells with different languages', function () { + const mockCells = [ + createNotebookCell( + createMockDocument('println(1 + 1);', 'somefile.ipynb', 'java'), + vscode.NotebookCellKind.Code + ), + createNotebookCell(createMockDocument('def example():\n return "test"')), + ] + const result = EditorContext.getNotebookCellsSliceContext(mockCells, 100, 'python', true) + assert.strictEqual(result, '# println(1 + 1);\ndef example():\n return "test"\n') + }) + }) + + describe('validateRequest', function () { + it('Should return false if request filename.length is invalid', function () { + const req = createMockClientRequest() + req.fileContext.filename = '' + assert.ok(!EditorContext.validateRequest(req)) + }) + + it('Should return false if request programming language is invalid', function () { + const req = createMockClientRequest() + req.fileContext.programmingLanguage.languageName = '' + assert.ok(!EditorContext.validateRequest(req)) + req.fileContext.programmingLanguage.languageName = 'a'.repeat(200) + assert.ok(!EditorContext.validateRequest(req)) + }) + + it('Should return false if request left or right context exceeds max length', function () { + const req = createMockClientRequest() + req.fileContext.leftFileContent = 'a'.repeat(256000) + assert.ok(!EditorContext.validateRequest(req)) + req.fileContext.leftFileContent = 'a' + req.fileContext.rightFileContent = 'a'.repeat(256000) + assert.ok(!EditorContext.validateRequest(req)) + }) + + it('Should return true if above conditions are not met', function () { + const req = createMockClientRequest() + assert.ok(EditorContext.validateRequest(req)) + }) + }) + + describe('getLeftContext', function () { + it('Should return expected left context', function () { + const editor = createMockTextEditor('import math\ndef two_sum(nums, target):\n', 'test.py', 'python', 1, 17) + const actual = EditorContext.getLeftContext(editor, 1) + const expected = '...wo_sum(nums, target)' + assert.strictEqual(actual, expected) + }) + }) + + describe('buildListRecommendationRequest', function () { + it('Should return expected fields for optOut, nextToken and reference config', async function () { + const nextToken = 'testToken' + const optOutPreference = false + await globals.telemetry.setTelemetryEnabled(false) + const editor = createMockTextEditor('import math\ndef two_sum(nums, target):\n', 'test.py', 'python', 1, 17) + const actual = await EditorContext.buildListRecommendationRequest(editor, nextToken, optOutPreference) + + assert.strictEqual(actual.request.nextToken, nextToken) + assert.strictEqual((actual.request as GenerateCompletionsRequest).optOutPreference, 'OPTOUT') + assert.strictEqual(actual.request.referenceTrackerConfiguration?.recommendationsWithReferences, 'BLOCK') + }) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/util/globalStateUtil.test.ts b/packages/amazonq/test/unit/codewhisperer/util/globalStateUtil.test.ts new file mode 100644 index 00000000000..24062a81b7c --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/util/globalStateUtil.test.ts @@ -0,0 +1,42 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' +import { resetCodeWhispererGlobalVariables } from 'aws-core-vscode/test' +import { getLogger } from 'aws-core-vscode/shared' +import { resetIntelliSenseState, vsCodeState } from 'aws-core-vscode/codewhisperer' + +describe('globalStateUtil', function () { + let loggerSpy: sinon.SinonSpy + + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + vsCodeState.isIntelliSenseActive = true + loggerSpy = sinon.spy(getLogger(), 'info') + }) + + this.afterEach(function () { + sinon.restore() + }) + + it('Should skip when CodeWhisperer is turned off', async function () { + const isManualTriggerEnabled = false + const isAutomatedTriggerEnabled = false + resetIntelliSenseState(isManualTriggerEnabled, isAutomatedTriggerEnabled, true) + assert.ok(!loggerSpy.called) + }) + + it('Should skip when invocationContext is not active', async function () { + vsCodeState.isIntelliSenseActive = false + resetIntelliSenseState(false, false, true) + assert.ok(!loggerSpy.called) + }) + + it('Should skip when no valid recommendations', async function () { + resetIntelliSenseState(true, true, false) + assert.ok(!loggerSpy.called) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/util/supplemetalContextUtil.test.ts b/packages/amazonq/test/unit/codewhisperer/util/supplemetalContextUtil.test.ts new file mode 100644 index 00000000000..cf2fd151262 --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/util/supplemetalContextUtil.test.ts @@ -0,0 +1,254 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as FakeTimers from '@sinonjs/fake-timers' +import * as vscode from 'vscode' +import * as sinon from 'sinon' +import * as os from 'os' +import * as crossFile from 'aws-core-vscode/codewhisperer' +import { TestFolder, assertTabCount, installFakeClock } from 'aws-core-vscode/test' +import { CodeWhispererSupplementalContext, FeatureConfigProvider } from 'aws-core-vscode/codewhisperer' +import { toTextEditor } from 'aws-core-vscode/test' + +const newLine = os.EOL + +describe('supplementalContextUtil', function () { + let testFolder: TestFolder + let clock: FakeTimers.InstalledClock + + const fakeCancellationToken: vscode.CancellationToken = { + isCancellationRequested: false, + onCancellationRequested: sinon.spy(), + } + + before(function () { + clock = installFakeClock() + }) + + after(function () { + clock.uninstall() + }) + + beforeEach(async function () { + testFolder = await TestFolder.create() + sinon.stub(FeatureConfigProvider.instance, 'getProjectContextGroup').returns('control') + }) + + afterEach(function () { + sinon.restore() + }) + + describe('fetchSupplementalContext', function () { + describe('openTabsContext', function () { + it('opentabContext should include chunks if non empty', async function () { + await toTextEditor('class Foo', 'Foo.java', testFolder.path, { preview: false }) + await toTextEditor('class Bar', 'Bar.java', testFolder.path, { preview: false }) + await toTextEditor('class Baz', 'Baz.java', testFolder.path, { preview: false }) + + const editor = await toTextEditor('public class Foo {}', 'Query.java', testFolder.path, { + preview: false, + }) + + await assertTabCount(4) + + const actual = await crossFile.fetchSupplementalContext(editor, fakeCancellationToken) + assert.ok(actual?.supplementalContextItems.length === 3) + }) + + it('opentabsContext should filter out empty chunks', async function () { + // open 3 files as supplemental context candidate files but none of them have contents + await toTextEditor('', 'Foo.java', testFolder.path, { preview: false }) + await toTextEditor('', 'Bar.java', testFolder.path, { preview: false }) + await toTextEditor('', 'Baz.java', testFolder.path, { preview: false }) + + const editor = await toTextEditor('public class Foo {}', 'Query.java', testFolder.path, { + preview: false, + }) + + await assertTabCount(4) + + const actual = await crossFile.fetchSupplementalContext(editor, fakeCancellationToken) + assert.ok(actual?.supplementalContextItems.length === 0) + }) + }) + }) + + describe('truncation', function () { + it('truncate context should do nothing if everything fits in constraint', function () { + const chunkA: crossFile.CodeWhispererSupplementalContextItem = { + content: 'a', + filePath: 'a.java', + score: 0, + } + const chunkB: crossFile.CodeWhispererSupplementalContextItem = { + content: 'b', + filePath: 'b.java', + score: 1, + } + const chunks = [chunkA, chunkB] + + const supplementalContext: CodeWhispererSupplementalContext = { + isUtg: false, + isProcessTimeout: false, + supplementalContextItems: chunks, + contentsLength: 25000, + latency: 0, + strategy: 'codemap', + } + + const actual = crossFile.truncateSuppelementalContext(supplementalContext) + assert.strictEqual(actual.supplementalContextItems.length, 2) + assert.strictEqual(actual.supplementalContextItems[0].content, 'a') + assert.strictEqual(actual.supplementalContextItems[1].content, 'b') + }) + + it('truncateLineByLine should drop the last line if max length is greater than threshold', function () { + const input = + repeatString('a', 11) + + newLine + + repeatString('b', 11) + + newLine + + repeatString('c', 11) + + newLine + + repeatString('d', 11) + + newLine + + repeatString('e', 11) + + assert.ok(input.length > 50) + const actual = crossFile.truncateLineByLine(input, 50) + assert.ok(actual.length <= 50) + + const input2 = repeatString(`b${newLine}`, 10) + const actual2 = crossFile.truncateLineByLine(input2, 8) + assert.ok(actual2.length <= 8) + }) + + it('truncation context should make context length per item lte 10240 cap', function () { + const chunkA: crossFile.CodeWhispererSupplementalContextItem = { + content: repeatString(`a${newLine}`, 4000), + filePath: 'a.java', + score: 0, + } + const chunkB: crossFile.CodeWhispererSupplementalContextItem = { + content: repeatString(`b${newLine}`, 6000), + filePath: 'b.java', + score: 1, + } + const chunkC: crossFile.CodeWhispererSupplementalContextItem = { + content: repeatString(`c${newLine}`, 1000), + filePath: 'c.java', + score: 2, + } + const chunkD: crossFile.CodeWhispererSupplementalContextItem = { + content: repeatString(`d${newLine}`, 1500), + filePath: 'd.java', + score: 3, + } + + assert.ok( + chunkA.content.length + chunkB.content.length + chunkC.content.length + chunkD.content.length > 20480 + ) + + const supplementalContext: CodeWhispererSupplementalContext = { + isUtg: false, + isProcessTimeout: false, + supplementalContextItems: [chunkA, chunkB, chunkC, chunkD], + contentsLength: 25000, + latency: 0, + strategy: 'codemap', + } + + const actual = crossFile.truncateSuppelementalContext(supplementalContext) + assert.strictEqual(actual.supplementalContextItems.length, 3) + assert.ok(actual.contentsLength <= 20480) + assert.strictEqual(actual.strategy, 'codemap') + }) + + it('truncate context should make context items lte 5', function () { + const chunkA: crossFile.CodeWhispererSupplementalContextItem = { + content: 'a', + filePath: 'a.java', + score: 0, + } + const chunkB: crossFile.CodeWhispererSupplementalContextItem = { + content: 'b', + filePath: 'b.java', + score: 1, + } + const chunkC: crossFile.CodeWhispererSupplementalContextItem = { + content: 'c', + filePath: 'c.java', + score: 2, + } + const chunkD: crossFile.CodeWhispererSupplementalContextItem = { + content: 'd', + filePath: 'd.java', + score: 3, + } + const chunkE: crossFile.CodeWhispererSupplementalContextItem = { + content: 'e', + filePath: 'e.java', + score: 4, + } + const chunkF: crossFile.CodeWhispererSupplementalContextItem = { + content: 'f', + filePath: 'f.java', + score: 5, + } + const chunkG: crossFile.CodeWhispererSupplementalContextItem = { + content: 'g', + filePath: 'g.java', + score: 6, + } + const chunks = [chunkA, chunkB, chunkC, chunkD, chunkE, chunkF, chunkG] + + assert.strictEqual(chunks.length, 7) + + const supplementalContext: CodeWhispererSupplementalContext = { + isUtg: false, + isProcessTimeout: false, + supplementalContextItems: chunks, + contentsLength: 25000, + latency: 0, + strategy: 'codemap', + } + + const actual = crossFile.truncateSuppelementalContext(supplementalContext) + assert.strictEqual(actual.supplementalContextItems.length, 5) + }) + + describe('truncate line by line', function () { + it('should return empty if empty string is provided', function () { + const input = '' + const actual = crossFile.truncateLineByLine(input, 50) + assert.strictEqual(actual, '') + }) + + it('should return empty if 0 max length is provided', function () { + const input = 'aaaaa' + const actual = crossFile.truncateLineByLine(input, 0) + assert.strictEqual(actual, '') + }) + + it('should flip the value if negative max length is provided', function () { + const input = `aaaaa${newLine}bbbbb` + const actual = crossFile.truncateLineByLine(input, -6) + const expected = crossFile.truncateLineByLine(input, 6) + assert.strictEqual(actual, expected) + assert.strictEqual(actual, 'aaaaa') + }) + }) + }) +}) + +function repeatString(s: string, n: number): string { + let output = '' + for (let i = 0; i < n; i++) { + output += s + } + + return output +} diff --git a/packages/amazonq/test/unit/codewhisperer/util/utgUtils.test.ts b/packages/amazonq/test/unit/codewhisperer/util/utgUtils.test.ts new file mode 100644 index 00000000000..67359b8a6fc --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/util/utgUtils.test.ts @@ -0,0 +1,63 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as utgUtils from 'aws-core-vscode/codewhisperer' + +describe('shouldFetchUtgContext', () => { + it('fully supported language', function () { + assert.ok(utgUtils.shouldFetchUtgContext('java')) + }) + + it('partially supported language', () => { + assert.strictEqual(utgUtils.shouldFetchUtgContext('python'), false) + }) + + it('not supported language', () => { + assert.strictEqual(utgUtils.shouldFetchUtgContext('typescript'), undefined) + + assert.strictEqual(utgUtils.shouldFetchUtgContext('javascript'), undefined) + + assert.strictEqual(utgUtils.shouldFetchUtgContext('javascriptreact'), undefined) + + assert.strictEqual(utgUtils.shouldFetchUtgContext('typescriptreact'), undefined) + + assert.strictEqual(utgUtils.shouldFetchUtgContext('scala'), undefined) + + assert.strictEqual(utgUtils.shouldFetchUtgContext('shellscript'), undefined) + + assert.strictEqual(utgUtils.shouldFetchUtgContext('csharp'), undefined) + + assert.strictEqual(utgUtils.shouldFetchUtgContext('c'), undefined) + }) +}) + +describe('guessSrcFileName', function () { + it('should return undefined if no matching regex', function () { + assert.strictEqual(utgUtils.guessSrcFileName('Foo.java', 'java'), undefined) + assert.strictEqual(utgUtils.guessSrcFileName('folder1/foo.py', 'python'), undefined) + assert.strictEqual(utgUtils.guessSrcFileName('Bar.js', 'javascript'), undefined) + }) + + it('java', function () { + assert.strictEqual(utgUtils.guessSrcFileName('FooTest.java', 'java'), 'Foo.java') + assert.strictEqual(utgUtils.guessSrcFileName('FooTests.java', 'java'), 'Foo.java') + }) + + it('python', function () { + assert.strictEqual(utgUtils.guessSrcFileName('test_foo.py', 'python'), 'foo.py') + assert.strictEqual(utgUtils.guessSrcFileName('foo_test.py', 'python'), 'foo.py') + }) + + it('typescript', function () { + assert.strictEqual(utgUtils.guessSrcFileName('Foo.test.ts', 'typescript'), 'Foo.ts') + assert.strictEqual(utgUtils.guessSrcFileName('Foo.spec.ts', 'typescript'), 'Foo.ts') + }) + + it('javascript', function () { + assert.strictEqual(utgUtils.guessSrcFileName('Foo.test.js', 'javascript'), 'Foo.js') + assert.strictEqual(utgUtils.guessSrcFileName('Foo.spec.js', 'javascript'), 'Foo.js') + }) +}) diff --git a/packages/core/src/codewhisperer/index.ts b/packages/core/src/codewhisperer/index.ts index ebbd68995e1..066e5ca2fcb 100644 --- a/packages/core/src/codewhisperer/index.ts +++ b/packages/core/src/codewhisperer/index.ts @@ -36,6 +36,7 @@ export { codeWhispererClient, } from './client/codewhisperer' export { listCodeWhispererCommands, listCodeWhispererCommandsId } from './ui/statusBarMenu' +export { InlineCompletionService } from './service/inlineCompletionService' export { refreshStatusBar, CodeWhispererStatusBarManager } from './service/statusBar' export { SecurityIssueHoverProvider } from './service/securityIssueHoverProvider' export { SecurityIssueCodeActionProvider } from './service/securityIssueCodeActionProvider' @@ -46,31 +47,44 @@ export { IssueItem, SeverityItem, } from './service/securityIssueTreeViewProvider' +export { onAcceptance } from './commands/onAcceptance' export { CodeWhispererTracker } from './tracker/codewhispererTracker' export { CodeWhispererUserGroupSettings } from './util/userGroupUtil' export { session } from './util/codeWhispererSession' +export { onInlineAcceptance } from './commands/onInlineAcceptance' export { stopTransformByQ } from './commands/startTransformByQ' export { featureDefinitions, FeatureConfigProvider } from '../shared/featureConfig' export { ReferenceInlineProvider } from './service/referenceInlineProvider' export { ReferenceHoverProvider } from './service/referenceHoverProvider' +export { CWInlineCompletionItemProvider } from './service/inlineCompletionItemProvider' +export { ClassifierTrigger } from './service/classifierTrigger' export { ReferenceLogViewProvider } from './service/referenceLogViewProvider' +export { RecommendationService } from './service/recommendationService' export { ImportAdderProvider } from './service/importAdderProvider' export { LicenseUtil } from './util/licenseUtil' export { SecurityIssueProvider } from './service/securityIssueProvider' export { listScanResults, mapToAggregatedList, pollScanJobStatus } from './service/securityScanHandler' export { TelemetryHelper } from './util/telemetryHelper' export { LineSelection, LineTracker } from './tracker/lineTracker' +export { BM25Okapi } from './util/supplementalContext/rankBm25' export { runtimeLanguageContext, RuntimeLanguageContext } from './util/runtimeLanguageContext' export * as startSecurityScan from './commands/startSecurityScan' +export * from './util/supplementalContext/utgUtils' +export * from './util/supplementalContext/crossFileContextUtil' +export * from './util/editorContext' export { acceptSuggestion } from './commands/onInlineAcceptance' export * from './util/showSsoPrompt' export * from './util/securityScanLanguageContext' export * from './util/importAdderUtil' +export * from './util/globalStateUtil' export * from './util/zipUtil' export * from './util/diagnosticsUtil' export * from './util/commonUtil' export * from './util/closingBracketUtil' +export * from './util/supplementalContext/codeParsingUtil' +export * from './util/supplementalContext/supplementalContextUtil' export * from './util/codewhispererSettings' +export * as supplementalContextUtil from './util/supplementalContext/supplementalContextUtil' export * from './service/diagnosticsProvider' export * as diagnosticsProvider from './service/diagnosticsProvider' export * from './ui/codeWhispererNodes'