diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a01b1ba..126b07c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -49,6 +49,8 @@ jobs: build-mode: none # This mode only analyzes Java. Set this to 'autobuild' or 'manual' to analyze Kotlin too. - language: python build-mode: none + - language: javascript-typescript + build-mode: none # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' # Use `c-cpp` to analyze code written in C, C++ or both # Use 'java-kotlin' to analyze code written in Java, Kotlin or both diff --git a/.gitignore b/.gitignore index 8e3678e..633122a 100644 --- a/.gitignore +++ b/.gitignore @@ -407,4 +407,10 @@ venv/ .project .settings mvnw* -target/ \ No newline at end of file +target/ + +package-lock.json + +# JavaScript bundler folder +out/ +*.tgz \ No newline at end of file diff --git a/Samples/BasicVariant.sample.json b/Samples/BasicVariant.sample.json index 8f67188..b787443 100644 --- a/Samples/BasicVariant.sample.json +++ b/Samples/BasicVariant.sample.json @@ -4,7 +4,7 @@ { "id": "Variant_Override_True", "description": "", - "enabled": "true", + "enabled": true, "conditions": { "client_filters": [] }, @@ -23,7 +23,7 @@ { "id": "Variant_Override_False", "description": "", - "enabled": "false", + "enabled": false, "conditions": { "client_filters": [] }, @@ -42,7 +42,7 @@ { "id": "TestVariants", "description": "", - "enabled": "true", + "enabled": true, "allocation": { "user": [ { diff --git a/Samples/VariantAssignment.sample.json b/Samples/VariantAssignment.sample.json index fc9d10b..cadd89c 100644 --- a/Samples/VariantAssignment.sample.json +++ b/Samples/VariantAssignment.sample.json @@ -4,7 +4,7 @@ { "id": "UserAssignedVariant", "description": "", - "enabled": "true", + "enabled": true, "allocation": { "user": [ { @@ -36,7 +36,7 @@ { "id": "GroupAssignedVariant", "description": "", - "enabled": "true", + "enabled": true, "allocation": { "group": [ { @@ -67,7 +67,7 @@ { "id": "AllocationAssignedVariant", "description": "", - "enabled": "true", + "enabled": true, "allocation": { "percentile": [ { @@ -96,7 +96,7 @@ { "id": "ComplexAssignment", "description": "", - "enabled": "true", + "enabled": true, "allocation": { "user": [ { diff --git a/libraryValidations/JavaScript/README.md b/libraryValidations/JavaScript/README.md new file mode 100644 index 0000000..534bd62 --- /dev/null +++ b/libraryValidations/JavaScript/README.md @@ -0,0 +1,13 @@ +# JavaScript Feature Management Validation Tests + +This directory contains test cases to verify that the correctness of the latest JS Feature Management library against the files in the `Samples` directory. + +## Running the test + +To run the tests, execute the following command: + +```bash +npm install +npm run build +npm run test +``` diff --git a/libraryValidations/JavaScript/featureEvaluationValidation.test.ts b/libraryValidations/JavaScript/featureEvaluationValidation.test.ts new file mode 100644 index 0000000..f9ac7b5 --- /dev/null +++ b/libraryValidations/JavaScript/featureEvaluationValidation.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as fs from "node:fs/promises"; +import { FeatureManager, ConfigurationObjectFeatureFlagProvider } from "@microsoft/feature-management"; +import {FILE_PATH, SAMPLE_JSON_KEY, TESTS_JSON_KEY, validateFeatureEvaluation, FeatureFlagTest } from "./utils.js"; + +async function runTest(testName: string) { + const config = JSON.parse(await fs.readFile(FILE_PATH + testName + SAMPLE_JSON_KEY, "utf8")); + const testcases: FeatureFlagTest[] = JSON.parse(await fs.readFile(FILE_PATH + testName + TESTS_JSON_KEY, "utf8")); + const ffProvider = new ConfigurationObjectFeatureFlagProvider(config); + const fm = new FeatureManager(ffProvider); + + for (const testcase of testcases){ + validateFeatureEvaluation(testcase, fm); + } +} + +describe("feature evaluation validation", function () { + it("should pass NoFilters test", async () => { + await runTest("NoFilters"); + }); + + it("should pass RequirementType test", async () => { + await runTest("RequirementType"); + }); + + it("should pass RequirementType test", async () => { + await runTest("RequirementType"); + }); + + it("should pass TimeWindowFilter test", async () => { + await runTest("TimeWindowFilter"); + }); + + it("should pass TargetingFilter test", async () => { + await runTest("TargetingFilter"); + }); + + it("should pass TargetingFilter.modified test", async () => { + await runTest("TargetingFilter.modified"); + }); + + it("should pass BasicVariant test", async () => { + await runTest("BasicVariant"); + }); + + it("should pass VariantAssignment test", async () => { + await runTest("VariantAssignment"); + }); +}); diff --git a/libraryValidations/JavaScript/package.json b/libraryValidations/JavaScript/package.json new file mode 100644 index 0000000..8824aab --- /dev/null +++ b/libraryValidations/JavaScript/package.json @@ -0,0 +1,27 @@ +{ + "type": "module", + "scripts": { + "build": "npm run clean && tsc -p ./tsconfig.json", + "clean": "rimraf out ", + "test": "mocha out/*.test.{js,cjs,mjs} --parallel" + }, + "dependencies": { + "@types/chai-as-promised": "^8.0.1", + "@types/mocha": "^10.0.6", + "@types/node": "^20.10.7", + "@types/sinon": "^17.0.1", + "chai": "^5.1.2", + "chai-as-promised": "^8.0.0", + "mocha": "^10.2.0", + "rimraf": "^5.0.5", + "sinon": "^15.2.0", + "tslib": "^2.6.2", + "typescript": "^5.3.3", + "@microsoft/feature-management": "2.0.0-preview.3", + "@microsoft/feature-management-applicationinsights-browser": "2.0.0-preview.3", + "@microsoft/feature-management-applicationinsights-node": "2.0.0-preview.3", + "@azure/app-configuration-provider": "2.0.0-preview.1", + "@microsoft/applicationinsights-web": "^3.3.4", + "applicationinsights": "^2.9.6" + } +} \ No newline at end of file diff --git a/libraryValidations/JavaScript/telemetryWithProviderValidation.test.ts b/libraryValidations/JavaScript/telemetryWithProviderValidation.test.ts new file mode 100644 index 0000000..edd37af --- /dev/null +++ b/libraryValidations/JavaScript/telemetryWithProviderValidation.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as sinon from "sinon"; +import * as fs from "node:fs/promises"; +import { load } from "@azure/app-configuration-provider"; +import { FeatureManager, ConfigurationMapFeatureFlagProvider } from "@microsoft/feature-management"; +import { createTelemetryPublisher as createNodeTelemetryPublisher } from "@microsoft/feature-management-applicationinsights-node"; +import { createTelemetryPublisher as createBrowserTelemetryPublisher } from "@microsoft/feature-management-applicationinsights-browser"; +import {FILE_PATH, TESTS_JSON_KEY, FeatureFlagTest, validateFeatureEvaluation, validateTelemetryWithProvider } from "./utils.js"; +import { ApplicationInsights } from "@microsoft/applicationinsights-web"; +import applicationInsights from "applicationinsights"; + +// For telemetry validation +let eventNameToValidate; +let eventPropertiesToValidate; + +applicationInsights.setup("DUMMY-CONNECTION-STRING").start(); +sinon.stub(applicationInsights.defaultClient, "trackEvent").callsFake((event) => { + eventNameToValidate = event.name; + eventPropertiesToValidate = event.properties; +}); + +const appInsights = new ApplicationInsights({ config: { connectionString: "DUMMY-CONNECTION-STRING" }}); +sinon.stub(appInsights, "trackEvent").callsFake((event, customProperties) => { + eventNameToValidate = event.name; + eventPropertiesToValidate = customProperties; +}); + +async function runTestWithProviderAndNodePackage(testName: string) { + const connectionString = process.env["APP_CONFIG_VALIDATION_CONNECTION_STRING"]; + if (connectionString === undefined) { + console.log("Skipping test as environment variable APP_CONFIG_VALIDATION_CONNECTION_STRING is not set."); + return; + } + const config = await load( + connectionString, + { + featureFlagOptions: { + enabled: true, + selectors: [ + { + keyFilter: "*" + } + ] + } + }); + const testcases: FeatureFlagTest[] = JSON.parse(await fs.readFile(FILE_PATH + testName + TESTS_JSON_KEY, "utf8")); + const ffProvider = new ConfigurationMapFeatureFlagProvider(config); + const fm = new FeatureManager(ffProvider, { onFeatureEvaluated: createNodeTelemetryPublisher(applicationInsights.defaultClient) }); + for (const testcase of testcases){ + const featureFlagName = testcase.FeatureFlagName; + const context = { userId: testcase.Inputs?.User, groups: testcase.Inputs?.Groups }; + await fm.getVariant(featureFlagName, context); + validateTelemetryWithProvider(testcase, connectionString, eventNameToValidate, eventPropertiesToValidate); + } +} + +async function runTestWithProviderAndBrowserPackage(testName: string) { + const connectionString = process.env["APP_CONFIG_VALIDATION_CONNECTION_STRING"]; + if (connectionString === undefined) { + console.log("Skipping test as environment variable APP_CONFIG_VALIDATION_CONNECTION_STRING is not set."); + return; + } + const config = await load( + connectionString, + { + featureFlagOptions: { + enabled: true, + selectors: [ + { + keyFilter: "*" + } + ] + } + }); + const testcases: FeatureFlagTest[] = JSON.parse(await fs.readFile(FILE_PATH + testName + TESTS_JSON_KEY, "utf8")); + const ffProvider = new ConfigurationMapFeatureFlagProvider(config); + const fm = new FeatureManager(ffProvider, { onFeatureEvaluated: createBrowserTelemetryPublisher(appInsights) }); + for (const testcase of testcases){ + const featureFlagName = testcase.FeatureFlagName; + const context = { userId: testcase.Inputs?.User, groups: testcase.Inputs?.Groups }; + await fm.getVariant(featureFlagName, context); + validateTelemetryWithProvider(testcase, connectionString, eventNameToValidate, eventPropertiesToValidate); + } +} + +describe("telemetry with provider and node package", function () { + it("should pass ProviderTelemetry test", async () => { + await runTestWithProviderAndNodePackage("ProviderTelemetry"); + }); + + it("should pass ProviderTelemetryComplete test", async () => { + await runTestWithProviderAndNodePackage("ProviderTelemetryComplete"); + }); +}); + +describe("telemetry with provider and browser package", function () { + it("should pass ProviderTelemetry test", async () => { + await runTestWithProviderAndBrowserPackage("ProviderTelemetry"); + }); + + it("should pass ProviderTelemetryComplete test", async () => { + await runTestWithProviderAndBrowserPackage("ProviderTelemetryComplete"); + }); +}); \ No newline at end of file diff --git a/libraryValidations/JavaScript/tsconfig.json b/libraryValidations/JavaScript/tsconfig.json new file mode 100644 index 0000000..33c7907 --- /dev/null +++ b/libraryValidations/JavaScript/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "lib": [ + "DOM", + "WebWorker", + "ESNext" + ], + "skipDefaultLibCheck": true, + "module": "ESNext", + "moduleResolution": "Node", + "target": "ES2022", + "strictNullChecks": true, + "strictFunctionTypes": true, + "sourceMap": true, + "inlineSources": true, + "esModuleInterop": true, + "outDir": "./out" + }, + "exclude": [ + "node_modules", + "**/node_modules/*" + ] +} \ No newline at end of file diff --git a/libraryValidations/JavaScript/utils.ts b/libraryValidations/JavaScript/utils.ts new file mode 100644 index 0000000..05633c8 --- /dev/null +++ b/libraryValidations/JavaScript/utils.ts @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as chai from "chai"; +import chaiAsPromised from "chai-as-promised"; +chai.use(chaiAsPromised); +const expect = chai.expect; + +import { FeatureManager } from "@microsoft/feature-management"; + +export const FILE_PATH = "../../Samples/" +export const SAMPLE_JSON_KEY = ".sample.json" +export const TESTS_JSON_KEY = ".tests.json" + +interface IContext { + User?: string; + Groups?: string[]; +} + +interface IsEnabledResult { + Result?: string; + Exception?: string; +} + +interface Variant { + Name?: string; + ConfigurationValue?: any; +} + +interface GetVariantResult { + Result?: null | Variant; + Exception?: string; +} + +interface FeatureEvaluationEventProperties { + FeatureName?: string; + Enabled?: string; + Version?: string; + Variant?: string; + VariantAssignmentReason?: string; + VariantAssignmentPercentage?: string; + DefaultWhenEnabled?: string; + AllocationId?: string; + FeatureFlagId?: string; + FeatureFlagReference?: string; + TargetingId?: string; +} + +interface TelemetryResult { + EventName?: string; + EventProperties?: FeatureEvaluationEventProperties; +} + +export interface FeatureFlagTest { + FeatureFlagName: string; + Inputs?: IContext; + IsEnabled?: IsEnabledResult; + Variant?: GetVariantResult; + Telemetry?: TelemetryResult; + Description?: string; +} + +export async function validateFeatureEvaluation(testcase: FeatureFlagTest, featureManager: FeatureManager) { + const featureFlagName = testcase.FeatureFlagName; + const context = { userId: testcase.Inputs?.User, groups: testcase.Inputs?.Groups }; + + if (testcase.IsEnabled) { + if (testcase.IsEnabled.Exception !== undefined) { + try { + await featureManager.isEnabled(featureFlagName, context); + expect.fail("It should throw exception."); + } + catch (error) { + // TODO: Verify the error message after we unify it across libraries + // expect(error.message).to.include(testcase.IsEnabled.Exception); + } + } + else { + expect(await featureManager.isEnabled(featureFlagName, context)).to.eq(testcase.IsEnabled.Result === "true"); + } + } + + if (testcase.Variant){ + if (testcase.Variant.Exception !== undefined) { + try { + await featureManager.getVariant(featureFlagName, context); + expect.fail("It should throw exception."); + } + catch (error) { + // TODO: Verify the error message after we unify it across libraries + // expect(error.message).to.include(testcase.IsEnabled.Exception); + } + } + else { + const variant = await featureManager.getVariant(featureFlagName, context); + if (testcase.Variant.Result === null) { + expect(variant).to.be.undefined; + } + else { + if (testcase.Variant.Result?.Name) { + expect(variant?.name).to.eq(testcase.Variant.Result.Name); + } + if (testcase.Variant.Result?.ConfigurationValue) { + expect(variant?.configuration).to.deep.eq(testcase.Variant.Result.ConfigurationValue); + } + } + } + } +} + +export function validateTelemetryWithProvider(testcase: FeatureFlagTest, connectionString: string, eventNameToValidate: string, eventPropertiesToValidate: any) { + // if (testcase.Telemetry?.EventName) { + // expect(eventNameToValidate).to.eq(testcase.Telemetry.EventName); + // } + + const eventProperties = testcase.Telemetry?.EventProperties; + if (eventProperties) { + if (eventProperties.FeatureName) { + expect(eventPropertiesToValidate["FeatureName"]).to.eq(eventProperties.FeatureName); + } + if (eventProperties.Enabled) { + expect(eventPropertiesToValidate["Enabled"]).to.eq(eventProperties.Enabled); + } + if (eventProperties.Version) { + expect(eventPropertiesToValidate["Version"]).to.eq(eventProperties.Version); + } + if (eventProperties.Variant) { + expect(eventPropertiesToValidate["Variant"]).to.eq(eventProperties.Variant); + } + if (eventProperties.VariantAssignmentReason) { + expect(eventPropertiesToValidate["VariantAssignmentReason"]).to.eq(eventProperties.VariantAssignmentReason); + } + if (eventProperties.VariantAssignmentPercentage) { + expect(eventPropertiesToValidate["VariantAssignmentPercentage"]).to.eq(eventProperties.VariantAssignmentPercentage); + } + if (eventProperties.DefaultWhenEnabled) { + expect(eventPropertiesToValidate["DefaultWhenEnabled"]).to.eq(eventProperties.DefaultWhenEnabled); + } + if (eventProperties.AllocationId) { + expect(eventPropertiesToValidate["AllocationId"]).to.eq(eventProperties.AllocationId); + } + if (eventProperties.FeatureFlagId) { + expect(eventPropertiesToValidate["FeatureFlagId"]).to.eq(eventProperties.FeatureFlagId); + } + if (eventProperties.FeatureFlagReference) { + const endpointMatch = connectionString.match(/Endpoint=([^;]+)/); + if (endpointMatch) { + expect(eventPropertiesToValidate["FeatureFlagReference"]).to.eq(endpointMatch[1] + eventProperties.FeatureFlagReference); + } + else { + expect.fail("Connection string does not contain endpoint."); + } + } + if (eventProperties.TargetingId) { + expect(eventPropertiesToValidate["TargetingId"]).to.eq(eventProperties.TargetingId); + } + } +} \ No newline at end of file diff --git a/libraryValidations/run_all_tests.ps1 b/libraryValidations/run_all_tests.ps1 index c2961ff..e2b96eb 100644 --- a/libraryValidations/run_all_tests.ps1 +++ b/libraryValidations/run_all_tests.ps1 @@ -14,4 +14,10 @@ deactivate Set-Location -Path "../Spring/validation-tests" mvn test +# Navigate to the JavaScript directory and run test +Set-Location -Path "../JavaScript" +npm install +npm run build +npm run test + Set-Location -Path "../.." \ No newline at end of file