diff --git a/package-lock.json b/package-lock.json index 01d671d07c8..35e80921caf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15044,13 +15044,13 @@ } }, "node_modules/@aws/language-server-runtimes": { - "version": "0.2.102", - "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes/-/language-server-runtimes-0.2.102.tgz", - "integrity": "sha512-O68zmXClLP6mtKxh0fzGKYW3MwgFCTkAgL32WKzOWLwD6gMc5CaVRrNsZ2cabkAudf2laTeWeSDZJZsiQ0hCfA==", + "version": "0.2.111", + "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes/-/language-server-runtimes-0.2.111.tgz", + "integrity": "sha512-eIHKzWkLTTb3qUCeT2nIrpP99dEv/OiUOcPB00MNCsOPWBBO/IoZhfGRNrE8+stgZMQkKLFH2ZYxn3ByB6OsCQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws/language-server-runtimes-types": "^0.1.43", + "@aws/language-server-runtimes-types": "^0.1.47", "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.200.0", "@opentelemetry/core": "^2.0.0", @@ -15077,9 +15077,9 @@ } }, "node_modules/@aws/language-server-runtimes-types": { - "version": "0.1.43", - "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes-types/-/language-server-runtimes-types-0.1.43.tgz", - "integrity": "sha512-qXaAGkiJ1hldF+Ynu6ZBXS18s47UOnbZEHxKiGRrBlBX2L75ih/4yasj8ITgshqS5Kx5JMntu+8vpc0CkGV6jA==", + "version": "0.1.47", + "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes-types/-/language-server-runtimes-types-0.1.47.tgz", + "integrity": "sha512-l5dOdx/MR3SO0HYXkSL9fcR05f4Aw7qRMuASMdWOK93LOSZeANPVOGIWblRnoJejfYiPXcufCFyjLnGpATExag==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -30063,8 +30063,8 @@ "@aws-sdk/types": "^3.13.1", "@aws/chat-client": "^0.1.4", "@aws/chat-client-ui-types": "^0.1.47", - "@aws/language-server-runtimes": "^0.2.102", - "@aws/language-server-runtimes-types": "^0.1.43", + "@aws/language-server-runtimes": "^0.2.111", + "@aws/language-server-runtimes-types": "^0.1.47", "@cspotcode/source-map-support": "^0.8.1", "@sinonjs/fake-timers": "^10.0.2", "@types/adm-zip": "^0.4.34", diff --git a/packages/amazonq/.changes/next-release/Bug Fix-316fb610-0ea9-40d1-bdb7-d371a6be4a4e.json b/packages/amazonq/.changes/next-release/Bug Fix-316fb610-0ea9-40d1-bdb7-d371a6be4a4e.json new file mode 100644 index 00000000000..1a9e5c32e6d --- /dev/null +++ b/packages/amazonq/.changes/next-release/Bug Fix-316fb610-0ea9-40d1-bdb7-d371a6be4a4e.json @@ -0,0 +1,4 @@ +{ + "type": "Bug Fix", + "description": "Let Enter invoke auto completion more consistently" +} diff --git a/packages/amazonq/.changes/next-release/Bug Fix-9d694e40-7fc7-4504-b08c-6b22a5ebcb1c.json b/packages/amazonq/.changes/next-release/Bug Fix-9d694e40-7fc7-4504-b08c-6b22a5ebcb1c.json new file mode 100644 index 00000000000..f2234549a0d --- /dev/null +++ b/packages/amazonq/.changes/next-release/Bug Fix-9d694e40-7fc7-4504-b08c-6b22a5ebcb1c.json @@ -0,0 +1,4 @@ +{ + "type": "Bug Fix", + "description": "Use documentChangeEvent as auto trigger condition" +} diff --git a/packages/amazonq/src/app/inline/EditRendering/imageRenderer.ts b/packages/amazonq/src/app/inline/EditRendering/imageRenderer.ts index 9af3878ef82..195879ff779 100644 --- a/packages/amazonq/src/app/inline/EditRendering/imageRenderer.ts +++ b/packages/amazonq/src/app/inline/EditRendering/imageRenderer.ts @@ -29,6 +29,12 @@ export async function showEdits( const { svgImage, startLine, newCode, origionalCodeHighlightRange } = await svgGenerationService.generateDiffSvg(currentFile, item.insertText as string) + // TODO: To investigate why it fails and patch [generateDiffSvg] + if (newCode.length === 0) { + getLogger('nextEditPrediction').warn('not able to apply provided edit suggestion, skip rendering') + return + } + if (svgImage) { // display the SVG image await displaySvgDecoration( diff --git a/packages/amazonq/src/app/inline/completion.ts b/packages/amazonq/src/app/inline/completion.ts index 360be53e67a..9020deac824 100644 --- a/packages/amazonq/src/app/inline/completion.ts +++ b/packages/amazonq/src/app/inline/completion.ts @@ -241,6 +241,12 @@ export class AmazonQInlineCompletionItemProvider implements InlineCompletionItem return [] } + const isAutoTrigger = context.triggerKind === InlineCompletionTriggerKind.Automatic + if (isAutoTrigger && !CodeSuggestionsState.instance.isSuggestionsEnabled()) { + // return early when suggestions are disabled with auto trigger + return [] + } + // yield event loop to let the document listen catch updates await sleep(1) // prevent user deletion invoking auto trigger @@ -254,12 +260,6 @@ export class AmazonQInlineCompletionItemProvider implements InlineCompletionItem try { const t0 = performance.now() vsCodeState.isRecommendationsActive = true - const isAutoTrigger = context.triggerKind === InlineCompletionTriggerKind.Automatic - if (isAutoTrigger && !CodeSuggestionsState.instance.isSuggestionsEnabled()) { - // return early when suggestions are disabled with auto trigger - return [] - } - // handling previous session const prevSession = this.sessionManager.getActiveSession() const prevSessionId = prevSession?.sessionId @@ -335,7 +335,8 @@ export class AmazonQInlineCompletionItemProvider implements InlineCompletionItem context, token, isAutoTrigger, - getAllRecommendationsOptions + getAllRecommendationsOptions, + this.documentEventListener.getLastDocumentChangeEvent(document.uri.fsPath)?.event ) // get active item from session for displaying const items = this.sessionManager.getActiveRecommendation() diff --git a/packages/amazonq/src/app/inline/documentEventListener.ts b/packages/amazonq/src/app/inline/documentEventListener.ts index 4e60b595ce2..36f65dc7331 100644 --- a/packages/amazonq/src/app/inline/documentEventListener.ts +++ b/packages/amazonq/src/app/inline/documentEventListener.ts @@ -21,6 +21,11 @@ export class DocumentEventListener { this.lastDocumentChangeEventMap.clear() } this.lastDocumentChangeEventMap.set(e.document.uri.fsPath, { event: e, timestamp: performance.now() }) + // The VS Code provideInlineCompletionCallback may not trigger when Enter is pressed, especially in Python files + // manually make this trigger. In case of duplicate, the provideInlineCompletionCallback is already debounced + if (this.isEnter(e) && vscode.window.activeTextEditor) { + void vscode.commands.executeCommand('editor.action.inlineSuggest.trigger') + } } }) } @@ -47,4 +52,18 @@ export class DocumentEventListener { this.documentChangeListener.dispose() } } + + private isEnter(e: vscode.TextDocumentChangeEvent): boolean { + if (e.contentChanges.length !== 1) { + return false + } + const str = e.contentChanges[0].text + if (str.length === 0) { + return false + } + return ( + (str.startsWith('\r\n') && str.substring(2).trim() === '') || + (str[0] === '\n' && str.substring(1).trim() === '') + ) + } } diff --git a/packages/amazonq/src/app/inline/recommendationService.ts b/packages/amazonq/src/app/inline/recommendationService.ts index ddde310999f..1329c68a51c 100644 --- a/packages/amazonq/src/app/inline/recommendationService.ts +++ b/packages/amazonq/src/app/inline/recommendationService.ts @@ -2,10 +2,12 @@ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * SPDX-License-Identifier: Apache-2.0 */ +import * as vscode from 'vscode' import { InlineCompletionListWithReferences, InlineCompletionWithReferencesParams, inlineCompletionWithReferencesRequestType, + TextDocumentContentChangeEvent, } from '@aws/language-server-runtimes/protocol' import { CancellationToken, InlineCompletionContext, Position, TextDocument } from 'vscode' import { LanguageClient } from 'vscode-languageclient' @@ -40,10 +42,20 @@ export class RecommendationService { context: InlineCompletionContext, token: CancellationToken, isAutoTrigger: boolean, - options: GetAllRecommendationsOptions = { emitTelemetry: true, showUi: true } + options: GetAllRecommendationsOptions = { emitTelemetry: true, showUi: true }, + documentChangeEvent?: vscode.TextDocumentChangeEvent ) { // Record that a regular request is being made this.cursorUpdateRecorder?.recordCompletionRequest() + const documentChangeParams = documentChangeEvent + ? { + textDocument: { + uri: document.uri.toString(), + version: document.version, + }, + contentChanges: documentChangeEvent.contentChanges.map((x) => x as TextDocumentContentChangeEvent), + } + : undefined let request: InlineCompletionWithReferencesParams = { textDocument: { @@ -51,6 +63,7 @@ export class RecommendationService { }, position, context, + documentChangeParams: documentChangeParams, } if (options.editsStreakToken) { request = { ...request, partialResultToken: options.editsStreakToken } diff --git a/packages/amazonq/src/extension.ts b/packages/amazonq/src/extension.ts index 9ca13136eab..1e26724ff61 100644 --- a/packages/amazonq/src/extension.ts +++ b/packages/amazonq/src/extension.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Auth, AuthUtils, CredentialsStore, LoginManager, initializeAuth } from 'aws-core-vscode/auth' +import { AuthUtils, CredentialsStore, LoginManager, initializeAuth } from 'aws-core-vscode/auth' import { activate as activateCodeWhisperer, shutdown as shutdownCodeWhisperer } from 'aws-core-vscode/codewhisperer' import { makeEndpointsProvider, registerGenericCommands } from 'aws-core-vscode' import { CommonAuthWebview } from 'aws-core-vscode/login' @@ -44,8 +44,8 @@ import * as vscode from 'vscode' import { registerCommands } from './commands' import { focusAmazonQPanel } from 'aws-core-vscode/codewhispererChat' import { activate as activateAmazonqLsp } from './lsp/activation' -import { activate as activateInlineCompletion } from './app/inline/activation' import { hasGlibcPatch } from './lsp/client' +import { RotatingLogChannel } from './lsp/rotatingLogChannel' export const amazonQContextPrefix = 'amazonq' @@ -104,7 +104,12 @@ export async function activateAmazonQCommon(context: vscode.ExtensionContext, is globals.manifestPaths.endpoints = context.asAbsolutePath(join('resources', 'endpoints.json')) globals.regionProvider = RegionProvider.fromEndpointsProvider(makeEndpointsProvider()) - const qLogChannel = vscode.window.createOutputChannel('Amazon Q Logs', { log: true }) + // Create rotating log channel for all Amazon Q logs + const qLogChannel = new RotatingLogChannel( + 'Amazon Q Logs', + context, + vscode.window.createOutputChannel('Amazon Q Logs', { log: true }) + ) await activateLogger(context, amazonQContextPrefix, qLogChannel) globals.logOutputChannel = qLogChannel globals.loginManager = new LoginManager(globals.awsContext, new CredentialsStore()) @@ -113,6 +118,8 @@ export async function activateAmazonQCommon(context: vscode.ExtensionContext, is getLogger().error('fs.init: invalid env vars found: %O', homeDirLogs) } + getLogger().info('Rotating logger has been setup') + await activateTelemetry(context, globals.awsContext, Settings.instance, 'Amazon Q For VS Code') await initializeAuth(globals.loginManager) @@ -126,17 +133,11 @@ export async function activateAmazonQCommon(context: vscode.ExtensionContext, is // This contains every lsp agnostic things (auth, security scan, code scan) await activateCodeWhisperer(extContext as ExtContext) - if ( - (Experiments.instance.get('amazonqLSP', true) || Auth.instance.isInternalAmazonUser()) && - (!isAmazonLinux2() || hasGlibcPatch()) - ) { - // start the Amazon Q LSP for internal users first - // for AL2, start LSP if glibc patch is found + + if (!isAmazonLinux2() || hasGlibcPatch()) { + // Activate Amazon Q LSP for everyone unless they're using AL2 without the glibc patch await activateAmazonqLsp(context) } - if (!Experiments.instance.get('amazonqLSPInline', true)) { - await activateInlineCompletion() - } // Generic extension commands registerGenericCommands(context, amazonQContextPrefix) diff --git a/packages/amazonq/src/lsp/chat/messages.ts b/packages/amazonq/src/lsp/chat/messages.ts index 9841c7edee9..f869bbe0da3 100644 --- a/packages/amazonq/src/lsp/chat/messages.ts +++ b/packages/amazonq/src/lsp/chat/messages.ts @@ -95,6 +95,7 @@ import { decryptResponse, encryptRequest } from '../encryption' import { getCursorState } from '../utils' import { focusAmazonQPanel } from './commands' import { ChatMessage } from '@aws/language-server-runtimes/server-interface' +import { CommentUtils } from 'aws-core-vscode/utils' export function registerActiveEditorChangeListener(languageClient: LanguageClient) { let debounceTimer: NodeJS.Timeout | undefined @@ -701,7 +702,7 @@ async function handleCompleteResult( ) { const decryptedMessage = await decryptResponse(result, encryptionKey) - handleSecurityFindings(decryptedMessage, languageClient) + await handleSecurityFindings(decryptedMessage, languageClient) void provider.webview?.postMessage({ command: chatRequestType.method, @@ -716,10 +717,10 @@ async function handleCompleteResult( disposable.dispose() } -function handleSecurityFindings( +async function handleSecurityFindings( decryptedMessage: { additionalMessages?: ChatMessage[] }, languageClient: LanguageClient -): void { +): Promise { if (decryptedMessage.additionalMessages === undefined || decryptedMessage.additionalMessages.length === 0) { return } @@ -730,10 +731,18 @@ function handleSecurityFindings( try { const aggregatedCodeScanIssues: AggregatedCodeScanIssue[] = JSON.parse(message.body) for (const aggregatedCodeScanIssue of aggregatedCodeScanIssues) { + const document = await vscode.workspace.openTextDocument(aggregatedCodeScanIssue.filePath) for (const issue of aggregatedCodeScanIssue.issues) { - issue.visible = !CodeWhispererSettings.instance + const isIssueTitleIgnored = CodeWhispererSettings.instance .getIgnoredSecurityIssues() .includes(issue.title) + const isSingleIssueIgnored = CommentUtils.detectCommentAboveLine( + document, + issue.startLine, + CodeWhispererConstants.amazonqIgnoreNextLine + ) + + issue.visible = !isIssueTitleIgnored && !isSingleIssueIgnored } } initSecurityScanRender(aggregatedCodeScanIssues, undefined, CodeAnalysisScope.PROJECT) diff --git a/packages/amazonq/src/lsp/client.ts b/packages/amazonq/src/lsp/client.ts index e6ef1e5dd9c..e94842123ac 100644 --- a/packages/amazonq/src/lsp/client.ts +++ b/packages/amazonq/src/lsp/client.ts @@ -8,6 +8,7 @@ import * as nls from 'vscode-nls' import { LanguageClient, LanguageClientOptions, RequestType, State } from 'vscode-languageclient' import { InlineCompletionManager } from '../app/inline/completion' import { AmazonQLspAuth, encryptionKey, notificationTypes } from './auth' +import { RotatingLogChannel } from './rotatingLogChannel' import { CreateFilesParams, DeleteFilesParams, @@ -94,6 +95,23 @@ export async function startLanguageServer( const clientId = 'amazonq' const traceServerEnabled = Settings.instance.isSet(`${clientId}.trace.server`) + + // Create custom output channel that writes to disk but sends UI output to the appropriate channel + const lspLogChannel = new RotatingLogChannel( + traceServerEnabled ? 'Amazon Q Language Server' : 'Amazon Q Logs', + extensionContext, + traceServerEnabled + ? vscode.window.createOutputChannel('Amazon Q Language Server', { log: true }) + : globals.logOutputChannel + ) + + // Add cleanup for our file output channel + toDispose.push({ + dispose: () => { + lspLogChannel.dispose() + }, + }) + let executable: string[] = [] // apply the GLIBC 2.28 path to node js runtime binary if (isSageMaker()) { @@ -168,6 +186,7 @@ export async function startLanguageServer( reroute: true, modelSelection: true, workspaceFilePath: vscode.workspace.workspaceFile?.fsPath, + qCodeReviewInChat: true, }, window: { notifications: true, @@ -190,15 +209,9 @@ export async function startLanguageServer( }, }, /** - * When the trace server is enabled it outputs a ton of log messages so: - * When trace server is enabled, logs go to a seperate "Amazon Q Language Server" output. - * Otherwise, logs go to the regular "Amazon Q Logs" channel. + * Using our RotatingLogger for all logs */ - ...(traceServerEnabled - ? {} - : { - outputChannel: globals.logOutputChannel, - }), + outputChannel: lspLogChannel, } const client = new LanguageClient( @@ -251,6 +264,59 @@ async function initializeAuth(client: LanguageClient): Promise { return auth } +// jscpd:ignore-start +async function initializeLanguageServerConfiguration(client: LanguageClient, context: string = 'startup') { + const logger = getLogger('amazonqLsp') + + if (AuthUtil.instance.isConnectionValid()) { + logger.info(`[${context}] Initializing language server configuration`) + // jscpd:ignore-end + + try { + // Send profile configuration + logger.debug(`[${context}] Sending profile configuration to language server`) + await sendProfileToLsp(client) + logger.debug(`[${context}] Profile configuration sent successfully`) + + // Send customization configuration + logger.debug(`[${context}] Sending customization configuration to language server`) + await pushConfigUpdate(client, { + type: 'customization', + customization: getSelectedCustomization(), + }) + logger.debug(`[${context}] Customization configuration sent successfully`) + + logger.info(`[${context}] Language server configuration completed successfully`) + } catch (error) { + logger.error(`[${context}] Failed to initialize language server configuration: ${error}`) + throw error + } + } else { + logger.warn( + `[${context}] Connection invalid, skipping language server configuration - this will cause authentication failures` + ) + const activeConnection = AuthUtil.instance.auth.activeConnection + const connectionState = activeConnection + ? AuthUtil.instance.auth.getConnectionState(activeConnection) + : 'no-connection' + logger.warn(`[${context}] Connection state: ${connectionState}`) + } +} + +async function sendProfileToLsp(client: LanguageClient) { + const logger = getLogger('amazonqLsp') + const profileArn = AuthUtil.instance.regionProfileManager.activeRegionProfile?.arn + + logger.debug(`Sending profile to LSP: ${profileArn || 'undefined'}`) + + await pushConfigUpdate(client, { + type: 'profile', + profileArn: profileArn, + }) + + logger.debug(`Profile sent to LSP successfully`) +} + async function onLanguageServerReady( extensionContext: vscode.ExtensionContext, auth: AmazonQLspAuth, @@ -282,14 +348,7 @@ async function onLanguageServerReady( // We manually push the cached values the first time since event handlers, which should push, may not have been setup yet. // Execution order is weird and should be fixed in the flare implementation. // TODO: Revisit if we need this if we setup the event handlers properly - if (AuthUtil.instance.isConnectionValid()) { - await sendProfileToLsp(client) - - await pushConfigUpdate(client, { - type: 'customization', - customization: getSelectedCustomization(), - }) - } + await initializeLanguageServerConfiguration(client, 'startup') toDispose.push( inlineManager, @@ -391,13 +450,6 @@ async function onLanguageServerReady( // Set this inside onReady so that it only triggers on subsequent language server starts (not the first) onServerRestartHandler(client, auth) ) - - async function sendProfileToLsp(client: LanguageClient) { - await pushConfigUpdate(client, { - type: 'profile', - profileArn: AuthUtil.instance.regionProfileManager.activeRegionProfile?.arn, - }) - } } /** @@ -417,8 +469,21 @@ function onServerRestartHandler(client: LanguageClient, auth: AmazonQLspAuth) { // TODO: Port this metric override to common definitions telemetry.languageServer_crash.emit({ id: 'AmazonQ' }) - // Need to set the auth token in the again - await auth.refreshConnection(true) + const logger = getLogger('amazonqLsp') + logger.info('[crash-recovery] Language server crash detected, reinitializing authentication') + + try { + // Send bearer token + logger.debug('[crash-recovery] Refreshing connection and sending bearer token') + await auth.refreshConnection(true) + logger.debug('[crash-recovery] Bearer token sent successfully') + + // Send profile and customization configuration + await initializeLanguageServerConfiguration(client, 'crash-recovery') + logger.info('[crash-recovery] Authentication reinitialized successfully') + } catch (error) { + logger.error(`[crash-recovery] Failed to reinitialize after crash: ${error}`) + } }) } diff --git a/packages/amazonq/src/lsp/config.ts b/packages/amazonq/src/lsp/config.ts index 66edc9ff6f1..6b88eb98d21 100644 --- a/packages/amazonq/src/lsp/config.ts +++ b/packages/amazonq/src/lsp/config.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ import * as vscode from 'vscode' -import { DevSettings, getServiceEnvVarConfig, BaseLspInstaller } from 'aws-core-vscode/shared' +import { DevSettings, getServiceEnvVarConfig, BaseLspInstaller, getLogger } from 'aws-core-vscode/shared' import { LanguageClient } from 'vscode-languageclient' import { DidChangeConfigurationNotification, @@ -68,23 +68,31 @@ export function toAmazonQLSPLogLevel(logLevel: vscode.LogLevel): LspLogLevel { * push the given config. */ export async function pushConfigUpdate(client: LanguageClient, config: QConfigs) { + const logger = getLogger('amazonqLsp') + switch (config.type) { case 'profile': + logger.debug(`Pushing profile configuration: ${config.profileArn || 'undefined'}`) await client.sendRequest(updateConfigurationRequestType.method, { section: 'aws.q', settings: { profileArn: config.profileArn }, }) + logger.debug(`Profile configuration pushed successfully`) break case 'customization': + logger.debug(`Pushing customization configuration: ${config.customization || 'undefined'}`) client.sendNotification(DidChangeConfigurationNotification.type.method, { section: 'aws.q', settings: { customization: config.customization }, }) + logger.debug(`Customization configuration pushed successfully`) break case 'logLevel': + logger.debug(`Pushing log level configuration`) client.sendNotification(DidChangeConfigurationNotification.type.method, { section: 'aws.logLevel', }) + logger.debug(`Log level configuration pushed successfully`) break } } diff --git a/packages/amazonq/src/lsp/rotatingLogChannel.ts b/packages/amazonq/src/lsp/rotatingLogChannel.ts new file mode 100644 index 00000000000..b8e3df276f9 --- /dev/null +++ b/packages/amazonq/src/lsp/rotatingLogChannel.ts @@ -0,0 +1,246 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import * as path from 'path' +import * as fs from 'fs' // eslint-disable-line no-restricted-imports +import { getLogger } from 'aws-core-vscode/shared' + +export class RotatingLogChannel implements vscode.LogOutputChannel { + private fileStream: fs.WriteStream | undefined + private originalChannel: vscode.LogOutputChannel + private logger = getLogger('amazonqLsp') + private currentFileSize = 0 + // eslint-disable-next-line @typescript-eslint/naming-convention + private readonly MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB + // eslint-disable-next-line @typescript-eslint/naming-convention + private readonly MAX_LOG_FILES = 4 + private static currentLogPath: string | undefined + + private static generateNewLogPath(logDir: string): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '-').replace('Z', '') + return path.join(logDir, `amazonq-lsp-${timestamp}.log`) + } + + constructor( + public readonly name: string, + private readonly extensionContext: vscode.ExtensionContext, + outputChannel: vscode.LogOutputChannel + ) { + this.originalChannel = outputChannel + this.initFileStream() + } + + private async cleanupOldLogs(): Promise { + try { + const logDir = this.extensionContext.storageUri?.fsPath + if (!logDir) { + return + } + + // Get all log files + const files = await fs.promises.readdir(logDir) + const logFiles = files + .filter((f) => f.startsWith('amazonq-lsp-') && f.endsWith('.log')) + .map((f) => ({ + name: f, + path: path.join(logDir, f), + time: fs.statSync(path.join(logDir, f)).mtime.getTime(), + })) + .sort((a, b) => b.time - a.time) // Sort newest to oldest + + // Remove all but the most recent MAX_LOG_FILES files + for (const file of logFiles.slice(this.MAX_LOG_FILES - 1)) { + try { + await fs.promises.unlink(file.path) + this.logger.debug(`Removed old log file: ${file.path}`) + } catch (err) { + this.logger.error(`Failed to remove old log file ${file.path}: ${err}`) + } + } + } catch (err) { + this.logger.error(`Failed to cleanup old logs: ${err}`) + } + } + + private getLogFilePath(): string { + // If we already have a path, reuse it + if (RotatingLogChannel.currentLogPath) { + return RotatingLogChannel.currentLogPath + } + + const logDir = this.extensionContext.storageUri?.fsPath + if (!logDir) { + throw new Error('No storage URI available') + } + + // Generate initial path + RotatingLogChannel.currentLogPath = RotatingLogChannel.generateNewLogPath(logDir) + return RotatingLogChannel.currentLogPath + } + + private async rotateLog(): Promise { + try { + // Close current stream + if (this.fileStream) { + this.fileStream.end() + } + + const logDir = this.extensionContext.storageUri?.fsPath + if (!logDir) { + throw new Error('No storage URI available') + } + + // Generate new path directly + RotatingLogChannel.currentLogPath = RotatingLogChannel.generateNewLogPath(logDir) + + // Create new log file with new path + this.fileStream = fs.createWriteStream(RotatingLogChannel.currentLogPath, { flags: 'a' }) + this.currentFileSize = 0 + + // Clean up old files + await this.cleanupOldLogs() + + this.logger.info(`Created new log file: ${RotatingLogChannel.currentLogPath}`) + } catch (err) { + this.logger.error(`Failed to rotate log file: ${err}`) + } + } + + private initFileStream() { + try { + const logDir = this.extensionContext.storageUri + if (!logDir) { + this.logger.error('Failed to get storage URI for logs') + return + } + + // Ensure directory exists + if (!fs.existsSync(logDir.fsPath)) { + fs.mkdirSync(logDir.fsPath, { recursive: true }) + } + + const logPath = this.getLogFilePath() + this.fileStream = fs.createWriteStream(logPath, { flags: 'a' }) + this.currentFileSize = 0 + this.logger.info(`Logging to file: ${logPath}`) + } catch (err) { + this.logger.error(`Failed to create log file: ${err}`) + } + } + + get logLevel(): vscode.LogLevel { + return this.originalChannel.logLevel + } + + get onDidChangeLogLevel(): vscode.Event { + return this.originalChannel.onDidChangeLogLevel + } + + trace(message: string, ...args: any[]): void { + this.originalChannel.trace(message, ...args) + this.writeToFile(`[TRACE] ${message}`) + } + + debug(message: string, ...args: any[]): void { + this.originalChannel.debug(message, ...args) + this.writeToFile(`[DEBUG] ${message}`) + } + + info(message: string, ...args: any[]): void { + this.originalChannel.info(message, ...args) + this.writeToFile(`[INFO] ${message}`) + } + + warn(message: string, ...args: any[]): void { + this.originalChannel.warn(message, ...args) + this.writeToFile(`[WARN] ${message}`) + } + + error(message: string | Error, ...args: any[]): void { + this.originalChannel.error(message, ...args) + this.writeToFile(`[ERROR] ${message instanceof Error ? message.stack || message.message : message}`) + } + + append(value: string): void { + this.originalChannel.append(value) + this.writeToFile(value) + } + + appendLine(value: string): void { + this.originalChannel.appendLine(value) + this.writeToFile(value + '\n') + } + + replace(value: string): void { + this.originalChannel.replace(value) + this.writeToFile(`[REPLACE] ${value}`) + } + + clear(): void { + this.originalChannel.clear() + } + + show(preserveFocus?: boolean): void + show(column?: vscode.ViewColumn, preserveFocus?: boolean): void + show(columnOrPreserveFocus?: vscode.ViewColumn | boolean, preserveFocus?: boolean): void { + if (typeof columnOrPreserveFocus === 'boolean') { + this.originalChannel.show(columnOrPreserveFocus) + } else { + this.originalChannel.show(columnOrPreserveFocus, preserveFocus) + } + } + + hide(): void { + this.originalChannel.hide() + } + + dispose(): void { + // First dispose the original channel + this.originalChannel.dispose() + + // Close our file stream if it exists + if (this.fileStream) { + this.fileStream.end() + } + + // Clean up all log files + const logDir = this.extensionContext.storageUri?.fsPath + if (logDir) { + try { + const files = fs.readdirSync(logDir) + for (const file of files) { + if (file.startsWith('amazonq-lsp-') && file.endsWith('.log')) { + fs.unlinkSync(path.join(logDir, file)) + } + } + this.logger.info('Cleaned up all log files during disposal') + } catch (err) { + this.logger.error(`Failed to cleanup log files during disposal: ${err}`) + } + } + } + + private writeToFile(content: string): void { + if (this.fileStream) { + try { + const timestamp = new Date().toISOString() + const logLine = `${timestamp} ${content}\n` + const size = Buffer.byteLength(logLine) + + // If this write would exceed max file size, rotate first + if (this.currentFileSize + size > this.MAX_FILE_SIZE) { + void this.rotateLog() + } + + this.fileStream.write(logLine) + this.currentFileSize += size + } catch (err) { + this.logger.error(`Failed to write to log file: ${err}`) + void this.rotateLog() + } + } + } +} diff --git a/packages/amazonq/src/test/rotatingLogChannel.test.ts b/packages/amazonq/src/test/rotatingLogChannel.test.ts new file mode 100644 index 00000000000..87c4c109603 --- /dev/null +++ b/packages/amazonq/src/test/rotatingLogChannel.test.ts @@ -0,0 +1,192 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +// eslint-disable-next-line no-restricted-imports +import * as fs from 'fs' +import * as path from 'path' +import * as assert from 'assert' +import { RotatingLogChannel } from '../lsp/rotatingLogChannel' + +describe('RotatingLogChannel', () => { + let testDir: string + let mockExtensionContext: vscode.ExtensionContext + let mockOutputChannel: vscode.LogOutputChannel + let logChannel: RotatingLogChannel + + beforeEach(() => { + // Create a temp test directory + testDir = fs.mkdtempSync('amazonq-test-logs-') + + // Mock extension context + mockExtensionContext = { + storageUri: { fsPath: testDir } as vscode.Uri, + } as vscode.ExtensionContext + + // Mock output channel + mockOutputChannel = { + name: 'Test Output Channel', + append: () => {}, + appendLine: () => {}, + replace: () => {}, + clear: () => {}, + show: () => {}, + hide: () => {}, + dispose: () => {}, + trace: () => {}, + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + logLevel: vscode.LogLevel.Info, + onDidChangeLogLevel: new vscode.EventEmitter().event, + } + + // Create log channel instance + logChannel = new RotatingLogChannel('test', mockExtensionContext, mockOutputChannel) + }) + + afterEach(() => { + // Cleanup test directory + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }) + } + }) + + it('creates log file on initialization', () => { + const files = fs.readdirSync(testDir) + assert.strictEqual(files.length, 1) + assert.ok(files[0].startsWith('amazonq-lsp-')) + assert.ok(files[0].endsWith('.log')) + }) + + it('writes logs to file', async () => { + const testMessage = 'test log message' + logChannel.info(testMessage) + + // Allow async operations to complete + await new Promise((resolve) => setTimeout(resolve, 100)) + + const files = fs.readdirSync(testDir) + const content = fs.readFileSync(path.join(testDir, files[0]), 'utf-8') + assert.ok(content.includes(testMessage)) + }) + + it('rotates files when size limit is reached', async () => { + // Write enough data to trigger rotation + const largeMessage = 'x'.repeat(1024 * 1024) // 1MB + for (let i = 0; i < 6; i++) { + // Should create at least 2 files + logChannel.info(largeMessage) + } + + // Allow async operations to complete + await new Promise((resolve) => setTimeout(resolve, 100)) + + const files = fs.readdirSync(testDir) + assert.ok(files.length > 1, 'Should have created multiple log files') + assert.ok(files.length <= 4, 'Should not exceed max file limit') + }) + + it('keeps only the specified number of files', async () => { + // Write enough data to create more than MAX_LOG_FILES + const largeMessage = 'x'.repeat(1024 * 1024) // 1MB + for (let i = 0; i < 20; i++) { + // Should trigger multiple rotations + logChannel.info(largeMessage) + } + + // Allow async operations to complete + await new Promise((resolve) => setTimeout(resolve, 100)) + + const files = fs.readdirSync(testDir) + assert.strictEqual(files.length, 4, 'Should keep exactly 4 files') + }) + + it('cleans up all files on dispose', async () => { + // Write some logs + logChannel.info('test message') + + // Allow async operations to complete + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Verify files exist + assert.ok(fs.readdirSync(testDir).length > 0) + + // Dispose + logChannel.dispose() + + // Allow async operations to complete + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Verify files are cleaned up + const remainingFiles = fs.readdirSync(testDir).filter((f) => f.startsWith('amazonq-lsp-') && f.endsWith('.log')) + assert.strictEqual(remainingFiles.length, 0, 'Should have no log files after disposal') + }) + + it('includes timestamps in log messages', async () => { + const testMessage = 'test message' + logChannel.info(testMessage) + + // Allow async operations to complete + await new Promise((resolve) => setTimeout(resolve, 100)) + + const files = fs.readdirSync(testDir) + const content = fs.readFileSync(path.join(testDir, files[0]), 'utf-8') + + // ISO date format regex + const timestampRegex = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/ + assert.ok(timestampRegex.test(content), 'Log entry should include ISO timestamp') + }) + + it('handles different log levels correctly', async () => { + const testMessage = 'test message' + logChannel.trace(testMessage) + logChannel.debug(testMessage) + logChannel.info(testMessage) + logChannel.warn(testMessage) + logChannel.error(testMessage) + + // Allow async operations to complete + await new Promise((resolve) => setTimeout(resolve, 100)) + + const files = fs.readdirSync(testDir) + const content = fs.readFileSync(path.join(testDir, files[0]), 'utf-8') + + assert.ok(content.includes('[TRACE]'), 'Should include TRACE level') + assert.ok(content.includes('[DEBUG]'), 'Should include DEBUG level') + assert.ok(content.includes('[INFO]'), 'Should include INFO level') + assert.ok(content.includes('[WARN]'), 'Should include WARN level') + assert.ok(content.includes('[ERROR]'), 'Should include ERROR level') + }) + + it('delegates log level to the original channel', () => { + // Set up a mock output channel with a specific log level + const mockChannel = { + ...mockOutputChannel, + logLevel: vscode.LogLevel.Trace, + } + + // Create a new log channel with the mock + const testLogChannel = new RotatingLogChannel('test-delegate', mockExtensionContext, mockChannel) + + // Verify that the log level is delegated correctly + assert.strictEqual( + testLogChannel.logLevel, + vscode.LogLevel.Trace, + 'Should delegate log level to original channel' + ) + + // Change the mock's log level + mockChannel.logLevel = vscode.LogLevel.Debug + + // Verify that the change is reflected + assert.strictEqual( + testLogChannel.logLevel, + vscode.LogLevel.Debug, + 'Should reflect changes to original channel log level' + ) + }) +}) diff --git a/packages/amazonq/test/unit/amazonq/apps/inline/recommendationService.test.ts b/packages/amazonq/test/unit/amazonq/apps/inline/recommendationService.test.ts index 744fcc63c53..54eea8347c5 100644 --- a/packages/amazonq/test/unit/amazonq/apps/inline/recommendationService.test.ts +++ b/packages/amazonq/test/unit/amazonq/apps/inline/recommendationService.test.ts @@ -146,6 +146,7 @@ describe('RecommendationService', () => { }, position: mockPosition, context: mockContext, + documentChangeParams: undefined, }) // Verify session management @@ -187,6 +188,7 @@ describe('RecommendationService', () => { }, position: mockPosition, context: mockContext, + documentChangeParams: undefined, } const secondRequestArgs = sendRequestStub.secondCall.args[1] assert.deepStrictEqual(firstRequestArgs, expectedRequestArgs) diff --git a/packages/amazonq/test/unit/amazonq/lsp/client.test.ts b/packages/amazonq/test/unit/amazonq/lsp/client.test.ts new file mode 100644 index 00000000000..7c99c47e0ea --- /dev/null +++ b/packages/amazonq/test/unit/amazonq/lsp/client.test.ts @@ -0,0 +1,268 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import sinon from 'sinon' +import { LanguageClient } from 'vscode-languageclient' +import { AuthUtil } from 'aws-core-vscode/codewhisperer' +import { AmazonQLspAuth } from '../../../../src/lsp/auth' + +// These tests verify the behavior of the authentication functions +// Since the actual functions are module-level and use real dependencies, +// we test the expected behavior through mock implementations + +describe('Language Server Client Authentication', function () { + let sandbox: sinon.SinonSandbox + let mockClient: any + let mockAuth: any + let authUtilStub: sinon.SinonStub + let loggerStub: any + let getLoggerStub: sinon.SinonStub + let pushConfigUpdateStub: sinon.SinonStub + + beforeEach(() => { + sandbox = sinon.createSandbox() + + // Mock LanguageClient + mockClient = { + sendRequest: sandbox.stub().resolves(), + sendNotification: sandbox.stub(), + onDidChangeState: sandbox.stub(), + } + + // Mock AmazonQLspAuth + mockAuth = { + refreshConnection: sandbox.stub().resolves(), + } + + // Mock AuthUtil + authUtilStub = sandbox.stub(AuthUtil, 'instance').get(() => ({ + isConnectionValid: sandbox.stub().returns(true), + regionProfileManager: { + activeRegionProfile: { arn: 'test-profile-arn' }, + }, + auth: { + getConnectionState: sandbox.stub().returns('valid'), + activeConnection: { id: 'test-connection' }, + }, + })) + + // Create logger stub + loggerStub = { + info: sandbox.stub(), + debug: sandbox.stub(), + warn: sandbox.stub(), + error: sandbox.stub(), + } + + // Clear all relevant module caches + const sharedModuleId = require.resolve('aws-core-vscode/shared') + const configModuleId = require.resolve('../../../../src/lsp/config') + delete require.cache[sharedModuleId] + delete require.cache[configModuleId] + + // jscpd:ignore-start + // Create getLogger stub + getLoggerStub = sandbox.stub().returns(loggerStub) + + // Create a mock shared module with stubbed getLogger + const mockSharedModule = { + getLogger: getLoggerStub, + } + + // Override the require cache with our mock + require.cache[sharedModuleId] = { + id: sharedModuleId, + filename: sharedModuleId, + loaded: true, + parent: undefined, + children: [], + exports: mockSharedModule, + paths: [], + } as any + // jscpd:ignore-end + + // Mock pushConfigUpdate + pushConfigUpdateStub = sandbox.stub().resolves() + const mockConfigModule = { + pushConfigUpdate: pushConfigUpdateStub, + } + + require.cache[configModuleId] = { + id: configModuleId, + filename: configModuleId, + loaded: true, + parent: undefined, + children: [], + exports: mockConfigModule, + paths: [], + } as any + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('initializeLanguageServerConfiguration behavior', function () { + it('should initialize configuration when connection is valid', async function () { + // Test the expected behavior of the function + const mockInitializeFunction = async (client: LanguageClient, context: string) => { + const { getLogger } = require('aws-core-vscode/shared') + const { pushConfigUpdate } = require('../../../../src/lsp/config') + const logger = getLogger('amazonqLsp') + + if (AuthUtil.instance.isConnectionValid()) { + logger.info(`[${context}] Initializing language server configuration`) + + // Send profile configuration + logger.debug(`[${context}] Sending profile configuration to language server`) + await pushConfigUpdate(client, { + type: 'profile', + profileArn: AuthUtil.instance.regionProfileManager.activeRegionProfile?.arn, + }) + logger.debug(`[${context}] Profile configuration sent successfully`) + + // Send customization configuration + logger.debug(`[${context}] Sending customization configuration to language server`) + await pushConfigUpdate(client, { + type: 'customization', + customization: 'test-customization', + }) + logger.debug(`[${context}] Customization configuration sent successfully`) + + logger.info(`[${context}] Language server configuration completed successfully`) + } else { + logger.warn(`[${context}] Connection invalid, skipping configuration`) + } + } + + await mockInitializeFunction(mockClient as any, 'startup') + + // Verify logging + assert(loggerStub.info.calledWith('[startup] Initializing language server configuration')) + assert(loggerStub.debug.calledWith('[startup] Sending profile configuration to language server')) + assert(loggerStub.debug.calledWith('[startup] Profile configuration sent successfully')) + assert(loggerStub.debug.calledWith('[startup] Sending customization configuration to language server')) + assert(loggerStub.debug.calledWith('[startup] Customization configuration sent successfully')) + assert(loggerStub.info.calledWith('[startup] Language server configuration completed successfully')) + + // Verify pushConfigUpdate was called twice + assert.strictEqual(pushConfigUpdateStub.callCount, 2) + + // Verify profile configuration + assert( + pushConfigUpdateStub.calledWith(mockClient, { + type: 'profile', + profileArn: 'test-profile-arn', + }) + ) + + // Verify customization configuration + assert( + pushConfigUpdateStub.calledWith(mockClient, { + type: 'customization', + customization: 'test-customization', + }) + ) + }) + + it('should log warning when connection is invalid', async function () { + // Mock invalid connection + authUtilStub.get(() => ({ + isConnectionValid: sandbox.stub().returns(false), + auth: { + getConnectionState: sandbox.stub().returns('invalid'), + activeConnection: { id: 'test-connection' }, + }, + })) + + const mockInitializeFunction = async (client: LanguageClient, context: string) => { + const { getLogger } = require('aws-core-vscode/shared') + const logger = getLogger('amazonqLsp') + + // jscpd:ignore-start + if (AuthUtil.instance.isConnectionValid()) { + // Should not reach here + } else { + logger.warn( + `[${context}] Connection invalid, skipping language server configuration - this will cause authentication failures` + ) + const activeConnection = AuthUtil.instance.auth.activeConnection + const connectionState = activeConnection + ? AuthUtil.instance.auth.getConnectionState(activeConnection) + : 'no-connection' + logger.warn(`[${context}] Connection state: ${connectionState}`) + // jscpd:ignore-end + } + } + + await mockInitializeFunction(mockClient as any, 'crash-recovery') + + // Verify warning logs + assert( + loggerStub.warn.calledWith( + '[crash-recovery] Connection invalid, skipping language server configuration - this will cause authentication failures' + ) + ) + assert(loggerStub.warn.calledWith('[crash-recovery] Connection state: invalid')) + + // Verify pushConfigUpdate was not called + assert.strictEqual(pushConfigUpdateStub.callCount, 0) + }) + }) + + describe('crash recovery handler behavior', function () { + it('should reinitialize authentication after crash', async function () { + const mockCrashHandler = async (client: LanguageClient, auth: AmazonQLspAuth) => { + const { getLogger } = require('aws-core-vscode/shared') + const { pushConfigUpdate } = require('../../../../src/lsp/config') + const logger = getLogger('amazonqLsp') + + logger.info('[crash-recovery] Language server crash detected, reinitializing authentication') + + try { + logger.debug('[crash-recovery] Refreshing connection and sending bearer token') + await auth.refreshConnection(true) + logger.debug('[crash-recovery] Bearer token sent successfully') + + // Mock the configuration initialization + if (AuthUtil.instance.isConnectionValid()) { + await pushConfigUpdate(client, { + type: 'profile', + profileArn: AuthUtil.instance.regionProfileManager.activeRegionProfile?.arn, + }) + } + + logger.info('[crash-recovery] Authentication reinitialized successfully') + } catch (error) { + logger.error(`[crash-recovery] Failed to reinitialize after crash: ${error}`) + } + } + + await mockCrashHandler(mockClient as any, mockAuth as any) + + // Verify crash recovery logging + assert( + loggerStub.info.calledWith( + '[crash-recovery] Language server crash detected, reinitializing authentication' + ) + ) + assert(loggerStub.debug.calledWith('[crash-recovery] Refreshing connection and sending bearer token')) + assert(loggerStub.debug.calledWith('[crash-recovery] Bearer token sent successfully')) + assert(loggerStub.info.calledWith('[crash-recovery] Authentication reinitialized successfully')) + + // Verify auth.refreshConnection was called + assert(mockAuth.refreshConnection.calledWith(true)) + + // Verify profile configuration was sent + assert( + pushConfigUpdateStub.calledWith(mockClient, { + type: 'profile', + profileArn: 'test-profile-arn', + }) + ) + }) + }) +}) diff --git a/packages/amazonq/test/unit/amazonq/lsp/config.test.ts b/packages/amazonq/test/unit/amazonq/lsp/config.test.ts index 69b15d6e311..c31e873e181 100644 --- a/packages/amazonq/test/unit/amazonq/lsp/config.test.ts +++ b/packages/amazonq/test/unit/amazonq/lsp/config.test.ts @@ -77,3 +77,151 @@ describe('getAmazonQLspConfig', () => { delete process.env.__AMAZONQLSP_UI } }) + +describe('pushConfigUpdate', () => { + let sandbox: sinon.SinonSandbox + let mockClient: any + let loggerStub: any + let getLoggerStub: sinon.SinonStub + let pushConfigUpdate: any + + beforeEach(() => { + sandbox = sinon.createSandbox() + + // Mock LanguageClient + mockClient = { + sendRequest: sandbox.stub().resolves(), + sendNotification: sandbox.stub(), + } + + // Create logger stub + loggerStub = { + debug: sandbox.stub(), + } + + // Clear all relevant module caches + const configModuleId = require.resolve('../../../../src/lsp/config') + const sharedModuleId = require.resolve('aws-core-vscode/shared') + delete require.cache[configModuleId] + delete require.cache[sharedModuleId] + + // jscpd:ignore-start + // Create getLogger stub and store reference for test verification + getLoggerStub = sandbox.stub().returns(loggerStub) + + // Create a mock shared module with stubbed getLogger + const mockSharedModule = { + getLogger: getLoggerStub, + } + + // Override the require cache with our mock + require.cache[sharedModuleId] = { + id: sharedModuleId, + filename: sharedModuleId, + loaded: true, + parent: undefined, + children: [], + exports: mockSharedModule, + paths: [], + } as any + + // Now require the module - it should use our mocked getLogger + // jscpd:ignore-end + const configModule = require('../../../../src/lsp/config') + pushConfigUpdate = configModule.pushConfigUpdate + }) + + afterEach(() => { + sandbox.restore() + }) + + it('should send profile configuration with logging', async () => { + const config = { + type: 'profile' as const, + profileArn: 'test-profile-arn', + } + + await pushConfigUpdate(mockClient, config) + + // Verify logging + assert(loggerStub.debug.calledWith('Pushing profile configuration: test-profile-arn')) + assert(loggerStub.debug.calledWith('Profile configuration pushed successfully')) + + // Verify client call + assert(mockClient.sendRequest.calledOnce) + assert( + mockClient.sendRequest.calledWith(sinon.match.string, { + section: 'aws.q', + settings: { profileArn: 'test-profile-arn' }, + }) + ) + }) + + it('should send customization configuration with logging', async () => { + const config = { + type: 'customization' as const, + customization: 'test-customization-arn', + } + + await pushConfigUpdate(mockClient, config) + + // Verify logging + assert(loggerStub.debug.calledWith('Pushing customization configuration: test-customization-arn')) + assert(loggerStub.debug.calledWith('Customization configuration pushed successfully')) + + // Verify client call + assert(mockClient.sendNotification.calledOnce) + assert( + mockClient.sendNotification.calledWith(sinon.match.string, { + section: 'aws.q', + settings: { customization: 'test-customization-arn' }, + }) + ) + }) + + it('should handle undefined profile ARN', async () => { + const config = { + type: 'profile' as const, + profileArn: undefined, + } + + await pushConfigUpdate(mockClient, config) + + // Verify logging with undefined + assert(loggerStub.debug.calledWith('Pushing profile configuration: undefined')) + assert(loggerStub.debug.calledWith('Profile configuration pushed successfully')) + }) + + it('should handle undefined customization ARN', async () => { + const config = { + type: 'customization' as const, + customization: undefined, + } + + await pushConfigUpdate(mockClient, config) + + // Verify logging with undefined + assert(loggerStub.debug.calledWith('Pushing customization configuration: undefined')) + assert(loggerStub.debug.calledWith('Customization configuration pushed successfully')) + }) + + it('should send logLevel configuration with logging', async () => { + const config = { + type: 'logLevel' as const, + } + + await pushConfigUpdate(mockClient, config) + + // Verify logging + assert(loggerStub.debug.calledWith('Pushing log level configuration')) + assert(loggerStub.debug.calledWith('Log level configuration pushed successfully')) + + // Verify client call + assert(mockClient.sendNotification.calledOnce) + assert( + mockClient.sendNotification.calledWith(sinon.match.string, { + section: 'aws.logLevel', + }) + ) + }) +}) diff --git a/packages/amazonq/test/unit/app/inline/EditRendering/imageRenderer.test.ts b/packages/amazonq/test/unit/app/inline/EditRendering/imageRenderer.test.ts index 3160f69fa95..8a625fe3544 100644 --- a/packages/amazonq/test/unit/app/inline/EditRendering/imageRenderer.test.ts +++ b/packages/amazonq/test/unit/app/inline/EditRendering/imageRenderer.test.ts @@ -52,6 +52,7 @@ describe('showEdits', function () { delete require.cache[moduleId] delete require.cache[sharedModuleId] + // jscpd:ignore-start // Create getLogger stub and store reference for test verification getLoggerStub = sandbox.stub().returns(loggerStub) @@ -72,6 +73,7 @@ describe('showEdits', function () { } as any // Now require the module - it should use our mocked getLogger + // jscpd:ignore-end const imageRendererModule = require('../../../../../src/app/inline/EditRendering/imageRenderer') showEdits = imageRendererModule.showEdits diff --git a/packages/amazonq/test/unit/codewhisperer/service/securityIssueHoverProvider.test.ts b/packages/amazonq/test/unit/codewhisperer/service/securityIssueHoverProvider.test.ts index 9c1bb751a35..7709eed10fe 100644 --- a/packages/amazonq/test/unit/codewhisperer/service/securityIssueHoverProvider.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/service/securityIssueHoverProvider.test.ts @@ -21,17 +21,41 @@ describe('securityIssueHoverProvider', () => { token = new vscode.CancellationTokenSource() }) - function buildCommandLink(command: string, args: any[], label: string, tooltip: string): string { - return `[$(${command.includes('ignore') ? 'error' : 'comment'}) ${label}](command:${command}?${encodeURIComponent(JSON.stringify(args))} '${tooltip}')` + function buildCommandLink( + command: string, + commandIcon: string, + args: any[], + label: string, + tooltip: string + ): string { + return `[$(${commandIcon}) ${label}](command:${command}?${encodeURIComponent(JSON.stringify(args))} '${tooltip}')` } function buildExpectedContent(issue: any, fileName: string, description: string, severity?: string): string { const severityBadge = severity ? ` ![${severity}](severity-${severity.toLowerCase()}.svg)` : ' ' const commands = [ - buildCommandLink('aws.amazonq.explainIssue', [issue, fileName], 'Explain', 'Explain with Amazon Q'), - buildCommandLink('aws.amazonq.generateFix', [issue, fileName], 'Fix', 'Fix with Amazon Q'), - buildCommandLink('aws.amazonq.security.ignore', [issue, fileName, 'hover'], 'Ignore', 'Ignore Issue'), - buildCommandLink('aws.amazonq.security.ignoreAll', [issue, 'hover'], 'Ignore All', 'Ignore Similar Issues'), + buildCommandLink( + 'aws.amazonq.explainIssue', + 'comment', + [issue, fileName], + 'Explain', + 'Explain with Amazon Q' + ), + buildCommandLink('aws.amazonq.generateFix', 'wrench', [issue, fileName], 'Fix', 'Fix with Amazon Q'), + buildCommandLink( + 'aws.amazonq.security.ignore', + 'error', + [issue, fileName, 'hover'], + 'Ignore', + 'Ignore Issue' + ), + buildCommandLink( + 'aws.amazonq.security.ignoreAll', + 'error', + [issue, 'hover'], + 'Ignore All', + 'Ignore Similar Issues' + ), ] return `## title${severityBadge}\n${description}\n\n${commands.join('\n | ')}\n` } diff --git a/packages/core/package.json b/packages/core/package.json index 6f8d27ef4dc..d446a1bdf41 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -471,8 +471,8 @@ "@aws-sdk/types": "^3.13.1", "@aws/chat-client": "^0.1.4", "@aws/chat-client-ui-types": "^0.1.47", - "@aws/language-server-runtimes": "^0.2.102", - "@aws/language-server-runtimes-types": "^0.1.43", + "@aws/language-server-runtimes": "^0.2.111", + "@aws/language-server-runtimes-types": "^0.1.47", "@cspotcode/source-map-support": "^0.8.1", "@sinonjs/fake-timers": "^10.0.2", "@types/adm-zip": "^0.4.34", diff --git a/packages/core/src/auth/activation.ts b/packages/core/src/auth/activation.ts index 5c48124c468..8305610dff7 100644 --- a/packages/core/src/auth/activation.ts +++ b/packages/core/src/auth/activation.ts @@ -12,7 +12,7 @@ import { isAmazonQ, isSageMaker } from '../shared/extensionUtilities' import { getLogger } from '../shared/logger/logger' import { getErrorMsg } from '../shared/errors' -interface SagemakerCookie { +export interface SagemakerCookie { authMode?: 'Sso' | 'Iam' } diff --git a/packages/core/src/codewhisperer/commands/basicCommands.ts b/packages/core/src/codewhisperer/commands/basicCommands.ts index 745fe1a45a9..efe993356bd 100644 --- a/packages/core/src/codewhisperer/commands/basicCommands.ts +++ b/packages/core/src/codewhisperer/commands/basicCommands.ts @@ -634,6 +634,12 @@ const registerToolkitApiCallbackOnce = once(() => { export const registerToolkitApiCallback = Commands.declare( { id: 'aws.amazonq.refreshConnectionCallback' }, () => async (toolkitApi?: any) => { + // Early return if already registered to avoid duplicate work + if (_toolkitApi) { + getLogger().debug('Toolkit API callback already registered, skipping') + return + } + // While the Q/CW exposes an API for the Toolkit to register callbacks on auth changes, // we need to do it manually here because the Toolkit would have been unable to call // this API if the Q/CW extension started afterwards (and this code block is running). diff --git a/packages/core/src/codewhisperer/region/regionProfileManager.ts b/packages/core/src/codewhisperer/region/regionProfileManager.ts index e463321be19..24d58d7f588 100644 --- a/packages/core/src/codewhisperer/region/regionProfileManager.ts +++ b/packages/core/src/codewhisperer/region/regionProfileManager.ts @@ -69,7 +69,7 @@ export class RegionProfileManager { constructor(private readonly profileProvider: () => Promise) { super( 'aws.amazonq.regionProfiles.cache', - 60000, + 3600000, { resource: { locked: false, @@ -77,7 +77,7 @@ export class RegionProfileManager { result: undefined, }, }, - { timeout: 15000, interval: 1500, truthy: true } + { timeout: 15000, interval: 500, truthy: true } ) } diff --git a/packages/core/src/codewhisperer/service/securityIssueHoverProvider.ts b/packages/core/src/codewhisperer/service/securityIssueHoverProvider.ts index c907f99abe3..bb9fe2cafa4 100644 --- a/packages/core/src/codewhisperer/service/securityIssueHoverProvider.ts +++ b/packages/core/src/codewhisperer/service/securityIssueHoverProvider.ts @@ -90,7 +90,7 @@ export class SecurityIssueHoverProvider implements vscode.HoverProvider { const generateFixCommand = this._getCommandMarkdown( 'aws.amazonq.generateFix', [issue, filePath], - 'comment', + 'wrench', 'Fix', 'Fix with Amazon Q' ) diff --git a/packages/core/src/shared/featureConfig.ts b/packages/core/src/shared/featureConfig.ts index d7acb9657be..c7b111b3243 100644 --- a/packages/core/src/shared/featureConfig.ts +++ b/packages/core/src/shared/featureConfig.ts @@ -55,6 +55,9 @@ export const featureDefinitions = new Map([ export class FeatureConfigProvider { private featureConfigs = new Map() + private fetchPromise: Promise | undefined = undefined + private lastFetchTime = 0 + private readonly minFetchInterval = 5000 // 5 seconds minimum between fetches static #instance: FeatureConfigProvider @@ -123,6 +126,28 @@ export class FeatureConfigProvider { return } + // Debounce multiple concurrent calls + const now = performance.now() + if (this.fetchPromise && now - this.lastFetchTime < this.minFetchInterval) { + getLogger().debug('amazonq: Debouncing feature config fetch') + return this.fetchPromise + } + + if (this.fetchPromise) { + return this.fetchPromise + } + + this.lastFetchTime = now + this.fetchPromise = this._fetchFeatureConfigsInternal() + + try { + await this.fetchPromise + } finally { + this.fetchPromise = undefined + } + } + + private async _fetchFeatureConfigsInternal(): Promise { getLogger().debug('amazonq: Fetching feature configs') try { const response = await this.listFeatureEvaluations() diff --git a/packages/core/src/shared/utilities/index.ts b/packages/core/src/shared/utilities/index.ts index ecf753090ca..18d86da4d55 100644 --- a/packages/core/src/shared/utilities/index.ts +++ b/packages/core/src/shared/utilities/index.ts @@ -7,3 +7,4 @@ export { isExtensionInstalled, isExtensionActive } from './vsCodeUtils' export { VSCODE_EXTENSION_ID } from '../extensions' export * from './functionUtils' export * as messageUtils from './messages' +export * as CommentUtils from './commentUtils' diff --git a/packages/core/src/shared/utilities/resourceCache.ts b/packages/core/src/shared/utilities/resourceCache.ts index c0beee61cd6..a399dea66ca 100644 --- a/packages/core/src/shared/utilities/resourceCache.ts +++ b/packages/core/src/shared/utilities/resourceCache.ts @@ -60,6 +60,21 @@ export abstract class CachedResource { abstract resourceProvider(): Promise async getResource(): Promise { + // Check cache without locking first + const quickCheck = this.readCacheOrDefault() + if (quickCheck.resource.result && !quickCheck.resource.locked) { + const duration = now() - quickCheck.resource.timestamp + if (duration < this.expirationInMilli) { + logger.debug( + `cache hit (fast path), duration(%sms) is less than expiration(%sms), returning cached value: %s`, + duration, + this.expirationInMilli, + this.key + ) + return quickCheck.resource.result + } + } + const cachedValue = await this.tryLoadResourceAndLock() const resource = cachedValue?.resource diff --git a/packages/core/src/test/auth/activation.test.ts b/packages/core/src/test/auth/activation.test.ts new file mode 100644 index 00000000000..f203033acba --- /dev/null +++ b/packages/core/src/test/auth/activation.test.ts @@ -0,0 +1,146 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import * as sinon from 'sinon' +import assert from 'assert' +import { initialize, SagemakerCookie } from '../../auth/activation' +import { LoginManager } from '../../auth/deprecated/loginManager' +import * as extensionUtilities from '../../shared/extensionUtilities' +import * as authUtils from '../../auth/utils' +import * as errors from '../../shared/errors' + +describe('auth/activation', function () { + let sandbox: sinon.SinonSandbox + let mockLoginManager: LoginManager + let executeCommandStub: sinon.SinonStub + let isAmazonQStub: sinon.SinonStub + let isSageMakerStub: sinon.SinonStub + let initializeCredentialsProviderManagerStub: sinon.SinonStub + let getErrorMsgStub: sinon.SinonStub + let mockLogger: any + + beforeEach(function () { + sandbox = sinon.createSandbox() + + // Create mocks + mockLoginManager = { + login: sandbox.stub(), + logout: sandbox.stub(), + } as any + + mockLogger = { + warn: sandbox.stub(), + info: sandbox.stub(), + error: sandbox.stub(), + debug: sandbox.stub(), + } + + // Stub external dependencies + executeCommandStub = sandbox.stub(vscode.commands, 'executeCommand') + isAmazonQStub = sandbox.stub(extensionUtilities, 'isAmazonQ') + isSageMakerStub = sandbox.stub(extensionUtilities, 'isSageMaker') + initializeCredentialsProviderManagerStub = sandbox.stub(authUtils, 'initializeCredentialsProviderManager') + getErrorMsgStub = sandbox.stub(errors, 'getErrorMsg') + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('initialize', function () { + it('should not execute sagemaker.parseCookies when not in AmazonQ and SageMaker environment', async function () { + isAmazonQStub.returns(false) + isSageMakerStub.returns(false) + + await initialize(mockLoginManager) + + assert.ok(!executeCommandStub.called) + assert.ok(!initializeCredentialsProviderManagerStub.called) + }) + + it('should not execute sagemaker.parseCookies when only in AmazonQ environment', async function () { + isAmazonQStub.returns(true) + isSageMakerStub.returns(false) + + await initialize(mockLoginManager) + + assert.ok(!executeCommandStub.called) + assert.ok(!initializeCredentialsProviderManagerStub.called) + }) + + it('should not execute sagemaker.parseCookies when only in SageMaker environment', async function () { + isAmazonQStub.returns(false) + isSageMakerStub.returns(true) + + await initialize(mockLoginManager) + + assert.ok(!executeCommandStub.called) + assert.ok(!initializeCredentialsProviderManagerStub.called) + }) + + it('should execute sagemaker.parseCookies when in both AmazonQ and SageMaker environment', async function () { + isAmazonQStub.returns(true) + isSageMakerStub.returns(true) + executeCommandStub.withArgs('sagemaker.parseCookies').resolves({ authMode: 'Sso' } as SagemakerCookie) + + await initialize(mockLoginManager) + + assert.ok(executeCommandStub.calledOnceWith('sagemaker.parseCookies')) + assert.ok(!initializeCredentialsProviderManagerStub.called) + }) + + it('should initialize credentials provider manager when authMode is not Sso', async function () { + isAmazonQStub.returns(true) + isSageMakerStub.returns(true) + executeCommandStub.withArgs('sagemaker.parseCookies').resolves({ authMode: 'Iam' } as SagemakerCookie) + + await initialize(mockLoginManager) + + assert.ok(executeCommandStub.calledOnceWith('sagemaker.parseCookies')) + assert.ok(initializeCredentialsProviderManagerStub.calledOnce) + }) + + it('should initialize credentials provider manager when authMode is undefined', async function () { + isAmazonQStub.returns(true) + isSageMakerStub.returns(true) + executeCommandStub.withArgs('sagemaker.parseCookies').resolves({} as SagemakerCookie) + + await initialize(mockLoginManager) + + assert.ok(executeCommandStub.calledOnceWith('sagemaker.parseCookies')) + assert.ok(initializeCredentialsProviderManagerStub.calledOnce) + }) + + it('should warn and not throw when sagemaker.parseCookies command is not found', async function () { + isAmazonQStub.returns(true) + isSageMakerStub.returns(true) + const error = new Error("command 'sagemaker.parseCookies' not found") + executeCommandStub.withArgs('sagemaker.parseCookies').rejects(error) + getErrorMsgStub.returns("command 'sagemaker.parseCookies' not found") + + await initialize(mockLoginManager) + + assert.ok(executeCommandStub.calledOnceWith('sagemaker.parseCookies')) + assert.ok(getErrorMsgStub.calledOnceWith(error)) + assert.ok(!initializeCredentialsProviderManagerStub.called) + }) + + it('should throw when sagemaker.parseCookies fails with non-command-not-found error', async function () { + isAmazonQStub.returns(true) + isSageMakerStub.returns(true) + const error = new Error('Some other error') + executeCommandStub.withArgs('sagemaker.parseCookies').rejects(error) + getErrorMsgStub.returns('Some other error') + + await assert.rejects(initialize(mockLoginManager), /Some other error/) + + assert.ok(executeCommandStub.calledOnceWith('sagemaker.parseCookies')) + assert.ok(getErrorMsgStub.calledOnceWith(error)) + assert.ok(!mockLogger.warn.called) + assert.ok(!initializeCredentialsProviderManagerStub.called) + }) + }) +}) diff --git a/packages/core/src/test/lambda/utils.test.ts b/packages/core/src/test/lambda/utils.test.ts index 975738edeba..a3eebe043a7 100644 --- a/packages/core/src/test/lambda/utils.test.ts +++ b/packages/core/src/test/lambda/utils.test.ts @@ -13,6 +13,7 @@ import { setFunctionInfo, compareCodeSha, } from '../../lambda/utils' +import { LambdaFunction } from '../../lambda/commands/uploadLambda' import { DefaultLambdaClient } from '../../shared/clients/lambdaClient' import { fs } from '../../shared/fs/fs' import { tempDirPath } from '../../shared/filesystemUtilities' @@ -116,9 +117,21 @@ describe('lambda utils', function () { }) describe('setFunctionInfo', function () { + let mockLambda: LambdaFunction + + // jscpd:ignore-start + beforeEach(function () { + mockLambda = { + name: 'test-function', + region: 'us-east-1', + configuration: { FunctionName: 'test-function' }, + } + }) + afterEach(function () { sinon.restore() }) + // jscpd:ignore-end it('merges with existing data', async function () { const existingData = { lastDeployed: 123456, undeployed: true, sha: 'old-sha', handlerFile: 'index.js' } @@ -140,9 +153,21 @@ describe('lambda utils', function () { }) describe('compareCodeSha', function () { + let mockLambda: LambdaFunction + + // jscpd:ignore-start + beforeEach(function () { + mockLambda = { + name: 'test-function', + region: 'us-east-1', + configuration: { FunctionName: 'test-function' }, + } + }) + afterEach(function () { sinon.restore() }) + // jscpd:ignore-end it('returns true when local and remote SHA match', async function () { sinon.stub(fs, 'readFileText').resolves(JSON.stringify({ sha: 'same-sha' }))