diff --git a/assets/featureFlag/alpha.json b/assets/featureFlag/alpha.json index e61838e..f8a6cbe 100644 --- a/assets/featureFlag/alpha.json +++ b/assets/featureFlag/alpha.json @@ -1,49 +1,53 @@ { - "version": 1, - "description": "Feature flags for alpha environment", - "features": { - "Constants": { - "enabled": false - }, - "EnhancedDryRun": { - "enabled": true, - "fleetPercentage": 100, - "allowlistedRegions": [ - "us-east-1", - "us-east-2", - "us-west-1", - "us-west-2", - "ca-central-1", - "ca-west-1", - "sa-east-1", - "mx-central-1", - "eu-north-1", - "eu-west-1", - "eu-west-2", - "eu-west-3", - "eu-central-1", - "eu-central-2", - "eu-south-1", - "eu-south-2", - "ap-east-1", - "ap-east-2", - "ap-south-1", - "ap-south-2", - "ap-northeast-1", - "ap-northeast-2", - "ap-northeast-3", - "ap-southeast-1", - "ap-southeast-2", - "ap-southeast-3", - "ap-southeast-4", - "ap-southeast-5", - "ap-southeast-7", - "me-south-1", - "me-central-1", - "af-south-1", - "il-central-1", - "test-region" - ] + "version": 1, + "description": "Feature flags for alpha environment", + "features": { + "Constants": { + "enabled": false + }, + "EnhancedDryRun": { + "enabled": true, + "fleetPercentage": 100, + "allowlistedRegions": [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ca-central-1", + "ca-west-1", + "sa-east-1", + "mx-central-1", + "eu-north-1", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-central-1", + "eu-central-2", + "eu-south-1", + "eu-south-2", + "ap-east-1", + "ap-east-2", + "ap-south-1", + "ap-south-2", + "ap-northeast-1", + "ap-northeast-2", + "ap-northeast-3", + "ap-southeast-1", + "ap-southeast-2", + "ap-southeast-3", + "ap-southeast-4", + "ap-southeast-5", + "ap-southeast-7", + "me-south-1", + "me-central-1", + "af-south-1", + "il-central-1", + "test-region" + ] + }, + "FileDb": { + "enabled": true, + "fleetPercentage": 100 + } } - } -} \ No newline at end of file +} diff --git a/assets/featureFlag/beta.json b/assets/featureFlag/beta.json index 7539720..4469780 100644 --- a/assets/featureFlag/beta.json +++ b/assets/featureFlag/beta.json @@ -1,48 +1,52 @@ { - "version": 1, - "description": "Feature flags for beta environment", - "features": { - "Constants": { - "enabled": false - }, - "EnhancedDryRun": { - "enabled": true, - "fleetPercentage": 100, - "allowlistedRegions": [ - "us-east-1", - "us-east-2", - "us-west-1", - "us-west-2", - "ca-central-1", - "ca-west-1", - "sa-east-1", - "mx-central-1", - "eu-north-1", - "eu-west-1", - "eu-west-2", - "eu-west-3", - "eu-central-1", - "eu-central-2", - "eu-south-1", - "eu-south-2", - "ap-east-1", - "ap-east-2", - "ap-south-1", - "ap-south-2", - "ap-northeast-1", - "ap-northeast-2", - "ap-northeast-3", - "ap-southeast-1", - "ap-southeast-2", - "ap-southeast-3", - "ap-southeast-4", - "ap-southeast-5", - "ap-southeast-7", - "me-south-1", - "me-central-1", - "af-south-1", - "il-central-1" - ] + "version": 1, + "description": "Feature flags for beta environment", + "features": { + "Constants": { + "enabled": false + }, + "EnhancedDryRun": { + "enabled": true, + "fleetPercentage": 100, + "allowlistedRegions": [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ca-central-1", + "ca-west-1", + "sa-east-1", + "mx-central-1", + "eu-north-1", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-central-1", + "eu-central-2", + "eu-south-1", + "eu-south-2", + "ap-east-1", + "ap-east-2", + "ap-south-1", + "ap-south-2", + "ap-northeast-1", + "ap-northeast-2", + "ap-northeast-3", + "ap-southeast-1", + "ap-southeast-2", + "ap-southeast-3", + "ap-southeast-4", + "ap-southeast-5", + "ap-southeast-7", + "me-south-1", + "me-central-1", + "af-south-1", + "il-central-1" + ] + }, + "FileDb": { + "enabled": false, + "fleetPercentage": 100 + } } - } -} \ No newline at end of file +} diff --git a/assets/featureFlag/prod.json b/assets/featureFlag/prod.json index 9499347..3ef2fde 100644 --- a/assets/featureFlag/prod.json +++ b/assets/featureFlag/prod.json @@ -1,48 +1,52 @@ { - "version": 1, - "description": "Feature flags for prod environment", - "features": { - "Constants": { - "enabled": false - }, - "EnhancedDryRun": { - "enabled": true, - "fleetPercentage": 100, - "allowlistedRegions": [ - "us-east-1", - "us-east-2", - "us-west-1", - "us-west-2", - "ca-central-1", - "ca-west-1", - "sa-east-1", - "mx-central-1", - "eu-north-1", - "eu-west-1", - "eu-west-2", - "eu-west-3", - "eu-central-1", - "eu-central-2", - "eu-south-1", - "eu-south-2", - "ap-east-1", - "ap-east-2", - "ap-south-1", - "ap-south-2", - "ap-northeast-1", - "ap-northeast-2", - "ap-northeast-3", - "ap-southeast-1", - "ap-southeast-2", - "ap-southeast-3", - "ap-southeast-4", - "ap-southeast-5", - "ap-southeast-7", - "me-south-1", - "me-central-1", - "af-south-1", - "il-central-1" - ] + "version": 1, + "description": "Feature flags for prod environment", + "features": { + "Constants": { + "enabled": false + }, + "EnhancedDryRun": { + "enabled": true, + "fleetPercentage": 100, + "allowlistedRegions": [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ca-central-1", + "ca-west-1", + "sa-east-1", + "mx-central-1", + "eu-north-1", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-central-1", + "eu-central-2", + "eu-south-1", + "eu-south-2", + "ap-east-1", + "ap-east-2", + "ap-south-1", + "ap-south-2", + "ap-northeast-1", + "ap-northeast-2", + "ap-northeast-3", + "ap-southeast-1", + "ap-southeast-2", + "ap-southeast-3", + "ap-southeast-4", + "ap-southeast-5", + "ap-southeast-7", + "me-south-1", + "me-central-1", + "af-south-1", + "il-central-1" + ] + }, + "FileDb": { + "enabled": false, + "fleetPercentage": 0 + } } - } -} \ No newline at end of file +} diff --git a/src/datastore/DataStore.ts b/src/datastore/DataStore.ts index 17d041a..ed26663 100644 --- a/src/datastore/DataStore.ts +++ b/src/datastore/DataStore.ts @@ -1,3 +1,4 @@ +import { FeatureFlag } from '../featureFlag/FeatureFlagI'; import { Closeable } from '../utils/Closeable'; import { isWindows } from '../utils/Environment'; import { pathToStorage } from '../utils/Storage'; @@ -62,8 +63,8 @@ export class MultiDataStoreFactoryProvider implements DataStoreFactoryProvider { private readonly memoryStoreFactory: MemoryStoreFactory; private readonly persistedStore: DataStoreFactory; - constructor() { - if (isWindows) { + constructor(fileDbFeatureFlag: FeatureFlag) { + if (fileDbFeatureFlag.isEnabled() || isWindows) { this.persistedStore = new FileStoreFactory(pathToStorage()); } else { this.persistedStore = new LMDBStoreFactory(pathToStorage()); diff --git a/src/featureFlag/DynamicFeatureFlag.ts b/src/featureFlag/DynamicFeatureFlag.ts index 5ad6a77..04c743c 100644 --- a/src/featureFlag/DynamicFeatureFlag.ts +++ b/src/featureFlag/DynamicFeatureFlag.ts @@ -2,7 +2,7 @@ import { Closeable } from '../utils/Closeable'; import { FeatureFlagBuilderType, FeatureFlagConfigType, TargetedFeatureFlagBuilderType } from './FeatureFlagBuilder'; import { FeatureFlag, TargetedFeatureFlag } from './FeatureFlagI'; -export const DynamicRefreshIntervalMs = 60 * 1000; +export const DynamicRefreshIntervalMs = 2 * 60 * 1000; export class DynamicFeatureFlag implements FeatureFlag, Closeable { private flag: FeatureFlag; diff --git a/src/featureFlag/FeatureFlagProvider.ts b/src/featureFlag/FeatureFlagProvider.ts index 926f220..8675f49 100644 --- a/src/featureFlag/FeatureFlagProvider.ts +++ b/src/featureFlag/FeatureFlagProvider.ts @@ -15,7 +15,7 @@ import { FeatureFlagSupplier, FeatureFlagConfigKey, TargetedFeatureFlagConfigKey const log = LoggerFactory.getLogger('FeatureFlagProvider'); -const RefreshIntervalMs = 5 * 60 * 1000; +const RefreshIntervalMs = 15 * 60 * 1000; export class FeatureFlagProvider implements Closeable { @Telemetry() @@ -28,7 +28,7 @@ export class FeatureFlagProvider implements Closeable { constructor( private readonly getLatestFeatureFlags: (env: string) => Promise, - private readonly localFile = join(__dirname, 'assets', 'featureFlag', `${AwsEnv.toLowerCase()}.json`), + private readonly localFile = featureFlagLocalFile(), refreshIntervalMs: number = RefreshIntervalMs, dynamicRefreshIntervalMs: number = DynamicRefreshIntervalMs, ) { @@ -78,6 +78,7 @@ export class FeatureFlagProvider implements Closeable { this.config = newConfig; writeFileSync(this.localFile, JSON.stringify(newConfig, undefined, 2)); this.telemetry.count('refresh.local.update', 1); + log.info('Updated and saved feature flags'); this.log(); } @@ -135,3 +136,7 @@ function defaultConfig(configFile: string, telemetry: ScopedTelemetry): FeatureF return { version: 1, description: 'Default empty config', features: {} }; } } + +export function featureFlagLocalFile(baseDir: string = __dirname) { + return join(baseDir, 'assets', 'featureFlag', `${AwsEnv.toLowerCase()}.json`); +} diff --git a/src/featureFlag/FeatureFlagSupplier.ts b/src/featureFlag/FeatureFlagSupplier.ts index cb9b502..68441fe 100644 --- a/src/featureFlag/FeatureFlagSupplier.ts +++ b/src/featureFlag/FeatureFlagSupplier.ts @@ -33,7 +33,10 @@ export class FeatureFlagSupplier implements Closeable { defaultConfig: () => unknown, dynamicRefreshIntervalMs: number = DynamicRefreshIntervalMs, ) { - for (const [key, builder] of Object.entries(FeatureBuilders)) { + for (const [key, builder] of Object.entries(FeatureBuilders) as [ + FeatureFlagConfigKey, + FeatureFlagBuilderType, + ][]) { const ff = new DynamicFeatureFlag( key, () => featureConfigSupplier(key, configSupplier, defaultConfig, this.telemetry), @@ -43,7 +46,10 @@ export class FeatureFlagSupplier implements Closeable { this._featureFlags.set(key, ff); } - for (const [key, builder] of Object.entries(TargetedFeatureBuilders)) { + for (const [key, builder] of Object.entries(TargetedFeatureBuilders) as [ + TargetedFeatureFlagConfigKey, + TargetedFeatureFlagBuilderType, + ][]) { const ff = new DynamicTargetedFeatureFlag( key, () => featureConfigSupplier(key, configSupplier, defaultConfig, this.telemetry), @@ -95,19 +101,20 @@ function featureConfigSupplier( return FeatureFlagConfigSchema.parse(configSupplier()).features[key]; } catch (err) { telemetry.count('used.config.default', 1); - log.warn(err, `Failed to parse feature flag config: \n${toString(configSupplier())}. Using defaults instead`); + log.error(err, `Failed to parse feature flag config: \n${toString(configSupplier())}. Using defaults instead`); return FeatureFlagConfigSchema.parse(defaultConfig()).features[key]; } } -const FeatureBuilders: Record = { +const FeatureBuilders = { Constants: buildStatic, -} as const; -const TargetedFeatureBuilders: Record> = { + FileDb: buildLocalHost, +} as const satisfies Record; +const TargetedFeatureBuilders = { EnhancedDryRun: (name: string, config?: FeatureFlagConfigType) => { return new CompoundFeatureFlag(buildLocalHost(name, config), buildRegional(name, config)); }, -} as const; +} as const satisfies Record>; export type FeatureFlagConfigKey = keyof typeof FeatureBuilders; export type TargetedFeatureFlagConfigKey = keyof typeof TargetedFeatureBuilders; diff --git a/src/server/CfnExternal.ts b/src/server/CfnExternal.ts index 8840af7..36aad85 100644 --- a/src/server/CfnExternal.ts +++ b/src/server/CfnExternal.ts @@ -1,4 +1,4 @@ -import { FeatureFlagProvider, getFromGitHub } from '../featureFlag/FeatureFlagProvider'; +import { FeatureFlagProvider } from '../featureFlag/FeatureFlagProvider'; import { LspComponents } from '../protocol/LspComponents'; import { getSamSchemas } from '../schema/GetSamSchemaTask'; import { getRemotePrivateSchemas, getRemotePublicSchemas } from '../schema/GetSchemaTask'; @@ -41,7 +41,7 @@ export class CfnExternal implements Configurables, Closeable { readonly featureFlags: FeatureFlagProvider; readonly onlineFeatureGuard: OnlineFeatureGuard; - constructor(lsp: LspComponents, core: CfnInfraCore, overrides: Partial = {}) { + constructor(lsp: LspComponents, core: CfnInfraCore, overrides: Omit, 'featureFlags'> = {}) { this.awsClient = overrides.awsClient ?? new AwsClient(core.awsCredentials, core.awsMetadata?.cloudformation?.endpoint); @@ -71,14 +71,7 @@ export class CfnExternal implements Configurables, Closeable { new GuardService(core.documentManager, core.diagnosticCoordinator, core.syntaxTreeManager); this.onlineStatus = overrides.onlineStatus ?? new OnlineStatus(); - this.featureFlags = - overrides.featureFlags ?? - new FeatureFlagProvider( - getFromGitHub, - undefined, - validatePositiveOrUndefined(core.awsMetadata?.featureFlags?.refreshIntervalMs), - validatePositiveOrUndefined(core.awsMetadata?.featureFlags?.dynamicRefreshIntervalMs), - ); + this.featureFlags = core.featureFlags; this.onlineFeatureGuard = overrides.onlineFeatureGuard ?? new OnlineFeatureGuard(core.awsCredentials); } @@ -87,12 +80,6 @@ export class CfnExternal implements Configurables, Closeable { } async close() { - return await closeSafely( - this.cfnLintService, - this.guardService, - this.schemaRetriever, - this.onlineStatus, - this.featureFlags, - ); + return await closeSafely(this.cfnLintService, this.guardService, this.schemaRetriever, this.onlineStatus); } } diff --git a/src/server/CfnInfraCore.ts b/src/server/CfnInfraCore.ts index 7f2422f..44a5288 100644 --- a/src/server/CfnInfraCore.ts +++ b/src/server/CfnInfraCore.ts @@ -5,6 +5,7 @@ import { SyntaxTreeManager } from '../context/syntaxtree/SyntaxTreeManager'; import { DataStoreFactoryProvider, MultiDataStoreFactoryProvider } from '../datastore/DataStore'; import { DocumentManager } from '../document/DocumentManager'; import { DocumentMetadata } from '../document/DocumentProtocol'; +import { featureFlagLocalFile, FeatureFlagProvider, getFromGitHub } from '../featureFlag/FeatureFlagProvider'; import { LspComponents } from '../protocol/LspComponents'; import { DiagnosticCoordinator } from '../services/DiagnosticCoordinator'; import { SettingsManager } from '../settings/SettingsManager'; @@ -15,6 +16,7 @@ import { UsageTracker } from '../usageTracker/UsageTracker'; import { UsageTrackerMetrics } from '../usageTracker/UsageTrackerMetrics'; import { Closeable, closeSafely } from '../utils/Closeable'; import { Configurable, Configurables } from '../utils/Configurable'; +import { validatePositiveOrUndefined } from '../utils/Number'; import { AwsMetadata, ExtendedInitializeParams } from './InitParams'; /** @@ -24,6 +26,7 @@ import { AwsMetadata, ExtendedInitializeParams } from './InitParams'; */ export class CfnInfraCore implements Configurables, Closeable { readonly awsMetadata?: AwsMetadata; + readonly featureFlags: FeatureFlagProvider; readonly dataStoreFactory: DataStoreFactoryProvider; readonly clientMessage: ClientMessage; readonly settingsManager: SettingsManager; @@ -45,7 +48,16 @@ export class CfnInfraCore implements Configurables, Closeable { overrides: Partial = {}, ) { this.awsMetadata = initializeParams.initializationOptions?.aws; - this.dataStoreFactory = overrides.dataStoreFactory ?? new MultiDataStoreFactoryProvider(); + this.featureFlags = + overrides.featureFlags ?? + new FeatureFlagProvider( + getFromGitHub, + featureFlagLocalFile(), + validatePositiveOrUndefined(this.awsMetadata?.featureFlags?.refreshIntervalMs), + validatePositiveOrUndefined(this.awsMetadata?.featureFlags?.dynamicRefreshIntervalMs), + ); + this.dataStoreFactory = + overrides.dataStoreFactory ?? new MultiDataStoreFactoryProvider(this.featureFlags.get('FileDb')); this.clientMessage = overrides.clientMessage ?? new ClientMessage(lspComponents.communication); this.settingsManager = overrides.settingsManager ?? new SettingsManager(lspComponents.workspace, this.awsMetadata?.settings); @@ -82,6 +94,11 @@ export class CfnInfraCore implements Configurables, Closeable { } async close() { - return await closeSafely(this.documentManager, this.dataStoreFactory, TelemetryService.instance); + return await closeSafely( + this.documentManager, + this.dataStoreFactory, + this.featureFlags, + TelemetryService.instance, + ); } } diff --git a/src/services/RelationshipSchemaService.ts b/src/services/RelationshipSchemaService.ts index 1e2a33a..bdb91e2 100644 --- a/src/services/RelationshipSchemaService.ts +++ b/src/services/RelationshipSchemaService.ts @@ -26,7 +26,7 @@ export type RelationshipGroupData = Record; export class RelationshipSchemaService { private readonly relationshipCache: Map = new Map(); - constructor(private readonly schemaFilePath: string = join(__dirname, 'assets', 'relationship_schemas.json')) { + constructor(private readonly schemaFilePath: string = relationshipLocalFile()) { this.loadAllSchemas(); } @@ -152,3 +152,7 @@ export class RelationshipSchemaService { return [...resourceTypes]; } } + +export function relationshipLocalFile(baseDir: string = __dirname) { + return join(baseDir, 'assets', 'relationship_schemas.json'); +} diff --git a/src/utils/File.ts b/src/utils/File.ts index 9c78ca7..b7021e4 100644 --- a/src/utils/File.ts +++ b/src/utils/File.ts @@ -44,7 +44,7 @@ export function readBufferIfExists( encoding?: null | undefined; flag?: string | undefined; } | null, -): NonSharedBuffer { +): Buffer { try { if (existsSync(path)) { return readFileSync(path, options); diff --git a/src/utils/RemoteDownload.ts b/src/utils/RemoteDownload.ts index 27cf26e..f46d36c 100644 --- a/src/utils/RemoteDownload.ts +++ b/src/utils/RemoteDownload.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import { LoggerFactory } from '../telemetry/LoggerFactory'; export async function downloadFile(url: string): Promise { const response = await axios({ @@ -7,10 +8,12 @@ export async function downloadFile(url: string): Promise { responseType: 'arraybuffer', }); + LoggerFactory.getLogger('Remote').info(`Fetching ${url}`); return Buffer.from(response.data); } export async function downloadJson(url: string): Promise { + LoggerFactory.getLogger('Remote').info(`Fetching ${url}`); const response = await axios({ method: 'get', url: url, diff --git a/src/utils/Retry.ts b/src/utils/Retry.ts index 211eee6..6be88ff 100644 --- a/src/utils/Retry.ts +++ b/src/utils/Retry.ts @@ -11,7 +11,7 @@ export type RetryOptions = { totalTimeoutMs: number; }; -function sleep(ms: number): Promise { +export function sleep(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); diff --git a/tools/telemetry-generator.ts b/tools/telemetry-generator.ts index 5822243..fd92636 100644 --- a/tools/telemetry-generator.ts +++ b/tools/telemetry-generator.ts @@ -120,9 +120,8 @@ 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'; -import { AwsEnv } from '../src/utils/Environment'; import { TelemetryService } from '../src/telemetry/TelemetryService'; +import { featureFlagLocalFile, FeatureFlagProvider, getFromGitHub } from '../src/featureFlag/FeatureFlagProvider'; const textDocuments = new TextDocuments(TextDocument); @@ -193,7 +192,8 @@ function main() { stubInterface(), ); - const dataStoreFactory = new MultiDataStoreFactoryProvider(); + const featureFlags = new FeatureFlagProvider(getFromGitHub, featureFlagLocalFile(join(__dirname, '..'))); + const dataStoreFactory = new MultiDataStoreFactoryProvider(featureFlags.get('FileDb')); const core = new CfnInfraCore( lsp, { @@ -207,16 +207,13 @@ function main() { { dataStoreFactory, documentManager: new DocumentManager(textDocuments), + featureFlags, }, ); const schemaStore = new SchemaStore(dataStoreFactory); const external = new CfnExternal(lsp, core, { schemaStore, - featureFlags: new FeatureFlagProvider( - getFromGitHub, - join(__dirname, '..', 'assets', 'featureFlag', `${AwsEnv}.json`), - ), }); const providers = new CfnLspProviders(core, external, { diff --git a/tst/unit/featureFlag/FeatureFlag.test.ts b/tst/unit/featureFlag/FeatureFlag.test.ts index 13a4a01..5ba3238 100644 --- a/tst/unit/featureFlag/FeatureFlag.test.ts +++ b/tst/unit/featureFlag/FeatureFlag.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect } from 'vitest'; +import { AndFeatureFlag, LocalHostTargetedFeatureFlag } from '../../../src/featureFlag/CombinedFeatureFlags'; import { StaticFeatureFlag, FleetTargetedFeatureFlag, RegionAllowlistFeatureFlag, } from '../../../src/featureFlag/FeatureFlag'; +import { buildLocalHost } from '../../../src/featureFlag/FeatureFlagBuilder'; import { AwsRegion } from '../../../src/utils/Region'; describe('StaticFeatureFlag', () => { @@ -80,3 +82,70 @@ describe('RegionAllowlistFeatureFlag', () => { expect(description).toContain('eu-west-1'); }); }); + +describe('AndFeatureFlag', () => { + it('should return true when all flags are enabled', () => { + const flag = new AndFeatureFlag(new StaticFeatureFlag('a', true), new StaticFeatureFlag('b', true)); + expect(flag.isEnabled()).toBe(true); + }); + + it('should return false when any flag is disabled', () => { + const flag = new AndFeatureFlag(new StaticFeatureFlag('a', true), new StaticFeatureFlag('b', false)); + expect(flag.isEnabled()).toBe(false); + }); + + it('should throw when constructed with no flags', () => { + expect(() => new AndFeatureFlag()).toThrow('1 or more feature flags required'); + }); + + it('should describe all child flags', () => { + const flag = new AndFeatureFlag(new StaticFeatureFlag('a', true), new StaticFeatureFlag('b', false)); + expect(flag.describe()).toContain('a'); + expect(flag.describe()).toContain('b'); + }); +}); + +describe('LocalHostTargetedFeatureFlag', () => { + it('should be enabled at 100% fleet percentage', () => { + const flag = new LocalHostTargetedFeatureFlag(new FleetTargetedFeatureFlag('test', 100)); + expect(flag.isEnabled()).toBe(true); + }); + + it('should be disabled at 0% fleet percentage', () => { + const flag = new LocalHostTargetedFeatureFlag(new FleetTargetedFeatureFlag('test', 0)); + expect(flag.isEnabled()).toBe(false); + }); + + it('should return consistent results across calls', () => { + const flag = new LocalHostTargetedFeatureFlag(new FleetTargetedFeatureFlag('test', 50)); + expect(flag.isEnabled()).toBe(flag.isEnabled()); + }); + + it('should describe itself with fleet info', () => { + const flag = new LocalHostTargetedFeatureFlag(new FleetTargetedFeatureFlag('test', 75)); + expect(flag.describe()).toContain('LocalHostTargetedFeatureFlag'); + expect(flag.describe()).toContain('75'); + }); +}); + +describe('buildLocalHost', () => { + it('should return enabled flag when enabled with 100% fleet', () => { + const flag = buildLocalHost('FileDb', { enabled: true, fleetPercentage: 100 }); + expect(flag.isEnabled()).toBe(true); + }); + + it('should return disabled flag when enabled is false', () => { + const flag = buildLocalHost('FileDb', { enabled: false, fleetPercentage: 100 }); + expect(flag.isEnabled()).toBe(false); + }); + + it('should return disabled flag when fleet percentage is 0', () => { + const flag = buildLocalHost('FileDb', { enabled: true, fleetPercentage: 0 }); + expect(flag.isEnabled()).toBe(false); + }); + + it('should default to disabled with no config', () => { + const flag = buildLocalHost('FileDb'); + expect(flag.isEnabled()).toBe(false); + }); +}); diff --git a/tst/unit/featureFlag/FeatureFlagProvider.test.ts b/tst/unit/featureFlag/FeatureFlagProvider.test.ts index 2209ca8..b622858 100644 --- a/tst/unit/featureFlag/FeatureFlagProvider.test.ts +++ b/tst/unit/featureFlag/FeatureFlagProvider.test.ts @@ -1,8 +1,8 @@ -import { readFileSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { FeatureFlagConfigSchema } from '../../../src/featureFlag/FeatureFlagBuilder'; -import { FeatureFlagProvider } from '../../../src/featureFlag/FeatureFlagProvider'; +import { featureFlagLocalFile, FeatureFlagProvider } from '../../../src/featureFlag/FeatureFlagProvider'; import { ScopedTelemetry } from '../../../src/telemetry/ScopedTelemetry'; describe('FeatureFlagProvider', () => { @@ -143,4 +143,30 @@ describe('FeatureFlagProvider', () => { await expect((provider as any).getFeatureFlags('alpha')).rejects.toThrow('status code 500'); }); }); + + describe('featureFlagLocalFile', () => { + const projectRoot = join(__dirname, '..', '..', '..'); + + it('should resolve to an existing file with project root as baseDir', () => { + const path = featureFlagLocalFile(projectRoot); + expect(existsSync(path)).toBe(true); + }); + + it('should produce a parseable feature flag config', () => { + const path = featureFlagLocalFile(projectRoot); + const content = JSON.parse(readFileSync(path, 'utf8')); + expect(FeatureFlagConfigSchema.parse(content)).toBeDefined(); + }); + + it('should build path with assets/featureFlag/.json structure', () => { + const path = featureFlagLocalFile('/some/base'); + expect(path).toMatch(/\/some\/base\/assets\/featureFlag\/\w+\.json$/); + }); + + it('should default baseDir to __dirname of the source module', () => { + const defaultPath = featureFlagLocalFile(); + expect(defaultPath).toContain(join('assets', 'featureFlag')); + expect(defaultPath).toMatch(/\.json$/); + }); + }); }); diff --git a/tst/unit/featureFlag/FeatureFlagSupplier.test.ts b/tst/unit/featureFlag/FeatureFlagSupplier.test.ts index 71142d1..e46af18 100644 --- a/tst/unit/featureFlag/FeatureFlagSupplier.test.ts +++ b/tst/unit/featureFlag/FeatureFlagSupplier.test.ts @@ -25,7 +25,7 @@ describe('FeatureFlagSupplier', () => { it('should initialize with feature flags', () => { const supplier = new FeatureFlagSupplier(configSupplier, throwError); - expect([...supplier.featureFlags.keys()]).toEqual(['Constants']); + expect([...supplier.featureFlags.keys()]).toEqual(['Constants', 'FileDb']); expect(supplier.featureFlags.get('Constants')?.isEnabled()).toBe(false); expect([...supplier.targetedFeatureFlags.keys()]).toEqual(['EnhancedDryRun']); @@ -52,7 +52,7 @@ describe('FeatureFlagSupplier', () => { it('should handle invalid config and fallback to default', () => { const supplier = new FeatureFlagSupplier(() => 'invalid', configSupplier); - expect([...supplier.featureFlags.keys()]).toEqual(['Constants']); + expect([...supplier.featureFlags.keys()]).toEqual(['Constants', 'FileDb']); expect([...supplier.targetedFeatureFlags.keys()]).toEqual(['EnhancedDryRun']); supplier.close(); @@ -61,7 +61,7 @@ describe('FeatureFlagSupplier', () => { it('should handle undefined config', () => { const supplier = new FeatureFlagSupplier(() => undefined, configSupplier); - expect([...supplier.featureFlags.keys()]).toEqual(['Constants']); + expect([...supplier.featureFlags.keys()]).toEqual(['Constants', 'FileDb']); expect([...supplier.targetedFeatureFlags.keys()]).toEqual(['EnhancedDryRun']); supplier.close(); diff --git a/tst/unit/utils/File.test.ts b/tst/unit/utils/File.test.ts new file mode 100644 index 0000000..cf42eb6 --- /dev/null +++ b/tst/unit/utils/File.test.ts @@ -0,0 +1,60 @@ +import { randomUUID as v4 } from 'crypto'; +import { mkdirSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { readFileIfExists, readFileIfExistsAsync, readBufferIfExists } from '../../../src/utils/File'; + +describe('File', () => { + const testDir = join(process.cwd(), 'node_modules', '.cache', 'file-tests', v4()); + const textFile = join(testDir, 'text.txt'); + const binaryFile = join(testDir, 'binary.bin'); + const textContent = 'hello world 🌍'; + const binaryContent = Buffer.from([0x00, 0x01, 0x02, 0xff]); + const nonexistentPath = join(testDir, 'does-not-exist.txt'); + + beforeAll(() => { + mkdirSync(testDir, { recursive: true }); + writeFileSync(textFile, textContent, 'utf8'); + writeFileSync(binaryFile, binaryContent); + }); + + afterAll(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + describe('readFileIfExists', () => { + it('should return file content as string', () => { + expect(readFileIfExists(textFile)).toBe(textContent); + }); + + it('should accept encoding options object', () => { + expect(readFileIfExists(textFile, { encoding: 'utf8' })).toBe(textContent); + }); + + it('should throw for nonexistent path', () => { + expect(() => readFileIfExists(nonexistentPath)).toThrow('does not exist'); + }); + }); + + describe('readFileIfExistsAsync', () => { + it('should return file content as string', async () => { + await expect(readFileIfExistsAsync(textFile)).resolves.toBe(textContent); + }); + + it('should throw for nonexistent path', async () => { + await expect(readFileIfExistsAsync(nonexistentPath)).rejects.toThrow('does not exist'); + }); + }); + + describe('readBufferIfExists', () => { + it('should return file content as buffer', () => { + const result = readBufferIfExists(binaryFile); + expect(Buffer.isBuffer(result)).toBe(true); + expect(Buffer.compare(result, binaryContent)).toBe(0); + }); + + it('should throw for nonexistent path', () => { + expect(() => readBufferIfExists(nonexistentPath)).toThrow('does not exist'); + }); + }); +}); diff --git a/tst/unit/utils/Retry.test.ts b/tst/unit/utils/Retry.test.ts index 8931a7c..599f09c 100644 --- a/tst/unit/utils/Retry.test.ts +++ b/tst/unit/utils/Retry.test.ts @@ -1,7 +1,16 @@ import { Logger } from 'pino'; import * as sinon from 'sinon'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { RetryOptions, retryWithExponentialBackoff } from '../../../src/utils/Retry'; +import { RetryOptions, retryWithExponentialBackoff, sleep } from '../../../src/utils/Retry'; + +describe('sleep', () => { + it('should resolve after the specified duration', async () => { + const start = performance.now(); + await sleep(50); + const elapsed = performance.now() - start; + expect(elapsed).toBeGreaterThanOrEqual(40); + }); +}); describe('retryWithExponentialBackoff', () => { const options: RetryOptions = { diff --git a/tst/utils/MockServerComponents.ts b/tst/utils/MockServerComponents.ts index 33ceb5a..ea60d4e 100644 --- a/tst/utils/MockServerComponents.ts +++ b/tst/utils/MockServerComponents.ts @@ -362,6 +362,7 @@ export function createMockComponents(o: Partial = {} const core: MockInfraCoreComponents = { dataStoreFactory, + featureFlags: overrides.featureFlags ?? stubInterface(), clientMessage: overrides.clientMessage ?? createMockClientMessage(), settingsManager: overrides.settingsManager ?? createMockSettingsManager(), syntaxTreeManager: overrides.syntaxTreeManager ?? createMockSyntaxTreeManager(), diff --git a/tst/utils/TestExtension.ts b/tst/utils/TestExtension.ts index e746a57..5714a39 100644 --- a/tst/utils/TestExtension.ts +++ b/tst/utils/TestExtension.ts @@ -51,7 +51,7 @@ import { IamCredentialsUpdateRequest, IamCredentialsDeleteNotification } from '. import { AwsCredentials } from '../../src/auth/AwsCredentials'; import { UpdateCredentialsParams } from '../../src/auth/AwsLspAuthTypes'; import { MultiDataStoreFactoryProvider } from '../../src/datastore/DataStore'; -import { FeatureFlagProvider } from '../../src/featureFlag/FeatureFlagProvider'; +import { featureFlagLocalFile, FeatureFlagProvider } from '../../src/featureFlag/FeatureFlagProvider'; import { LspCapabilities } from '../../src/protocol/LspCapabilities'; import { LspConnection } from '../../src/protocol/LspConnection'; import { SchemaRetriever } from '../../src/schema/SchemaRetriever'; @@ -133,9 +133,14 @@ export class TestExtension implements Closeable { const lsp = this.serverConnection.components; LoggerFactory.reconfigure('warn'); - const dataStoreFactory = new MultiDataStoreFactoryProvider(); + const ffFile = featureFlagLocalFile(join(__dirname, '..', '..')); + const featureFlags = new FeatureFlagProvider((_env) => { + return Promise.resolve(JSON.parse(readFileSync(ffFile, 'utf8'))); + }, ffFile); + const dataStoreFactory = new MultiDataStoreFactoryProvider(featureFlags.get('FileDb')); this.core = new CfnInfraCore(lsp, params, { dataStoreFactory, + featureFlags, }); const schemaStore = new SchemaStore(dataStoreFactory); @@ -150,14 +155,10 @@ export class TestExtension implements Closeable { }, ); - const ffFile = join(__dirname, '..', '..', 'assets', 'featureFlag', 'alpha.json'); this.external = new CfnExternal(lsp, this.core, { schemaStore, schemaRetriever, cfnLintService: createMockCfnLintService(), - featureFlags: new FeatureFlagProvider((_env) => { - return Promise.resolve(JSON.parse(readFileSync(ffFile, 'utf8'))); - }, ffFile), awsClient: config.awsClientFactory?.( this.core.awsCredentials, this.core.awsMetadata?.cloudformation?.endpoint,