diff --git a/package.json b/package.json index 7c11d4443..12dcc1e01 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "order": 1, "properties": { "snyk.advanced.authenticationMethod": { + "order": 1, "type": "string", "default": "OAuth2 (Recommended)", "description": "Specifies whether to authenticate with OAuth2, PAT, or with an API token (Legacy). \n\nNote: OAuth2 authentication is recommended as it provides enhanced security.", @@ -85,6 +86,7 @@ "markdownDescription": "Specifies whether to authenticate with OAuth2, PAT, or with an API token (Legacy). \n\nNote: OAuth2 authentication is recommended as it provides enhanced security." }, "snyk.advanced.tokenStorage": { + "order": 3, "type": "string", "enum": [ "Always use VS Code's secret storage" @@ -93,18 +95,27 @@ "markdownDescription": "Snyk uses VS Code's [secret storage](https://code.visualstudio.com/api/references/vscode-api#SecretStorage) to safely persist API token instead of saving it in plaintext in `settings.json`. To set the token manually, run the VS Code command [Snyk: Set Token](command:snyk.setToken)." }, "snyk.advanced.customEndpoint": { + "order": 2, "type": "string", "markdownDescription": "If you're using SSO with Snyk and OAuth2, the custom endpoint configuration is automatically populated. \n\nOtherwise, for public regional instances, see our [documentation](https://docs.snyk.io/working-with-snyk/regional-hosting-and-data-residency#available-snyk-regions). \n\nFor private instances, contact your team or account manager.", "scope": "window", "pattern": "^(|(https?://)api.*.(snyk|snykgov).io)$" }, + "snyk.advanced.autoSelectOrganization": { + "order": 4, + "type": "boolean", + "markdownDescription": "Use automatic organization selection. When enabled, Snyk will automatically select the most appropriate organization for your project using context found in your repository and your authentication. If an organization is configured manually, this feature will be overridden. If an appropriate organization cannot be identified automatically, the preferred organization defined in your [web account settings](https://app.snyk.io/account) will be used as a fallback.", + "scope": "resource", + "default": true + }, "snyk.advanced.organization": { + "order": 5, "type": "string", - "markdownDescription": "Specifies an organization ID to run tests for that organization.\n\nRetrieve the organization ID from the organization settings in the Snyk UI: `https://app.snyk.io/org/[ORG_NAME]/manage/settings` and copy the ID from the **Organization ID** section.\n\nNote: If not specified, the preferred organization as defined in your [web account settings](https://app.snyk.io/account) is used to run tests.", - "scope": "window" + "markdownDescription": "Specify the organization (ID or name) for Snyk to run scans against. If the organization is provided manually, automatic organization selection is overridden. If the organization value is blank or invalid, the preferred organization defined in your [web account settings](https://app.snyk.io/account) will be used.", + "scope": "resource" }, "snyk.yesCrashReport": { - "//": "Name starts with y to put it at the end, as configs are sorted alphbetically", + "order": 6, "type": "boolean", "default": true, "markdownDescription": "Send error reports to Snyk", @@ -128,7 +139,7 @@ "order": 2, "type": "boolean", "title": "Snyk Code security issues", - "description": "Find and fix security issues in your application code in real time.", + "description": "Find and fix security issues in your application code in real time.\n\nFor these scans to run, note that it must be enabled for the organization.", "default": true }, "snyk.features.infrastructureAsCode": { @@ -189,7 +200,7 @@ } }, "additionalProperties": false, - "markdownDescription": "[Code Consistent Ignores](https://docs.snyk.io/manage-risk/prioritize-issues-for-fixing/ignore-issues/consistent-ignores-for-snyk-code) is a feature which provides consistent handling of \"ignore\" rules for code security findings across all surfaces - such as the CLI, IDE, and Snyk UI\n\nShow the following issues:", + "markdownDescription": "[Code Consistent Ignores](https://docs.snyk.io/manage-risk/prioritize-issues-for-fixing/ignore-issues/consistent-ignores-for-snyk-code) is a feature which provides consistent handling of \"ignore\" rules for code security findings across all surfaces - such as the CLI, IDE, and Snyk UI.\n\nNote: These filters will do nothing if Code Consistent Ignores (CCI) is disabled for the organization.\n\nShow the following issues:", "scope": "window" }, "snyk.allIssuesVsNetNewIssues": { @@ -243,7 +254,6 @@ }, "snyk.yesBackgroundOssNotification": { "order": 3, - "//": "Name starts with y to put it at the end, as configs are sorted alphabetically", "type": "boolean", "default": true, "markdownDescription": "Show scan notification for critical Open Source Security issues when Snyk view is hidden", @@ -260,7 +270,7 @@ "order": 1, "type": "array", "default": [], - "description": "Folder configuration for Snyk scans." + "description": "Folder configuration for Snyk scans. This value is read-only, any changes made will be overridden." }, "snyk.securityAtInception.autoConfigureSnykMcpServer": { "order": 2, @@ -305,13 +315,14 @@ "order": 5, "properties": { "snyk.yesWelcomeNotification": { - "//": "Name starts with y to put it at the end, as configs are sorted alphabetically", + "order": 2, "type": "boolean", "default": true, "markdownDescription": "Show welcome notification after installation and restart", "scope": "application" }, "snyk.trustedFolders": { + "order": 1, "type": "array", "default": [], "description": "Folders to trust for Snyk scans." diff --git a/src/snyk/cli/services/cliService.ts b/src/snyk/cli/services/cliService.ts index b7bdaa1c8..fc3d95a95 100644 --- a/src/snyk/cli/services/cliService.ts +++ b/src/snyk/cli/services/cliService.ts @@ -1,4 +1,4 @@ export class CliError { // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents - constructor(public error: string | Error | unknown, public path?: string, public isCancellation = false) {} + constructor(public error: string, public path?: string, public isCancellation = false) {} } diff --git a/src/snyk/common/commands/commandController.ts b/src/snyk/common/commands/commandController.ts index 73c97ae50..c3ebfb84f 100644 --- a/src/snyk/common/commands/commandController.ts +++ b/src/snyk/common/commands/commandController.ts @@ -1,4 +1,5 @@ import _ from 'lodash'; +import * as vscode from 'vscode'; import { IAuthenticationService } from '../../base/services/authenticationService'; import { createDCIgnore as createDCIgnoreUtil } from '../../snykCode/utils/ignoreFileUtils'; import { CodeIssueCommandArg } from '../../snykCode/views/interfaces'; @@ -15,7 +16,7 @@ import { import { COMMAND_DEBOUNCE_INTERVAL } from '../constants/general'; import { ErrorHandler } from '../error/errorHandler'; import { ILanguageServer } from '../languageServer/languageServer'; -import { CodeIssueData, IacIssueData } from '../languageServer/types'; +import { CodeIssueData, IacIssueData, PresentableError } from '../languageServer/types'; import { ILog } from '../logger/interfaces'; import { IOpenerService } from '../services/openerService'; import { IProductService } from '../services/productService'; @@ -63,7 +64,10 @@ export class CommandController { async openLocal(path: Uri, range?: Range): Promise { try { - await this.window.showTextDocumentViaUri(path, { viewColumn: 1, selection: range }); + await this.window.showTextDocumentViaUri(path, { + viewColumn: 1, + selection: range, + }); } catch (e) { ErrorHandler.handle(e, this.logger); } @@ -71,7 +75,10 @@ export class CommandController { async openLocalFile(filePath: string, range?: Range): Promise { try { - await this.window.showTextDocumentViaFilepath(filePath, { viewColumn: 1, selection: range }); + await this.window.showTextDocumentViaFilepath(filePath, { + viewColumn: 1, + selection: range, + }); } catch (e) { ErrorHandler.handle(e, this.logger); } @@ -95,7 +102,7 @@ export class CommandController { async createDCIgnore(custom = false, uriAdapter: IUriAdapter, path?: string): Promise { if (!path) { - const paths = this.workspace.getWorkspaceFolders(); + const paths = this.workspace.getWorkspaceFolderPaths(); const promises = []; for (const p of paths) { promises.push(createDCIgnoreUtil(p, custom, this.workspace, this.window, uriAdapter)); @@ -161,9 +168,32 @@ export class CommandController { return this.logger.showOutput(); } - showLsOutputChannel(): void { + async showLsOutputChannel(presentableError?: PresentableError): Promise { // To get an instance of an OutputChannel use createOutputChannel. - return this.languageServer.showOutputChannel(); + this.languageServer.showOutputChannel(); + + if (presentableError?.error) { + // Format JSON as rows, excluding showNotification, treeNodeSuffix, and empty values + const details = Object.entries(presentableError) + .filter(([key]) => key !== 'showNotification' && key !== 'treeNodeSuffix') + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .map(([key, value]) => `${key}: ${JSON.stringify(value)}`) + .join('\n'); + + const copyButton = 'Copy'; + const result = await vscode.window.showInformationMessage( + details, + { + modal: true, + detail: `You can copy the error message and use the filter field in the output channel to locate it.`, + }, + copyButton, + ); + + if (result === copyButton) { + await vscode.env.clipboard.writeText(presentableError.error); + } + } } async executeCommand( diff --git a/src/snyk/common/configuration/configuration.ts b/src/snyk/common/configuration/configuration.ts index 2fe61fed2..c77e261ac 100644 --- a/src/snyk/common/configuration/configuration.ts +++ b/src/snyk/common/configuration/configuration.ts @@ -7,6 +7,7 @@ import { ADVANCED_ADDITIONAL_PARAMETERS_SETTING, ADVANCED_ADVANCED_MODE_SETTING, ADVANCED_AUTHENTICATION_METHOD, + ADVANCED_AUTO_SELECT_ORGANIZATION, ADVANCED_AUTOMATIC_DEPENDENCY_MANAGEMENT, ADVANCED_AUTOSCAN_OSS_SETTING, ADVANCED_CLI_BASE_DOWNLOAD_URL, @@ -34,6 +35,7 @@ import { } from '../constants/settings'; import SecretStorageAdapter from '../vscode/secretStorage'; import { IVSCodeWorkspace } from '../vscode/workspace'; +import { WorkspaceFolder } from '../vscode/types'; export const NEWISSUES = 'Net new issues'; export const ALLISSUES = 'All issues'; @@ -57,6 +59,10 @@ export type FolderConfig = { localBranches: string[] | undefined; referenceFolderPath: string | undefined; scanCommandConfig?: Record; + orgSetByUser: boolean; + preferredOrg: string; + autoDeterminedOrg: string; + orgMigratedFromGlobalConfig: boolean; }; export interface IssueViewOptions { @@ -89,6 +95,8 @@ export const DEFAULT_SEVERITY_FILTER: SeverityFilter = { low: true, }; +const DEFAULT_AUTO_ORGANIZATION = true; // Should match value in package.json. + export type PreviewFeatures = Record; export interface IConfiguration { @@ -123,6 +131,16 @@ export interface IConfiguration { organization: string | undefined; + isAutoSelectOrganizationEnabled(workspaceFolder: WorkspaceFolder): boolean; + + setAutoSelectOrganization(workspaceFolder: WorkspaceFolder, autoSelectOrganization: boolean): Promise; + + getOrganization(workspaceFolder: WorkspaceFolder): string | undefined; + + getOrganizationAtWorkspaceFolderLevel(workspaceFolder: WorkspaceFolder): string | undefined; + + setOrganization(workspaceFolder: WorkspaceFolder, organization?: string): Promise; + getAdditionalCliParameters(): string | undefined; snykApiEndpoint: string; @@ -179,6 +197,8 @@ export interface IConfiguration { setFolderConfigs(folderConfig: FolderConfig[]): Promise; + getConfigurationAtFolderLevelOnly(configSettingName: string, workspaceFolder: WorkspaceFolder): T | undefined; + getSecureAtInceptionExecutionFrequency(): string; setSecureAtInceptionExecutionFrequency(frequency: string): Promise; @@ -556,10 +576,67 @@ export class Configuration implements IConfiguration { return config ?? DEFAULT_SEVERITY_FILTER; } + /** + * Gets the auto organization setting for a workspace folder, considering all levels (folder, workspace, global, default). + */ + isAutoSelectOrganizationEnabled(workspaceFolder: WorkspaceFolder): boolean { + return ( + this.workspace.getConfiguration( + CONFIGURATION_IDENTIFIER, + this.getConfigName(ADVANCED_AUTO_SELECT_ORGANIZATION), + workspaceFolder, + ) ?? DEFAULT_AUTO_ORGANIZATION + ); + } + + /** + * Sets the auto organization setting at the workspace folder level. + */ + async setAutoSelectOrganization(workspaceFolder: WorkspaceFolder, autoSelectOrganization: boolean): Promise { + await this.workspace.updateConfiguration( + CONFIGURATION_IDENTIFIER, + this.getConfigName(ADVANCED_AUTO_SELECT_ORGANIZATION), + autoSelectOrganization, + workspaceFolder, + ); + } + + /** + * Gets the organization setting from the global & workspace scopes only. + */ get organization(): string | undefined { return this.workspace.getConfiguration(CONFIGURATION_IDENTIFIER, this.getConfigName(ADVANCED_ORGANIZATION)); } + getOrganization(workspaceFolder: WorkspaceFolder): string | undefined { + return this.workspace.getConfiguration( + CONFIGURATION_IDENTIFIER, + this.getConfigName(ADVANCED_ORGANIZATION), + workspaceFolder, + ); + } + + getOrganizationAtWorkspaceFolderLevel(workspaceFolder: WorkspaceFolder): string | undefined { + return this.workspace.inspectConfiguration( + CONFIGURATION_IDENTIFIER, + this.getConfigName(ADVANCED_ORGANIZATION), + workspaceFolder, + )?.workspaceFolderValue; + } + + /** + * Sets the organization at the workspace folder level. + * If the empty string or undefined is provided, the organization will be cleared. + */ + async setOrganization(workspaceFolder: WorkspaceFolder, organization?: string): Promise { + await this.workspace.updateConfiguration( + CONFIGURATION_IDENTIFIER, + this.getConfigName(ADVANCED_ORGANIZATION), + organization === '' ? undefined : organization, + workspaceFolder, + ); + } + getPreviewFeatures(): PreviewFeatures { const defaultSetting: PreviewFeatures = {}; @@ -644,6 +721,19 @@ export class Configuration implements IConfiguration { ); } + /** + * Gets a configuration setting ONLY at the workspace folder level (no fallback to workspace/global). + * Returns undefined if the setting is not specifically set at the folder level. + */ + getConfigurationAtFolderLevelOnly(configSettingName: string, workspaceFolder: WorkspaceFolder): T | undefined { + const inspectionResult = this.workspace.inspectConfiguration( + CONFIGURATION_IDENTIFIER, + this.getConfigName(configSettingName), + workspaceFolder, + ); + return inspectionResult?.workspaceFolderValue; + } + private getConfigName = (setting: string) => setting.replace(`${CONFIGURATION_IDENTIFIER}.`, ''); async setSecureAtInceptionExecutionFrequency(frequency: string): Promise { diff --git a/src/snyk/common/configuration/folderConfigs.ts b/src/snyk/common/configuration/folderConfigs.ts index eca999292..051405b01 100644 --- a/src/snyk/common/configuration/folderConfigs.ts +++ b/src/snyk/common/configuration/folderConfigs.ts @@ -11,26 +11,16 @@ export interface IFolderConfigs { setBranch(window: IVSCodeWindow, config: IConfiguration, folderPath: string): Promise; setReferenceFolder(window: IVSCodeWindow, config: IConfiguration, folderPath: string): Promise; - - resetFolderConfigsCache(): void; } export class FolderConfigs implements IFolderConfigs { - private folderConfigsCache?: ReadonlyArray; - getFolderConfig(config: IConfiguration, folderPath: string): FolderConfig | undefined { const folderConfigs = this.getFolderConfigs(config); return folderConfigs.find(i => i.folderPath === folderPath); } getFolderConfigs(config: IConfiguration): ReadonlyArray { - if (this.folderConfigsCache !== undefined) { - return this.folderConfigsCache; - } - const folderConfigs = config.getFolderConfigs(); - this.folderConfigsCache = folderConfigs; - - return folderConfigs; + return config.getFolderConfigs(); } async setReferenceFolder(window: IVSCodeWindow, config: IConfiguration, folderPath: string): Promise { @@ -91,10 +81,5 @@ export class FolderConfigs implements IFolderConfigs { i.folderPath === folderConfig.folderPath ? folderConfig : i, ); await config.setFolderConfigs(finalFolderConfigs); - this.folderConfigsCache = finalFolderConfigs; - } - - resetFolderConfigsCache() { - this.folderConfigsCache = undefined; } } diff --git a/src/snyk/common/constants/languageServer.ts b/src/snyk/common/constants/languageServer.ts index cfeb976aa..05ab61907 100644 --- a/src/snyk/common/constants/languageServer.ts +++ b/src/snyk/common/constants/languageServer.ts @@ -2,7 +2,7 @@ // Language Server name, used e.g. for the output channel export const SNYK_LANGUAGE_SERVER_NAME = 'Snyk Language Server'; // The internal language server protocol version for custom messages and configuration -export const PROTOCOL_VERSION = 20; +export const PROTOCOL_VERSION = 21; // LS protocol methods (needed for not having to rely on vscode dependencies in testing) export const DID_CHANGE_CONFIGURATION_METHOD = 'workspace/didChangeConfiguration'; diff --git a/src/snyk/common/constants/settings.ts b/src/snyk/common/constants/settings.ts index 79b5d4559..837c059c2 100644 --- a/src/snyk/common/constants/settings.ts +++ b/src/snyk/common/constants/settings.ts @@ -15,6 +15,7 @@ export const ADVANCED_ADVANCED_MODE_SETTING = `${CONFIGURATION_IDENTIFIER}.advan export const ADVANCED_AUTOSCAN_OSS_SETTING = `${CONFIGURATION_IDENTIFIER}.advanced.autoScanOpenSourceSecurity`; export const ADVANCED_ADDITIONAL_PARAMETERS_SETTING = `${CONFIGURATION_IDENTIFIER}.advanced.additionalParameters`; export const ADVANCED_CUSTOM_ENDPOINT = `${CONFIGURATION_IDENTIFIER}.advanced.customEndpoint`; +export const ADVANCED_AUTO_SELECT_ORGANIZATION = `${CONFIGURATION_IDENTIFIER}.advanced.autoSelectOrganization`; export const ADVANCED_ORGANIZATION = `${CONFIGURATION_IDENTIFIER}.advanced.organization`; export const ADVANCED_AUTOMATIC_DEPENDENCY_MANAGEMENT = `${CONFIGURATION_IDENTIFIER}.advanced.automaticDependencyManagement`; export const ADVANCED_CLI_PATH = `${CONFIGURATION_IDENTIFIER}.advanced.cliPath`; diff --git a/src/snyk/common/editor/codeActionsProvider.ts b/src/snyk/common/editor/codeActionsProvider.ts index 68b1f394d..4c7484cf9 100644 --- a/src/snyk/common/editor/codeActionsProvider.ts +++ b/src/snyk/common/editor/codeActionsProvider.ts @@ -1,4 +1,4 @@ -import { Issue } from '../languageServer/types'; +import { Issue, isPresentableError } from '../languageServer/types'; import { ICodeActionKindAdapter } from '../vscode/codeAction'; import { CodeAction, CodeActionContext, CodeActionProvider, Range, TextDocument } from '../vscode/types'; import { ProductResult } from '../services/productService'; @@ -25,7 +25,7 @@ export abstract class CodeActionsProvider implements CodeActionProvider { } for (const [folderPath, issues] of this.issues.entries()) { - if (issues instanceof Error || !issues) { + if (isPresentableError(issues) || !issues) { continue; } diff --git a/src/snyk/common/languageServer/languageServer.ts b/src/snyk/common/languageServer/languageServer.ts index 05a63ae14..70e819db0 100644 --- a/src/snyk/common/languageServer/languageServer.ts +++ b/src/snyk/common/languageServer/languageServer.ts @@ -38,17 +38,23 @@ export interface ILanguageServer { showOutputChannel(): void; cliReady$: ReplaySubject; - scan$: Subject>; + scan$: Subject; showIssueDetailTopic$: Subject; } export class LanguageServer implements ILanguageServer { private client: LanguageClient; readonly cliReady$ = new ReplaySubject(1); - readonly scan$ = new Subject>(); + readonly scan$ = new Subject(); private geminiIntegrationService: GeminiIntegrationService; readonly showIssueDetailTopic$ = new Subject(); public static ReceivedFolderConfigsFromLs = false; + // Track folder paths where LS is updating org settings to prevent circular updates + private static foldersBeingUpdatedByLS = new Set(); + + static isLSUpdatingOrg(folderPath: string): boolean { + return LanguageServer.foldersBeingUpdatedByLS.has(folderPath); + } constructor( private user: User, @@ -197,8 +203,29 @@ export class LanguageServer implements ILanguageServer { }); client.onNotification(SNYK_FOLDERCONFIG, ({ folderConfigs }: { folderConfigs: FolderConfig[] }) => { - LanguageServer.ReceivedFolderConfigsFromLs = true; - this.configuration.setFolderConfigs(folderConfigs).catch((error: Error) => { + // Process each folder config: merge on first receipt, handle org settings on subsequent receipts + const processedFolderConfigs = folderConfigs.map(folderConfig => { + const isFirstReceipt = !this.configuration + .getFolderConfigs() + .find(cachedFC => cachedFC.folderPath === folderConfig.folderPath); + if (isFirstReceipt) { + // First time receiving config for this folder - merge VS Code settings into LS config + return this.mergeOrgSettingsIntoLSFolderConfig(folderConfig); + } + + // Subsequent receipt - return as-is (will be handled by handleOrgSettingsFromFolderConfigs) + return folderConfig; + }); + + // Update org settings in VS Code UI to reflect the current state + this.handleOrgSettingsFromFolderConfigs(processedFolderConfigs); + + // Set global flag after first folder config received (used for initialization options) + if (!LanguageServer.ReceivedFolderConfigsFromLs) { + LanguageServer.ReceivedFolderConfigsFromLs = true; + } + + this.configuration.setFolderConfigs(processedFolderConfigs).catch((error: Error) => { ErrorHandler.handle(error, this.logger, error instanceof Error ? error.message : 'An error occurred'); }); }); @@ -209,7 +236,7 @@ export class LanguageServer implements ILanguageServer { }); }); - client.onNotification(SNYK_SCAN, (scan: Scan) => { + client.onNotification(SNYK_SCAN, (scan: Scan) => { this.logger.info(`${_.capitalize(scan.product)} scan for ${scan.folderPath}: ${scan.status}.`); this.scan$.next(scan); }); @@ -233,6 +260,85 @@ export class LanguageServer implements ILanguageServer { this.client.outputChannel.show(); } + private handleOrgSettingsFromFolderConfigs(folderConfigs: FolderConfig[]): void { + const currentWorkspaceFolders = this.workspace.getWorkspaceFolders(); + + folderConfigs.forEach(folderConfig => { + // Only set organization for folders that are part of the current VS Code workspace + const workspaceFolder = currentWorkspaceFolders.find( + workspaceFolder => folderConfig.folderPath === workspaceFolder.uri.fsPath, + ); + + if (!workspaceFolder) { + this.logger.warn(`No workspace folder found for path: ${folderConfig.folderPath}`); + return; + } + + const orgToDisplay = folderConfig.orgSetByUser ? folderConfig.preferredOrg : folderConfig.autoDeterminedOrg; + + // Mark this folder as being updated by LS to prevent circular updates in the watcher + LanguageServer.foldersBeingUpdatedByLS.add(folderConfig.folderPath); + + this.configuration + .setOrganization(workspaceFolder, orgToDisplay) + .then( + () => { + this.logger.debug( + `Set organization "${orgToDisplay}" for workspace folder: ${folderConfig.folderPath} (orgSetByUser: ${folderConfig.orgSetByUser})`, + ); + }, + error => { + this.logger.warn(`Failed to set organization for folder ${folderConfig.folderPath}: ${error}`); + }, + ) + .finally(() => { + // Clear the flag after update completes (whether success or error) + LanguageServer.foldersBeingUpdatedByLS.delete(folderConfig.folderPath); + }); + + // Set auto-organization at workspace folder level only if the desired value differs from + // the current configuration value when querying all levels (folder, workspace, global, default). + // Unless the desired auto-org is true (selected), then it should be written at the folder level. + const desiredAutoOrg = !folderConfig.orgSetByUser; + const currentAutoOrg = this.configuration.isAutoSelectOrganizationEnabled(workspaceFolder); + + if (desiredAutoOrg !== currentAutoOrg || desiredAutoOrg) { + this.configuration.setAutoSelectOrganization(workspaceFolder, desiredAutoOrg).then( + () => { + this.logger.debug( + `Set auto-organization to ${desiredAutoOrg} for workspace folder: ${folderConfig.folderPath}`, + ); + }, + error => { + this.logger.warn(`Failed to set auto-organization for folder ${folderConfig.folderPath}: ${error}`); + }, + ); + } + }); + } + + private mergeOrgSettingsIntoLSFolderConfig(folderConfig: FolderConfig): FolderConfig { + const workspaceFolder = this.workspace.getWorkspaceFolder(folderConfig.folderPath); + if (!workspaceFolder) { + // LS must be crazy, we don't know of this folder, so we will just store it as-is. + return folderConfig; + } + + const orgSetByUser = !this.configuration.isAutoSelectOrganizationEnabled(workspaceFolder); + if (orgSetByUser) { + return { + ...folderConfig, + preferredOrg: this.configuration.getOrganizationAtWorkspaceFolderLevel(workspaceFolder) ?? '', + orgSetByUser: true, + }; + } else { + return { + ...folderConfig, + orgSetByUser: false, + }; + } + } + async stop(): Promise { this.logger.info('Stopping Snyk Language Server...'); if (!this.client) { diff --git a/src/snyk/common/languageServer/types.ts b/src/snyk/common/languageServer/types.ts index 19008bf8e..d797c16ce 100644 --- a/src/snyk/common/languageServer/types.ts +++ b/src/snyk/common/languageServer/types.ts @@ -19,17 +19,30 @@ export enum ScanStatus { Error = 'error', } -export enum LsErrorMessage { - repositoryInvalidError = 'repository does not exist', - missingDeltaReferenceError = 'must specify reference for delta scans', +export type PresentableError = { + code?: number; + error?: string; + path?: string; + command?: string; + showNotification: boolean; + treeNodeSuffix: string; +}; + +export function isPresentableError(result: unknown): result is PresentableError { + return ( + typeof result === 'object' && + result !== null && + 'error' in result && + 'treeNodeSuffix' in result && + !Array.isArray(result) + ); } -export type Scan = { +export type Scan = { folderPath: string; product: ScanProduct; status: ScanStatus; - issues: Issue[]; - errorMessage: string; + presentableError?: PresentableError; }; export type Issue = { diff --git a/src/snyk/common/llm/geminiIntegrationService.ts b/src/snyk/common/llm/geminiIntegrationService.ts index ff9a22faf..b8a995d3b 100644 --- a/src/snyk/common/llm/geminiIntegrationService.ts +++ b/src/snyk/common/llm/geminiIntegrationService.ts @@ -44,7 +44,7 @@ export class GeminiIntegrationService { private readonly logger: ILog, private readonly configuration: IConfiguration, private readonly extensionContext: IExtensionRetriever, - private readonly scan$: Subject>, + private readonly scan$: Subject, private readonly uriAdapter: IUriAdapter, private readonly markdownAdapter: IMarkdownStringAdapter, private readonly codeCommands: IVSCodeCommands, @@ -158,7 +158,7 @@ export class GeminiIntegrationService { let openScansCount = this.countEnabledProducts(); // subscribe to snyk scan topic to get issue data - this.scan$.subscribe((scan: Scan) => { + this.scan$.subscribe((scan: Scan) => { // const msg = 'Scan status for ' + scan.folderPath + ': ' + scan.status + '.'; // responseStream.push(this.markdownAdapter.get(msg)); diff --git a/src/snyk/common/services/productService.ts b/src/snyk/common/services/productService.ts index 8fcd99399..f63df9109 100644 --- a/src/snyk/common/services/productService.ts +++ b/src/snyk/common/services/productService.ts @@ -4,7 +4,7 @@ import { IConfiguration } from '../configuration/configuration'; import { IWorkspaceTrust } from '../configuration/trustedFolders'; import { CodeActionsProvider } from '../editor/codeActionsProvider'; import { ILanguageServer } from '../languageServer/languageServer'; -import { Issue, LsScanProduct, Scan, ScanProduct, ScanStatus } from '../languageServer/types'; +import { Issue, isPresentableError, LsScanProduct, PresentableError, Scan, ScanProduct, ScanStatus } from '../languageServer/types'; import { ILog } from '../logger/interfaces'; import { IViewManagerService } from './viewManagerService'; import { IProductWebviewProvider } from '../views/webviewProvider'; @@ -14,7 +14,7 @@ import { Disposable } from '../vscode/types'; import { IVSCodeWorkspace } from '../vscode/workspace'; import { IDiagnosticsIssueProvider } from './diagnosticsService'; -export type WorkspaceFolderResult = Issue[] | Error; +export type WorkspaceFolderResult = Issue[] | PresentableError; export type ProductResult = Map>; // map of a workspace folder to results array or an error occurred in this folder export interface IProductService extends AnalysisStatusProvider, Disposable { @@ -78,7 +78,7 @@ export abstract class ProductService extends AnalysisStatusProvider implement getIssue(folderPath: string, issueId: string): Issue | undefined { const folderResult = this._result.get(folderPath); - if (folderResult instanceof Error) { + if (isPresentableError(folderResult)) { return undefined; } @@ -88,7 +88,7 @@ export abstract class ProductService extends AnalysisStatusProvider implement getIssueById(issueId: string): Issue | undefined { const results = this._result.values(); for (const folderResult of results) { - if (folderResult instanceof Error) { + if (isPresentableError(folderResult)) { return undefined; } @@ -106,7 +106,7 @@ export abstract class ProductService extends AnalysisStatusProvider implement } get isAnyWorkspaceFolderTrusted(): boolean { - const workspacePaths = this.workspace.getWorkspaceFolders(); + const workspacePaths = this.workspace.getWorkspaceFolderPaths(); return this.workspaceTrust.getTrustedFolders(this.config, workspacePaths).length > 0; } @@ -162,7 +162,7 @@ export abstract class ProductService extends AnalysisStatusProvider implement } // Must be called from the child class to listen on scan messages - handleLsScanMessage(scanMsg: Scan) { + handleLsScanMessage(scanMsg: Scan) { if (scanMsg.status == ScanStatus.InProgress) { if (!this.isAnalysisRunning) { this.analysisStarted(); @@ -190,7 +190,7 @@ export abstract class ProductService extends AnalysisStatusProvider implement }); } - private handleSuccessOrError(scanMsg: Scan) { + private handleSuccessOrError(scanMsg: Scan) { this.runningScanCount--; if (scanMsg.status == ScanStatus.Success) { @@ -200,7 +200,7 @@ export abstract class ProductService extends AnalysisStatusProvider implement ); this._result.set(scanMsg.folderPath, issues); } else { - this._result.set(scanMsg.folderPath, new Error(scanMsg.errorMessage)); + this._result.set(scanMsg.folderPath, scanMsg.presentableError!); } if (this.runningScanCount <= 0) { diff --git a/src/snyk/common/views/analysisTreeNodeProvider.ts b/src/snyk/common/views/analysisTreeNodeProvider.ts index ad0aca949..ace8f7443 100644 --- a/src/snyk/common/views/analysisTreeNodeProvider.ts +++ b/src/snyk/common/views/analysisTreeNodeProvider.ts @@ -7,6 +7,7 @@ import { NODE_ICONS, TreeNode } from './treeNode'; import { TreeNodeProvider } from './treeNodeProvider'; import { SNYK_NAME_EXTENSION, SNYK_PUBLISHER } from '../constants/general'; import { FEATURE_FLAGS } from '../constants/featureFlags'; +import { PresentableError } from '../languageServer/types'; export abstract class AnalysisTreeNodeProvider extends TreeNodeProvider { constructor(protected readonly configuration: IConfiguration, private statusProvider: AnalysisStatusProvider) { @@ -69,20 +70,18 @@ export abstract class AnalysisTreeNodeProvider extends TreeNodeProvider { return null; } - protected getErrorEncounteredTreeNode( - errorMessage: string = messages.clickToProblem, - showErrorIcon: boolean = true, - ): TreeNode { + protected getErrorEncounteredTreeNode(error: PresentableError, showErrorIcon: boolean = true): TreeNode { return new TreeNode({ ...(showErrorIcon ? { icon: NODE_ICONS.error } : {}), - text: messages.scanFailed, - description: errorMessage, + text: '', + description: messages.clickToProblem, internal: { isError: true, }, command: { command: SNYK_SHOW_LS_OUTPUT_COMMAND, title: '', + arguments: [error], }, }); } diff --git a/src/snyk/common/views/issueTreeProvider.ts b/src/snyk/common/views/issueTreeProvider.ts index 2ef221eb6..ebcb05290 100644 --- a/src/snyk/common/views/issueTreeProvider.ts +++ b/src/snyk/common/views/issueTreeProvider.ts @@ -1,7 +1,7 @@ import _, { flatten } from 'lodash'; import * as vscode from 'vscode'; // todo: invert dependency import { IConfiguration } from '../configuration/configuration'; -import { Issue, IssueSeverity, LsErrorMessage } from '../languageServer/types'; +import { Issue, isPresentableError, IssueSeverity } from '../languageServer/types'; import { messages as commonMessages } from '../../common/messages/analysisMessages'; import { IContextService } from '../services/contextService'; import { IProductService } from '../services/productService'; @@ -15,7 +15,6 @@ import path from 'path'; import { ILog } from '../logger/interfaces'; import { ErrorHandler } from '../error/errorHandler'; import { FEATURE_FLAGS } from '../constants/featureFlags'; -import { isEnumStringValueOf } from '../tsUtil'; export interface ISeverityCounts { [severity: string]: number; @@ -68,7 +67,13 @@ export abstract class ProductIssueTreeProvider extends AnalysisTreeNodeProvid if (!this.shouldShowTree()) return nodes; if (!this.productService.isLsDownloadSuccessful) { - return [this.getErrorEncounteredTreeNode()]; + return [ + this.getErrorEncounteredTreeNode({ + treeNodeSuffix: '(download failed)', + showNotification: false, + error: 'Snyk language server download failed', + }), + ]; } if (!this.productService.isAnyWorkspaceFolderTrusted) { return [this.getNoWorkspaceTrustTreeNode()]; @@ -92,7 +97,7 @@ export abstract class ProductIssueTreeProvider extends AnalysisTreeNodeProvid nodes.push(...this.getResultNodes()); const folderResults = Array.from(this.productService.result.values()); - const allFailed = folderResults.every(folderResult => folderResult instanceof Error); + const allFailed = folderResults.every(folderResult => isPresentableError(folderResult)); if (allFailed) { return nodes; } @@ -220,17 +225,14 @@ export abstract class ProductIssueTreeProvider extends AnalysisTreeNodeProvid let folderIcon: INodeIcon; let folderDescription: string | undefined; - if (folderResult instanceof Error) { + if (isPresentableError(folderResult)) { folderIcon = NODE_ICONS.error; - folderDescription = 'An error occurred'; + folderDescription = folderResult.treeNodeSuffix; if (singleFolderWorkspace) { addTo.push(this.createFolderNode(folderName, folderDescription, folderIcon)); } - const errorMessage = isEnumStringValueOf(LsErrorMessage, folderResult.message) - ? folderResult.toString() - : undefined; - addTo.push(this.getErrorEncounteredTreeNode(errorMessage, false)); + addTo.push(this.getErrorEncounteredTreeNode(folderResult, false)); } else { const { fileNodes, folderVulnCount, folderSeverityCounts } = this.processFolderFiles(folderResult, folderPath); addTo.push(...fileNodes); @@ -265,7 +267,11 @@ export abstract class ProductIssueTreeProvider extends AnalysisTreeNodeProvid private processFolderFiles( issues: Issue[], folderPath: string, - ): { fileNodes: TreeNode[]; folderVulnCount: number; folderSeverityCounts: ISeverityCounts } { + ): { + fileNodes: TreeNode[]; + folderVulnCount: number; + folderSeverityCounts: ISeverityCounts; + } { let folderVulnCount = 0; const folderSeverityCounts = this.initSeverityCounts(); const fileNodes: TreeNode[] = []; diff --git a/src/snyk/common/vscode/types.ts b/src/snyk/common/vscode/types.ts index d6990445f..1c215d8dc 100644 --- a/src/snyk/common/vscode/types.ts +++ b/src/snyk/common/vscode/types.ts @@ -31,6 +31,7 @@ export type SecretStorage = vscode.SecretStorage; export type SecretStorageChangeEvent = vscode.SecretStorageChangeEvent; export type Event = vscode.Event; export type Uri = vscode.Uri; +export type WorkspaceFolder = vscode.WorkspaceFolder; export type MarkdownString = vscode.MarkdownString; export type CodeAction = vscode.CodeAction; export type CodeActionKind = vscode.CodeActionKind; diff --git a/src/snyk/common/vscode/workspace.ts b/src/snyk/common/vscode/workspace.ts index 6a6d00732..d48038560 100644 --- a/src/snyk/common/vscode/workspace.ts +++ b/src/snyk/common/vscode/workspace.ts @@ -1,23 +1,51 @@ import * as vscode from 'vscode'; import { TextDocumentChangeEvent } from 'vscode'; -import { TextDocument, Uri } from './types'; +import { TextDocument, Uri, WorkspaceFolder } from './types'; export interface IVSCodeWorkspace { fs: vscode.FileSystem; - getConfiguration(configurationIdentifier: string, section: string): T | undefined; + getConfiguration( + configurationIdentifier: string, + section: string, + workspaceFolder?: WorkspaceFolder, + ): T | undefined; + + inspectConfiguration( + configurationIdentifier: string, + section: string, + workspaceFolder?: WorkspaceFolder, + ): + | { + globalValue?: T; + workspaceValue?: T; + workspaceFolderValue?: T; + defaultValue?: T; + } + | undefined; + updateConfiguration( configurationIdentifier: string, section: string, value: unknown, - configurationTarget?: boolean, + configurationTarget?: boolean | WorkspaceFolder, overrideInLanguage?: boolean, ): Promise; - getWorkspaceFolders(): string[]; + + getWorkspaceFolders(): readonly WorkspaceFolder[]; + + getWorkspaceFolderPaths(): string[]; + + getWorkspaceFolder(folderPath: string): WorkspaceFolder | undefined; + createFileSystemWatcher(globPattern: string): vscode.FileSystemWatcher; + onDidChangeTextDocument(listener: (e: TextDocumentChangeEvent) => unknown): vscode.Disposable; + openFileTextDocument(fileName: string): Promise; + openTextDocument(options?: { language?: string; content?: string }): Promise; + openTextDocumentViaUri(uri: Uri): Promise; } @@ -25,20 +53,46 @@ export interface IVSCodeWorkspace { * A wrapper class for the vscode.workspace to provide centralised access to dealing with the current workspace. */ export class VSCodeWorkspace implements IVSCodeWorkspace { - getConfiguration(configurationIdentifier: string, section: string): T | undefined { - return vscode.workspace.getConfiguration(configurationIdentifier).get(section); + getConfiguration( + configurationIdentifier: string, + section: string, + workspaceFolder?: WorkspaceFolder, + ): T | undefined { + return vscode.workspace.getConfiguration(configurationIdentifier, workspaceFolder).get(section); + } + + inspectConfiguration( + configurationIdentifier: string, + section: string, + workspaceFolder?: WorkspaceFolder, + ): + | { + globalValue?: T; + workspaceValue?: T; + workspaceFolderValue?: T; + defaultValue?: T; + } + | undefined { + return vscode.workspace.getConfiguration(configurationIdentifier, workspaceFolder).inspect(section); } updateConfiguration( configurationIdentifier: string, section: string, value: unknown, - configurationTarget?: boolean, + configurationTarget?: boolean | WorkspaceFolder, overrideInLanguage?: boolean, ): Promise { return new Promise((resolve, reject) => { + let workspaceFolder: WorkspaceFolder | undefined; + if (typeof configurationTarget === 'object') { + // configurationTarget is a WorkspaceFolder - set at folder level + workspaceFolder = configurationTarget; + configurationTarget = undefined; // undefined == folder level + } + vscode.workspace - .getConfiguration(configurationIdentifier) + .getConfiguration(configurationIdentifier, workspaceFolder) .update(section, value, configurationTarget, overrideInLanguage) .then( () => resolve(), @@ -47,8 +101,16 @@ export class VSCodeWorkspace implements IVSCodeWorkspace { }); } - getWorkspaceFolders(): string[] { - return (vscode.workspace.workspaceFolders || []).map(f => f.uri.fsPath); + getWorkspaceFolders(): readonly WorkspaceFolder[] { + return vscode.workspace.workspaceFolders || []; + } + + getWorkspaceFolderPaths(): string[] { + return this.getWorkspaceFolders().map(f => f.uri.fsPath); + } + + getWorkspaceFolder(folderPath: string): WorkspaceFolder | undefined { + return this.getWorkspaceFolders().find(f => f.uri.fsPath === folderPath); } createFileSystemWatcher(globPattern: string): vscode.FileSystemWatcher { diff --git a/src/snyk/common/watchers/configurationWatcher.ts b/src/snyk/common/watchers/configurationWatcher.ts index 2322dd738..7c4a80ec3 100644 --- a/src/snyk/common/watchers/configurationWatcher.ts +++ b/src/snyk/common/watchers/configurationWatcher.ts @@ -7,6 +7,7 @@ import { ADVANCED_ADVANCED_MODE_SETTING, ADVANCED_AUTOSCAN_OSS_SETTING, ADVANCED_CUSTOM_ENDPOINT, + ADVANCED_AUTO_SELECT_ORGANIZATION, ADVANCED_ORGANIZATION, IAC_ENABLED_SETTING, ISSUE_VIEW_OPTIONS_SETTING, @@ -26,10 +27,12 @@ import { ErrorHandler } from '../error/errorHandler'; import { ILog } from '../logger/interfaces'; import { errorsLogs } from '../messages/errors'; import SecretStorageAdapter from '../vscode/secretStorage'; +import { vsCodeWorkspace } from '../vscode/workspace'; import { IWatcher } from './interfaces'; import { SNYK_CONTEXT } from '../constants/views'; import { handleSecurityAtInceptionChange } from '../configuration/securityAtInceptionHandler'; import { User } from '../user'; +import { LanguageServer } from '../languageServer/languageServer'; class ConfigurationWatcher implements IWatcher { constructor( @@ -38,8 +41,15 @@ class ConfigurationWatcher implements IWatcher { private readonly vscodeContext: vscode.ExtensionContext, ) {} - private async onChangeConfiguration(extension: IExtension, key: string): Promise { - if (key === ADVANCED_ORGANIZATION) { + private async onChangeConfiguration( + extension: IExtension, + key: string, + event: vscode.ConfigurationChangeEvent, + ): Promise { + if (key === ADVANCED_AUTO_SELECT_ORGANIZATION) { + return this.syncFolderConfigAutoOrgOnChange(event); + } else if (key === ADVANCED_ORGANIZATION) { + await this.syncFolderConfigPreferredOrgOnWorkspaceFolderOrgSettingChanged(event); return extension.setupFeatureFlags(); } else if (key === ADVANCED_ADVANCED_MODE_SETTING) { return extension.checkAdvancedMode(); @@ -93,6 +103,7 @@ class ConfigurationWatcher implements IWatcher { const change = [ ADVANCED_ADVANCED_MODE_SETTING, ADVANCED_AUTOSCAN_OSS_SETTING, + ADVANCED_AUTO_SELECT_ORGANIZATION, ADVANCED_ORGANIZATION, OSS_ENABLED_SETTING, CODE_SECURITY_ENABLED_SETTING, @@ -112,7 +123,7 @@ class ConfigurationWatcher implements IWatcher { if (change) { try { - await this.onChangeConfiguration(extension, change); + await this.onChangeConfiguration(extension, change, event); } catch (error) { ErrorHandler.handle(error, this.logger, `${errorsLogs.configWatcher}. Configuration key: ${change}`); } @@ -125,6 +136,76 @@ class ConfigurationWatcher implements IWatcher { } }); } + + private async syncFolderConfigAutoOrgOnChange(event: vscode.ConfigurationChangeEvent): Promise { + const affectedWorkspaceFolders = vsCodeWorkspace + .getWorkspaceFolders() + .filter(folder => event.affectsConfiguration(ADVANCED_AUTO_SELECT_ORGANIZATION, folder)); + + if (affectedWorkspaceFolders.length === 0) { + return; + } + + const updatedFolderConfigs = configuration.getFolderConfigs().map(folderConfig => { + const workspaceFolder = affectedWorkspaceFolders.find( + workspaceFolder => workspaceFolder.uri.fsPath === folderConfig.folderPath, + ); + if (!workspaceFolder) { + return folderConfig; + } + + // Get the effective value considering all levels (global, workspace, folder) + const autoOrgEnabled = configuration.isAutoSelectOrganizationEnabled(workspaceFolder); + + return { + ...folderConfig, + orgSetByUser: !autoOrgEnabled, + }; + }); + + await configuration.setFolderConfigs(updatedFolderConfigs); + } + + private async syncFolderConfigPreferredOrgOnWorkspaceFolderOrgSettingChanged( + event: vscode.ConfigurationChangeEvent, + ): Promise { + const affectedWorkspaceFolders = vsCodeWorkspace + .getWorkspaceFolders() + .filter(folder => event.affectsConfiguration(ADVANCED_ORGANIZATION, folder)); + + if (affectedWorkspaceFolders.length === 0) { + return; + } + + const updatedFolderConfigs = configuration.getFolderConfigs().map(folderConfig => { + const workspaceFolder = affectedWorkspaceFolders.find( + workspaceFolder => workspaceFolder.uri.fsPath === folderConfig.folderPath, + ); + if (!workspaceFolder) { + return folderConfig; + } + + // Skip updates triggered by LS displaying org from folder configs + if (LanguageServer.isLSUpdatingOrg(folderConfig.folderPath)) { + return folderConfig; + } + + const orgValueAtFolderLevel = configuration.getConfigurationAtFolderLevelOnly( + ADVANCED_ORGANIZATION, + workspaceFolder, + ); + + // Update preferredOrg with the new value + // Note: We could set orgSetByUser=true here when detecting a user change from auto-org mode, + // but we let LS handle it - LS will detect the preferredOrg change and set orgSetByUser=true + return { + ...folderConfig, + preferredOrg: orgValueAtFolderLevel ?? '', + }; + }); + + await configuration.setFolderConfigs(updatedFolderConfigs); + } } export default ConfigurationWatcher; diff --git a/src/snyk/extension.ts b/src/snyk/extension.ts index c54c7d16d..78fbf7681 100644 --- a/src/snyk/extension.ts +++ b/src/snyk/extension.ts @@ -7,6 +7,7 @@ import { EmptyTreeDataProvider } from './base/views/emptyTreeDataProvider'; import { SupportProvider } from './base/views/supportProvider'; import { CommandController } from './common/commands/commandController'; import { OpenIssueCommandArg } from './common/commands/types'; +import { PresentableError } from './common/languageServer/types'; import { configuration } from './common/configuration/instance'; import { SnykConfiguration } from './common/configuration/snykConfiguration'; import { @@ -169,7 +170,7 @@ class SnykExtension extends SnykLib implements IExtension { // set the workspace context so that the text to add folders is only shown if really the case // initializing after LS startup and just before scan is too late - const workspacePaths = vsCodeWorkspace.getWorkspaceFolders(); + const workspacePaths = vsCodeWorkspace.getWorkspaceFolderPaths(); await this.setWorkspaceContext(workspacePaths); this.user = await User.getAnonymous(this.context, Logger); @@ -565,7 +566,9 @@ class SnykExtension extends SnykLib implements IExtension { this.commandController.openIssueCommand(arg), ), vscode.commands.registerCommand(SNYK_SHOW_OUTPUT_COMMAND, () => this.commandController.showOutputChannel()), - vscode.commands.registerCommand(SNYK_SHOW_LS_OUTPUT_COMMAND, () => this.commandController.showLsOutputChannel()), + vscode.commands.registerCommand(SNYK_SHOW_LS_OUTPUT_COMMAND, (presentableError?: PresentableError) => + this.commandController.showLsOutputChannel(presentableError), + ), vscode.commands.registerCommand(SNYK_IGNORE_ISSUE_COMMAND, IgnoreCommand.ignoreIssues), vscode.commands.registerCommand(SNYK_SET_DELTA_REFERENCE_COMMAND, async (folderPath: string) => { const referenceBranch = 'Select a reference branch'; diff --git a/src/snyk/snykCode/codeService.ts b/src/snyk/snykCode/codeService.ts index 75f8ed36e..b38858c1f 100644 --- a/src/snyk/snykCode/codeService.ts +++ b/src/snyk/snykCode/codeService.ts @@ -51,7 +51,7 @@ export class SnykCodeService extends ProductService { } subscribeToLsScanMessages(): Subscription { - return this.languageServer.scan$.subscribe((scan: Scan) => { + return this.languageServer.scan$.subscribe((scan: Scan) => { if (scan.product !== ScanProduct.Code) { return; } diff --git a/src/snyk/snykCode/utils/analysisUtils.ts b/src/snyk/snykCode/utils/analysisUtils.ts index bcb99123c..54abba82a 100644 --- a/src/snyk/snykCode/utils/analysisUtils.ts +++ b/src/snyk/snykCode/utils/analysisUtils.ts @@ -21,7 +21,7 @@ export const getAbsoluteMarkerFilePath = ( return suggestionFilePath; } - const workspaceFolders = workspace.getWorkspaceFolders(); + const workspaceFolders = workspace.getWorkspaceFolderPaths(); if (workspaceFolders.length > 1) { return markerFilePath; } diff --git a/src/snyk/snykCode/views/suggestion/codeSuggestionWebviewProvider.ts b/src/snyk/snykCode/views/suggestion/codeSuggestionWebviewProvider.ts index 3b220aa34..6cfdb9e9c 100644 --- a/src/snyk/snykCode/views/suggestion/codeSuggestionWebviewProvider.ts +++ b/src/snyk/snykCode/views/suggestion/codeSuggestionWebviewProvider.ts @@ -197,7 +197,7 @@ export class CodeSuggestionWebviewProvider private getWorkspaceFolderPath(filePath: string) { // get the workspace folders // look at the filepath and identify the folder that contains the filepath - for (const folderPath of this.workspace.getWorkspaceFolders()) { + for (const folderPath of this.workspace.getWorkspaceFolderPaths()) { if (filePath.startsWith(folderPath)) { return folderPath; } diff --git a/src/snyk/snykIac/iacService.ts b/src/snyk/snykIac/iacService.ts index 4944d53be..9fa86e6b0 100644 --- a/src/snyk/snykIac/iacService.ts +++ b/src/snyk/snykIac/iacService.ts @@ -51,7 +51,7 @@ export class IacService extends ProductService { } subscribeToLsScanMessages(): Subscription { - return this.languageServer.scan$.subscribe((scan: Scan) => { + return this.languageServer.scan$.subscribe((scan: Scan) => { if (scan.product !== ScanProduct.InfrastructureAsCode) { return; } diff --git a/src/snyk/snykOss/ossService.ts b/src/snyk/snykOss/ossService.ts index 8db623fd5..647b4979c 100644 --- a/src/snyk/snykOss/ossService.ts +++ b/src/snyk/snykOss/ossService.ts @@ -51,7 +51,7 @@ export class OssService extends ProductService { } subscribeToLsScanMessages(): Subscription { - return this.languageServer.scan$.subscribe((scan: Scan) => { + return this.languageServer.scan$.subscribe((scan: Scan) => { if (scan.product !== ScanProduct.OpenSource) { return; } diff --git a/src/snyk/snykOss/providers/ossCodeActionsProvider.ts b/src/snyk/snykOss/providers/ossCodeActionsProvider.ts index 6201425f2..aa73155af 100644 --- a/src/snyk/snykOss/providers/ossCodeActionsProvider.ts +++ b/src/snyk/snykOss/providers/ossCodeActionsProvider.ts @@ -2,7 +2,7 @@ import { CodeAction, Range, TextDocument, Uri } from 'vscode'; import { OpenCommandIssueType, OpenIssueCommandArg } from '../../common/commands/types'; import { SNYK_OPEN_ISSUE_COMMAND } from '../../common/constants/commands'; import { CodeActionsProvider } from '../../common/editor/codeActionsProvider'; -import { Issue, IssueSeverity, OssIssueData } from '../../common/languageServer/types'; +import { Issue, isPresentableError, IssueSeverity, OssIssueData } from '../../common/languageServer/types'; import { ProductResult } from '../../common/services/productService'; import { ICodeActionAdapter, ICodeActionKindAdapter } from '../../common/vscode/codeAction'; import { IVSCodeLanguages } from '../../common/vscode/languages'; @@ -98,7 +98,7 @@ export class OssCodeActionsProvider extends CodeActionsProvider { private getVulnerabilities(folderPath: string, context: CodeActionContext): Issue[] | undefined { // get all OSS vulnerabilities for the folder const ossResult = this.issues.get(folderPath); - if (!ossResult || ossResult instanceof Error) { + if (!ossResult || isPresentableError(ossResult)) { return; } diff --git a/src/snyk/snykOss/providers/ossVulnerabilityCountProvider.ts b/src/snyk/snykOss/providers/ossVulnerabilityCountProvider.ts index 9ff0d4189..79adfd684 100644 --- a/src/snyk/snykOss/providers/ossVulnerabilityCountProvider.ts +++ b/src/snyk/snykOss/providers/ossVulnerabilityCountProvider.ts @@ -6,6 +6,7 @@ import { InlineValueText, LSPTextDocument } from '../../common/vscode/types'; import { IUriAdapter } from '../../common/vscode/uri'; import { convertIssue, isResultCliError, OssFileResult, OssResultBody } from '../interfaces'; import { OssService } from '../ossService'; +import { isPresentableError } from '../../common/languageServer/types'; import { ImportedModule, ModuleVulnerabilityCount } from '../services/vulnerabilityCount/importedModule'; import { VulnerabilityCountEmitter } from '../services/vulnerabilityCount/vulnerabilityCountEmitter'; @@ -95,9 +96,9 @@ export class OssVulnerabilityCountProvider { const resultCache = new Map(); for (const [, value] of this.ossService.result) { - // value is Error - if (value instanceof Error) { - tempResultArray.push(new CliError(value)); + // value is PresentableError + if (isPresentableError(value)) { + tempResultArray.push(new CliError(value.error || '', value.path)); } // value is Issue[] else { diff --git a/src/test/integration/configuration.test.ts b/src/test/integration/configuration.test.ts index ce1ba7a88..b5eaf5ba1 100644 --- a/src/test/integration/configuration.test.ts +++ b/src/test/integration/configuration.test.ts @@ -1,8 +1,15 @@ import { deepStrictEqual, strictEqual } from 'assert'; -import { FeaturesConfiguration } from '../../snyk/common/configuration/configuration'; +import path from 'path'; +import { sleep } from '@amplitude/experiment-node-server/dist/src/util/time'; +import { FeaturesConfiguration, FolderConfig } from '../../snyk/common/configuration/configuration'; import { configuration } from '../../snyk/common/configuration/instance'; import vscode from 'vscode'; -import { ADVANCED_CUSTOM_ENDPOINT } from '../../snyk/common/constants/settings'; +import { + ADVANCED_CUSTOM_ENDPOINT, + ADVANCED_ORGANIZATION, + CONFIGURATION_IDENTIFIER, +} from '../../snyk/common/constants/settings'; +import { assertEventually } from '../util/testUtils'; suite('Configuration', () => { test('settings change is reflected', async () => { @@ -49,4 +56,438 @@ suite('Configuration', () => { await configuration.setFeaturesConfiguration(enabledFeaturesConfig); } }); + + suite('Folder Organization Configuration Sync', () => { + let workspaceFolder: vscode.WorkspaceFolder | undefined; + const secondFolderPath = path.resolve(__dirname, '../../../src/test/integration/test_data/minimal_project'); + + const clearOrganizationAtAllLevels = async (): Promise => { + await vscode.workspace + .getConfiguration(CONFIGURATION_IDENTIFIER) + .update('advanced.organization', undefined, vscode.ConfigurationTarget.Global); + await vscode.workspace + .getConfiguration(CONFIGURATION_IDENTIFIER) + .update('advanced.organization', undefined, vscode.ConfigurationTarget.Workspace); + + // Clear for all workspace folders + const folders = vscode.workspace.workspaceFolders; + if (folders) { + await folders.reduce( + (promise, folder) => + promise.then(() => + vscode.workspace + .getConfiguration(CONFIGURATION_IDENTIFIER, folder) + .update('advanced.organization', undefined, vscode.ConfigurationTarget.WorkspaceFolder), + ), + Promise.resolve(), + ); + } + }; + + setup(async () => { + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) { + throw new Error('Incorrect number of workspace folders during setup'); + } + workspaceFolder = folders[0]; + + await clearOrganizationAtAllLevels(); + await configuration.setFolderConfigs([]); + }); + + teardown(async () => { + await sleep(200); // Allow VS Code time to fully work out its workspace situation before cleanup + + await clearOrganizationAtAllLevels(); + await configuration.setFolderConfigs([]); + + // Remove second folder if it exists + const folders = vscode.workspace.workspaceFolders; + if (folders) { + const secondFolderIndex = folders.findIndex(f => f.uri.fsPath === secondFolderPath); + if (secondFolderIndex >= 0) { + const removeSuccess = vscode.workspace.updateWorkspaceFolders(secondFolderIndex, 1); + if (!removeSuccess) { + throw new Error('Failed to remove second workspace folder during cleanup'); + } + await sleep(200); // Allow VS Code to fully remove the workspace folder + } + } + }); + + test('should sync folder-level organization setting to folder config', async () => { + if (!workspaceFolder) { + throw new Error('Workspace folder not available'); + } + + // Set up initial folder config without a preferred org + const initialFolderConfig = { + folderPath: workspaceFolder.uri.fsPath, + baseBranch: 'main', + localBranches: undefined, + referenceFolderPath: undefined, + preferredOrg: '', + orgSetByUser: false, + autoDeterminedOrg: 'irrelevant-org', + orgMigratedFromGlobalConfig: true, + }; + await configuration.setFolderConfigs([initialFolderConfig]); + + // Simulate user changing organization setting at folder level + await vscode.workspace + .getConfiguration(CONFIGURATION_IDENTIFIER, workspaceFolder) + .update('advanced.organization', 'test-org-123', vscode.ConfigurationTarget.WorkspaceFolder); + + // Wait for configuration change event to propagate and folder config to update + await assertEventually( + () => { + const configs = configuration.getFolderConfigs(); + return configs.length === 1 && configs[0].preferredOrg === 'test-org-123'; + }, + 2000, + 50, + 'Folder config organization should eventually be "test-org-123"', + ); + }); + + test('should clear folder config organization when setting is unset', async () => { + if (!workspaceFolder) { + throw new Error('Workspace folder not available'); + } + + // Set up folder config with an organization + const initialFolderConfig = { + folderPath: workspaceFolder.uri.fsPath, + baseBranch: 'main', + localBranches: undefined, + referenceFolderPath: undefined, + preferredOrg: 'existing-org', + orgSetByUser: true, + autoDeterminedOrg: 'irrelevant-org', + orgMigratedFromGlobalConfig: true, + }; + await configuration.setFolderConfigs([initialFolderConfig]); + + // Set organization at folder level + await vscode.workspace + .getConfiguration(CONFIGURATION_IDENTIFIER, workspaceFolder) + .update('advanced.organization', 'temp-org', vscode.ConfigurationTarget.WorkspaceFolder); + + // Wait for sync + await assertEventually( + () => { + const configs = configuration.getFolderConfigs(); + return configs.length > 0 && configs[0].preferredOrg === 'temp-org'; + }, + 2000, + 50, + 'Folder config organization should eventually be "temp-org"', + ); + + // Now unset the organization + await vscode.workspace + .getConfiguration(CONFIGURATION_IDENTIFIER, workspaceFolder) + .update('advanced.organization', undefined, vscode.ConfigurationTarget.WorkspaceFolder); + + // Wait for configuration change event to propagate + await assertEventually( + () => { + const configs = configuration.getFolderConfigs(); + return configs.length === 1 && configs[0].preferredOrg === ''; + }, + 2000, + 50, + 'Folder config organization should eventually be cleared (empty string)', + ); + }); + + test('should only update folder configs for affected workspace folders', async () => { + if (!workspaceFolder) { + throw new Error('Workspace folder not available'); + } + + // Set up multiple folder configs + const folderConfig1 = { + folderPath: workspaceFolder.uri.fsPath, + baseBranch: 'main', + localBranches: undefined, + referenceFolderPath: undefined, + preferredOrg: 'org-1', + orgSetByUser: true, + autoDeterminedOrg: 'irrelevant-org', + orgMigratedFromGlobalConfig: true, + }; + const folderConfig2 = { + folderPath: '/path/to/different/folder', + baseBranch: 'main', + localBranches: undefined, + referenceFolderPath: undefined, + preferredOrg: 'org-2', + orgSetByUser: true, + autoDeterminedOrg: 'irrelevant-org', + orgMigratedFromGlobalConfig: true, + }; + await configuration.setFolderConfigs([folderConfig1, folderConfig2]); + + // Change organization only for the first workspace folder + await vscode.workspace + .getConfiguration(CONFIGURATION_IDENTIFIER, workspaceFolder) + .update('advanced.organization', 'updated-org-1', vscode.ConfigurationTarget.WorkspaceFolder); + + // Wait for configuration change event to propagate and verify only matching folder updated + await assertEventually( + () => { + const configs = configuration.getFolderConfigs(); + if (configs.length !== 2) return false; + + const config1 = configs.find(fc => fc.folderPath === workspaceFolder?.uri.fsPath); + const config2 = configs.find(fc => fc.folderPath === '/path/to/different/folder'); + + return config1?.preferredOrg === 'updated-org-1' && config2?.preferredOrg === 'org-2'; + }, + 2000, + 50, + 'Only the affected workspace folder config should eventually be updated, other folder configs should remain unchanged', + ); + }); + + test('should update folder config to empty string when global org is set but no folder-level org exists', async () => { + if (!workspaceFolder) { + throw new Error('Workspace folder not available'); + } + + // Set up folder config with an organization + const initialFolderConfig = { + folderPath: workspaceFolder.uri.fsPath, + baseBranch: 'main', + localBranches: undefined, + referenceFolderPath: undefined, + preferredOrg: 'folder-org', + orgSetByUser: true, + autoDeterminedOrg: 'irrelevant-org', + orgMigratedFromGlobalConfig: true, + }; + await configuration.setFolderConfigs([initialFolderConfig]); + + // Set organization at global level (not folder level) + // This should clear the folder config org since there's no folder-level override + await vscode.workspace + .getConfiguration(CONFIGURATION_IDENTIFIER) + .update('advanced.organization', 'global-org', vscode.ConfigurationTarget.Global); + + // Wait for configuration change event to propagate and verify folder config cleared + await assertEventually( + () => { + const configs = configuration.getFolderConfigs(); + return configs.length === 1 && configs[0].preferredOrg === ''; + }, + 2000, + 50, + 'Folder config organization should eventually be cleared when no folder-level org setting exists', + ); + }); + + // Skipped: Relies on real LS, which we aren't doing in integration tests (at time of writing) + test.skip('should handle config migration when LS sends orgMigratedFromGlobalConfig and orgSetByUser', async function () { + this.timeout(15000); + + if (!workspaceFolder) { + throw new Error('Workspace folder not available'); + } + + // Step 1: Set org in a single workspace's folder config + // This will be sent to LS, which will automatically migrate it + await vscode.workspace + .getConfiguration(CONFIGURATION_IDENTIFIER, workspaceFolder) + .update('advanced.organization', 'user-set-org', vscode.ConfigurationTarget.WorkspaceFolder); + + // Step 2: Wait for LS to migrate and send back the config with migration flags + await assertEventually( + () => { + const configs = configuration.getFolderConfigs(); + return ( + configs.length === 1 && + configs[0].preferredOrg === 'user-set-org' && + configs[0].orgMigratedFromGlobalConfig === true && + configs[0].orgSetByUser === true + ); + }, + 5000, + 100, + 'Folder config should eventually be migrated by LS with orgMigratedFromGlobalConfig=true and orgSetByUser=true', + ); + + // Step 3: Forcibly reset folder config back to unmigrated state + // This will cause LS to run the migration logic and send us back a migrated folder config, which we will then process. + const unmigratedConfig: FolderConfig = { + folderPath: workspaceFolder.uri.fsPath, + baseBranch: 'main', + localBranches: undefined, + referenceFolderPath: undefined, + preferredOrg: '', + orgSetByUser: false, + autoDeterminedOrg: '', + orgMigratedFromGlobalConfig: false, + }; + await configuration.setFolderConfigs([unmigratedConfig]); + + // Step 4: Wait for LS to detect the unmigrated config and re-migrate it + // LS will automatically migrate any config it sees without migration flags + await assertEventually( + () => { + const configs = configuration.getFolderConfigs(); + return ( + configs.length === 1 && + configs[0].preferredOrg === 'user-set-org' && + configs[0].orgMigratedFromGlobalConfig === true && + configs[0].orgSetByUser === true + ); + }, + 5000, + 100, + 'LS should automatically re-migrate the config with migration flags set', + ); + + // Step 5: Verify the org has been left alone in the VS Code settings at the workspace folder level + const folderLevelOrg = vscode.workspace + .getConfiguration(CONFIGURATION_IDENTIFIER, workspaceFolder) + .get(ADVANCED_ORGANIZATION); + strictEqual(folderLevelOrg, 'user-set-org', 'Workspace folder level org setting should remain unchanged'); + }); + + // Skipped: Relies on real LS, which we aren't doing in integration tests (at time of writing) + test.skip('should handle config migration with multiple workspace folders', async function () { + this.timeout(15000); + + // Verify we start with exactly one folder + const initialFolders = vscode.workspace.workspaceFolders; + if (!initialFolders || initialFolders.length !== 1) { + throw new Error(`Expected exactly 1 workspace folder at start but got ${initialFolders?.length ?? 0}`); + } + + // Add a second workspace folder for this test + const addSuccess = vscode.workspace.updateWorkspaceFolders( + 1, // Start index (after first folder) + 0, // Delete count (don't delete any) + { uri: vscode.Uri.file(secondFolderPath) }, + ); + + if (!addSuccess) { + throw new Error('Failed to add second workspace folder'); + } + + // Wait for workspace folders to update with assertEventually + await assertEventually( + () => { + const folders = vscode.workspace.workspaceFolders; + return folders !== undefined && folders.length === 2; + }, + 2000, + 100, + 'Expected 2 workspace folders after adding second folder', + ); + + await sleep(500); // Give VS Code extra time to fully initialize the workspace folder and its settings resources + + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length !== 2) { + throw new Error(`Expected exactly 2 workspace folders but got ${folders?.length ?? 0}`); + } + + const folder1 = folders[0]; + const folder2 = folders.find(f => f.uri.fsPath === secondFolderPath); + + if (!folder2) { + throw new Error('Second workspace folder was not found after adding'); + } + + // Step 1: Set org only for folder2, leave folder1 without org setting + // folder1 will have no org set (should get resolved by LS migration) + // folder2 will have an explicit org set by user + await vscode.workspace + .getConfiguration(CONFIGURATION_IDENTIFIER, folder2) + .update('advanced.organization', 'folder2-org', vscode.ConfigurationTarget.WorkspaceFolder); + + // Step 2: Wait for LS to migrate both configs + await assertEventually( + () => { + const configs = configuration.getFolderConfigs(); + const config1 = configs.find(c => c.folderPath === folder1.uri.fsPath); + const config2 = configs.find(c => c.folderPath === folder2.uri.fsPath); + return ( + configs.length === 2 && + config1?.orgMigratedFromGlobalConfig === true && + config1?.orgSetByUser === false && + config2?.preferredOrg === 'folder2-org' && + config2?.orgMigratedFromGlobalConfig === true && + config2?.orgSetByUser === true + ); + }, + 5000, + 100, + 'Both folder configs should eventually be migrated by LS', + ); + + // Step 3: Force reset both folders to unmigrated state + // folder1 has no org set in its workspace folder settings + // folder2 has an org set in its workspace folder settings + const unmigratedConfig1: FolderConfig = { + folderPath: folder1.uri.fsPath, + baseBranch: 'main', + localBranches: undefined, + referenceFolderPath: undefined, + preferredOrg: '', + orgSetByUser: false, + autoDeterminedOrg: '', + orgMigratedFromGlobalConfig: false, + }; + + const unmigratedConfig2: FolderConfig = { + folderPath: folder2.uri.fsPath, + baseBranch: 'main', + localBranches: undefined, + referenceFolderPath: undefined, + preferredOrg: '', + orgSetByUser: false, + autoDeterminedOrg: '', + orgMigratedFromGlobalConfig: false, + }; + + await configuration.setFolderConfigs([unmigratedConfig1, unmigratedConfig2]); + + // Step 4: Wait for LS to re-migrate both folders + await assertEventually( + () => { + const configs = configuration.getFolderConfigs(); + const config1 = configs.find(c => c.folderPath === folder1.uri.fsPath); + const config2 = configs.find(c => c.folderPath === folder2.uri.fsPath); + return ( + config1?.orgMigratedFromGlobalConfig === true && + config1?.orgSetByUser === false && + config2?.preferredOrg === 'folder2-org' && + config2?.orgMigratedFromGlobalConfig === true && + config2?.orgSetByUser === true + ); + }, + 5000, + 100, + 'LS should eventually re-migrate both folder configs', + ); + + // Step 5: Verify both orgs in VS Code settings + // folder1 will get an org populated by LS migration logic (resolved from LDX-Sync or default) + // folder2 should still have the org set by user + const folder1Org = vscode.workspace + .getConfiguration(CONFIGURATION_IDENTIFIER, folder1) + .get(ADVANCED_ORGANIZATION); + const folder2Org = vscode.workspace + .getConfiguration(CONFIGURATION_IDENTIFIER, folder2) + .get(ADVANCED_ORGANIZATION); + + // folder1 gets resolved to some org by LS - we just verify it's been set to something + strictEqual(typeof folder1Org, 'string', 'Folder1 org should be populated by LS migration logic'); + strictEqual(folder1Org!.length > 0, true, 'Folder1 org should not be empty'); + strictEqual(folder2Org, 'folder2-org', 'Folder2 org setting should remain unchanged'); + }); + }); }); diff --git a/src/test/integration/runTest.ts b/src/test/integration/runTest.ts index a1b800f78..4a3f4081a 100644 --- a/src/test/integration/runTest.ts +++ b/src/test/integration/runTest.ts @@ -1,4 +1,6 @@ import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; import { runTests } from '@vscode/test-electron'; async function main() { @@ -19,7 +21,38 @@ async function main() { // Passed to --extensionTestsPath const extensionTestsPath = path.resolve(__dirname, './index'); - const launchArgs = [path.resolve(__dirname, '../../../src/test/integration/mocked_data')]; + // Create a fresh workspace file in a temp directory for each test run to help ensure clean state + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-test-')); + const workspaceFilePath = path.join(tempDir, 'test.code-workspace'); + const workspaceConfig = { + folders: [ + { + path: path.resolve(__dirname, '../../../src/test/integration/mocked_data'), + }, + ], + settings: { + 'window.restoreWindows': 'none', + }, + }; + fs.writeFileSync(workspaceFilePath, JSON.stringify(workspaceConfig, null, 2)); + console.log('Created temporary workspace file:', workspaceFilePath); + + const launchArgs = [ + '--new-window', + '--use-inmemory-secretstorage', // Prevent OS keychain pop-ups + '--skip-add-to-recently-opened', + '--skip-welcome', + '--skip-release-notes', + '--disable-workspace-trust', + '--disable-extensions', // Doesn't disable our extension + '--disable-telemetry', + '--disable-experiments', + '--disable-updates', + workspaceFilePath, + ]; + + // Skip the prelaunch setup that compiles and prepares built-in extensions + process.env.VSCODE_SKIP_PRELAUNCH = '1'; // Download VS Code, unzip it and run the integration test await runTests({ extensionDevelopmentPath, extensionTestsPath, launchArgs }); diff --git a/src/test/integration/securityIssueTreeProvider.test.ts b/src/test/integration/securityIssueTreeProvider.test.ts index bc4df36cc..7022e96ba 100644 --- a/src/test/integration/securityIssueTreeProvider.test.ts +++ b/src/test/integration/securityIssueTreeProvider.test.ts @@ -2,7 +2,7 @@ import sinon from 'sinon'; import * as vscode from 'vscode'; import { IVSCodeLanguages } from '../../snyk/common/vscode/languages'; -import { CodeIssueData, Issue, LsErrorMessage, ScanProduct } from '../../snyk/common/languageServer/types'; +import { CodeIssueData, Issue, PresentableError, ScanProduct } from '../../snyk/common/languageServer/types'; import { IContextService } from '../../snyk/common/services/contextService'; import { IProductService, ProductResult } from '../../snyk/common/services/productService'; import { deepStrictEqual } from 'assert'; @@ -91,7 +91,6 @@ suite('Code Security Issue Tree Provider', () => { setFolderConfig: () => Promise.resolve(), setBranch: () => Promise.resolve(), setReferenceFolder: () => Promise.resolve(), - resetFolderConfigsCache: () => {}, } as IFolderConfigs; }); @@ -210,7 +209,11 @@ suite('Code Security Issue Tree Provider', () => { test('getRootChildren returns correctly for single folder workspace scan error', async () => { try { // Setup - const repositoryInvalidError = new Error(LsErrorMessage.repositoryInvalidError); + const repositoryInvalidError: PresentableError = { + error: 'repository does not exist', + showNotification: false, + treeNodeSuffix: '', + }; await setCCIAndIVOs(false); const issueTreeProvider = createIssueTreeProvider(new Map([['fake-dir', repositoryInvalidError]])); @@ -222,7 +225,7 @@ suite('Code Security Issue Tree Provider', () => { // Scan failed Error: repository does not exist deepStrictEqual(rootChildren.length, 2); verifyFolderNodeWithError(rootChildren[0], 'fake-dir'); - verifyScanFailedErrorNode(rootChildren[1], repositoryInvalidError.toString()); + verifyScanFailedErrorNode(rootChildren[1], repositoryInvalidError.error || ''); } finally { await setCCIAndIVOs(true, DEFAULT_ISSUE_VIEW_OPTIONS); } @@ -233,9 +236,12 @@ suite('Code Security Issue Tree Provider', () => { // Setup await setCCIAndIVOs(false); const folderNames = ['dir-one', 'dir-two']; - const issueTreeProvider = createIssueTreeProvider( - new Map(folderNames.map(name => [name, new Error('Some scan error')])), - ); + const scanError: PresentableError = { + error: 'Some scan error', + showNotification: false, + treeNodeSuffix: '', + }; + const issueTreeProvider = createIssueTreeProvider(new Map(folderNames.map(name => [name, scanError]))); // Act const rootChildren = issueTreeProvider.getRootChildren(); diff --git a/src/test/integration/test_data/minimal_project/dummy.js b/src/test/integration/test_data/minimal_project/dummy.js new file mode 100644 index 000000000..af2aa0142 --- /dev/null +++ b/src/test/integration/test_data/minimal_project/dummy.js @@ -0,0 +1 @@ +// Test file diff --git a/src/test/unit/common/languageServer/languageServer.test.ts b/src/test/unit/common/languageServer/languageServer.test.ts index e3f37aefd..3ca561ba2 100644 --- a/src/test/unit/common/languageServer/languageServer.test.ts +++ b/src/test/unit/common/languageServer/languageServer.test.ts @@ -36,9 +36,27 @@ suite('Language Server', () => { let languageServer: LanguageServer; let downloadServiceMock: DownloadService; - const path = 'testPath'; const logger = new LoggerMockFailOnErrors(); + const createFakeLanguageServer = (languageClientAdapter: ILanguageClientAdapter, workspace: IVSCodeWorkspace) => { + return new LanguageServer( + user, + configurationMock, + languageClientAdapter, + workspace, + windowMock, + authServiceMock, + logger, + downloadServiceMock, + {} as IExtensionRetriever, + {} as ISummaryProviderService, + {} as IUriAdapter, + {} as IMarkdownStringAdapter, + {} as IVSCodeCommands, + {} as IDiagnosticsIssueProvider, + ); + }; + setup(() => { configurationMock = { getAuthenticationMethod(): string { @@ -51,7 +69,7 @@ suite('Language Server', () => { return false; }, getCliPath(): Promise { - return Promise.resolve(path); + return Promise.resolve('testPath'); }, getToken(): Promise { return Promise.resolve('testToken'); @@ -118,21 +136,9 @@ suite('Language Server', () => { }, }); - languageServer = new LanguageServer( - user, - configurationMock, + languageServer = createFakeLanguageServer( lca as unknown as ILanguageClientAdapter, stubWorkspaceConfiguration('snyk.loglevel', 'trace'), - windowMock, - authServiceMock, - logger, - downloadServiceMock, - {} as IExtensionRetriever, - {} as ISummaryProviderService, - {} as IUriAdapter, - {} as IMarkdownStringAdapter, - {} as IVSCodeCommands, - {} as IDiagnosticsIssueProvider, ); downloadServiceMock.downloadReady$.next(); @@ -152,11 +158,11 @@ suite('Language Server', () => { ): LanguageClient { return { start(): Promise { - assert.strictEqual(id, 'Snyk LS'); + assert.strictEqual(id, 'SnykLS'); assert.strictEqual(name, 'Snyk Language Server'); assert.strictEqual( // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - 'options' in serverOptions ? serverOptions?.options?.env?.http_proxy : undefined, + 'options' in serverOptions ? serverOptions?.options?.env?.HTTP_PROXY : undefined, expectedProxy, ); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access @@ -173,21 +179,9 @@ suite('Language Server', () => { }, }); - languageServer = new LanguageServer( - user, - configurationMock, + languageServer = createFakeLanguageServer( lca as unknown as ILanguageClientAdapter, stubWorkspaceConfiguration('http.proxy', expectedProxy), - windowMock, - authServiceMock, - new LoggerMock(), - downloadServiceMock, - {} as IExtensionRetriever, - {} as ISummaryProviderService, - {} as IUriAdapter, - {} as IMarkdownStringAdapter, - {} as IVSCodeCommands, - {} as IDiagnosticsIssueProvider, ); downloadServiceMock.downloadReady$.next(); await languageServer.start(); @@ -204,124 +198,84 @@ suite('Language Server', () => { create: sinon.stub().returns(mockLanguageClient), getLanguageClient: sinon.stub().returns(mockLanguageClient), }; - languageServer = new LanguageServer( - user, - configurationMock, - mockLanguageClientAdapter, - {} as IVSCodeWorkspace, - windowMock, - authServiceMock, - new LoggerMock(), - downloadServiceMock, - {} as IExtensionRetriever, - {} as ISummaryProviderService, - {} as IUriAdapter, - {} as IMarkdownStringAdapter, - {} as IVSCodeCommands, - {} as IDiagnosticsIssueProvider, - ); + languageServer = createFakeLanguageServer(mockLanguageClientAdapter, {} as IVSCodeWorkspace); }); - test('LanguageServer should provide empty folder configs when no folder configs were received', async () => { - const expectedInitializationOptions: ServerSettings = { - activateSnykCodeSecurity: 'true', - enableDeltaFindings: 'false', - activateSnykOpenSource: 'false', - activateSnykIac: 'true', - token: 'testToken', - cliPath: 'testPath', - sendErrorReports: 'true', - integrationName: 'VS_CODE', - integrationVersion: '0.0.0', - automaticAuthentication: 'false', - endpoint: undefined, - organization: undefined, - additionalParams: '--all-projects -d', - manageBinariesAutomatically: 'true', - deviceId: user.anonymousId, - filterSeverity: DEFAULT_SEVERITY_FILTER, - issueViewOptions: DEFAULT_ISSUE_VIEW_OPTIONS, - enableTrustedFoldersFeature: 'true', - trustedFolders: ['/trusted/test/folder'], - insecure: 'true', - requiredProtocolVersion: PROTOCOL_VERSION.toString(), - scanningMode: 'auto', + teardown(() => { + LanguageServer.ReceivedFolderConfigsFromLs = false; + }); + + const tcs: { + name: string; + folderConfigs: FolderConfig[]; + simulateReceivedFolderConfigsFromLs: boolean; + }[] = [ + { + name: 'LanguageServer should provide empty folder configs when no folder configs were received (first init)', folderConfigs: [], - authenticationMethod: 'oauth', - enableSnykOSSQuickFixCodeActions: 'true', - hoverVerbosity: 1, - }; + simulateReceivedFolderConfigsFromLs: false, + }, + { + name: 'LanguageServer should include folder configs when they have been received from language server (LS restarts)', + folderConfigs: [ + { + folderPath: '/test/path', + baseBranch: 'main', + localBranches: ['main', 'develop'], + referenceFolderPath: undefined, + preferredOrg: 'irrelevant-org', + orgSetByUser: true, + autoDeterminedOrg: 'irrelevant-org', + orgMigratedFromGlobalConfig: true, + }, + ], + simulateReceivedFolderConfigsFromLs: true, + }, + ]; + tcs.forEach(tc => { + test(tc.name, async () => { + // Setup folder configs mock + configurationMock.getFolderConfigs = () => tc.folderConfigs; - deepStrictEqual(await languageServer.getInitializationOptions(), expectedInitializationOptions); - }); + // Simulate language server notification about folder configs + // This is normally done in the registerListeners method when receiving a notification + LanguageServer.ReceivedFolderConfigsFromLs = tc.simulateReceivedFolderConfigsFromLs; - test('LanguageServer should include folder configs when they have been received from language server', async () => { - // Setup a sample folder config - const sampleFolderConfig: FolderConfig = { - folderPath: '/test/path', - baseBranch: 'main', - localBranches: ['main', 'develop'], - referenceFolderPath: undefined, - }; - configurationMock.getFolderConfigs = () => [sampleFolderConfig]; - - // Simulate language server notification about folder configs - // This is normally done in the registerListeners method when receiving a notification - // Access private field via type assertion to LanguageServer with private field type - LanguageServer.ReceivedFolderConfigsFromLs = true; - - // Create expected initialization options with the folder config included - const expectedInitializationOptions: ServerSettings = { - activateSnykCodeSecurity: 'true', - enableDeltaFindings: 'false', - activateSnykOpenSource: 'false', - activateSnykIac: 'true', - token: 'testToken', - cliPath: 'testPath', - sendErrorReports: 'true', - integrationName: 'VS_CODE', - integrationVersion: '0.0.0', - automaticAuthentication: 'false', - endpoint: undefined, - organization: undefined, - additionalParams: '--all-projects -d', - manageBinariesAutomatically: 'true', - deviceId: user.anonymousId, - filterSeverity: DEFAULT_SEVERITY_FILTER, - issueViewOptions: DEFAULT_ISSUE_VIEW_OPTIONS, - enableTrustedFoldersFeature: 'true', - trustedFolders: ['/trusted/test/folder'], - insecure: 'true', - requiredProtocolVersion: PROTOCOL_VERSION.toString(), - scanningMode: 'auto', - folderConfigs: [sampleFolderConfig], - authenticationMethod: 'oauth', - enableSnykOSSQuickFixCodeActions: 'true', - hoverVerbosity: 1, - }; - const initializationOptions = await languageServer.getInitializationOptions(); - LanguageServer.ReceivedFolderConfigsFromLs = false; - deepStrictEqual(initializationOptions, expectedInitializationOptions); + const expectedInitializationOptions: ServerSettings = { + activateSnykCodeSecurity: 'true', + enableDeltaFindings: 'false', + activateSnykOpenSource: 'false', + activateSnykIac: 'true', + token: 'testToken', + cliPath: 'testPath', + sendErrorReports: 'true', + integrationName: 'VS_CODE', + integrationVersion: '0.0.0', + automaticAuthentication: 'false', + endpoint: undefined, + organization: undefined, + additionalParams: '--all-projects -d', + manageBinariesAutomatically: 'true', + deviceId: user.anonymousId, + filterSeverity: DEFAULT_SEVERITY_FILTER, + issueViewOptions: DEFAULT_ISSUE_VIEW_OPTIONS, + enableTrustedFoldersFeature: 'true', + trustedFolders: ['/trusted/test/folder'], + insecure: 'true', + requiredProtocolVersion: PROTOCOL_VERSION.toString(), + scanningMode: 'auto', + folderConfigs: tc.folderConfigs, + authenticationMethod: 'oauth', + enableSnykOSSQuickFixCodeActions: 'true', + hoverVerbosity: 1, + }; + + const initializationOptions = await languageServer.getInitializationOptions(); + deepStrictEqual(initializationOptions, expectedInitializationOptions); + }); }); test('LanguageServer should respect experiment setup for Code', async () => { - languageServer = new LanguageServer( - user, - configurationMock, - {} as ILanguageClientAdapter, - {} as IVSCodeWorkspace, - windowMock, - authServiceMock, - new LoggerMock(), - downloadServiceMock, - {} as IExtensionRetriever, - {} as ISummaryProviderService, - {} as IUriAdapter, - {} as IMarkdownStringAdapter, - {} as IVSCodeCommands, - {} as IDiagnosticsIssueProvider, - ); - const initOptions = await languageServer.getInitializationOptions(); strictEqual(initOptions.activateSnykCodeSecurity, `true`); @@ -336,4 +290,155 @@ suite('Language Server', () => { }); }); }); + + suite('handleOrgSettingsFromFolderConfigs', () => { + let getWorkspaceFoldersStub: sinon.SinonStub; + let workspaceMock: IVSCodeWorkspace; + let setOrganizationStub: sinon.SinonStub; + let setAutoSelectOrganizationStub: sinon.SinonStub; + let isAutoSelectOrganizationEnabledStub: sinon.SinonStub; + let languageClientAdapter: ILanguageClientAdapter; + const AUTO_DETERMINED_ORG = 'auto-determined-org'; + + setup(() => { + setOrganizationStub = sinon.stub().resolves(); + configurationMock.setOrganization = setOrganizationStub; + setAutoSelectOrganizationStub = sinon.stub().resolves(); + configurationMock.setAutoSelectOrganization = setAutoSelectOrganizationStub; + isAutoSelectOrganizationEnabledStub = sinon.stub(); + configurationMock.isAutoSelectOrganizationEnabled = isAutoSelectOrganizationEnabledStub; + + languageClientAdapter = { + create: sinon.stub(), + } as unknown as ILanguageClientAdapter; + + getWorkspaceFoldersStub = sinon.stub(); + workspaceMock = { + getWorkspaceFolders: getWorkspaceFoldersStub, + } as unknown as IVSCodeWorkspace; + + languageServer = createFakeLanguageServer(languageClientAdapter, workspaceMock); + }); + + const createFolderConfig = (folderPath: string, preferredOrg: string, orgSetByUser: boolean): FolderConfig => ({ + folderPath, + baseBranch: 'main', + localBranches: undefined, + referenceFolderPath: undefined, + preferredOrg, + orgSetByUser, + autoDeterminedOrg: AUTO_DETERMINED_ORG, + orgMigratedFromGlobalConfig: true, + }); + + test('should set org settings from LS folder configs (when contains orgSetByUser as true)', () => { + const testCases = [ + { + // User unticked the "Auto-select organization" checkbox, so preferredOrg was blanked by LS + folderPath: '/path/to/folder1', + preferredOrg: '', + }, + { + // User wrote in an org name manually, so orgSetByUser was set to true by LS + folderPath: '/path/to/folder2', + preferredOrg: 'org-for-folder2', + }, + ]; + + const workspaceFolders = testCases.map(tc => ({ uri: { fsPath: tc.folderPath } })); + getWorkspaceFoldersStub.returns(workspaceFolders); + isAutoSelectOrganizationEnabledStub.returns(true); + + const folderConfigs = testCases.map(tc => createFolderConfig(tc.folderPath, tc.preferredOrg, true)); + + languageServer['handleOrgSettingsFromFolderConfigs'](folderConfigs); + + strictEqual(setOrganizationStub.callCount, testCases.length); + strictEqual(setAutoSelectOrganizationStub.callCount, testCases.length); + testCases.forEach((tc, index) => { + strictEqual(setOrganizationStub.getCall(index).args[0], workspaceFolders[index]); + strictEqual(setOrganizationStub.getCall(index).args[1], tc.preferredOrg); + strictEqual(setAutoSelectOrganizationStub.getCall(index).args[0], workspaceFolders[index]); + strictEqual(setAutoSelectOrganizationStub.getCall(index).args[1], false); + }); + }); + + for (const currentAutoOrg of [true, false]) { + test(`should set org settings at workspace folder level for folder configs from LS (when orgSetByUser is false regardless of previous auto-org status, currentAutoOrg=${currentAutoOrg})`, () => { + const workspaceFolder = { uri: { fsPath: '/path/to/folder' } }; + getWorkspaceFoldersStub.returns([workspaceFolder]); + isAutoSelectOrganizationEnabledStub.returns(currentAutoOrg); + + const folderConfigs = [createFolderConfig('/path/to/folder', '', false)]; + + languageServer['handleOrgSettingsFromFolderConfigs'](folderConfigs); + + strictEqual(setOrganizationStub.callCount, 1); + strictEqual(setOrganizationStub.getCall(0).args[0], workspaceFolder); + strictEqual(setOrganizationStub.getCall(0).args[1], AUTO_DETERMINED_ORG); + + // We still write it to ensure it is set at the folder level + strictEqual(setAutoSelectOrganizationStub.callCount, 1); + strictEqual(setAutoSelectOrganizationStub.getCall(0).args[0], workspaceFolder); + strictEqual(setAutoSelectOrganizationStub.getCall(0).args[1], true); + }); + } + + test('should not set auto-org setting from LS folder configs (when already opted out of auto-org and orgSetByUser is true)', () => { + const testCases = [ + { + // User blanked the org manually + folderPath: '/path/to/folder1', + preferredOrg: '', + }, + { + // User changed the org manually + folderPath: '/path/to/folder2', + preferredOrg: 'org-for-folder2', + }, + ]; + + const workspaceFolders = testCases.map(tc => ({ uri: { fsPath: tc.folderPath } })); + getWorkspaceFoldersStub.returns(workspaceFolders); + isAutoSelectOrganizationEnabledStub.returns(false); + + const folderConfigs = testCases.map(tc => createFolderConfig(tc.folderPath, tc.preferredOrg, true)); + + languageServer['handleOrgSettingsFromFolderConfigs'](folderConfigs); + + strictEqual(setOrganizationStub.callCount, testCases.length); + strictEqual(setAutoSelectOrganizationStub.callCount, 0); + testCases.forEach((tc, index) => { + strictEqual(setOrganizationStub.getCall(index).args[0], workspaceFolders[index]); + strictEqual(setOrganizationStub.getCall(index).args[1], tc.preferredOrg); + }); + }); + + test('should warn and skip folder configs without matching workspace folders', () => { + const workspaceFolder = { uri: { fsPath: '/path/to/existing/folder' } }; + getWorkspaceFoldersStub.returns([workspaceFolder]); + isAutoSelectOrganizationEnabledStub.returns(true); + + const folderConfigs = [ + createFolderConfig('/path/to/existing/folder', 'existing-org', true), + createFolderConfig('/path/to/missing/folder', 'missing-org', true), + ]; + + const loggerWarnSpy = sinon.spy(logger, 'warn'); + languageServer['handleOrgSettingsFromFolderConfigs'](folderConfigs); + + // Should only process the existing folder + strictEqual(setOrganizationStub.callCount, 1); + strictEqual(setOrganizationStub.getCall(0).args[0], workspaceFolder); + strictEqual(setOrganizationStub.getCall(0).args[1], 'existing-org'); + + strictEqual(setAutoSelectOrganizationStub.callCount, 1); + strictEqual(setAutoSelectOrganizationStub.getCall(0).args[0], workspaceFolder); + strictEqual(setAutoSelectOrganizationStub.getCall(0).args[1], false); + + // Should warn about the missing folder + strictEqual(loggerWarnSpy.callCount, 1); + strictEqual(loggerWarnSpy.getCall(0).args[0], 'No workspace folder found for path: /path/to/missing/folder'); + }); + }); }); diff --git a/src/test/unit/common/llm/geminiIntegrationService.test.ts b/src/test/unit/common/llm/geminiIntegrationService.test.ts index c1b11081a..09ce8c071 100644 --- a/src/test/unit/common/llm/geminiIntegrationService.test.ts +++ b/src/test/unit/common/llm/geminiIntegrationService.test.ts @@ -31,7 +31,7 @@ suite('Workspace Scan Command Parameter Test', () => { } as IConfiguration; const extensionContextMock = {} as IExtensionRetriever; - const scanSubject = new Subject>(); + const scanSubject = new Subject(); const uriAdapterMock = {} as IUriAdapter; const markdownAdapterMock = { diff --git a/src/test/unit/common/services/productService.test.ts b/src/test/unit/common/services/productService.test.ts index d495b98c9..1e70d4529 100644 --- a/src/test/unit/common/services/productService.test.ts +++ b/src/test/unit/common/services/productService.test.ts @@ -24,8 +24,8 @@ class MockProductService extends ProductService { showSuggestionProviderById = sinon.fake(); subscribeToLsScanMessages(): Subscription { - return this.languageServer.scan$.subscribe((scan: Scan) => { - super.handleLsScanMessage(scan as Scan); + return this.languageServer.scan$.subscribe((scan: Scan) => { + super.handleLsScanMessage(scan); }); } @@ -53,7 +53,7 @@ suite('Product Service', () => { {} as unknown as IProductWebviewProvider>, viewManagerService, { - getWorkspaceFolders: () => [''], + getWorkspaceFolderPaths: () => [''], } as IVSCodeWorkspace, new WorkspaceTrust(), ls, @@ -75,9 +75,7 @@ suite('Product Service', () => { ls.scan$.next({ product: ScanProduct.InfrastructureAsCode, folderPath: 'test/path', - issues: [], status: ScanStatus.InProgress, - errorMessage: '', }); strictEqual(service.isAnalysisRunning, true); @@ -89,16 +87,12 @@ suite('Product Service', () => { ls.scan$.next({ product: ScanProduct.InfrastructureAsCode, folderPath, - issues: [], status: ScanStatus.InProgress, - errorMessage: '', }); ls.scan$.next({ product: ScanProduct.InfrastructureAsCode, folderPath, - issues: [], status: ScanStatus.Success, - errorMessage: '', }); strictEqual(service.isAnalysisRunning, false); @@ -110,16 +104,12 @@ suite('Product Service', () => { ls.scan$.next({ product: ScanProduct.InfrastructureAsCode, folderPath, - issues: [], status: ScanStatus.InProgress, - errorMessage: 'Scan failed', }); ls.scan$.next({ product: ScanProduct.InfrastructureAsCode, folderPath, - issues: [], status: ScanStatus.Error, - errorMessage: 'Scan failed', }); strictEqual(service.isAnalysisRunning, false); @@ -132,23 +122,17 @@ suite('Product Service', () => { ls.scan$.next({ product: ScanProduct.InfrastructureAsCode, folderPath: folder1Path, - issues: [], status: ScanStatus.InProgress, - errorMessage: '', }); ls.scan$.next({ product: ScanProduct.InfrastructureAsCode, folderPath: folder2Path, - issues: [], status: ScanStatus.InProgress, - errorMessage: '', }); ls.scan$.next({ product: ScanProduct.InfrastructureAsCode, folderPath: folder1Path, - issues: [], status: ScanStatus.Success, - errorMessage: '', }); strictEqual(service.isAnalysisRunning, true); @@ -156,9 +140,7 @@ suite('Product Service', () => { ls.scan$.next({ product: ScanProduct.InfrastructureAsCode, folderPath: folder2Path, - issues: [], status: ScanStatus.Success, - errorMessage: '', }); strictEqual(service.isAnalysisRunning, false); diff --git a/src/test/unit/mocks/languageServer.mock.ts b/src/test/unit/mocks/languageServer.mock.ts index c11366801..850736572 100644 --- a/src/test/unit/mocks/languageServer.mock.ts +++ b/src/test/unit/mocks/languageServer.mock.ts @@ -15,6 +15,6 @@ export class LanguageServerMock implements ILanguageServer { showOutputChannel = sinon.fake(); cliReady$ = new ReplaySubject(1); - scan$ = new Subject>(); + scan$ = new Subject(); showIssueDetailTopic$ = new Subject(); } diff --git a/src/test/unit/mocks/workspace.mock.ts b/src/test/unit/mocks/workspace.mock.ts index a32acf384..b59ab1f7e 100644 --- a/src/test/unit/mocks/workspace.mock.ts +++ b/src/test/unit/mocks/workspace.mock.ts @@ -2,7 +2,7 @@ import { IVSCodeWorkspace } from '../../../snyk/common/vscode/workspace'; export function stubWorkspaceConfiguration(configSetting: string, returnValue: T | undefined): IVSCodeWorkspace { return { - getConfiguration: (identifier: string, key: string) => { + getConfiguration: (identifier: string, key: string, _workspaceFolder?) => { if (`${identifier}.${key}` === configSetting) return returnValue; return undefined; }, diff --git a/src/test/unit/snykCode/codeService.test.ts b/src/test/unit/snykCode/codeService.test.ts index f5d917817..8988831bc 100644 --- a/src/test/unit/snykCode/codeService.test.ts +++ b/src/test/unit/snykCode/codeService.test.ts @@ -39,7 +39,7 @@ suite('Code Service', () => { } as ICodeActionKindAdapter, viewManagerService, { - getWorkspaceFolders: () => [''], + getWorkspaceFolderPaths: () => [''], } as IVSCodeWorkspace, new WorkspaceTrust(), ls, @@ -57,9 +57,7 @@ suite('Code Service', () => { ls.scan$.next({ product: ScanProduct.OpenSource, folderPath: 'test/path', - issues: [], status: ScanStatus.InProgress, - errorMessage: '', }); strictEqual(service.isAnalysisRunning, false); diff --git a/src/test/unit/snykCode/utils/analysisUtils.test.ts b/src/test/unit/snykCode/utils/analysisUtils.test.ts index 0baa75654..a4680292e 100644 --- a/src/test/unit/snykCode/utils/analysisUtils.test.ts +++ b/src/test/unit/snykCode/utils/analysisUtils.test.ts @@ -31,7 +31,7 @@ suite('Snyk Code Analysis Utils', () => { const suggestionFilePath = '/Users/snyk/goof/test.js'; const markerFilePath = '/Users/snyk/goof/test2.js'; workspace = { - getWorkspaceFolders: () => ['/Users/snyk/goof1', '/Users/snyk/goof2'], + getWorkspaceFolderPaths: () => ['/Users/snyk/goof1', '/Users/snyk/goof2'], } as unknown as IVSCodeWorkspace; // act @@ -47,7 +47,7 @@ suite('Snyk Code Analysis Utils', () => { const relativeMarkerFilePath = 'test2.js'; const workspaceFolder = '/Users/snyk/goof1'; workspace = { - getWorkspaceFolders: () => [workspaceFolder], + getWorkspaceFolderPaths: () => [workspaceFolder], } as unknown as IVSCodeWorkspace; // act diff --git a/src/test/unit/snykIac/iacService.test.ts b/src/test/unit/snykIac/iacService.test.ts index 5ed6645e3..3c290a882 100644 --- a/src/test/unit/snykIac/iacService.test.ts +++ b/src/test/unit/snykIac/iacService.test.ts @@ -39,7 +39,7 @@ suite('IaC Service', () => { } as ICodeActionKindAdapter, viewManagerService, { - getWorkspaceFolders: () => [''], + getWorkspaceFolderPaths: () => [''], } as IVSCodeWorkspace, new WorkspaceTrust(), ls, @@ -59,9 +59,7 @@ suite('IaC Service', () => { ls.scan$.next({ product: ScanProduct.OpenSource, folderPath: 'test/path', - issues: [], status: ScanStatus.InProgress, - errorMessage: '', }); strictEqual(service.isAnalysisRunning, false); diff --git a/src/test/unit/snykOss/ossService.test.ts b/src/test/unit/snykOss/ossService.test.ts index 9950e7bad..2222e5968 100644 --- a/src/test/unit/snykOss/ossService.test.ts +++ b/src/test/unit/snykOss/ossService.test.ts @@ -37,7 +37,7 @@ suite('OSS Service', () => { { getQuickFix: sinon.fake() } as ICodeActionKindAdapter, viewManagerService, { - getWorkspaceFolders: () => [''], + getWorkspaceFolderPaths: () => [''], } as IVSCodeWorkspace, new WorkspaceTrust(), ls, @@ -57,9 +57,7 @@ suite('OSS Service', () => { ls.scan$.next({ product: ScanProduct.OpenSource, folderPath: 'test/path', - issues: [], status: ScanStatus.InProgress, - errorMessage: '', }); strictEqual(service.isAnalysisRunning, true); @@ -70,9 +68,7 @@ suite('OSS Service', () => { ls.scan$.next({ product: ScanProduct.Code, folderPath: 'test/path', - issues: [], status: ScanStatus.InProgress, - errorMessage: '', }); strictEqual(service.isAnalysisRunning, false); diff --git a/src/test/util/testUtils.ts b/src/test/util/testUtils.ts new file mode 100644 index 000000000..6501c0680 --- /dev/null +++ b/src/test/util/testUtils.ts @@ -0,0 +1,27 @@ +/** + * Asserts that a condition eventually becomes true within the given timeout. + * Similar to assert.Eventually in Go's testify library. + * + * @param condition - Function that returns true when the expected condition is met + * @param timeout - Maximum time to wait in milliseconds (default: 2000ms) + * @param interval - Polling interval in milliseconds (default: 50ms) + * @param message - Optional custom error message + * @throws Error if condition doesn't become true within timeout + */ +export async function assertEventually( + condition: () => boolean, + timeout: number = 2000, + interval: number = 50, + message?: string, +): Promise { + const start = Date.now(); + + while (!condition()) { + if (Date.now() - start > timeout) { + const errorMessage = message || `Condition not met within ${timeout}ms`; + throw new Error(errorMessage); + } + // eslint-disable-next-line no-await-in-loop -- Sequential await is intentional for polling + await new Promise(resolve => setTimeout(resolve, interval)); + } +}