diff --git a/.gitignore b/.gitignore index 55dff8a7..e174d456 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,5 @@ test-git-repo-diff-utils/ test-cs-diagnostics/ coverage/ .cs-prod-build +bin/cs-agent bench-results/ diff --git a/.vscodeignore b/.vscodeignore index c6b51966..b803b02b 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -19,6 +19,9 @@ cs-* !cs-cwf/ !cs-cwf/** +## Include the agent binary +!bin/ + ## Misc dotfiles .mocharc.json Makefile diff --git a/CLAUDE.md b/CLAUDE.md index fdd5f9f0..0dae7b25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,4 +12,12 @@ Validate your work using `make lint`. Satifying all linter faults is mandatory - If you find an issue at any stage, you must fix it immediately, regardless of whether you think it's pre-existing. +This project is a VS Code extension. You might need to study the VS Code accordingly. It might be cloned at `../vscode`. +This project uses CWF for rendering views, and can typically be found at `../cs-webview`. +This project uses cs-agent for performing refactorings, and can typically be found at `../cs-agent`. +This project uses the CodeScene CLI for performing various code analysis tasks, and can typically be found at `../codescene/cli`. +This project used in the past ACE where cs-agent is now used, and can typically be found at `../automated-refactoring`. + +When querying external projects, please use subagents. + Commit messages follow Conventional Commits. Keep the subject line (`type: imperative summary`) to at most 100 characters and do not end it with a period. Put the why in the body after a blank line, wrapped near 80 columns. The commit-msg hook rejects messages that fail commitlint. `make lint` checks every commit from `main` to `HEAD`. diff --git a/Makefile b/Makefile index 9414f09d..4bfc7168 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build package tsc clean lint watch test pretest test-release updatecwf benchmark benchmark-isolated +.PHONY: build package package-darwin-arm64 package-darwin-x64 package-linux-arm64 package-linux-x64 package-win32-x64 package-all tsc clean lint watch test pretest test-release updatecwf benchmark benchmark-isolated .DEFAULT_GOAL := build @@ -14,6 +14,34 @@ package: lint pretest npx @vscode/vsce@4.0.0 package; \ git checkout .vscodeignore; \ +package-darwin-arm64: lint pretest + npm i + cd ~/cs-agent/agent && $(MAKE) build-all + mkdir -p ./bin + cp ~/cs-agent/agent/target/aarch64-apple-darwin/release/cs-agent ./bin/cs-agent + node ./scripts/package-platform.js darwin arm64 + +package-darwin-x64: lint pretest + npm i + cd ~/cs-agent/agent && $(MAKE) build-all + mkdir -p ./bin + cp ~/cs-agent/agent/target/x86_64-apple-darwin/release/cs-agent ./bin/cs-agent + node ./scripts/package-platform.js darwin x64 + +package-linux-arm64: lint pretest + npm i + node ./scripts/package-platform.js linux arm64 + +package-linux-x64: lint pretest + npm i + node ./scripts/package-platform.js linux x64 + +package-win32-x64: lint pretest + npm i + node ./scripts/package-platform.js win32 x64 + +package-all: package-darwin-arm64 package-darwin-x64 package-linux-arm64 package-linux-x64 package-win32-x64 + tsc: npx tsc --noEmit diff --git a/README.md b/README.md index c4bcdb11..56fa44bd 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Location: `Code Health Monitor` > `Extension Settings` | **Enable Review Code Lenses** | Boolean | true | Enables/Disables showing Code Health Score and Code Smells in the active documents | | **Enable Auto Refactor** | Boolean | false | Enables/Disables ACE, CodeScenes automatic refactoring tool | | **Server URL** | String | - | CodeScene server for browser sign-in. Empty uses `CS_ONPREM_URL`, then CodeScene Cloud. Used when Experimental: Sign In is on. | -| **Auth Token** | String | - | Personal access token — fallback for WSL, remote and headless environments. Browser sign-in is recommended. Also used for Automatic Refactoring in ACE. | +| **Auth Token** | String | - | Personal access token — an alternative to signing in, for WSL, remote and headless environments. Also used for refactoring. Browser sign-in is recommended. | | **Experimental: Sign In** | Boolean | false | Sign in to CodeScene in the browser (experimental). Requires a CodeScene IDE server with sign-in support. | | **Enable Telemetry** | Boolean | true | Enable/Disable collecting anonymized telemetry for tracking usage and feature engagement. | diff --git a/package.json b/package.json index 60af07a8..34bfdbc6 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,10 @@ "command": "codescene.copyDeviceId", "title": "CodeScene: copy Device ID" }, + { + "command": "codescene.testConnectivity", + "title": "CodeScene: test provider connectivity" + }, { "command": "codescene.browserSignIn", "title": "CodeScene: Sign in" @@ -140,6 +144,10 @@ "command": "codescene.startAutomaticAnalysis", "when": "!config.codescene.enableAutomaticAnalysis" }, + { + "command": "codescene.testConnectivity", + "when": "codescene.aceUsable" + }, { "command": "codescene.browserSignIn", "when": "codescene.signInActive" @@ -172,9 +180,84 @@ "codescene.enableAutoRefactor": { "type": "boolean", "default": true, - "description": "Enable CodeScene ACE", + "description": "Enable CodeScene Refactoring Agent", "order": 15 }, + "codescene.agentModel": { + "type": "string", + "default": "amazon-bedrock/eu.anthropic.claude-sonnet-4-6", + "enum": [ + "amazon-bedrock/eu.anthropic.claude-sonnet-4-6", + "amazon-bedrock/eu.anthropic.claude-opus-5", + "amazon-bedrock/eu.anthropic.claude-opus-4-8", + "amazon-bedrock/eu.anthropic.claude-sonnet-4-5-20250929-v1:0", + "amazon-bedrock/eu.anthropic.claude-haiku-4-5-20251001-v1:0", + "amazon-bedrock/us.anthropic.claude-opus-5", + "amazon-bedrock/us.anthropic.claude-sonnet-4-6", + "anthropic/claude-sonnet-4-0", + "anthropic/claude-opus-4", + "anthropic/claude-haiku-3-5-sonnet", + "openai/gpt-4o", + "openai/gpt-4-turbo", + "openai/o3", + "google/gemini-2.5-pro", + "google/gemini-2.5-flash" + ], + "enumDescriptions": [ + "Claude Sonnet 4.6 via Amazon Bedrock (EU)", + "Claude Opus 5 via Amazon Bedrock (EU) - Most advanced", + "Claude Opus 4.8 via Amazon Bedrock (EU) - Optimized for coding", + "Claude Sonnet 4.5 via Amazon Bedrock (EU) - Optimized for agents", + "Claude Haiku 4.5 via Amazon Bedrock (EU) - Fast and lightweight", + "Claude Opus 5 via Amazon Bedrock (US) - Most advanced", + "Claude Sonnet 4.6 via Amazon Bedrock (US)", + "Claude Sonnet 4.0 via Anthropic API", + "Claude Opus 4 via Anthropic API", + "Claude Haiku 3.5 via Anthropic API - Fast and lightweight", + "GPT-4o via OpenAI API", + "GPT-4 Turbo via OpenAI API", + "o3 via OpenAI API - Advanced reasoning", + "Gemini 2.5 Pro via Google API", + "Gemini 2.5 Flash via Google API - Fast and efficient" + ], + "description": "Model to use for the Refactoring Agent.", + "order": 16 + }, + "codescene.agentModelOverride": { + "type": "string", + "default": "", + "description": "Custom model identifier. If set, overrides the dropdown selection above.", + "order": 17 + }, + "codescene.providerOptions": { + "type": "object", + "properties": { + "amazon-bedrock:profile": { + "type": "string", + "description": "AWS profile name for Amazon Bedrock" + }, + "amazon-bedrock:region": { + "type": "string", + "description": "AWS region for Amazon Bedrock" + }, + "anthropic:api-key": { + "type": "string", + "description": "API key for Anthropic" + }, + "openai:api-key": { + "type": "string", + "description": "API key for OpenAI" + }, + "google:api-key": { + "type": "string", + "description": "API key for Google (Gemini)" + } + }, + "additionalProperties": false, + "default": {}, + "markdownDescription": "Provider-specific options for the Refactoring Agent. Use this to authenticate into your preferred model.\n\n**[Test connectivity](command:codescene.testConnectivity)**", + "order": 18 + }, "codescene.serverUrl": { "type": "string", "default": "", @@ -184,7 +267,7 @@ "codescene.authToken": { "type": "string", "default": "", - "description": "Personal access token — fallback for WSL, remote and headless environments. Browser sign-in is recommended.", + "description": "Personal access token — an alternative to signing in, for WSL, remote and headless environments. Also used for refactoring. Browser sign-in is recommended.", "order": 19.5 }, "codescene.experimental.signIn": { diff --git a/scripts/cli-config.js b/scripts/cli-config.js index a61eaac2..c7eb102e 100644 --- a/scripts/cli-config.js +++ b/scripts/cli-config.js @@ -6,7 +6,7 @@ * - TypeScript source files (via artifact-info.ts) that import from this file */ -const requiredDevtoolsVersion = 'eff2f1fd6e7d928b0abd2f59f2243c63cefdf885'; +const requiredDevtoolsVersion = '4a1fe05787f040c67f8a9da547108c87082e6ec5'; const nativeOsNames = { darwin: 'macos', linux: 'linux', win32: 'windows' }; const nativeArchNames = { x64: 'amd64', arm64: 'aarch64' }; diff --git a/src/auth/ace-credential.ts b/src/auth/ace-credential.ts index e21dab50..ab0153f5 100644 --- a/src/auth/ace-credential.ts +++ b/src/auth/ace-credential.ts @@ -3,7 +3,7 @@ import { MissingAuthTokenError } from '../missing-auth-token-error'; export interface AceAuthState { active: boolean; - status: Pick | null; + status: Pick | null; } function nonBlank(value: string | undefined): string | undefined { @@ -16,7 +16,7 @@ export function resolveAceToken(settingToken: string | undefined, legacyToken: s } export function isAceAvailable(token: string | undefined, auth: AceAuthState): boolean { - return token !== undefined || (auth.active && auth.status?.['ace-credential'] === true); + return token !== undefined || (auth.active && auth.status?.state === 'signed-in'); } export function refactorToken(token: string | undefined, auth: AceAuthState): string | undefined { diff --git a/src/auth/auth-token.ts b/src/auth/auth-token.ts new file mode 100644 index 00000000..0ce40d23 --- /dev/null +++ b/src/auth/auth-token.ts @@ -0,0 +1,9 @@ +export interface AuthTokenResponse { + 'credential-source': 'env-token' | 'setting-token' | 'oauth'; + 'access-token': string; +} + +export function tokenFromResponse(response: Partial | null | undefined): string | undefined { + const token = response?.['access-token']?.trim(); + return token ? token : undefined; +} diff --git a/src/centralized-webview-framework/types/index.d.ts b/src/centralized-webview-framework/types/index.d.ts index 7027b2b7..76e34995 100644 --- a/src/centralized-webview-framework/types/index.d.ts +++ b/src/centralized-webview-framework/types/index.d.ts @@ -93,6 +93,7 @@ export interface AceContextViewProps extends AuthField { error?: string; isStale: boolean; loading: boolean; + progressMessage?: string; }; } diff --git a/src/code-health-monitor/codelens.ts b/src/code-health-monitor/codelens.ts index 56179c66..d0bef7f5 100644 --- a/src/code-health-monitor/codelens.ts +++ b/src/code-health-monitor/codelens.ts @@ -1,6 +1,7 @@ import vscode, { Uri } from 'vscode'; import { ACE_ENABLED } from '../build-flags'; import { onDidChangeConfiguration, reviewCodeLensesEnabled } from '../configuration'; +import { aceUsable } from '../devtools-api'; import { issueToDocsParams } from '../documentation/commands'; import { reviewDocumentSelector } from '../language-support'; import { DeltaFunctionInfo } from './delta-function-info'; @@ -18,6 +19,10 @@ export function register(context: vscode.ExtensionContext) { ); } +function canOfferRefactoring(functionInfo: DeltaFunctionInfo): boolean { + return ACE_ENABLED && aceUsable() && functionInfo.isRefactoringSupported; +} + export class CodeHealthMonitorCodeLens implements vscode.CodeLensProvider { private changeCodeLensesEmitter = new vscode.EventEmitter(); onDidChangeCodeLenses = this.changeCodeLensesEmitter.event; @@ -55,7 +60,7 @@ export class CodeHealthMonitorCodeLens implements vscode.CodeLensProvider {}, diff --git a/src/code-health-monitor/home/home-view.ts b/src/code-health-monitor/home/home-view.ts index 72115d26..a06f74bc 100644 --- a/src/code-health-monitor/home/home-view.ts +++ b/src/code-health-monitor/home/home-view.ts @@ -114,9 +114,8 @@ export class HomeView implements WebviewViewProvider, Disposable { // Detect changes to the delta result, updates the list and lastly converts the data to CWF webview format private updateFileDeltaData(event: DeltaAnalysisEvent) { - const { document, result, updateMonitor, enrichment } = event; + const { document, result, updateMonitor } = event; if (!updateMonitor) return; - if (enrichment && !this.fileIssueMap.has(document.uri.fsPath)) return; const evtData = (fileWithIssues: FileWithIssues) => { const { nIssues, scoreChange } = fileWithIssues; diff --git a/src/codescene-tab/webview/ace/ace-data-mapper.ts b/src/codescene-tab/webview/ace/ace-data-mapper.ts index 96226746..afad7d3d 100644 --- a/src/codescene-tab/webview/ace/ace-data-mapper.ts +++ b/src/codescene-tab/webview/ace/ace-data-mapper.ts @@ -11,10 +11,11 @@ interface CwfAceData { isStale: boolean; error?: string; loading: boolean; + progressMessage?: string; } export function getAceData(data: CwfAceData): AceContextViewProps { - const { request, result, isStale, error, loading } = data; + const { request, result, isStale, error, loading, progressMessage } = data; return { view: 'ace', @@ -40,6 +41,7 @@ export function getAceData(data: CwfAceData): AceContextViewProps { isStale, loading, error, + progressMessage, }, }; } diff --git a/src/codescene-tab/webview/ace/acknowledgement/ace-acknowledgement-mapper.ts b/src/codescene-tab/webview/ace/acknowledgement/ace-acknowledgement-mapper.ts index 7a8b6a59..551f3d5f 100644 --- a/src/codescene-tab/webview/ace/acknowledgement/ace-acknowledgement-mapper.ts +++ b/src/codescene-tab/webview/ace/acknowledgement/ace-acknowledgement-mapper.ts @@ -23,13 +23,13 @@ export function getAutoRefactorConfig(): AutoRefactorConfig { return { disabled: true, activated: false, visible: false }; } - const hasToken = aceAvailable(); + const authenticated = aceAvailable(); const acknowledged = CsExtensionState.acknowledgedAceUsage === true; - const activated = !(!acknowledged && hasToken); + const activated = !(!acknowledged && authenticated); return { - disabled: !hasToken, + disabled: !authenticated, activated, - visible: CsExtensionState.stateProperties.features.ace.state === 'enabled', + visible: authenticated && CsExtensionState.stateProperties.features.ace.state === 'enabled', }; } diff --git a/src/codescene-tab/webview/ace/acknowledgement/cwf-webview-ace-acknowledgement-panel.ts b/src/codescene-tab/webview/ace/acknowledgement/cwf-webview-ace-acknowledgement-panel.ts index 7207b2c3..edbe5ea6 100644 --- a/src/codescene-tab/webview/ace/acknowledgement/cwf-webview-ace-acknowledgement-panel.ts +++ b/src/codescene-tab/webview/ace/acknowledgement/cwf-webview-ace-acknowledgement-panel.ts @@ -10,6 +10,7 @@ import { highlightCode } from '../../../../refactoring/utils'; import Telemetry from '../../../../telemetry'; import { reportError, showDocAtPosition } from '../../../../utils'; import { commonResourceRoots } from '../../../../webview-utils'; +import { handleOpenSettings } from '../../../../code-health-monitor/home/cwf-message-handlers'; import { CsExtensionState } from '../../../../cs-extension-state'; import { getAceAcknowledgeData } from './ace-acknowledgement-mapper'; @@ -37,7 +38,7 @@ export class CodeSceneCWFAceAcknowledgementTabPanel implements Disposable { constructor() { this.webViewPanel = vscode.window.createWebviewPanel( CodeSceneCWFAceAcknowledgementTabPanel.viewType, - 'CodeScene ACE - AI-Powered Refactoring', + 'CodeScene Refactoring Agent - AI-Powered Refactoring', { viewColumn: ViewColumn.Beside, preserveFocus: true }, { enableScripts: true, @@ -71,7 +72,7 @@ export class CodeSceneCWFAceAcknowledgementTabPanel implements Disposable { if (!this.state) return; await this.handleMessage(this.state.request, message); } catch (e) { - reportError({ context: 'An error occurred in the CodeScene ACE Acknowledgement panel', e }); + reportError({ context: 'An error occurred in the CodeScene Agent Acknowledgement panel', e }); } } @@ -82,6 +83,7 @@ export class CodeSceneCWFAceAcknowledgementTabPanel implements Disposable { await showDocAtPosition(request.document, request.fnToRefactor.vscodeRange.start); void highlightCode(request, false); }, + 'open-settings': handleOpenSettings, acknowledged: async () => { const document = this.state?.request.document; const fnToRefactor = this.state?.request.fnToRefactor; diff --git a/src/codescene-tab/webview/ace/cwf-webview-ace-panel.ts b/src/codescene-tab/webview/ace/cwf-webview-ace-panel.ts index cecd56c8..f26cd1f6 100644 --- a/src/codescene-tab/webview/ace/cwf-webview-ace-panel.ts +++ b/src/codescene-tab/webview/ace/cwf-webview-ace-panel.ts @@ -27,6 +27,7 @@ export interface CwfAceTabParams { request: RefactoringRequest; cwfProps?: AceContextViewProps; isStale?: boolean; + progressMessage?: string; } export class CodeSceneCWFAceTabPanel implements Disposable { @@ -55,7 +56,7 @@ export class CodeSceneCWFAceTabPanel implements Disposable { constructor() { this.webViewPanel = vscode.window.createWebviewPanel( CodeSceneCWFAceTabPanel.viewType, - 'CodeScene ACE', + 'CodeScene Agent', { viewColumn: ViewColumn.Beside, preserveFocus: true }, { enableScripts: true, @@ -107,7 +108,7 @@ export class CodeSceneCWFAceTabPanel implements Disposable { if (!this.state) return; await this.handleAceMessage(this.state.request, message); } catch (e) { - reportError({ context: 'An error occurred in the CodeScene ACE panel', e }); + reportError({ context: 'An error occurred in the CodeScene Agent panel', e }); } } @@ -242,6 +243,13 @@ export class CodeSceneCWFAceTabPanel implements Disposable { if (this.isDisposing) return; const isStale = this.state?.isStale ?? false; + request.onProgress = (message: string) => { + if (this.state?.request === request) { + this.state.progressMessage = message; + void this.renderAce(request, getAceData({ request, isStale, loading: true, progressMessage: message })); + } + }; + await this.renderAce(request, getAceData({ request, isStale, loading: true })); try { diff --git a/src/codescene-tab/webview/refactoring-components.ts b/src/codescene-tab/webview/refactoring-components.ts index 92336710..fc2f70e7 100644 --- a/src/codescene-tab/webview/refactoring-components.ts +++ b/src/codescene-tab/webview/refactoring-components.ts @@ -139,7 +139,7 @@ export function refactoringError(isAuthError = false) { default: return /*html*/ `
-

Unfortunately, we are unable to provide a CodeScene ACE refactoring recommendation or a code improvement +

Unfortunately, we are unable to provide a CodeScene Refactoring Agent recommendation or a code improvement guide at this time. We recommend reviewing your code manually to identify potential areas for enhancement.

For further assistance, please refer to the CodeScene documentation for best practices and guidance on improving your code.

diff --git a/src/configuration.ts b/src/configuration.ts index 3ceb62df..45362160 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -68,6 +68,43 @@ export function getAuthToken() { return getConfiguration('authToken', ''); } +const DEFAULT_AGENT_MODEL = 'amazon-bedrock/eu.anthropic.claude-sonnet-4-6'; + +function inferModelFromProviderOptions(): string | undefined { + const opts = getProviderOptions(); + if (opts['openai:api-key']) { + return 'openai/gpt-4o'; + } + if (opts['anthropic:api-key']) { + return 'anthropic/claude-sonnet-4-0'; + } + return undefined; +} + +export function getAgentModel(): string { + const override = getConfiguration('agentModelOverride', '')?.trim(); + if (override) { + return override; + } + + const configured = getConfiguration('agentModel', ''); + const inferred = inferModelFromProviderOptions(); + + if (configured && configured !== DEFAULT_AGENT_MODEL) { + return configured; + } + + if (inferred) { + return inferred; + } + + return configured || DEFAULT_AGENT_MODEL; +} + +export function getProviderOptions(): Record { + return getConfiguration>('providerOptions') ?? {}; +} + export function automaticAnalysisEnabled() { return getConfiguration('enableAutomaticAnalysis', true) !== false; } diff --git a/src/cs-extension-state.ts b/src/cs-extension-state.ts index 6383c5c0..485235eb 100644 --- a/src/cs-extension-state.ts +++ b/src/cs-extension-state.ts @@ -208,3 +208,8 @@ function featureState(feature: CsFeature) { if (isDefined(feature.error)) return 'error'; return feature.state; } + +export function getExtensionPath(): string | undefined { + if (!CsExtensionState.hasInstance) return undefined; + return CsExtensionState.extensionUri.fsPath; +} diff --git a/src/cs-statusbar.ts b/src/cs-statusbar.ts index 259a5118..cdbf5b38 100644 --- a/src/cs-statusbar.ts +++ b/src/cs-statusbar.ts @@ -1,7 +1,7 @@ import vscode, { Disposable } from 'vscode'; import { ACE_ENABLED } from './build-flags'; import { AnalysisFeature, CsExtensionState, CsFeature } from './cs-extension-state'; -import { aceAvailable } from './devtools-api'; +import { aceUsable } from './devtools-api'; import { CreditsInfoError } from './devtools-api/credits-info-error'; import { isDefined, toUppercase } from './utils'; import { automaticAnalysisEnabled, onDidChangeConfiguration } from './configuration'; @@ -51,6 +51,11 @@ export class CsStatusBar implements Disposable { return; } const item = this.aceStatus; + if (!aceUsable()) { + item.hide(); + return; + } + item.show(); const handler = this.aceStateHandlers[ace.state] || (() => {}); handler(ace, item); } @@ -150,14 +155,6 @@ export class CsStatusBar implements Disposable { background: 'statusBarItem.warningBackground', }); }, - disabled: (ace, item) => { - this.setStatus(item, { - text: `$(error) ACE ${toUppercase(ace.state)}`, - tooltip: 'Enable ACE in the extension settings', - command: 'codescene.ace.setEnabled', - background: 'statusBarItem.warningBackground', - }); - }, loading: (ace, item) => { this.setStatus(item, { text: '$(loading~spin) ACE', @@ -165,12 +162,9 @@ export class CsStatusBar implements Disposable { }); }, enabled: (ace, item) => { - const hasToken = aceAvailable(); this.setStatus(item, { - text: `$(${hasToken ? 'cs-logo' : 'error'}) ACE`, - command: 'codescene.openSettingsAndFocusToken', - background: hasToken ? undefined : 'statusBarItem.warningBackground', - tooltip: hasToken ? 'CodeScene ACE is active' : 'Configure ACE auth token in extension Workspace settings', + text: '$(cs-logo) ACE', + tooltip: 'CodeScene Agent is active', }); }, }; diff --git a/src/devtools-api/auth-log-summaries.ts b/src/devtools-api/auth-log-summaries.ts index 84859cf2..ed65221a 100644 --- a/src/devtools-api/auth-log-summaries.ts +++ b/src/devtools-api/auth-log-summaries.ts @@ -29,4 +29,5 @@ export const authResponseSummaries: Record = { 'cs-ide/auth-cancel-login': statusResponse, 'cs-ide/auth-logout': statusResponse, 'cs-ide/auth-select-account': statusResponse, + 'cs-ide/auth-token': (value) => formatLogFields({ source: value['credential-source'] }), }; diff --git a/src/devtools-api/delta-model.ts b/src/devtools-api/delta-model.ts index 3937d019..9d2f5ea7 100644 --- a/src/devtools-api/delta-model.ts +++ b/src/devtools-api/delta-model.ts @@ -62,7 +62,7 @@ export interface FunctionFinding { 'change-details': ChangeDetail[]; function: Function; - /* Present if the function finding is deemed refactorable. See Analyser.addRefactorableFunctionsToDeltaResult() */ + /* Present if the function finding is deemed refactorable. See DevtoolsAPI.addRefactorableFunctions() */ refactorableFn?: FnToRefactor; } diff --git a/src/devtools-api/delta-utils.ts b/src/devtools-api/delta-utils.ts deleted file mode 100644 index ed8bd2ad..00000000 --- a/src/devtools-api/delta-utils.ts +++ /dev/null @@ -1,22 +0,0 @@ -import vscode from 'vscode'; -import { DevtoolsAPI } from '.'; -import { vscodeRange } from '../review/utils'; -import { Delta } from './delta-model'; - -/** - * NOTE - Mutates the delta result by adding info about refactorable functions to the 'function-level-findings' list. - */ -export async function addRefactorableFunctionsToDeltaResult(document: vscode.TextDocument, deltaForFile: Delta) { - const functionsToRefactor = await DevtoolsAPI.fnsToRefactorFromDelta(document, deltaForFile); - if (!functionsToRefactor) return; - - // Add a refactorableFn property to the findings that matches function name and range - deltaForFile['function-level-findings'].forEach((finding) => { - const findingRange = vscodeRange(finding.function.range); - if (!findingRange) return; - const refactorableFunctionForFinding = functionsToRefactor.find( - (fn) => fn.name === finding.function.name && fn.vscodeRange.intersection(findingRange) - ); - finding.refactorableFn = refactorableFunctionForFinding; - }); -} diff --git a/src/devtools-api/fns-to-refactor-cache.ts b/src/devtools-api/fns-to-refactor-cache.ts deleted file mode 100644 index a08da94c..00000000 --- a/src/devtools-api/fns-to-refactor-cache.ts +++ /dev/null @@ -1,84 +0,0 @@ -import vscode from 'vscode'; -import { FnToRefactor } from './refactor-models'; -import { CodeSmell } from './review-model'; -import { DevtoolsAPI } from '.'; - -/** - * Shared cache for fnsToRefactor results to avoid redundant binary calls. - * Key format: :v::: - */ -class FnsToRefactorCache { - private cache = new Map(); - - // Kept private in order to enforce use of the caching layer. - private async fnsToRefactorFromCodeSmell( - document: vscode.TextDocument, - codeSmell: CodeSmell - ): Promise { - const result = await DevtoolsAPI.fnsToRefactor(document, { 'code-smells': [codeSmell] }); - return result?.[0]; - } - - private buildCacheKey(document: vscode.TextDocument, codeSmell: CodeSmell): string { - const line = codeSmell['highlight-range']['start-line']; - const character = codeSmell['highlight-range']['start-column']; - return `${document.uri.toString()}:v${document.version}:${line}:${character}:${codeSmell.category}`; - } - - async fnsToRefactor(document: vscode.TextDocument, codeSmell: CodeSmell): Promise { - const cacheKey = this.buildCacheKey(document, codeSmell); - - const cached = this.cache.get(cacheKey); - if (this.cache.has(cacheKey)) { - return cached; - } - - const result = await this.fnsToRefactorFromCodeSmell(document, codeSmell); - this.cache.set(cacheKey, result); - return result; - } - - private isStaleEntry(key: string, docUri: string, currentVersion: number): boolean { - if (!key.startsWith(docUri + ':v')) { - return false; - } - - const versionMatch = key.match(/:v(\d+):/); - if (!versionMatch) { - return false; - } - - const cachedVersion = parseInt(versionMatch[1], 10); - return cachedVersion < currentVersion; - } - - invalidateForDocument(document: vscode.TextDocument): void { - const docUri = document.uri.toString(); - const currentVersion = document.version; - const keysToDelete: string[] = []; - - for (const key of this.cache.keys()) { - if (this.isStaleEntry(key, docUri, currentVersion)) { - keysToDelete.push(key); - } - } - - keysToDelete.forEach((key) => this.cache.delete(key)); - } - - cleanupForDocument(document: vscode.TextDocument): void { - const docUri = document.uri.toString(); - const keysToDelete: string[] = []; - - for (const key of this.cache.keys()) { - if (key.startsWith(docUri + ':v')) { - keysToDelete.push(key); - } - } - - keysToDelete.forEach((key) => this.cache.delete(key)); - } - -} - -export const fnsToRefactorCache = new FnsToRefactorCache(); diff --git a/src/devtools-api/ide-server-client.ts b/src/devtools-api/ide-server-client.ts index 4e2af24d..5f438ff5 100644 --- a/src/devtools-api/ide-server-client.ts +++ b/src/devtools-api/ide-server-client.ts @@ -1,46 +1,28 @@ import { ChildProcess, spawn } from 'child_process'; import * as path from 'path'; import vscode from 'vscode'; -import { CancellationToken, CancellationTokenSource, createMessageConnection, MessageConnection } from 'vscode-jsonrpc/node'; +import { createMessageConnection, MessageConnection } from 'vscode-jsonrpc/node'; +import type { AuthTokenResponse } from '../auth/auth-token'; import type { AuthStatus } from '../centralized-webview-framework/types/auth'; import { formatLogFields, logOutputChannel } from '../log'; import { redactSecrets } from '../log-redaction'; import { authRequestSummaries, authResponseSummaries, authStatusSummary } from './auth-log-summaries'; import { relativePosix, toPosixRelPath } from '../utils/fs-paths'; -import { AbortError } from './abort-error'; import { Delta } from './delta-model'; -import { DevtoolsError } from './devtools-error'; import { CheckRulesResponse, CodeHealthRulesTemplateResponse } from './model'; -import { FnToRefactor, PreFlightResponse, RefactorResponse } from './refactor-models'; import { Review } from './review-model'; import { TelemetryEvent, TelemetryResponse } from './telemetry-model'; import { checkRulesResponse, deltaResponse, deviceIdResponse, - fnToRefactorResponse, notificationRepoRoot, - preflightResponse, - refactorResponse, reviewProgressResponse, reviewResponse, watchInventoryResponse, } from './rpc-response-normalizers'; const STARTUP_TIMEOUT_MS = 30000; -const HTTP_FAILURE = /^Request failed \[(\d{3})\]/; - -/** - * cs-ide reports failed backend calls as a JSON-RPC error with the HTTP status only in the message. - * Callers tell auth failures apart through `DevtoolsError.status`, as they did before the cs-ide server. - */ -function httpFailure(error: unknown): DevtoolsError | undefined { - if (!(error instanceof Error)) return; - const match = HTTP_FAILURE.exec(error.message); - if (!match) return; - return new DevtoolsError({ message: error.message, status: Number(match[1]) }); -} - export function distributionServerCommand( distributionPath: string, serverArgs: string[] = ['server'] @@ -113,22 +95,6 @@ export interface DeltaParams { 'new-score'?: string; } -export interface FnsToRefactorParams { - 'file-name': string; - 'file-content': string; - preflight: PreFlightResponse; - 'cache-path'?: string; - 'delta-result'?: Delta; - 'code-smells'?: unknown[]; -} - -export interface RefactorParams { - token?: string; - 'fn-to-refactor'?: FnToRefactor; - 'fn-to-refactor-nippy-b64'?: string; - 'skip-cache'?: boolean; -} - interface ResultNotification { id?: string; path: string; @@ -262,30 +228,14 @@ export class CsIdeServerClient implements vscode.Disposable { return response ? deltaResponse(response) : null; } - async preflight(params: { force?: boolean } = {}): Promise { - return preflightResponse(await this.sendRequest('cs-ide/preflight', params)); - } - - async fnsToRefactor(params: FnsToRefactorParams): Promise { - const response = await this.sendRequest[]>('cs-ide/fns-to-refactor', params); - return response.map(fnToRefactorResponse); - } - - async refactor(params: RefactorParams, signal?: AbortSignal): Promise { - const cancellation = this.bindRefactorCancellation(signal); - try { - return refactorResponse(await this.sendRequest('cs-ide/refactor', params, cancellation.token)); - } catch (error) { - throw this.refactorRejection(error, signal); - } finally { - cancellation.dispose(); - } - } - async telemetry(event: TelemetryEvent): Promise { return this.sendRequest('cs-ide/telemetry', { event }); } + authToken(): Promise { + return this.sendRequest('cs-ide/auth-token', {}); + } + async deviceId(): Promise<{ 'device-id': string }> { return deviceIdResponse(await this.sendRequest('cs-ide/device-id', {})); } @@ -361,26 +311,7 @@ export class CsIdeServerClient implements vscode.Disposable { this.authStatusEmitter.dispose(); } - private bindRefactorCancellation(signal?: AbortSignal): { token: CancellationToken; dispose: () => void } { - const cancellation = new CancellationTokenSource(); - const cancel = () => cancellation.cancel(); - signal?.addEventListener('abort', cancel); - if (signal?.aborted) cancel(); - return { - token: cancellation.token, - dispose: () => { - signal?.removeEventListener('abort', cancel); - cancellation.dispose(); - }, - }; - } - - private refactorRejection(error: unknown, signal?: AbortSignal): unknown { - if (signal?.aborted) return new AbortError(); - return httpFailure(error) ?? error; - } - - private async sendRequest(method: string, params: unknown, token?: CancellationToken): Promise { + private async sendRequest(method: string, params: unknown): Promise { await this.start(); if (!this.connection) throw new Error('CodeScene IDE server is not running'); const id = ++this.requestSeq; @@ -390,9 +321,7 @@ export class CsIdeServerClient implements vscode.Disposable { `[cs-ide] sending id=${id} method=${method}${requestSummary ? ` ${requestSummary}` : ''}` ); try { - const result = token === undefined - ? await this.connection.sendRequest(method, params) - : await this.connection.sendRequest(method, params, token); + const result = await this.connection.sendRequest(method, params); const responseSummary = summarizeResponse(method, result); logOutputChannel.info( `[cs-ide] received id=${id} method=${method} durationMs=${Date.now() - started}${responseSummary ? ` ${responseSummary}` : ''}` @@ -612,25 +541,6 @@ function isWireObject(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } -function wireField(value: Record, ...keys: string[]): T | undefined { - for (const key of keys) { - if (value[key] !== undefined) return value[key] as T; - } - return undefined; -} - -function fnsToRefactorInput(value: Record): string { - if (value['delta-result'] !== undefined) return 'delta-result'; - if (Array.isArray(value['code-smells'])) return 'code-smells'; - return 'none'; -} - -function refactorPayloadFormat(value: Record): string { - if (value['fn-to-refactor-nippy-b64'] !== undefined) return 'nippy-b64'; - if (value['fn-to-refactor'] !== undefined) return 'json'; - return 'none'; -} - function telemetryEventName(value: Record): unknown { return isWireObject(value.event) ? value.event['event-name'] : undefined; } @@ -645,17 +555,6 @@ const requestSummaries: Record) => strin hasOldScore: value['old-score'] !== undefined, hasNewScore: value['new-score'] !== undefined, }), - 'cs-ide/preflight': (value) => formatLogFields({ force: value.force === true }), - 'cs-ide/fns-to-refactor': (value) => formatLogFields({ - fileName: value['file-name'], - input: fnsToRefactorInput(value), - smellCount: Array.isArray(value['code-smells']) ? value['code-smells'].length : undefined, - hasCachePath: value['cache-path'] !== undefined, - }), - 'cs-ide/refactor': (value) => formatLogFields({ - format: refactorPayloadFormat(value), - skipCache: value['skip-cache'] === true, - }), 'cs-ide/telemetry': (value) => formatLogFields({ eventName: telemetryEventName(value) }), 'cs-ide/check-rules': (value) => formatLogFields({ repoRoot: value['repo-root'], path: value.path }), 'cs-ide/getWatchInventory': (value) => formatLogFields({ repoRoot: value['repo-root'] }), @@ -667,36 +566,9 @@ function summarizeRequest(method: string, params: unknown): string { return requestSummaries[method]?.(params) ?? ''; } -function summarizeFnsToRefactorResponse(result: unknown[]): string { - return formatLogFields({ - count: result.length, - functions: result.map(summarizeFnToRefactor), - }); -} - const responseSummaries: Record) => string> = { 'cs-ide/review': (value) => formatLogFields(reviewSummary(reviewResponse(value))), 'cs-ide/delta': (value) => formatLogFields(deltaSummary(deltaResponse(value))), - 'cs-ide/preflight': (value) => { - const response = preflightResponse(value); - return formatLogFields({ - version: response.version, - fileTypes: response['file-types'], - codeSmellCount: response['language-common']['code-smells'].length, - maxInputLoc: response['language-common']['max-input-loc'], - }); - }, - 'cs-ide/refactor': (value) => { - const response = refactorResponse(value); - return formatLogFields({ - traceId: response['trace-id'], - confidence: response.confidence.level, - cached: response.metadata['cached?'] === true, - reasonCount: response.reasons.length, - added: response['refactoring-properties']['added-code-smells'], - removed: response['refactoring-properties']['removed-code-smells'], - }); - }, 'cs-ide/telemetry': (value) => formatLogFields({ status: value.status }), 'cs-ide/device-id': () => formatLogFields({ ok: true }), 'cs-ide/code-health-rules-template': () => formatLogFields({ ok: true }), @@ -715,9 +587,6 @@ const responseSummaries: Record) => stri }; function summarizeResponse(method: string, result: unknown): string { - if (method === 'cs-ide/fns-to-refactor' && Array.isArray(result)) { - return summarizeFnsToRefactorResponse(result); - } if (!isWireObject(result)) return ''; return responseSummaries[method]?.(result) ?? ''; } @@ -750,26 +619,3 @@ function deltaSummary( functionFindings: result['function-level-findings'].length, }; } - -function summarizeFnToRefactor(value: unknown): string { - if (!isWireObject(value)) return '(invalid)'; - const name = typeof value.name === 'string' ? value.name : '(unnamed)'; - const rangeLabel = fnRangeLabel(value.range); - const categories = refactoringTargetCategories(value); - return `${name}${rangeLabel ? `@${rangeLabel}` : ''}${categories.length ? `[${categories.join(', ')}]` : ''}`; -} - -function fnRangeLabel(range: unknown): string | undefined { - if (!isWireObject(range)) return undefined; - const startLine = wireField(range, 'startLine', 'start-line'); - const startColumn = wireField(range, 'startColumn', 'start-column'); - const endLine = wireField(range, 'endLine', 'end-line'); - const endColumn = wireField(range, 'endColumn', 'end-column'); - if ([startLine, startColumn, endLine, endColumn].includes(undefined)) return undefined; - return `${startLine}:${startColumn}-${endLine}:${endColumn}`; -} - -function refactoringTargetCategories(fn: Record): string[] { - const targets = wireField>(fn, 'refactoringTargets', 'refactoring-targets') ?? []; - return targets.map((target) => target.category).filter((category): category is string => !!category); -} diff --git a/src/devtools-api/index.ts b/src/devtools-api/index.ts index ebff7aad..5155b7f1 100644 --- a/src/devtools-api/index.ts +++ b/src/devtools-api/index.ts @@ -1,9 +1,7 @@ -import { CodeSmell, Review } from './review-model'; +import { Review } from './review-model'; import { assertError, getWorkspaceCwd, networkErrors, rangeStr, reportError } from '../utils'; import { AceRequestEvent, CodeHealthRulesResult } from './model'; import { FnToRefactor, RefactorResponse } from './refactor-models'; -import { basename, dirname } from 'path'; -import { existsSync } from 'fs'; import vscode, { ExtensionContext, TextDocument } from 'vscode'; import { isAceAvailable, refactorToken, resolveAceToken } from '../auth/ace-credential'; import { ACE_ENABLED } from '../build-flags'; @@ -11,15 +9,14 @@ import { getAuthToken, automaticAnalysisEnabled } from '../configuration'; import { CsExtensionState, CsFeature } from '../cs-extension-state'; import { logOutputChannel } from '../log'; import { RefactoringRequest } from '../refactoring/request'; -import { vscodeRange } from '../review/utils'; +import { AgentRefactoringService, ProgressCallback } from '../refactoring/agent-service'; import { Delta } from './delta-model'; -import { addRefactorableFunctionsToDeltaResult } from './delta-utils'; +import { fnToRefactorForFinding } from '../refactoring/fn-to-refactor'; import { TelemetryEvent, TelemetryResponse } from './telemetry-model'; -import { ReviewCache } from './review-cache'; import { AbortError } from './abort-error'; import { acquireGitApi, fireFileDeletedFromGit, getRepoRootPath } from '../git-utils'; import Reviewer, { ReviewOpts } from '../review/reviewer'; -import { CsIdeServerClient, RefactorParams, ReviewQueue, ServerStartEvent, WatchInventory } from './ide-server-client'; +import { CsIdeServerClient, ReviewQueue, ServerStartEvent, WatchInventory } from './ide-server-client'; import { v4 as uuid } from 'uuid'; import { PresentedDelta, PresentedReview, ReviewPipeline, ReviewPipelinePresentation } from '../review/review-pipeline'; import { CsReview } from '../review/cs-review'; @@ -27,7 +24,6 @@ import CsDiagnostics from '../diagnostics/cs-diagnostics'; import { relativePosix } from '../utils/fs-paths'; export class DevtoolsAPI { - private static reviewCache: ReviewCache; private static ideServer: CsIdeServerClient; private static pipeline: ReviewPipeline; private static queueSubscription?: vscode.Disposable; @@ -37,7 +33,6 @@ export class DevtoolsAPI { DevtoolsAPI.pipeline?.dispose(); DevtoolsAPI.queueSubscription?.dispose(); DevtoolsAPI.ideServer?.dispose(); - DevtoolsAPI.reviewCache = new ReviewCache(context); DevtoolsAPI.ideServer = ideServer ?? new CsIdeServerClient(binaryPath); DevtoolsAPI.pipeline = new ReviewPipeline(DevtoolsAPI.ideServer, DevtoolsAPI.pipelinePresentation(), uuid); DevtoolsAPI.queueCount = 0; @@ -201,18 +196,15 @@ export class DevtoolsAPI { normalized['file-level-findings'] ??= []; normalized['function-level-findings'] ??= []; } + if (normalized && DevtoolsAPI.aceEnabled()) DevtoolsAPI.addRefactorableFunctions(document, normalized); Reviewer.instance.reviewCache.get(document, 'any')?.setDelta(normalized); DevtoolsAPI.deltaAnalysisEmitter.fire({ document, result: normalized, updateMonitor }); - if (normalized) void DevtoolsAPI.enrichServerDelta(document, normalized, updateMonitor); } - private static async enrichServerDelta(document: TextDocument, result: Delta, updateMonitor: boolean): Promise { - try { - await addRefactorableFunctionsToDeltaResult(document, result); - DevtoolsAPI.deltaAnalysisEmitter.fire({ document, result, updateMonitor, enrichment: true }); - } catch (error) { - logOutputChannel.warn(`[cs-ide] could not enrich delta for ${document.fileName}: ${assertError(error).message}`); - } + private static addRefactorableFunctions(document: TextDocument, delta: Delta): void { + delta['function-level-findings'].forEach((finding) => { + finding.refactorableFn = fnToRefactorForFinding(document, finding); + }); } private static removeServerReview(document: TextDocument): void { @@ -223,58 +215,24 @@ export class DevtoolsAPI { private static readonly deltaAnalysisEmitter = new vscode.EventEmitter(); public static readonly onDidDeltaAnalysisComplete = DevtoolsAPI.deltaAnalysisEmitter.event; - private static readonly preflightRequestEmitter = new vscode.EventEmitter(); - public static readonly onDidChangePreflightState = DevtoolsAPI.preflightRequestEmitter.event; - private static preflightJson?: string; + private static readonly aceStateEmitter = new vscode.EventEmitter(); + public static readonly onDidChangeAceState = DevtoolsAPI.aceStateEmitter.event; + private static aceActive = false; - static async preflight() { + static enableAce() { if (!ACE_ENABLED) return; - DevtoolsAPI.preflightRequestEmitter.fire({ state: 'loading' }); - try { - const response = await DevtoolsAPI.ideServer.preflight(); - DevtoolsAPI.preflightJson = JSON.stringify(response); - DevtoolsAPI.preflightRequestEmitter.fire({ state: 'enabled' }); - return response; - } catch (e) { - if (DevtoolsAPI.shouldHandleOfflineBehavior(e)) { - DevtoolsAPI.handleOfflineBehavior(); - return; - } - DevtoolsAPI.preflightRequestEmitter.fire({ state: 'error', error: assertError(e) }); - reportError({ context: 'Unable to enable refactoring capabilities', e }); - } + DevtoolsAPI.aceActive = true; + DevtoolsAPI.aceStateEmitter.fire({ state: 'enabled' }); } static aceEnabled() { - return ACE_ENABLED && DevtoolsAPI.preflightJson !== undefined; + return ACE_ENABLED && DevtoolsAPI.aceActive; } static disableAce() { if (!ACE_ENABLED) return; - DevtoolsAPI.preflightJson = undefined; - DevtoolsAPI.preflightRequestEmitter.fire({ state: 'disabled' }); - } - - static async fnsToRefactorFromDelta(document: TextDocument, delta: Delta) { - return this.fnsToRefactor(document, { 'delta-result': delta }); - } - - static async fnsToRefactor(document: TextDocument, args: { 'delta-result': Delta } | { 'code-smells': [CodeSmell] }) { - if (!DevtoolsAPI.aceEnabled()) return; - logOutputChannel.debug(`Calling fns-to-refactor for ${basename(document.fileName)}`); - const fp = fileParts(document); - if (!validateFileParts('fnsToRefactor', fp, document)) return; - const cachePath = DevtoolsAPI.reviewCache.getCachePath(); - const ret = await DevtoolsAPI.ideServer.fnsToRefactor({ - 'file-name': fp.fileName, - 'file-content': document.getText(), - preflight: JSON.parse(DevtoolsAPI.preflightJson!), - ...(cachePath ? { 'cache-path': cachePath } : {}), - ...args, - }); - ret.forEach((fn) => (fn.vscodeRange = vscodeRange(fn.range)!)); - logOutputChannel.debug(`Completed fns-to-refactor for ${basename(document.fileName)}, found ${ret.length} function(s)`); - return ret; + DevtoolsAPI.aceActive = false; + DevtoolsAPI.aceStateEmitter.fire({ state: 'disabled' }); } private static readonly refactoringRequestEmitter = new vscode.EventEmitter(); @@ -282,24 +240,18 @@ export class DevtoolsAPI { private static readonly refactoringErrorEmitter = new vscode.EventEmitter(); public static readonly onDidRefactoringFail = DevtoolsAPI.refactoringErrorEmitter.event; - private static buildRefactoringPayload(fnToRefactor: FnToRefactor, skipCache: boolean, token: string | undefined): RefactorParams { - const payload: RefactorParams = token ? { token } : {}; - if (fnToRefactor['nippy-b64']) payload['fn-to-refactor-nippy-b64'] = fnToRefactor['nippy-b64']; - else payload['fn-to-refactor'] = fnToRefactor; - if (skipCache) payload['skip-cache'] = true; - return payload; + static authToken() { + return DevtoolsAPI.ideServer.authToken(); } - static async postRefactoring(request: RefactoringRequest): Promise { + static async postRefactoring(request: RefactoringRequest, onProgress?: ProgressCallback): Promise { this.checkAceEnabled(); const { document, fnToRefactor, skipCache, signal } = request; - const token = this.getRefactoringAuthToken(); + this.getRefactoringAuthToken(); DevtoolsAPI.refactoringRequestEmitter.fire({ document, request, type: 'start' }); try { - const payload = DevtoolsAPI.buildRefactoringPayload(fnToRefactor, skipCache, token); this.logRefactorRequested(fnToRefactor, skipCache); - this.validateRefactoringDocument(document); - const response = await DevtoolsAPI.ideServer.refactor(payload, signal); + const response = await DevtoolsAPI.executeRefactor(document, fnToRefactor, signal, onProgress); this.logRefactorDone(fnToRefactor, skipCache, response); DevtoolsAPI.handleBackOnline(); return response; @@ -323,11 +275,13 @@ export class DevtoolsAPI { logOutputChannel.info(`Refactor requested for ${logIdString(fnToRefactor)}${skipCache ? ' (retry)' : ''}, with refactoring targets: [${fnToRefactor['refactoring-targets'].map((target) => target.category).join(', ')}]`); } - private static validateRefactoringDocument(document: TextDocument): void { - const fp = fileParts(document); - if (!validateFileParts('postRefactoring', fp, document)) { - throw new Error('Invalid file parts: document directory is missing or does not exist'); - } + private static async executeRefactor( + document: TextDocument, + fnToRefactor: FnToRefactor, + signal: AbortSignal, + onProgress?: ProgressCallback + ): Promise { + return AgentRefactoringService.runRefactoring(document, fnToRefactor, signal, undefined, onProgress); } private static logRefactorDone(fnToRefactor: FnToRefactor, skipCache: boolean, response: RefactorResponse): void { @@ -361,13 +315,13 @@ export class DevtoolsAPI { void vscode.window.showInformationMessage('CodeScene extension is running in offline mode. Some features may be unavailable.'); } logOutputChannel.warn('CodeScene extension is running in offline mode. The requested action could not be completed. Please check your internet connection to restore full functionality.'); - DevtoolsAPI.preflightRequestEmitter.fire({ state: 'offline' }); + DevtoolsAPI.aceStateEmitter.fire({ state: 'offline' }); } private static handleBackOnline() { DevtoolsAPI.lastNetworkError = false; if (ACE_ENABLED && CsExtensionState.stateProperties.features.ace.state === 'offline') { - DevtoolsAPI.preflightRequestEmitter.fire({ state: 'enabled' }); + DevtoolsAPI.aceStateEmitter.fire({ state: 'enabled' }); void vscode.window.showInformationMessage('CodeScene extension is back online.'); } } @@ -380,35 +334,12 @@ export class DevtoolsAPI { try { DevtoolsAPI.analysisErrorEmitter.dispose(); } catch {} try { DevtoolsAPI.reviewEmitter.dispose(); } catch {} try { DevtoolsAPI.deltaAnalysisEmitter.dispose(); } catch {} - try { DevtoolsAPI.preflightRequestEmitter.dispose(); } catch {} + try { DevtoolsAPI.aceStateEmitter.dispose(); } catch {} try { DevtoolsAPI.refactoringRequestEmitter.dispose(); } catch {} try { DevtoolsAPI.refactoringErrorEmitter.dispose(); } catch {} } } -interface FileParts { - fileName: string; - documentDirectory: string; -} - -function fileParts(document: vscode.TextDocument): FileParts { - return { fileName: basename(document.fileName), documentDirectory: dirname(document.fileName) }; -} - -function validateFileParts(operation: string, fp: FileParts, document: vscode.TextDocument): boolean { - if (!fp.documentDirectory?.trim()) { - logOutputChannel.warn(`Operation ${operation} skipped for ${basename(document.fileName)}: document directory is empty`); - return false; - } - let exists = true; - try { exists = existsSync(fp.documentDirectory); } catch {} - if (!exists) { - logOutputChannel.warn(`Operation ${operation} skipped for ${basename(document.fileName)}: document directory does not exist: ${fp.documentDirectory}`); - return false; - } - return true; -} - export function aceToken(): string | undefined { return resolveAceToken(getAuthToken(), CsExtensionState.stateProperties.legacySession?.accessToken); } @@ -417,6 +348,10 @@ export function aceAvailable(): boolean { return isAceAvailable(aceToken(), CsExtensionState.stateProperties.auth); } +export function aceUsable(): boolean { + return DevtoolsAPI.aceEnabled() && aceAvailable(); +} + export function logIdString(fnToRefactor: FnToRefactor, traceId?: string) { return `[traceId ${traceId ?? 'n/a'}] "${fnToRefactor.name}" ${rangeStr(fnToRefactor.vscodeRange)}`; } @@ -433,5 +368,4 @@ export type DeltaAnalysisEvent = { document: vscode.TextDocument; result?: Delta; updateMonitor: boolean; - enrichment?: boolean; }; diff --git a/src/devtools-api/refactor-models.ts b/src/devtools-api/refactor-models.ts index 0d9425b8..0848d0a1 100644 --- a/src/devtools-api/refactor-models.ts +++ b/src/devtools-api/refactor-models.ts @@ -62,18 +62,6 @@ export interface RefactoringTarget { line: number; } -export type PreFlightResponse = { - version: number; - 'file-types': string[]; - 'language-common': RefactorSupport; - 'language-specific'?: Record>; -}; - -export type RefactorSupport = { - 'max-input-loc': number; - 'code-smells': string[]; -}; - export interface RefactorResponse { /** * Refactored code diff --git a/src/devtools-api/review-cache.ts b/src/devtools-api/review-cache.ts deleted file mode 100644 index c23e4ab2..00000000 --- a/src/devtools-api/review-cache.ts +++ /dev/null @@ -1,41 +0,0 @@ - -import fs from 'fs'; -import path from 'path'; -import { ExtensionContext } from 'vscode'; - -function deleteFilesOlderThan(days: number, folderPath: string) { - const now = new Date(); - const files = fs.readdirSync(folderPath); - const daysInMilliseconds = days * 1000 * 60 * 60 * 24; - files.forEach((file) => { - const filePath = path.join(folderPath, file); - const stats = fs.statSync(filePath); - const fileAge = now.getTime() - stats.mtime.getTime(); - if (fileAge >= daysInMilliseconds) { - fs.unlinkSync(filePath); - } - }); -} - -export class ReviewCache { - private cachePath: string | undefined; - - constructor(context: ExtensionContext) { - const storagePath = context.storageUri?.fsPath; - if (storagePath) { - const cachePath = path.join(storagePath, ".review-caches"); - this.cachePath = cachePath; - if (!fs.existsSync(cachePath)) { - fs.mkdirSync(cachePath, {recursive: true}); - } - deleteFilesOlderThan(30, cachePath); - } - else { - this.cachePath = undefined; - } - } - - getCachePath(): string | undefined { - return this.cachePath; - } -} diff --git a/src/devtools-api/rpc-response-normalizers.ts b/src/devtools-api/rpc-response-normalizers.ts index 4ab2f7f0..662de029 100644 --- a/src/devtools-api/rpc-response-normalizers.ts +++ b/src/devtools-api/rpc-response-normalizers.ts @@ -1,13 +1,5 @@ import { ChangeDetail, ChangeType, Delta, FunctionFinding } from './delta-model'; import { CheckRulesResponse } from './model'; -import { - Confidence, - FnToRefactor, - PreFlightResponse, - RefactorResponse, - RefactorSupport, - RefactoringProperties, -} from './refactor-models'; import { CodeSmell, Function as ReviewFunction, Range, Review } from './review-model'; type WireObject = Record; @@ -92,75 +84,6 @@ export function deltaResponse(value: WireObject): Delta { }; } -function refactorSupport(value: WireObject): RefactorSupport { - return { - 'max-input-loc': field(value, 'maxInputLoc', 'max-input-loc')!, - 'code-smells': field(value, 'codeSmells', 'code-smells') ?? [], - }; -} - -export function preflightResponse(value: WireObject): PreFlightResponse { - const languageSpecific = field>(value, 'languageSpecific', 'language-specific'); - return { - version: value.version, - 'file-types': field(value, 'fileTypes', 'file-types') ?? [], - 'language-common': refactorSupport(field(value, 'languageCommon', 'language-common')!), - ...(languageSpecific - ? { 'language-specific': Object.fromEntries(Object.entries(languageSpecific).map(([language, support]) => [language, refactorSupport(support)])) } - : {}), - }; -} - -export function fnToRefactorResponse(value: WireObject): FnToRefactor { - return { - body: value.body, - name: value.name, - 'file-type': field(value, 'fileType', 'file-type')!, - ...(field(value, 'functionType', 'function-type') === undefined - ? {} - : { 'function-type': field(value, 'functionType', 'function-type') }), - ...(field(value, 'nippyB64', 'nippy-b64') === undefined && value['nippy-b-64'] === undefined - ? {} - : { 'nippy-b64': field(value, 'nippyB64', 'nippy-b64') ?? value['nippy-b-64'] }), - range: range(value.range)!, - 'refactoring-targets': field(value, 'refactoringTargets', 'refactoring-targets') ?? [], - vscodeRange: undefined!, - }; -} - -function confidence(value: WireObject): Confidence { - return { - level: value.level, - title: value.title, - 'recommended-action': field(value, 'recommendedAction', 'recommended-action')!, - ...(field(value, 'reviewHeader', 'review-header') === undefined - ? {} - : { 'review-header': field(value, 'reviewHeader', 'review-header') }), - }; -} - -function refactoringProperties(value: WireObject): RefactoringProperties { - return { - 'added-code-smells': field(value, 'addedCodeSmells', 'added-code-smells') ?? [], - 'removed-code-smells': field(value, 'removedCodeSmells', 'removed-code-smells') ?? [], - }; -} - -export function refactorResponse(value: WireObject): RefactorResponse { - return { - code: value.code, - confidence: confidence(value.confidence), - ...(value.declarations === undefined ? {} : { declarations: value.declarations }), - metadata: value.metadata, - reasons: value.reasons ?? [], - 'refactoring-properties': refactoringProperties(field(value, 'refactoringProperties', 'refactoring-properties')!), - 'trace-id': field(value, 'traceId', 'trace-id')!, - ...(field(value, 'creditsInfo', 'credits-info') === undefined - ? {} - : { 'credits-info': field(value, 'creditsInfo', 'credits-info') }), - }; -} - export function checkRulesResponse(value: WireObject): CheckRulesResponse { return { result: value.result, diff --git a/src/extension-impl.ts b/src/extension-impl.ts index 7b6d40f6..9d61c612 100644 --- a/src/extension-impl.ts +++ b/src/extension-impl.ts @@ -5,7 +5,7 @@ import { activate as activateCHMonitor, deactivate as deactivateAddon } from './ import { register as registerCHRulesCommands } from './code-health-rules'; import { automaticAnalysisEnabled, onDidChangeConfiguration, setAutomaticAnalysisEnabled, toggleReviewCodeLenses } from './configuration'; import { CsExtensionState } from './cs-extension-state'; -import { DevtoolsAPI } from './devtools-api'; +import { aceUsable, DevtoolsAPI } from './devtools-api'; import type { CsIdeServerClient } from './devtools-api/ide-server-client'; import CsDiagnostics from './diagnostics/cs-diagnostics'; import { register as registerDocumentationCommands } from './documentation/commands'; @@ -27,6 +27,7 @@ import { discoverCodeHealthRulesFileUris } from './git/codescene-file-discovery' import { createWorkspaceWatchDependencies, WorkspaceWatch } from './git/workspace-watch'; import { onGitDetectedAsUnavailable } from './git/git-detection'; import { ACE_ENABLED } from './build-flags'; +import { testConnectivity } from './refactoring/connectivity'; import { initExtensionId } from './extension-id'; import { guardWindowLifecycleDuringTests, reloadWindowForUpdate } from './extension-reload'; @@ -232,6 +233,42 @@ function registerCommands(context: vscode.ExtensionContext) { context.subscriptions.push(stopAutomaticAnalysisCmd, startAutomaticAnalysisCmd); registerCHRulesCommands(context); + registerTestConnectivityCommand(context); +} + +function registerTestConnectivityCommand(context: vscode.ExtensionContext) { + const cmd = vscode.commands.registerCommand('codescene.testConnectivity', async () => { + if (!aceUsable()) { + void vscode.window.showInformationMessage( + 'Enable "Enable Auto Refactor" and sign in to CodeScene to test provider connectivity.' + ); + return; + } + const abortController = new AbortController(); + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Testing provider connectivity...', + cancellable: true, + }, + async (progress, cancelToken) => { + void progress; + cancelToken.onCancellationRequested(() => { + abortController.abort(); + }); + + const result = await testConnectivity({ signal: abortController.signal }); + if (result.success) { + void vscode.window.showInformationMessage('Provider connectivity test successful.'); + } else if (!result.error?.includes('cancelled')) { + void vscode.window.showErrorMessage(`Provider connectivity test failed: ${result.error}`); + } + } + ); + }); + DISPOSABLES.push(cmd); + context.subscriptions.push(cmd); } function registerOpenCsSettingsCommand(context: vscode.ExtensionContext) { diff --git a/src/refactoring/agent-config.ts b/src/refactoring/agent-config.ts new file mode 100644 index 00000000..2fe69728 --- /dev/null +++ b/src/refactoring/agent-config.ts @@ -0,0 +1,67 @@ +import { getProviderOptions } from '../configuration'; +import { AgentConfig, BedrockOpencodeProvider, ProviderCredentials } from './agent-types'; + +function hasAnyApiKey(anthropicApiKey: string | undefined, openaiApiKey: string | undefined, googleApiKey: string | undefined): boolean { + return Boolean(anthropicApiKey || openaiApiKey || googleApiKey); +} + +export function buildProviderCredentials(opts: Record): ProviderCredentials | undefined { + const anthropicApiKey = opts['anthropic:api-key']; + const openaiApiKey = opts['openai:api-key']; + const googleApiKey = opts['google:api-key']; + + if (!hasAnyApiKey(anthropicApiKey, openaiApiKey, googleApiKey)) { + return undefined; + } + + const credentials: ProviderCredentials = {}; + + if (anthropicApiKey) { + credentials.anthropic_api_key = anthropicApiKey; + } + + if (openaiApiKey) { + credentials.openai_api_key = openaiApiKey; + } + + if (googleApiKey) { + credentials.google_api_key = googleApiKey; + } + + return credentials; +} + +export function buildOpencodeConfig(opts: Record): AgentConfig['opencode_config'] | undefined { + const bedrockProfile = opts['amazon-bedrock:profile']; + const bedrockRegion = opts['amazon-bedrock:region']; + + if (!bedrockProfile && !bedrockRegion) { + return undefined; + } + + const provider: BedrockOpencodeProvider = { + 'amazon-bedrock': { + options: { + ...(bedrockProfile && { profile: bedrockProfile }), + ...(bedrockRegion && { region: bedrockRegion }), + }, + }, + }; + + return { provider }; +} + +export function buildAgentConfigWithToken(token: string | undefined, ioDir?: string): AgentConfig { + const providerOpts = getProviderOptions(); + const providerCredentials = buildProviderCredentials(providerOpts); + const opencodeConfig = buildOpencodeConfig(providerOpts); + + return { + codescene_access_token: token ?? '', + plugins: ['render-code-fix-reporter'], + tracking: { environment: 'codescene-vscode' }, + ...(ioDir && { io_json_dir: ioDir }), + ...(providerCredentials && { provider: providerCredentials }), + ...(opencodeConfig && { opencode_config: opencodeConfig }), + }; +} diff --git a/src/refactoring/agent-service.ts b/src/refactoring/agent-service.ts new file mode 100644 index 00000000..e061b68c --- /dev/null +++ b/src/refactoring/agent-service.ts @@ -0,0 +1,317 @@ +import { spawn, ChildProcess } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { v4 as uuidv4 } from 'uuid'; +import { TextDocument } from 'vscode'; +import { FnToRefactor, RefactorResponse, Confidence, Reason } from '../devtools-api/refactor-models'; +import { logOutputChannel } from '../log'; +import { buildAgentConfigWithToken } from './agent-config'; +import { AgentInput, AgentOutput, ConfidenceLevel, AgentChange } from './agent-types'; +import { getExtensionPath } from '../cs-extension-state'; +import { resolveAgentToken } from './agent-token'; +import { getAgentModel } from '../configuration'; +import { acquireGitApi, getRepoRootPath } from '../git-utils'; + +function stripAnsi(str: string): string { + // eslint-disable-next-line no-control-regex + return str.replace(/\x1b\[[0-9;]*m/g, ''); +} + +function toLf(str: string): string { + return str.replace(/\r\n/g, '\n'); +} + +const INPUT_FILE = 'render-code-fix-input.json'; +const OUTPUT_FILE = 'render-code-fix-output.json'; + +export type ProgressCallback = (message: string) => void; + +export class AgentRefactoringService { + private static getAgentBinaryPath(): string { + const extensionPath = getExtensionPath(); + if (!extensionPath) { + throw new Error('Extension path not available'); + } + const binaryName = process.platform === 'win32' ? 'cs-agent.exe' : 'cs-agent'; + return path.join(extensionPath, 'bin', binaryName); + } + + static async runRefactoring( + document: TextDocument, + fnToRefactor: FnToRefactor, + signal?: AbortSignal, + skipCleanup?: boolean, + onProgress?: ProgressCallback + ): Promise { + if (document.isDirty) { + throw new Error(`Save ${path.basename(document.fileName)} before refactoring: the agent reads the file from disk.`); + } + const taskId = `refactor-${uuidv4()}`; + const gitApi = acquireGitApi(); + const repo = gitApi?.getRepository(document.uri); + const workDir = repo ? getRepoRootPath(repo) : path.dirname(document.fileName); + const ioDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-agent-io-')); + const inputPath = path.join(ioDir, INPUT_FILE); + const outputPath = path.join(ioDir, OUTPUT_FILE); + + try { + const input = AgentRefactoringService.buildInput(document, fnToRefactor, taskId); + fs.writeFileSync(inputPath, JSON.stringify(input, null, 2)); + + logOutputChannel.info(`Agent refactoring started for task ${taskId}`); + + const token = await AgentRefactoringService.requireToken(); + const stderr = await AgentRefactoringService.invokeAgent(workDir, ioDir, token, signal, onProgress); + + if (!fs.existsSync(outputPath)) throw AgentRefactoringService.missingOutputError(stderr); + + const outputContent = fs.readFileSync(outputPath, 'utf-8'); + const output: AgentOutput = JSON.parse(outputContent); + + logOutputChannel.info(`Agent refactoring completed for task ${taskId}: ${output.fix_result}`); + + return AgentRefactoringService.mapOutputToResponse(output, document, fnToRefactor); + } finally { + if (!skipCleanup) { + AgentRefactoringService.cleanup(ioDir); + } + } + } + + private static missingOutputError(stderr: string): Error { + const detail = stripAnsi(stderr).trim().split('\n').slice(-5).join('\n'); + logOutputChannel.error(`Agent exited successfully without producing output. stderr: ${detail}`); + return new Error(detail ? `Agent did not produce output file: ${detail}` : 'Agent did not produce output file'); + } + + private static buildInput(document: TextDocument, fnToRefactor: FnToRefactor, taskId: string): AgentInput { + const target = fnToRefactor['refactoring-targets'][0]; + if (!target) { + throw new Error('No refactoring target found'); + } + + return { + '@context': { '@vocab': 'https://codescene.io/schemas/code-health-fix#' }, + task_id: taskId, + file: document.fileName, + line: target.line, + smell: target.category, + }; + } + + private static async requireToken(): Promise { + const token = await resolveAgentToken(); + if (!token) { + throw new Error( + 'Refactoring requires you to be signed in to CodeScene. Sign in, or set a personal access token in the "Auth token" setting.' + ); + } + return token; + } + + private static spawnAgentProcess( + workDir: string, + ioDir: string, + token: string + ): { proc: ChildProcess; binaryPath: string } { + const binaryPath = AgentRefactoringService.getAgentBinaryPath(); + const config = buildAgentConfigWithToken(token, ioDir); + const configJson = JSON.stringify(config); + + const args: string[] = ['run', 'skill:render-code-fix', '--model', getAgentModel()]; + const env: NodeJS.ProcessEnv = { + ...process.env, + CS_AGENT_CONFIG: configJson, + }; + + logOutputChannel.debug(`Spawning agent: ${binaryPath} ${args.join(' ')}`); + + const proc = spawn(binaryPath, args, { cwd: workDir, env }); + return { proc, binaryPath }; + } + + private static setupStderrHandler(proc: ChildProcess): { getStderr: () => string } { + let stderr = ''; + proc.stderr?.on('data', (data: Buffer) => { + stderr += data.toString(); + const lines = data.toString().split('\n').filter((line) => line.trim()); + for (const line of lines) { + logOutputChannel.debug(`[cs-agent:err] ${stripAnsi(line)}`); + } + }); + return { getStderr: () => stderr }; + } + + private static setupStdoutHandler(proc: ChildProcess, onProgress?: ProgressCallback): void { + proc.stdout?.on('data', (data: Buffer) => { + const output = data.toString(); + logOutputChannel.debug(`Agent stdout: ${output}`); + if (!onProgress) return; + const lines = output.split('\n').filter((line) => line.trim()); + const lastLine = lines[lines.length - 1]; + if (lastLine) onProgress(lastLine); + }); + } + + private static invokeAgent( + workDir: string, + ioDir: string, + token: string, + signal?: AbortSignal, + onProgress?: ProgressCallback + ): Promise { + return new Promise((resolve, reject) => { + const { proc } = AgentRefactoringService.spawnAgentProcess(workDir, ioDir, token); + const { getStderr } = AgentRefactoringService.setupStderrHandler(proc); + AgentRefactoringService.setupStdoutHandler(proc, onProgress); + + const abortHandler = () => { + proc.kill('SIGTERM'); + reject(new Error('Agent invocation aborted')); + }; + + signal?.addEventListener('abort', abortHandler); + + proc.on('close', (code: number | null) => { + signal?.removeEventListener('abort', abortHandler); + const stderr = getStderr(); + if (code === 0) { + resolve(stderr); + } else { + logOutputChannel.error(`Agent exited with code ${code}: ${stderr}`); + reject(new Error(`Agent exited with code ${code}: ${stderr}`)); + } + }); + + proc.on('error', (err: Error) => { + signal?.removeEventListener('abort', abortHandler); + reject(err); + }); + }); + } + + static mapOutputToResponse( + output: AgentOutput, + document: TextDocument, + fnToRefactor: FnToRefactor + ): RefactorResponse { + const code = AgentRefactoringService.applyChanges(document, fnToRefactor, output.changes); + + const effectiveOutput = + code === fnToRefactor.body + ? { ...output, fix_result: 'unable_to_fix' as const, confidence: 'low' as const, summary: 'No changes proposed' } + : output; + + const confidence = AgentRefactoringService.mapConfidence(effectiveOutput); + const reasons = AgentRefactoringService.mapReasons(effectiveOutput); + + return { + code, + confidence, + 'credits-info': undefined, + declarations: undefined, + metadata: { 'cached?': false }, + reasons, + 'refactoring-properties': { + 'added-code-smells': [], + 'removed-code-smells': fnToRefactor['refactoring-targets'].map((t) => t.category), + }, + 'trace-id': output.task_id, + }; + } + + private static applyChange(code: string, change: AgentChange): string { + if (change.change_type === 'whole_file' && change.whole_file_content) { + return toLf(change.whole_file_content); + } + if (!change.replacements) return code; + return change.replacements.reduce((c, r) => { + const exact = { search: toLf(r.search), replace: toLf(r.replace) }; + const trimmed = { search: exact.search.replace(/\n+$/, ''), replace: exact.replace.replace(/\n+$/, '') }; + const match = [exact, trimmed].find((candidate) => candidate.search && c.includes(candidate.search)); + if (!match) { + logOutputChannel.warn( + `Agent replacement did not match the function body.\nsearch: ${JSON.stringify(r.search)}\nbody: ${JSON.stringify(code)}` + ); + return c; + } + return c.replace(match.search, () => match.replace); + }, code); + } + + private static applyChanges( + document: TextDocument, + fnToRefactor: FnToRefactor, + changes: AgentChange[] + ): string { + const relevantChanges = changes.filter( + (change) => path.basename(change.file) === path.basename(document.fileName) + ); + const usesCrlf = fnToRefactor.body.includes('\r\n'); + const patched = relevantChanges.reduce( + (code, change) => AgentRefactoringService.applyChange(code, change), + toLf(fnToRefactor.body) + ); + return usesCrlf ? patched.replace(/\n/g, '\r\n') : patched; + } + + private static mapConfidence(output: AgentOutput): Confidence { + const levelMap: Record = { + high: 3, + medium: 2, + low: 1, + }; + + let title: string; + let description: string; + + switch (output.fix_result) { + case 'fix_proposed': + title = output.summary; + description = 'Review the suggested changes and apply if appropriate.'; + break; + case 'unable_to_fix': + title = 'Unable to refactor'; + description = 'The agent could not find a suitable refactoring for this code.'; + break; + case 'needs_human_review': + title = 'Needs review'; + description = 'The suggested changes require careful human review before applying.'; + break; + } + + return { + level: levelMap[output.confidence] ?? 2, + title, + 'review-header': output.fix_result === 'fix_proposed' ? undefined : 'Review required', + 'recommended-action': { + description, + details: output.reasoning, + }, + }; + } + + private static mapReasons(output: AgentOutput): Reason[] { + if (output.fix_result === 'fix_proposed') { + return []; + } + + return [ + { + summary: output.summary, + details: undefined, + }, + ]; + } + + private static cleanup(ioDir: string): void { + try { + if (fs.existsSync(ioDir)) { + fs.rmSync(ioDir, { recursive: true }); + } + } catch (err) { + logOutputChannel.warn(`Failed to cleanup directory ${ioDir}: ${err}`); + } + } +} diff --git a/src/refactoring/agent-token.ts b/src/refactoring/agent-token.ts new file mode 100644 index 00000000..1e2a77be --- /dev/null +++ b/src/refactoring/agent-token.ts @@ -0,0 +1,18 @@ +import { tokenFromResponse } from '../auth/auth-token'; +import { aceToken, DevtoolsAPI } from '../devtools-api'; +import { logOutputChannel } from '../log'; +import { redactSecrets } from '../log-redaction'; + +async function fetchIdeToken(): Promise { + try { + return tokenFromResponse(await DevtoolsAPI.authToken()); + } catch (error) { + const detail = redactSecrets(error instanceof Error ? error.message : String(error)); + logOutputChannel.debug(`No IDE sign-in token available: ${detail}`); + return undefined; + } +} + +export async function resolveAgentToken(): Promise { + return aceToken() ?? (await fetchIdeToken()); +} diff --git a/src/refactoring/agent-types.ts b/src/refactoring/agent-types.ts new file mode 100644 index 00000000..be3105b9 --- /dev/null +++ b/src/refactoring/agent-types.ts @@ -0,0 +1,76 @@ +export interface AgentInputContext { + '@vocab': string; +} + +export interface AgentInput { + '@context': AgentInputContext; + task_id: string; + file: string; + line: number; + smell: string; +} + +export interface AgentOutputContext { + '@vocab': string; +} + +export type FixResult = 'fix_proposed' | 'unable_to_fix' | 'needs_human_review'; +export type ConfidenceLevel = 'high' | 'medium' | 'low'; +export type ChangeType = 'partial' | 'whole_file'; + +export interface AgentReplacement { + search: string; + replace: string; +} + +export interface AgentChange { + file: string; + change_type: ChangeType; + description: string; + replacements?: AgentReplacement[]; + whole_file_content?: string; +} + +export interface AgentOutput { + '@context': AgentOutputContext; + schema_version: string; + task_id: string; + fix_result: FixResult; + confidence: ConfidenceLevel; + summary: string; + reasoning: string; + changes: AgentChange[]; + generated_at: string; +} + +export interface BedrockProviderOptions { + options: { + profile?: string; + region?: string; + }; +} + +export interface BedrockOpencodeProvider { + 'amazon-bedrock'?: BedrockProviderOptions; +} + +export interface ProviderCredentials { + anthropic_api_key?: string; + openai_api_key?: string; + google_api_key?: string; +} + +export interface TrackingConfig { + environment?: string; +} + +export interface AgentConfig { + codescene_access_token: string; + plugins: string[]; + io_json_dir?: string; + provider?: ProviderCredentials; + opencode_config?: { + provider: BedrockOpencodeProvider; + }; + tracking?: TrackingConfig; +} diff --git a/src/refactoring/commands.ts b/src/refactoring/commands.ts index ca8cb0d8..19f76231 100644 --- a/src/refactoring/commands.ts +++ b/src/refactoring/commands.ts @@ -3,6 +3,7 @@ import { CsExtensionState } from '../cs-extension-state'; import { FnToRefactor } from '../devtools-api/refactor-models'; import Telemetry from '../telemetry'; import { RefactoringRequest } from './request'; +import { ensureSavedForRefactoring } from './unsaved-changes'; import { createTempDocument, decorateCode, findFnToRefactor, selectCode, targetEditor } from './utils'; import { reportError } from '../utils'; import { CodeSceneCWFAceTabPanel } from '../codescene-tab/webview/ace/cwf-webview-ace-panel'; @@ -54,6 +55,8 @@ export class CsRefactoringCommands implements vscode.Disposable { return; } + if (!(await ensureSavedForRefactoring(document))) return; + if (!CsExtensionState.acknowledgedAceUsage) { Telemetry.logUsage('ace-info/presented', { source }); CodeSceneCWFAceAcknowledgementTabPanel.show(new RefactoringRequest(toRefactor, document, skipCache)); diff --git a/src/refactoring/connectivity.ts b/src/refactoring/connectivity.ts new file mode 100644 index 00000000..26678d08 --- /dev/null +++ b/src/refactoring/connectivity.ts @@ -0,0 +1,99 @@ +import { spawn } from 'child_process'; +import * as path from 'path'; +import vscode from 'vscode'; +import { getExtensionPath } from '../cs-extension-state'; +import { aceToken } from '../devtools-api'; +import { buildAgentConfigWithToken } from './agent-config'; +import { getAgentModel } from '../configuration'; +import { logOutputChannel } from '../log'; + +export interface ConnectivityResult { + success: boolean; + error?: string; +} + +export interface ConnectivityOptions { + signal?: AbortSignal; + timeoutMs?: number; +} + +function getAgentBinaryPath(): string { + const extensionPath = getExtensionPath(); + if (!extensionPath) { + throw new Error('Extension path not available'); + } + const binaryName = process.platform === 'win32' ? 'cs-agent.exe' : 'cs-agent'; + return path.join(extensionPath, 'bin', binaryName); +} + +const DEFAULT_TIMEOUT_MS = 60000; + +export async function testConnectivity(options?: ConnectivityOptions): Promise { + const binaryPath = getAgentBinaryPath(); + const config = buildAgentConfigWithToken(aceToken()); + const configJson = JSON.stringify(config); + const model = getAgentModel(); + + const args = ['run', 'skill:check-connectivity', '--model', model]; + const env: NodeJS.ProcessEnv = { + ...process.env, + CS_AGENT_CONFIG: configJson, + }; + + logOutputChannel.debug(`Testing connectivity: ${binaryPath} ${args.join(' ')}`); + + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + return new Promise((resolve) => { + const proc = spawn(binaryPath, args, { cwd: workspaceFolder, env, stdio: ['ignore', 'pipe', 'pipe'] }); + let stderr = ''; + let resolved = false; + + const resolveOnce = (result: ConnectivityResult) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + options?.signal?.removeEventListener('abort', abortHandler); + resolve(result); + }; + + const timeoutId = setTimeout(() => { + proc.kill('SIGTERM'); + logOutputChannel.warn('Connectivity test timed out'); + resolveOnce({ success: false, error: 'Connectivity test timed out' }); + }, timeoutMs); + + const abortHandler = () => { + proc.kill('SIGTERM'); + logOutputChannel.info('Connectivity test cancelled'); + resolveOnce({ success: false, error: 'Connectivity test cancelled' }); + }; + + options?.signal?.addEventListener('abort', abortHandler); + + proc.stdout?.on('data', (data: Buffer) => { + logOutputChannel.debug(`Connectivity stdout: ${data.toString().trim()}`); + }); + + proc.stderr?.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + proc.on('error', (err) => { + logOutputChannel.error(`Connectivity test spawn error: ${err.message}`); + resolveOnce({ success: false, error: err.message }); + }); + + proc.on('close', (code) => { + if (code === 0) { + logOutputChannel.info('Connectivity test successful'); + resolveOnce({ success: true }); + } else { + const errorMsg = stderr.trim() || `Process exited with code ${code}`; + logOutputChannel.error(`Connectivity test failed: ${errorMsg}`); + resolveOnce({ success: false, error: errorMsg }); + } + }); + }); +} diff --git a/src/refactoring/fn-to-refactor.ts b/src/refactoring/fn-to-refactor.ts new file mode 100644 index 00000000..e280db90 --- /dev/null +++ b/src/refactoring/fn-to-refactor.ts @@ -0,0 +1,57 @@ +import { basename, extname } from 'path'; +import vscode from 'vscode'; +import { ChangeType, FunctionFinding } from '../devtools-api/delta-model'; +import { FnToRefactor, RefactoringTarget } from '../devtools-api/refactor-models'; +import { CodeSmell, Range, Review } from '../devtools-api/review-model'; +import { findFunctionForCodeSmell, vscodeRange } from '../review/utils'; +import { isDefined } from '../utils'; + +function wholeLinesRange(document: vscode.TextDocument, startLine: number, endLine: number): Range { + const lastLine = Math.max(startLine, Math.min(endLine, document.lineCount)); + return { + 'start-line': startLine, + 'start-column': 1, + 'end-line': lastLine, + 'end-column': document.lineAt(lastLine - 1).text.length + 1, + }; +} + +function buildFnToRefactor( + document: vscode.TextDocument, + name: string, + range: Range, + targets: RefactoringTarget[] +): FnToRefactor { + const fnRange = vscodeRange(range)!; + return { + name, + body: document.getText(fnRange), + 'file-type': extname(document.fileName).slice(1), + range, + 'refactoring-targets': targets, + vscodeRange: fnRange, + }; +} + +export function fnToRefactorForCodeSmell( + document: vscode.TextDocument, + codeSmell: CodeSmell, + review?: Review +): FnToRefactor { + const target = { category: codeSmell.category, line: codeSmell['highlight-range']['start-line'] }; + const fn = review && findFunctionForCodeSmell(review, codeSmell); + if (fn) { + return buildFnToRefactor(document, fn.function, wholeLinesRange(document, fn.range['start-line'], fn.range['end-line']), [target]); + } + return buildFnToRefactor(document, basename(document.fileName), wholeLinesRange(document, 1, document.lineCount), [target]); +} + +export function fnToRefactorForFinding(document: vscode.TextDocument, finding: FunctionFinding): FnToRefactor | undefined { + const { name, range } = finding.function; + if (!range) return; + const targets = finding['change-details'] + .filter((detail) => detail['change-type'] !== ChangeType.Fixed && isDefined(detail.line)) + .map((detail) => ({ category: detail.category, line: detail.line! })); + if (targets.length === 0) return; + return buildFnToRefactor(document, name, wholeLinesRange(document, range['start-line'], range['end-line']), targets); +} diff --git a/src/refactoring/index.ts b/src/refactoring/index.ts index 950d5734..d5508061 100644 --- a/src/refactoring/index.ts +++ b/src/refactoring/index.ts @@ -1,42 +1,51 @@ import vscode from 'vscode'; -import { getConfiguration } from '../configuration'; +import { getConfiguration, onDidChangeConfiguration } from '../configuration'; import { CsExtensionState } from '../cs-extension-state'; -import { DevtoolsAPI } from '../devtools-api'; +import { aceUsable, DevtoolsAPI } from '../devtools-api'; import { logOutputChannel } from '../log'; import { CsRefactoringCommands } from './commands'; import { createTmpDiffUriScheme } from './utils'; /** - * Initialize commands and diff scheme and try to enable ACE by requesting a preflight from the - * Devtools API. + * Initialize commands and diff scheme and enable ACE. * Provides the codescene.ace.setEnabled command to enable/disable ACE, which respects * the enableAutoRefactor configuration setting. * * @param context */ +export const ACE_USABLE_CONTEXT_KEY = 'codescene.aceUsable'; + +function updateAceUsableContext() { + void vscode.commands.executeCommand('setContext', ACE_USABLE_CONTEXT_KEY, aceUsable()); +} + export function initAce(context: vscode.ExtensionContext) { context.subscriptions.push( + CsExtensionState.onAceStateChanged(updateAceUsableContext), + CsExtensionState.onAuthChanged(updateAceUsableContext), + onDidChangeConfiguration('authToken', updateAceUsableContext), vscode.commands.registerCommand('codescene.ace.setEnabled', (enable = true) => { - enable ? void enableAce() : disableAce(); + enable ? enableAce() : disableAce(); }), new CsRefactoringCommands(), createTmpDiffUriScheme(), - DevtoolsAPI.onDidChangePreflightState((event) => { + DevtoolsAPI.onDidChangeAceState((event) => { CsExtensionState.setACEState(event); }) ); - void enableAce(); + enableAce(); + updateAceUsableContext(); } -async function enableAce() { +function enableAce() { const enableACE = getConfiguration('enableAutoRefactor'); if (!enableACE) { DevtoolsAPI.disableAce(); return; } - const preflight = await DevtoolsAPI.preflight(); - if (preflight) logOutputChannel.info('ACE enabled!'); + DevtoolsAPI.enableAce(); + logOutputChannel.info('ACE enabled!'); } function disableAce() { diff --git a/src/refactoring/request.ts b/src/refactoring/request.ts index c91b669b..97167ac5 100644 --- a/src/refactoring/request.ts +++ b/src/refactoring/request.ts @@ -3,18 +3,20 @@ import { TextDocument } from 'vscode'; import { DevtoolsAPI, logIdString } from '../devtools-api'; import { FnToRefactor, RefactorResponse } from '../devtools-api/refactor-models'; import { logOutputChannel } from '../log'; +import { ProgressCallback } from './agent-service'; export class RefactoringRequest { readonly traceId: string; promise: Promise; private abortController = new AbortController(); readonly signal = this.abortController.signal; + onProgress?: ProgressCallback; constructor(readonly fnToRefactor: FnToRefactor, readonly document: TextDocument, readonly skipCache: boolean = false) { this.document = document; this.fnToRefactor = fnToRefactor; this.traceId = uuidv4(); - this.promise = DevtoolsAPI.postRefactoring(this); + this.promise = DevtoolsAPI.postRefactoring(this, (message) => this.onProgress?.(message)); } get eventData() { diff --git a/src/refactoring/unsaved-changes.ts b/src/refactoring/unsaved-changes.ts new file mode 100644 index 00000000..13677158 --- /dev/null +++ b/src/refactoring/unsaved-changes.ts @@ -0,0 +1,17 @@ +import path from 'path'; +import vscode from 'vscode'; + +export const SAVE_AND_REFACTOR = 'Save and refactor'; + +export async function ensureSavedForRefactoring(document: vscode.TextDocument): Promise { + if (!document.isDirty) return true; + + const choice = await vscode.window.showWarningMessage( + `Save ${path.basename(document.fileName)} before refactoring? The refactoring agent reads the file from disk.`, + { modal: true }, + SAVE_AND_REFACTOR + ); + if (choice !== SAVE_AND_REFACTOR) return false; + + return document.save(); +} diff --git a/src/refactoring/utils.ts b/src/refactoring/utils.ts index 1db017be..e5a26358 100644 --- a/src/refactoring/utils.ts +++ b/src/refactoring/utils.ts @@ -11,8 +11,10 @@ import vscode, { import { FnToRefactor, RefactorResponse } from '../devtools-api/refactor-models'; import { isDefined } from '../utils'; import { RefactoringRequest } from './request'; -import { fnsToRefactorCache } from '../devtools-api/fns-to-refactor-cache'; import { CodeSmell } from '../devtools-api/review-model'; +import { DevtoolsAPI } from '../devtools-api'; +import Reviewer from '../review/reviewer'; +import { fnToRefactorForCodeSmell } from './fn-to-refactor'; function singleLineCommentSeparator(languageId: string) { switch (languageId) { @@ -159,8 +161,8 @@ export function isFunctionUnchangedInDocument( } export async function findFnToRefactor(document: vscode.TextDocument | undefined, codeSmell: CodeSmell | undefined) { - if (document && codeSmell) { - const fn = await fnsToRefactorCache.fnsToRefactor(document, codeSmell); - return fn; - } + if (!DevtoolsAPI.aceEnabled()) return; + if (!document || !codeSmell) return; + const reviewResult = await Reviewer.instance?.reviewCache.get(document, 'any')?.review.reviewResult; + return fnToRefactorForCodeSmell(document, codeSmell, reviewResult || undefined); } diff --git a/src/review/codeaction.ts b/src/review/codeaction.ts index 15544f72..f4aabcc6 100644 --- a/src/review/codeaction.ts +++ b/src/review/codeaction.ts @@ -6,8 +6,8 @@ import { toDocsParamsRanged } from '../documentation/commands'; import { reviewDocumentSelector } from '../language-support'; import { isDefined } from '../utils'; import Reviewer from './reviewer'; -import { aceAvailable } from '../devtools-api'; -import { fnsToRefactorCache } from '../devtools-api/fns-to-refactor-cache'; +import { aceUsable } from '../devtools-api'; +import { findFnToRefactor } from '../refactoring/utils'; import { buildInsertText, getLineIndentation } from '../utils/codeaction-utils'; export function register(context: vscode.ExtensionContext) { @@ -75,31 +75,22 @@ export class ReviewCodeActionProvider implements vscode.CodeActionProvider, vsco codeSmells: any[], actions: vscode.CodeAction[] ) { - if (!ACE_ENABLED) { + if (!ACE_ENABLED || !aceUsable()) { return; } - const aceUsable = aceAvailable(); - const fnsToRefactor = await Promise.all( - codeSmells.map((codeSmell) => fnsToRefactorCache.fnsToRefactor(document, codeSmell)) - ); - const fnToRefactor = fnsToRefactor.find(isDefined); + const fnToRefactor = await findFnToRefactor(document, codeSmells[0]); if (!fnToRefactor) return; const refactorHighligting = new vscode.Diagnostic(fnToRefactor.vscodeRange, 'Function to refactor'); - const refactorAction = new vscode.CodeAction('Refactor using CodeScene ACE', vscode.CodeActionKind.QuickFix); + const refactorAction = new vscode.CodeAction('Refactor using CodeScene Agent', vscode.CodeActionKind.QuickFix); refactorAction.diagnostics = [refactorHighligting]; refactorAction.command = { command: 'codescene.requestAndPresentRefactoring', - title: 'Refactor using CodeScene ACE', + title: 'Refactor using CodeScene Agent', arguments: [document, 'codeaction', fnToRefactor], }; - refactorAction.disabled = !aceUsable - ? { - reason: 'Refactoring is not available. Please verify your authentication token in Workspace settings.', - } - : undefined; actions.push(refactorAction); } @@ -113,10 +104,7 @@ export class ReviewCodeActionProvider implements vscode.CodeActionProvider, vsco const reviewResultRaw = await reviewCacheItem.review.reviewResult; const reviewResult: Review | undefined = reviewResultRaw && typeof reviewResultRaw === 'object' ? reviewResultRaw : undefined; - const fnsToRefactor = await Promise.all( - codeSmells.map((codeSmell) => fnsToRefactorCache.fnsToRefactor(document, codeSmell)) - ); - const fnToRefactor = fnsToRefactor.find(isDefined); + const fnToRefactor = await findFnToRefactor(document, codeSmells[0]); codeSmells.forEach((codeSmell) => { const { category } = codeSmell; diff --git a/src/review/codelens.ts b/src/review/codelens.ts index 3fe477bd..5418cd44 100644 --- a/src/review/codelens.ts +++ b/src/review/codelens.ts @@ -4,7 +4,7 @@ import { onDidChangeConfiguration, reviewCodeLensesEnabled } from '../configurat import { DevtoolsAPI } from '../devtools-api'; import { Delta } from '../devtools-api/delta-model'; import { Review } from '../devtools-api/review-model'; -import { fnsToRefactorCache } from '../devtools-api/fns-to-refactor-cache'; +import { findFnToRefactor } from '../refactoring/utils'; import { FnToRefactor } from '../devtools-api/refactor-models'; import { CsDiagnostic } from '../diagnostics/cs-diagnostic'; import { toDocsParamsRanged } from '../documentation/commands'; @@ -52,9 +52,6 @@ implements vscode.CodeLensProvider, vscode.Disposa onDidChangeConfiguration('enableReviewCodeLenses', () => this.changeCodeLensesEmitter.fire()), DevtoolsAPI.onDidReviewComplete(() => this.changeCodeLensesEmitter.fire()), DevtoolsAPI.onDidDeltaAnalysisComplete(() => this.changeCodeLensesEmitter.fire()), - vscode.workspace.onDidChangeTextDocument((event) => { - fnsToRefactorCache.invalidateForDocument(event.document); - }), vscode.workspace.onDidCloseTextDocument((document) => { this.cleanupCachesForDocument(document); }) @@ -76,8 +73,6 @@ implements vscode.CodeLensProvider, vscode.Disposa this.commandDisposables.delete(key); this.commandCache.delete(key); }); - - fnsToRefactorCache.cleanupForDocument(document); } dispose() { @@ -148,7 +143,7 @@ implements vscode.CodeLensProvider, vscode.Disposa private async openInteractiveDocsCommand(codeLens: CsCodeLens, document: vscode.TextDocument) { const { codeSmell, range, cacheKey } = codeLens; - const fnToRefactor = await fnsToRefactorCache.fnsToRefactor(document, codeSmell); + const fnToRefactor = await findFnToRefactor(document, codeSmell); let cached = this.commandCache.get(cacheKey); diff --git a/src/test/fixtures/agent-output/fix-proposed.json b/src/test/fixtures/agent-output/fix-proposed.json new file mode 100644 index 00000000..e17f5639 --- /dev/null +++ b/src/test/fixtures/agent-output/fix-proposed.json @@ -0,0 +1,23 @@ +{ + "@context": { "@vocab": "https://codescene.io/schemas/code-health-fix#" }, + "schema_version": "1.0", + "task_id": "refactor-test-123", + "fix_result": "fix_proposed", + "confidence": "high", + "summary": "Refactoring suggestion", + "reasoning": "The refactoring improves code health.", + "changes": [ + { + "file": "test.ts", + "change_type": "partial", + "description": "Extract deeply nested condition to helper", + "replacements": [ + { + "search": "if (a > 0) {\n if (a > 1) {\n return a;\n }\n }", + "replace": "return processValue(a);" + } + ] + } + ], + "generated_at": "2026-08-31T12:00:00Z" +} diff --git a/src/test/fixtures/agent-output/needs-human-review.json b/src/test/fixtures/agent-output/needs-human-review.json new file mode 100644 index 00000000..bfd32294 --- /dev/null +++ b/src/test/fixtures/agent-output/needs-human-review.json @@ -0,0 +1,23 @@ +{ + "@context": { "@vocab": "https://codescene.io/schemas/code-health-fix#" }, + "schema_version": "1.0", + "task_id": "refactor-test-789", + "fix_result": "needs_human_review", + "confidence": "medium", + "summary": "Refactoring results", + "reasoning": "The code needs to be inspected and verified carefully.", + "changes": [ + { + "file": "test.ts", + "change_type": "partial", + "description": "Simplify error handling", + "replacements": [ + { + "search": "try {\n doSomething();\n } catch (e) {\n handleError(e);\n }", + "replace": "await doSomethingSafe();" + } + ] + } + ], + "generated_at": "2026-08-31T12:00:00Z" +} diff --git a/src/test/fixtures/agent-output/unable-to-fix.json b/src/test/fixtures/agent-output/unable-to-fix.json new file mode 100644 index 00000000..f995b173 --- /dev/null +++ b/src/test/fixtures/agent-output/unable-to-fix.json @@ -0,0 +1,11 @@ +{ + "@context": { "@vocab": "https://codescene.io/schemas/code-health-fix#" }, + "schema_version": "1.0", + "task_id": "refactor-test-456", + "fix_result": "unable_to_fix", + "confidence": "low", + "summary": "Code improvement guide", + "reasoning": "CodeScene Refactoring Agent was unable to generate a refactoring recommendation. Please refer to the manual steps for improving your code.", + "changes": [], + "generated_at": "2026-08-31T12:00:00Z" +} diff --git a/src/test/fixtures/ide-server-fixture.ts b/src/test/fixtures/ide-server-fixture.ts index 5708e126..f74580b9 100644 --- a/src/test/fixtures/ide-server-fixture.ts +++ b/src/test/fixtures/ide-server-fixture.ts @@ -19,57 +19,6 @@ connection.onRequest('cs-ide/delta', () => ({ oldGitBlobSha: 'old-sha', newGitBlobSha: 'new-sha', })); -connection.onRequest('cs-ide/preflight', () => ({ - version: 2, - fileTypes: ['ts'], - languageCommon: { maxInputLoc: 100, codeSmells: ['Complex Method'] }, -})); -connection.onRequest('cs-ide/fns-to-refactor', (params: { 'file-name'?: string; fileName?: string }) => { - const fileName = params && typeof params === 'object' && !Array.isArray(params) - ? params['file-name'] ?? params.fileName - : undefined; - if (!fileName) { - throw new Error('Missing file-name'); - } - return [{ - body: 'function f() {}', - name: 'f', - fileType: 'TypeScript', - functionType: 'Function', - nippyB64: 'encoded', - range: { startLine: 1, startColumn: 1, endLine: 1, endColumn: 16 }, - refactoringTargets: [{ category: 'Complex Method', line: 1 }], - }]; -}); -connection.onRequest('cs-ide/refactor', (params, token) => { - if (params?.token === 'unauthorized') { - throw new ResponseError(-32603, 'Request failed [401]'); - } - if (params?.token === 'hang') { - return new Promise((resolve, reject) => { - const cancel = () => reject(new ResponseError(-32800, 'Request cancelled')); - if (token.isCancellationRequested) { - cancel(); - return; - } - token.onCancellationRequested(cancel); - }); - } - return { - code: 'function f() {}', - confidence: { - level: 1, - title: 'High confidence', - recommendedAction: { description: 'Apply', details: 'Safe change' }, - reviewHeader: 'Review', - }, - metadata: { 'cached?': false }, - reasons: [], - refactoringProperties: { addedCodeSmells: [], removedCodeSmells: ['Complex Method'] }, - traceId: 'trace-1', - creditsInfo: { limit: 10, used: 1 }, - }; -}); connection.onRequest('cs-ide/telemetry', (params) => ({ status: 202, params })); connection.onRequest('cs-ide/device-id', () => ({ deviceId: 'device-42' })); connection.onRequest('cs-ide/code-health-rules-template', () => ({ template: '{"rule_sets":[]}' })); @@ -205,6 +154,7 @@ const signedOutStatus = { error: null, }; connection.onRequest('cs-ide/auth-status', () => signedOutStatus); +connection.onRequest('cs-ide/auth-token', () => ({ 'credential-source': 'oauth', 'access-token': 'fixture-access-token' })); connection.onRequest('cs-ide/auth-configure', () => signedOutStatus); connection.onRequest('cs-ide/auth-login', () => { throw new ResponseError(-32803, 'This CodeScene server does not support browser sign-in.', { code: 'auth/oauth-unsupported' }); diff --git a/src/test/suite/ace-acknowledgement-mapper.test.ts b/src/test/suite/ace-acknowledgement-mapper.test.ts index 1ebd0785..66fb8adb 100644 --- a/src/test/suite/ace-acknowledgement-mapper.test.ts +++ b/src/test/suite/ace-acknowledgement-mapper.test.ts @@ -118,7 +118,16 @@ aceSuite('AceAcknowledgementMapper Test Suite', () => { cloud: disabledWith(authStatusFixture('signed-in-oauth')), onpremPat: disabledWith(authStatusFixture('signed-in-setting-token-onprem')), }, - { cloud: false, onpremPat: true } + { cloud: false, onpremPat: false } ); }); + + test('visible requires an authenticated user', () => { + mockAcknowledged(true); + const visibleWith = (token: string) => { + mockToken(token); + return getAutoRefactorConfig().visible; + }; + assert.deepStrictEqual({ authenticated: visibleWith('secret'), unauthenticated: visibleWith('') }, { authenticated: true, unauthenticated: false }); + }); }); diff --git a/src/test/suite/ace-credential.test.ts b/src/test/suite/ace-credential.test.ts index f1024116..52aec7b3 100644 --- a/src/test/suite/ace-credential.test.ts +++ b/src/test/suite/ace-credential.test.ts @@ -18,7 +18,9 @@ suite('isAceAvailable', () => { expected: false, }, { name: 'no token, signed in with an ACE credential', token: undefined, auth: SIGNED_IN_CLOUD, expected: true }, - { name: 'no token, signed in without an ACE credential', token: undefined, auth: SIGNED_IN_ONPREM_PAT, expected: false }, + { name: 'no token, signed in without an ACE credential', token: undefined, auth: SIGNED_IN_ONPREM_PAT, expected: true }, + { name: 'no token, signing in', token: undefined, auth: { active: true, status: authStatusFixture('signing-in') }, expected: false }, + { name: 'no token, signed out', token: undefined, auth: { active: true, status: authStatusFixture('signed-out') }, expected: false }, { name: 'a token while signed out', token: 'cloud-ace-1', auth: { active: true, status: authStatusFixture('signed-out') }, expected: true }, ]; @@ -39,7 +41,7 @@ suite('refactorToken', () => { }); test('throws when ACE is unavailable', () => { - assert.throws(() => refactorToken(undefined, SIGNED_IN_ONPREM_PAT), MissingAuthTokenError); + assert.throws(() => refactorToken(undefined, INACTIVE), MissingAuthTokenError); }); }); diff --git a/src/test/suite/agent-config.test.ts b/src/test/suite/agent-config.test.ts new file mode 100644 index 00000000..e20cddb0 --- /dev/null +++ b/src/test/suite/agent-config.test.ts @@ -0,0 +1,326 @@ +import * as assert from 'assert'; +import { + buildAgentConfigWithToken, + buildOpencodeConfig, + buildProviderCredentials, +} from '../../refactoring/agent-config'; +import { getAgentModel } from '../../configuration'; +import { mockConfiguration, restoreDefaultConfiguration } from '../setup'; +import { aceSuite } from '../ace-test-suite'; + +const testCases = [ + { + name: 'buildAgentConfigWithToken includes required fields without providerOptions', + config: {}, + token: 'test-token-123', + ioDir: undefined, + assertions: (result: any) => { + assert.strictEqual(result.codescene_access_token, 'test-token-123'); + assert.deepStrictEqual(result.plugins, ['render-code-fix-reporter']); + assert.deepStrictEqual(result.tracking, { environment: 'codescene-vscode' }); + assert.strictEqual(result.io_json_dir, undefined, 'io_json_dir should not be present when not provided'); + assert.strictEqual(result.provider, undefined, 'provider should not be present when no API keys'); + assert.strictEqual(result.opencode_config, undefined, 'opencode_config should not be present when no bedrock options'); + }, + }, + { + name: 'buildAgentConfigWithToken includes io_json_dir when provided', + config: {}, + token: 'test-token-456', + ioDir: '/tmp/cs-agent-io-test', + assertions: (result: any) => { + assert.strictEqual(result.codescene_access_token, 'test-token-456'); + assert.deepStrictEqual(result.plugins, ['render-code-fix-reporter']); + assert.strictEqual(result.io_json_dir, '/tmp/cs-agent-io-test', 'io_json_dir should match provided value'); + }, + }, + { + name: 'buildAgentConfigWithToken builds opencode_config from bedrock providerOptions', + config: { + providerOptions: { + 'amazon-bedrock:profile': 'my-profile', + 'amazon-bedrock:region': 'us-east-1', + }, + }, + token: 'test-token-789', + ioDir: undefined, + assertions: (result: any) => { + assert.strictEqual(result.provider, undefined, 'provider should not be present when no API keys'); + assert.ok(result.opencode_config, 'opencode_config should be present'); + assert.ok(result.opencode_config.provider['amazon-bedrock'], 'amazon-bedrock provider should be present'); + assert.strictEqual(result.opencode_config.provider['amazon-bedrock'].options.profile, 'my-profile'); + assert.strictEqual(result.opencode_config.provider['amazon-bedrock'].options.region, 'us-east-1'); + }, + }, + { + name: 'buildAgentConfigWithToken handles partial bedrock providerOptions', + config: { + providerOptions: { + 'amazon-bedrock:profile': 'only-profile', + }, + }, + token: 'test-token-partial', + ioDir: undefined, + assertions: (result: any) => { + assert.strictEqual(result.provider, undefined, 'provider should not be present when no API keys'); + assert.ok(result.opencode_config, 'opencode_config should be present'); + assert.ok(result.opencode_config.provider['amazon-bedrock'], 'amazon-bedrock provider should be present'); + assert.strictEqual(result.opencode_config.provider['amazon-bedrock'].options.profile, 'only-profile'); + assert.strictEqual(result.opencode_config.provider['amazon-bedrock'].options.region, undefined); + }, + }, + { + name: 'buildAgentConfigWithToken produces exact bedrock structure', + config: { + providerOptions: { + 'amazon-bedrock:profile': 'codescene-dev', + 'amazon-bedrock:region': 'eu-west-1', + }, + }, + token: 'test-token-exact', + ioDir: undefined, + assertions: (result: any) => { + assert.strictEqual(result.provider, undefined, 'provider should not be present when no API keys'); + const expectedOpencodeConfig = { + provider: { + 'amazon-bedrock': { + options: { + profile: 'codescene-dev', + region: 'eu-west-1', + }, + }, + }, + }; + assert.deepStrictEqual(result.opencode_config, expectedOpencodeConfig); + }, + }, + { + name: 'buildAgentConfigWithToken builds provider with Anthropic API key', + config: { + providerOptions: { + 'anthropic:api-key': 'sk-ant-test-key-123', + }, + }, + token: 'test-token-anthropic', + ioDir: undefined, + assertions: (result: any) => { + assert.ok(result.provider, 'provider should be present'); + assert.strictEqual(result.provider.anthropic_api_key, 'sk-ant-test-key-123'); + assert.strictEqual(result.opencode_config, undefined, 'opencode_config should not be present when no bedrock options'); + }, + }, + { + name: 'buildAgentConfigWithToken builds provider with OpenAI API key', + config: { + providerOptions: { + 'openai:api-key': 'sk-openai-test-key-456', + }, + }, + token: 'test-token-openai', + ioDir: undefined, + assertions: (result: any) => { + assert.ok(result.provider, 'provider should be present'); + assert.strictEqual(result.provider.openai_api_key, 'sk-openai-test-key-456'); + assert.strictEqual(result.opencode_config, undefined, 'opencode_config should not be present when no bedrock options'); + }, + }, + { + name: 'buildAgentConfigWithToken builds provider with Google API key', + config: { + providerOptions: { + 'google:api-key': 'google-test-key-789', + }, + }, + token: 'test-token-google', + ioDir: undefined, + assertions: (result: any) => { + assert.ok(result.provider, 'provider should be present'); + assert.strictEqual(result.provider.google_api_key, 'google-test-key-789'); + assert.strictEqual(result.opencode_config, undefined, 'opencode_config should not be present when no bedrock options'); + }, + }, + { + name: 'buildAgentConfigWithToken combines multiple provider options', + config: { + providerOptions: { + 'amazon-bedrock:profile': 'my-profile', + 'anthropic:api-key': 'sk-ant-key', + 'openai:api-key': 'sk-openai-key', + 'google:api-key': 'google-key', + }, + }, + token: 'test-token-multi', + ioDir: undefined, + assertions: (result: any) => { + assert.ok(result.provider, 'provider should be present'); + assert.strictEqual(result.provider.anthropic_api_key, 'sk-ant-key'); + assert.strictEqual(result.provider.openai_api_key, 'sk-openai-key'); + assert.strictEqual(result.provider.google_api_key, 'google-key'); + assert.ok(result.opencode_config, 'opencode_config should be present for bedrock'); + assert.strictEqual(result.opencode_config.provider['amazon-bedrock'].options.profile, 'my-profile'); + }, + }, +]; + +aceSuite('AgentConfig Test Suite', () => { + teardown(() => { + restoreDefaultConfiguration(); + }); + + for (const tc of testCases) { + test(tc.name, () => { + mockConfiguration('codescene', tc.config); + const result = buildAgentConfigWithToken(tc.token, tc.ioDir); + tc.assertions(result); + }); + } + + test('buildAgentConfigWithToken falls back to an empty token when none is available', () => { + mockConfiguration('codescene', {}); + assert.strictEqual(buildAgentConfigWithToken(undefined).codescene_access_token, ''); + }); + + test('buildOpencodeConfig produces exact bedrock structure', () => { + const opts = { + 'amazon-bedrock:profile': 'codescene-dev', + 'amazon-bedrock:region': 'eu-west-1', + }; + const result = buildOpencodeConfig(opts); + const expected = { + provider: { + 'amazon-bedrock': { + options: { + profile: 'codescene-dev', + region: 'eu-west-1', + }, + }, + }, + }; + assert.deepStrictEqual(result, expected); + }); + + test('buildOpencodeConfig returns undefined when no bedrock options', () => { + assert.strictEqual(buildOpencodeConfig({}), undefined); + }); + + test('buildOpencodeConfig returns undefined for API keys (not bedrock)', () => { + assert.strictEqual(buildOpencodeConfig({ 'openai:api-key': 'sk-test' }), undefined); + assert.strictEqual(buildOpencodeConfig({ 'anthropic:api-key': 'sk-ant-test' }), undefined); + assert.strictEqual(buildOpencodeConfig({ 'google:api-key': 'google-test' }), undefined); + }); + + test('buildOpencodeConfig handles partial bedrock options', () => { + const result = buildOpencodeConfig({ 'amazon-bedrock:region': 'us-west-2' }); + assert.deepStrictEqual(result, { + provider: { + 'amazon-bedrock': { + options: { + region: 'us-west-2', + }, + }, + }, + }); + }); + + test('buildProviderCredentials builds Anthropic credentials', () => { + const result = buildProviderCredentials({ 'anthropic:api-key': 'sk-ant-test-key' }); + assert.deepStrictEqual(result, { + anthropic_api_key: 'sk-ant-test-key', + }); + }); + + test('buildProviderCredentials builds OpenAI credentials', () => { + const result = buildProviderCredentials({ 'openai:api-key': 'sk-openai-test-key' }); + assert.deepStrictEqual(result, { + openai_api_key: 'sk-openai-test-key', + }); + }); + + test('buildProviderCredentials builds Google credentials', () => { + const result = buildProviderCredentials({ 'google:api-key': 'google-test-key' }); + assert.deepStrictEqual(result, { + google_api_key: 'google-test-key', + }); + }); + + test('buildProviderCredentials returns undefined when no API keys', () => { + assert.strictEqual(buildProviderCredentials({}), undefined); + assert.strictEqual(buildProviderCredentials({ 'amazon-bedrock:profile': 'test' }), undefined); + }); + + test('buildProviderCredentials combines all API keys', () => { + const result = buildProviderCredentials({ + 'anthropic:api-key': 'sk-ant-key', + 'openai:api-key': 'sk-openai-key', + 'google:api-key': 'google-key', + }); + assert.deepStrictEqual(result, { + anthropic_api_key: 'sk-ant-key', + openai_api_key: 'sk-openai-key', + google_api_key: 'google-key', + }); + }); + + test('getAgentModel returns override when set', () => { + mockConfiguration('codescene', { agentModelOverride: 'custom/model-override' }); + assert.strictEqual(getAgentModel(), 'custom/model-override'); + }); + + test('getAgentModel returns configured agentModel when set', () => { + mockConfiguration('codescene', { agentModel: 'anthropic/claude-opus-4' }); + assert.strictEqual(getAgentModel(), 'anthropic/claude-opus-4'); + }); + + test('getAgentModel infers OpenAI model from providerOptions', () => { + mockConfiguration('codescene', { + providerOptions: { 'openai:api-key': 'sk-test-key' }, + }); + assert.strictEqual(getAgentModel(), 'openai/gpt-4o'); + }); + + test('getAgentModel infers Anthropic model from providerOptions', () => { + mockConfiguration('codescene', { + providerOptions: { 'anthropic:api-key': 'sk-ant-test' }, + }); + assert.strictEqual(getAgentModel(), 'anthropic/claude-sonnet-4-0'); + }); + + test('getAgentModel prefers OpenAI over Anthropic when both set', () => { + mockConfiguration('codescene', { + providerOptions: { + 'openai:api-key': 'sk-openai', + 'anthropic:api-key': 'sk-ant', + }, + }); + assert.strictEqual(getAgentModel(), 'openai/gpt-4o'); + }); + + test('getAgentModel returns Bedrock default when no provider keys', () => { + mockConfiguration('codescene', { providerOptions: {} }); + assert.strictEqual(getAgentModel(), 'amazon-bedrock/eu.anthropic.claude-sonnet-4-6'); + }); + + test('getAgentModel override takes precedence over inferred model', () => { + mockConfiguration('codescene', { + agentModelOverride: 'custom/override', + providerOptions: { 'openai:api-key': 'sk-test' }, + }); + assert.strictEqual(getAgentModel(), 'custom/override'); + }); + + test('getAgentModel infers OpenAI even when agentModel has Bedrock default', () => { + mockConfiguration('codescene', { + agentModel: 'amazon-bedrock/eu.anthropic.claude-sonnet-4-6', + providerOptions: { 'openai:api-key': 'sk-test-key' }, + }); + assert.strictEqual(getAgentModel(), 'openai/gpt-4o'); + }); + + test('getAgentModel respects explicit non-default agentModel over inferred', () => { + mockConfiguration('codescene', { + agentModel: 'openai/gpt-4-turbo', + providerOptions: { 'anthropic:api-key': 'sk-ant-key' }, + }); + assert.strictEqual(getAgentModel(), 'openai/gpt-4-turbo'); + }); +}); diff --git a/src/test/suite/agent-integration.test.ts b/src/test/suite/agent-integration.test.ts new file mode 100644 index 00000000..c4d8478b --- /dev/null +++ b/src/test/suite/agent-integration.test.ts @@ -0,0 +1,265 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as vscode from 'vscode'; +import { mockWorkspaceFolders, createMockWorkspaceFolder, restoreDefaultWorkspaceFolders } from '../setup'; +import { TestTextDocument } from '../mocks/test-text-document'; +import { createTestDir } from '../integration_helper'; +import * as configModule from '../../configuration'; +import * as csExtensionState from '../../cs-extension-state'; +import { AgentRefactoringService } from '../../refactoring/agent-service'; +import { FnToRefactor } from '../../devtools-api/refactor-models'; + +import { aceSuite } from '../ace-test-suite'; + +aceSuite('Agent Integration Test Suite', () => { + const testDir = createTestDir('test-agent-integration'); + const testToken = process.env.CODESCENE_TEST_TOKEN; + let originalGetAuthToken: any; + let originalGetExtensionPath: any; + + if (!testToken) { + console.log('Skipping Agent Integration tests: CODESCENE_TEST_TOKEN environment variable not set'); + return; + } + + const projectRoot = path.join(__dirname, '..', '..', '..'); + const agentBinaryPath = path.join(projectRoot, 'bin', 'cs-agent'); + if (!fs.existsSync(agentBinaryPath)) { + console.log('Skipping Agent Integration tests: cs-agent binary not found at ' + agentBinaryPath); + return; + } + + function createTestFile(filename: string, content: string, languageId: string = 'cpp'): TestTextDocument { + const testFile = path.resolve(testDir, filename); + fs.writeFileSync(testFile, content); + return new TestTextDocument(testFile, content, languageId); + } + + setup(async function () { + this.timeout(60000); + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + fs.mkdirSync(testDir, { recursive: true }); + + const { execSync } = require('child_process'); + execSync('git init', { cwd: testDir }); + execSync('git config user.email "test@test.com"', { cwd: testDir }); + execSync('git config user.name "Test"', { cwd: testDir }); + + mockWorkspaceFolders([createMockWorkspaceFolder(testDir)]); + + originalGetAuthToken = configModule.getAuthToken; + (configModule as any).getAuthToken = () => testToken; + + originalGetExtensionPath = csExtensionState.getExtensionPath; + (csExtensionState as any).getExtensionPath = () => projectRoot; + + Object.defineProperty(csExtensionState.CsExtensionState, 'stateProperties', { + get: () => ({ + session: undefined, + features: { + analysis: { state: 'enabled' }, + ace: { state: 'enabled' }, + }, + }), + configurable: true, + }); + }); + + teardown(() => { + if (originalGetAuthToken) { + (configModule as any).getAuthToken = originalGetAuthToken; + } + + if (originalGetExtensionPath) { + (csExtensionState as any).getExtensionPath = originalGetExtensionPath; + } + + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + + restoreDefaultWorkspaceFolders(); + }); + + test('runRefactoring returns valid response with code and confidence', async function () { + this.timeout(120000); + + const complexContent = `int f(int a) { + if (a > 0) { + if (a > 1) { + if (a > 2) { + if (a > 3) { + return a; + } + } + } + } + return 0; +} +`; + + const doc = createTestFile('complex.cpp', complexContent); + + const fnToRefactor: FnToRefactor = { + 'file-type': 'cpp', + name: 'f', + body: complexContent, + 'refactoring-targets': [ + { + category: 'Deep, Nested Complexity', + line: 2, + }, + ], + range: { + 'start-line': 1, + 'start-column': 1, + 'end-line': 13, + 'end-column': 2, + }, + vscodeRange: new vscode.Range(0, 0, 12, 1), + }; + + const response = await AgentRefactoringService.runRefactoring(doc, fnToRefactor); + + const inputFilePath = path.join(testDir, 'render-code-fix-input.json'); + const outputFilePath = path.join(testDir, 'render-code-fix-output.json'); + assert.ok(!fs.existsSync(inputFilePath), 'Input JSON file should NOT be in the repo directory'); + assert.ok(!fs.existsSync(outputFilePath), 'Output JSON file should NOT be in the repo directory'); + + assert.ok(response, 'Response should be defined'); + assert.ok(response.code, 'Response should have code'); + assert.ok(response.confidence, 'Response should have confidence'); + assert.strictEqual(typeof response.confidence.level, 'number', 'Confidence level should be a number'); + assert.ok(response.confidence.title, 'Confidence should have a title'); + assert.ok(response.confidence['recommended-action'], 'Confidence should have recommended action'); + assert.ok(response['trace-id'], 'Response should have trace-id'); + assert.ok(response['refactoring-properties'], 'Response should have refactoring-properties'); + }); + + test('runRefactoring writes JSON files to temp directory', async function () { + this.timeout(120000); + + const simpleContent = `int g(int x) { + if (x > 0) { + if (x > 1) { + if (x > 2) { + return x; + } + } + } + return 0; +} +`; + + const doc = createTestFile('simple.cpp', simpleContent); + + const fnToRefactor: FnToRefactor = { + 'file-type': 'cpp', + name: 'g', + body: simpleContent, + 'refactoring-targets': [ + { + category: 'Deep, Nested Complexity', + line: 2, + }, + ], + range: { + 'start-line': 1, + 'start-column': 1, + 'end-line': 11, + 'end-column': 2, + }, + vscodeRange: new vscode.Range(0, 0, 10, 1), + }; + + for (const dir of fs.readdirSync(os.tmpdir()).filter((d) => d.startsWith('cs-agent-io-'))) { + fs.rmSync(path.join(os.tmpdir(), dir), { recursive: true }); + } + + await AgentRefactoringService.runRefactoring(doc, fnToRefactor, undefined, true); + + const tmpDirsAfter = fs.readdirSync(os.tmpdir()).filter((d) => d.startsWith('cs-agent-io-')); + assert.strictEqual(tmpDirsAfter.length, 1, 'Exactly one temp directory should exist'); + + const ioDir = path.join(os.tmpdir(), tmpDirsAfter[0]); + const inputFile = path.join(ioDir, 'render-code-fix-input.json'); + const outputFile = path.join(ioDir, 'render-code-fix-output.json'); + + assert.ok(fs.existsSync(inputFile), 'Input JSON file should exist in temp directory'); + assert.ok(fs.existsSync(outputFile), 'Output JSON file should exist in temp directory'); + + const inputContent = JSON.parse(fs.readFileSync(inputFile, 'utf-8')); + assert.ok(inputContent.task_id, 'Input file should contain task_id'); + assert.ok(inputContent.file, 'Input file should contain file path'); + assert.strictEqual(inputContent.smell, 'Deep, Nested Complexity', 'Input file should contain smell'); + + const outputContent = JSON.parse(fs.readFileSync(outputFile, 'utf-8')); + assert.ok(outputContent.fix_result, 'Output file should contain fix_result'); + assert.ok(outputContent.changes, 'Output file should contain changes'); + assert.strictEqual( + outputContent.task_id, + inputContent.task_id, + 'Output task_id should match input task_id (proves agent read from temp directory)' + ); + if (outputContent.fix_result === 'fix_proposed') { + assert.ok(outputContent.changes.length > 0, 'fix_proposed should have at least one change'); + const changedFiles = outputContent.changes.map((c: { file: string }) => c.file); + assert.ok( + changedFiles.some((f: string) => f.includes('simple.cpp')), + `Changes should reference the input file (got: ${changedFiles.join(', ')})` + ); + } + + fs.rmSync(ioDir, { recursive: true }); + }); + + test('runRefactoring cleans up temp directory after completion', async function () { + this.timeout(120000); + + const simpleContent = `int h(int x) { + if (x > 0) { + if (x > 1) { + if (x > 2) { + return x; + } + } + } + return 0; +} +`; + + const doc = createTestFile('cleanup.cpp', simpleContent); + + const fnToRefactor: FnToRefactor = { + 'file-type': 'cpp', + name: 'h', + body: simpleContent, + 'refactoring-targets': [ + { + category: 'Deep, Nested Complexity', + line: 2, + }, + ], + range: { + 'start-line': 1, + 'start-column': 1, + 'end-line': 11, + 'end-column': 2, + }, + vscodeRange: new vscode.Range(0, 0, 10, 1), + }; + + for (const dir of fs.readdirSync(os.tmpdir()).filter((d) => d.startsWith('cs-agent-io-'))) { + fs.rmSync(path.join(os.tmpdir(), dir), { recursive: true }); + } + + await AgentRefactoringService.runRefactoring(doc, fnToRefactor); + + const tmpDirsAfter = fs.readdirSync(os.tmpdir()).filter((d) => d.startsWith('cs-agent-io-')); + assert.strictEqual(tmpDirsAfter.length, 0, 'Temp directory should be cleaned up after refactoring'); + }); +}); diff --git a/src/test/suite/agent-service.test.ts b/src/test/suite/agent-service.test.ts new file mode 100644 index 00000000..5965649e --- /dev/null +++ b/src/test/suite/agent-service.test.ts @@ -0,0 +1,333 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import { AgentRefactoringService } from '../../refactoring/agent-service'; +import { AgentOutput, ConfidenceLevel } from '../../refactoring/agent-types'; +import { FnToRefactor } from '../../devtools-api/refactor-models'; +import { aceSuite } from '../ace-test-suite'; +import { Range, Position } from '../mocks/vscode'; + +const fixturesDir = path.join(__dirname, '..', '..', '..', 'src', 'test', 'fixtures', 'agent-output'); + +function loadFixture(name: string): AgentOutput { + const content = fs.readFileSync(path.join(fixturesDir, name), 'utf-8'); + return JSON.parse(content); +} + +function createMockFnToRefactor(body: string): FnToRefactor { + return { + name: 'testFunction', + body, + 'file-type': 'typescript', + range: { + 'start-line': 1, + 'start-column': 1, + 'end-line': 10, + 'end-column': 2, + }, + 'refactoring-targets': [{ category: 'Deep, Nested Complexity', line: 1 }], + vscodeRange: new Range(new Position(0, 0), new Position(9, 1)) as any, + }; +} + +function createMockDocument(fileName: string, content: string) { + return { + fileName, + getText: () => content, + uri: { fsPath: fileName }, + } as any; +} + +aceSuite('AgentRefactoringService Test Suite', () => { + const testCases = [ + { + name: 'mapOutputToResponse maps fix_proposed correctly', + fixture: 'fix-proposed.json', + fnBody: 'function test(a: number) {\n if (a > 0) {\n if (a > 1) {\n return a;\n }\n }\n return 0;\n}', + assertions: (response: any, output: AgentOutput) => { + assert.strictEqual(response['trace-id'], output.task_id); + assert.strictEqual(response.confidence.level, 3); + assert.strictEqual(response.confidence.title, output.summary); + assert.strictEqual(response.confidence['recommended-action'].details, output.reasoning); + assert.strictEqual(response.reasons.length, 0); + assert.deepStrictEqual(response['refactoring-properties']['removed-code-smells'], ['Deep, Nested Complexity']); + }, + }, + { + name: 'mapOutputToResponse maps unable_to_fix correctly', + fixture: 'unable-to-fix.json', + fnBody: 'function complex() { return 42; }', + assertions: (response: any) => { + assert.strictEqual(response.confidence.level, 1); + assert.strictEqual(response.confidence.title, 'Unable to refactor'); + assert.strictEqual(response.confidence['review-header'], 'Review required'); + assert.strictEqual(response.reasons.length, 1); + assert.strictEqual(response.reasons[0].summary, 'No changes proposed'); + }, + }, + { + name: 'mapOutputToResponse maps needs_human_review correctly', + fixture: 'needs-human-review.json', + fnBody: 'function test() {\n try {\n doSomething();\n } catch (e) {\n handleError(e);\n }\n}', + assertions: (response: any, output: AgentOutput) => { + assert.strictEqual(response['trace-id'], output.task_id); + assert.strictEqual(response.confidence.level, 2); + assert.strictEqual(response.confidence.title, 'Needs review'); + assert.strictEqual(response.confidence['review-header'], 'Review required'); + assert.strictEqual(response.reasons.length, 1); + }, + }, + ]; + + for (const tc of testCases) { + test(tc.name, () => { + const output = loadFixture(tc.fixture); + const fnToRefactor = createMockFnToRefactor(tc.fnBody); + const document = createMockDocument('/test/test.ts', tc.fnBody); + + const response = AgentRefactoringService.mapOutputToResponse(output, document, fnToRefactor); + + tc.assertions(response, output); + }); + } + + const confidenceLevelCases: Array<{ confidence: ConfidenceLevel; expectedLevel: number }> = [ + { confidence: 'high', expectedLevel: 3 }, + { confidence: 'medium', expectedLevel: 2 }, + { confidence: 'low', expectedLevel: 1 }, + ]; + + for (const tc of confidenceLevelCases) { + test(`mapOutputToResponse maps confidence level ${tc.confidence} to ${tc.expectedLevel}`, () => { + const fnBody = 'function test() { oldCode; }'; + const output: AgentOutput = { + '@context': { '@vocab': 'https://codescene.io/schemas/code-health-fix#' }, + schema_version: '1.0', + task_id: 'test-123', + fix_result: 'fix_proposed', + confidence: tc.confidence, + summary: 'Test summary', + reasoning: 'Test reasoning', + changes: [ + { + file: 'test.ts', + change_type: 'partial', + description: 'Test change', + replacements: [{ search: 'oldCode', replace: 'newCode' }], + }, + ], + generated_at: '2026-08-31T12:00:00Z', + }; + + const fnToRefactor = createMockFnToRefactor(fnBody); + const document = createMockDocument('/test/test.ts', fnBody); + + const response = AgentRefactoringService.mapOutputToResponse(output, document, fnToRefactor); + + assert.strictEqual(response.confidence.level, tc.expectedLevel); + }); + } + + test('mapOutputToResponse applies replacements to code', () => { + const output: AgentOutput = { + '@context': { '@vocab': 'https://codescene.io/schemas/code-health-fix#' }, + schema_version: '1.0', + task_id: 'test-replace', + fix_result: 'fix_proposed', + confidence: 'high', + summary: 'Applied change', + reasoning: 'Test', + changes: [ + { + file: 'test.ts', + change_type: 'partial', + description: 'Replace old with new', + replacements: [{ search: 'oldCode', replace: 'newCode' }], + }, + ], + generated_at: '2026-08-31T12:00:00Z', + }; + + const fnBody = 'function test() { oldCode; }'; + const fnToRefactor = createMockFnToRefactor(fnBody); + const document = createMockDocument('/test/test.ts', fnBody); + + const response = AgentRefactoringService.mapOutputToResponse(output, document, fnToRefactor); + + assert.strictEqual(response.code, 'function test() { newCode; }'); + }); + + test('mapOutputToResponse ignores changes for different files', () => { + const output: AgentOutput = { + '@context': { '@vocab': 'https://codescene.io/schemas/code-health-fix#' }, + schema_version: '1.0', + task_id: 'test-different-file', + fix_result: 'fix_proposed', + confidence: 'high', + summary: 'Applied change', + reasoning: 'Test', + changes: [ + { + file: 'other.ts', + change_type: 'partial', + description: 'Replace in other file', + replacements: [{ search: 'oldCode', replace: 'newCode' }], + }, + ], + generated_at: '2026-08-31T12:00:00Z', + }; + + const fnBody = 'function test() { oldCode; }'; + const fnToRefactor = createMockFnToRefactor(fnBody); + const document = createMockDocument('/test/test.ts', fnBody); + + const response = AgentRefactoringService.mapOutputToResponse(output, document, fnToRefactor); + + assert.strictEqual(response.code, fnBody); + }); + + test('mapOutputToResponse applies LF-based replacements to a CRLF function body', () => { + const fnBody = 'void F(int a)\r\n{\r\n if (a > 0)\r\n {\r\n Run(a);\r\n }\r\n}'; + const output: AgentOutput = { + '@context': { '@vocab': 'https://codescene.io/schemas/code-health-fix#' }, + schema_version: '1.0', + task_id: 'test-crlf', + fix_result: 'fix_proposed', + confidence: 'high', + summary: 'Applied change', + reasoning: 'Test', + changes: [ + { + file: 'test.cs', + change_type: 'partial', + description: 'Guard clause', + replacements: [ + { + search: ' if (a > 0)\n {\n Run(a);\n }\n', + replace: ' if (a <= 0) return;\n Run(a);\n', + }, + ], + }, + ], + generated_at: '2026-08-31T12:00:00Z', + }; + + const fnToRefactor = createMockFnToRefactor(fnBody); + const document = createMockDocument('/test/test.cs', fnBody); + + const response = AgentRefactoringService.mapOutputToResponse(output, document, fnToRefactor); + + assert.strictEqual(response.code, 'void F(int a)\r\n{\r\n if (a <= 0) return;\r\n Run(a);\r\n}'); + assert.strictEqual(response.confidence.title, 'Applied change'); + }); + + const trailingNewlineCases = [ + { name: 'LF', eol: '\n' }, + { name: 'CRLF', eol: '\r\n' }, + ]; + + for (const { name, eol } of trailingNewlineCases) { + test(`mapOutputToResponse applies a whole-method replacement whose search ends with a trailing newline (${name})`, () => { + const fnBody = [' void F(int a)', ' {', ' Run(a);', ' }'].join(eol); + const output: AgentOutput = { + '@context': { '@vocab': 'https://codescene.io/schemas/code-health-fix#' }, + schema_version: '1.0', + task_id: 'test-trailing-newline', + fix_result: 'fix_proposed', + confidence: 'high', + summary: 'Applied change', + reasoning: 'Test', + changes: [ + { + file: 'test.cs', + change_type: 'partial', + description: 'Extract helper', + replacements: [ + { + search: ' void F(int a)\n {\n Run(a);\n }\n', + replace: ' void F(int a)\n {\n Helper(a);\n }\n\n void Helper(int a)\n {\n Run(a);\n }\n', + }, + ], + }, + ], + generated_at: '2026-08-31T12:00:00Z', + }; + + const fnToRefactor = createMockFnToRefactor(fnBody); + const document = createMockDocument('/test/test.cs', fnBody); + + const response = AgentRefactoringService.mapOutputToResponse(output, document, fnToRefactor); + + const expected = [ + ' void F(int a)', + ' {', + ' Helper(a);', + ' }', + '', + ' void Helper(int a)', + ' {', + ' Run(a);', + ' }', + ].join(eol); + assert.strictEqual(response.code, expected); + assert.strictEqual(response.confidence.title, 'Applied change'); + }); + } + + test('runRefactoring rejects a document with unsaved changes', async () => { + const fnBody = 'function test() { oldCode; }'; + const fnToRefactor = createMockFnToRefactor(fnBody); + const document = { ...createMockDocument('/test/test.ts', fnBody), isDirty: true }; + + await assert.rejects( + () => AgentRefactoringService.runRefactoring(document, fnToRefactor), + /Save .*test\.ts.* before refactoring/ + ); + }); + + test('mapOutputToResponse sets metadata cached to false', () => { + const output: AgentOutput = { + '@context': { '@vocab': 'https://codescene.io/schemas/code-health-fix#' }, + schema_version: '1.0', + task_id: 'test-metadata', + fix_result: 'fix_proposed', + confidence: 'high', + summary: 'Test', + reasoning: 'Test', + changes: [], + generated_at: '2026-08-31T12:00:00Z', + }; + + const fnToRefactor = createMockFnToRefactor('function test() {}'); + const document = createMockDocument('/test/test.ts', 'function test() {}'); + + const response = AgentRefactoringService.mapOutputToResponse(output, document, fnToRefactor); + + assert.strictEqual(response.metadata['cached?'], false); + }); + + test('mapOutputToResponse marks content-identical refactoring as unable_to_fix', () => { + const fnBody = 'function test() { return 42; }'; + const output: AgentOutput = { + '@context': { '@vocab': 'https://codescene.io/schemas/code-health-fix#' }, + schema_version: '1.0', + task_id: 'test-identical', + fix_result: 'fix_proposed', + confidence: 'high', + summary: 'Refactoring suggestion', + reasoning: 'The refactoring improves code health.', + changes: [], + generated_at: '2026-08-31T12:00:00Z', + }; + + const fnToRefactor = createMockFnToRefactor(fnBody); + const document = createMockDocument('/test/test.ts', fnBody); + + const response = AgentRefactoringService.mapOutputToResponse(output, document, fnToRefactor); + + assert.strictEqual(response.confidence.level, 1); + assert.strictEqual(response.confidence.title, 'Unable to refactor'); + assert.strictEqual(response.confidence['review-header'], 'Review required'); + assert.strictEqual(response.reasons.length, 1); + }); +}); diff --git a/src/test/suite/auth-token.test.ts b/src/test/suite/auth-token.test.ts new file mode 100644 index 00000000..fe0483d1 --- /dev/null +++ b/src/test/suite/auth-token.test.ts @@ -0,0 +1,18 @@ +import * as assert from 'assert'; +import { tokenFromResponse } from '../../auth/auth-token'; + +suite('tokenFromResponse', () => { + const cases = [ + { name: 'returns the token', response: { 'credential-source': 'oauth' as const, 'access-token': 'abc' }, expected: 'abc' }, + { name: 'trims the token', response: { 'access-token': ' abc ' }, expected: 'abc' }, + { name: 'ignores a blank token', response: { 'access-token': ' ' }, expected: undefined }, + { name: 'ignores a missing token', response: {}, expected: undefined }, + { name: 'ignores a missing response', response: undefined, expected: undefined }, + ]; + + cases.forEach(({ name, response, expected }) => { + test(name, () => { + assert.strictEqual(tokenFromResponse(response), expected); + }); + }); +}); diff --git a/src/test/suite/codeaction.test.ts b/src/test/suite/codeaction.test.ts index 21a11a02..2896bf76 100644 --- a/src/test/suite/codeaction.test.ts +++ b/src/test/suite/codeaction.test.ts @@ -6,12 +6,10 @@ import { ReviewCodeActionProvider } from '../../review/codeaction'; import { CodeSmell } from '../../devtools-api/review-model'; import Reviewer from '../../review/reviewer'; import { CsReview } from '../../review/cs-review'; -import { DevtoolsAPI } from '../../devtools-api'; import { CsExtensionState } from '../../cs-extension-state'; let provider: ReviewCodeActionProvider; let mockReviewCache: any; -let originalDevtoolsAPIInstance: any; const originalCsExtensionStateInstance = (CsExtensionState as any)._instance; function setupTestEnvironment() { @@ -32,11 +30,6 @@ function setupTestEnvironment() { reviewCache: mockReviewCache }; - originalDevtoolsAPIInstance = (DevtoolsAPI as any).instance; - (DevtoolsAPI as any).instance = { - preflightJson: undefined - }; - (CsExtensionState as any)._instance = { stateProperties: { auth: { active: false, status: null } @@ -46,7 +39,6 @@ function setupTestEnvironment() { function teardownTestEnvironment() { (CsExtensionState as any)._instance = originalCsExtensionStateInstance; - (DevtoolsAPI as any).instance = originalDevtoolsAPIInstance; vscode.window.setActiveEditor(undefined); mockReviewCache.reviewMap.clear(); } diff --git a/src/test/suite/connectivity-integration.test.ts b/src/test/suite/connectivity-integration.test.ts new file mode 100644 index 00000000..fc5b4e17 --- /dev/null +++ b/src/test/suite/connectivity-integration.test.ts @@ -0,0 +1,132 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import * as fs from 'fs'; +import { mockWorkspaceFolders, createMockWorkspaceFolder, restoreDefaultWorkspaceFolders } from '../setup'; +import { createTestDir } from '../integration_helper'; +import * as configModule from '../../configuration'; +import * as csExtensionState from '../../cs-extension-state'; +import { testConnectivity } from '../../refactoring/connectivity'; + +import { aceSuite } from '../ace-test-suite'; + +aceSuite('Connectivity Integration Test Suite', () => { + const testDir = createTestDir('test-connectivity-integration'); + const awsProfile = process.env.CS_AGENT_AWS_PROFILE; + const awsRegion = process.env.CS_AGENT_AWS_REGION; + let originalGetAuthToken: any; + let originalGetExtensionPath: any; + let originalGetProviderOptions: any; + let originalGetAgentModel: any; + + if (!awsProfile || !awsRegion) { + console.log( + 'Skipping Connectivity Integration tests: CS_AGENT_AWS_PROFILE and CS_AGENT_AWS_REGION environment variables not set' + ); + return; + } + + const projectRoot = path.join(__dirname, '..', '..', '..'); + const agentBinaryPath = path.join(projectRoot, 'bin', 'cs-agent'); + if (!fs.existsSync(agentBinaryPath)) { + console.log('Skipping Connectivity Integration tests: cs-agent binary not found at ' + agentBinaryPath); + return; + } + + setup(async function () { + this.timeout(60000); + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + fs.mkdirSync(testDir, { recursive: true }); + + mockWorkspaceFolders([createMockWorkspaceFolder(testDir)]); + + originalGetAuthToken = configModule.getAuthToken; + (configModule as any).getAuthToken = () => 'dummy-token'; + + originalGetProviderOptions = configModule.getProviderOptions; + (configModule as any).getProviderOptions = () => ({ + 'amazon-bedrock:profile': awsProfile, + 'amazon-bedrock:region': awsRegion, + }); + + originalGetAgentModel = configModule.getAgentModel; + (configModule as any).getAgentModel = () => 'amazon-bedrock/eu.anthropic.claude-sonnet-4-6'; + + originalGetExtensionPath = csExtensionState.getExtensionPath; + (csExtensionState as any).getExtensionPath = () => projectRoot; + + Object.defineProperty(csExtensionState.CsExtensionState, 'stateProperties', { + get: () => ({ + session: undefined, + features: { + analysis: { state: 'enabled' }, + ace: { state: 'enabled' }, + }, + }), + configurable: true, + }); + }); + + teardown(() => { + if (originalGetAuthToken) { + (configModule as any).getAuthToken = originalGetAuthToken; + } + + if (originalGetProviderOptions) { + (configModule as any).getProviderOptions = originalGetProviderOptions; + } + + if (originalGetAgentModel) { + (configModule as any).getAgentModel = originalGetAgentModel; + } + + if (originalGetExtensionPath) { + (csExtensionState as any).getExtensionPath = originalGetExtensionPath; + } + + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + + restoreDefaultWorkspaceFolders(); + }); + + test('testConnectivity returns success with valid credentials', async function () { + this.timeout(120000); + + const result = await testConnectivity({ timeoutMs: 90000 }); + + assert.ok(result, 'Result should be defined'); + assert.strictEqual(result.success, true, `Connectivity should succeed but got error: ${result.error}`); + }); + + test('testConnectivity respects timeout option', async function () { + this.timeout(10000); + + const result = await testConnectivity({ timeoutMs: 1 }); + + assert.ok(result, 'Result should be defined'); + assert.strictEqual(result.success, false, 'Should fail due to timeout'); + assert.ok( + result.error?.includes('timed out') || result.error?.includes('cancelled'), + `Error should mention timeout or cancellation, got: ${result.error}` + ); + }); + + test('testConnectivity respects abort signal', async function () { + this.timeout(10000); + + const abortController = new AbortController(); + + const resultPromise = testConnectivity({ signal: abortController.signal }); + + abortController.abort(); + + const result = await resultPromise; + + assert.ok(result, 'Result should be defined'); + assert.strictEqual(result.success, false, 'Should fail due to abort'); + assert.ok(result.error?.includes('cancelled'), `Error should mention cancellation, got: ${result.error}`); + }); +}); diff --git a/src/test/suite/delta-presentation.test.ts b/src/test/suite/delta-presentation.test.ts index 3b5ffb78..69dffc78 100644 --- a/src/test/suite/delta-presentation.test.ts +++ b/src/test/suite/delta-presentation.test.ts @@ -79,11 +79,11 @@ suite('Delta presentation Test Suite', () => { const testCases = [ { - name: 'presents an enriched delta to the monitor when the review owns the monitor entry', + name: 'presents the delta to the monitor when the review owns the monitor entry', updateMonitor: true, }, { - name: 'keeps an enriched delta out of the monitor when the review does not own the monitor entry', + name: 'keeps the delta out of the monitor when the review does not own the monitor entry', updateMonitor: false, }, ]; @@ -104,10 +104,10 @@ suite('Delta presentation Test Suite', () => { server.deltaEmitter.fire({ id, repoRoot, path: 'src/file.ts', result: degradation() }); await review; - await waitUntil(() => events.length === 2); + await waitUntil(() => events.length === 1); assert.deepStrictEqual( events.map((event) => event.updateMonitor), - [updateMonitor, updateMonitor] + [updateMonitor] ); }); }); diff --git a/src/test/suite/delta.test.ts b/src/test/suite/delta.test.ts index 559c6e82..3160ef83 100644 --- a/src/test/suite/delta.test.ts +++ b/src/test/suite/delta.test.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import * as fs from 'fs'; import * as vscode from 'vscode'; import { DevtoolsAPI } from '../../devtools-api'; +import { fnToRefactorForCodeSmell } from '../../refactoring/fn-to-refactor'; import { mockWorkspaceFolders, createMockWorkspaceFolder, restoreDefaultWorkspaceFolders } from '../setup'; import { TestTextDocument } from '../mocks/test-text-document'; import { createMockExtensionContext } from '../mocks/mock-extension-context'; @@ -59,7 +60,7 @@ suite('Delta Integration Test Suite', () => { }); - aceTest('fnsToRefactor with code-smells path returns refactorable functions', async function() { + aceTest('fnToRefactorForCodeSmell resolves the enclosing function from the review', async function() { this.timeout(60000); const complexContent = 'int f(int a) {\n if (a > 0) {\n if (a > 1) {\n if (a > 2) {\n if (a > 3) {\n return a;\n }\n }\n }\n }\n return 0;\n}\n'; @@ -69,8 +70,6 @@ suite('Delta Integration Test Suite', () => { assertNoAnalysisError(); assert.ok(review, 'Review should be defined'); - await DevtoolsAPI.preflight(); - const functionLevelSmells = review['function-level-code-smells']; assert.ok(functionLevelSmells.length > 0, 'Should have function-level code smells'); @@ -79,27 +78,26 @@ suite('Delta Integration Test Suite', () => { const codeSmell = firstFunction['code-smells'][0]; - const result = await DevtoolsAPI.fnsToRefactor(doc, { 'code-smells': [codeSmell] }); + const result = fnToRefactorForCodeSmell(doc, codeSmell, review); - const expected = [{ - 'file-type': 'cpp', - 'nippy-b64': 'TlBZAHAFagRuYW1laQFmagRib2R5EACUaW50IGYoaW50IGEpIHsKICBpZiAoYSA+IDApIHsKICAgIGlmIChhID4gMSkgewogICAgICBpZiAoYSA+IDIpIHsKICAgICAgICBpZiAoYSA+IDMpIHsKICAgICAgICAgIHJldHVybiBhOwogICAgICAgIH0KICAgICAgfQogICAgfQogIH0KICByZXR1cm4gMDsKfWoJZmlsZS10eXBlaQNjcHBqBXJhbmdlcARqCnN0YXJ0LWxpbmUqAAAAAWoIZW5kLWxpbmUqAAAADGoMc3RhcnQtY29sdW1uZAFqCmVuZC1jb2x1bW5kAmoTcmVmYWN0b3JpbmctdGFyZ2V0c24BcAJqCGNhdGVnb3J5aRdEZWVwLCBOZXN0ZWQgQ29tcGxleGl0eWoEbGluZSoAAAAB', - 'refactoring-targets': [ - { - category: 'Deep, Nested Complexity', - line: 1 - } - ], - body: 'int f(int a) {\n if (a > 0) {\n if (a > 1) {\n if (a > 2) {\n if (a > 3) {\n return a;\n }\n }\n }\n }\n return 0;\n}', + const expected = { name: 'f', + body: 'int f(int a) {\n if (a > 0) {\n if (a > 1) {\n if (a > 2) {\n if (a > 3) {\n return a;\n }\n }\n }\n }\n return 0;\n}', + 'file-type': 'cpp', range: { 'start-line': 1, 'start-column': 1, 'end-line': 12, 'end-column': 2 }, + 'refactoring-targets': [ + { + category: 'Deep, Nested Complexity', + line: 1 + } + ], vscodeRange: new vscode.Range(0, 0, 11, 1) - }]; + }; assert.deepStrictEqual(result, expected, 'Result should match expected structure'); }); diff --git a/src/test/suite/devtools-api-jsonrpc.test.ts b/src/test/suite/devtools-api-jsonrpc.test.ts index 63b66bc1..f4a86fb7 100644 --- a/src/test/suite/devtools-api-jsonrpc.test.ts +++ b/src/test/suite/devtools-api-jsonrpc.test.ts @@ -2,7 +2,6 @@ import * as assert from 'assert'; import * as path from 'path'; import { AnalysisEvent, DevtoolsAPI } from '../../devtools-api'; import { CsIdeServerClient } from '../../devtools-api/ide-server-client'; -import { aceTest } from '../ace-test-suite'; import { createMockExtensionContext } from '../mocks/mock-extension-context'; suite('DevtoolsAPI JSON-RPC Test Suite', () => { @@ -61,8 +60,4 @@ suite('DevtoolsAPI JSON-RPC Test Suite', () => { queueDone: 0, }); }); - - aceTest('uses the shared server for preflight', async () => { - assert.strictEqual((await DevtoolsAPI.preflight())?.version, 2); - }); }); diff --git a/src/test/suite/devtools-api-no-ace.test.ts b/src/test/suite/devtools-api-no-ace.test.ts index 4901b870..804bd8fb 100644 --- a/src/test/suite/devtools-api-no-ace.test.ts +++ b/src/test/suite/devtools-api-no-ace.test.ts @@ -21,9 +21,9 @@ noAceSuite('DevtoolsAPI no-ACE guard Test Suite', () => { restoreDefaultWorkspaceFolders(); }); - test('preflight returns immediately when ACE is disabled', async function () { - const response = await DevtoolsAPI.preflight(); - assert.strictEqual(response, undefined); + test('enableAce is a no-op when ACE is disabled', () => { + DevtoolsAPI.enableAce(); + assert.strictEqual(DevtoolsAPI.aceEnabled(), false); }); test('aceEnabled returns false when ACE is disabled', () => { diff --git a/src/test/suite/fn-to-refactor.test.ts b/src/test/suite/fn-to-refactor.test.ts new file mode 100644 index 00000000..0ddc8026 --- /dev/null +++ b/src/test/suite/fn-to-refactor.test.ts @@ -0,0 +1,141 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { ChangeType, FunctionFinding } from '../../devtools-api/delta-model'; +import { CodeSmell, Review } from '../../devtools-api/review-model'; +import { fnToRefactorForCodeSmell, fnToRefactorForFinding } from '../../refactoring/fn-to-refactor'; +import { TestTextDocument } from '../mocks/test-text-document'; + +const content = 'const a = 1;\nfunction f(x) {\n return x;\n}\n'; +const document = new TestTextDocument('/repo/src/file.ts', content, 'typescript'); + +function smell(category: string, startLine: number, endLine: number): CodeSmell { + return { + category, + details: '', + 'highlight-range': { 'start-line': startLine, 'start-column': 1, 'end-line': endLine, 'end-column': 2 }, + }; +} + +const complexMethod = smell('Complex Method', 2, 2); +const lowCohesion = smell('Low Cohesion', 1, 1); + +const review: Review = { + 'file-level-code-smells': [lowCohesion], + 'function-level-code-smells': [ + { + function: 'f', + range: { 'start-line': 2, 'start-column': 1, 'end-line': 4, 'end-column': 2 }, + 'code-smells': [complexMethod], + }, + ], + 'raw-score': 'raw', +}; + +suite('fnToRefactorForCodeSmell', () => { + const cases = [ + { + name: 'uses the enclosing function for function-level smells', + codeSmell: complexMethod, + review, + expected: { + name: 'f', + body: 'function f(x) {\n return x;\n}', + range: { 'start-line': 2, 'start-column': 1, 'end-line': 4, 'end-column': 2 }, + vscodeRange: new vscode.Range(1, 0, 3, 1), + targets: [{ category: 'Complex Method', line: 2 }], + }, + }, + { + name: 'uses the whole file for file-level smells', + codeSmell: lowCohesion, + review, + expected: { + name: 'file.ts', + body: content, + range: { 'start-line': 1, 'start-column': 1, 'end-line': 5, 'end-column': 1 }, + vscodeRange: new vscode.Range(0, 0, 4, 0), + targets: [{ category: 'Low Cohesion', line: 1 }], + }, + }, + { + name: 'uses the whole file when no review is available', + codeSmell: complexMethod, + review: undefined, + expected: { + name: 'file.ts', + body: content, + range: { 'start-line': 1, 'start-column': 1, 'end-line': 5, 'end-column': 1 }, + vscodeRange: new vscode.Range(0, 0, 4, 0), + targets: [{ category: 'Complex Method', line: 2 }], + }, + }, + ]; + + cases.forEach(({ name, codeSmell, review, expected }) => { + test(name, () => { + const fn = fnToRefactorForCodeSmell(document, codeSmell, review); + assert.strictEqual(fn.name, expected.name); + assert.strictEqual(fn.body, expected.body); + assert.strictEqual(fn['file-type'], 'ts'); + assert.deepStrictEqual(fn.range, expected.range); + assert.deepStrictEqual(fn.vscodeRange, expected.vscodeRange); + assert.deepStrictEqual(fn['refactoring-targets'], expected.targets); + }); + }); +}); + +suite('fnToRefactorForFinding', () => { + const functionRange = { 'start-line': 2, 'start-column': 1, 'end-line': 4, 'end-column': 2 }; + + function finding(changeDetails: FunctionFinding['change-details'], range = functionRange): FunctionFinding { + return { function: { name: 'f', range }, 'change-details': changeDetails }; + } + + const detail = (category: string, changeType: ChangeType, line?: number) => ({ + category, + 'change-type': changeType, + description: '', + line, + }); + + const cases = [ + { + name: 'targets every remaining smell in the function', + finding: finding([ + detail('Complex Method', ChangeType.Introduced, 2), + detail('Bumpy Road Ahead', ChangeType.Improved, 3), + detail('Large Method', ChangeType.Fixed, 2), + ]), + expectedTargets: [ + { category: 'Complex Method', line: 2 }, + { category: 'Bumpy Road Ahead', line: 3 }, + ], + }, + { + name: 'is not refactorable when every smell was fixed', + finding: finding([detail('Complex Method', ChangeType.Fixed, 2)]), + expectedTargets: undefined, + }, + { + name: 'skips changes without a line', + finding: finding([detail('Complex Method', ChangeType.Degraded)]), + expectedTargets: undefined, + }, + { + name: 'is not refactorable without a function range', + finding: { function: { name: 'f' }, 'change-details': [detail('Complex Method', ChangeType.Degraded, 2)] }, + expectedTargets: undefined, + }, + ]; + + cases.forEach(({ name, finding, expectedTargets }) => { + test(name, () => { + const fn = fnToRefactorForFinding(document, finding); + assert.deepStrictEqual(fn?.['refactoring-targets'], expectedTargets); + if (fn) { + assert.strictEqual(fn.name, 'f'); + assert.strictEqual(fn.body, 'function f(x) {\n return x;\n}'); + } + }); + }); +}); diff --git a/src/test/suite/fns-to-refactor.test.ts b/src/test/suite/fns-to-refactor.test.ts deleted file mode 100644 index 7c2e3827..00000000 --- a/src/test/suite/fns-to-refactor.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import * as assert from 'assert'; -import * as path from 'path'; -import * as fs from 'fs'; -import * as vscode from 'vscode'; -import { DevtoolsAPI } from '../../devtools-api'; -import { mockWorkspaceFolders, createMockWorkspaceFolder, restoreDefaultWorkspaceFolders } from '../setup'; -import { TestTextDocument } from '../mocks/test-text-document'; -import { createMockExtensionContext } from '../mocks/mock-extension-context'; -import { createTestDir, ensureBinary } from '../integration_helper'; - -import { aceSuite } from '../ace-test-suite'; - -aceSuite('FnsToRefactor Integration Test Suite', () => { - const testDir = createTestDir('test-fns-to-refactor'); - let analysisError: Error | undefined; - let errorListener: vscode.Disposable; - - function createTestFile(filename: string, content: string): TestTextDocument { - const testFile = path.resolve(testDir, filename); - fs.writeFileSync(testFile, content); - return new TestTextDocument(testFile, content, 'cpp'); - } - - function assertNoAnalysisError() { - if (analysisError) { - assert.fail(`Analysis failed with error: ${analysisError.message}\n${analysisError.stack}`); - } - } - - setup(async function() { - this.timeout(60000); - if (fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true, force: true }); - } - fs.mkdirSync(testDir, { recursive: true }); - - mockWorkspaceFolders([createMockWorkspaceFolder(testDir)]); - - const binaryPath = await ensureBinary(); - const mockContext = createMockExtensionContext(testDir); - - DevtoolsAPI.init(binaryPath, mockContext); - - analysisError = undefined; - - errorListener = DevtoolsAPI.onDidAnalysisFail((error) => { - analysisError = error; - }); - - await DevtoolsAPI.preflight(); - }); - - teardown(() => { - errorListener?.dispose(); - - if (fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true, force: true }); - } - - restoreDefaultWorkspaceFolders(); - }); - - - test('fnsToRefactorFromDelta returns undefined when ACE is disabled', async function() { - this.timeout(10000); - - DevtoolsAPI.disableAce(); - - const content = 'int foo() { return 42; }\n'; - const doc = createTestFile('test-disabled.cpp', content); - - const deltaResult = { - 'score-change': -1.0, - 'file-level-findings': [], - 'function-level-findings': [] - }; - - const result = await DevtoolsAPI.fnsToRefactorFromDelta(doc, deltaResult); - - assert.strictEqual(result, undefined, 'Should return undefined when ACE is disabled'); - }); - - test('fnsToRefactorFromDelta handles delta with no function findings', async function() { - this.timeout(60000); - - const cleanContent = 'int add(int a, int b) { return a + b; }\n'; - const doc = createTestFile('clean.cpp', cleanContent); - - const deltaResult = { - 'score-change': 0.0, - 'file-level-findings': [], - 'function-level-findings': [] - }; - - const result = await DevtoolsAPI.fnsToRefactorFromDelta(doc, deltaResult); - - assertNoAnalysisError(); - - assert.ok(Array.isArray(result), 'Result should be an array'); - assert.strictEqual(result.length, 0, 'Should return empty array for delta with no findings'); - }); - -}); diff --git a/src/test/suite/home-view.test.ts b/src/test/suite/home-view.test.ts index 6f5b9192..d8ccb685 100644 --- a/src/test/suite/home-view.test.ts +++ b/src/test/suite/home-view.test.ts @@ -213,40 +213,14 @@ suite('HomeView', () => { assert.strictEqual(badgeCount, 0); }); - const enrichmentCases = [ - { - name: 'adds a file on its first delta', - initialFiles: [] as string[], - enrichment: false, - expectedFiles: ['/workspace/fileA.ts'], - }, - { - name: 'does not resurrect a file removed while its delta was being enriched', - initialFiles: [] as string[], - enrichment: true, - expectedFiles: [] as string[], - }, - { - name: 'updates a file that is still monitored when its enriched delta arrives', - initialFiles: ['/workspace/fileA.ts'], - enrichment: true, - expectedFiles: ['/workspace/fileA.ts'], - }, - ]; - - enrichmentCases.forEach(({ name, initialFiles, enrichment, expectedFiles }) => { - test(name, () => { - initialFiles.forEach(addFileToHomeView); - - (homeView as any).updateFileDeltaData({ - document: mockDocument('/workspace/fileA.ts'), - result: mockDeltaResult, - updateMonitor: true, - enrichment, - }); - - assert.deepStrictEqual([...homeView.getFileIssueMap().keys()], expectedFiles); + test('adds a file on its first delta', () => { + (homeView as any).updateFileDeltaData({ + document: mockDocument('/workspace/fileA.ts'), + result: mockDeltaResult, + updateMonitor: true, }); + + assert.deepStrictEqual([...homeView.getFileIssueMap().keys()], ['/workspace/fileA.ts']); }); }); diff --git a/src/test/suite/ide-server-client.test.ts b/src/test/suite/ide-server-client.test.ts index 10c8ae72..7a22d132 100644 --- a/src/test/suite/ide-server-client.test.ts +++ b/src/test/suite/ide-server-client.test.ts @@ -1,7 +1,5 @@ import * as assert from 'assert'; import * as path from 'path'; -import { AbortError } from '../../devtools-api/abort-error'; -import { DevtoolsError } from '../../devtools-api/devtools-error'; import { CsIdeServerClient } from '../../devtools-api/ide-server-client'; import { gitBlobSha } from '../../review/review-pipeline'; import { authStatusFixture } from '../fixtures/auth/auth-fixtures'; @@ -76,12 +74,6 @@ suite('CsIdeServerClient Test Suite', () => { }); test('sends typed requests over one connection', async () => { - const codeSmells = { - 'file-name': 'file.ts', - 'file-content': 'function f() {}', - preflight: { version: 2, 'file-types': ['ts'], 'language-common': { 'max-input-loc': 100, 'code-smells': [] } }, - 'code-smells': [{ category: 'Complex Method' }], - }; const telemetry = { 'editor-type': 'VSCode', 'event-name': 'test', @@ -104,24 +96,9 @@ suite('CsIdeServerClient Test Suite', () => { 'old-git-blob-sha': 'old-sha', 'new-git-blob-sha': 'new-sha', }); - assert.deepStrictEqual(await client.preflight({ force: true }), { - version: 2, - 'file-types': ['ts'], - 'language-common': { 'max-input-loc': 100, 'code-smells': ['Complex Method'] }, - }); - const [fn] = await client.fnsToRefactor(codeSmells); - assert.strictEqual(fn['file-type'], 'TypeScript'); - assert.strictEqual(fn['function-type'], 'Function'); - assert.strictEqual(fn['nippy-b64'], 'encoded'); - assert.deepStrictEqual(fn.range, { 'start-line': 1, 'start-column': 1, 'end-line': 1, 'end-column': 16 }); - assert.deepStrictEqual(fn['refactoring-targets'], [{ category: 'Complex Method', line: 1 }]); - const refactor = await client.refactor({ token: 'token' }); - assert.strictEqual(refactor['trace-id'], 'trace-1'); - assert.deepStrictEqual(refactor['credits-info'], { limit: 10, used: 1 }); - assert.deepStrictEqual(refactor['refactoring-properties'], { 'added-code-smells': [], 'removed-code-smells': ['Complex Method'] }); - assert.deepStrictEqual(refactor.confidence['recommended-action'], { description: 'Apply', details: 'Safe change' }); assert.deepStrictEqual(await client.telemetry(telemetry), { status: 202, params: { event: telemetry } }); assert.deepStrictEqual(await client.deviceId(), { 'device-id': 'device-42' }); + assert.deepStrictEqual(await client.authToken(), { 'credential-source': 'oauth', 'access-token': 'fixture-access-token' }); assert.deepStrictEqual(await client.codeHealthRulesTemplate(), { template: '{"rule_sets":[]}' }); assert.deepStrictEqual(await client.checkRules('/repo', 'src/file.ts'), { result: 'matched', @@ -130,20 +107,6 @@ suite('CsIdeServerClient Test Suite', () => { }); }); - test('maps a cancelled refactor to AbortError', async () => { - const controller = new AbortController(); - const pending = client.refactor({ token: 'hang' }, controller.signal); - controller.abort(); - await assert.rejects(pending, (error: unknown) => error instanceof AbortError); - }); - - test('maps an HTTP failure from the server to a DevtoolsError with its status', async () => { - await assert.rejects( - client.refactor({ token: 'unauthorized' }), - (error: unknown) => error instanceof DevtoolsError && error.status === 401 - ); - }); - test('sends check-rules path relative to the repo root', async () => { const rpc = client as unknown as { sendRequest(method: string, params: unknown): Promise }; await client.checkRules('/repo', '/repo/src/file.ts'); @@ -321,16 +284,9 @@ suite('CsIdeServerClient Test Suite', () => { await client.review({ path: 'file.ts' }); await client.delta({ 'old-score': 'old' }); - await client.preflight({ force: true }); - await client.fnsToRefactor({ - 'file-name': 'file.ts', - 'file-content': 'function f() {}', - preflight: { version: 2, 'file-types': ['ts'], 'language-common': { 'max-input-loc': 100, 'code-smells': [] } }, - 'code-smells': [{ category: 'Complex Method' } as any], - }); - await client.refactor({ token: 'secret-token', 'fn-to-refactor-nippy-b64': 'encoded', 'skip-cache': true }); await client.telemetry({ 'editor-type': 'VSCode', 'event-name': 'test-event', 'extension-version': '1.0.0', 'user-id': 'user-99' }); await client.deviceId(); + await client.authToken(); await client.codeHealthRulesTemplate(); await client.checkRules('/repo', 'src/file.ts'); await client.getWatchInventory('/repo'); @@ -341,26 +297,21 @@ suite('CsIdeServerClient Test Suite', () => { assertLogContains('info', 'score=9.68 fileSmells=0 functionSmells=0 hasRulesError=false sha=review-sha'); assertLogContains('info', 'method=cs-ide/delta hasOldScore=true hasNewScore=false'); assertLogContains('info', 'oldScore=10 newScore=9.68 scoreChange=-0.32 fileFindings=0 functionFindings=0'); - assertLogContains('info', 'method=cs-ide/preflight force=true'); - assertLogContains('info', 'version=2 fileTypes=ts codeSmellCount=1 maxInputLoc=100'); - assertLogContains('info', 'method=cs-ide/fns-to-refactor fileName=file.ts input=code-smells smellCount=1'); - assertLogContains('info', 'functions=f@1:1-1:16[Complex Method]'); - assertLogContains('info', 'method=cs-ide/refactor format=nippy-b64 skipCache=true'); - assertLogContains('info', 'traceId=trace-1 confidence=1 cached=false reasonCount=0'); assertLogContains('info', 'method=cs-ide/telemetry eventName=test-event'); assertLogContains('info', 'method=cs-ide/telemetry durationMs='); assertLogContains('info', 'status=202'); assertLogContains('info', 'method=cs-ide/device-id'); assertLogContains('info', 'method=cs-ide/device-id durationMs='); assertLogContains('info', 'ok=true'); + assertLogContains('info', 'method=cs-ide/auth-token durationMs='); + assertLogContains('info', 'source=oauth'); assertLogContains('info', 'method=cs-ide/check-rules repoRoot=/repo path=src/file.ts'); assertLogContains('info', 'method=cs-ide/getWatchInventory repoRoot=/repo'); assertLogContains('info', 'count=1 files=requested.ts'); - assertLogOmits('secret-token'); assertLogOmits('function f() {}'); - assertLogOmits('encoded'); assertLogOmits('device-42'); + assertLogOmits('fixture-access-token'); assertLogOmits('user-99'); assertLogOmits('{"rule_sets":[]}'); assert.ok(!capturedLogText().includes('file-content'), capturedLogText()); diff --git a/src/test/suite/post-refactoring.test.ts b/src/test/suite/post-refactoring.test.ts index 718fb8c1..6bee95ac 100644 --- a/src/test/suite/post-refactoring.test.ts +++ b/src/test/suite/post-refactoring.test.ts @@ -8,6 +8,7 @@ import { TestTextDocument } from '../mocks/test-text-document'; import { createMockExtensionContext } from '../mocks/mock-extension-context'; import { RefactoringRequest } from '../../refactoring/request'; import { ChangeType } from '../../devtools-api/delta-model'; +import { fnToRefactorForFinding } from '../../refactoring/fn-to-refactor'; import * as configModule from '../../configuration'; import * as csExtensionState from '../../cs-extension-state'; import { createTestDir, ensureBinary } from '../integration_helper'; @@ -22,12 +23,20 @@ aceSuite('PostRefactoring Integration Test Suite', () => { let errorListener: vscode.Disposable; let refactoringErrorListener: vscode.Disposable; let originalGetAuthToken: any; + let originalGetExtensionPath: any; + const projectRoot = path.join(__dirname, '..', '..', '..'); if (!testToken) { console.log('Skipping PostRefactoring tests: CODESCENE_TEST_TOKEN environment variable not set'); return; } + const agentBinaryPath = path.join(__dirname, '..', '..', '..', 'bin', 'cs-agent'); + if (!fs.existsSync(agentBinaryPath)) { + console.log('Skipping PostRefactoring tests: cs-agent binary not found at ' + agentBinaryPath); + return; + } + function createTestFile(filename: string, content: string, languageId: string = 'cpp'): TestTextDocument { const testFile = path.resolve(testDir, filename); fs.writeFileSync(testFile, content); @@ -53,6 +62,11 @@ aceSuite('PostRefactoring Integration Test Suite', () => { } fs.mkdirSync(testDir, { recursive: true }); + const { execSync } = require('child_process'); + execSync('git init', { cwd: testDir }); + execSync('git config user.email "test@test.com"', { cwd: testDir }); + execSync('git config user.name "Test"', { cwd: testDir }); + mockWorkspaceFolders([createMockWorkspaceFolder(testDir)]); const binaryPath = await ensureBinary(); @@ -61,6 +75,9 @@ aceSuite('PostRefactoring Integration Test Suite', () => { originalGetAuthToken = configModule.getAuthToken; (configModule as any).getAuthToken = () => testToken; + originalGetExtensionPath = csExtensionState.getExtensionPath; + (csExtensionState as any).getExtensionPath = () => projectRoot; + Object.defineProperty(csExtensionState.CsExtensionState, 'stateProperties', { get: () => ({ auth: { active: false, status: null }, @@ -85,7 +102,7 @@ aceSuite('PostRefactoring Integration Test Suite', () => { refactoringError = error; }); - await DevtoolsAPI.preflight(); + DevtoolsAPI.enableAce(); }); teardown(() => { @@ -96,6 +113,10 @@ aceSuite('PostRefactoring Integration Test Suite', () => { (configModule as any).getAuthToken = originalGetAuthToken; } + if (originalGetExtensionPath) { + (csExtensionState as any).getExtensionPath = originalGetExtensionPath; + } + if (fs.existsSync(testDir)) { fs.rmSync(testDir, { recursive: true, force: true }); } @@ -136,14 +157,11 @@ aceSuite('PostRefactoring Integration Test Suite', () => { }] }; - const fnsToRefactor = await DevtoolsAPI.fnsToRefactorFromDelta(doc, deltaResult); + const fnToRefactor = fnToRefactorForFinding(doc, deltaResult['function-level-findings'][0]); assertNoAnalysisError(); + assert.ok(fnToRefactor, 'Should find a refactorable function'); - assert.ok(Array.isArray(fnsToRefactor), 'Result should be an array'); - assert.ok(fnsToRefactor.length > 0, 'Should find at least one refactorable function'); - - const fnToRefactor = fnsToRefactor[0]; const request = new RefactoringRequest(fnToRefactor, doc, false); const response = await request.promise; @@ -192,12 +210,10 @@ aceSuite('PostRefactoring Integration Test Suite', () => { }] }; - const fnsToRefactor = await DevtoolsAPI.fnsToRefactorFromDelta(doc, deltaResult); + const fnToRefactor = fnToRefactorForFinding(doc, deltaResult['function-level-findings'][0]); assertNoAnalysisError(); - assert.ok(fnsToRefactor && fnsToRefactor.length > 0, 'Should find refactorable functions'); - - const fnToRefactor = fnsToRefactor[0]; + assert.ok(fnToRefactor, 'Should find a refactorable function'); const request1 = new RefactoringRequest(fnToRefactor, doc, false); const response1 = await request1.promise; @@ -245,12 +261,10 @@ aceSuite('PostRefactoring Integration Test Suite', () => { }] }; - const fnsToRefactor = await DevtoolsAPI.fnsToRefactorFromDelta(doc, deltaResult); + const fnToRefactor = fnToRefactorForFinding(doc, deltaResult['function-level-findings'][0]); assertNoAnalysisError(); - assert.ok(fnsToRefactor && fnsToRefactor.length > 0, 'Should find refactorable functions'); - - const fnToRefactor = fnsToRefactor[0]; + assert.ok(fnToRefactor, 'Should find a refactorable function'); assert.ok(fnToRefactor['refactoring-targets'].length > 0, 'Should have refactoring targets'); const request = new RefactoringRequest(fnToRefactor, doc, false); @@ -296,12 +310,10 @@ aceSuite('PostRefactoring Integration Test Suite', () => { }] }; - const fnsToRefactor = await DevtoolsAPI.fnsToRefactorFromDelta(doc, deltaResult); + const fnToRefactor = fnToRefactorForFinding(doc, deltaResult['function-level-findings'][0]); assertNoAnalysisError(); - assert.ok(fnsToRefactor && fnsToRefactor.length > 0, 'Should find refactorable functions'); - - const fnToRefactor = fnsToRefactor[0]; + assert.ok(fnToRefactor, 'Should find a refactorable function'); const request = new RefactoringRequest(fnToRefactor, doc, false); const response = await request.promise; diff --git a/src/test/suite/preflight.test.ts b/src/test/suite/preflight.test.ts deleted file mode 100644 index 916b8cff..00000000 --- a/src/test/suite/preflight.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import * as assert from 'assert'; -import * as fs from 'fs'; -import { DevtoolsAPI } from '../../devtools-api'; -import { mockWorkspaceFolders, createMockWorkspaceFolder, restoreDefaultWorkspaceFolders } from '../setup'; -import { createMockExtensionContext } from '../mocks/mock-extension-context'; -import { createTestDir, ensureBinary } from '../integration_helper'; - -import { aceSuite } from '../ace-test-suite'; - -aceSuite('Preflight Integration Test Suite', () => { - const testDir = createTestDir('test-preflight'); - let preflightStateChangeFired = false; - let lastPreflightState: any; - let stateChangeListener: any; - - function waitForEvent(ms: number = 1000): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - function assertPreflightResponseStructure(response: any) { - assert.ok(response, 'Response should exist'); - assert.ok('version' in response); - assert.ok('language-common' in response); - assert.ok('language-specific' in response); - assert.ok('file-types' in response); - - assert.ok(response['language-common'], 'language-common should exist'); - assert.ok('max-input-loc' in response['language-common']); - assert.ok('code-smells' in response['language-common']); - assert.ok(Array.isArray(response['language-common']['code-smells'])); - assert.ok(Array.isArray(response['file-types'])); - } - - setup(async function() { - this.timeout(60000); - if (fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true, force: true }); - } - fs.mkdirSync(testDir, { recursive: true }); - - mockWorkspaceFolders([createMockWorkspaceFolder(testDir)]); - - const binaryPath = await ensureBinary(); - const mockContext = createMockExtensionContext(testDir); - - DevtoolsAPI.init(binaryPath, mockContext); - DevtoolsAPI.disableAce(); - - preflightStateChangeFired = false; - lastPreflightState = undefined; - - stateChangeListener = DevtoolsAPI.onDidChangePreflightState((state) => { - preflightStateChangeFired = true; - lastPreflightState = state; - }); - }); - - teardown(() => { - stateChangeListener?.dispose(); - - if (fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true, force: true }); - } - - restoreDefaultWorkspaceFolders(); - }); - - test('preflight returns expected response structure', async function() { - this.timeout(60000); - - const response = await DevtoolsAPI.preflight(); - - assertPreflightResponseStructure(response); - assert.ok(response, 'Response should exist'); - assert.strictEqual(response.version, 2.0); - assert.ok(response['language-common']['max-input-loc'] > 0); - assert.ok(response['language-common']['code-smells'].length > 0); - assert.ok(response['file-types'].length > 0); - }); - - test('preflight fires state change event with loading state', async function() { - this.timeout(60000); - - preflightStateChangeFired = false; - lastPreflightState = undefined; - - const preflightPromise = DevtoolsAPI.preflight(); - - await waitForEvent(100); - - assert.ok(preflightStateChangeFired, 'Preflight state change event should fire'); - assert.ok(lastPreflightState, 'Last preflight state should be captured'); - - await preflightPromise; - }); - - test('preflight fires state change event with enabled state on success', async function() { - this.timeout(60000); - - const initialStateChangeFired = preflightStateChangeFired; - const initialState = lastPreflightState; - assert.strictEqual(initialStateChangeFired, false, 'State change event should not have fired initially'); - assert.strictEqual(initialState, undefined, 'Last preflight state should be undefined initially'); - - await DevtoolsAPI.preflight(); - - await waitForEvent(100); - - assert.strictEqual(preflightStateChangeFired, true, 'Preflight state change event should fire'); - assert.ok(lastPreflightState, 'Last preflight state should be captured'); - assert.strictEqual(lastPreflightState.state, 'enabled'); - }); - - test('preflight enables ACE when successful', async function() { - this.timeout(60000); - - const initialAceState = DevtoolsAPI.aceEnabled(); - assert.strictEqual(initialAceState, false, 'ACE should be disabled initially'); - - await DevtoolsAPI.preflight(); - - await waitForEvent(100); - - const finalAceState = DevtoolsAPI.aceEnabled(); - - assert.strictEqual(finalAceState, true, 'ACE should be enabled after successful preflight'); - }); - - test('preflight contains expected code smells', async function() { - this.timeout(60000); - - const response = await DevtoolsAPI.preflight(); - - assert.ok(response, 'Response should exist'); - const codeSmells = response['language-common']['code-smells']; - assert.ok(codeSmells.includes('Complex Conditional')); - assert.ok(codeSmells.includes('Complex Method')); - assert.ok(codeSmells.includes('Large Method')); - }); - - test('preflight contains expected file types', async function() { - this.timeout(60000); - - const response = await DevtoolsAPI.preflight(); - - assert.ok(response, 'Response should exist'); - const fileTypes = response['file-types']; - assert.ok(fileTypes.includes('cpp')); - assert.ok(fileTypes.includes('java')); - assert.ok(fileTypes.includes('js')); - assert.ok(fileTypes.includes('ts')); - }); -}); diff --git a/src/test/suite/unsaved-changes.test.ts b/src/test/suite/unsaved-changes.test.ts new file mode 100644 index 00000000..31db4ec9 --- /dev/null +++ b/src/test/suite/unsaved-changes.test.ts @@ -0,0 +1,56 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { ensureSavedForRefactoring, SAVE_AND_REFACTOR } from '../../refactoring/unsaved-changes'; + +suite('ensureSavedForRefactoring', () => { + const originalWarning = vscode.window.showWarningMessage; + let prompts: Array<{ message: string; options: unknown; items: unknown[] }>; + + function stubPrompt(answer: string | undefined) { + (vscode.window as any).showWarningMessage = async (message: string, options: unknown, ...items: unknown[]) => { + prompts.push({ message, options, items }); + return answer; + }; + } + + setup(() => { + prompts = []; + }); + + teardown(() => { + (vscode.window as any).showWarningMessage = originalWarning; + }); + + const cases = [ + { name: 'a saved document proceeds without prompting', isDirty: false, answer: undefined, saveResult: true, expected: true, prompted: false, saved: false }, + { name: 'a dirty document is saved when the user accepts', isDirty: true, answer: SAVE_AND_REFACTOR, saveResult: true, expected: true, prompted: true, saved: true }, + { name: 'a dirty document is left alone when the user dismisses', isDirty: true, answer: undefined, saveResult: true, expected: false, prompted: true, saved: false }, + { name: 'a failed save cancels the refactoring', isDirty: true, answer: SAVE_AND_REFACTOR, saveResult: false, expected: false, prompted: true, saved: true }, + ]; + + for (const tc of cases) { + test(tc.name, async () => { + stubPrompt(tc.answer); + let saveCalls = 0; + const document = { + isDirty: tc.isDirty, + fileName: '/repo/CSharp/PrimitiveObsessionExample.cs', + save: async () => { + saveCalls++; + return tc.saveResult; + }, + } as unknown as vscode.TextDocument; + + const proceed = await ensureSavedForRefactoring(document); + + assert.strictEqual(proceed, tc.expected); + assert.strictEqual(prompts.length > 0, tc.prompted); + assert.strictEqual(saveCalls > 0, tc.saved); + if (tc.prompted) { + assert.match(prompts[0].message, /PrimitiveObsessionExample\.cs/); + assert.deepStrictEqual(prompts[0].options, { modal: true }); + assert.deepStrictEqual(prompts[0].items, [SAVE_AND_REFACTOR]); + } + }); + } +});