diff --git a/src/handlers/SystemHandler.ts b/src/handlers/SystemHandler.ts new file mode 100644 index 00000000..0adef6aa --- /dev/null +++ b/src/handlers/SystemHandler.ts @@ -0,0 +1,22 @@ +import { RequestHandler } from 'vscode-languageserver'; +import { GetSystemStatusResponse } from '../protocol/LspSystemHandlers'; +import { ServerComponents } from '../server/ServerComponents'; +import { handleLspError } from '../utils/Errors'; + +export function getSystemStatusHandler( + components: ServerComponents, +): RequestHandler { + return (): GetSystemStatusResponse => { + try { + return { + settingsReady: components.settingsManager.isReady(), + schemasReady: components.schemaReadiness.isReady(), + cfnLintReady: components.cfnLintService.isReady(), + cfnGuardReady: components.guardService.isReady(), + currentSettings: components.settingsManager.getCurrentSettings(), + }; + } catch (error) { + handleLspError(error, 'Failed to get system status'); + } + }; +} diff --git a/src/protocol/LspComponents.ts b/src/protocol/LspComponents.ts index aba5a55b..fde8e1e1 100644 --- a/src/protocol/LspComponents.ts +++ b/src/protocol/LspComponents.ts @@ -8,6 +8,7 @@ import { LspRelatedResourcesHandlers } from './LspRelatedResourcesHandlers'; import { LspResourceHandlers } from './LspResourceHandlers'; import { LspS3Handlers } from './LspS3Handlers'; import { LspStackHandlers } from './LspStackHandlers'; +import { LspSystemHandlers } from './LspSystemHandlers'; import { LspWorkspace } from './LspWorkspace'; export class LspComponents { @@ -23,5 +24,6 @@ export class LspComponents { public readonly resourceHandlers: LspResourceHandlers, public readonly relatedResourcesHandlers: LspRelatedResourcesHandlers, public readonly s3Handlers: LspS3Handlers, + public readonly systemHandlers: LspSystemHandlers, ) {} } diff --git a/src/protocol/LspConnection.ts b/src/protocol/LspConnection.ts index ac4c7417..ce58bc3a 100644 --- a/src/protocol/LspConnection.ts +++ b/src/protocol/LspConnection.ts @@ -13,6 +13,7 @@ import { LspRelatedResourcesHandlers } from './LspRelatedResourcesHandlers'; import { LspResourceHandlers } from './LspResourceHandlers'; import { LspS3Handlers } from './LspS3Handlers'; import { LspStackHandlers } from './LspStackHandlers'; +import { LspSystemHandlers } from './LspSystemHandlers'; import { LspWorkspace } from './LspWorkspace'; type LspConnectionHandlers = { @@ -34,6 +35,7 @@ export class LspConnection { private readonly resourceHandlers: LspResourceHandlers; private readonly relatedResourcesHandlers: LspRelatedResourcesHandlers; private readonly s3Handlers: LspS3Handlers; + private readonly systemHandlers: LspSystemHandlers; private initializeParams?: InitializeParams; @@ -59,6 +61,7 @@ export class LspConnection { this.resourceHandlers = new LspResourceHandlers(this.connection); this.relatedResourcesHandlers = new LspRelatedResourcesHandlers(this.connection); this.s3Handlers = new LspS3Handlers(this.connection); + this.systemHandlers = new LspSystemHandlers(this.connection); this.communication.console.info(`${ExtensionName} launched from ${__dirname}`); @@ -94,6 +97,7 @@ export class LspConnection { this.resourceHandlers, this.relatedResourcesHandlers, this.s3Handlers, + this.systemHandlers, ); } diff --git a/src/protocol/LspSystemHandlers.ts b/src/protocol/LspSystemHandlers.ts new file mode 100644 index 00000000..bfcef6d7 --- /dev/null +++ b/src/protocol/LspSystemHandlers.ts @@ -0,0 +1,21 @@ +import { Connection, RequestHandler, RequestType } from 'vscode-languageserver'; +import { Settings } from '../settings/Settings'; +import { ReadinessStatus } from '../utils/ReadinessContributor'; + +export type GetSystemStatusResponse = { + settingsReady: ReadinessStatus; + schemasReady: ReadinessStatus; + cfnLintReady: ReadinessStatus; + cfnGuardReady: ReadinessStatus; + currentSettings: Settings; +}; + +export const GetSystemStatusRequestType = new RequestType('aws/system/status'); + +export class LspSystemHandlers { + constructor(private readonly connection: Connection) {} + + onGetSystemStatus(handler: RequestHandler) { + this.connection.onRequest(GetSystemStatusRequestType.method, handler); + } +} diff --git a/src/schema/SchemaReadiness.ts b/src/schema/SchemaReadiness.ts new file mode 100644 index 00000000..54451539 --- /dev/null +++ b/src/schema/SchemaReadiness.ts @@ -0,0 +1,26 @@ +import { SettingsConfigurable, ISettingsSubscriber, SettingsSubscription } from '../settings/ISettingsSubscriber'; +import { DefaultSettings, ProfileSettings } from '../settings/Settings'; +import { ReadinessContributor, ReadinessStatus } from '../utils/ReadinessContributor'; +import { SchemaStore } from './SchemaStore'; + +export class SchemaReadiness implements ReadinessContributor, SettingsConfigurable { + private settings: ProfileSettings = DefaultSettings.profile; + private settingsSubscription?: SettingsSubscription; + + constructor(private readonly schemaStore: SchemaStore) {} + + isReady(): ReadinessStatus { + return { ready: this.schemaStore.getPublicSchemaRegions().includes(this.settings.region) }; + } + + configure(settingsManager: ISettingsSubscriber): void { + // Clean up existing subscription if present + if (this.settingsSubscription) { + this.settingsSubscription.unsubscribe(); + } + + this.settingsSubscription = settingsManager.subscribe('profile', (newSettings) => { + this.settings = newSettings; + }); + } +} diff --git a/src/server/CfnExternal.ts b/src/server/CfnExternal.ts index 1cf0a8bf..8840af71 100644 --- a/src/server/CfnExternal.ts +++ b/src/server/CfnExternal.ts @@ -2,6 +2,7 @@ import { FeatureFlagProvider, getFromGitHub } from '../featureFlag/FeatureFlagPr import { LspComponents } from '../protocol/LspComponents'; import { getSamSchemas } from '../schema/GetSamSchemaTask'; import { getRemotePrivateSchemas, getRemotePublicSchemas } from '../schema/GetSchemaTask'; +import { SchemaReadiness } from '../schema/SchemaReadiness'; import { SchemaRetriever } from '../schema/SchemaRetriever'; import { SchemaStore } from '../schema/SchemaStore'; import { AwsClient } from '../services/AwsClient'; @@ -31,6 +32,7 @@ export class CfnExternal implements Configurables, Closeable { readonly schemaStore: SchemaStore; readonly schemaRetriever: SchemaRetriever; + readonly schemaReadiness: SchemaReadiness; readonly cfnLintService: CfnLintService; readonly guardService: GuardService; @@ -59,6 +61,7 @@ export class CfnExternal implements Configurables, Closeable { undefined, validatePositiveOrUndefined(core.awsMetadata?.schema?.staleDaysThreshold), ); + this.schemaReadiness = overrides.schemaReadiness ?? new SchemaReadiness(this.schemaStore); this.cfnLintService = overrides.cfnLintService ?? @@ -80,7 +83,7 @@ export class CfnExternal implements Configurables, Closeable { } configurables(): Configurable[] { - return [this.schemaRetriever, this.cfnLintService, this.guardService]; + return [this.schemaRetriever, this.schemaReadiness, this.cfnLintService, this.guardService]; } async close() { diff --git a/src/server/CfnServer.ts b/src/server/CfnServer.ts index cae7a809..7cc7fba1 100644 --- a/src/server/CfnServer.ts +++ b/src/server/CfnServer.ts @@ -50,6 +50,7 @@ import { describeChangeSetHandler, describeEventsHandler, } from '../handlers/StackHandler'; +import { getSystemStatusHandler } from '../handlers/SystemHandler'; import { LspComponents } from '../protocol/LspComponents'; import { LoggerFactory } from '../telemetry/LoggerFactory'; import { withTelemetryContext } from '../telemetry/TelemetryContext'; @@ -115,6 +116,10 @@ export class CfnServer { ); this.lsp.handlers.onCodeLens(withTelemetryContext('CodeLens', codeLensHandler(this.components))); + this.lsp.systemHandlers.onGetSystemStatus( + withTelemetryContext('SystemStatus', getSystemStatusHandler(this.components)), + ); + this.lsp.authHandlers.onIamCredentialsUpdate( withTelemetryContext('Auth.Update', iamCredentialsUpdateHandler(this.components)), ); diff --git a/src/services/cfnLint/CfnLintService.ts b/src/services/cfnLint/CfnLintService.ts index 8599a85d..4f0eed5e 100644 --- a/src/services/cfnLint/CfnLintService.ts +++ b/src/services/cfnLint/CfnLintService.ts @@ -14,6 +14,7 @@ import { Count, Telemetry } from '../../telemetry/TelemetryDecorator'; import { Closeable } from '../../utils/Closeable'; import { CancellationError, Delayer } from '../../utils/Delayer'; import { extractErrorMessage } from '../../utils/Errors'; +import { ReadinessContributor, ReadinessStatus } from '../../utils/ReadinessContributor'; import { byteSize } from '../../utils/String'; import { DiagnosticCoordinator } from '../DiagnosticCoordinator'; import { WorkerNotInitializedError, MountError } from './CfnLintErrors'; @@ -44,7 +45,7 @@ export function sleep(ms: number): Promise { }); } -export class CfnLintService implements SettingsConfigurable, Closeable { +export class CfnLintService implements SettingsConfigurable, Closeable, ReadinessContributor { private static readonly CFN_LINT_SOURCE = 'cfn-lint'; private status: STATUS = STATUS.Uninitialized; @@ -128,6 +129,13 @@ export class CfnLintService implements SettingsConfigurable, Closeable { }); } + isReady(): ReadinessStatus { + if (!this.settings.enabled) { + return { ready: true }; + } + return { ready: this.status === STATUS.Initialized }; + } + private onSettingsChanged(newSettings: CfnLintSettings): void { this.settings = newSettings; this.workerManager.updateSettings(newSettings); diff --git a/src/services/guard/GuardService.ts b/src/services/guard/GuardService.ts index d0301c57..0abc5226 100644 --- a/src/services/guard/GuardService.ts +++ b/src/services/guard/GuardService.ts @@ -13,6 +13,7 @@ import { Closeable } from '../../utils/Closeable'; import { CancellationError, Delayer } from '../../utils/Delayer'; import { extractErrorMessage } from '../../utils/Errors'; import { readFileIfExistsAsync } from '../../utils/File'; +import { ReadinessContributor, ReadinessStatus } from '../../utils/ReadinessContributor'; import { byteSize } from '../../utils/String'; import { DiagnosticCoordinator } from '../DiagnosticCoordinator'; import { getRulesForPack, getAvailableRulePacks, GuardRuleData } from './GeneratedGuardRules'; @@ -41,7 +42,7 @@ interface ValidationQueueEntry { reject: (error: Error) => void; } -export class GuardService implements SettingsConfigurable, Closeable { +export class GuardService implements SettingsConfigurable, Closeable, ReadinessContributor { private static readonly CFN_GUARD_SOURCE = 'cfn-guard'; private settings: GuardSettings; @@ -60,6 +61,9 @@ export class GuardService implements SettingsConfigurable, Closeable { // Cache loaded rules private enabledRules: GuardRule[] = []; + // Track async rule loading state + private isLoadingRules = false; + // Validation queuing for concurrent requests private readonly validationQueue: ValidationQueueEntry[] = []; private readonly activeValidations = new Map>(); @@ -93,6 +97,13 @@ export class GuardService implements SettingsConfigurable, Closeable { }); } + public isReady(): ReadinessStatus { + if (!this.settings.enabled) { + return { ready: true }; + } + return { ready: !this.isLoadingRules && this.enabledRules.length > 0 }; + } + /** * Configure the GuardService with settings manager * Sets up subscription to diagnostics settings changes @@ -138,13 +149,18 @@ export class GuardService implements SettingsConfigurable, Closeable { // Clear maps only when rule configuration actually changes this.ruleToPacksMap.clear(); this.ruleCustomMessages.clear(); - // Preload rules with new settings + + // Track async rule loading + this.isLoadingRules = true; this.getEnabledRulesByConfiguration() .then((rules) => { this.enabledRules = rules; }) .catch((error) => { this.log.error(`Failed to preload rules after settings change: ${extractErrorMessage(error)}`); + }) + .finally(() => { + this.isLoadingRules = false; }); this.revalidateAllDocuments(); } diff --git a/src/settings/SettingsManager.ts b/src/settings/SettingsManager.ts index 5976e5d6..f7d73bed 100644 --- a/src/settings/SettingsManager.ts +++ b/src/settings/SettingsManager.ts @@ -4,6 +4,7 @@ import { LspWorkspace } from '../protocol/LspWorkspace'; import { LoggerFactory } from '../telemetry/LoggerFactory'; import { ScopedTelemetry } from '../telemetry/ScopedTelemetry'; import { Measure, Telemetry } from '../telemetry/TelemetryDecorator'; +import { ReadinessContributor, ReadinessStatus } from '../utils/ReadinessContributor'; import { AwsRegion } from '../utils/Region'; import { toString } from '../utils/String'; import { PartialDataObserver, SubscriptionManager } from '../utils/SubscriptionManager'; @@ -14,10 +15,11 @@ import { parseSettings } from './SettingsParser'; const logger = LoggerFactory.getLogger('SettingsManager'); -export class SettingsManager implements ISettingsSubscriber { +export class SettingsManager implements ISettingsSubscriber, ReadinessContributor { @Telemetry() private readonly telemetry!: ScopedTelemetry; private readonly settingsState = new SettingsState(); private readonly subscriptionManager = new SubscriptionManager(); + private settingsReady = false; constructor( private readonly workspace: LspWorkspace, @@ -33,6 +35,10 @@ export class SettingsManager implements ISettingsSubscriber { return this.settingsState.toSettings(); } + isReady(): ReadinessStatus { + return { ready: this.settingsReady }; + } + reset() { this.validateAndUpdate(DefaultSettings); } @@ -74,6 +80,7 @@ export class SettingsManager implements ISettingsSubscriber { const settings = parseWithPrettyError(parseSettings, mergedConfig, this.getCurrentSettings()); this.validateAndUpdate(settings); + this.settingsReady = true; } catch (error) { logger.error(error, `Failed to sync configuration, keeping previous settings`); } @@ -115,6 +122,8 @@ export class SettingsManager implements ISettingsSubscriber { */ @Measure({ name: 'settingsUpdate', captureErrorAttributes: true }) private validateAndUpdate(newSettings: Settings): void { + this.settingsReady = false; + const oldSettings = this.settingsState.toSettings(); newSettings.diagnostics.cfnLint.initialization.maxDelayMs = clipNumber( @@ -158,10 +167,16 @@ export class SettingsManager implements ISettingsSubscriber { const hasChanged = Object.keys(difference).length > 0; if (hasChanged) { - this.settingsState.update(newSettings); - logger.info(`Settings updated: ${toString(difference)}`); - this.subscriptionManager.notify(newSettings, oldSettings); + try { + this.settingsState.update(newSettings); + logger.info(`Settings updated: ${toString(difference)}`); + this.subscriptionManager.notify(newSettings, oldSettings); + } catch (error) { + logger.error(error, 'Failed to update settings'); + } } + + this.settingsReady = true; } private registerSettingsGauges(): void { diff --git a/src/utils/ReadinessContributor.ts b/src/utils/ReadinessContributor.ts new file mode 100644 index 00000000..e94f7d91 --- /dev/null +++ b/src/utils/ReadinessContributor.ts @@ -0,0 +1,7 @@ +export type ReadinessStatus = { + readonly ready: boolean; +}; + +export interface ReadinessContributor { + isReady(): ReadinessStatus; +} diff --git a/tools/telemetry-generator.ts b/tools/telemetry-generator.ts index c06d4540..5822243d 100644 --- a/tools/telemetry-generator.ts +++ b/tools/telemetry-generator.ts @@ -117,6 +117,7 @@ import { LspStackHandlers } from '../src/protocol/LspStackHandlers'; import { LspResourceHandlers } from '../src/protocol/LspResourceHandlers'; import { LspRelatedResourcesHandlers } from '../src/protocol/LspRelatedResourcesHandlers'; import { LspS3Handlers } from '../src/protocol/LspS3Handlers'; +import { LspSystemHandlers } from '../src/protocol/LspSystemHandlers'; import { RelationshipSchemaService } from '../src/services/RelationshipSchemaService'; import { LspCfnEnvironmentHandlers } from '../src/protocol/LspCfnEnvironmentHandlers'; import { FeatureFlagProvider, getFromGitHub } from '../src/featureFlag/FeatureFlagProvider'; @@ -189,6 +190,7 @@ function main() { stubInterface(), stubInterface(), stubInterface(), + stubInterface(), ); const dataStoreFactory = new MultiDataStoreFactoryProvider(); diff --git a/tst/unit/handlers/SystemHandler.test.ts b/tst/unit/handlers/SystemHandler.test.ts new file mode 100644 index 00000000..ad2951ca --- /dev/null +++ b/tst/unit/handlers/SystemHandler.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { CancellationToken, ResponseError } from 'vscode-languageserver'; +import { getSystemStatusHandler } from '../../../src/handlers/SystemHandler'; +import { DefaultSettings } from '../../../src/settings/Settings'; +import { createMockComponents } from '../../utils/MockServerComponents'; + +describe('SystemStatusHandler', () => { + let mockComponents: ReturnType; + + beforeEach(() => { + mockComponents = createMockComponents(); + }); + + describe('systemStatusHandler', () => { + it('should return system status when all components ready', () => { + mockComponents.guardService.isReady.returns({ ready: true }); + mockComponents.settingsManager.getCurrentSettings.returns(DefaultSettings); + mockComponents.settingsManager.isReady.returns({ ready: true }); + mockComponents.cfnLintService.isReady.returns({ ready: true }); + mockComponents.schemaReadiness.isReady.returns({ ready: true }); + + const handler = getSystemStatusHandler(mockComponents); + + const result = handler(undefined, CancellationToken.None); + + expect(result).toEqual({ + settingsReady: { ready: true }, + schemasReady: { ready: true }, + cfnLintReady: { ready: true }, + cfnGuardReady: { ready: true }, + currentSettings: DefaultSettings, + }); + }); + + it('should return system status when components not ready', () => { + mockComponents.guardService.isReady.returns({ ready: false }); + mockComponents.settingsManager.getCurrentSettings.returns(DefaultSettings); + mockComponents.settingsManager.isReady.returns({ ready: true }); + mockComponents.cfnLintService.isReady.returns({ + ready: false, + }); + mockComponents.schemaReadiness.isReady.returns({ ready: false }); + + const handler = getSystemStatusHandler(mockComponents); + + const result = handler(undefined, CancellationToken.None); + + expect(result).toEqual({ + settingsReady: { ready: true }, + schemasReady: { ready: false }, + cfnLintReady: { ready: false }, + cfnGuardReady: { ready: false }, + currentSettings: DefaultSettings, + }); + }); + + it('should handle errors gracefully', () => { + const originalError = new Error('Database error'); + mockComponents.settingsManager.getCurrentSettings.throws(originalError); + + const handler = getSystemStatusHandler(mockComponents); + + expect(() => handler(undefined, CancellationToken.None)).toThrow(ResponseError); + expect(() => handler(undefined, CancellationToken.None)).toThrow('Failed to get system status'); + }); + }); +}); diff --git a/tst/unit/protocol/LspSystemHandlers.test.ts b/tst/unit/protocol/LspSystemHandlers.test.ts new file mode 100644 index 00000000..8b23bea8 --- /dev/null +++ b/tst/unit/protocol/LspSystemHandlers.test.ts @@ -0,0 +1,31 @@ +import { StubbedInstance, stubInterface } from 'ts-sinon'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { Connection } from 'vscode-languageserver/node'; +import { GetSystemStatusRequestType, LspSystemHandlers } from '../../../src/protocol/LspSystemHandlers'; + +describe('LspSystemHandlers', () => { + let lspSystemHandlers: LspSystemHandlers; + let mockConnection: StubbedInstance; + + beforeEach(() => { + vi.clearAllMocks(); + mockConnection = stubInterface(); + lspSystemHandlers = new LspSystemHandlers(mockConnection); + }); + + describe('constructor', () => { + it('should initialize with connection', () => { + expect(lspSystemHandlers).toBeDefined(); + }); + }); + + describe('handler registration', () => { + it('should register system status handler', () => { + const mockHandler = vi.fn(); + + lspSystemHandlers.onGetSystemStatus(mockHandler); + + expect(mockConnection.onRequest.calledWith(GetSystemStatusRequestType.method)).toBe(true); + }); + }); +}); diff --git a/tst/unit/schema/SchemaReadiness.test.ts b/tst/unit/schema/SchemaReadiness.test.ts new file mode 100644 index 00000000..ea5098fd --- /dev/null +++ b/tst/unit/schema/SchemaReadiness.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { SchemaReadiness } from '../../../src/schema/SchemaReadiness'; +import { DefaultSettings } from '../../../src/settings/Settings'; +import { AwsRegion } from '../../../src/utils/Region'; +import { createMockSchemaStore, createMockSettingsManager } from '../../utils/MockServerComponents'; + +describe('SchemaReadiness', () => { + let mockSchemaStore: ReturnType; + let mockSettingsManager: ReturnType; + let schemaReadiness: SchemaReadiness; + + beforeEach(() => { + mockSchemaStore = createMockSchemaStore(); + mockSettingsManager = createMockSettingsManager(); + // SchemaReadiness initializes with DefaultSettings.profile (region: us-east-1) + schemaReadiness = new SchemaReadiness(mockSchemaStore); + }); + + describe('isReady', () => { + it('should return ready when schemas are available for current region', () => { + mockSchemaStore.getPublicSchemaRegions.returns([AwsRegion.US_EAST_1, AwsRegion.US_WEST_2]); + + const result = schemaReadiness.isReady(); + + expect(result).toEqual({ ready: true }); + }); + + it('should return not ready when schemas are not available for current region', () => { + mockSchemaStore.getPublicSchemaRegions.returns([AwsRegion.EU_WEST_1]); + + const result = schemaReadiness.isReady(); + + expect(result).toEqual({ ready: false }); + }); + + it('should return not ready when no schemas are available', () => { + mockSchemaStore.getPublicSchemaRegions.returns([]); + + const result = schemaReadiness.isReady(); + + expect(result).toEqual({ ready: false }); + }); + }); + + describe('configure', () => { + it('should subscribe to profile settings changes', () => { + schemaReadiness.configure(mockSettingsManager); + + expect(mockSettingsManager.subscribe.calledWith('profile')).toBe(true); + }); + + it('should update settings when profile changes', () => { + const newSettings = { ...DefaultSettings.profile, region: AwsRegion.EU_WEST_1 }; + mockSchemaStore.getPublicSchemaRegions.returns([AwsRegion.EU_WEST_1]); + + schemaReadiness.configure(mockSettingsManager); + const callback = mockSettingsManager.subscribe.getCall(0).args[1]; + callback(newSettings); + + const result = schemaReadiness.isReady(); + expect(result).toEqual({ ready: true }); + }); + + it('should unsubscribe existing subscription before creating new one', () => { + const mockUnsubscribe = { unsubscribe: vi.fn(), isActive: vi.fn().mockReturnValue(true) }; + mockSettingsManager.subscribe.returns(mockUnsubscribe); + + schemaReadiness.configure(mockSettingsManager); + schemaReadiness.configure(mockSettingsManager); + + expect(mockUnsubscribe.unsubscribe).toHaveBeenCalled(); + }); + }); +}); diff --git a/tst/unit/services/guard/GuardService.test.ts b/tst/unit/services/guard/GuardService.test.ts index 61e017a9..bcf4a5aa 100644 --- a/tst/unit/services/guard/GuardService.test.ts +++ b/tst/unit/services/guard/GuardService.test.ts @@ -616,4 +616,43 @@ rule S3_BUCKET_ENCRYPTION { expect(service).toBeInstanceOf(GuardService); }); }); + + describe('isReady', () => { + it('should return not ready when no rules loaded', () => { + const service = GuardService.create(mockComponents, mockGuardEngine, mockRuleConfiguration, mockDelayer); + const result = service.isReady(); + expect(result).toEqual({ ready: false }); + }); + + it('should return not ready when loading rules', () => { + const service = GuardService.create(mockComponents, mockGuardEngine, mockRuleConfiguration, mockDelayer); + + // Set the loading state via private property access + (service as any).isLoadingRules = true; + + const result = service.isReady(); + expect(result).toEqual({ ready: false }); + }); + + it('should return ready when service is disabled', () => { + const disabledSettings = { + ...DefaultSettings, + diagnostics: { + ...DefaultSettings.diagnostics, + cfnGuard: { + ...DefaultSettings.diagnostics.cfnGuard, + enabled: false, + }, + }, + }; + mockComponents.settingsManager.getCurrentSettings.returns(disabledSettings); + const service = GuardService.create(mockComponents, mockGuardEngine, mockRuleConfiguration, mockDelayer); + + // Configure the service to pick up the settings + service.configure(mockComponents.settingsManager); + + const result = service.isReady(); + expect(result).toEqual({ ready: true }); + }); + }); }); diff --git a/tst/unit/settings/SettingsManager.test.ts b/tst/unit/settings/SettingsManager.test.ts index c238422d..de897ff1 100644 --- a/tst/unit/settings/SettingsManager.test.ts +++ b/tst/unit/settings/SettingsManager.test.ts @@ -378,6 +378,40 @@ describe('SettingsManager', () => { }); }); + describe('settings readiness status', () => { + test('should return not ready initially', () => { + expect(manager.isReady()).toEqual({ ready: false }); + }); + + test('should return updating during settings update', async () => { + const mockConfig = { hover: { enabled: false } }; + mockWorkspace.getConfiguration.resolves(mockConfig); + + let statusDuringUpdate: any = null; + + // Spy on the subscription manager notify method to capture the status during update + const originalNotify = (manager as any).subscriptionManager.notify; + (manager as any).subscriptionManager.notify = vi.fn().mockImplementation((...args) => { + statusDuringUpdate = manager.isReady(); + return originalNotify.apply((manager as any).subscriptionManager, args); + }); + + await manager.syncConfiguration(); + + expect(statusDuringUpdate).toEqual({ ready: false }); + expect(manager.isReady()).toEqual({ ready: true }); + }); + + test('should return ready after settings update completes', async () => { + const mockConfig = { hover: { enabled: false } }; + mockWorkspace.getConfiguration.resolves(mockConfig); + + await manager.syncConfiguration(); + + expect(manager.isReady()).toEqual({ ready: true }); + }); + }); + describe('initialization settings', () => { test('should apply init settings when workspace returns null', async () => { const initSettings = { diff --git a/tst/utils/MockServerComponents.ts b/tst/utils/MockServerComponents.ts index bf145504..33ceb5aa 100644 --- a/tst/utils/MockServerComponents.ts +++ b/tst/utils/MockServerComponents.ts @@ -30,6 +30,7 @@ import { LspRelatedResourcesHandlers } from '../../src/protocol/LspRelatedResour import { LspResourceHandlers } from '../../src/protocol/LspResourceHandlers'; import { LspS3Handlers } from '../../src/protocol/LspS3Handlers'; import { LspStackHandlers } from '../../src/protocol/LspStackHandlers'; +import { LspSystemHandlers } from '../../src/protocol/LspSystemHandlers'; import { LspWorkspace } from '../../src/protocol/LspWorkspace'; import { RelatedResourcesSnippetProvider } from '../../src/relatedResources/RelatedResourcesSnippetProvider'; import { ResourceStateImporter } from '../../src/resourceState/ResourceStateImporter'; @@ -132,6 +133,7 @@ export function createMockCfnLintService() { mock.lint.returns(Promise.resolve()); mock.lintDelayed.returns(Promise.resolve()); mock.isInitialized.returns(true); + mock.isReady.returns({ ready: true }); return mock; } @@ -144,6 +146,7 @@ export function createMockGuardService() { mock.getPendingValidationCount.returns(0); mock.getQueuedValidationCount.returns(0); mock.getActiveValidationCount.returns(0); + mock.isReady.returns({ ready: true }); return mock; } @@ -215,6 +218,7 @@ export function createMockResourceStateImporter() { export function createMockSettingsManager(customSettings?: Settings) { const mock = stubInterface(); mock.getCurrentSettings.returns(customSettings ?? DefaultSettings); + mock.isReady.returns({ ready: true }); mock.syncConfiguration.returns(Promise.resolve()); return mock; } @@ -353,6 +357,7 @@ export function createMockComponents(o: Partial = {} resourceHandlers: overrides.resourceHandlers ?? stubInterface(), relatedResourcesHandlers: overrides.relatedResourcesHandlers ?? stubInterface(), s3Handlers: overrides.s3Handlers ?? stubInterface(), + systemHandlers: overrides.systemHandlers ?? stubInterface(), }; const core: MockInfraCoreComponents = { @@ -379,6 +384,7 @@ export function createMockComponents(o: Partial = {} iacGeneratorService: overrides.iacGeneratorService ?? createMockIacGeneratorService(), schemaStore: overrides.schemaStore ?? createMockSchemaStore(), schemaRetriever: overrides.schemaRetriever ?? createMockSchemaRetriever(), + schemaReadiness: overrides.schemaReadiness ?? stubInterface(), cfnLintService: overrides.cfnLintService ?? createMockCfnLintService(), guardService: overrides.guardService ?? createMockGuardService(), s3Service: overrides.s3Service ?? stubInterface(),