diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4174865..1493d90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,4 +30,5 @@ jobs: - run: npm ci - run: npm run lint - run: npm run build - - run: npm test \ No newline at end of file + - run: npm run test + - run: npm run test-browser \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1ccd7b9..5fd795e 100644 --- a/.gitignore +++ b/.gitignore @@ -399,7 +399,6 @@ FodyWeavers.xsd # bundled folder dist/ -dist-esm/ out/ types/ @@ -411,3 +410,6 @@ types/ # examples examples/package-lock.json + +# playwright test result +test-results diff --git a/package-lock.json b/package-lock.json index 8b061cc..0988ed1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,16 +8,15 @@ "name": "@microsoft/feature-management", "version": "1.0.0-preview", "license": "MIT", - "dependencies": { - "chai-as-promised": "^7.1.1" - }, "devDependencies": { + "@playwright/test": "^1.46.1", "@rollup/plugin-typescript": "^11.1.5", "@types/mocha": "^10.0.6", "@types/node": "^20.10.7", "@typescript-eslint/eslint-plugin": "^6.18.1", "@typescript-eslint/parser": "^6.18.1", "chai": "^4.4.0", + "chai-as-promised": "^7.1.1", "eslint": "^8.56.0", "mocha": "^10.2.0", "rimraf": "^5.0.5", @@ -467,6 +466,21 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.46.1.tgz", + "integrity": "sha512-Fq6SwLujA/DOIvNC2EL/SojJnkKf/rAwJ//APpJJHRyMi1PdKrY3Az+4XNQ51N4RTbItbIByQ0jgd1tayq1aeA==", + "dev": true, + "dependencies": { + "playwright": "1.46.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/plugin-typescript": { "version": "11.1.5", "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.1.5.tgz", @@ -1039,6 +1053,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, "engines": { "node": "*" } @@ -1111,6 +1126,7 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.0.tgz", "integrity": "sha512-x9cHNq1uvkCdU+5xTkNh5WtgD4e4yDFCsp9jVc7N7qVeKeftv3gO/ZrviX5d+3ZfxdYnZXZYujjRInu1RogU6A==", + "dev": true, "dependencies": { "assertion-error": "^1.1.0", "check-error": "^1.0.3", @@ -1128,6 +1144,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz", "integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==", + "dev": true, "dependencies": { "check-error": "^1.0.2" }, @@ -1155,6 +1172,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, "dependencies": { "get-func-name": "^2.0.2" }, @@ -1283,6 +1301,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "dev": true, "dependencies": { "type-detect": "^4.0.0" }, @@ -1727,6 +1746,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, "engines": { "node": "*" } @@ -2127,6 +2147,7 @@ "version": "2.3.7", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, "dependencies": { "get-func-name": "^2.0.1" } @@ -2484,6 +2505,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, "engines": { "node": "*" } @@ -2500,6 +2522,50 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.46.1.tgz", + "integrity": "sha512-oPcr1yqoXLCkgKtD5eNUPLiN40rYEM39odNpIb6VE6S7/15gJmA1NzVv6zJYusV0e7tzvkU/utBFNa/Kpxmwng==", + "dev": true, + "dependencies": { + "playwright-core": "1.46.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.46.1.tgz", + "integrity": "sha512-h9LqIQaAv+CYvWzsZ+h3RsrqCStkBHlgo6/TJlFst3cOTlLghBQlJwPOZKQJTKNaD3QIB7aAVQ+gfWbN3NXB7A==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2927,6 +2993,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, "engines": { "node": ">=4" } diff --git a/package.json b/package.json index ea96126..8c9c012 100644 --- a/package.json +++ b/package.json @@ -2,30 +2,24 @@ "name": "@microsoft/feature-management", "version": "1.0.0-preview", "description": "Feature Management is a library for enabling/disabling features at runtime. Developers can use feature flags in simple use cases like conditional statement to more advanced scenarios like conditionally adding routes.", - "main": "dist/index.js", - "module": "./dist-esm/index.js", + "main": "./dist/commonjs/index.js", + "module": "./dist/esm/index.js", "types": "types/index.d.ts", "files": [ - "dist/**/*.js", - "dist/**/*.map", - "dist/**/*.d.ts", - "dist-esm/**/*.js", - "dist-esm/**/*.map", - "dist-esm/**/*.d.ts", - "types/**/*.d.ts", + "dist/", + "types/", "LICENSE", "README.md" ], "scripts": { - "build": "npm run clean && npm run build-cjs && npm run build-esm && npm run build-test", - "build-cjs": "rollup --config", - "build-esm": "tsc -p ./tsconfig.json", + "build": "npm run clean && rollup --config && npm run build-test", "build-test": "tsc -p ./tsconfig.test.json", - "clean": "rimraf dist dist-esm out types", + "clean": "rimraf dist out types", "dev": "rollup --config --watch", - "lint": "eslint src/ test/", - "fix-lint": "eslint src/ test/ --fix", - "test": "mocha out/test/*.test.{js,cjs,mjs} --parallel" + "lint": "eslint src/ test/ --ignore-pattern test/browser/testcases.js", + "fix-lint": "eslint src/ test/ --fix --ignore-pattern test/browser/testcases.js", + "test": "mocha out/test/*.test.{js,cjs,mjs} --parallel", + "test-browser": "npx playwright install chromium && npx playwright test" }, "repository": { "type": "git", @@ -42,6 +36,7 @@ "@types/node": "^20.10.7", "@typescript-eslint/eslint-plugin": "^6.18.1", "@typescript-eslint/parser": "^6.18.1", + "@playwright/test": "^1.46.1", "chai": "^4.4.0", "chai-as-promised": "^7.1.1", "eslint": "^8.56.0", @@ -51,5 +46,7 @@ "rollup-plugin-dts": "^6.1.0", "tslib": "^2.6.2", "typescript": "^5.3.3" + }, + "dependencies": { } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..e379b86 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,15 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './test/browser', + fullyParallel: true, + + retries: 0, + reporter: 'list', + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + } + ], +}); diff --git a/rollup.config.mjs b/rollup.config.mjs index b91fd90..42018b0 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -4,13 +4,27 @@ import dts from "rollup-plugin-dts"; export default [ { + external: ["crypto"], input: "src/index.ts", output: [ { - file: "dist/index.js", + dir: "dist/commonjs/", format: "cjs", - sourcemap: true + sourcemap: true, + preserveModules: true, + }, + { + dir: "dist/esm/", + format: "esm", + sourcemap: true, + preserveModules: true, }, + { + file: "dist/umd/index.js", + format: "umd", + name: 'FeatureManagement', + sourcemap: true + } ], plugins: [ typescript({ @@ -28,13 +42,16 @@ export default [ "strictFunctionTypes": true, "sourceMap": true, "inlineSources": true - } + }, + "exclude": [ + "test/**/*" + ] }) ], }, { input: "src/index.ts", - output: [{ file: "types/index.d.ts", format: "es" }], + output: [{ file: "types/index.d.ts", format: "esm" }], plugins: [dts()], }, ]; diff --git a/src/filter/TargetingFilter.ts b/src/filter/TargetingFilter.ts index 3c0d9ca..406d846 100644 --- a/src/filter/TargetingFilter.ts +++ b/src/filter/TargetingFilter.ts @@ -2,7 +2,6 @@ // Licensed under the MIT license. import { IFeatureFilter } from "./FeatureFilter.js"; -import { createHash } from "crypto"; type TargetingFilterParameters = { Audience: { @@ -32,7 +31,7 @@ type TargetingFilterAppContext = { export class TargetingFilter implements IFeatureFilter { name: string = "Microsoft.Targeting"; - evaluate(context: TargetingFilterEvaluationContext, appContext?: TargetingFilterAppContext): boolean { + async evaluate(context: TargetingFilterEvaluationContext, appContext?: TargetingFilterAppContext): Promise { const { featureName, parameters } = context; TargetingFilter.#validateParameters(parameters); @@ -72,7 +71,7 @@ export class TargetingFilter implements IFeatureFilter { if (appContext.groups.includes(group.Name)) { const audienceContextId = constructAudienceContextId(featureName, appContext.userId, group.Name); const rolloutPercentage = group.RolloutPercentage; - if (TargetingFilter.#isTargeted(audienceContextId, rolloutPercentage)) { + if (await TargetingFilter.#isTargeted(audienceContextId, rolloutPercentage)) { return true; } } @@ -84,12 +83,12 @@ export class TargetingFilter implements IFeatureFilter { return TargetingFilter.#isTargeted(defaultContextId, parameters.Audience.DefaultRolloutPercentage); } - static #isTargeted(audienceContextId: string, rolloutPercentage: number): boolean { + static async #isTargeted(audienceContextId: string, rolloutPercentage: number): Promise { if (rolloutPercentage === 100) { return true; } // Cryptographic hashing algorithms ensure adequate entropy across hash values. - const contextMarker = stringToUint32(audienceContextId); + const contextMarker = await stringToUint32(audienceContextId); const contextPercentage = (contextMarker / 0xFFFFFFFF) * 100; return contextPercentage < rolloutPercentage; } @@ -130,14 +129,44 @@ function constructAudienceContextId(featureName: string, userId: string | undefi return contextId; } -function stringToUint32(str: string): number { - // Create a SHA-256 hash of the string - const hash = createHash("sha256").update(str).digest(); +async function stringToUint32(str: string): Promise { + let crypto; - // Get the first 4 bytes of the hash - const first4Bytes = hash.subarray(0, 4); + // Check for browser environment + if (typeof window !== "undefined" && window.crypto && window.crypto.subtle) { + crypto = window.crypto; + } + // Check for Node.js environment + else if (typeof global !== "undefined" && global.crypto) { + crypto = global.crypto; + } + // Fallback to native Node.js crypto module + else { + try { + if (typeof module !== "undefined" && module.exports) { + crypto = require("crypto"); + } + else { + crypto = await import("crypto"); + } + } catch (error) { + console.error("Failed to load the crypto module:", error.message); + throw error; + } + } - // Convert the 4 bytes to a uint32 with little-endian encoding - const uint32 = first4Bytes.readUInt32LE(0); - return uint32; + // In the browser, use crypto.subtle.digest + if (crypto.subtle) { + const data = new TextEncoder().encode(str); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const dataView = new DataView(hashBuffer); + const uint32 = dataView.getUint32(0, true); + return uint32; + } + // In Node.js, use the crypto module's hash function + else { + const hash = crypto.createHash("sha256").update(str).digest(); + const uint32 = hash.readUInt32LE(0); + return uint32; + } } diff --git a/test/browser/browser.test.ts b/test/browser/browser.test.ts new file mode 100644 index 0000000..e6b137c --- /dev/null +++ b/test/browser/browser.test.ts @@ -0,0 +1,22 @@ +import { test } from "@playwright/test"; +import chai from "chai"; +import path from "path"; + +test("Testcase can pass in browser environment", async ({ page }) => { + + const filePath = path.join(__dirname, "index.html"); + + let hasPageError = false; + + page.on("pageerror", (err) => { + hasPageError = true; + console.log(`Page Error: ${err.message}`); + }); + + await page.goto(`file:${filePath}`); + + const failures = await page.evaluate(() => (window as any).mochaFailures); + + chai.expect(failures).to.equal(0); + chai.expect(hasPageError).to.be.false; +}); diff --git a/test/browser/index.html b/test/browser/index.html new file mode 100644 index 0000000..57df7fd --- /dev/null +++ b/test/browser/index.html @@ -0,0 +1,27 @@ + + + + + Mocha Tests + + + + +
+ + + + + + + + + + \ No newline at end of file diff --git a/test/browser/testcases.js b/test/browser/testcases.js new file mode 100644 index 0000000..ba865ed --- /dev/null +++ b/test/browser/testcases.js @@ -0,0 +1,63 @@ +const ConfigurationObjectFeatureFlagProvider = FeatureManagement.ConfigurationObjectFeatureFlagProvider; +const FeatureManager = FeatureManagement.FeatureManager; + +describe("feature manager", () => { + it("should load from json string", + async () => { + const jsonObject = { + "feature_management": { + "feature_flags": [ + { + "id": "ComplexTargeting", + "description": "A feature flag using a targeting filter, that will return true for Alice, Stage1, and 50% of Stage2. Dave and Stage3 are excluded. The default rollout percentage is 25%.", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.Targeting", + "parameters": { + "Audience": { + "Users": [ + "Alice" + ], + "Groups": [ + { + "Name": "Stage1", + "RolloutPercentage": 100 + }, + { + "Name": "Stage2", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 25, + "Exclusion": { + "Users": ["Dave"], + "Groups": ["Stage3"] + } + } + } + } + ] + } + } + ] + } + }; + + const provider = new ConfigurationObjectFeatureFlagProvider(jsonObject); + const featureManager = new FeatureManager(provider); + chai.expect(await featureManager.isEnabled("ComplexTargeting", { userId: "Aiden" })).to.eq(false); + chai.expect(await featureManager.isEnabled("ComplexTargeting", { userId: "Blossom" })).to.eq(true); + chai.expect(await featureManager.isEnabled("ComplexTargeting", { userId: "Alice" })).to.eq(true); + chai.expect(await featureManager.isEnabled("ComplexTargeting", { userId: "Aiden", groups: ["Stage1"] })).to.eq(true); + chai.expect(await featureManager.isEnabled("ComplexTargeting", { groups: ["Stage2"] })).to.eq(false); + chai.expect(await featureManager.isEnabled("ComplexTargeting", { userId: "Aiden", groups: ["Stage2"] })).to.eq(true); + chai.expect(await featureManager.isEnabled("ComplexTargeting", { userId: "Chris", groups: ["Stage2"] })).to.eq(false); + chai.expect(await featureManager.isEnabled("ComplexTargeting", { groups: ["Stage3"] })).to.eq(false), + chai.expect(await featureManager.isEnabled("ComplexTargeting", { userId: "Alice", groups: ["Stage3"] })).to.eq(false); + chai.expect(await featureManager.isEnabled("ComplexTargeting", { userId: "Blossom", groups: ["Stage3"] })).to.eq(false); + chai.expect(await featureManager.isEnabled("ComplexTargeting", { userId: "Dave", groups: ["Stage1"] })).to.eq(false); + } + ); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json index 0f96a19..358df9e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -14,9 +14,6 @@ "sourceMap": true, "inlineSources": true }, - "include": [ - "src/**/*" - ], "exclude": [ "node_modules", "**/node_modules/*" diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index f2fc0e9..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "module": "ESNext", - "outDir": "./dist-esm" - }, - "include": [ - "src/**/*" - ] -} \ No newline at end of file diff --git a/tsconfig.test.json b/tsconfig.test.json index 3cbd3c0..8a81b80 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -5,6 +5,6 @@ "outDir": "./out" }, "include": [ - "test/**/*" + "test/*" ] } \ No newline at end of file