diff --git a/.github/workflows/ghr_backfill.yml b/.github/workflows/ghr_backfill.yml new file mode 100644 index 000000000..fe0141b73 --- /dev/null +++ b/.github/workflows/ghr_backfill.yml @@ -0,0 +1,78 @@ +name: Backfill GitHub Package Registry + +# Manually triggered. Copies versions already published on npm into the GitHub +# Package Registry (GHR) so GHR mirrors npm. Re-publishing the npm tarball +# guarantees the GHR copy is byte-identical to what npm consumers received +# (no rebuild). +# +# dist-tags: scripts/publish.sh computes the tag for each version from the +# version string against GHR's current latest (latest only when strictly newer, +# otherwise v-last-published; beta/alpha/rc for pre-releases), so latest +# never moves backwards while backfilling historical versions. +# +# Idempotent: scripts/publish.sh skips any version already present on GHR, so +# this can be re-run safely. + +on: + workflow_dispatch: + inputs: + version: + description: "Single version to backfill (e.g. 5.3.4). Leave blank to process all published npm versions (versions already on GHR are skipped)." + required: false + default: "" + dry_run: + description: "Dry run: report what would be published to GHR without publishing." + type: boolean + required: false + default: false + +jobs: + backfill: + name: Backfill npm versions to GHR + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout branch + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 18 + + - name: Configure GitHub Package Registry auth + run: echo "//npm.pkg.github.com/:_authToken=\${NODE_AUTH_TOKEN}" >> ~/.npmrc + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Backfill + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DRY_RUN: ${{ inputs.dry_run }} + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + pkg="@optimizely/optimizely-sdk" + npm_registry="https://registry.npmjs.org" + gpr_registry="https://npm.pkg.github.com" + + if [[ -n "$INPUT_VERSION" ]]; then + versions="$INPUT_VERSION" + else + versions=$(npm view "$pkg" versions --json --registry "$npm_registry" | jq -r '.[]') + fi + + for v in $versions; do + echo "::group::${pkg}@${v}" + # npm pack writes the tarball filename to stdout and notices/errors to + # stderr; keep stderr visible so pack failures (auth/404/network) show + # in the logs. pipefail (set above) makes a failed pack abort the job. + tarball=$(npm pack "${pkg}@${v}" --registry "$npm_registry" | tail -n1) + scripts/publish.sh "$gpr_registry" "$tarball" + rm -f "$tarball" + echo "::endgroup::" + done diff --git a/.github/workflows/javascript.yml b/.github/workflows/javascript.yml index ec1c01176..7ff3ba050 100644 --- a/.github/workflows/javascript.yml +++ b/.github/workflows/javascript.yml @@ -42,6 +42,21 @@ jobs: npm install npm run test-typescript + commonjs_test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Node + uses: actions/setup-node@v3 + with: + node-version: '18' + cache-dependency-path: ./package-lock.json + cache: 'npm' + - name: Run CommonJS example + run: | + npm install + npm run test-commonjs + integration_tests: uses: optimizely/javascript-sdk/.github/workflows/integration_test.yml@master secrets: @@ -100,6 +115,21 @@ jobs: # npm install # npm run test-ci + generate_messages_test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Node + uses: actions/setup-node@v3 + with: + node-version: 20 + cache-dependency-path: ./package-lock.json + cache: 'npm' + - name: Test generate-messages script + run: | + npm install + node scripts/test-generate-messages.js + unit_tests: runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f3e710a44..8b79c33b8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,77 +1,78 @@ -name: Publish SDK to NPM +name: Publish SDK to NPM and GitHub Package Registry on: release: types: [published, edited] workflow_dispatch: {} +permissions: + id-token: write + contents: read + packages: write + jobs: publish: - name: Publish to NPM + name: Publish to NPM and GitHub Package Registry runs-on: ubuntu-latest if: ${{ github.event_name == 'workflow_dispatch' || !github.event.release.draft }} + environment: npm steps: - name: Checkout branch uses: actions/checkout@v4 + with: + persist-credentials: false - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 18 - registry-url: "https://registry.npmjs.org/" - always-auth: "true" - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} + node-version: 22 + + - name: Upgrade npm for OIDC support + run: npm install -g npm@latest + + # Configure auth for GHR only. npm registry auth is handled by OIDC + # (no .npmrc token entry needed — omitting registry-url from setup-node + # ensures npm falls through to OIDC for registry.npmjs.org). + - name: Configure GitHub Package Registry auth + run: echo "//npm.pkg.github.com/:_authToken=\${NODE_AUTH_TOKEN}" >> ~/.npmrc + # Deps only. --ignore-scripts stops `prepare` from building here; we build + # exactly once in the pack step below. - name: Install dependencies - run: npm install + run: npm ci --ignore-scripts - - id: latest-release - name: Export latest release git tag + # Test, build, and pack a single tarball. Both registries publish this + # same artifact, so npm and GHR receive byte-identical bytes and the test + # suite runs only once. --ignore-scripts on pack avoids a rebuild. + - id: pack + name: Test, build, and pack run: | - echo "latest-release-tag=$(curl -qsSL \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ - "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases/latest" \ - | jq -r .tag_name)" >> $GITHUB_OUTPUT + npm test + npm run build + tarball=$(npm pack --ignore-scripts | tail -n1) + echo "tarball=$tarball" >> "$GITHUB_OUTPUT" - - id: npm-tag - name: Determine NPM tag + # publish.sh derives name/version from the tarball and computes the + # dist-tag itself: `latest` only when strictly newer than the registry's + # current latest, otherwise v-last-published; beta/alpha/rc for + # pre-releases. npm publishes first; if it fails the job stops before GHR, + # keeping the registries in sync. + # + # npm publish uses OIDC trusted publishing (no NPM_PUBLISH_TOKEN needed). + # publish.sh adds --provenance for supply-chain attestations. + - id: release + name: Publish to NPM env: - GITHUB_RELEASE_TAG: ${{ github.event.release.tag_name }} - run: | - VERSION=$(jq -r '.version' package.json) - LATEST_RELEASE_TAG="${{ steps.latest-release.outputs['latest-release-tag']}}" - - if [[ ${{ github.event_name }} == "workflow_dispatch" ]]; then - RELEASE_TAG=${GITHUB_REF#refs/tags/} - else - RELEASE_TAG=$GITHUB_RELEASE_TAG - fi - - if [[ $RELEASE_TAG == $LATEST_RELEASE_TAG ]]; then - echo "npm-tag=latest" >> "$GITHUB_OUTPUT" - elif [[ "$VERSION" == *"-beta"* ]]; then - echo "npm-tag=beta" >> "$GITHUB_OUTPUT" - elif [[ "$VERSION" == *"-alpha"* ]]; then - echo "npm-tag=alpha" >> "$GITHUB_OUTPUT" - elif [[ "$VERSION" == *"-rc"* ]]; then - echo "npm-tag=rc" >> "$GITHUB_OUTPUT" - else - echo "npm-tag=v$(echo $VERSION | awk -F. '{print $1}')-latest" >> "$GITHUB_OUTPUT" - fi + TARBALL: ${{ steps.pack.outputs.tarball }} + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' }} + run: scripts/publish.sh "https://registry.npmjs.org" "$TARBALL" - - id: release - name: Test, build and publish to npm + - name: Publish to GitHub Package Registry env: - BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} - BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} - run: | - if [[ ${{ github.event_name }} == "workflow_dispatch" ]]; then - DRY_RUN="--dry-run" - fi - npm publish --tag=${{ steps.npm-tag.outputs['npm-tag'] }} $DRY_RUN + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TARBALL: ${{ steps.pack.outputs.tarball }} + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' }} + run: scripts/publish.sh "https://npm.pkg.github.com" "$TARBALL" # - name: Report results to Jellyfish # uses: optimizely/jellyfish-deployment-reporter-action@main diff --git a/.github/workflows/source_clear_crone.yml b/.github/workflows/source_clear_crone.yml deleted file mode 100644 index 328feb6ab..000000000 --- a/.github/workflows/source_clear_crone.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Source clear - -on: - push: - branches: [ master ] - schedule: - # Runs "weekly" - - cron: '0 0 * * 0' - -jobs: - source_clear: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Source clear scan - env: - SRCCLR_API_TOKEN: ${{ secrets.SRCCLR_API_TOKEN }} - run: curl -sSL https://download.sourceclear.com/ci.sh | bash -s - scan \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 45a87fd2e..34e629441 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [6.5.0] - July 8, 2026 + +### New Features +- **Local Holdouts**: Added support for Local Holdouts, enabling holdout experiments +to be scoped to specific feature flag rules rather than applied globally. +Local Holdouts let you measure the true incremental impact of individual features +by holding out a subset of users from specific rollouts while still serving them other experiences. +See [Holdouts docs](https://support.optimizely.com/hc/en-us/articles/38941939408269-Global-holdouts) for more information. + +### Changed +- Refactor project config creation ([#1164](https://github.com/optimizely/javascript-sdk/pull/1164)) +- Replace deprecated url.parse() with the WHATWG URL in NodeRequestHandler ([#1161](https://github.com/optimizely/javascript-sdk/pull/1161)) +- Upgrade to uuid v13 ([#1159](https://github.com/optimizely/javascript-sdk/pull/1159)) +- Don't send ODP identify event for single identifier ([#1152](https://github.com/optimizely/javascript-sdk/pull/1152)) + +### Bug Fixes +- Use attribute id instead of key for CMAB prediction requests ([#1160](https://github.com/optimizely/javascript-sdk/pull/1160)) +- Normalize decision event campaign_id, variation_id, and entity_id ([#1158](https://github.com/optimizely/javascript-sdk/pull/1158)) +- Fix content-length header in NodeRequestHandler ([#1156](https://github.com/optimizely/javascript-sdk/pull/1156)) + + +## [6.4.0] - May 7, 2026 + +### New Features + +**Feature Rollout**: Added support for Feature Rollouts, a new experiment type +combining Targeted Delivery simplicity with A/B test measurement capabilities. +Feature Rollouts enable progressive rollouts with full impact analytics, metric tracking, +and confidence intervals. +See [Feature Rollout docs](https://support.optimizely.com/hc/en-us/articles/45552846481037-Run-Feature-Rollouts-in-Feature-Experimentation) for more information. + +- Add Feature Rollout support ([#1140](https://github.com/optimizely/javascript-sdk/pull/1140)) + +### Bug Fixes +- Export `UserProfileServiceAsync` type + + ## [6.3.1] - January 26, 2026 ### Changed diff --git a/README.md b/README.md index 3e22b28e3..3c81208af 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ This system is enforced at build time through ESLint rules and validation script ### Unit Tests -There is a mix of testing paradigms used within the JavaScript SDK which include Mocha, Chai, Karma, and Vitest, indicated by their respective `*.tests.js` and `*.spec.ts` filenames. +There is a mix of testing paradigms used within the JavaScript SDK which include Mocha, Chai, and Vitest, indicated by their respective `*.tests.js` and `*.spec.ts` filenames. When contributing code to the SDK, aim to keep the percentage of code test coverage at the current level ([![Coveralls](https://img.shields.io/coveralls/optimizely/javascript-sdk.svg)](https://coveralls.io/github/optimizely/javascript-sdk)) or above. @@ -231,8 +231,8 @@ To run unit tests, you can take the following steps: 2. Run `npm test` to run all test files. 3. Run `npm run test-vitest` to run only tests written using Vitest. 4. Run `npm run test-mocha` to run only tests written using Mocha. -4. (For cross-browser testing) Run `npm run test-xbrowser` to run tests in many browsers via BrowserStack. -5. Resolve any tests that fail before continuing with your contribution. +5. (For cross-browser testing) Run `npm run test-browser-browserstack` to run browser tests via BrowserStack, or `npm run test-browser-local` to run them locally. +6. Resolve any tests that fail before continuing with your contribution. This information is relevant only if you plan on contributing to the SDK itself. @@ -243,14 +243,20 @@ npm install # Run unit tests. npm test -# Run unit tests in many browsers, currently via BrowserStack. +# Run browser tests locally. +npm run test-browser-local + +# Run browser tests via BrowserStack. # For this to work, the following environment variables must be set: -# - BROWSER_STACK_USERNAME -# - BROWSER_STACK_PASSWORD -npm run test-xbrowser +# - BROWSERSTACK_USERNAME +# - BROWSERSTACK_ACCESS_KEY +npm run test-browser-browserstack + +# Run UMD bundle tests via BrowserStack. +npm run test-umd-browserstack ``` -[/.github/workflows/javascript.yml](/.github/workflows/javascript.yml) contains the definitions for `BROWSER_STACK_USERNAME` and `BROWSER_STACK_ACCESS_KEY` used in the GitHub Actions CI pipeline. When developing locally, you must provide your own credentials in order to run `npm run test-xbrowser`. You can register for an account for free on [the BrowserStack official website here](https://www.browserstack.com/). +[/.github/workflows/javascript.yml](/.github/workflows/javascript.yml) contains the definitions for `BROWSERSTACK_USERNAME` and `BROWSERSTACK_ACCESS_KEY` used in the GitHub Actions CI pipeline. When developing locally, you must provide your own credentials in order to run `npm run test-browser-browserstack`. You can register for an account for free on [the BrowserStack official website here](https://www.browserstack.com/). ### Contributing diff --git a/examples/node-commonjs/index.js b/examples/node-commonjs/index.js new file mode 100644 index 000000000..4a1ee5260 --- /dev/null +++ b/examples/node-commonjs/index.js @@ -0,0 +1,84 @@ +/** + * Copyright 2026, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const fs = require('fs'); +const path = require('path'); + +const { + createInstance, + createStaticProjectConfigManager, + createForwardingEventProcessor, + createLogger, + createErrorNotifier, + INFO, +} = require('@optimizely/optimizely-sdk'); + +const datafilePath = path.join(__dirname, '..', 'shared', 'datafile.json'); +const testDatafile = fs.readFileSync(datafilePath, 'utf8'); + +const TIMEOUT_MS = 10000; + +async function testCommonJsRequire() { + let resolveUuid; + const uuidPromise = new Promise((resolve, reject) => { + resolveUuid = resolve; + setTimeout(() => reject(new Error('Timed out waiting for event dispatch')), TIMEOUT_MS); + }); + + const dispatcher = { + dispatchEvent(logEvent) { + const uuid = logEvent.params.visitors[0].snapshots[0].events[0].uuid; + console.log(`Dispatched event with uuid: ${uuid}`); + + if (typeof uuid === 'string' && uuid.length > 0) { + resolveUuid(uuid); + } + + return Promise.resolve({}); + }, + }; + + const configManager = createStaticProjectConfigManager({ + datafile: testDatafile, + }); + + const eventProcessor = createForwardingEventProcessor(dispatcher); + + const client = createInstance({ + projectConfigManager: configManager, + eventProcessor: eventProcessor, + }); + + await client.onReady(); + + const userContext = client.createUserContext('test_user', { age: 22 }); + userContext.decide('flag_1'); + + const uuid = await uuidPromise; + console.log(`Test passed: event contained valid uuid "${uuid}"`); + + client.close(); +} + +testCommonJsRequire() + .then(() => { + console.log('\n=== CommonJS example completed successfully! ===\n'); + process.exit(0); + }) + .catch((error) => { + console.error('Test failed:', error); + process.exit(1); + }); diff --git a/examples/node-commonjs/package.json b/examples/node-commonjs/package.json new file mode 100644 index 000000000..ef436bad9 --- /dev/null +++ b/examples/node-commonjs/package.json @@ -0,0 +1,9 @@ +{ + "name": "optimizely-sdk-commonjs-example", + "version": "1.0.0", + "private": true, + "description": "CommonJS example for Optimizely SDK - verifies CJS compatibility", + "scripts": { + "start": "node index.js" + } +} diff --git a/examples/typescript/datafile-server.js b/examples/shared/datafile-server.js similarity index 90% rename from examples/typescript/datafile-server.js rename to examples/shared/datafile-server.js index d6984c1d3..58ffced11 100755 --- a/examples/typescript/datafile-server.js +++ b/examples/shared/datafile-server.js @@ -21,14 +21,9 @@ * Runs at http://localhost:PORT */ -import http from 'http'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { dirname } from 'path'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); +const http = require('http'); +const fs = require('fs'); +const path = require('path'); const PORT = 8910; const datafilePath = path.join(__dirname, 'datafile.json'); diff --git a/examples/typescript/datafile.json b/examples/shared/datafile.json similarity index 100% rename from examples/typescript/datafile.json rename to examples/shared/datafile.json diff --git a/examples/typescript/.gitignore b/examples/typescript/.gitignore index 5d3055a0d..dd6e803c7 100644 --- a/examples/typescript/.gitignore +++ b/examples/typescript/.gitignore @@ -2,4 +2,3 @@ node_modules/ dist/ *.log .DS_Store -package-lock.json diff --git a/examples/typescript/README.md b/examples/typescript/README.md index cc4430e0f..918f71624 100644 --- a/examples/typescript/README.md +++ b/examples/typescript/README.md @@ -5,8 +5,6 @@ This example demonstrates all factory functions from the Optimizely SDK, includi ## Files - `src/index.ts` - Main example demonstrating all SDK factory functions -- `datafile.json` - Test datafile with Optimizely configuration -- `datafile-server.js` - Simple HTTP server for testing polling datafile manager ## Running the Example diff --git a/examples/typescript/package-lock.json b/examples/typescript/package-lock.json new file mode 100644 index 000000000..907f416c9 --- /dev/null +++ b/examples/typescript/package-lock.json @@ -0,0 +1,41 @@ +{ + "name": "optimizely-sdk-typescript-example", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "optimizely-sdk-typescript-example", + "version": "1.0.0", + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^4.7.4" + } + }, + "node_modules/@types/node": { + "version": "20.19.28", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + } + } +} diff --git a/examples/typescript/src/index.ts b/examples/typescript/src/index.ts index 1681d245b..85d9655d0 100644 --- a/examples/typescript/src/index.ts +++ b/examples/typescript/src/index.ts @@ -50,7 +50,7 @@ import { fileURLToPath } from 'url'; const __filename: string = fileURLToPath(import.meta.url); const __dirname: string = dirname(__filename); -const datafilePath: string = path.join(__dirname, '..', 'datafile.json'); +const datafilePath: string = path.join(__dirname, '..', '..', 'shared', 'datafile.json'); const testDatafile: string = fs.readFileSync(datafilePath, 'utf8'); async function testStaticConfigSetup(): Promise { diff --git a/karma.base.conf.js b/karma.base.conf.js deleted file mode 100644 index 9691083ef..000000000 --- a/karma.base.conf.js +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Copyright 2018, 2020, 2022 Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// Karma base configuration -module.exports = { - // base path that will be used to resolve all patterns (eg. files, exclude) - basePath: '', - - //plugins - plugins: ['karma-mocha', 'karma-webpack', require('karma-browserstack-launcher')], - - webpack: { - mode: 'production', - module: { - rules: [ - { - test: /\.[tj]s$/, - use: 'ts-loader', - exclude: /node_modules/, - }, - ], - }, - resolve: { - extensions: ['.ts', '.js'], - }, - }, - - //browserStack setup - browserStack: { - username: process.env.BROWSER_STACK_USERNAME, - accessKey: process.env.BROWSER_STACK_ACCESS_KEY, - }, - - // to avoid DISCONNECTED messages when connecting to BrowserStack - browserDisconnectTimeout: 10000, // default 2000 - browserDisconnectTolerance: 1, // default 0 - browserNoActivityTimeout: 4 * 60 * 1000, //default 10000 - captureTimeout: 4 * 60 * 1000, //default 60000 - - // define browsers - customLaunchers: { - bs_chrome_mac: { - base: 'BrowserStack', - os: 'OS X', - os_version: 'Mojave', - browserName: 'Chrome', - browser_version: '102.0', - browser: 'Chrome', - }, - bs_edge: { - base: 'BrowserStack', - os: 'Windows', - os_version: '10', - browserName: 'Edge', - browser_version: '84.0', - browser: 'Edge', - }, - bs_firefox: { - base: 'BrowserStack', - os: 'Windows', - browser: 'Firefox', - os_version: '10', - browserName: 'Firefox', - browser_version: '91.0', - }, - - bs_opera_mac: { - base: 'BrowserStack', - browser: 'Opera', - os_version: 'Mojave', - browserName: 'Opera', - browser: 'Opera', - browser_version: '76.0', - os: 'OS X', - }, - bs_safari: { - base: 'BrowserStack', - os: 'OS X', - os_version: 'Catalina', - browserName: 'Safari', - browser_version: '13.0', - browser: 'Safari', - device: null, - }, - }, - - browsers: ['bs_chrome_mac', 'bs_edge', 'bs_firefox', 'bs_opera_mac', 'bs_safari'], - - // frameworks to use - // available frameworks: https://npmjs.org/browse/keyword/karma-adapter - frameworks: ['mocha'], - - // list of files to exclude - exclude: [], - - // preprocess matching files before serving them to the browser - // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor - preprocessors: { - './lib/**/*tests.js': ['webpack'], - }, - - // test results reporter to use - // possible values: 'dots', 'progress' - // available reporters: https://npmjs.org/browse/keyword/karma-reporter - reporters: ['progress'], - - // web server port - port: 9876, - - // enable / disable colors in the output (reporters and logs) - colors: true, - - // enable / disable watching file and executing tests whenever any file changes - autoWatch: false, - - // Continuous Integration mode - // if true, Karma captures browsers, runs the tests and exits - singleRun: true, - - // Concurrency level - // how many browser should be started simultaneous - concurrency: Infinity, -}; diff --git a/karma.bs.conf.js b/karma.bs.conf.js deleted file mode 100644 index 7b7ce8cbb..000000000 --- a/karma.bs.conf.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Copyright 2018, Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// Karma configuration for cross-browser testing -const baseConfig = require('./karma.base.conf.js'); - -module.exports = function(config) { - config.set({ - ...baseConfig, - - // level of logging - // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG - logLevel: config.LOG_INFO, - - // list of files / patterns to load in the browser - files: [ - './node_modules/promise-polyfill/dist/polyfill.min.js', - './lib/index.browser.tests.js' - ], - }); -}; diff --git a/karma.local_chrome.bs.conf.js b/karma.local_chrome.bs.conf.js deleted file mode 100644 index 7b6fc0b8a..000000000 --- a/karma.local_chrome.bs.conf.js +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Copyright 2021 Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -const baseConfig = require('./karma.base.conf'); - -module.exports = function(config) { - config.set({ - ...baseConfig, - plugins: ['karma-mocha', 'karma-webpack', 'karma-chrome-launcher'], - browserStack: null, - browsers: ['Chrome'], - files: [ - './node_modules/promise-polyfill/dist/polyfill.min.js', - './lib/index.browser.tests.js' - ], - }); -} diff --git a/karma.local_chrome.umd.conf.js b/karma.local_chrome.umd.conf.js deleted file mode 100644 index 9c5da74c2..000000000 --- a/karma.local_chrome.umd.conf.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright 2021 Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -const baseConfig = require('./karma.base.conf'); - -module.exports = function(config) { - config.set({ - ...baseConfig, - plugins: ['karma-mocha', 'karma-webpack', 'karma-chrome-launcher'], - browserStack: null, - browsers: ['Chrome'], - files: [ - './node_modules/promise-polyfill/dist/polyfill.min.js', - './dist/optimizely.browser.umd.min.js', - './lib/index.browser.umdtests.js' - ], - }); -} diff --git a/karma.umd.conf.js b/karma.umd.conf.js deleted file mode 100644 index 51d621238..000000000 --- a/karma.umd.conf.js +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Copyright 2018, Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// Karma configuration for UMD bundle testing -const baseConfig = require('./karma.base.conf.js'); - -module.exports = function(config) { - config.set({ - ...baseConfig, - - // level of logging - // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG - logLevel: config.LOG_INFO, - - // list of files / patterns to load in the browser - files: [ - './node_modules/promise-polyfill/dist/polyfill.min.js', - './dist/optimizely.browser.umd.min.js', - './lib/index.browser.umdtests.js' - ], - }); -}; diff --git a/lib/core/bucketer/index.spec.ts b/lib/core/bucketer/index.spec.ts index 4a6998597..a00c860f0 100644 --- a/lib/core/bucketer/index.spec.ts +++ b/lib/core/bucketer/index.spec.ts @@ -74,7 +74,7 @@ describe('excluding groups', () => { vi.clearAllMocks(); setLogSpy(mockLogger); - configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -125,7 +125,7 @@ describe('including groups: random', () => { vi.clearAllMocks(); setLogSpy(mockLogger); - configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore bucketerParams = { @@ -231,7 +231,7 @@ describe('including groups: overlapping', () => { vi.clearAllMocks(); setLogSpy(mockLogger); - configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore bucketerParams = { @@ -276,7 +276,7 @@ describe('bucket value falls into empty traffic allocation ranges', () => { beforeEach(() => { setLogSpy(mockLogger); - configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore bucketerParams = { @@ -326,7 +326,7 @@ describe('traffic allocation has invalid variation ids', () => { beforeEach(() => { setLogSpy(mockLogger); - configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore bucketerParams = { @@ -369,7 +369,7 @@ describe('testBucketWithBucketingId', () => { let bucketerParams: BucketerParams; beforeEach(() => { - const configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + const configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore bucketerParams = { diff --git a/lib/core/bucketer/index.tests.js b/lib/core/bucketer/index.tests.js index a1e046088..976ac82ab 100644 --- a/lib/core/bucketer/index.tests.js +++ b/lib/core/bucketer/index.tests.js @@ -65,7 +65,7 @@ describe('lib/core/bucketer', function () { describe('return values for bucketing (excluding groups)', function () { beforeEach(function () { - configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); bucketerParams = { experimentId: configObj.experiments[0].id, experimentKey: configObj.experiments[0].key, @@ -107,7 +107,7 @@ describe('lib/core/bucketer', function () { describe('return values for bucketing (including groups)', function () { var bucketerStub; beforeEach(function () { - configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); bucketerParams = { experimentId: configObj.experiments[0].id, experimentKey: configObj.experiments[0].key, @@ -255,7 +255,7 @@ describe('lib/core/bucketer', function () { describe('when the bucket value falls into empty traffic allocation ranges', function () { beforeEach(function () { - configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); bucketerParams = { experimentId: configObj.experiments[0].id, experimentKey: configObj.experiments[0].key, @@ -303,7 +303,7 @@ describe('lib/core/bucketer', function () { describe('when the traffic allocation has invalid variation ids', function () { beforeEach(function () { - configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); bucketerParams = { experimentId: configObj.experiments[0].id, experimentKey: configObj.experiments[0].key, @@ -364,7 +364,7 @@ describe('lib/core/bucketer', function () { }); beforeEach(function () { - var configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + var configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); bucketerParams = { trafficAllocationConfig: configObj.experiments[0].trafficAllocation, variationIdMap: configObj.variationIdMap, diff --git a/lib/core/decision_service/cmab/cmab_service.spec.ts b/lib/core/decision_service/cmab/cmab_service.spec.ts index 38ee205e4..fa3c3615f 100644 --- a/lib/core/decision_service/cmab/cmab_service.spec.ts +++ b/lib/core/decision_service/cmab/cmab_service.spec.ts @@ -96,8 +96,8 @@ describe('DefaultCmabService', () => { expect(ruleIdArg).toEqual(ruleId); expect(userIdArg).toEqual(userContext.getUserId()); expect(attributesArg).toEqual({ - country: 'US', - age: '25', + '66': 'US', + '77': '25', }); }); @@ -124,13 +124,13 @@ describe('DefaultCmabService', () => { expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(2); expect(mockCmabClient.fetchDecision.mock.calls[0][2]).toEqual({ - country: 'US', - age: '25', - language: 'en', + '66': 'US', + '77': '25', + '88': 'en', }); expect(mockCmabClient.fetchDecision.mock.calls[1][2]).toEqual({ - country: 'US', - gender: 'male' + '66': 'US', + '99': 'male' }); }); diff --git a/lib/core/decision_service/cmab/cmab_service.ts b/lib/core/decision_service/cmab/cmab_service.ts index 004a146c0..2e613b4fd 100644 --- a/lib/core/decision_service/cmab/cmab_service.ts +++ b/lib/core/decision_service/cmab/cmab_service.ts @@ -182,9 +182,9 @@ export class DefaultCmabService implements CmabService { cmabAttributeIds.forEach((aid) => { const attribute = projectConfig.attributeIdMap[aid]; - + if (userAttributes.hasOwnProperty(attribute.key)) { - filteredAttributes[attribute.key] = userAttributes[attribute.key]; + filteredAttributes[aid] = userAttributes[attribute.key]; } }); diff --git a/lib/core/decision_service/index.spec.ts b/lib/core/decision_service/index.spec.ts index af76674b6..2334a386b 100644 --- a/lib/core/decision_service/index.spec.ts +++ b/lib/core/decision_service/index.spec.ts @@ -212,7 +212,7 @@ describe('DecisionService', () => { userId: 'tester' }); - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const fakeDecisionResponse = { result: '111128', @@ -242,7 +242,7 @@ describe('DecisionService', () => { }, }); - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const fakeDecisionResponse = { result: '111128', @@ -269,7 +269,7 @@ describe('DecisionService', () => { userId: 'user2' }); - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const experiment = config.experimentIdMap['122227']; @@ -292,7 +292,7 @@ describe('DecisionService', () => { userId: 'user3' }); - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const experiment = config.experimentIdMap['122227']; @@ -317,7 +317,7 @@ describe('DecisionService', () => { userId: 'user2' }); - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const experiment = config.experimentIdMap['122227']; @@ -342,7 +342,7 @@ describe('DecisionService', () => { userId: 'user3', // no attributes are set, should not satisfy audience condition 11154 }); - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const experiment = config.experimentIdMap['122227']; @@ -362,7 +362,7 @@ describe('DecisionService', () => { userId: 'user1' }); - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const experiment = config.experimentIdMap['133337']; @@ -382,7 +382,7 @@ describe('DecisionService', () => { reasons: [], }; - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const experiment = config.experimentIdMap['111127']; const attributes: UserAttributes = { @@ -431,7 +431,7 @@ describe('DecisionService', () => { reasons: [], }); - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const experiment = config.experimentIdMap['111127']; const user = new OptimizelyUserContext({ @@ -466,7 +466,7 @@ describe('DecisionService', () => { experiment_bucket_map: {}, }); - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const experiment = config.experimentIdMap['111127']; const user = new OptimizelyUserContext({ @@ -504,7 +504,7 @@ describe('DecisionService', () => { userProfileService?.lookup.mockReturnValue(null); - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const experiment = config.experimentIdMap['111127']; const user = new OptimizelyUserContext({ @@ -549,7 +549,7 @@ describe('DecisionService', () => { }, }); - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const experiment = config.experimentIdMap['111127']; const user = new OptimizelyUserContext({ @@ -593,7 +593,7 @@ describe('DecisionService', () => { experiment_bucket_map: {}, }); - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const experiment = config.experimentIdMap['111127']; const user = new OptimizelyUserContext({ @@ -634,7 +634,7 @@ describe('DecisionService', () => { throw new Error('I am an error'); }); - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const experiment = config.experimentIdMap['111127']; const user = new OptimizelyUserContext({ @@ -678,7 +678,7 @@ describe('DecisionService', () => { throw new Error('I am an error'); }); - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const experiment = config.experimentIdMap['111127']; const user = new OptimizelyUserContext({ @@ -720,7 +720,7 @@ describe('DecisionService', () => { }, }); - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const experiment = config.experimentIdMap['111127']; const attributes: UserAttributes = { @@ -753,7 +753,7 @@ describe('DecisionService', () => { }, }); - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const experiment = config.experimentIdMap['111127']; const attributes: UserAttributes = { @@ -786,7 +786,7 @@ describe('DecisionService', () => { }, }); - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const experiment = config.experimentIdMap['111127']; const attributes: UserAttributes = { @@ -812,7 +812,7 @@ describe('DecisionService', () => { userProfileService?.lookup.mockReturnValue(null); - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const experiment = config.experimentIdMap['111127']; const attributes: UserAttributes = { @@ -864,7 +864,7 @@ describe('DecisionService', () => { }); }); - const config = createProjectConfig(getDecisionTestDatafile()); + const config = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const user = new OptimizelyUserContext({ optimizely: {} as any, @@ -914,7 +914,7 @@ describe('DecisionService', () => { }); }); - const config = createProjectConfig(getDecisionTestDatafile()); + const config = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const user = new OptimizelyUserContext({ optimizely: {} as any, @@ -970,7 +970,7 @@ describe('DecisionService', () => { }); }); - const config = createProjectConfig(getDecisionTestDatafile()); + const config = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const user = new OptimizelyUserContext({ optimizely: {} as any, @@ -1037,7 +1037,7 @@ describe('DecisionService', () => { reasons: [], })); - const config = createProjectConfig(getDecisionTestDatafile()); + const config = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const user = new OptimizelyUserContext({ optimizely: {} as any, @@ -1091,7 +1091,7 @@ describe('DecisionService', () => { reasons: [], })); - const config = createProjectConfig(getDecisionTestDatafile()); + const config = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const user = new OptimizelyUserContext({ optimizely: {} as any, @@ -1147,7 +1147,7 @@ describe('DecisionService', () => { reasons: [], })); - const config = createProjectConfig(getDecisionTestDatafile()); + const config = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const user = new OptimizelyUserContext({ optimizely: {} as any, @@ -1214,7 +1214,7 @@ describe('DecisionService', () => { }); }); - const config = createProjectConfig(getDecisionTestDatafile()); + const config = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const user = new OptimizelyUserContext({ optimizely: {} as any, @@ -1246,7 +1246,7 @@ describe('DecisionService', () => { reasons: [], })); - const config = createProjectConfig(getDecisionTestDatafile()); + const config = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const user = new OptimizelyUserContext({ optimizely: {} as any, @@ -1300,7 +1300,7 @@ describe('DecisionService', () => { reasons: [], })); - const config = createProjectConfig(getDecisionTestDatafile()); + const config = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const rolloutId = config.featureKeyMap['flag_1'].rolloutId; config.rolloutIdMap[rolloutId].experiments = []; // remove the experiments from the rollout @@ -1340,7 +1340,7 @@ describe('DecisionService', () => { it('should return variation from the first experiment for which a variation is available', async () => { const { decisionService } = getDecisionService(); - const config = createProjectConfig(getDecisionTestDatafile()); + const config = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const user = new OptimizelyUserContext({ optimizely: {} as any, @@ -1379,7 +1379,7 @@ describe('DecisionService', () => { it('should not return variation and should not call cmab service \ for cmab experiment if user is not bucketed into it', async () => { const { decisionService, cmabService } = getDecisionService(); - const config = createProjectConfig(getDecisionTestDatafile()); + const config = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const user = new OptimizelyUserContext({ optimizely: {} as any, @@ -1419,7 +1419,7 @@ describe('DecisionService', () => { it('should get decision from the cmab service if the experiment is a cmab experiment \ and user is bucketed into it', async () => { const { decisionService, cmabService } = getDecisionService(); - const config = createProjectConfig(getDecisionTestDatafile()); + const config = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const user = new OptimizelyUserContext({ optimizely: {} as any, @@ -1471,7 +1471,7 @@ describe('DecisionService', () => { it('should pass the correct DecideOptionMap to cmabService', async () => { const { decisionService, cmabService } = getDecisionService(); - const config = createProjectConfig(getDecisionTestDatafile()); + const config = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const user = new OptimizelyUserContext({ optimizely: {} as any, @@ -1527,7 +1527,7 @@ describe('DecisionService', () => { it('should return error if cmab getDecision fails', async () => { const { decisionService, cmabService } = getDecisionService(); - const config = createProjectConfig(getDecisionTestDatafile()); + const config = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const user = new OptimizelyUserContext({ optimizely: {} as any, @@ -1610,7 +1610,7 @@ describe('DecisionService', () => { } }); - const config = createProjectConfig(getDecisionTestDatafile()); + const config = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const user1 = new OptimizelyUserContext({ optimizely: {} as any, @@ -1697,7 +1697,7 @@ describe('DecisionService', () => { cmabUuid: 'uuid-test', }); - const config = createProjectConfig(getDecisionTestDatafile()); + const config = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const user = new OptimizelyUserContext({ optimizely: {} as any, @@ -1756,7 +1756,7 @@ describe('DecisionService', () => { } }); - const config = createProjectConfig(getDecisionTestDatafile()); + const config = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const user = new OptimizelyUserContext({ optimizely: {} as any, @@ -1817,7 +1817,7 @@ describe('DecisionService', () => { } }); - const config = createProjectConfig(getDecisionTestDatafile()); + const config = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const user = new OptimizelyUserContext({ optimizely: {} as any, @@ -1893,7 +1893,7 @@ describe('DecisionService', () => { cmabUuid: 'uuid-test-2', }); - const config = createProjectConfig(getDecisionTestDatafile()); + const config = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const user = new OptimizelyUserContext({ optimizely: {} as any, @@ -1938,7 +1938,7 @@ describe('DecisionService', () => { describe('holdout', () => { it('should return holdout variation when user is bucketed into running holdout', async () => { const { decisionService } = getDecisionService(); - const config = createProjectConfig(getHoldoutTestDatafile()); + const config = createProjectConfig(JSON.stringify(getHoldoutTestDatafile())); const user = new OptimizelyUserContext({ optimizely: {} as any, userId: 'tester', @@ -1960,55 +1960,7 @@ describe('DecisionService', () => { }); }); - it("should consider global holdout even if local holdout is present", async () => { - const { decisionService } = getDecisionService(); - const datafile = getHoldoutTestDatafile(); - const newEntry = { - id: 'holdout_included_id', - key: 'holdout_included', - status: 'Running', - includedFlags: ['1001'], - excludedFlags: [], - audienceIds: ['4002'], // age_40 audience - audienceConditions: ['or', '4002'], - variations: [ - { - id: 'holdout_variation_included_id', - key: 'holdout_variation_included', - variables: [], - }, - ], - trafficAllocation: [ - { - entityId: 'holdout_variation_included_id', - endOfRange: 5000, - }, - ], - }; - datafile.holdouts = [newEntry, ...datafile.holdouts]; - const config = createProjectConfig(datafile); - const user = new OptimizelyUserContext({ - optimizely: {} as any, - userId: 'tester', - attributes: { - age: 20, // satisfies both global holdout (age_22) and included holdout (age_40) audiences - }, - }); - const feature = config.featureKeyMap['flag_1']; - const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get(); - - expect(value).toBeInstanceOf(Promise); - - const variation = (await value)[0]; - - expect(variation.result).toEqual({ - experiment: config.holdoutIdMap && config.holdoutIdMap['holdout_running_id'], - variation: config.variationIdMap['holdout_variation_running_id'], - decisionSource: DECISION_SOURCES.HOLDOUT, - }); - }); - - it("should consider local holdout if misses global holdout", async () => { + it("should consider next global holdout if misses previous holdouts", async () => { const { decisionService } = getDecisionService(); const datafile = getHoldoutTestDatafile(); @@ -2016,7 +1968,7 @@ describe('DecisionService', () => { id: 'holdout_included_specific_id', key: 'holdout_included_specific', status: 'Running', - includedFlags: ['1001'], + includedFlags: [], excludedFlags: [], audienceIds: ['4002'], // age_60 audience (age <= 60) audienceConditions: ['or', '4002'], @@ -2034,12 +1986,12 @@ describe('DecisionService', () => { } ] }); - const config = createProjectConfig(datafile); + const config = createProjectConfig(JSON.stringify(datafile)); const user = new OptimizelyUserContext({ optimizely: {} as any, userId: 'test_holdout_user', attributes: { - age: 50, // Does not satisfy global holdout (age_22, age <= 22) but satisfies included holdout (age_60, age <= 60) + age: 50, // Does not satisfy first global holdout (age_22, age <= 22) but satisfies newly added holdout (age_60, age <= 60) }, }); const feature = config.featureKeyMap['flag_1']; @@ -2070,7 +2022,7 @@ describe('DecisionService', () => { return holdout; }); - const config = createProjectConfig(datafile); + const config = createProjectConfig(JSON.stringify(datafile)); const user = new OptimizelyUserContext({ optimizely: {} as any, userId: 'tester', @@ -2094,7 +2046,7 @@ describe('DecisionService', () => { it('should fallback to experiment when user does not meet holdout audience conditions', async () => { const { decisionService } = getDecisionService(); - const config = createProjectConfig(getHoldoutTestDatafile()); + const config = createProjectConfig(JSON.stringify(getHoldoutTestDatafile())); const user = new OptimizelyUserContext({ optimizely: {} as any, userId: 'tester', @@ -2118,7 +2070,7 @@ describe('DecisionService', () => { it('should fallback to experiment when user is not bucketed into holdout traffic', async () => { const { decisionService } = getDecisionService(); - const config = createProjectConfig(getHoldoutTestDatafile()); + const config = createProjectConfig(JSON.stringify(getHoldoutTestDatafile())); const user = new OptimizelyUserContext({ optimizely: {} as any, userId: 'tester', @@ -2173,7 +2125,7 @@ describe('DecisionService', () => { return experiment; }); - const config = createProjectConfig(datafile); + const config = createProjectConfig(JSON.stringify(datafile)); const user = new OptimizelyUserContext({ optimizely: {} as any, userId: 'tester', @@ -2195,42 +2147,6 @@ describe('DecisionService', () => { }); }); - it('should skip holdouts excluded for specific flags', async () => { - const { decisionService } = getDecisionService(); - const datafile = getHoldoutTestDatafile(); - - datafile.holdouts = datafile.holdouts.map((holdout: any) => { - if(holdout.id === 'holdout_running_id') { - return { - ...holdout, - excludedFlags: ['1001'] - } - } - return holdout; - }); - - const config = createProjectConfig(datafile); - const user = new OptimizelyUserContext({ - optimizely: {} as any, - userId: 'tester', - attributes: { - age: 15, // satisfies age_22 audience condition (age <= 22) for global holdout, but holdout excludes flag_1 - }, - }); - const feature = config.featureKeyMap['flag_1']; - const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get(); - - expect(value).toBeInstanceOf(Promise); - - const variation = (await value)[0]; - - expect(variation.result).toEqual({ - experiment: config.experimentKeyMap['exp_1'], - variation: config.variationIdMap['5001'], - decisionSource: DECISION_SOURCES.FEATURE_TEST, - }); - }); - it('should handle multiple holdouts and use first matching one', async () => { const { decisionService } = getDecisionService(); const datafile = getHoldoutTestDatafile(); @@ -2258,7 +2174,7 @@ describe('DecisionService', () => { ] }); - const config = createProjectConfig(datafile); + const config = createProjectConfig(JSON.stringify(datafile)); const user = new OptimizelyUserContext({ optimizely: {} as any, userId: 'tester', @@ -2290,7 +2206,7 @@ describe('DecisionService', () => { it('should skip cmab experiments', async () => { const { decisionService, cmabService } = getDecisionService(); - const config = createProjectConfig(getDecisionTestDatafile()); + const config = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const user = new OptimizelyUserContext({ optimizely: {} as any, @@ -2363,7 +2279,7 @@ describe('DecisionService', () => { }; }); - const config = createProjectConfig(getDecisionTestDatafile()); + const config = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const user = new OptimizelyUserContext({ optimizely: {} as any, @@ -2425,7 +2341,7 @@ describe('DecisionService', () => { }; }); - const config = createProjectConfig(getDecisionTestDatafile()); + const config = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const user1 = new OptimizelyUserContext({ optimizely: {} as any, @@ -2516,7 +2432,7 @@ describe('DecisionService', () => { }); }); - const config = createProjectConfig(getDecisionTestDatafile()); + const config = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const user = new OptimizelyUserContext({ optimizely: {} as any, @@ -2599,7 +2515,7 @@ describe('DecisionService', () => { }); }); - const config = createProjectConfig(getDecisionTestDatafile()); + const config = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const user = new OptimizelyUserContext({ optimizely: {} as any, @@ -2649,7 +2565,7 @@ describe('DecisionService', () => { describe('forced variation management', () => { it('should return true for a valid forcedVariation in setForcedVariation', function() { - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const { decisionService } = getDecisionService(); const didSetVariation = decisionService.setForcedVariation( @@ -2662,7 +2578,7 @@ describe('DecisionService', () => { }); it('should return the same variation from getVariation as was set in setVariation', function() { - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const { decisionService } = getDecisionService(); decisionService.setForcedVariation( config, @@ -2676,7 +2592,7 @@ describe('DecisionService', () => { }); it('should return null from getVariation if no forced variation was set for a valid experimentKey', function() { - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const { decisionService } = getDecisionService(); expect(config.experimentKeyMap['testExperiment']).toBeDefined(); @@ -2686,7 +2602,7 @@ describe('DecisionService', () => { }); it('should return null from getVariation for an invalid experimentKey', function() { - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const { decisionService } = getDecisionService(); expect(config.experimentKeyMap['definitely_not_valid_exp_key']).not.toBeDefined(); @@ -2696,7 +2612,7 @@ describe('DecisionService', () => { }); it('should return null when a forced decision is set on another experiment key', function() { - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const { decisionService } = getDecisionService(); decisionService.setForcedVariation(config, 'testExperiment', 'user1', 'control'); @@ -2705,7 +2621,7 @@ describe('DecisionService', () => { }); it('should not set forced variation for an invalid variation key and return false', function() { - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const { decisionService } = getDecisionService(); const wasSet = decisionService.setForcedVariation( @@ -2721,7 +2637,7 @@ describe('DecisionService', () => { }); it('should reset the forcedVariation if null is passed to setForcedVariation', function() { - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const { decisionService } = getDecisionService(); const didSetVariation = decisionService.setForcedVariation( @@ -2750,7 +2666,7 @@ describe('DecisionService', () => { }); it('should be able to add variations for multiple experiments for one user', function() { - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const { decisionService } = getDecisionService(); const didSetVariation1 = decisionService.setForcedVariation( @@ -2776,7 +2692,7 @@ describe('DecisionService', () => { }); it('should be able to forced variation to same experiment for multiple users', function() { - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const { decisionService } = getDecisionService(); const didSetVariation1 = decisionService.setForcedVariation( @@ -2803,7 +2719,7 @@ describe('DecisionService', () => { }); it('should be able to reset a variation for a user with multiple experiments', function() { - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const { decisionService } = getDecisionService(); // Set the first time @@ -2846,7 +2762,7 @@ describe('DecisionService', () => { }); it('should be able to unset a variation for a user with multiple experiments', function() { - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const { decisionService } = getDecisionService(); // Set the first time @@ -2883,7 +2799,7 @@ describe('DecisionService', () => { }); it('should return false for an empty variation key', function() { - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const { decisionService } = getDecisionService(); const didSetVariation = decisionService.setForcedVariation(config, 'testExperiment', 'user1', ''); @@ -2891,7 +2807,7 @@ describe('DecisionService', () => { }); it('should return null when a variation was previously set, and that variation no longer exists on the config object', function() { - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const { decisionService } = getDecisionService(); const didSetVariation = decisionService.setForcedVariation( @@ -2921,13 +2837,13 @@ describe('DecisionService', () => { user2: 'variation', }; // Now the only variation in testExperiment is 'variation' - const newConfigObj = createProjectConfig(newDatafile); + const newConfigObj = createProjectConfig(JSON.stringify(newDatafile)); const forcedVar = decisionService.getForcedVariation(newConfigObj, 'testExperiment', 'user1').result; expect(forcedVar).toBe(null); }); it("should return null when a variation was previously set, and that variation's experiment no longer exists on the config object", function() { - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const { decisionService } = getDecisionService(); const didSetVariation = decisionService.setForcedVariation( @@ -2938,13 +2854,13 @@ describe('DecisionService', () => { ); expect(didSetVariation).toBe(true); - const newConfigObj = createProjectConfig(cloneDeep(testDataWithFeatures)); + const newConfigObj = createProjectConfig(JSON.stringify(testDataWithFeatures)); const forcedVar = decisionService.getForcedVariation(newConfigObj, 'testExperiment', 'user1').result; expect(forcedVar).toBe(null); }); it('should return false from setForcedVariation and not set for invalid experiment key', function() { - const config = createProjectConfig(cloneDeep(testData)); + const config = createProjectConfig(JSON.stringify(testData)); const { decisionService } = getDecisionService(); const didSetVariation = decisionService.setForcedVariation( @@ -2963,4 +2879,228 @@ describe('DecisionService', () => { expect(variation).toBe(null); }); }); + + describe('local holdouts', () => { + // Helper: build a datafile that has a local holdout targeting a specific experiment or delivery rule. + // Per FSSDK-12760, local holdouts live in the top-level `localHoldouts` section + // (separate from `holdouts`, which now only carries global holdouts). + const makeLocalHoldoutDatafile = (targetRuleId: string, ruleIds: string[] = [targetRuleId]) => { + const datafile = getDecisionTestDatafile(); + (datafile as any).holdouts = []; + (datafile as any).localHoldouts = [ + { + id: 'local_holdout_id', + key: 'local_holdout', + status: 'Running', + includedFlags: [], + excludedFlags: [], + includedRules: ruleIds, + audienceIds: [], + audienceConditions: [], + variations: [ + { + id: 'local_holdout_variation_id', + key: 'local_holdout_variation', + variables: [] + } + ], + trafficAllocation: [ + { entityId: 'local_holdout_variation_id', endOfRange: 10000 } + ] + } + ]; + return datafile; + }; + + beforeEach(() => { + mockBucket.mockReset(); + }); + + it('global holdout branch: global holdout is evaluated before per-rule logic', async () => { + const datafile = getDecisionTestDatafile(); + (datafile as any).holdouts = [ + { + id: 'global_holdout_id', + key: 'global_holdout', + status: 'Running', + includedFlags: [], + excludedFlags: [], + // No includedRules → global holdout + audienceIds: [], + audienceConditions: [], + variations: [ + { id: 'global_holdout_var_id', key: 'global_holdout_var', variables: [] } + ], + trafficAllocation: [{ entityId: 'global_holdout_var_id', endOfRange: 10000 }] + } + ]; + const config = createProjectConfig(JSON.stringify(datafile)); + const { decisionService } = getDecisionService(); + + // bucket returns the global holdout variation for the holdout, nothing for experiments + mockBucket.mockImplementation((params: BucketerParams) => { + if (params.experimentId === 'global_holdout_id') { + return { result: 'global_holdout_var_id', reasons: [] }; + } + return { result: null, reasons: [] }; + }); + + const user = new OptimizelyUserContext({ optimizely: {} as any, userId: 'user1' }); + const feature = config.featureKeyMap['flag_1']; + const value = await decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get(); + + // Decision should be from the global holdout, not from any experiment + expect(value[0].result.decisionSource).toBe(DECISION_SOURCES.HOLDOUT); + expect(value[0].result.experiment?.id).toBe('global_holdout_id'); + }); + + it('local holdout hit branch: user bucketed into local holdout for experiment rule returns holdout variation; audience and traffic not evaluated for that rule', async () => { + // exp_1 has id '2001' + const config = createProjectConfig(JSON.stringify(makeLocalHoldoutDatafile('2001'))); + const { decisionService } = getDecisionService(); + + // bucket returns holdout variation when evaluating the local holdout + mockBucket.mockImplementation((params: BucketerParams) => { + if (params.experimentId === 'local_holdout_id') { + return { result: 'local_holdout_variation_id', reasons: [] }; + } + return { result: null, reasons: [] }; + }); + + const user = new OptimizelyUserContext({ optimizely: {} as any, userId: 'user1' }); + const feature = config.featureKeyMap['flag_1']; + const value = await decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get(); + + // Should return holdout decision for the local holdout + expect(value[0].result.decisionSource).toBe(DECISION_SOURCES.HOLDOUT); + expect(value[0].result.experiment?.id).toBe('local_holdout_id'); + expect(value[0].result.variation?.id).toBe('local_holdout_variation_id'); + }); + + it('local holdout miss branch: user not bucketed into local holdout falls through to regular rule evaluation', async () => { + // exp_1 has id '2001' and audience 4001 (age <= 22) + const config = createProjectConfig(JSON.stringify(makeLocalHoldoutDatafile('2001'))); + const { decisionService } = getDecisionService(); + + // bucket returns null for the local holdout, then succeeds for the experiment + mockBucket.mockImplementation((params: BucketerParams) => { + if (params.experimentId === 'local_holdout_id') { + return { result: null, reasons: [] }; + } + if (params.experimentId === '2001') { + return { result: '5001', reasons: [] }; // variation_1 in exp_1 + } + return { result: null, reasons: [] }; + }); + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'user1', + attributes: { age: 15 }, // satisfies 4001 audience (age <= 22) + }); + const feature = config.featureKeyMap['flag_1']; + const value = await decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get(); + + // Should fall through to experiment evaluation (not holdout) + expect(value[0].result.decisionSource).toBe(DECISION_SOURCES.FEATURE_TEST); + expect(value[0].result.variation?.id).toBe('5001'); + }); + + it('rule specificity: local holdout targeting experiment rule X does not affect experiment rule Y', async () => { + // exp_1 = '2001', exp_2 = '2002'. Local holdout targets only '2002' (exp_2). + // Audience for exp_1: 4001 (age <= 22). User satisfies exp_1 audience but not exp_2. + const config = createProjectConfig(JSON.stringify(makeLocalHoldoutDatafile('2002'))); + const { decisionService } = getDecisionService(); + + // bucket returns holdout variation only for the local holdout when evaluating for '2002', + // and returns experiment variation for '2001' + mockBucket.mockImplementation((params: BucketerParams) => { + if (params.experimentId === 'local_holdout_id') { + return { result: 'local_holdout_variation_id', reasons: [] }; + } + if (params.experimentId === '2001') { + return { result: '5001', reasons: [] }; + } + return { result: null, reasons: [] }; + }); + + // User satisfies exp_1 audience (age <= 22) + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'user1', + attributes: { age: 15 }, + }); + const feature = config.featureKeyMap['flag_1']; + const value = await decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get(); + + // exp_1 is evaluated first; local holdout targets '2002' not '2001', so exp_1 is evaluated normally + expect(value[0].result.decisionSource).toBe(DECISION_SOURCES.FEATURE_TEST); + expect(value[0].result.experiment?.id).toBe('2001'); + }); + + it('local holdout applies to delivery rules (rollouts) as well as experiment rules', async () => { + // delivery_1 has id '3001' + const config = createProjectConfig(JSON.stringify(makeLocalHoldoutDatafile('3001'))); + const { decisionService } = getDecisionService(); + + // bucket returns null for all experiments and the local holdout variation for delivery rule + mockBucket.mockImplementation((params: BucketerParams) => { + if (params.experimentId === 'local_holdout_id') { + return { result: 'local_holdout_variation_id', reasons: [] }; + } + return { result: null, reasons: [] }; + }); + + // No audience attributes → experiments won't match, falls through to rollout + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'user1', + attributes: { age: 15 }, // satisfies 4001 used by delivery_1 + }); + const feature = config.featureKeyMap['flag_1']; + const value = await decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get(); + + // Should be a holdout decision from the local holdout targeting the delivery rule + expect(value[0].result.decisionSource).toBe(DECISION_SOURCES.HOLDOUT); + expect(value[0].result.experiment?.id).toBe('local_holdout_id'); + }); + + it('forced decision beats a 100% traffic local holdout: forced decision takes precedence over local holdout', async () => { + // exp_1 has id '2001', key 'exp_1', variation key 'variation_1' (id '5001') + // Local holdout targets '2001' with 100% traffic allocation. + // User also has a forced decision set for exp_1. + // Expected: forced decision wins; decisionSource is FEATURE_TEST, not HOLDOUT. + const config = createProjectConfig(JSON.stringify(makeLocalHoldoutDatafile('2001'))); + const { decisionService } = getDecisionService(); + + // bucket should NOT be called for local_holdout_id because forced decision short-circuits first + mockBucket.mockImplementation((params: BucketerParams) => { + if (params.experimentId === 'local_holdout_id') { + // returning holdout variation here to prove the test fails if ordering is wrong + return { result: 'local_holdout_variation_id', reasons: [] }; + } + return { result: null, reasons: [] }; + }); + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'user1', + attributes: { age: 15 }, + }); + + // Set forced decision for exp_1 → variation_1 + user.setForcedDecision( + { flagKey: 'flag_1', ruleKey: 'exp_1' }, + { variationKey: 'variation_1' } + ); + + const feature = config.featureKeyMap['flag_1']; + const value = await decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get(); + + // Forced decision must win — source must be FEATURE_TEST, not HOLDOUT + expect(value[0].result.decisionSource).toBe(DECISION_SOURCES.FEATURE_TEST); + expect(value[0].result.variation?.key).toBe('variation_1'); + }); + }); }); + diff --git a/lib/core/decision_service/index.tests.js b/lib/core/decision_service/index.tests.js index 84c4f8c5f..0559460c3 100644 --- a/lib/core/decision_service/index.tests.js +++ b/lib/core/decision_service/index.tests.js @@ -82,7 +82,7 @@ var createLogger = () => ({ describe('lib/core/decision_service', function() { describe('APIs', function() { - var configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + var configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); var decisionServiceInstance; var mockLogger = createLogger({ logLevel: LOG_LEVEL.INFO }); var bucketerStub; @@ -991,7 +991,7 @@ describe('lib/core/decision_service', function() { user2: 'variation', }; // Now the only variation in testExperiment is 'variation' - var newConfigObj = projectConfig.createProjectConfig(newDatafile); + var newConfigObj = projectConfig.createProjectConfig(JSON.stringify(newDatafile)); var forcedVar = decisionServiceInstance.getForcedVariation(newConfigObj, 'testExperiment', 'user1').result; assert.strictEqual(forcedVar, null); }); @@ -1004,7 +1004,7 @@ describe('lib/core/decision_service', function() { 'control' ); assert.strictEqual(didSetVariation, true); - var newConfigObj = projectConfig.createProjectConfig(cloneDeep(testDataWithFeatures)); + var newConfigObj = projectConfig.createProjectConfig(JSON.stringify(testDataWithFeatures)); var forcedVar = decisionServiceInstance.getForcedVariation(newConfigObj, 'testExperiment', 'user1').result; assert.strictEqual(forcedVar, null); }); @@ -1029,7 +1029,7 @@ describe('lib/core/decision_service', function() { // TODO: Move tests that test methods of Optimizely to lib/optimizely/index.tests.js describe('when a bucketingID is provided', function() { - var configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + var configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); var createdLogger = createLogger({ logLevel: LOG_LEVEL.DEBUG, logToConsole: false, @@ -1040,7 +1040,7 @@ describe('lib/core/decision_service', function() { optlyInstance = new Optimizely({ clientEngine: 'node-sdk', projectConfigManager: getMockProjectConfigManager({ - initConfig: createProjectConfig(cloneDeep(testData)) + initConfig: createProjectConfig(JSON.stringify(testData)) }), jsonSchemaValidator: jsonSchemaValidator, isValidInstance: true, @@ -1173,7 +1173,7 @@ describe('lib/core/decision_service', function() { sinon.stub(mockLogger, 'warn'); sinon.stub(mockLogger, 'error'); - configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); decisionService = createDecisionService({ logger: mockLogger, }); @@ -1216,7 +1216,7 @@ describe('lib/core/decision_service', function() { }); var user; beforeEach(function() { - configObj = projectConfig.createProjectConfig(cloneDeep(testDataWithFeatures)); + configObj = projectConfig.createProjectConfig(JSON.stringify(testDataWithFeatures)); sandbox = sinon.sandbox.create(); sandbox.stub(mockLogger, 'debug'); sandbox.stub(mockLogger, 'info'); @@ -1941,7 +1941,7 @@ describe('lib/core/decision_service', function() { var user; beforeEach(function() { - configObj = projectConfig.createProjectConfig(cloneDeep(testDataWithFeatures)); + configObj = projectConfig.createProjectConfig(JSON.stringify(testDataWithFeatures)); feature = configObj.featureKeyMap.test_feature; decisionService = createDecisionService({ logger: createLogger({ logLevel: LOG_LEVEL.INFO }), diff --git a/lib/core/decision_service/index.ts b/lib/core/decision_service/index.ts index 217550f17..2064ec2a2 100644 --- a/lib/core/decision_service/index.ts +++ b/lib/core/decision_service/index.ts @@ -29,9 +29,10 @@ import { getVariationIdFromExperimentAndVariationKey, getVariationFromId, getVariationKeyFromId, + getGlobalHoldouts, + getHoldoutsForRule, isActive, ProjectConfig, - getHoldoutsForFlag, } from '../../project_config/project_config'; import { AudienceEvaluator, createAudienceEvaluator } from '../audience_evaluator'; import * as stringValidator from '../../utils/string_value_validator'; @@ -73,7 +74,7 @@ import { } from 'log_message'; import { OptimizelyError } from '../../error/optimizly_error'; import { CmabService } from './cmab/cmab_service'; -import { Maybe, OpType, OpValue } from '../../utils/type'; +import { Maybe, OpType } from '../../utils/type'; import { Value } from '../../utils/promise/operation_value'; import { Platform } from '../../platform_support'; @@ -134,28 +135,48 @@ interface DecisionServiceOptions { cmabService: CmabService; } -interface DeliveryRuleResponse extends DecisionResponse { - skipToEveryoneElse: K; -} -interface UserProfileTracker { - userProfile: ExperimentBucketMap | null; - isProfileUpdated: boolean; -} +export type DecisionReason = [string, ...any[]]; -type VarationKeyWithCmabParams = { - variationKey?: string; +export type VariationIdWithCmabParams = { + variationId? : string; cmabUuid?: string; }; -export type DecisionReason = [string, ...any[]]; -export type VariationResult = DecisionResponse; -export type DecisionResult = DecisionResponse; -type VariationIdWithCmabParams = { - variationId? : string; + +export type VariationKeyWithCmabParams = { + variationKey? : string; cmabUuid?: string; }; + +export type DecisionResult = DecisionResponse; + + +type LocalHoldoutResult = DecisionResponse & { + holdoutDecision: true +} + +type ExperimentEvaluationResult = DecisionResponse & { + holdoutDecision?: false; +} + +export type ExperimentRuleResponse = ExperimentEvaluationResult | LocalHoldoutResult; + +type DeliveryEvaluationResult = DecisionResponse & { + holdoutDecision?: false; + skipToEveryoneElse: boolean; +} + +type DeliveryRuleResponse = DeliveryEvaluationResult | LocalHoldoutResult; + +type VariationKeyResult = DecisionResponse + export type DecideOptionsMap = Partial>; +interface UserProfileTracker { + userProfile: ExperimentBucketMap | null; + isProfileUpdated: boolean; +} + export const CMAB_DUMMY_ENTITY_ID= '$' export const LOGGER_NAME = 'DecisionService'; @@ -213,7 +234,7 @@ export class DecisionService { user: OptimizelyUserContext, decideOptions: DecideOptionsMap, userProfileTracker?: UserProfileTracker, - ): Value { + ): Value { const userId = user.getUserId(); const experimentKey = experiment.key; @@ -301,7 +322,7 @@ export class DecisionService { this.getDecisionForCmabExperiment(op, configObj, experiment, user, decideOptions) : this.getDecisionFromBucketer(op, configObj, experiment, user); - return decisionVariationValue.then((variationResult): Value => { + return decisionVariationValue.then((variationResult): Value => { decideReasons.push(...variationResult.reasons); if (variationResult.error) { return Value.of(op, { @@ -943,9 +964,12 @@ export class DecisionService { reasons: decideReasons, }); } - const holdouts = getHoldoutsForFlag(configObj, feature.key); - for (const holdout of holdouts) { + // Global holdouts are evaluated at flag level, before any per-rule logic. + // getGlobalHoldouts() returns holdouts with includedRules == null/undefined. + const globalHoldouts = getGlobalHoldouts(configObj); + + for (const holdout of globalHoldouts) { const holdoutDecision = this.getVariationForHoldout(configObj, holdout, user); decideReasons.push(...holdoutDecision.reasons); @@ -1071,14 +1095,22 @@ export class DecisionService { op, configObj, feature, fromIndex + 1, user, decideReasons, decideOptions, userProfileTracker); } - const decisionVariationValue = this.getVariationFromExperimentRule( + const experimentResponse = this.getVariationFromExperimentRule( op, configObj, feature.key, experiment, user, decideOptions, userProfileTracker, ); - return decisionVariationValue.then((decisionVariation) => { - decideReasons.push(...decisionVariation.reasons); + return experimentResponse.then((experimentResponse) => { + decideReasons.push(...experimentResponse.reasons); - if (decisionVariation.error) { + // If a local holdout was hit, return the holdout decision directly (preserves holdout as experiment). + if (experimentResponse.holdoutDecision) { + return Value.of(op, { + result: experimentResponse.result, + reasons: decideReasons, + }); + } + + if (experimentResponse.error) { return Value.of(op, { error: true, result: { @@ -1090,12 +1122,12 @@ export class DecisionService { }); } - if(!decisionVariation.result.variationKey) { + if(!experimentResponse.result.variationKey) { return this.traverseFeatureExperimentList( op, configObj, feature, fromIndex + 1, user, decideReasons, decideOptions, userProfileTracker); } - - const variationKey = decisionVariation.result.variationKey; + + const variationKey = experimentResponse.result.variationKey; let variation: Variation | null = experiment.variationKeyMap[variationKey]; if (!variation) { variation = getFlagVariationByKey(configObj, feature.key, variationKey); @@ -1103,7 +1135,7 @@ export class DecisionService { return Value.of(op, { result: { - cmabUuid: decisionVariation.result.cmabUuid, + cmabUuid: experimentResponse.result.cmabUuid, experiment, variation, decisionSource: DECISION_SOURCES.FEATURE_TEST, @@ -1119,11 +1151,10 @@ export class DecisionService { user: OptimizelyUserContext, ): DecisionResponse { const decideReasons: DecisionReason[] = []; - let decisionObj: DecisionObj; if (!feature.rolloutId) { this.logger?.debug(NO_ROLLOUT_EXISTS, feature.key); decideReasons.push([NO_ROLLOUT_EXISTS, feature.key]); - decisionObj = { + const decisionObj = { experiment: null, variation: null, decisionSource: DECISION_SOURCES.ROLLOUT, @@ -1143,7 +1174,7 @@ export class DecisionService { feature.key, ); decideReasons.push([INVALID_ROLLOUT_ID, feature.rolloutId, feature.key]); - decisionObj = { + const decisionObj = { experiment: null, variation: null, decisionSource: DECISION_SOURCES.ROLLOUT, @@ -1161,7 +1192,7 @@ export class DecisionService { feature.rolloutId, ); decideReasons.push([ROLLOUT_HAS_NO_EXPERIMENTS, feature.rolloutId]); - decisionObj = { + const decisionObj = { experiment: null, variation: null, decisionSource: DECISION_SOURCES.ROLLOUT, @@ -1171,19 +1202,34 @@ export class DecisionService { reasons: decideReasons, }; } - let decisionVariation; - let skipToEveryoneElse; - let variation; - let rolloutRule; + let index = 0; while (index < rolloutRules.length) { - decisionVariation = this.getVariationFromDeliveryRule(configObj, feature.key, rolloutRules, index, user); - decideReasons.push(...decisionVariation.reasons); - variation = decisionVariation.result; - skipToEveryoneElse = decisionVariation.skipToEveryoneElse; + const deliveryRuleResponse = this.getVariationFromDeliveryRule(configObj, feature.key, rolloutRules, index, user); + + decideReasons.push(...deliveryRuleResponse.reasons); + + // If a local holdout was hit for this delivery rule, use its decision object directly. + if (deliveryRuleResponse.holdoutDecision) { + return { + reasons: decideReasons, + result: deliveryRuleResponse.result, + } + } + + + const variation = deliveryRuleResponse.result; + const skipToEveryoneElse = deliveryRuleResponse.skipToEveryoneElse; + if (variation) { - rolloutRule = configObj.experimentIdMap[rolloutRules[index].id]; - decisionObj = { + // if (decisionVariation.localHoldoutDecision) { + // return { + // result: decisionVariation.localHoldoutDecision, + // reasons: decideReasons, + // }; + // } + const rolloutRule = configObj.experimentIdMap[rolloutRules[index].id]; + const decisionObj = { experiment: rolloutRule, variation: variation, decisionSource: DECISION_SOURCES.ROLLOUT @@ -1197,7 +1243,7 @@ export class DecisionService { index = skipToEveryoneElse ? (rolloutRules.length - 1) : (index + 1); } - decisionObj = { + const decisionObj = { experiment: null, variation: null, decisionSource: DECISION_SOURCES.ROLLOUT, @@ -1546,7 +1592,7 @@ export class DecisionService { user: OptimizelyUserContext, decideOptions: DecideOptionsMap, userProfileTracker?: UserProfileTracker, - ): Value { + ): Value { const decideReasons: DecisionReason[] = []; // check forced decision first @@ -1560,26 +1606,33 @@ export class DecisionService { reasons: decideReasons, }); } + + // Check local holdouts targeting this specific experiment rule. + // Inserted immediately after the forced-decision block, before regular rule evaluation. + const localHoldoutsForExperiment = getHoldoutsForRule(configObj, rule.id); + for (const holdout of localHoldoutsForExperiment) { + const holdoutDecision = this.getVariationForHoldout(configObj, holdout, user); + decideReasons.push(...holdoutDecision.reasons); + if (holdoutDecision.result.variation) { + // Signal the caller to use the holdout decision directly, preserving the holdout as experiment. + return Value.of(op, { + result: holdoutDecision.result, + reasons: decideReasons, + holdoutDecision: true, + }); + } + } + const decisionVariationValue = this.resolveVariation(op, configObj, rule, user, decideOptions, userProfileTracker); return decisionVariationValue.then((variationResult) => { decideReasons.push(...variationResult.reasons); - return Value.of(op, { + return Value.of(op, { error: variationResult.error, result: variationResult.result, reasons: decideReasons, }); }); - - // return response; - - // decideReasons.push(...decisionVariation.reasons); - // const variationKey = decisionVariation.result; - - // return { - // result: variationKey, - // reasons: decideReasons, - // }; } private getVariationFromDeliveryRule( @@ -1588,7 +1641,7 @@ export class DecisionService { rules: Experiment[], ruleIndex: number, user: OptimizelyUserContext - ): DeliveryRuleResponse { + ): DeliveryRuleResponse { const decideReasons: DecisionReason[] = []; let skipToEveryoneElse = false; @@ -1606,6 +1659,21 @@ export class DecisionService { }; } + // Check local holdouts targeting this specific delivery rule (FSSDK-12369). + // Inserted immediately after the forced-decision block, before audience and traffic allocation checks. + const localHoldoutsForDelivery = getHoldoutsForRule(configObj, rule.id); + for (const holdout of localHoldoutsForDelivery) { + const holdoutDecision = this.getVariationForHoldout(configObj, holdout, user); + decideReasons.push(...holdoutDecision.reasons); + if (holdoutDecision.result.variation) { + return { + result: holdoutDecision.result, + reasons: decideReasons, + holdoutDecision: true, + }; + } + } + const userId = user.getUserId(); const attributes = user.getAttributes(); const bucketingId = this.getBucketingId(userId, attributes); diff --git a/lib/event_processor/event_builder/log_event.spec.ts b/lib/event_processor/event_builder/log_event.spec.ts index ad3b22b94..50750d508 100644 --- a/lib/event_processor/event_builder/log_event.spec.ts +++ b/lib/event_processor/event_builder/log_event.spec.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022, 2024, Optimizely + * Copyright 2022, 2024, 2026, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,19 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { makeEventBatch, buildLogEvent, } from './log_event'; -import { ImpressionEvent, ConversionEvent, UserEvent } from './user_event'; +import { ImpressionEvent, ConversionEvent } from './user_event'; import { Region } from '../../project_config/project_config'; describe('makeEventBatch', () => { it('should build a batch with single impression event when experiment and variation are defined', () => { + // Fixtures use valid numeric IDs so the wire output matches the + // post-normalization happy-path expectations. const impressionEvent: ImpressionEvent = { type: 'impression', timestamp: 69, @@ -47,16 +49,16 @@ describe('makeEventBatch', () => { }, layer: { - id: 'layerId', + id: '11111', }, experiment: { - id: 'expId', + id: '22222', key: 'expKey', }, variation: { - id: 'varId', + id: '33333', key: 'varKey', }, @@ -82,9 +84,9 @@ describe('makeEventBatch', () => { { decisions: [ { - campaign_id: 'layerId', - experiment_id: 'expId', - variation_id: 'varId', + campaign_id: '11111', + experiment_id: '22222', + variation_id: '33333', metadata: { flag_key: 'flagKey1', rule_key: 'expKey', @@ -96,7 +98,7 @@ describe('makeEventBatch', () => { ], events: [ { - entity_id: 'layerId', + entity_id: '11111', timestamp: 69, key: 'campaign_activated', uuid: 'uuid', @@ -125,6 +127,9 @@ describe('makeEventBatch', () => { }) it('should build a batch with simlge impression event when experiment and variation are not defined', () => { + // When campaign_id, experiment_id, and variation_id are all + // missing/invalid, the normalized wire output is campaign_id=null, + // variation_id=null, and entity_id mirrors campaign_id byte-for-byte. const impressionEvent: ImpressionEvent = { type: 'impression', timestamp: 69, @@ -183,7 +188,7 @@ describe('makeEventBatch', () => { { campaign_id: null, experiment_id: "", - variation_id: "", + variation_id: null, metadata: { flag_key: 'flagKey1', rule_key: '', @@ -691,17 +696,18 @@ describe('makeEventBatch', () => { attributes: [{ entityId: 'attr1-id', key: 'attr1-key', value: 'attr1-value' }], }, + // Use numeric-string IDs so the happy-path wire output is unchanged. layer: { - id: 'layerId', + id: '11111', }, experiment: { - id: 'expId', + id: '22222', key: 'expKey', }, variation: { - id: 'varId', + id: '33333', key: 'varKey', }, @@ -728,9 +734,9 @@ describe('makeEventBatch', () => { { decisions: [ { - campaign_id: 'layerId', - experiment_id: 'expId', - variation_id: 'varId', + campaign_id: '11111', + experiment_id: '22222', + variation_id: '33333', metadata: { flag_key: 'flagKey1', rule_key: 'expKey', @@ -742,7 +748,7 @@ describe('makeEventBatch', () => { ], events: [ { - entity_id: 'layerId', + entity_id: '11111', timestamp: 69, key: 'campaign_activated', uuid: 'uuid', @@ -809,6 +815,8 @@ describe('makeEventBatch', () => { describe('buildLogEvent', () => { it('should select the correct URL based on the event context region', () => { + // Use numeric-string IDs so normalization is a no-op here; this test + // only covers URL-region behavior. const baseEvent: ImpressionEvent = { type: 'impression', timestamp: 69, @@ -827,14 +835,14 @@ describe('buildLogEvent', () => { attributes: [] }, layer: { - id: 'layerId' + id: '11111' }, experiment: { - id: 'expId', + id: '22222', key: 'expKey' }, variation: { - id: 'varId', + id: '33333', key: 'varKey' }, ruleKey: 'expKey', @@ -868,3 +876,382 @@ describe('buildLogEvent', () => { expect(euResult.url).toBe('https://eu.logx.optimizely.com/v1/events'); }); }); + +/** + * Decision-event ID normalization tests. + * + * Pins the normalization contract for decisions[].campaign_id, + * decisions[].variation_id, and events[].entity_id on impression events: + * + * - campaign_id: non-empty string (opaque IDs allowed); fall back to + * experiment_id when empty/null/missing; emit null when both are + * empty/null. + * - variation_id: non-empty decimal-digit string OR null; any invalid + * input (empty, whitespace, non-numeric) normalizes to null. + * - events[].entity_id (impression only) follows the campaign_id rule + * and must equal decisions[].campaign_id byte-for-byte. + * - Rule applies uniformly across all decision types (experiment, + * feature-test, rollout, holdout) — no per-type branching. + * - Conversion events derive entity_id from event.id and are unchanged. + * - Normalization never drops, fails, or logs. + */ +describe('decision event ID normalization', () => { + const baseContext = { + accountId: 'accountId', + projectId: 'projectId', + clientName: 'node-sdk', + clientVersion: '3.0.0', + revision: 'revision', + botFiltering: true, + anonymizeIP: true, + }; + + const baseUser = { + id: 'userId', + attributes: [], + }; + + const makeImpression = (overrides: { + layerId: string | null; + experimentId: string | null; + variationId: string | null; + ruleType: string; + }): ImpressionEvent => ({ + type: 'impression', + timestamp: 69, + uuid: 'uuid', + context: { ...baseContext }, + user: { ...baseUser, attributes: [] }, + layer: { id: overrides.layerId }, + experiment: { id: overrides.experimentId, key: 'expKey' }, + variation: { id: overrides.variationId, key: 'varKey' }, + ruleKey: 'expKey', + flagKey: 'flagKey1', + ruleType: overrides.ruleType, + enabled: true, + }); + + const getDecision = (event: ImpressionEvent) => + makeEventBatch([event]).visitors[0].snapshots[0].decisions![0]; + + const getSnapshotEvent = (event: ImpressionEvent) => + makeEventBatch([event]).visitors[0].snapshots[0].events[0]; + + // --------------------------------------------------------------------------- + // FR-001 / FR-002: campaign_id normalization + // --------------------------------------------------------------------------- + describe('campaign_id (FR-001 / FR-002)', () => { + it('preserves a valid numeric-string layerId', () => { + const decision = getDecision( + makeImpression({ layerId: '12345', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + ); + expect(decision.campaign_id).toBe('12345'); + }); + + it('preserves a numeric-string layerId with leading zeros', () => { + const decision = getDecision( + makeImpression({ layerId: '00042', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + ); + expect(decision.campaign_id).toBe('00042'); + }); + + it('preserves an opaque non-numeric layerId unchanged', () => { + // campaign_id contract is "any non-empty string"; opaque IDs like + // "layer_abc" or "default-12345" pass through unchanged. + const decision = getDecision( + makeImpression({ layerId: 'layer_abc', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + ); + expect(decision.campaign_id).toBe('layer_abc'); + }); + + it('preserves a layerId with a negative sign unchanged', () => { + const decision = getDecision( + makeImpression({ layerId: '-123', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + ); + expect(decision.campaign_id).toBe('-123'); + }); + + it('preserves a decimal-formatted layerId unchanged', () => { + const decision = getDecision( + makeImpression({ layerId: '123.45', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + ); + expect(decision.campaign_id).toBe('123.45'); + }); + + it('preserves an exponential-notation layerId unchanged', () => { + const decision = getDecision( + makeImpression({ layerId: '1e5', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + ); + expect(decision.campaign_id).toBe('1e5'); + }); + + it('preserves a whitespace-only layerId unchanged', () => { + // Whitespace is a non-empty string; only empty string, null, and + // undefined trigger the experiment_id fallback. + const decision = getDecision( + makeImpression({ layerId: ' ', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + ); + expect(decision.campaign_id).toBe(' '); + }); + + it('substitutes experiment_id when layerId is null', () => { + const decision = getDecision( + makeImpression({ layerId: null, experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + ); + expect(decision.campaign_id).toBe('67890'); + }); + + it('substitutes experiment_id when layerId is an empty string', () => { + const decision = getDecision( + makeImpression({ layerId: '', experimentId: '67890', variationId: '111', ruleType: 'experiment' }) + ); + expect(decision.campaign_id).toBe('67890'); + }); + + it('substitutes an opaque experiment_id when layerId is null', () => { + // experiment_id fallback also accepts any non-empty string. + const decision = getDecision( + makeImpression({ layerId: null, experimentId: 'exp_42', variationId: '111', ruleType: 'experiment' }) + ); + expect(decision.campaign_id).toBe('exp_42'); + }); + + it('emits null when both layerId and experiment_id are null', () => { + const decision = getDecision( + makeImpression({ layerId: null, experimentId: null, variationId: '111', ruleType: 'experiment' }) + ); + expect(decision.campaign_id).toBeNull(); + }); + + it('emits null when both layerId and experiment_id are empty strings', () => { + const decision = getDecision( + makeImpression({ layerId: '', experimentId: '', variationId: '111', ruleType: 'experiment' }) + ); + expect(decision.campaign_id).toBeNull(); + }); + }); + + // --------------------------------------------------------------------------- + // FR-003 / FR-004: variation_id normalization + // --------------------------------------------------------------------------- + describe('variation_id (FR-003 / FR-004)', () => { + it('preserves a valid numeric-string variationId', () => { + const decision = getDecision( + makeImpression({ layerId: '12345', experimentId: '67890', variationId: '99999', ruleType: 'experiment' }) + ); + expect(decision.variation_id).toBe('99999'); + }); + + it('preserves a numeric-string variationId with leading zeros', () => { + const decision = getDecision( + makeImpression({ layerId: '12345', experimentId: '67890', variationId: '00007', ruleType: 'experiment' }) + ); + expect(decision.variation_id).toBe('00007'); + }); + + it('normalizes null variationId to null', () => { + const decision = getDecision( + makeImpression({ layerId: '12345', experimentId: '67890', variationId: null, ruleType: 'experiment' }) + ); + expect(decision.variation_id).toBeNull(); + }); + + it('normalizes empty-string variationId to null', () => { + const decision = getDecision( + makeImpression({ layerId: '12345', experimentId: '67890', variationId: '', ruleType: 'experiment' }) + ); + expect(decision.variation_id).toBeNull(); + }); + + it('normalizes whitespace variationId to null', () => { + const decision = getDecision( + makeImpression({ layerId: '12345', experimentId: '67890', variationId: ' ', ruleType: 'experiment' }) + ); + expect(decision.variation_id).toBeNull(); + }); + + it('normalizes non-numeric variationId to null', () => { + const decision = getDecision( + makeImpression({ layerId: '12345', experimentId: '67890', variationId: 'variation_a', ruleType: 'experiment' }) + ); + expect(decision.variation_id).toBeNull(); + }); + + it('normalizes negative numeric variationId to null', () => { + const decision = getDecision( + makeImpression({ layerId: '12345', experimentId: '67890', variationId: '-1', ruleType: 'experiment' }) + ); + expect(decision.variation_id).toBeNull(); + }); + + it('normalizes decimal variationId to null', () => { + const decision = getDecision( + makeImpression({ layerId: '12345', experimentId: '67890', variationId: '1.5', ruleType: 'experiment' }) + ); + expect(decision.variation_id).toBeNull(); + }); + }); + + // --------------------------------------------------------------------------- + // FR-005: Uniform application across all decision types + // --------------------------------------------------------------------------- + describe('uniform application across decision types (FR-005)', () => { + const ruleTypes = ['experiment', 'feature-test', 'rollout', 'holdout']; + + ruleTypes.forEach((ruleType) => { + it(`normalizes campaign_id identically for ruleType=${ruleType}`, () => { + // null layerId triggers fallback to experiment_id; the fallback + // fires identically regardless of rule type. + const decision = getDecision( + makeImpression({ layerId: null, experimentId: '67890', variationId: '111', ruleType }) + ); + expect(decision.campaign_id).toBe('67890'); + }); + + it(`normalizes variation_id identically for ruleType=${ruleType}`, () => { + const decision = getDecision( + makeImpression({ layerId: '12345', experimentId: '67890', variationId: 'bad', ruleType }) + ); + expect(decision.variation_id).toBeNull(); + }); + }); + + it('produces byte-equivalent campaign_id for the same invalid input across all rule types', () => { + const outputs = ruleTypes.map((ruleType) => + getDecision( + makeImpression({ layerId: null, experimentId: '67890', variationId: '111', ruleType }) + ).campaign_id + ); + expect(new Set(outputs).size).toBe(1); + expect(outputs[0]).toBe('67890'); + }); + }); + + // --------------------------------------------------------------------------- + // FR-006: Never drop, defer, or fail event dispatch + // --------------------------------------------------------------------------- + describe('event dispatch resilience (FR-006)', () => { + it('does not throw on all-invalid inputs', () => { + expect(() => + makeEventBatch([ + makeImpression({ layerId: null, experimentId: null, variationId: null, ruleType: 'holdout' }), + ]) + ).not.toThrow(); + }); + + it('still produces a single visitor + snapshot when ids are invalid', () => { + const batch = makeEventBatch([ + makeImpression({ layerId: null, experimentId: null, variationId: null, ruleType: 'rollout' }), + ]); + expect(batch.visitors).toHaveLength(1); + expect(batch.visitors[0].snapshots).toHaveLength(1); + expect(batch.visitors[0].snapshots[0].decisions).toHaveLength(1); + expect(batch.visitors[0].snapshots[0].events).toHaveLength(1); + }); + }); + + // --------------------------------------------------------------------------- + // FR-007: Normalization path must not log + // --------------------------------------------------------------------------- + describe('silent normalization (FR-007)', () => { + it('does not write to console.warn or console.error on invalid inputs', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + makeEventBatch([ + makeImpression({ layerId: 'abc', experimentId: 'def', variationId: 'ghi', ruleType: 'experiment' }), + ]); + expect(warnSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); + }); + + // --------------------------------------------------------------------------- + // FR-008: Cross-SDK byte-equivalent wire output for the same input + // --------------------------------------------------------------------------- + describe('byte-equivalent output (FR-008)', () => { + it('produces identical JSON for two identical event inputs', () => { + // layerId here is a valid non-empty string (passes through); the + // variationId is non-numeric and normalizes to null. + const e1 = makeImpression({ + layerId: 'layer_abc', + experimentId: '67890', + variationId: 'bad', + ruleType: 'experiment', + }); + const e2 = makeImpression({ + layerId: 'layer_abc', + experimentId: '67890', + variationId: 'bad', + ruleType: 'experiment', + }); + const out1 = JSON.stringify(makeEventBatch([e1])); + const out2 = JSON.stringify(makeEventBatch([e2])); + expect(out1).toBe(out2); + }); + }); + + // --------------------------------------------------------------------------- + // FR-009: entity_id on impression events equals campaign_id byte-for-byte + // --------------------------------------------------------------------------- + describe('impression entity_id equals campaign_id byte-for-byte (FR-009)', () => { + const cases: Array<{ + name: string; + layerId: string | null; + experimentId: string | null; + }> = [ + { name: 'valid numeric layerId', layerId: '12345', experimentId: '67890' }, + { name: 'opaque non-numeric layerId preserved', layerId: 'layer_abc', experimentId: '67890' }, + { name: 'empty-string layerId falls back to experiment_id', layerId: '', experimentId: '67890' }, + { name: 'null layerId falls back to experiment_id', layerId: null, experimentId: '67890' }, + { name: 'both empty/null -> null', layerId: '', experimentId: '' }, + { name: 'both null -> null', layerId: null, experimentId: null }, + { name: 'layerId leading zeros preserved', layerId: '00099', experimentId: '67890' }, + ]; + + cases.forEach(({ name, layerId, experimentId }) => { + it(`entity_id === campaign_id (${name})`, () => { + const event = makeImpression({ layerId, experimentId, variationId: '111', ruleType: 'experiment' }); + const decision = getDecision(event); + const snapshotEvent = getSnapshotEvent(event); + expect(snapshotEvent.entity_id).toBe(decision.campaign_id); + }); + }); + }); + + // --------------------------------------------------------------------------- + // FR-010: Conversion events derive entity_id from a different source + // --------------------------------------------------------------------------- + describe('conversion entity_id is not normalized (FR-010)', () => { + const makeConversion = (eventId: string | null): ConversionEvent => ({ + type: 'conversion', + timestamp: 69, + uuid: 'uuid', + context: { ...baseContext }, + user: { ...baseUser, attributes: [] }, + event: { id: eventId, key: 'event-key' }, + tags: undefined, + revenue: null, + value: null, + }); + + it('passes through a non-numeric conversion event id unchanged', () => { + const batch = makeEventBatch([makeConversion('event-id-string')]); + expect(batch.visitors[0].snapshots[0].events[0].entity_id).toBe('event-id-string'); + }); + + it('passes through null conversion event id unchanged', () => { + const batch = makeEventBatch([makeConversion(null)]); + expect(batch.visitors[0].snapshots[0].events[0].entity_id).toBeNull(); + }); + + it('passes through a numeric conversion event id unchanged', () => { + const batch = makeEventBatch([makeConversion('42')]); + expect(batch.visitors[0].snapshots[0].events[0].entity_id).toBe('42'); + }); + }); +}); diff --git a/lib/event_processor/event_builder/log_event.ts b/lib/event_processor/event_builder/log_event.ts index 1d13bb5fb..8d63e421a 100644 --- a/lib/event_processor/event_builder/log_event.ts +++ b/lib/event_processor/event_builder/log_event.ts @@ -1,5 +1,5 @@ /** - * Copyright 2021-2022, 2024, Optimizely + * Copyright 2021-2022, 2024, 2026, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -160,17 +160,23 @@ function makeConversionSnapshot(conversion: ConversionEvent): Snapshot { } } +function normalizeVariationId(id: string | null | undefined): string | null { + return id && /^[0-9]+$/.test(id) ? id : null +} + function makeDecisionSnapshot(event: ImpressionEvent): Snapshot { const { layer, experiment, variation, ruleKey, flagKey, ruleType, enabled, cmabUuid } = event - const layerId = layer ? layer.id : null - const experimentId = experiment?.id ?? '' - const variationId = variation?.id ?? '' + const layerId = layer?.id; + const experimentId = experiment?.id ?? ''; + const variationId = normalizeVariationId(variation?.id); const variationKey = variation ? variation.key : '' + const campaignId = layerId || experimentId || null; + return { decisions: [ { - campaign_id: layerId, + campaign_id: campaignId, experiment_id: experimentId, variation_id: variationId, metadata: { @@ -185,7 +191,7 @@ function makeDecisionSnapshot(event: ImpressionEvent): Snapshot { ], events: [ { - entity_id: layerId, + entity_id: campaignId, timestamp: event.timestamp, key: ACTIVATE_EVENT_KEY, uuid: event.uuid, diff --git a/lib/event_processor/event_builder/user_event.spec.ts b/lib/event_processor/event_builder/user_event.spec.ts index e8cb373b3..7bd55ce76 100644 --- a/lib/event_processor/event_builder/user_event.spec.ts +++ b/lib/event_processor/event_builder/user_event.spec.ts @@ -7,7 +7,7 @@ import testData from '../../tests/test_data'; describe('buildImpressionEvent', () => { it('should use correct region from projectConfig in event context', () => { const projectConfig = createProjectConfig( - testData.getTestProjectConfig(), + JSON.stringify(testData.getTestProjectConfig()), ) const experiment = projectConfig.experiments[0]; @@ -51,7 +51,7 @@ describe('buildImpressionEvent', () => { describe('buildConversionEvent', () => { it('should use correct region from projectConfig in event context', () => { const projectConfig = createProjectConfig( - testData.getTestProjectConfig(), + JSON.stringify(testData.getTestProjectConfig()), ) const conversionEvent = buildConversionEvent({ diff --git a/lib/export_types.ts b/lib/export_types.ts index 51109d933..652ed7ae2 100644 --- a/lib/export_types.ts +++ b/lib/export_types.ts @@ -95,6 +95,7 @@ export type { Event, DatafileOptions, UserProfileService, + UserProfileServiceAsync, UserProfile, ListenerPayload, OptimizelyDecision, diff --git a/lib/index.browser.tests.js b/lib/index.browser.tests.js index 46e29748a..21576b24a 100644 --- a/lib/index.browser.tests.js +++ b/lib/index.browser.tests.js @@ -19,7 +19,6 @@ import Optimizely from './optimizely'; import testData from './tests/test_data'; import packageJSON from '../package.json'; import * as optimizelyFactory from './index.browser'; -import configValidator from './utils/config_validator'; import { getMockProjectConfigManager } from './tests/mock/mock_project_config_manager'; import { createProjectConfig } from './project_config/project_config'; import { wrapConfigManager } from './project_config/config_manager_factory'; @@ -153,7 +152,7 @@ describe('javascript-sdk (Browser)', function() { }); assert.instanceOf(optlyInstance, Optimizely); - assert.equal(optlyInstance.clientVersion, '6.3.1'); + assert.equal(optlyInstance.clientVersion, packageJSON.version); }); it('should set the JavaScript client engine and version', function() { @@ -180,7 +179,7 @@ describe('javascript-sdk (Browser)', function() { it('should activate with provided event dispatcher', function() { var optlyInstance = optimizelyFactory.createInstance({ projectConfigManager: wrapConfigManager(getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfig())), })), logger: wrapLogger(mockLogger), }); @@ -191,7 +190,7 @@ describe('javascript-sdk (Browser)', function() { it('should be able to set and get a forced variation', function() { var optlyInstance = optimizelyFactory.createInstance({ projectConfigManager: wrapConfigManager(getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfig())), })), logger: wrapLogger(mockLogger), }); @@ -206,7 +205,7 @@ describe('javascript-sdk (Browser)', function() { it('should be able to set and unset a forced variation', function() { var optlyInstance = optimizelyFactory.createInstance({ projectConfigManager: wrapConfigManager(getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfig())), })), logger: wrapLogger(mockLogger), }); @@ -227,7 +226,7 @@ describe('javascript-sdk (Browser)', function() { it('should be able to set multiple experiments for one user', function() { var optlyInstance = optimizelyFactory.createInstance({ projectConfigManager: wrapConfigManager(getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfig())), })), logger: wrapLogger(mockLogger), }); @@ -252,7 +251,7 @@ describe('javascript-sdk (Browser)', function() { it('should be able to set multiple experiments for one user, and unset one', function() { var optlyInstance = optimizelyFactory.createInstance({ projectConfigManager: wrapConfigManager(getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfig())), })), logger: wrapLogger(mockLogger), }); @@ -280,7 +279,7 @@ describe('javascript-sdk (Browser)', function() { it('should be able to set multiple experiments for one user, and reset one', function() { var optlyInstance = optimizelyFactory.createInstance({ projectConfigManager: wrapConfigManager(getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfig())), })), logger: wrapLogger(mockLogger), }); @@ -312,7 +311,7 @@ describe('javascript-sdk (Browser)', function() { it('should override bucketing when setForcedVariation is called', function() { var optlyInstance = optimizelyFactory.createInstance({ projectConfigManager: wrapConfigManager(getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfig())), })), logger: wrapLogger(mockLogger), }); @@ -333,7 +332,7 @@ describe('javascript-sdk (Browser)', function() { it('should override bucketing when setForcedVariation is called for a not running experiment', function() { var optlyInstance = optimizelyFactory.createInstance({ projectConfigManager: wrapConfigManager(getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfig())), })), logger: wrapLogger(mockLogger), }); diff --git a/lib/index.browser.umdtests.js b/lib/index.browser.umdtests.js index a13f5046b..ac8b4906c 100644 --- a/lib/index.browser.umdtests.js +++ b/lib/index.browser.umdtests.js @@ -16,14 +16,12 @@ import { assert } from 'chai'; import sinon from 'sinon'; -import configValidator from './utils/config_validator'; import * as enums from './utils/enums'; import * as logger from './plugins/logger'; import Optimizely from './optimizely'; import testData from './tests/test_data'; import packageJSON from '../package.json'; import eventDispatcher from './plugins/event_dispatcher/index.browser'; -import { INVALID_CONFIG_OR_SOMETHING } from './exception_messages'; describe('javascript-sdk', function() { describe('APIs', function() { @@ -37,7 +35,6 @@ describe('javascript-sdk', function() { logLevel: enums.LOG_LEVEL.INFO, logToConsole: false, }); - sinon.stub(configValidator, 'validate'); sinon.stub(Optimizely.prototype, 'close'); global.XMLHttpRequest = sinon.useFakeXMLHttpRequest(); @@ -56,7 +53,6 @@ describe('javascript-sdk', function() { console.warn.restore(); console.error.restore(); window.addEventListener.restore(); - configValidator.validate.restore(); Optimizely.prototype.close.restore(); delete global.XMLHttpRequest }); @@ -93,7 +89,6 @@ describe('javascript-sdk', function() { }); it('should not throw if the provided config is not valid', function() { - configValidator.validate.throws(new Error(INVALID_CONFIG_OR_SOMETHING)); assert.doesNotThrow(function() { var optlyInstance = window.optimizelySdk.createInstance({ datafile: {}, diff --git a/lib/index.node.tests.js b/lib/index.node.tests.js index 2ce076d6c..382f0cc9f 100644 --- a/lib/index.node.tests.js +++ b/lib/index.node.tests.js @@ -18,8 +18,8 @@ import sinon from 'sinon'; import Optimizely from './optimizely'; import testData from './tests/test_data'; +import packageJSON from '../package.json'; import * as optimizelyFactory from './index.node'; -import configValidator from './utils/config_validator'; import { getMockProjectConfigManager } from './tests/mock/mock_project_config_manager'; import { wrapConfigManager } from './project_config/config_manager_factory'; import { wrapLogger } from './logging/logger_factory'; @@ -88,7 +88,7 @@ describe('optimizelyFactory', function() { }); assert.instanceOf(optlyInstance, Optimizely); - assert.equal(optlyInstance.clientVersion, '6.3.1'); + assert.equal(optlyInstance.clientVersion, packageJSON.version); }); // TODO: user will create and inject an event processor // these tests will be refactored accordingly @@ -105,7 +105,7 @@ describe('optimizelyFactory', function() { // it('should ignore invalid event flush interval and use default instead', function() { // optimizelyFactory.createInstance({ // projectConfigManager: getMockProjectConfigManager({ - // initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + // initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfigWithFeatures())), // }), // errorHandler: fakeErrorHandler, // eventDispatcher: fakeEventDispatcher, @@ -123,7 +123,7 @@ describe('optimizelyFactory', function() { // it('should use default event flush interval when none is provided', function() { // optimizelyFactory.createInstance({ // projectConfigManager: getMockProjectConfigManager({ - // initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + // initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfigWithFeatures())), // }), // errorHandler: fakeErrorHandler, // eventDispatcher: fakeEventDispatcher, @@ -140,7 +140,7 @@ describe('optimizelyFactory', function() { // it('should use provided event flush interval when valid', function() { // optimizelyFactory.createInstance({ // projectConfigManager: getMockProjectConfigManager({ - // initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + // initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfigWithFeatures())), // }), // errorHandler: fakeErrorHandler, // eventDispatcher: fakeEventDispatcher, @@ -158,7 +158,7 @@ describe('optimizelyFactory', function() { // it('should ignore invalid event batch size and use default instead', function() { // optimizelyFactory.createInstance({ // projectConfigManager: getMockProjectConfigManager({ - // initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + // initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfigWithFeatures())), // }), // errorHandler: fakeErrorHandler, // eventDispatcher: fakeEventDispatcher, @@ -176,7 +176,7 @@ describe('optimizelyFactory', function() { // it('should use default event batch size when none is provided', function() { // optimizelyFactory.createInstance({ // projectConfigManager: getMockProjectConfigManager({ - // initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + // initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfigWithFeatures())), // }), // errorHandler: fakeErrorHandler, // eventDispatcher: fakeEventDispatcher, @@ -193,7 +193,7 @@ describe('optimizelyFactory', function() { // it('should use provided event batch size when valid', function() { // optimizelyFactory.createInstance({ // projectConfigManager: getMockProjectConfigManager({ - // initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + // initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfigWithFeatures())), // }), // errorHandler: fakeErrorHandler, // eventDispatcher: fakeEventDispatcher, diff --git a/lib/index.react_native.spec.ts b/lib/index.react_native.spec.ts index ba7f7ab5d..0715c6518 100644 --- a/lib/index.react_native.spec.ts +++ b/lib/index.react_native.spec.ts @@ -19,7 +19,6 @@ import Optimizely from './optimizely'; import testData from './tests/test_data'; import packageJSON from '../package.json'; import * as optimizelyFactory from './index.react_native'; -import configValidator from './utils/config_validator'; import { getMockProjectConfigManager } from './tests/mock/mock_project_config_manager'; import { createProjectConfig } from './project_config/project_config'; import { getMockLogger } from './tests/mock/mock_logger'; @@ -92,7 +91,7 @@ describe('javascript-sdk/react-native', () => { expect(optlyInstance).toBeInstanceOf(Optimizely); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - expect(optlyInstance.clientVersion).toEqual('6.3.1'); + expect(optlyInstance.clientVersion).toEqual(packageJSON.version); }); it('should set the React Native JS client engine and javascript SDK version', () => { @@ -137,7 +136,7 @@ describe('javascript-sdk/react-native', () => { // it('should call logging.setLogLevel', () => { // optimizelyFactory.createInstance({ // projectConfigManager: getMockProjectConfigManager({ - // initConfig: createProjectConfig(testData.getTestProjectConfig()), + // initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfig())), // }), // logLevel: optimizelyFactory.enums.LOG_LEVEL.ERROR, // }); @@ -159,7 +158,7 @@ describe('javascript-sdk/react-native', () => { // const fakeLogger = { log: function() {} }; // optimizelyFactory.createInstance({ // projectConfigManager: getMockProjectConfigManager({ - // initConfig: createProjectConfig(testData.getTestProjectConfig()), + // initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfig())), // }), // // eslint-disable-next-line @typescript-eslint/ban-ts-comment // // @ts-ignore diff --git a/lib/message/error_message.ts b/lib/message/error_message.ts index 624e4085d..20433e153 100644 --- a/lib/message/error_message.ts +++ b/lib/message/error_message.ts @@ -87,6 +87,7 @@ export const OUT_OF_BOUNDS = export const REQUEST_TIMEOUT = 'Request timeout'; export const REQUEST_ERROR = 'Request error'; export const NO_STATUS_CODE_IN_RESPONSE = 'No status code in response'; +export const INVALID_REQUEST_URL = 'Invalid request URL: %s'; export const UNSUPPORTED_PROTOCOL = 'Unsupported protocol: %s'; export const RETRY_CANCELLED = 'Retry cancelled'; export const ONLY_POST_REQUESTS_ARE_SUPPORTED = 'Only POST requests are supported'; @@ -100,6 +101,7 @@ export const INVALID_CMAB_FETCH_RESPONSE = 'Invalid CMAB fetch response'; export const PROMISE_NOT_ALLOWED = "Promise value is not allowed in sync operation"; export const SERVICE_NOT_RUNNING = "%s not running"; export const EVENT_STORE_FULL = 'Event store is full. Not saving event with id %d.'; +export const LOCAL_HOLDOUT_MISSING_INCLUDED_RULES = 'Local holdout "%s" is missing or has empty "includedRules"; skipping.'; export const messages: string[] = []; diff --git a/lib/message/log_message.ts b/lib/message/log_message.ts index 2e9180535..39a5e33fd 100644 --- a/lib/message/log_message.ts +++ b/lib/message/log_message.ts @@ -69,6 +69,7 @@ export const INVALIDATE_CMAB_CACHE = 'Invalidating CMAB cache for user %s and ru export const CMAB_CACHE_HIT = 'Cache hit for user %s and rule %s.'; export const CMAB_CACHE_ATTRIBUTES_MISMATCH = 'CMAB cache attributes mismatch for user %s and rule %s, fetching new decision.'; export const CMAB_CACHE_MISS = 'Cache miss for user %s and rule %s.'; +export const ODP_IDENTIFY_NOT_DISPATCHED= 'ODP identify event is not dispatched (fewer than 2 valid identifiers).' export const messages: string[] = []; diff --git a/lib/odp/odp_manager.spec.ts b/lib/odp/odp_manager.spec.ts index 9ae0daf69..3cc8c4532 100644 --- a/lib/odp/odp_manager.spec.ts +++ b/lib/odp/odp_manager.spec.ts @@ -618,7 +618,7 @@ describe('DefaultOdpManager', () => { expect(identifiers).toEqual(new Map([['fs_user_id', 'user'], ['vuid', 'vuid_a']])); }); - it('sends identified event when called with just fs_user_id in first parameter', async () => { + it('does not send identified event when called with just fs_user_id (single identifier)', async () => { const eventManager = getMockOdpEventManager(); eventManager.onRunning.mockReturnValue(Promise.resolve()); @@ -634,12 +634,10 @@ describe('DefaultOdpManager', () => { await odpManager.onRunning(); odpManager.identifyUser('user'); - expect(mockSendEvents).toHaveBeenCalledOnce(); - const { identifiers } = mockSendEvents.mock.calls[0][0]; - expect(identifiers).toEqual(new Map([['fs_user_id', 'user']])); + expect(mockSendEvents).not.toHaveBeenCalled(); }); - it('sends identified event when called with just vuid in first parameter', async () => { + it('does not send identified event when called with just vuid (single identifier)', async () => { const eventManager = getMockOdpEventManager(); eventManager.onRunning.mockReturnValue(Promise.resolve()); @@ -655,9 +653,7 @@ describe('DefaultOdpManager', () => { await odpManager.onRunning(); odpManager.identifyUser('vuid_a'); - expect(mockSendEvents).toHaveBeenCalledOnce(); - const { identifiers } = mockSendEvents.mock.calls[0][0]; - expect(identifiers).toEqual(new Map([['vuid', 'vuid_a']])); + expect(mockSendEvents).not.toHaveBeenCalled(); }); it('should reject onRunning() if stopped in new state', async () => { diff --git a/lib/odp/odp_manager.ts b/lib/odp/odp_manager.ts index 7525d0efb..2eb9c2e16 100644 --- a/lib/odp/odp_manager.ts +++ b/lib/odp/odp_manager.ts @@ -15,7 +15,7 @@ */ import { v4 as uuidV4} from 'uuid'; -import { LoggerFacade } from '../logging/logger'; +import { LoggerFacade, LogLevel } from '../logging/logger'; import { OdpIntegrationConfig, odpIntegrationsAreEqual } from './odp_config'; import { OdpEventManager } from './event_manager/odp_event_manager'; @@ -32,6 +32,7 @@ import { Maybe } from '../utils/type'; import { sprintf } from '../utils/fns'; import { SERVICE_STOPPED_BEFORE_RUNNING } from '../service'; import { Platform } from '../platform_support'; +import { ODP_IDENTIFY_NOT_DISPATCHED } from '../message/log_message'; export interface OdpManager extends Service { updateConfig(odpIntegrationConfig: OdpIntegrationConfig): boolean; @@ -212,7 +213,7 @@ export class DefaultOdpManager extends BaseService implements OdpManager { identifyUser(userId: string, vuid?: string): void { const identifiers = new Map(); - + let finalUserId: Maybe = userId; let finalVuid: Maybe = vuid; @@ -229,6 +230,13 @@ export class DefaultOdpManager extends BaseService implements OdpManager { identifiers.set(ODP_USER_KEY.FS_USER_ID, finalUserId); } + // Identify requires 2+ identifiers to link (e.g., vuid + fs_user_id). + // A single identifier has no cross-reference value and generates unnecessary traffic. + if (identifiers.size < 2) { + this.logger?.debug(ODP_IDENTIFY_NOT_DISPATCHED); + return; + } + const event = new OdpEvent(ODP_DEFAULT_EVENT_TYPE, ODP_EVENT_ACTION.IDENTIFIED, identifiers); this.sendEvent(event); } diff --git a/lib/optimizely/index.spec.ts b/lib/optimizely/index.spec.ts index 4ed119fba..d891e283a 100644 --- a/lib/optimizely/index.spec.ts +++ b/lib/optimizely/index.spec.ts @@ -75,7 +75,7 @@ describe('Optimizely', () => { it('should pass disposable options to the respective services', () => { const projectConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfig())), }); vi.spyOn(projectConfigManager, 'makeDisposable'); @@ -100,7 +100,7 @@ describe('Optimizely', () => { it('should set child logger to respective services', () => { const projectConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfig())), }); const eventProcessor = getForwardingEventProcessor(eventDispatcher); @@ -136,7 +136,7 @@ describe('Optimizely', () => { describe('decideAsync', () => { it('should return an error decision with correct reasons if decisionService returns error', async () => { - const projectConfig = createProjectConfig(getDecisionTestDatafile()); + const projectConfig = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const projectConfigManager = getMockProjectConfigManager({ initConfig: projectConfig, @@ -186,7 +186,7 @@ describe('Optimizely', () => { }); it('should include cmab uuid in dispatched event if decisionService returns a cmab uuid', async () => { - const projectConfig = createProjectConfig(getDecisionTestDatafile()); + const projectConfig = createProjectConfig(JSON.stringify(getDecisionTestDatafile())); const projectConfigManager = getMockProjectConfigManager({ initConfig: projectConfig, @@ -257,7 +257,7 @@ describe('Optimizely', () => { beforeEach(() => { const datafile = getDecisionTestDatafile(); datafile.holdouts = JSON.parse(JSON.stringify(holdoutData)); // Deep copy to avoid mutations - projectConfig = createProjectConfig(datafile); + projectConfig = createProjectConfig(JSON.stringify(datafile)); const projectConfigManager = getMockProjectConfigManager({ initConfig: projectConfig, @@ -876,7 +876,7 @@ describe('Optimizely', () => { it('should flush eventProcessor and odpManager on flushImmediately()', async () => { const projectConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfig())), }); const eventProcessor = getForwardingEventProcessor(eventDispatcher); @@ -909,7 +909,7 @@ describe('Optimizely', () => { it('should log error when sendOdpEvent is called without odpManager', () => { const projectConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfig())), }); const mockLogger = getMockLogger(); @@ -931,7 +931,7 @@ describe('Optimizely', () => { it('should log error when fetchQualifiedSegments is called without odpManager', async () => { const projectConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfig())), }); const mockLogger = getMockLogger(); diff --git a/lib/optimizely/index.tests.js b/lib/optimizely/index.tests.js index 437749cfb..d1ea1b8b3 100644 --- a/lib/optimizely/index.tests.js +++ b/lib/optimizely/index.tests.js @@ -1,5 +1,5 @@ /** - * Copyright 2016-2025, Optimizely + * Copyright 2016-2026, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -103,7 +103,7 @@ var createLogger = () => ({ const getOptlyInstance = ({ datafileObj, defaultDecideOptions }) => { const mockConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(datafileObj), + initConfig: createProjectConfig(JSON.stringify(datafileObj)), }); const eventDispatcher = getMockEventDispatcher(); const eventProcessor = getForwardingEventProcessor(eventDispatcher); @@ -302,7 +302,7 @@ describe('lib/optimizely', function() { }); beforeEach(function() { const mockConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfig())), }); optlyInstance = new Optimizely({ @@ -922,7 +922,7 @@ describe('lib/optimizely', function() { bucketStub.returns(fakeDecisionResponse); const mockConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfig())), }); var instance = new Optimizely({ @@ -1675,7 +1675,7 @@ describe('lib/optimizely', function() { it('should track when logger is in DEBUG mode', function() { const mockConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfig())), }); var instance = new Optimizely({ @@ -2535,7 +2535,7 @@ describe('lib/optimizely', function() { describe('activate', function() { beforeEach(function() { const mockConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfig())), }); optlyInstance = new Optimizely({ @@ -2598,7 +2598,7 @@ describe('lib/optimizely', function() { describe('getVariation', function() { beforeEach(function() { const mockConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfig())), }); optlyInstance = new Optimizely({ @@ -2655,7 +2655,7 @@ describe('lib/optimizely', function() { it('should send notification with variation key and type feature-test when getVariation returns feature experiment variation', function() { const mockConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfigWithFeatures())), }); var optly = new Optimizely({ @@ -2696,7 +2696,7 @@ describe('lib/optimizely', function() { beforeEach(function() { const mockConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfigWithFeatures())), }); optlyInstance = new Optimizely({ @@ -4319,7 +4319,7 @@ describe('lib/optimizely', function() { describe('#createUserContext', function() { beforeEach(function() { const mockConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestDecideProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestDecideProjectConfig())), }); optlyInstance = new Optimizely({ @@ -4986,7 +4986,7 @@ describe('lib/optimizely', function() { describe('with DISABLE_DECISION_EVENT flag in default decide options', function() { beforeEach(function() { const mockConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestDecideProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestDecideProjectConfig())), }); optlyInstance = new Optimizely({ @@ -5057,7 +5057,7 @@ describe('lib/optimizely', function() { describe('with INCLUDE_REASONS flag in default decide options', function() { beforeEach(function() { const mockConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestDecideProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestDecideProjectConfig())), }); optlyInstance = new Optimizely({ @@ -5115,7 +5115,7 @@ describe('lib/optimizely', function() { save: sinon.stub(), }; const mockConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestDecideProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestDecideProjectConfig())), }); var optlyInstanceWithUserProfile = new Optimizely({ @@ -5543,7 +5543,7 @@ describe('lib/optimizely', function() { save: sinon.stub(), }; const mockConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestDecideProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestDecideProjectConfig())), }); optlyInstanceWithUserProfile = new Optimizely({ @@ -5590,7 +5590,7 @@ describe('lib/optimizely', function() { save: sinon.stub(), }; const mockConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestDecideProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestDecideProjectConfig())), }); optlyInstanceWithUserProfile = new Optimizely({ @@ -5641,7 +5641,7 @@ describe('lib/optimizely', function() { save: sinon.stub(), }; const mockConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestDecideProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestDecideProjectConfig())), }); optlyInstanceWithUserProfile = new Optimizely({ @@ -5771,7 +5771,7 @@ describe('lib/optimizely', function() { clientEngine: 'node-sdk', datafile: testData.getTestDecideProjectConfig(), projectConfigManager: getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestDecideProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestDecideProjectConfig())), }), userProfileService: userProfileServiceInstance, @@ -6013,7 +6013,7 @@ describe('lib/optimizely', function() { clientEngine: 'node-sdk', datafile: testData.getTestDecideProjectConfig(), projectConfigManager: getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestDecideProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestDecideProjectConfig())), }), userProfileService: userProfileServiceInstance, @@ -6084,7 +6084,7 @@ describe('lib/optimizely', function() { ); beforeEach(function() { const mockConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfig())), }); optlyInstance = new Optimizely({ @@ -6157,7 +6157,7 @@ describe('lib/optimizely', function() { beforeEach(function() { const mockConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfigWithFeatures())), }); optlyInstance = new Optimizely({ @@ -6690,7 +6690,7 @@ describe('lib/optimizely', function() { { campaign_id: null, experiment_id: '', - variation_id: '', + variation_id: null, metadata: { flag_key: 'test_feature', rule_key: '', @@ -6787,7 +6787,7 @@ describe('lib/optimizely', function() { it('return features that are enabled for the user and send notification for every feature', function() { const mockConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfigWithFeatures()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfigWithFeatures())), }); optlyInstance = new Optimizely({ @@ -8882,7 +8882,7 @@ describe('lib/optimizely', function() { ); beforeEach(function() { const mockConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTypedAudiencesConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTypedAudiencesConfig())), }); optlyInstance = new Optimizely({ @@ -9027,7 +9027,7 @@ describe('lib/optimizely', function() { ); beforeEach(function() { const mockConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTypedAudiencesConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTypedAudiencesConfig())), }); optlyInstance = new Optimizely({ @@ -9266,7 +9266,7 @@ describe('lib/optimizely', function() { eventProcessorStopPromise = Promise.resolve(); mockEventProcessor.onTerminated.returns(eventProcessorStopPromise); const mockConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfig())), }); optlyInstance = new Optimizely({ @@ -9303,7 +9303,7 @@ describe('lib/optimizely', function() { eventProcessorStopPromise.catch(() => {}); mockEventProcessor.onTerminated.returns(eventProcessorStopPromise); const mockConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestProjectConfig()), + initConfig: createProjectConfig(JSON.stringify(testData.getTestProjectConfig())), }); optlyInstance = new Optimizely({ @@ -9613,7 +9613,7 @@ describe('lib/optimizely', function() { const datafile = testData.getTestProjectConfigWithFeatures(); - const newConfig = createProjectConfig(datafile, JSON.stringify(datafile)); + const newConfig = createProjectConfig(JSON.stringify(datafile)); fakeProjectConfigManager.setConfig(newConfig); fakeProjectConfigManager.pushUpdate(newConfig); @@ -9644,7 +9644,7 @@ describe('lib/optimizely', function() { ], }); differentDatafile.revision = '44'; - var differentConfig = createProjectConfig(differentDatafile, JSON.stringify(differentDatafile)); + var differentConfig = createProjectConfig(JSON.stringify(differentDatafile)); fakeProjectConfigManager.setConfig(differentConfig); fakeProjectConfigManager.pushUpdate(differentConfig); @@ -9658,7 +9658,7 @@ describe('lib/optimizely', function() { NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, listener ); - var newConfig = createProjectConfig(testData.getTestProjectConfigWithFeatures()); + var newConfig = createProjectConfig(JSON.stringify(testData.getTestProjectConfigWithFeatures())); fakeProjectConfigManager.pushUpdate(newConfig); sinon.assert.calledOnce(listener); @@ -9683,7 +9683,7 @@ describe('lib/optimizely', function() { const datafile = testData.getTestProjectConfig(); const mockConfigManager = getMockProjectConfigManager(); - mockConfigManager.setConfig(createProjectConfig(datafile, JSON.stringify(datafile))); + mockConfigManager.setConfig(createProjectConfig(JSON.stringify(datafile))); optlyInstance = new Optimizely({ clientEngine: 'node-sdk', diff --git a/lib/optimizely_user_context/index.spec.ts b/lib/optimizely_user_context/index.spec.ts index 5f451ccb4..097d0ae1d 100644 --- a/lib/optimizely_user_context/index.spec.ts +++ b/lib/optimizely_user_context/index.spec.ts @@ -51,7 +51,7 @@ interface GetOptlyInstanceParams { const getOptlyInstance = ({ datafileObj, defaultDecideOptions }: GetOptlyInstanceParams) => { const createdLogger = getMockLogger(); const mockConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(datafileObj), + initConfig: createProjectConfig(JSON.stringify(datafileObj)), }); const eventDispatcher = getMockEventDispatcher(); const eventProcessor = getForwardingEventProcessor(eventDispatcher); diff --git a/lib/optimizely_user_context/index.tests.js b/lib/optimizely_user_context/index.tests.js index 6eed2437e..97b07634b 100644 --- a/lib/optimizely_user_context/index.tests.js +++ b/lib/optimizely_user_context/index.tests.js @@ -54,7 +54,7 @@ var createLogger = () => ({ const getOptlyInstance = ({ datafileObj, defaultDecideOptions }) => { const createdLogger = createLogger({ logLevel: LOG_LEVEL.INFO }); const mockConfigManager = getMockProjectConfigManager({ - initConfig: createProjectConfig(datafileObj), + initConfig: createProjectConfig(JSON.stringify(datafileObj)), }); const eventDispatcher = getMockEventDispatcher(); const eventProcessor = getForwardingEventProcessor(eventDispatcher); @@ -400,7 +400,7 @@ describe('lib/optimizely_user_context', function() { optlyInstance = new Optimizely({ clientEngine: 'node-sdk', projectConfigManager: getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestDecideProjectConfig()) + initConfig: createProjectConfig(JSON.stringify(testData.getTestDecideProjectConfig())) }), eventProcessor, isValidInstance: true, @@ -759,7 +759,7 @@ describe('lib/optimizely_user_context', function() { optlyInstance = new Optimizely({ clientEngine: 'node-sdk', projectConfigManager: getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestDecideProjectConfig()) + initConfig: createProjectConfig(JSON.stringify(testData.getTestDecideProjectConfig())) }), eventProcessor, isValidInstance: true, @@ -865,7 +865,7 @@ describe('lib/optimizely_user_context', function() { optlyInstance = new Optimizely({ clientEngine: 'node-sdk', projectConfigManager: getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestDecideProjectConfig()) + initConfig: createProjectConfig(JSON.stringify(testData.getTestDecideProjectConfig())) }), eventProcessor, isValidInstance: true, @@ -911,7 +911,7 @@ describe('lib/optimizely_user_context', function() { var optlyInstance = new Optimizely({ clientEngine: 'node-sdk', projectConfigManager: getMockProjectConfigManager({ - initConfig: createProjectConfig(testData.getTestDecideProjectConfig()) + initConfig: createProjectConfig(JSON.stringify(testData.getTestDecideProjectConfig())) }), eventProcessor, isValidInstance: true, diff --git a/lib/project_config/optimizely_config.spec.ts b/lib/project_config/optimizely_config.spec.ts index 6e9e6747b..bd48a68a2 100644 --- a/lib/project_config/optimizely_config.spec.ts +++ b/lib/project_config/optimizely_config.spec.ts @@ -31,7 +31,6 @@ const datafile: ProjectConfig = getTestProjectConfigWithFeatures(); const typedAudienceDatafile = getTypedAudiencesConfig(); const similarRuleKeyDatafile = getSimilarRuleKeyConfig(); const similarExperimentKeyDatafile = getSimilarExperimentKeyConfig(); -const cloneDeep = (obj: any) => JSON.parse(JSON.stringify(obj)); const getAllExperimentsFromDatafile = (datafile: ProjectConfig) => { const allExperiments: Experiment[] = []; datafile.groups.forEach(group => { @@ -57,15 +56,15 @@ describe('Optimizely Config', () => { const logger = getMockLogger(); beforeEach(() => { - projectConfigObject = createProjectConfig(cloneDeep(datafile as any)); + projectConfigObject = createProjectConfig(JSON.stringify(datafile)); optimizelyConfigObject = createOptimizelyConfig(projectConfigObject, JSON.stringify(datafile)); - projectTypedAudienceConfigObject = createProjectConfig(cloneDeep(typedAudienceDatafile)); - projectSimilarRuleKeyConfigObject = createProjectConfig(cloneDeep(similarRuleKeyDatafile)); + projectTypedAudienceConfigObject = createProjectConfig(JSON.stringify(typedAudienceDatafile)); + projectSimilarRuleKeyConfigObject = createProjectConfig(JSON.stringify(similarRuleKeyDatafile)); optimizelySimilarRuleKeyConfigObject = createOptimizelyConfig( projectSimilarRuleKeyConfigObject, JSON.stringify(similarRuleKeyDatafile) ); - projectSimilarExperimentKeyConfigObject = createProjectConfig(cloneDeep(similarExperimentKeyDatafile)); + projectSimilarExperimentKeyConfigObject = createProjectConfig(JSON.stringify(similarExperimentKeyDatafile)); optimizelySimilarExperimentkeyConfigObject = createOptimizelyConfig( projectSimilarExperimentKeyConfigObject, JSON.stringify(similarExperimentKeyDatafile) @@ -99,7 +98,7 @@ describe('Optimizely Config', () => { it('should keep the last experiment in case of duplicate key and log a warning', () => { const datafile = getDuplicateExperimentKeyConfig(); - const configObj = createProjectConfig(datafile, JSON.stringify(datafile)); + const configObj = createProjectConfig(JSON.stringify(datafile)); const optimizelyConfig = createOptimizelyConfig(configObj, JSON.stringify(datafile), logger); const experimentsMap = optimizelyConfig.experimentsMap; const duplicateKey = 'experiment_rule'; diff --git a/lib/project_config/optimizely_config.tests.js b/lib/project_config/optimizely_config.tests.js index 22d2b95f3..244fb632f 100644 --- a/lib/project_config/optimizely_config.tests.js +++ b/lib/project_config/optimizely_config.tests.js @@ -14,7 +14,6 @@ * limitations under the License. */ import { assert } from 'chai'; -import { cloneDeep } from 'lodash'; import sinon from 'sinon'; import { createOptimizelyConfig, OptimizelyConfig } from './optimizely_config'; @@ -57,13 +56,13 @@ describe('lib/core/optimizely_config', function() { var projectSimilarExperimentKeyConfigObject; beforeEach(function() { - projectConfigObject = createProjectConfig(cloneDeep(datafile)); + projectConfigObject = createProjectConfig(JSON.stringify(datafile)); optimizelyConfigObject = createOptimizelyConfig(projectConfigObject, JSON.stringify(datafile)); - projectTypedAudienceConfigObject = createProjectConfig(cloneDeep(typedAudienceDatafile)); + projectTypedAudienceConfigObject = createProjectConfig(JSON.stringify(typedAudienceDatafile)); optimizelyTypedAudienceConfigObject = createOptimizelyConfig(projectTypedAudienceConfigObject, JSON.stringify(typedAudienceDatafile)); - projectSimilarRuleKeyConfigObject = createProjectConfig(cloneDeep(similarRuleKeyDatafile)); + projectSimilarRuleKeyConfigObject = createProjectConfig(JSON.stringify(similarRuleKeyDatafile)); optimizelySimilarRuleKeyConfigObject = createOptimizelyConfig(projectSimilarRuleKeyConfigObject, JSON.stringify(similarRuleKeyDatafile)); - projectSimilarExperimentKeyConfigObject = createProjectConfig(cloneDeep(similarExperimentKeyDatafile)); + projectSimilarExperimentKeyConfigObject = createProjectConfig(JSON.stringify(similarExperimentKeyDatafile)); optimizelySimilarExperimentkeyConfigObject = createOptimizelyConfig(projectSimilarExperimentKeyConfigObject, JSON.stringify(similarExperimentKeyDatafile)); }); @@ -90,7 +89,7 @@ describe('lib/core/optimizely_config', function() { it('should keep the last experiment in case of duplicate key and log a warning', function() { const datafile = getDuplicateExperimentKeyConfig(); - const configObj = createProjectConfig(datafile, JSON.stringify(datafile)); + const configObj = createProjectConfig(JSON.stringify(datafile)); const logger = { warn: sinon.spy(), diff --git a/lib/project_config/project_config.spec.ts b/lib/project_config/project_config.spec.ts index ad288c515..b4521267b 100644 --- a/lib/project_config/project_config.spec.ts +++ b/lib/project_config/project_config.spec.ts @@ -21,16 +21,16 @@ import { UNEXPECTED_RESERVED_ATTRIBUTE_PREFIX, UNRECOGNIZED_ATTRIBUTE, VARIABLE_KEY_NOT_IN_DATAFILE, + LOCAL_HOLDOUT_MISSING_INCLUDED_RULES, } from 'error_message'; import { Mock, afterAll, afterEach, assert, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { OptimizelyError } from '../error/optimizly_error'; import { VariableType } from '../shared_types'; import { getMockLogger } from '../tests/mock/mock_logger'; import testDatafile from '../tests/test_data'; -import configValidator from '../utils/config_validator'; import { FEATURE_VARIABLE_TYPES } from '../utils/enums'; import { keyBy, sprintf } from '../utils/fns'; -import projectConfig, { ProjectConfig, getHoldoutsForFlag } from './project_config'; +import projectConfig, { ProjectConfig, getGlobalHoldouts, getHoldoutsForRule } from './project_config'; const cloneDeep = (obj: any) => JSON.parse(JSON.stringify(obj)); const logger = getMockLogger(); @@ -41,7 +41,7 @@ describe('createProjectConfig', () => { it('should use US region when no region is specified in datafile', () => { const datafile = testDatafile.getTestProjectConfig(); - const config = projectConfig.createProjectConfig(datafile); + const config = projectConfig.createProjectConfig(JSON.stringify(datafile)); expect(config.region).toBe('US'); }); @@ -50,19 +50,19 @@ describe('createProjectConfig', () => { const datafileUs = testDatafile.getTestProjectConfig(); datafileUs.region = 'US'; - const configUs = projectConfig.createProjectConfig(datafileUs); + const configUs = projectConfig.createProjectConfig(JSON.stringify(datafileUs)); expect(configUs.region).toBe('US'); const datafileEu = testDatafile.getTestProjectConfig(); datafileEu.region = 'EU'; - const configEu = projectConfig.createProjectConfig(datafileEu); + const configEu = projectConfig.createProjectConfig(JSON.stringify(datafileEu)); expect(configEu.region).toBe('EU'); }); it('should set properties correctly when createProjectConfig is called', () => { const testData: Record = testDatafile.getTestProjectConfig(); - configObj = projectConfig.createProjectConfig(testData as JSON); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); testData.audiences.forEach((audience: any) => { audience.conditions = JSON.parse(audience.conditions); @@ -153,21 +153,13 @@ describe('createProjectConfig', () => { expect(configObj.experimentIdMap).toEqual(expectedExperimentIdMap); }); - - it('should not mutate the datafile', () => { - const datafile = testDatafile.getTypedAudiencesConfig(); - const datafileClone = cloneDeep(datafile); - projectConfig.createProjectConfig(datafile as any); - - expect(datafile).toEqual(datafileClone); - }); }); describe('createProjectConfig - feature management', () => { let configObj: ProjectConfig; beforeEach(() => { - configObj = projectConfig.createProjectConfig(testDatafile.getTestProjectConfigWithFeatures()); + configObj = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getTestProjectConfigWithFeatures())); }); it('should create a rolloutIdMap from rollouts in the datafile', () => { @@ -241,7 +233,7 @@ describe('createProjectConfig - flag variations', () => { let configObj: ProjectConfig; beforeEach(() => { - configObj = projectConfig.createProjectConfig(testDatafile.getTestDecideProjectConfig()); + configObj = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getTestDecideProjectConfig())); }); it('should populate flagVariationsMap correctly', function() { @@ -259,8 +251,8 @@ describe('createProjectConfig - flag variations', () => { return variation.key; }, {}); - expect(feature1VariationsKeys).toEqual(['a', 'b', '3324490633', '3324490562', '18257766532']); - expect(feature2VariationsKeys).toEqual(['variation_with_traffic', 'variation_no_traffic']); + expect(feature1VariationsKeys.sort()).toEqual(['18257766532', '3324490562', '3324490633', 'a', 'b']); + expect(feature2VariationsKeys.sort()).toEqual(['variation_no_traffic', 'variation_with_traffic']); expect(feature3VariationsKeys).toEqual([]); }); }); @@ -278,7 +270,7 @@ describe('createProjectConfig - cmab experiments', () => { trafficAllocation: 1414, }; - const configObj = projectConfig.createProjectConfig(datafile); + const configObj = projectConfig.createProjectConfig(JSON.stringify(datafile)); const experiment0 = configObj.experiments[0]; expect(experiment0.cmab).toEqual({ @@ -329,7 +321,7 @@ const getHoldoutDatafile = () => { key: 'holdout_2', status: 'Running', includedFlags: [], - excludedFlags: ['44829230000'], + excludedFlags: [], audienceIds: [], audienceConditions: [], variations: [ @@ -350,7 +342,7 @@ const getHoldoutDatafile = () => { id: 'holdout_id_3', key: 'holdout_3', status: 'Draft', - includedFlags: ['4482920077'], + includedFlags: [], excludedFlags: [], audienceIds: [], audienceConditions: [], @@ -377,7 +369,7 @@ describe('createProjectConfig - holdouts', () => { it('should populate holdouts fields correctly', function() { const datafile = getHoldoutDatafile(); - const configObj = projectConfig.createProjectConfig(JSON.parse(JSON.stringify(datafile))); + const configObj = projectConfig.createProjectConfig(JSON.stringify(datafile)); expect(configObj.holdouts).toHaveLength(3); configObj.holdouts.forEach((holdout, i) => { @@ -392,35 +384,15 @@ describe('createProjectConfig - holdouts', () => { holdout_id_2: configObj.holdouts[1], holdout_id_3: configObj.holdouts[2], }); - - expect(configObj.globalHoldouts).toHaveLength(2); - expect(configObj.globalHoldouts).toEqual([ - configObj.holdouts[0], // holdout_1 has empty includedFlags - configObj.holdouts[1] // holdout_2 has empty includedFlags - ]); - - expect(configObj.includedHoldouts).toEqual({ - feature_1: [configObj.holdouts[2]], // holdout_3 includes feature_1 (ID: 4482920077) - }); - - expect(configObj.excludedHoldouts).toEqual({ - feature_3: [configObj.holdouts[1]] // holdout_2 excludes feature_3 (ID: 44829230000) - }); - - expect(configObj.flagHoldoutsMap).toEqual({}); }); it('should handle empty holdouts array', function() { const datafile = testDatafile.getTestProjectConfig(); - const configObj = projectConfig.createProjectConfig(datafile); + const configObj = projectConfig.createProjectConfig(JSON.stringify(datafile)); expect(configObj.holdouts).toEqual([]); expect(configObj.holdoutIdMap).toEqual({}); - expect(configObj.globalHoldouts).toEqual([]); - expect(configObj.includedHoldouts).toEqual({}); - expect(configObj.excludedHoldouts).toEqual({}); - expect(configObj.flagHoldoutsMap).toEqual({}); }); it('should handle undefined includedFlags and excludedFlags in holdout', function() { @@ -428,7 +400,7 @@ describe('createProjectConfig - holdouts', () => { datafile.holdouts[0].includedFlags = undefined; datafile.holdouts[0].excludedFlags = undefined; - const configObj = projectConfig.createProjectConfig(JSON.parse(JSON.stringify(datafile))); + const configObj = projectConfig.createProjectConfig(JSON.stringify(datafile)); expect(configObj.holdouts).toHaveLength(3); expect(configObj.holdouts[0].includedFlags).toEqual([]); @@ -436,31 +408,353 @@ describe('createProjectConfig - holdouts', () => { }); }); -describe('getHoldoutsForFlag', () => { - it('should return all applicable holdouts for a flag', () => { - const datafile = getHoldoutDatafile(); - const configObj = projectConfig.createProjectConfig(JSON.parse(JSON.stringify(datafile))); - - const feature1Holdouts = getHoldoutsForFlag(configObj, 'feature_1'); - expect(feature1Holdouts).toHaveLength(3); - expect(feature1Holdouts).toEqual([ - configObj.holdouts[0], - configObj.holdouts[1], - configObj.holdouts[2], - ]); +// Level 1 tests for local holdouts (FSSDK-12369, FSSDK-12760). +// Section membership (`holdouts` vs `localHoldouts`) is the sole signal for scope. +describe('createProjectConfig - local holdouts (FSSDK-12369, FSSDK-12760)', () => { + const makeHoldoutsDatafile = () => { + const datafile = testDatafile.getTestDecideProjectConfig(); + // Entries in `holdouts` are global by section membership. + (datafile as any).holdouts = [ + { + id: 'global_holdout_id', + key: 'global_holdout', + status: 'Running', + includedFlags: [], + excludedFlags: [], + audienceIds: [], + audienceConditions: [], + variations: [{ id: 'global_var_id', key: 'global_var', variables: [] }], + trafficAllocation: [{ entityId: 'global_var_id', endOfRange: 5000 }], + }, + ]; + // Entries in `localHoldouts` are local; scoped via includedRules. + (datafile as any).localHoldouts = [ + { + id: 'local_holdout_rule_a_id', + key: 'local_holdout_rule_a', + status: 'Running', + includedFlags: [], + excludedFlags: [], + audienceIds: [], + audienceConditions: [], + includedRules: ['rule_a', 'rule_b'], + variations: [{ id: 'local_var_id', key: 'local_var', variables: [] }], + trafficAllocation: [{ entityId: 'local_var_id', endOfRange: 5000 }], + }, + ]; + return datafile; + }; + + it('should set isGlobal=true for entries in the holdouts section (backward compat with old datafiles)', () => { + const config = projectConfig.createProjectConfig(JSON.stringify(makeHoldoutsDatafile())); + const holdout = config.holdoutIdMap!['global_holdout_id']; + expect(holdout.isGlobal).toBe(true); + }); + + it('should set isGlobal=false for entries in the localHoldouts section', () => { + const config = projectConfig.createProjectConfig(JSON.stringify(makeHoldoutsDatafile())); + const holdout = config.holdoutIdMap!['local_holdout_rule_a_id']; + expect(holdout.isGlobal).toBe(false); + }); + + it('getGlobalHoldouts should return only entries from the holdouts section', () => { + const config = projectConfig.createProjectConfig(JSON.stringify(makeHoldoutsDatafile())); + const globals = getGlobalHoldouts(config); + const globalIds = globals.map(h => h.id); + expect(globalIds).toContain('global_holdout_id'); + expect(globalIds).not.toContain('local_holdout_rule_a_id'); + }); + + it('getHoldoutsForRule should return local holdouts targeting the given rule ID', () => { + const config = projectConfig.createProjectConfig(JSON.stringify(makeHoldoutsDatafile())); + const forRuleA = getHoldoutsForRule(config, 'rule_a'); + expect(forRuleA).toHaveLength(1); + expect(forRuleA[0].id).toBe('local_holdout_rule_a_id'); + + const forRuleB = getHoldoutsForRule(config, 'rule_b'); + expect(forRuleB).toHaveLength(1); + expect(forRuleB[0].id).toBe('local_holdout_rule_a_id'); + }); + + it('getHoldoutsForRule should only return holdouts targeting that rule and exclude others', () => { + const datafile = cloneDeep(makeHoldoutsDatafile()); + (datafile as any).localHoldouts = [ + { + id: 'holdout_for_rule_x', + key: 'holdout_rule_x', + status: 'Running', + includedFlags: [], + excludedFlags: [], + audienceIds: [], + audienceConditions: [], + includedRules: ['rule_x'], + variations: [{ id: 'var_x', key: 'var_x', variables: [] }], + trafficAllocation: [{ entityId: 'var_x', endOfRange: 5000 }], + }, + { + id: 'holdout_for_rule_y', + key: 'holdout_rule_y', + status: 'Running', + includedFlags: [], + excludedFlags: [], + audienceIds: [], + audienceConditions: [], + includedRules: ['rule_y'], + variations: [{ id: 'var_y', key: 'var_y', variables: [] }], + trafficAllocation: [{ entityId: 'var_y', endOfRange: 5000 }], + }, + { + id: 'holdout_for_both', + key: 'holdout_both', + status: 'Running', + includedFlags: [], + excludedFlags: [], + audienceIds: [], + audienceConditions: [], + includedRules: ['rule_x', 'rule_y'], + variations: [{ id: 'var_both', key: 'var_both', variables: [] }], + trafficAllocation: [{ entityId: 'var_both', endOfRange: 5000 }], + }, + ]; + const config = projectConfig.createProjectConfig(JSON.stringify(datafile)); + + const forRuleX = getHoldoutsForRule(config, 'rule_x'); + const forRuleXIds = forRuleX.map(h => h.id).sort(); + expect(forRuleXIds).toEqual(['holdout_for_both', 'holdout_for_rule_x']); + + const forRuleY = getHoldoutsForRule(config, 'rule_y'); + const forRuleYIds = forRuleY.map(h => h.id).sort(); + expect(forRuleYIds).toEqual(['holdout_for_both', 'holdout_for_rule_y']); + + // rule_x results must not contain holdout_for_rule_y and vice versa + expect(forRuleXIds).not.toContain('holdout_for_rule_y'); + expect(forRuleYIds).not.toContain('holdout_for_rule_x'); + }); + + it('getHoldoutsForRule should not return global holdouts', () => { + const datafile = cloneDeep(makeHoldoutsDatafile()); + (datafile as any).localHoldouts = [ + { + id: 'local_only', + key: 'local_only', + status: 'Running', + includedFlags: [], + excludedFlags: [], + audienceIds: [], + audienceConditions: [], + includedRules: ['rule_z'], + variations: [{ id: 'var_local', key: 'var_local', variables: [] }], + trafficAllocation: [{ entityId: 'var_local', endOfRange: 5000 }], + }, + ]; + const config = projectConfig.createProjectConfig(JSON.stringify(datafile)); + + const forRuleZ = getHoldoutsForRule(config, 'rule_z'); + expect(forRuleZ).toHaveLength(1); + expect(forRuleZ[0].id).toBe('local_only'); + // global holdout must never appear in rule lookups + const allRuleHoldoutIds = forRuleZ.map(h => h.id); + expect(allRuleHoldoutIds).not.toContain('global_holdout_id'); + }); + + it('getHoldoutsForRule should return empty array for an unknown rule ID', () => { + const config = projectConfig.createProjectConfig(JSON.stringify(makeHoldoutsDatafile())); + const forUnknown = getHoldoutsForRule(config, 'nonexistent_rule'); + expect(forUnknown).toHaveLength(0); + }); + + it('getGlobalHoldouts should return empty array when only the localHoldouts section is populated', () => { + const datafile = cloneDeep(makeHoldoutsDatafile()); + (datafile as any).holdouts = []; + (datafile as any).localHoldouts = [ + { + id: 'only_local_id', + key: 'only_local', + status: 'Running', + includedFlags: [], + excludedFlags: [], + audienceIds: [], + audienceConditions: [], + includedRules: ['some_rule'], + variations: [{ id: 'only_local_var_id', key: 'only_local_var', variables: [] }], + trafficAllocation: [{ entityId: 'only_local_var_id', endOfRange: 5000 }], + }, + ]; + const config = projectConfig.createProjectConfig(JSON.stringify(datafile)); + expect(getGlobalHoldouts(config)).toHaveLength(0); + }); - const feature2Holdouts = getHoldoutsForFlag(configObj, 'feature_2'); - expect(feature2Holdouts).toHaveLength(2); - expect(feature2Holdouts).toEqual([ - configObj.holdouts[0], - configObj.holdouts[1], - ]); + it('should handle datafile with no holdouts gracefully', () => { + const datafile = testDatafile.getTestProjectConfig(); + const config = projectConfig.createProjectConfig(JSON.stringify(datafile)); + expect(getGlobalHoldouts(config)).toHaveLength(0); + expect(getHoldoutsForRule(config, 'any_rule')).toHaveLength(0); + }); - const feature3Holdouts = getHoldoutsForFlag(configObj, 'feature_3'); - expect(feature3Holdouts).toHaveLength(1); - expect(feature3Holdouts).toEqual([ - configObj.holdouts[0], - ]); + it('a single local holdout targeting multiple rules should appear for each targeted rule', () => { + const config = projectConfig.createProjectConfig(JSON.stringify(makeHoldoutsDatafile())); + const forRuleA = getHoldoutsForRule(config, 'rule_a'); + const forRuleB = getHoldoutsForRule(config, 'rule_b'); + // Both rule_a and rule_b point to the same holdout + expect(forRuleA[0].id).toBe('local_holdout_rule_a_id'); + expect(forRuleB[0].id).toBe('local_holdout_rule_a_id'); + }); +}); + +// Level 1 tests for the FSSDK-12760 backward-compatible localHoldouts section design. +// Older SDKs (Gen 1/Gen 2) ignore the unknown `localHoldouts` top-level key entirely. +// Gen 3 SDKs (this one) treat section membership as the sole signal for scope. +describe('createProjectConfig - localHoldouts section (FSSDK-12760)', () => { + const makeBaseDatafile = () => testDatafile.getTestDecideProjectConfig(); + + const makeLocal = (id: string, key: string, includedRules: any) => ({ + id, + key, + status: 'Running', + includedFlags: [], + excludedFlags: [], + audienceIds: [], + audienceConditions: [], + includedRules, + variations: [{ id: `${id}_var`, key: 'holdout', variables: [] }], + trafficAllocation: [{ entityId: `${id}_var`, endOfRange: 10000 }], + }); + + const makeGlobal = (id: string, key: string, extra: Record = {}) => ({ + id, + key, + status: 'Running', + includedFlags: [], + excludedFlags: [], + audienceIds: [], + audienceConditions: [], + variations: [{ id: `${id}_var`, key: 'holdout', variables: [] }], + trafficAllocation: [{ entityId: `${id}_var`, endOfRange: 10000 }], + ...extra, + }); + + it('exposes localHoldouts as a top-level array on the project config', () => { + const datafile = cloneDeep(makeBaseDatafile()) as any; + datafile.holdouts = []; + datafile.localHoldouts = [makeLocal('l1', 'local_h', ['rule_x'])]; + const config = projectConfig.createProjectConfig(JSON.stringify(datafile)); + expect(Array.isArray(config.localHoldouts)).toBe(true); + expect(config.localHoldouts).toHaveLength(1); + expect(config.localHoldouts[0].id).toBe('l1'); + }); + + it('defaults localHoldouts to an empty array when the section is absent (backward compat)', () => { + const datafile = cloneDeep(makeBaseDatafile()) as any; + datafile.holdouts = [makeGlobal('g1', 'global_h')]; + // No localHoldouts key at all + const config = projectConfig.createProjectConfig(JSON.stringify(datafile)); + expect(config.localHoldouts).toEqual([]); + expect(getGlobalHoldouts(config).map(h => h.id)).toContain('g1'); + expect(getHoldoutsForRule(config, 'any_rule')).toEqual([]); + }); + + it('ignores includedRules on entries in the global holdouts section', () => { + // An includedRules field on a `holdouts` entry must NOT narrow its scope — + // section membership is the sole signal for scope. + const datafile = cloneDeep(makeBaseDatafile()) as any; + datafile.holdouts = [ + makeGlobal('stray', 'stray_global', { includedRules: ['rule_should_be_ignored'] }), + ]; + datafile.localHoldouts = []; + const config = projectConfig.createProjectConfig(JSON.stringify(datafile)); + + const stray = config.holdoutIdMap!['stray']; + // includedRules must be stripped at parse time + expect(stray.includedRules).toBeUndefined(); + // Entity is global + expect(stray.isGlobal).toBe(true); + // It appears in global list and NOT in the rule map + expect(getGlobalHoldouts(config).map(h => h.id)).toContain('stray'); + expect(getHoldoutsForRule(config, 'rule_should_be_ignored')).toEqual([]); + }); + + it('does not mutate the caller-provided datafile when stripping includedRules', () => { + // Regression guard: parsing a datafile must not bleed mutations into the + // caller's reference (some hosts re-use the same datafile object). + const datafile = cloneDeep(makeBaseDatafile()) as any; + datafile.holdouts = [ + makeGlobal('stray', 'stray_global', { includedRules: ['rule_x'] }), + ]; + projectConfig.createProjectConfig(JSON.stringify(datafile)); + // Original datafile still has includedRules on the entry + expect(datafile.holdouts[0].includedRules).toEqual(['rule_x']); + }); + + it('partitions both sections correctly when both are present', () => { + const datafile = cloneDeep(makeBaseDatafile()) as any; + datafile.holdouts = [makeGlobal('g1', 'g'), makeGlobal('g2', 'g2')]; + datafile.localHoldouts = [ + makeLocal('l1', 'l1', ['rule_a']), + makeLocal('l2', 'l2', ['rule_b']), + ]; + const config = projectConfig.createProjectConfig(JSON.stringify(datafile)); + + expect(getGlobalHoldouts(config).map(h => h.id).sort()).toEqual(['g1', 'g2']); + expect(getHoldoutsForRule(config, 'rule_a').map(h => h.id)).toEqual(['l1']); + expect(getHoldoutsForRule(config, 'rule_b').map(h => h.id)).toEqual(['l2']); + // ID map covers both sections + expect(config.holdoutIdMap!['g1']).toBeDefined(); + expect(config.holdoutIdMap!['l1']).toBeDefined(); + }); + + it('logs an error and excludes localHoldouts entries with no includedRules', () => { + const datafile = cloneDeep(makeBaseDatafile()) as any; + datafile.holdouts = []; + // Invalid: no includedRules at all + const invalid: any = makeLocal('bad', 'invalid_local', undefined); + delete invalid.includedRules; + datafile.localHoldouts = [invalid]; + + const config = projectConfig.createProjectConfig(JSON.stringify(datafile), { logger }); + + expect(getGlobalHoldouts(config)).toEqual([]); + expect(getHoldoutsForRule(config, 'any_rule')).toEqual([]); + expect(config.holdoutIdMap!['bad']).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + LOCAL_HOLDOUT_MISSING_INCLUDED_RULES, 'invalid_local' + ); + }); + + it('logs an error and excludes localHoldouts entries with null includedRules', () => { + const datafile = cloneDeep(makeBaseDatafile()) as any; + datafile.holdouts = []; + datafile.localHoldouts = [makeLocal('bad_null', 'null_local', null)]; + + const config = projectConfig.createProjectConfig(JSON.stringify(datafile), { logger }); + + expect(getHoldoutsForRule(config, 'any_rule')).toEqual([]); + expect(config.holdoutIdMap!['bad_null']).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + LOCAL_HOLDOUT_MISSING_INCLUDED_RULES, 'null_local' + ); + }); + + it('logs an error and excludes localHoldouts entries with empty includedRules', () => { + const datafile = cloneDeep(makeBaseDatafile()) as any; + datafile.holdouts = []; + datafile.localHoldouts = [makeLocal('bad_empty', 'empty_local', [])]; + + const config = projectConfig.createProjectConfig(JSON.stringify(datafile), { logger }); + + expect(getHoldoutsForRule(config, 'any_rule')).toEqual([]); + expect(config.holdoutIdMap!['bad_empty']).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + LOCAL_HOLDOUT_MISSING_INCLUDED_RULES, 'empty_local' + ); + }); + + it('keeps holdout variations in the variationIdMap from both sections', () => { + const datafile = cloneDeep(makeBaseDatafile()) as any; + datafile.holdouts = [makeGlobal('g1', 'g')]; + datafile.localHoldouts = [makeLocal('l1', 'l', ['rule_x'])]; + const config = projectConfig.createProjectConfig(JSON.stringify(datafile)); + expect(config.variationIdMap['g1_var']).toBeDefined(); + expect(config.variationIdMap['l1_var']).toBeDefined(); }); }); @@ -471,7 +765,7 @@ describe('getExperimentId', () => { beforeEach(function() { testData = cloneDeep(testDatafile.getTestProjectConfig()); - configObj = projectConfig.createProjectConfig(cloneDeep(testData) as JSON); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); createdLogger = getMockLogger(); }); @@ -497,7 +791,7 @@ describe('getLayerId', () => { beforeEach(function() { testData = cloneDeep(testDatafile.getTestProjectConfig()); - configObj = projectConfig.createProjectConfig(cloneDeep(testData) as JSON); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); }); it('should retrieve layer ID for valid experiment key in getLayerId', function() { @@ -521,7 +815,7 @@ describe('getAttributeId', () => { beforeEach(function() { testData = cloneDeep(testDatafile.getTestProjectConfig()); - configObj = projectConfig.createProjectConfig(cloneDeep(testData) as JSON); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); createdLogger = getMockLogger(); }); @@ -559,7 +853,7 @@ describe('getEventId', () => { beforeEach(function() { testData = cloneDeep(testDatafile.getTestProjectConfig()); - configObj = projectConfig.createProjectConfig(cloneDeep(testData) as JSON); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); }); it('should retrieve event ID for valid event key in getEventId', function() { @@ -577,7 +871,7 @@ describe('getExperimentStatus', () => { beforeEach(function() { testData = cloneDeep(testDatafile.getTestProjectConfig()); - configObj = projectConfig.createProjectConfig(cloneDeep(testData) as JSON); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); }); it('should retrieve experiment status for valid experiment key in getExperimentStatus', function() { @@ -599,7 +893,7 @@ describe('isActive', () => { beforeEach(function() { testData = cloneDeep(testDatafile.getTestProjectConfig()); - configObj = projectConfig.createProjectConfig(cloneDeep(testData) as JSON); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); }); it('should return true if experiment status is set to Running in isActive', function() { @@ -617,7 +911,7 @@ describe('isRunning', () => { beforeEach(() => { testData = cloneDeep(testDatafile.getTestProjectConfig()); - configObj = projectConfig.createProjectConfig(cloneDeep(testData) as JSON); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); }); it('should return true if experiment status is set to Running in isRunning', function() { @@ -635,7 +929,7 @@ describe('getVariationKeyFromId', () => { beforeEach(function() { testData = cloneDeep(testDatafile.getTestProjectConfig()); - configObj = projectConfig.createProjectConfig(cloneDeep(testData) as JSON); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); }); it('should retrieve variation key for valid experiment key and variation ID in getVariationKeyFromId', function() { expect(projectConfig.getVariationKeyFromId(configObj, testData.experiments[0].variations[0].id)).toBe( @@ -650,7 +944,7 @@ describe('getTrafficAllocation', () => { beforeEach(function() { testData = cloneDeep(testDatafile.getTestProjectConfig()); - configObj = projectConfig.createProjectConfig(cloneDeep(testData) as JSON); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); }); it('should retrieve traffic allocation given valid experiment key in getTrafficAllocation', function() { @@ -677,7 +971,7 @@ describe('getVariationIdFromExperimentAndVariationKey', () => { beforeEach(function() { testData = cloneDeep(testDatafile.getTestProjectConfig()); - configObj = projectConfig.createProjectConfig(cloneDeep(testData) as JSON); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); }); it('should return the variation id for the given experiment key and variation key', () => { @@ -697,7 +991,7 @@ describe('getSendFlagDecisionsValue', () => { beforeEach(function() { testData = cloneDeep(testDatafile.getTestProjectConfig()); - configObj = projectConfig.createProjectConfig(cloneDeep(testData) as JSON); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); }); it('should return false when sendFlagDecisions is undefined', () => { @@ -725,7 +1019,7 @@ describe('getVariableForFeature', function() { beforeEach(() => { featureManagementLogger = getMockLogger(); - configObj = projectConfig.createProjectConfig(testDatafile.getTestProjectConfigWithFeatures()); + configObj = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getTestProjectConfigWithFeatures())); }); afterEach(() => { @@ -786,7 +1080,7 @@ describe('getVariableValueForVariation', () => { beforeEach(() => { featureManagementLogger = getMockLogger(); - configObj = projectConfig.createProjectConfig(testDatafile.getTestProjectConfigWithFeatures()); + configObj = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getTestProjectConfigWithFeatures())); }); afterEach(() => { @@ -874,7 +1168,7 @@ describe('getTypeCastValue', () => { beforeEach(() => { featureManagementLogger = getMockLogger(); - configObj = projectConfig.createProjectConfig(testDatafile.getTestProjectConfigWithFeatures()); + configObj = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getTestProjectConfigWithFeatures())); }); afterEach(() => { @@ -1007,7 +1301,7 @@ describe('getAudiencesById', () => { let configObj: ProjectConfig; beforeEach(() => { - configObj = projectConfig.createProjectConfig(testDatafile.getTypedAudiencesConfig()); + configObj = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getTypedAudiencesConfig())); }); it('should retrieve audiences by checking first in typedAudiences, and then second in audiences', () => { @@ -1024,13 +1318,13 @@ describe('getExperimentAudienceConditions', () => { }); it('should retrieve audiences for valid experiment key', () => { - configObj = projectConfig.createProjectConfig(cloneDeep(testData) as JSON); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); expect(projectConfig.getExperimentAudienceConditions(configObj, testData.experiments[1].id)).toEqual(['11154']); }); it('should throw error for invalid experiment key', () => { - configObj = projectConfig.createProjectConfig(cloneDeep(testData) as JSON); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); expect(() => { projectConfig.getExperimentAudienceConditions(configObj, 'invalidExperimentId'); @@ -1043,7 +1337,7 @@ describe('getExperimentAudienceConditions', () => { }); it('should return experiment audienceIds if experiment has no audienceConditions', () => { - configObj = projectConfig.createProjectConfig(testDatafile.getTypedAudiencesConfig()); + configObj = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getTypedAudiencesConfig())); const result = projectConfig.getExperimentAudienceConditions(configObj, '11564051718'); expect(result).toEqual([ @@ -1058,7 +1352,7 @@ describe('getExperimentAudienceConditions', () => { }); it('should return experiment audienceConditions if experiment has audienceConditions', () => { - configObj = projectConfig.createProjectConfig(testDatafile.getTypedAudiencesConfig()); + configObj = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getTypedAudiencesConfig())); // audience_combinations_experiment has both audienceConditions and audienceIds // audienceConditions should be preferred over audienceIds const result = projectConfig.getExperimentAudienceConditions(configObj, '1323241598'); @@ -1073,21 +1367,21 @@ describe('getExperimentAudienceConditions', () => { describe('isFeatureExperiment', () => { it('should return true for a feature test', () => { - const config = projectConfig.createProjectConfig(testDatafile.getTestProjectConfigWithFeatures()); + const config = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getTestProjectConfigWithFeatures())); const result = projectConfig.isFeatureExperiment(config, '594098'); // id of 'testing_my_feature' expect(result).toBe(true); }); it('should return false for an A/B test', () => { - const config = projectConfig.createProjectConfig(testDatafile.getTestProjectConfig()); + const config = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getTestProjectConfig())); const result = projectConfig.isFeatureExperiment(config, '111127'); // id of 'testExperiment' expect(result).toBe(false); }); it('should return true for a feature test in a mutex group', () => { - const config = projectConfig.createProjectConfig(testDatafile.getMutexFeatureTestsConfig()); + const config = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getMutexFeatureTestsConfig())); let result = projectConfig.isFeatureExperiment(config, '17128410791'); // id of 'f_test1' expect(result).toBe(true); @@ -1154,7 +1448,7 @@ describe('integrations: with segments', () => { let configObj: ProjectConfig; beforeEach(() => { - configObj = projectConfig.createProjectConfig(testDatafile.getOdpIntegratedConfigWithSegments()); + configObj = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getOdpIntegratedConfigWithSegments())); }); it('should convert integrations from the datafile into the project config', () => { @@ -1181,7 +1475,7 @@ describe('integrations: with segments', () => { describe('integrations: without segments', () => { let config: ProjectConfig; beforeEach(() => { - config = projectConfig.createProjectConfig(testDatafile.getOdpIntegratedConfigWithoutSegments()); + config = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getOdpIntegratedConfigWithoutSegments())); }); it('should convert integrations from the datafile into the project config', () => { @@ -1205,7 +1499,7 @@ describe('without valid integration key', () => { it('should throw an error when parsing the project config due to integrations not containing a key', () => { const odpIntegratedConfigWithoutKey = testDatafile.getOdpIntegratedConfigWithoutKey(); - expect(() => projectConfig.createProjectConfig(odpIntegratedConfigWithoutKey)).toThrowError(OptimizelyError); + expect(() => projectConfig.createProjectConfig(JSON.stringify(odpIntegratedConfigWithoutKey))).toThrowError(OptimizelyError); }); }); @@ -1215,7 +1509,7 @@ describe('without integrations', () => { beforeEach(() => { const odpIntegratedConfigWithSegments = testDatafile.getOdpIntegratedConfigWithSegments(); const noIntegrationsConfigWithSegments = { ...odpIntegratedConfigWithSegments, integrations: [] }; - config = projectConfig.createProjectConfig(noIntegrationsConfigWithSegments); + config = projectConfig.createProjectConfig(JSON.stringify(noIntegrationsConfigWithSegments)); }); it('should convert integrations from the datafile into the project config', () => { @@ -1230,25 +1524,23 @@ describe('without integrations', () => { }); }); -describe('tryCreatingProjectConfig', () => { +describe('createProjectConfig with options', () => { let mockJsonSchemaValidator: Mock; beforeEach(() => { mockJsonSchemaValidator = vi.fn().mockReturnValue(true); - vi.spyOn(configValidator, 'validateDatafile').mockReturnValue(true); }); afterEach(() => { vi.restoreAllMocks(); }); - it('should return a project config object created by createProjectConfig when all validation is applied and there are no errors', () => { + it('should return a project config object when all validation is applied and there are no errors', () => { const configDatafile = { + version: '4', foo: 'bar', experiments: [{ key: 'a' }, { key: 'b' }], }; - vi.spyOn(configValidator, 'validateDatafile').mockReturnValueOnce(configDatafile); - const configObj = { foo: 'bar', experimentKeyMap: { @@ -1257,11 +1549,9 @@ describe('tryCreatingProjectConfig', () => { }, }; - // stubJsonSchemaValidator.returns(true); mockJsonSchemaValidator.mockReturnValueOnce(true); - const result = projectConfig.tryCreatingProjectConfig({ - datafile: configDatafile, + const result = projectConfig.createProjectConfig(JSON.stringify(configDatafile), { jsonSchemaValidator: mockJsonSchemaValidator, logger: logger, }); @@ -1269,15 +1559,11 @@ describe('tryCreatingProjectConfig', () => { expect(result).toMatchObject(configObj); }); - it('should throw an error when validateDatafile throws', function() { - vi.spyOn(configValidator, 'validateDatafile').mockImplementationOnce(() => { - throw new Error(); - }); + it('should throw an error when datafile is invalid JSON', function() { mockJsonSchemaValidator.mockReturnValueOnce(true); expect(() => - projectConfig.tryCreatingProjectConfig({ - datafile: { foo: 'bar' }, + projectConfig.createProjectConfig('invalid json', { jsonSchemaValidator: mockJsonSchemaValidator, logger: logger, }) @@ -1285,14 +1571,13 @@ describe('tryCreatingProjectConfig', () => { }); it('should throw an error when jsonSchemaValidator.validate throws', function() { - vi.spyOn(configValidator, 'validateDatafile').mockReturnValueOnce(true); + const configDatafile = { version: '4', foo: 'bar' }; mockJsonSchemaValidator.mockImplementationOnce(() => { throw new Error(); }); expect(() => - projectConfig.tryCreatingProjectConfig({ - datafile: { foo: 'bar' }, + projectConfig.createProjectConfig(JSON.stringify(configDatafile), { jsonSchemaValidator: mockJsonSchemaValidator, logger: logger, }) @@ -1301,12 +1586,11 @@ describe('tryCreatingProjectConfig', () => { it('should skip json validation when jsonSchemaValidator is not provided', function() { const configDatafile = { + version: '4', foo: 'bar', experiments: [{ key: 'a' }, { key: 'b' }], }; - vi.spyOn(configValidator, 'validateDatafile').mockReturnValueOnce(configDatafile); - const configObj = { foo: 'bar', experimentKeyMap: { @@ -1315,8 +1599,7 @@ describe('tryCreatingProjectConfig', () => { }, }; - const result = projectConfig.tryCreatingProjectConfig({ - datafile: configDatafile, + const result = projectConfig.createProjectConfig(JSON.stringify(configDatafile), { logger: logger, }); @@ -1324,3 +1607,163 @@ describe('tryCreatingProjectConfig', () => { expect(logger.error).not.toHaveBeenCalled(); }); }); + +describe('Feature Rollout support', () => { + const makeDatafile = (overrides: Record = {}) => { + const base: Record = { + version: '4', + revision: '1', + projectId: 'rollout_test', + accountId: '12345', + sdkKey: 'test-key', + environmentKey: 'production', + events: [], + audiences: [], + typedAudiences: [], + attributes: [], + groups: [], + integrations: [], + holdouts: [], + experiments: [ + { + id: 'exp_ab', + key: 'ab_experiment', + layerId: 'layer_ab', + status: 'Running', + variations: [{ id: 'var_ab_1', key: 'variation_ab_1', variables: [] }], + trafficAllocation: [{ entityId: 'var_ab_1', endOfRange: 10000 }], + audienceIds: [], + audienceConditions: [], + forcedVariations: {}, + }, + { + id: 'exp_rollout', + key: 'rollout_experiment', + layerId: 'layer_rollout', + status: 'Running', + type: 'fr', + variations: [{ id: 'var_rollout_1', key: 'variation_rollout_1', variables: [] }], + trafficAllocation: [{ entityId: 'var_rollout_1', endOfRange: 5000 }], + audienceIds: [], + audienceConditions: [], + forcedVariations: {}, + }, + ], + rollouts: [ + { + id: 'rollout_1', + experiments: [ + { + id: 'rollout_rule_1', + key: 'rollout_rule_1_key', + layerId: 'rollout_layer_1', + status: 'Running', + variations: [{ id: 'var_rr1', key: 'variation_rr1', variables: [] }], + trafficAllocation: [{ entityId: 'var_rr1', endOfRange: 10000 }], + audienceIds: [], + audienceConditions: [], + forcedVariations: {}, + }, + { + id: 'rollout_everyone_else', + key: 'rollout_everyone_else_key', + layerId: 'rollout_layer_ee', + status: 'Running', + variations: [{ id: 'var_ee', key: 'variation_everyone_else', variables: [] }], + trafficAllocation: [{ entityId: 'var_ee', endOfRange: 10000 }], + audienceIds: [], + audienceConditions: [], + forcedVariations: {}, + }, + ], + }, + ], + featureFlags: [ + { + id: 'feature_1', + key: 'feature_rollout_flag', + rolloutId: 'rollout_1', + experimentIds: ['exp_ab', 'exp_rollout'], + variables: [], + }, + ], + ...overrides, + }; + return base; + }; + + it('should preserve type=undefined for experiments without type field (backward compatibility)', () => { + const datafile = makeDatafile(); + const config = projectConfig.createProjectConfig(JSON.stringify(datafile)); + const abExperiment = config.experimentIdMap['exp_ab']; + expect(abExperiment.type).toBeUndefined(); + }); + + it('should inject everyone else variation into fr (feature rollout) experiments', () => { + const datafile = makeDatafile(); + const config = projectConfig.createProjectConfig(JSON.stringify(datafile)); + const rolloutExperiment = config.experimentIdMap['exp_rollout']; + + // Should have 2 variations: original + injected everyone else + expect(rolloutExperiment.variations).toHaveLength(2); + expect(rolloutExperiment.variations[1].id).toBe('var_ee'); + expect(rolloutExperiment.variations[1].key).toBe('variation_everyone_else'); + + // Should have injected traffic allocation entry + const lastAllocation = rolloutExperiment.trafficAllocation[rolloutExperiment.trafficAllocation.length - 1]; + expect(lastAllocation.entityId).toBe('var_ee'); + expect(lastAllocation.endOfRange).toBe(10000); + }); + + it('should update variation lookup maps with injected variation', () => { + const datafile = makeDatafile(); + const config = projectConfig.createProjectConfig(JSON.stringify(datafile)); + const rolloutExperiment = config.experimentIdMap['exp_rollout']; + + // variationKeyMap on the experiment should contain the injected variation + expect(rolloutExperiment.variationKeyMap['variation_everyone_else']).toBeDefined(); + expect(rolloutExperiment.variationKeyMap['variation_everyone_else'].id).toBe('var_ee'); + + // Global variationIdMap should contain the injected variation + expect(config.variationIdMap['var_ee']).toBeDefined(); + expect(config.variationIdMap['var_ee'].key).toBe('variation_everyone_else'); + }); + + it('should not modify non-rollout experiments (A/B, MAB, CMAB)', () => { + const datafile = makeDatafile(); + const config = projectConfig.createProjectConfig(JSON.stringify(datafile)); + const abExperiment = config.experimentIdMap['exp_ab']; + + // A/B experiment should still have only 1 variation + expect(abExperiment.variations).toHaveLength(1); + expect(abExperiment.variations[0].id).toBe('var_ab_1'); + expect(abExperiment.trafficAllocation).toHaveLength(1); + }); + + it('should silently skip injection when feature has no rolloutId', () => { + const datafile = makeDatafile({ + featureFlags: [ + { + id: 'feature_no_rollout', + key: 'feature_no_rollout', + rolloutId: '', + experimentIds: ['exp_rollout'], + variables: [], + }, + ], + }); + const config = projectConfig.createProjectConfig(JSON.stringify(datafile)); + const rolloutExperiment = config.experimentIdMap['exp_rollout']; + + // Should still have only 1 variation (no injection) + expect(rolloutExperiment.variations).toHaveLength(1); + expect(rolloutExperiment.variations[0].id).toBe('var_rollout_1'); + }); + + it('should correctly preserve experiment type field from datafile', () => { + const datafile = makeDatafile(); + const config = projectConfig.createProjectConfig(JSON.stringify(datafile)); + const rolloutExperiment = config.experimentIdMap['exp_rollout']; + expect(rolloutExperiment.type).toBe('fr'); + }); +}); diff --git a/lib/project_config/project_config.tests.js b/lib/project_config/project_config.tests.js index d69afda46..eebbce3a1 100644 --- a/lib/project_config/project_config.tests.js +++ b/lib/project_config/project_config.tests.js @@ -21,7 +21,6 @@ import fns from '../utils/fns'; import projectConfig from './project_config'; import { FEATURE_VARIABLE_TYPES, LOG_LEVEL } from '../utils/enums'; import testDatafile from '../tests/test_data'; -import configValidator from '../utils/config_validator'; import { INVALID_EXPERIMENT_ID, INVALID_EXPERIMENT_KEY, @@ -47,7 +46,7 @@ describe('lib/core/project_config', function() { describe('createProjectConfig method', function() { it('should set properties correctly when createProjectConfig is called', function() { var testData = testDatafile.getTestProjectConfig(); - var configObj = projectConfig.createProjectConfig(testData); + var configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); forEach(testData.audiences, function(audience) { audience.conditions = JSON.parse(audience.conditions); @@ -184,17 +183,10 @@ describe('lib/core/project_config', function() { }; }); - it('should not mutate the datafile', function() { - var datafile = testDatafile.getTypedAudiencesConfig(); - var datafileClone = cloneDeep(datafile); - projectConfig.createProjectConfig(datafile); - assert.deepEqual(datafileClone, datafile); - }); - describe('feature management', function() { var configObj; beforeEach(function() { - configObj = projectConfig.createProjectConfig(testDatafile.getTestProjectConfigWithFeatures()); + configObj = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getTestProjectConfigWithFeatures())); }); it('creates a rolloutIdMap from rollouts in the datafile', function() { @@ -265,7 +257,7 @@ describe('lib/core/project_config', function() { describe('flag variations', function() { var configObj; beforeEach(function() { - configObj = projectConfig.createProjectConfig(testDatafile.getTestDecideProjectConfig()); + configObj = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getTestDecideProjectConfig())); }); it('it should populate flagVariationsMap correctly', function() { @@ -283,8 +275,8 @@ describe('lib/core/project_config', function() { return variation.key; }, {}); - assert.deepEqual(feature1VariationsKeys, ['a', 'b', '3324490633', '3324490562', '18257766532']); - assert.deepEqual(feature2VariationsKeys, ['variation_with_traffic', 'variation_no_traffic']); + assert.deepEqual(feature1VariationsKeys.sort(), ['18257766532', '3324490562', '3324490633', 'a', 'b']); + assert.deepEqual(feature2VariationsKeys.sort(), ['variation_no_traffic', 'variation_with_traffic']); assert.deepEqual(feature3VariationsKeys, []); }); }); @@ -296,7 +288,7 @@ describe('lib/core/project_config', function() { var createdLogger = createLogger({ logLevel: LOG_LEVEL.INFO }); beforeEach(function() { - configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); sinon.stub(createdLogger, 'warn'); }); @@ -450,7 +442,7 @@ describe('lib/core/project_config', function() { describe('feature management', function() { var featureManagementLogger = createLogger({ logLevel: LOG_LEVEL.INFO }); beforeEach(function() { - configObj = projectConfig.createProjectConfig(testDatafile.getTestProjectConfigWithFeatures()); + configObj = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getTestProjectConfigWithFeatures())); sinon.stub(featureManagementLogger, 'warn'); sinon.stub(featureManagementLogger, 'error'); sinon.stub(featureManagementLogger, 'info'); @@ -676,7 +668,7 @@ describe('lib/core/project_config', function() { describe('#getAudiencesById', function() { beforeEach(function() { - configObj = projectConfig.createProjectConfig(testDatafile.getTypedAudiencesConfig()); + configObj = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getTypedAudiencesConfig())); }); it('should retrieve audiences by checking first in typedAudiences, and then second in audiences', function() { @@ -686,14 +678,14 @@ describe('lib/core/project_config', function() { describe('#getExperimentAudienceConditions', function() { it('should retrieve audiences for valid experiment key', function() { - configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); assert.deepEqual(projectConfig.getExperimentAudienceConditions(configObj, testData.experiments[1].id), [ '11154', ]); }); it('should throw error for invalid experiment key', function() { - configObj = projectConfig.createProjectConfig(cloneDeep(testData)); + configObj = projectConfig.createProjectConfig(JSON.stringify(testData)); const ex = assert.throws(function() { projectConfig.getExperimentAudienceConditions(configObj, 'invalidExperimentId'); }); @@ -702,7 +694,7 @@ describe('lib/core/project_config', function() { }); it('should return experiment audienceIds if experiment has no audienceConditions', function() { - configObj = projectConfig.createProjectConfig(testDatafile.getTypedAudiencesConfig()); + configObj = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getTypedAudiencesConfig())); var result = projectConfig.getExperimentAudienceConditions(configObj, '11564051718'); assert.deepEqual(result, [ '3468206642', @@ -716,7 +708,7 @@ describe('lib/core/project_config', function() { }); it('should return experiment audienceConditions if experiment has audienceConditions', function() { - configObj = projectConfig.createProjectConfig(testDatafile.getTypedAudiencesConfig()); + configObj = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getTypedAudiencesConfig())); // audience_combinations_experiment has both audienceConditions and audienceIds // audienceConditions should be preferred over audienceIds var result = projectConfig.getExperimentAudienceConditions(configObj, '1323241598'); @@ -730,19 +722,19 @@ describe('lib/core/project_config', function() { describe('#isFeatureExperiment', function() { it('returns true for a feature test', function() { - var config = projectConfig.createProjectConfig(testDatafile.getTestProjectConfigWithFeatures()); + var config = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getTestProjectConfigWithFeatures())); var result = projectConfig.isFeatureExperiment(config, '594098'); // id of 'testing_my_feature' assert.isTrue(result); }); it('returns false for an A/B test', function() { - var config = projectConfig.createProjectConfig(testDatafile.getTestProjectConfig()); + var config = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getTestProjectConfig())); var result = projectConfig.isFeatureExperiment(config, '111127'); // id of 'testExperiment' assert.isFalse(result); }); it('returns true for a feature test in a mutex group', function() { - var config = projectConfig.createProjectConfig(testDatafile.getMutexFeatureTestsConfig()); + var config = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getMutexFeatureTestsConfig())); var result = projectConfig.isFeatureExperiment(config, '17128410791'); // id of 'f_test1' assert.isTrue(result); result = projectConfig.isFeatureExperiment(config, '17139931304'); // id of 'f_test2' @@ -800,13 +792,13 @@ describe('lib/core/project_config', function() { }); it('returns false for an A/B test', function() { - var config = projectConfig.createProjectConfig(testDatafile.getTestProjectConfig()); + var config = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getTestProjectConfig())); var result = projectConfig.isFeatureExperiment(config, '111127'); // id of 'testExperiment' assert.isFalse(result); }); it('returns true for a feature test in a mutex group', function() { - var config = projectConfig.createProjectConfig(testDatafile.getMutexFeatureTestsConfig()); + var config = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getMutexFeatureTestsConfig())); var result = projectConfig.isFeatureExperiment(config, '17128410791'); // id of 'f_test1' assert.isTrue(result); result = projectConfig.isFeatureExperiment(config, '17139931304'); // id of 'f_test2' @@ -819,7 +811,7 @@ describe('lib/core/project_config', function() { describe('#withSegments', () => { var config; beforeEach(() => { - config = projectConfig.createProjectConfig(testDatafile.getOdpIntegratedConfigWithSegments()); + config = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getOdpIntegratedConfigWithSegments())); }); it('should convert integrations from the datafile into the project config', () => { @@ -839,7 +831,7 @@ describe('lib/core/project_config', function() { describe('#withoutSegments', () => { var config; beforeEach(() => { - config = projectConfig.createProjectConfig(testDatafile.getOdpIntegratedConfigWithoutSegments()); + config = projectConfig.createProjectConfig(JSON.stringify(testDatafile.getOdpIntegratedConfigWithoutSegments())); }); it('should convert integrations from the datafile into the project config', () => { @@ -860,7 +852,7 @@ describe('lib/core/project_config', function() { it('should throw an error when parsing the project config due to integrations not containing a key', () => { const odpIntegratedConfigWithoutKey = testDatafile.getOdpIntegratedConfigWithoutKey(); assert.throws(() => { - projectConfig.createProjectConfig(odpIntegratedConfigWithoutKey); + projectConfig.createProjectConfig(JSON.stringify(odpIntegratedConfigWithoutKey)); }); }); }); @@ -870,7 +862,7 @@ describe('lib/core/project_config', function() { beforeEach(() => { const odpIntegratedConfigWithSegments = testDatafile.getOdpIntegratedConfigWithSegments(); const noIntegrationsConfigWithSegments = { ...odpIntegratedConfigWithSegments, integrations: [] }; - config = projectConfig.createProjectConfig(noIntegrationsConfigWithSegments); + config = projectConfig.createProjectConfig(JSON.stringify(noIntegrationsConfigWithSegments)); }); it('should convert integrations from the datafile into the project config', () => { @@ -885,25 +877,23 @@ describe('lib/core/project_config', function() { }); }); -describe('#tryCreatingProjectConfig', function() { +describe('#createProjectConfig with options', function() { var stubJsonSchemaValidator; beforeEach(function() { stubJsonSchemaValidator = sinon.stub().returns(true); - sinon.stub(configValidator, 'validateDatafile').returns(true); sinon.spy(logger, 'error'); }); afterEach(function() { - configValidator.validateDatafile.restore(); logger.error.restore(); }); - it('returns a project config object created by createProjectConfig when all validation is applied and there are no errors', function() { + it('returns a project config object when all validation is applied and there are no errors', function() { var configDatafile = { + version: '4', foo: 'bar', experiments: [{ key: 'a' }, { key: 'b' }], }; - configValidator.validateDatafile.returns(configDatafile); var configObj = { foo: 'bar', experimentKeyMap: { @@ -914,8 +904,7 @@ describe('#tryCreatingProjectConfig', function() { stubJsonSchemaValidator.returns(true); - var result = projectConfig.tryCreatingProjectConfig({ - datafile: configDatafile, + var result = projectConfig.createProjectConfig(JSON.stringify(configDatafile), { jsonSchemaValidator: stubJsonSchemaValidator, logger: logger, }); @@ -923,12 +912,10 @@ describe('#tryCreatingProjectConfig', function() { assert.deepInclude(result, configObj); }); - it('throws an error when validateDatafile throws', function() { - configValidator.validateDatafile.throws(); + it('throws an error when datafile is invalid JSON', function() { stubJsonSchemaValidator.returns(true); assert.throws(() => { - projectConfig.tryCreatingProjectConfig({ - datafile: { foo: 'bar' }, + projectConfig.createProjectConfig('invalid json', { jsonSchemaValidator: stubJsonSchemaValidator, logger: logger, }); @@ -936,11 +923,10 @@ describe('#tryCreatingProjectConfig', function() { }); it('throws an error when jsonSchemaValidator.validate throws', function() { - configValidator.validateDatafile.returns(true); + var configDatafile = { version: '4', foo: 'bar' }; stubJsonSchemaValidator.throws(); assert.throws(() => { - projectConfig.tryCreatingProjectConfig({ - datafile: { foo: 'bar' }, + projectConfig.createProjectConfig(JSON.stringify(configDatafile), { jsonSchemaValidator: stubJsonSchemaValidator, logger: logger, }); @@ -949,12 +935,11 @@ describe('#tryCreatingProjectConfig', function() { it('skips json validation when jsonSchemaValidator is not provided', function() { var configDatafile = { + version: '4', foo: 'bar', experiments: [{ key: 'a' }, { key: 'b' }], }; - configValidator.validateDatafile.returns(configDatafile); - var configObj = { foo: 'bar', experimentKeyMap: { @@ -963,8 +948,7 @@ describe('#tryCreatingProjectConfig', function() { }, }; - var result = projectConfig.tryCreatingProjectConfig({ - datafile: configDatafile, + var result = projectConfig.createProjectConfig(JSON.stringify(configDatafile), { logger: logger, }); diff --git a/lib/project_config/project_config.ts b/lib/project_config/project_config.ts index 2627abe09..5496e1d01 100644 --- a/lib/project_config/project_config.ts +++ b/lib/project_config/project_config.ts @@ -13,10 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { find, objectEntries, objectValues, keyBy, assignBy } from '../utils/fns'; +import { find, keyBy, assignBy } from '../utils/fns'; -import { FEATURE_VARIABLE_TYPES } from '../utils/enums'; -import configValidator from '../utils/config_validator'; +import { DATAFILE_VERSIONS, EXPERIMENT_TYPES, FEATURE_VARIABLE_TYPES } from '../utils/enums'; +import { + INVALID_DATAFILE_MALFORMED, + INVALID_DATAFILE_VERSION, +} from 'error_message'; import { LoggerFacade } from '../logging/logger'; @@ -49,14 +52,12 @@ import { UNRECOGNIZED_ATTRIBUTE, VARIABLE_KEY_NOT_IN_DATAFILE, VARIATION_ID_NOT_IN_DATAFILE, + LOCAL_HOLDOUT_MISSING_INCLUDED_RULES, } from 'error_message'; import { SKIPPING_JSON_VALIDATION, VALID_DATAFILE } from 'log_message'; import { OptimizelyError } from '../error/optimizly_error'; import { Platform } from '../platform_support'; -interface TryCreatingProjectConfigConfig { - // TODO[OASIS-6649]: Don't use object type - // eslint-disable-next-line @typescript-eslint/ban-types - datafile: string | object; +interface CreateProjectConfigOptions { jsonSchemaValidator?: Transformer; logger?: LoggerFacade; } @@ -109,68 +110,25 @@ export interface ProjectConfig { flagRulesMap: { [key: string]: Experiment[] }; flagVariationsMap: { [key: string]: Variation[] }; integrations: Integration[]; - integrationKeyMap?: { [key: string]: Integration }; + integrationKeyMap: { [key: string]: Integration }; odpIntegrationConfig: OdpIntegrationConfig; holdouts: Holdout[]; - holdoutIdMap?: { [id: string]: Holdout }; - globalHoldouts: Holdout[]; - includedHoldouts: { [key: string]: Holdout[]; } - excludedHoldouts: { [key: string]: Holdout[]; } - flagHoldoutsMap: { [key: string]: Holdout[]; } + /** + * Local (rule-scoped) holdouts parsed from the top-level `localHoldouts` + * datafile section. Absent in older datafiles — defaults to an empty array. + */ + localHoldouts: Holdout[]; + holdoutIdMap: { [id: string]: Holdout }; + /** Maps a rule ID to the local holdouts that target it. */ + ruleHoldoutsMap: { [ruleId: string]: Holdout[] }; } const EXPERIMENT_RUNNING_STATUS = 'Running'; const RESERVED_ATTRIBUTE_PREFIX = '$opt_'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function createMutationSafeDatafileCopy(datafile: any): ProjectConfig { - const datafileCopy = { ...datafile }; - - datafileCopy.audiences = (datafile.audiences || []).map((audience: Audience) => { - return { ...audience }; - }); - datafileCopy.experiments = (datafile.experiments || []).map((experiment: Experiment) => { - return { ...experiment }; - }); - datafileCopy.featureFlags = (datafile.featureFlags || []).map((featureFlag: FeatureFlag) => { - return { ...featureFlag }; - }); - datafileCopy.groups = (datafile.groups || []).map((group: Group) => { - const groupCopy = { ...group }; - groupCopy.experiments = (group.experiments || []).map(experiment => { - return { ...experiment }; - }); - return groupCopy; - }); - datafileCopy.rollouts = (datafile.rollouts || []).map((rollout: Rollout) => { - const rolloutCopy = { ...rollout }; - rolloutCopy.experiments = (rollout.experiments || []).map(experiment => { - return { ...experiment }; - }); - return rolloutCopy; - }); - - datafileCopy.environmentKey = datafile.environmentKey ?? ''; - datafileCopy.sdkKey = datafile.sdkKey ?? ''; - - return datafileCopy; -} - -/** - * Creates projectConfig object to be used for quick project property lookup - * @param {Object} datafileObj JSON datafile representing the project - * @param {string|null} datafileStr JSON string representation of the datafile - * @return {ProjectConfig} Object representing project configuration - */ -export const createProjectConfig = function(datafileObj?: JSON, datafileStr: string | null = null): ProjectConfig { - const projectConfig = createMutationSafeDatafileCopy(datafileObj); - - if (!projectConfig.region) { - projectConfig.region = 'US'; // Default to US region if not specified - } - - projectConfig.__datafileStr = datafileStr === null ? JSON.stringify(datafileObj) : datafileStr; +const SUPPORTED_DATAFILE_VERSIONS = Object.values(DATAFILE_VERSIONS); +const parseAudienceConfig = (projectConfig: ProjectConfig) => { /* * Conditions of audiences in projectConfig.typedAudiences are not * expected to be string-encoded as they are here in projectConfig.audiences. @@ -183,7 +141,9 @@ export const createProjectConfig = function(datafileObj?: JSON, datafileStr: str projectConfig.audiencesById = {}; assignBy(projectConfig.audiences, 'id', projectConfig.audiencesById); assignBy(projectConfig.typedAudiences, 'id', projectConfig.audiencesById); +} +const parseAttributeConfig = (projectConfig: ProjectConfig) => { projectConfig.attributes = projectConfig.attributes || []; projectConfig.attributeKeyMap = {}; projectConfig.attributeIdMap = {}; @@ -191,38 +151,40 @@ export const createProjectConfig = function(datafileObj?: JSON, datafileStr: str projectConfig.attributeKeyMap[attribute.key] = attribute; projectConfig.attributeIdMap[attribute.id] = attribute; }); +} - projectConfig.eventKeyMap = keyBy(projectConfig.events, 'key'); - projectConfig.groupIdMap = keyBy(projectConfig.groups, 'id'); +const parseGroupConfig = (projectConfig: ProjectConfig) => { + projectConfig.groupIdMap = {}; - let experiments; - Object.keys(projectConfig.groupIdMap || {}).forEach(Id => { - experiments = projectConfig.groupIdMap[Id].experiments; - (experiments || []).forEach(experiment => { - experiment.groupId = Id; + (projectConfig.groups || []).forEach((group) => { + projectConfig.groupIdMap[group.id] = group; + + (group.experiments || []).forEach((experiment) => { + experiment.groupId = group.id; projectConfig.experiments.push(experiment); - }); + }) }); +} - projectConfig.rolloutIdMap = keyBy(projectConfig.rollouts || [], 'id'); - objectValues(projectConfig.rolloutIdMap || {}).forEach(rollout => { - (rollout.experiments || []).forEach(experiment => { +const parseRolloutConfig = (projectConfig: ProjectConfig) => { + projectConfig.rolloutIdMap = {}; + (projectConfig.rollouts || []).forEach((rollout) => { + projectConfig.rolloutIdMap[rollout.id] = rollout; + (rollout.experiments || []).forEach((experiment) => { experiment.isRollout = true projectConfig.experiments.push(experiment); - // Creates { : } map inside of the experiment - experiment.variationKeyMap = keyBy(experiment.variations, 'key'); }); }); +} +const parseIntegrationConfig = (projectConfig: ProjectConfig) => { const allSegmentsSet = new Set(); - Object.keys(projectConfig.audiencesById) - .map(audience => getAudienceSegments(projectConfig.audiencesById[audience])) - .forEach(audienceSegments => { - audienceSegments.forEach(segment => { - allSegmentsSet.add(segment); - }); + Object.keys(projectConfig.audiencesById).forEach(id => { + getAudienceSegments(projectConfig.audiencesById[id]).forEach(segment => { + allSegmentsSet.add(segment); }); + }); const allSegments = Array.from(allSegmentsSet); @@ -231,22 +193,22 @@ export const createProjectConfig = function(datafileObj?: JSON, datafileStr: str let odpApiKey = ''; let odpPixelUrl = ''; - if (projectConfig.integrations) { - projectConfig.integrationKeyMap = keyBy(projectConfig.integrations, 'key'); + projectConfig.integrationKeyMap = {}; - projectConfig.integrations.forEach(integration => { - if (!('key' in integration)) { - throw new OptimizelyError(MISSING_INTEGRATION_KEY); - } + (projectConfig.integrations || []).forEach(integration => { + if (!('key' in integration)) { + throw new OptimizelyError(MISSING_INTEGRATION_KEY); + } - if (integration.key === 'odp') { - odpIntegrated = true; - odpApiKey = odpApiKey || integration.publicKey || ''; - odpApiHost = odpApiHost || integration.host || ''; - odpPixelUrl = odpPixelUrl || integration.pixelUrl || ''; - } - }); - } + projectConfig.integrationKeyMap[integration.key] = integration + + if (integration.key === 'odp') { + odpIntegrated = true; + odpApiKey = odpApiKey || integration.publicKey || ''; + odpApiHost = odpApiHost || integration.host || ''; + odpPixelUrl = odpPixelUrl || integration.pixelUrl || ''; + } + }); if (odpIntegrated) { projectConfig.odpIntegrationConfig = { @@ -256,159 +218,271 @@ export const createProjectConfig = function(datafileObj?: JSON, datafileStr: str } else { projectConfig.odpIntegrationConfig = { integrated: false }; } +} - projectConfig.experimentKeyMap = keyBy(projectConfig.experiments, 'key'); - projectConfig.experimentIdMap = keyBy(projectConfig.experiments, 'id'); - +const parseExperimentConfig = (projectConfig: ProjectConfig) => { + projectConfig.experimentKeyMap = {}; + projectConfig.experimentIdMap = {}; projectConfig.variationIdMap = {}; projectConfig.variationVariableUsageMap = {}; - (projectConfig.experiments || []).forEach(experiment => { - // Creates { : } map inside of the experiment - experiment.variationKeyMap = keyBy(experiment.variations, 'key'); - assignBy(experiment.variations, 'id', projectConfig.variationIdMap); + (projectConfig.experiments || []).forEach((experiment) => { + projectConfig.experimentKeyMap[experiment.key] = experiment; + projectConfig.experimentIdMap[experiment.id] = experiment; - objectValues(experiment.variationKeyMap || {}).forEach(variation => { + experiment.variationKeyMap = {}; + (experiment.variations || []).forEach((variation) => { + experiment.variationKeyMap[variation.key] = variation; + projectConfig.variationIdMap[variation.id] = variation; if (variation.variables) { projectConfig.variationVariableUsageMap[variation.id] = keyBy(variation.variables, 'id'); } }); }); +} +const parseFeatureFlagConfig = (projectConfig: ProjectConfig) => { // Object containing experiment Ids that exist in any feature // for checking that experiment is a feature experiment or not. projectConfig.experimentFeatureMap = {}; + projectConfig.featureKeyMap = {} + + // all rules (experiment rules and delivery rules) for each flag + projectConfig.flagRulesMap = {}; + projectConfig.flagVariationsMap = {}; + + (projectConfig.featureFlags || []).forEach((feature) => { + projectConfig.featureKeyMap[feature.key] = feature; + + feature.variableKeyMap = {} - projectConfig.featureKeyMap = keyBy(projectConfig.featureFlags || [], 'key'); - objectValues(projectConfig.featureKeyMap || {}).forEach(feature => { - // Json type is represented in datafile as a subtype of string for the sake of backwards compatibility. - // Converting it to a first-class json type while creating Project Config feature.variables.forEach(variable => { + // Json type is represented in datafile as a subtype of string for the sake of backwards compatibility. + // Converting it to a first-class json type while creating Project Config if (variable.type === FEATURE_VARIABLE_TYPES.STRING && variable.subType === FEATURE_VARIABLE_TYPES.JSON) { variable.type = FEATURE_VARIABLE_TYPES.JSON as VariableType; delete variable.subType; } - }); - feature.variableKeyMap = keyBy(feature.variables, 'key'); - (feature.experimentIds || []).forEach(experimentId => { - // Add this experiment in experiment-feature map. - if (projectConfig.experimentFeatureMap[experimentId]) { - projectConfig.experimentFeatureMap[experimentId].push(feature.id); - } else { - projectConfig.experimentFeatureMap[experimentId] = [feature.id]; - } + feature.variableKeyMap[variable.key] = variable; }); - }); - // all rules (experiment rules and delivery rules) for each flag - projectConfig.flagRulesMap = {}; - (projectConfig.featureFlags || []).forEach(featureFlag => { const flagRuleExperiments: Experiment[] = []; - featureFlag.experimentIds.forEach(experimentId => { + + // all variations for the flag mapped by id + const flagVariationsById: Record = {}; + + const everyoneElseVariation = getEveryoneElseVariation(projectConfig, feature); + + (feature.experimentIds || []).forEach(experimentId => { + + projectConfig.experimentFeatureMap[experimentId] = + projectConfig.experimentFeatureMap[experimentId] || []; + + projectConfig.experimentFeatureMap[experimentId].push(feature.id); + const experiment = projectConfig.experimentIdMap[experimentId]; - if (experiment) { - flagRuleExperiments.push(experiment); + + flagRuleExperiments.push(experiment); + + // Inject "everyone else" variation into feature rollout (FR) experiments + if (experiment && experiment.type === EXPERIMENT_TYPES.FR && everyoneElseVariation) { + experiment.variations.push(everyoneElseVariation); + experiment.trafficAllocation.push({ + entityId: everyoneElseVariation.id, + endOfRange: 10000, + }); + + // Update variation lookup map + experiment.variationKeyMap[everyoneElseVariation.key] = everyoneElseVariation; } + + (experiment.variations || []).forEach((variation) => { + flagVariationsById[variation.id] = variation; + }); }); - const rollout = projectConfig.rolloutIdMap[featureFlag.rolloutId]; + // add all rollout experiments to the flagRuleExperiments list + const rollout = projectConfig.rolloutIdMap[feature.rolloutId]; if (rollout) { - flagRuleExperiments.push(...rollout.experiments); - } + (rollout.experiments || []).forEach((experiment) => { + flagRuleExperiments.push(experiment); + (experiment.variations || []).forEach((variation) => { + flagVariationsById[variation.id] = variation; + }); + }); + } - projectConfig.flagRulesMap[featureFlag.key] = flagRuleExperiments; + projectConfig.flagRulesMap[feature.key] = flagRuleExperiments; + projectConfig.flagVariationsMap[feature.key] = Object.values(flagVariationsById) }); +} - // all variations for each flag - // - datafile does not contain a separate entity for this. - // - we collect variations used in each rule (experiment rules and delivery rules) - projectConfig.flagVariationsMap = {}; +/** + * Creates projectConfig object to be used for quick project property lookup + * @param {string} datafileStr JSON string representation of the datafile + * @return {ProjectConfig} Object representing project configuration + */ +export const createProjectConfig = function(datafileStr: string, options?: CreateProjectConfigOptions): ProjectConfig { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let projectConfig: ProjectConfig; + + try { + projectConfig = JSON.parse(datafileStr); + } catch { + throw new OptimizelyError(INVALID_DATAFILE_MALFORMED); + } - objectEntries(projectConfig.flagRulesMap || {}).forEach(([flagKey, rules]) => { - const variations: OptimizelyVariation[] = []; - rules.forEach(rule => { - rule.variations.forEach(variation => { - if (!find(variations, item => item.id === variation.id)) { - variations.push(variation); - } - }); - }); - projectConfig.flagVariationsMap[flagKey] = variations; - }); + const version = (projectConfig as unknown as { version: string }).version + + if (SUPPORTED_DATAFILE_VERSIONS.indexOf(version) === -1) { + throw new OptimizelyError(INVALID_DATAFILE_VERSION, version); + } + + if (options?.jsonSchemaValidator) { + options.jsonSchemaValidator(projectConfig); + options.logger?.info(VALID_DATAFILE); + } else { + options?.logger?.info(SKIPPING_JSON_VALIDATION); + } + + if (!projectConfig.region) { + projectConfig.region = 'US'; + } + + projectConfig.__datafileStr = datafileStr; + + parseAudienceConfig(projectConfig); + parseAttributeConfig(projectConfig); + + projectConfig.eventKeyMap = keyBy(projectConfig.events, 'key'); + + parseGroupConfig(projectConfig); - parseHoldoutsConfig(projectConfig); + parseRolloutConfig(projectConfig); + + parseIntegrationConfig(projectConfig); + + parseExperimentConfig(projectConfig); + + parseFeatureFlagConfig(projectConfig); + + parseHoldoutsConfig(projectConfig, options?.logger); return projectConfig; }; -const parseHoldoutsConfig = (projectConfig: ProjectConfig): void => { - projectConfig.holdouts = projectConfig.holdouts || []; - projectConfig.holdoutIdMap = keyBy(projectConfig.holdouts, 'id'); - projectConfig.globalHoldouts = []; - projectConfig.includedHoldouts = {}; - projectConfig.excludedHoldouts = {}; - projectConfig.flagHoldoutsMap = {}; +/** + * Get the "everyone else" variation from the last rule in the flag's rollout. + * Returns null if the rollout cannot be resolved or has no variations. + */ +const getEveryoneElseVariation = function( + projectConfig: ProjectConfig, + featureFlag: FeatureFlag, +): Variation | null { + if (!featureFlag.rolloutId) { + return null; + } + const rollout = projectConfig.rolloutIdMap[featureFlag.rolloutId]; + if (!rollout || !rollout.experiments || rollout.experiments.length === 0) { + return null; + } + const everyoneElseRule = rollout.experiments[rollout.experiments.length - 1]; + if (!everyoneElseRule.variations || everyoneElseRule.variations.length === 0) { + return null; + } + return everyoneElseRule.variations[0]; +}; - const featureFlagIdMap = keyBy(projectConfig.featureFlags, 'id'); +/** + * Parse holdouts from the two top-level datafile sections. + * + * Two top-level sections drive holdout scoping (Gen 3+): + * - `holdouts` → ALL entries are global holdouts (applied to every flag). + * Any `includedRules` field on these entries is IGNORED; + * section membership alone determines scope. + * - `localHoldouts` → ALL entries are local holdouts (rule-scoped via + * `includedRules`). Entries missing/with empty `includedRules` + * are invalid and skipped with an error log. + * + * Backward compatibility: older datafiles that only emit the `holdouts` section + * continue to work — every entry is treated as global, matching pre-localHoldouts + * behavior. The `localHoldouts` key is simply absent and parsed as an empty list. + */ +const parseHoldoutsConfig = (projectConfig: ProjectConfig, logger?: LoggerFacade): void => { + projectConfig.holdouts = projectConfig.holdouts || []; + projectConfig.localHoldouts = projectConfig.localHoldouts || []; + + // const global: Holdout[] = []; + const ruleHoldoutsMap: { [ruleId: string]: Holdout[] } = {}; + const holdoutIdMap: { [id: string]: Holdout } = {}; + + // Helper to seed common per-holdout fields (matches legacy behavior). + const initHoldout = (holdout: Holdout): void => { + // Original design of holdouts made use of the includeFlags and excludeFlags fields to identify local holdouts. + // But this was never released. In the current design, these fields are no longer used. These fields are kept + // and assigned empty array to keep the published type `Holdout` unchanged. + holdout.includedFlags = []; + holdout.excludedFlags = []; + holdout.variationKeyMap = keyBy(holdout.variations, 'key'); + assignBy(holdout.variations, 'id', projectConfig.variationIdMap); + }; + // Process global holdouts: section membership is the sole signal for scope. + // Drop any `includedRules` field on entries here so the entity is unambiguously + // global (isGlobal === true), even if the datafile incorrectly includes one. projectConfig.holdouts.forEach((holdout) => { - if (!holdout.includedFlags) { - holdout.includedFlags = []; - } + initHoldout(holdout); + delete holdout.includedRules; + holdout.isGlobal = true; + holdoutIdMap[holdout.id] = holdout; + }); - if (!holdout.excludedFlags) { - holdout.excludedFlags = []; + // Process local holdouts: every entry must carry a non-empty `includedRules` list. + // Entries missing it (or with [] / null) are invalid per spec — log an error and + // exclude from evaluation. Do NOT fall back to global application — the partition + // between sections is hard. + projectConfig.localHoldouts.forEach((holdout) => { + const includedRules = holdout.includedRules; + if (!Array.isArray(includedRules) || includedRules.length === 0) { + logger?.error(LOCAL_HOLDOUT_MISSING_INCLUDED_RULES, holdout.key); + return; } - holdout.variationKeyMap = keyBy(holdout.variations, 'key'); - - assignBy(holdout.variations, 'id', projectConfig.variationIdMap); - - if (holdout.includedFlags.length === 0) { - projectConfig.globalHoldouts.push(holdout); - - holdout.excludedFlags.forEach((flagId: string) => { - const flag = featureFlagIdMap[flagId]; - if (flag) { - const flagKey = flag.key; - if (!projectConfig.excludedHoldouts[flagKey]) { - projectConfig.excludedHoldouts[flagKey] = []; - } - projectConfig.excludedHoldouts[flagKey].push(holdout); - } - }); - } else { - holdout.includedFlags.forEach((flagId: string) => { - const flag = featureFlagIdMap[flagId]; - if (flag) { - const flagKey = flag.key; - if (!projectConfig.includedHoldouts[flagKey]) { - projectConfig.includedHoldouts[flagKey] = []; - } - projectConfig.includedHoldouts[flagKey].push(holdout); - } - }) + initHoldout(holdout); + holdout.isGlobal = false; + holdoutIdMap[holdout.id] = holdout; + for (const ruleId of includedRules) { + if (!ruleHoldoutsMap[ruleId]) { + ruleHoldoutsMap[ruleId] = []; + } + ruleHoldoutsMap[ruleId].push(holdout); } }); -} -export const getHoldoutsForFlag = (projectConfig: ProjectConfig, flagKey: string): Holdout[] => { - if (projectConfig.flagHoldoutsMap[flagKey]) { - return projectConfig.flagHoldoutsMap[flagKey]; - } + projectConfig.holdoutIdMap = holdoutIdMap; + projectConfig.ruleHoldoutsMap = ruleHoldoutsMap; +} - const flagHoldouts: Holdout[] = [ - ...projectConfig.globalHoldouts.filter((holdout) => { - return !(projectConfig.excludedHoldouts[flagKey] || []).includes(holdout); - }), - ...(projectConfig.includedHoldouts[flagKey] || []), - ]; +/** + * Returns all global holdouts (parsed from the top-level `holdouts` section). + * Section membership in `holdouts` is the sole signal for global scope — + * any `includedRules` field on these entries is ignored. + */ +export const getGlobalHoldouts = (projectConfig: ProjectConfig): Holdout[] => { + return projectConfig.holdouts; +}; - projectConfig.flagHoldoutsMap[flagKey] = flagHoldouts; - return flagHoldouts; -} +/** + * Returns local holdouts targeting the given rule ID, or an empty array. + * + * Local holdouts come from the top-level `localHoldouts` datafile section and + * are scoped per-rule via their `includedRules` field. + */ +export const getHoldoutsForRule = (projectConfig: ProjectConfig, ruleId: string): Holdout[] => { + return projectConfig.ruleHoldoutsMap[ruleId] ?? []; +}; /** * Extract all audience segments used in this audience's conditions @@ -898,46 +972,6 @@ export const toDatafile = function(projectConfig: ProjectConfig): string { return projectConfig.__datafileStr; }; -/** - * @typedef {Object} - * @property {Object|null} configObj - * @property {Error|null} error - */ - -/** - * Try to create a project config object from the given datafile and - * configuration properties. - * Returns a ProjectConfig if successful. - * Otherwise, throws an error. - * @param {Object} config - * @param {Object|string} config.datafile - * @param {Object} config.jsonSchemaValidator - * @param {Object} config.logger - * @returns {Object} ProjectConfig - * @throws {Error} - */ -export const tryCreatingProjectConfig = function( - config: TryCreatingProjectConfigConfig -): ProjectConfig { - const newDatafileObj = configValidator.validateDatafile(config.datafile); - - if (config.jsonSchemaValidator) { - config.jsonSchemaValidator(newDatafileObj); - config.logger?.info(VALID_DATAFILE); - } else { - config.logger?.info(SKIPPING_JSON_VALIDATION); - } - - const createProjectConfigArgs = [newDatafileObj]; - if (typeof config.datafile === 'string') { - // Since config.datafile was validated above, we know that it is a valid JSON string - createProjectConfigArgs.push(config.datafile); - } - - const newConfigObj = createProjectConfig(...createProjectConfigArgs); - return newConfigObj; -}; - /** * Get the send flag decisions value * @param {ProjectConfig} projectConfig @@ -973,7 +1007,6 @@ export default { eventWithKeyExists, isFeatureExperiment, toDatafile, - tryCreatingProjectConfig, getTrafficAllocation, }; diff --git a/lib/project_config/project_config_manager.spec.ts b/lib/project_config/project_config_manager.spec.ts index 507c90b46..8fb63dc98 100644 --- a/lib/project_config/project_config_manager.spec.ts +++ b/lib/project_config/project_config_manager.spec.ts @@ -23,8 +23,6 @@ import { resolvablePromise } from '../utils/promise/resolvablePromise'; import { getMockDatafileManager } from '../tests/mock/mock_datafile_manager'; import { wait } from '../tests/testUtils'; -const cloneDeep = (x: any) => JSON.parse(JSON.stringify(x)); - describe('ProjectConfigManagerImpl', () => { describe('a logger is passed in the constructor', () => { it('should set name on the logger passed into the constructor', () => { @@ -92,7 +90,7 @@ describe('ProjectConfigManagerImpl', () => { describe('when constructed with only a datafile', () => { it('should reject onRunning() and log error if the datafile is invalid', async () => { const logger = getMockLogger(); - const manager = new ProjectConfigManagerImpl({ logger, datafile: {}}); + const manager = new ProjectConfigManagerImpl({ logger, datafile: JSON.stringify({})}); manager.start(); await expect(manager.onRunning()).rejects.toThrow(); expect(logger.error).toHaveBeenCalled(); @@ -100,7 +98,7 @@ describe('ProjectConfigManagerImpl', () => { it('should set status to Failed if the datafile is invalid', async () => { const logger = getMockLogger(); - const manager = new ProjectConfigManagerImpl({ logger, datafile: {}}); + const manager = new ProjectConfigManagerImpl({ logger, datafile: JSON.stringify({})}); manager.start(); await expect(manager.onRunning()).rejects.toThrow(); expect(manager.getState()).toBe(ServiceState.Failed); @@ -115,7 +113,7 @@ describe('ProjectConfigManagerImpl', () => { it('should fulfill onRunning() and set status to Running if the datafile is valid', async () => { const logger = getMockLogger(); - const manager = new ProjectConfigManagerImpl({ logger, datafile: testData.getTestProjectConfig()}); + const manager = new ProjectConfigManagerImpl({ logger, datafile: JSON.stringify(testData.getTestProjectConfig())}); manager.start(); await expect(manager.onRunning()).resolves.not.toThrow(); expect(manager.getState()).toBe(ServiceState.Running); @@ -123,7 +121,7 @@ describe('ProjectConfigManagerImpl', () => { it('should call onUpdate listeners registered before start() with the project config', async () => { const logger = getMockLogger(); - const manager = new ProjectConfigManagerImpl({ logger, datafile: testData.getTestProjectConfig()}); + const manager = new ProjectConfigManagerImpl({ logger, datafile: JSON.stringify(testData.getTestProjectConfig())}); const listener = vi.fn(); manager.onUpdate(listener); manager.start(); @@ -131,16 +129,16 @@ describe('ProjectConfigManagerImpl', () => { await manager.onRunning(); expect(listener).toHaveBeenCalledOnce(); - expect(listener).toHaveBeenCalledWith(createProjectConfig(testData.getTestProjectConfig())); + expect(listener).toHaveBeenCalledWith(createProjectConfig(JSON.stringify(testData.getTestProjectConfig()))); }); it('should return the correct config from getConfig() both before or after onRunning() resolves', async () => { const logger = getMockLogger(); - const manager = new ProjectConfigManagerImpl({ logger, datafile: testData.getTestProjectConfig()}); + const manager = new ProjectConfigManagerImpl({ logger, datafile: JSON.stringify(testData.getTestProjectConfig())}); manager.start(); - expect(manager.getConfig()).toEqual(createProjectConfig(testData.getTestProjectConfig())); + expect(manager.getConfig()).toEqual(createProjectConfig(JSON.stringify(testData.getTestProjectConfig()))); await manager.onRunning(); - expect(manager.getConfig()).toEqual(createProjectConfig(testData.getTestProjectConfig())); + expect(manager.getConfig()).toEqual(createProjectConfig(JSON.stringify(testData.getTestProjectConfig()))); }); }); @@ -152,7 +150,7 @@ describe('ProjectConfigManagerImpl', () => { onRunning: resolvablePromise().promise, // this will not be resolved }); vi.spyOn(datafileManager, 'onRunning'); - const manager = new ProjectConfigManagerImpl({ datafile: testData.getTestProjectConfig(), datafileManager }); + const manager = new ProjectConfigManagerImpl({ datafile: JSON.stringify(testData.getTestProjectConfig()), datafileManager }); manager.start(); expect(datafileManager.onRunning).toHaveBeenCalled(); @@ -165,7 +163,7 @@ describe('ProjectConfigManagerImpl', () => { onRunning, }); vi.spyOn(datafileManager, 'onRunning'); - const manager = new ProjectConfigManagerImpl({ datafile: testData.getTestProjectConfig(), datafileManager }); + const manager = new ProjectConfigManagerImpl({ datafile: JSON.stringify(testData.getTestProjectConfig()), datafileManager }); manager.start(); expect(datafileManager.onRunning).toHaveBeenCalled(); @@ -179,11 +177,11 @@ describe('ProjectConfigManagerImpl', () => { const listener = vi.fn(); - const manager = new ProjectConfigManagerImpl({ datafile: testData.getTestProjectConfig(), datafileManager }); + const manager = new ProjectConfigManagerImpl({ datafile: JSON.stringify(testData.getTestProjectConfig()), datafileManager }); manager.onUpdate(listener); manager.start(); await expect(manager.onRunning()).resolves.not.toThrow(); - expect(listener).toHaveBeenCalledWith(createProjectConfig(testData.getTestProjectConfig())); + expect(listener).toHaveBeenCalledWith(createProjectConfig(JSON.stringify(testData.getTestProjectConfig()))); }); it('should return the correct config from getConfig() both before or after onRunning() resolves', async () => { @@ -191,60 +189,60 @@ describe('ProjectConfigManagerImpl', () => { onRunning: resolvablePromise().promise, // this will not be resolved }); - const manager = new ProjectConfigManagerImpl({ datafile: testData.getTestProjectConfig(), datafileManager }); + const manager = new ProjectConfigManagerImpl({ datafile: JSON.stringify(testData.getTestProjectConfig()), datafileManager }); manager.start(); - expect(manager.getConfig()).toEqual(createProjectConfig(testData.getTestProjectConfig())); + expect(manager.getConfig()).toEqual(createProjectConfig(JSON.stringify(testData.getTestProjectConfig()))); await manager.onRunning(); - expect(manager.getConfig()).toEqual(createProjectConfig(testData.getTestProjectConfig())); + expect(manager.getConfig()).toEqual(createProjectConfig(JSON.stringify(testData.getTestProjectConfig()))); }); }); describe('when datafile is invalid', () => { it('should reject onRunning() with the same error if datafileManager.onRunning() rejects', async () => { const datafileManager = getMockDatafileManager({ onRunning: Promise.reject(new Error('test error')) }); - const manager = new ProjectConfigManagerImpl({ datafile: {}, datafileManager }); + const manager = new ProjectConfigManagerImpl({ datafile: JSON.stringify({}), datafileManager }); manager.start(); await expect(manager.onRunning()).rejects.toThrow('DatafileManager failed to start, reason: test error'); }); it('should resolve onRunning() if datafileManager.onUpdate() is fired and should update config', async () => { const datafileManager = getMockDatafileManager({ onRunning: Promise.resolve() }); - const manager = new ProjectConfigManagerImpl({ datafile: {}, datafileManager }); + const manager = new ProjectConfigManagerImpl({ datafile: JSON.stringify({}), datafileManager }); manager.start(); - datafileManager.pushUpdate(testData.getTestProjectConfig()); + datafileManager.pushUpdate(JSON.stringify(testData.getTestProjectConfig())); await expect(manager.onRunning()).resolves.not.toThrow(); - expect(manager.getConfig()).toEqual(createProjectConfig(testData.getTestProjectConfig())); + expect(manager.getConfig()).toEqual(createProjectConfig(JSON.stringify(testData.getTestProjectConfig()))); }); it('should resolve onRunning(), update config and call onUpdate listeners if datafileManager.onUpdate() is fired', async () => { const datafileManager = getMockDatafileManager({ onRunning: Promise.resolve() }); - const manager = new ProjectConfigManagerImpl({ datafile: {}, datafileManager }); + const manager = new ProjectConfigManagerImpl({ datafile: JSON.stringify({}), datafileManager }); manager.start(); const listener = vi.fn(); manager.onUpdate(listener); - datafileManager.pushUpdate(testData.getTestProjectConfig()); + datafileManager.pushUpdate(JSON.stringify(testData.getTestProjectConfig())); await expect(manager.onRunning()).resolves.not.toThrow(); - expect(manager.getConfig()).toEqual(createProjectConfig(testData.getTestProjectConfig())); - expect(listener).toHaveBeenCalledWith(createProjectConfig(testData.getTestProjectConfig())); + expect(manager.getConfig()).toEqual(createProjectConfig(JSON.stringify(testData.getTestProjectConfig()))); + expect(listener).toHaveBeenCalledWith(createProjectConfig(JSON.stringify(testData.getTestProjectConfig()))); }); it('should return undefined from getConfig() before onRunning() resolves', async () => { const datafileManager = getMockDatafileManager({ onRunning: Promise.resolve() }); - const manager = new ProjectConfigManagerImpl({ datafile: {}, datafileManager }); + const manager = new ProjectConfigManagerImpl({ datafile: JSON.stringify({}), datafileManager }); manager.start(); expect(manager.getConfig()).toBeUndefined(); }); it('should return the correct config from getConfig() after onRunning() resolves', async () => { const datafileManager = getMockDatafileManager({ onRunning: Promise.resolve() }); - const manager = new ProjectConfigManagerImpl({ datafile: {}, datafileManager }); + const manager = new ProjectConfigManagerImpl({ datafile: JSON.stringify({}), datafileManager }); manager.start(); - datafileManager.pushUpdate(testData.getTestProjectConfig()); + datafileManager.pushUpdate(JSON.stringify(testData.getTestProjectConfig())); await expect(manager.onRunning()).resolves.not.toThrow(); - expect(manager.getConfig()).toEqual(createProjectConfig(testData.getTestProjectConfig())); + expect(manager.getConfig()).toEqual(createProjectConfig(JSON.stringify(testData.getTestProjectConfig()))); }); }); }); @@ -273,10 +271,10 @@ describe('ProjectConfigManagerImpl', () => { const listener = vi.fn(); manager.onUpdate(listener); - datafileManager.pushUpdate(testData.getTestProjectConfig()); + datafileManager.pushUpdate(JSON.stringify(testData.getTestProjectConfig())); await expect(manager.onRunning()).resolves.not.toThrow(); - expect(manager.getConfig()).toEqual(createProjectConfig(testData.getTestProjectConfig())); - expect(listener).toHaveBeenCalledWith(createProjectConfig(testData.getTestProjectConfig())); + expect(manager.getConfig()).toEqual(createProjectConfig(JSON.stringify(testData.getTestProjectConfig()))); + expect(listener).toHaveBeenCalledWith(createProjectConfig(JSON.stringify(testData.getTestProjectConfig()))); }); it('should return undefined from getConfig() before onRunning() resolves', async () => { @@ -291,9 +289,9 @@ describe('ProjectConfigManagerImpl', () => { const manager = new ProjectConfigManagerImpl({ datafileManager }); manager.start(); - datafileManager.pushUpdate(testData.getTestProjectConfig()); + datafileManager.pushUpdate(JSON.stringify(testData.getTestProjectConfig())); await expect(manager.onRunning()).resolves.not.toThrow(); - expect(manager.getConfig()).toEqual(createProjectConfig(testData.getTestProjectConfig())); + expect(manager.getConfig()).toEqual(createProjectConfig(JSON.stringify(testData.getTestProjectConfig()))); }); }); @@ -301,25 +299,25 @@ describe('ProjectConfigManagerImpl', () => { const datafileManager = getMockDatafileManager({}); const datafile = testData.getTestProjectConfig(); - const manager = new ProjectConfigManagerImpl({ datafile, datafileManager }); + const manager = new ProjectConfigManagerImpl({ datafile: JSON.stringify(datafile), datafileManager }); const listener = vi.fn(); manager.onUpdate(listener); manager.start(); - expect(manager.getConfig()).toEqual(createProjectConfig(datafile)); + expect(manager.getConfig()).toEqual(createProjectConfig(JSON.stringify(datafile))); await manager.onRunning(); - expect(manager.getConfig()).toEqual(createProjectConfig(datafile)); - expect(listener).toHaveBeenNthCalledWith(1, createProjectConfig(datafile)); + expect(manager.getConfig()).toEqual(createProjectConfig(JSON.stringify(datafile))); + expect(listener).toHaveBeenNthCalledWith(1, createProjectConfig(JSON.stringify(datafile))); - const updatedDatafile = cloneDeep(datafile); + const updatedDatafile = testData.getTestProjectConfig(); updatedDatafile['revision'] = '99'; - datafileManager.pushUpdate(updatedDatafile); + datafileManager.pushUpdate(JSON.stringify(updatedDatafile)); await Promise.resolve(); - expect(manager.getConfig()).toEqual(createProjectConfig(updatedDatafile)); - expect(listener).toHaveBeenNthCalledWith(2, createProjectConfig(updatedDatafile)); + expect(manager.getConfig()).toEqual(createProjectConfig(JSON.stringify(updatedDatafile))); + expect(listener).toHaveBeenNthCalledWith(2, createProjectConfig(JSON.stringify(updatedDatafile))); }); it('should not call onUpdate handlers and should log error when datafileManager onUpdate is fired with invalid datafile', async () => { @@ -327,24 +325,24 @@ describe('ProjectConfigManagerImpl', () => { const logger = getMockLogger(); const datafile = testData.getTestProjectConfig(); - const manager = new ProjectConfigManagerImpl({ logger, datafile, datafileManager }); + const manager = new ProjectConfigManagerImpl({ logger, datafile: JSON.stringify(datafile), datafileManager }); const listener = vi.fn(); manager.onUpdate(listener); manager.start(); - expect(manager.getConfig()).toEqual(createProjectConfig(testData.getTestProjectConfig())); + expect(manager.getConfig()).toEqual(createProjectConfig(JSON.stringify(testData.getTestProjectConfig()))); await manager.onRunning(); - expect(manager.getConfig()).toEqual(createProjectConfig(testData.getTestProjectConfig())); + expect(manager.getConfig()).toEqual(createProjectConfig(JSON.stringify(testData.getTestProjectConfig()))); - expect(listener).toHaveBeenCalledWith(createProjectConfig(datafile)); + expect(listener).toHaveBeenCalledWith(createProjectConfig(JSON.stringify(datafile))); - const updatedDatafile = {}; - datafileManager.pushUpdate(updatedDatafile); + const updatedDatafile = {}; + datafileManager.pushUpdate(JSON.stringify(updatedDatafile)); await Promise.resolve(); - expect(manager.getConfig()).toEqual(createProjectConfig(datafile)); + expect(manager.getConfig()).toEqual(createProjectConfig(JSON.stringify(datafile))); expect(listener).toHaveBeenCalledTimes(1); expect(logger.error).toHaveBeenCalled(); }); @@ -354,41 +352,41 @@ describe('ProjectConfigManagerImpl', () => { const datafile = testData.getTestProjectConfig(); const jsonSchemaValidator = vi.fn().mockReturnValue(true); - const manager = new ProjectConfigManagerImpl({ datafile, datafileManager, jsonSchemaValidator }); + const manager = new ProjectConfigManagerImpl({ datafile: JSON.stringify(datafile), datafileManager, jsonSchemaValidator }); manager.start(); await manager.onRunning(); - const updatedDatafile = cloneDeep(datafile); + const updatedDatafile = testData.getTestProjectConfig(); updatedDatafile['revision'] = '99'; - datafileManager.pushUpdate(updatedDatafile); + datafileManager.pushUpdate(JSON.stringify(updatedDatafile)); await Promise.resolve(); expect(jsonSchemaValidator).toHaveBeenCalledTimes(2); - expect(jsonSchemaValidator).toHaveBeenNthCalledWith(1, datafile); - expect(jsonSchemaValidator).toHaveBeenNthCalledWith(2, updatedDatafile); + expect(jsonSchemaValidator.mock.calls[0][0].version).toBe(datafile.version); + expect(jsonSchemaValidator.mock.calls[1][0].revision).toBe('99'); }); it('should not call onUpdate handlers when datafileManager onUpdate is fired with the same datafile', async () => { const datafileManager = getMockDatafileManager({}); const datafile = testData.getTestProjectConfig(); - const manager = new ProjectConfigManagerImpl({ datafile, datafileManager }); + const manager = new ProjectConfigManagerImpl({ datafile: JSON.stringify(datafile), datafileManager }); const listener = vi.fn(); manager.onUpdate(listener); manager.start(); - expect(manager.getConfig()).toEqual(createProjectConfig(datafile)); + expect(manager.getConfig()).toEqual(createProjectConfig(JSON.stringify(datafile))); await manager.onRunning(); - expect(manager.getConfig()).toEqual(createProjectConfig(datafile)); - expect(listener).toHaveBeenNthCalledWith(1, createProjectConfig(datafile)); + expect(manager.getConfig()).toEqual(createProjectConfig(JSON.stringify(datafile))); + expect(listener).toHaveBeenNthCalledWith(1, createProjectConfig(JSON.stringify(datafile))); - datafileManager.pushUpdate(cloneDeep(datafile)); + datafileManager.pushUpdate(JSON.stringify(datafile)); await Promise.resolve(); - expect(manager.getConfig()).toEqual(createProjectConfig(datafile)); + expect(manager.getConfig()).toEqual(createProjectConfig(JSON.stringify(datafile))); expect(listener).toHaveBeenCalledTimes(1); }); @@ -396,7 +394,7 @@ describe('ProjectConfigManagerImpl', () => { const datafile = testData.getTestProjectConfig(); const datafileManager = getMockDatafileManager({}); - const manager = new ProjectConfigManagerImpl({ datafile }); + const manager = new ProjectConfigManagerImpl({ datafile: JSON.stringify(datafile) }); const listener = vi.fn(); const dispose = manager.onUpdate(listener); @@ -404,11 +402,11 @@ describe('ProjectConfigManagerImpl', () => { manager.start(); await manager.onRunning(); - expect(listener).toHaveBeenNthCalledWith(1, createProjectConfig(datafile)); + expect(listener).toHaveBeenNthCalledWith(1, createProjectConfig(JSON.stringify(datafile))); dispose(); - datafileManager.pushUpdate(cloneDeep(testData.getTestProjectConfigWithFeatures())); + datafileManager.pushUpdate(JSON.stringify(testData.getTestProjectConfigWithFeatures())); await Promise.resolve(); expect(listener).toHaveBeenCalledTimes(1); }); @@ -424,8 +422,8 @@ describe('ProjectConfigManagerImpl', () => { manager.start(); await manager.onRunning(); - expect(listener).toHaveBeenCalledWith(createProjectConfig(datafile)); - expect(manager.getConfig()).toEqual(createProjectConfig(datafile)); + expect(listener).toHaveBeenCalledWith(createProjectConfig(JSON.stringify(datafile))); + expect(manager.getConfig()).toEqual(createProjectConfig(JSON.stringify(datafile))); }); it('should reject onRunning() and log error if the datafile string is an invalid json', async () => { @@ -439,7 +437,7 @@ describe('ProjectConfigManagerImpl', () => { it('should reject onRunning() and log error if the datafile version is not supported', async () => { const logger = getMockLogger(); const datafile = testData.getUnsupportedVersionConfig(); - const manager = new ProjectConfigManagerImpl({ logger, datafile }); + const manager = new ProjectConfigManagerImpl({ logger, datafile: JSON.stringify(datafile) }); manager.start(); await expect(manager.onRunning()).rejects.toThrow(); @@ -476,7 +474,7 @@ describe('ProjectConfigManagerImpl', () => { }); it('should set status to Terminated immediately if no datafile manager is provided and resolve onTerminated', async () => { - const manager = new ProjectConfigManagerImpl({ datafile: testData.getTestProjectConfig() }); + const manager = new ProjectConfigManagerImpl({ datafile: JSON.stringify(testData.getTestProjectConfig()) }); manager.stop(); expect(manager.getState()).toBe(ServiceState.Terminated); await expect(manager.onTerminated()).resolves.not.toThrow(); @@ -487,7 +485,7 @@ describe('ProjectConfigManagerImpl', () => { const datafileManager = getMockDatafileManager({ onRunning: Promise.resolve(), onTerminated: datafileManagerTerminated.promise }); const manager = new ProjectConfigManagerImpl({ datafileManager }); manager.start(); - datafileManager.pushUpdate(testData.getTestProjectConfig()); + datafileManager.pushUpdate(JSON.stringify(testData.getTestProjectConfig())); await manager.onRunning(); manager.stop(); @@ -502,7 +500,7 @@ describe('ProjectConfigManagerImpl', () => { const datafileManager = getMockDatafileManager({ onRunning: Promise.resolve(), onTerminated: datafileManagerTerminated.promise }); const manager = new ProjectConfigManagerImpl({ datafileManager }); manager.start(); - datafileManager.pushUpdate(testData.getTestProjectConfig()); + datafileManager.pushUpdate(JSON.stringify(testData.getTestProjectConfig())); await manager.onRunning(); manager.stop(); @@ -521,7 +519,7 @@ describe('ProjectConfigManagerImpl', () => { const datafileManager = getMockDatafileManager({ onRunning: Promise.resolve(), onTerminated: datafileManagerTerminated.promise }); const manager = new ProjectConfigManagerImpl({ datafileManager }); manager.start(); - datafileManager.pushUpdate(testData.getTestProjectConfig()); + datafileManager.pushUpdate(JSON.stringify(testData.getTestProjectConfig())); await manager.onRunning(); manager.stop(); @@ -544,12 +542,12 @@ describe('ProjectConfigManagerImpl', () => { const listener = vi.fn(); manager.onUpdate(listener); - datafileManager.pushUpdate(testData.getTestProjectConfig()); + datafileManager.pushUpdate(JSON.stringify(testData.getTestProjectConfig())); await manager.onRunning(); expect(listener).toHaveBeenCalledTimes(1); manager.stop(); - datafileManager.pushUpdate(testData.getTestProjectConfigWithFeatures()); + datafileManager.pushUpdate(JSON.stringify(testData.getTestProjectConfigWithFeatures())); datafileManagerTerminated.resolve(); await expect(manager.onTerminated()).resolves.not.toThrow(); diff --git a/lib/project_config/project_config_manager.ts b/lib/project_config/project_config_manager.ts index b7d527a23..0985c5c3a 100644 --- a/lib/project_config/project_config_manager.ts +++ b/lib/project_config/project_config_manager.ts @@ -17,7 +17,7 @@ import { LoggerFacade } from '../logging/logger'; import { createOptimizelyConfig } from './optimizely_config'; import { OptimizelyConfig } from '../shared_types'; import { DatafileManager } from './datafile_manager'; -import { ProjectConfig, toDatafile, tryCreatingProjectConfig } from './project_config'; +import { ProjectConfig, toDatafile, createProjectConfig } from './project_config'; import { Service, ServiceState, BaseService } from '../service'; import { Consumer, Fn, Transformer } from '../utils/type'; import { EventEmitter } from '../utils/event_emitter/event_emitter'; @@ -33,7 +33,7 @@ export const GOT_INVALID_DATAFILE = 'got invalid datafile'; import { sprintf } from '../utils/fns'; import { Platform } from '../platform_support'; interface ProjectConfigManagerConfig { - datafile?: string | Record; + datafile?: string; jsonSchemaValidator?: Transformer, datafileManager?: DatafileManager; logger?: LoggerFacade; @@ -57,7 +57,7 @@ export interface ProjectConfigManager extends Service { export const LOGGER_NAME = 'ProjectConfigManager'; export class ProjectConfigManagerImpl extends BaseService implements ProjectConfigManager { - private datafile?: string | object; + private datafile?: string; private projectConfig?: ProjectConfig; private optimizelyConfig?: OptimizelyConfig; public jsonSchemaValidator?: Transformer; @@ -146,14 +146,13 @@ export class ProjectConfigManagerImpl extends BaseService implements ProjectConf * the project config and optimizely config objects will not be updated. If the error * is fatal, handleInitError will be called. */ - private handleNewDatafile(newDatafile: string | object, fromConfig = false): void { + private handleNewDatafile(newDatafile: string, fromConfig = false): void { if (this.isDone()) { return; } try { - const config = tryCreatingProjectConfig({ - datafile: newDatafile, + const config = createProjectConfig(newDatafile, { jsonSchemaValidator: this.jsonSchemaValidator, logger: this.logger, }); diff --git a/lib/shared_types.ts b/lib/shared_types.ts index c46b38da6..6640622ab 100644 --- a/lib/shared_types.ts +++ b/lib/shared_types.ts @@ -163,6 +163,7 @@ export interface Experiment extends ExperimentCore { status: string; forcedVariations?: { [key: string]: string }; isRollout?: boolean; + type?: string; cmab?: { trafficAllocation: number; attributeIds: string[]; @@ -175,15 +176,25 @@ export interface Holdout extends ExperimentCore { status: HoldoutStatus; includedFlags: string[]; excludedFlags: string[]; + /** + * Per-rule targeting for local holdouts. Required on entries from the + * `localHoldouts` datafile section; stripped from entries in the `holdouts` + * section at parse time. Scope is determined by datafile section membership, + * not this field. + */ + includedRules?: string[] | null; + /** + * True if this holdout came from the `holdouts` (global) datafile section. + * Computed during config parsing in parseHoldoutsConfig — `includedRules` is + * stripped from global-section entries, so this stays consistent with section + * membership. + */ + isGlobal: boolean; } export function isHoldout(obj: Experiment | Holdout): obj is Holdout { - // Holdout has 'status', 'includedFlags', and 'excludedFlags' properties - return ( - (obj as Holdout).status !== undefined && - Array.isArray((obj as Holdout).includedFlags) && - Array.isArray((obj as Holdout).excludedFlags) - ); + // Holdout doesn't have 'layerId' property, while Experiment does + return (obj as Experiment).layerId === undefined; } export enum VariableType { diff --git a/lib/tests/mock/mock_datafile_manager.ts b/lib/tests/mock/mock_datafile_manager.ts index 1c9d66b38..2a48f3660 100644 --- a/lib/tests/mock/mock_datafile_manager.ts +++ b/lib/tests/mock/mock_datafile_manager.ts @@ -21,14 +21,14 @@ import { BaseService } from '../../service'; import { LoggerFacade } from '../../logging/logger'; type MockConfig = { - datafile?: string | object; + datafile?: string; onRunning?: Promise, onTerminated?: Promise, } class MockDatafileManager extends BaseService implements DatafileManager { eventEmitter: EventEmitter<{ update: string}> = new EventEmitter(); - datafile: string | object | undefined; + datafile: string | undefined; constructor(opt: MockConfig) { super(); @@ -49,9 +49,6 @@ class MockDatafileManager extends BaseService implements DatafileManager { } get(): string | undefined { - if (typeof this.datafile === 'object') { - return JSON.stringify(this.datafile); - } return this.datafile; } @@ -63,10 +60,7 @@ class MockDatafileManager extends BaseService implements DatafileManager { return this.eventEmitter.on('update', listener) } - pushUpdate(datafile: string | object): void { - if (typeof datafile === 'object') { - datafile = JSON.stringify(datafile); - } + pushUpdate(datafile: string): void { this.datafile = datafile; this.eventEmitter.emit('update', datafile); } diff --git a/lib/tests/uuid_cjs_shim.js b/lib/tests/uuid_cjs_shim.js new file mode 100644 index 000000000..117f990da --- /dev/null +++ b/lib/tests/uuid_cjs_shim.js @@ -0,0 +1,22 @@ +/** + * CJS require hook that intercepts `require('uuid')` to provide a CJS-compatible + * shim. Needed because uuid v13+ is ESM-only and cannot be loaded via require(). + * Uses Node's built-in crypto.randomUUID() which produces identical RFC 4122 v4 UUIDs. + */ +const Module = require('module'); // eslint-disable-line @typescript-eslint/no-var-requires +const crypto = require('crypto'); // eslint-disable-line @typescript-eslint/no-var-requires + +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +const uuidShim = { + v4: () => crypto.randomUUID(), + validate: (str) => typeof str === 'string' && UUID_REGEX.test(str), +}; + +const originalLoad = Module._load; +Module._load = function (request, parent, isMain) { + if (request === 'uuid') { + return uuidShim; + } + return originalLoad.call(this, request, parent, isMain); +}; diff --git a/lib/utils/config_validator/index.spec.ts b/lib/utils/config_validator/index.spec.ts deleted file mode 100644 index c8496ecc4..000000000 --- a/lib/utils/config_validator/index.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Copyright 2025, Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect } from 'vitest'; -import configValidator from './'; -import testData from '../../tests/test_data'; -import { INVALID_DATAFILE_MALFORMED, INVALID_DATAFILE_VERSION, NO_DATAFILE_SPECIFIED } from 'error_message'; -import { OptimizelyError } from '../../error/optimizly_error'; - -describe('validate', () => { - it('should complain if datafile is not provided', () => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - expect(() => configValidator.validateDatafile()).toThrow(OptimizelyError); - - try { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - configValidator.validateDatafile(); - } catch (err) { - expect(err).toBeInstanceOf(OptimizelyError); - expect(err.baseMessage).toBe(NO_DATAFILE_SPECIFIED); - } - }); - - it('should complain if datafile is malformed', () => { - expect(() => configValidator.validateDatafile('abc')).toThrow( OptimizelyError); - - try { - configValidator.validateDatafile('abc'); - } catch(err) { - expect(err).toBeInstanceOf(OptimizelyError); - expect(err.baseMessage).toBe(INVALID_DATAFILE_MALFORMED); - } - }); - - it('should complain if datafile version is not supported', () => { - expect(() => configValidator.validateDatafile(JSON.stringify(testData.getUnsupportedVersionConfig())).toThrow(OptimizelyError)); - - try { - configValidator.validateDatafile(JSON.stringify(testData.getUnsupportedVersionConfig())); - } catch(err) { - expect(err).toBeInstanceOf(OptimizelyError); - expect(err.baseMessage).toBe(INVALID_DATAFILE_VERSION); - expect(err.params).toEqual(['5']); - } - }); - - it('should not complain if datafile is valid', () => { - expect(() => configValidator.validateDatafile(JSON.stringify(testData.getTestProjectConfig())).not.toThrowError()); - }); -}); diff --git a/lib/utils/config_validator/index.tests.js b/lib/utils/config_validator/index.tests.js deleted file mode 100644 index 2680a07da..000000000 --- a/lib/utils/config_validator/index.tests.js +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Copyright 2016, 2018-2020, 2022, Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { assert } from 'chai'; -import { sprintf } from '../../utils/fns'; - -import configValidator from './'; -import testData from '../../tests/test_data'; -import { - INVALID_DATAFILE_MALFORMED, - INVALID_DATAFILE_VERSION, - INVALID_ERROR_HANDLER, - INVALID_EVENT_DISPATCHER, - INVALID_LOGGER, - NO_DATAFILE_SPECIFIED, -} from 'error_message'; - -describe('lib/utils/config_validator', function() { - describe('APIs', function() { - describe('validate', function() { - it.skip('should complain if the provided error handler is invalid', function() { - const ex = assert.throws(function() { - configValidator.validate({ - errorHandler: {}, - }); - }); - assert.equal(ex.baseMessage, INVALID_ERROR_HANDLER); - }); - - it.skip('should complain if the provided event dispatcher is invalid', function() { - const ex = assert.throws(function() { - configValidator.validate({ - eventDispatcher: {}, - }); - }); - assert.equal(ex.baseMessage, INVALID_EVENT_DISPATCHER); - }); - - it.skip('should complain if the provided logger is invalid', function() { - const ex = assert.throws(function() { - configValidator.validate({ - logger: {}, - }); - }); - assert.equal(ex.baseMessage, INVALID_LOGGER); - }); - - it('should complain if datafile is not provided', function() { - const ex = assert.throws(function() { - configValidator.validateDatafile(); - }); - assert.equal(ex.baseMessage, NO_DATAFILE_SPECIFIED); - }); - - it('should complain if datafile is malformed', function() { - const ex = assert.throws(function() { - configValidator.validateDatafile('abc'); - }); - assert.equal(ex.baseMessage, INVALID_DATAFILE_MALFORMED); - }); - - it('should complain if datafile version is not supported', function() { - const ex = assert.throws(function() { - configValidator.validateDatafile(JSON.stringify(testData.getUnsupportedVersionConfig())); - }); - assert.equal(ex.baseMessage, INVALID_DATAFILE_VERSION); - assert.deepEqual(ex.params, ['5']); - }); - - it('should not complain if datafile is valid', function() { - assert.doesNotThrow(function() { - configValidator.validateDatafile(JSON.stringify(testData.getTestProjectConfig())); - }); - }); - }); - }); -}); diff --git a/lib/utils/config_validator/index.ts b/lib/utils/config_validator/index.ts deleted file mode 100644 index 4b59b066d..000000000 --- a/lib/utils/config_validator/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Copyright 2016, 2018-2020, 2022, 2025, Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - DATAFILE_VERSIONS, -} from '../enums'; -import { - INVALID_DATAFILE_MALFORMED, - INVALID_DATAFILE_VERSION, - NO_DATAFILE_SPECIFIED, -} from 'error_message'; -import { OptimizelyError } from '../../error/optimizly_error'; -import { Platform } from '../../platform_support'; -const SUPPORTED_VERSIONS = [DATAFILE_VERSIONS.V2, DATAFILE_VERSIONS.V3, DATAFILE_VERSIONS.V4]; - -/** - * Validates the datafile - * @param {Object|string} datafile - * @return {Object} The datafile object if the datafile is valid - * @throws If the datafile is not valid for any of the following reasons: - - The datafile string is undefined - - The datafile string cannot be parsed as a JSON object - - The datafile version is not supported - */ -// eslint-disable-next-line -export const validateDatafile = function(datafile: unknown): any { - if (!datafile) { - throw new OptimizelyError(NO_DATAFILE_SPECIFIED); - } - if (typeof datafile === 'string') { - // Attempt to parse the datafile string - try { - datafile = JSON.parse(datafile); - } catch (ex) { - throw new OptimizelyError(INVALID_DATAFILE_MALFORMED); - } - } - if (typeof datafile === 'object' && !Array.isArray(datafile) && datafile !== null) { - if (SUPPORTED_VERSIONS.indexOf(datafile['version' as keyof unknown]) === -1) { - throw new OptimizelyError(INVALID_DATAFILE_VERSION, datafile['version' as keyof unknown]); - } - } else { - throw new OptimizelyError(INVALID_DATAFILE_MALFORMED); - } - - return datafile; -}; - -export default { - validateDatafile: validateDatafile, -} - -export const __platforms: Platform[] = ['__universal__']; diff --git a/lib/utils/enums/index.ts b/lib/utils/enums/index.ts index abc8e0b1d..65f511fe8 100644 --- a/lib/utils/enums/index.ts +++ b/lib/utils/enums/index.ts @@ -44,7 +44,7 @@ export const CONTROL_ATTRIBUTES = { export const JAVASCRIPT_CLIENT_ENGINE = 'javascript-sdk'; export const NODE_CLIENT_ENGINE = 'node-sdk'; export const REACT_NATIVE_JS_CLIENT_ENGINE = 'react-native-js-sdk'; -export const CLIENT_VERSION = '6.3.1'; +export const CLIENT_VERSION = '6.5.0'; /* * Represents the source of a decision for feature management. When a feature @@ -61,6 +61,14 @@ export const DECISION_SOURCES = { export type DecisionSource = typeof DECISION_SOURCES[keyof typeof DECISION_SOURCES]; +export const EXPERIMENT_TYPES = { + AB: 'ab', + MAB: 'mab', + CMAB: 'cmab', + TD: 'td', + FR: 'fr', +} as const; + export const AUDIENCE_EVALUATION_TYPES = { RULE: 'rule', EXPERIMENT: 'experiment', diff --git a/lib/utils/http_request_handler/request_handler.node.spec.ts b/lib/utils/http_request_handler/request_handler.node.spec.ts index 33f190676..b982c5ec5 100644 --- a/lib/utils/http_request_handler/request_handler.node.spec.ts +++ b/lib/utils/http_request_handler/request_handler.node.spec.ts @@ -16,10 +16,13 @@ import { describe, beforeEach, afterEach, beforeAll, afterAll, it, vi, expect } from 'vitest'; +import http from 'http'; import nock from 'nock'; import zlib from 'zlib'; import { NodeRequestHandler } from './request_handler.node'; import { getMockLogger } from '../../tests/mock/mock_logger'; +import { OptimizelyError } from '../../error/optimizly_error'; +import { INVALID_REQUEST_URL } from 'error_message'; beforeAll(() => { nock.disableNetConnect(); @@ -172,6 +175,16 @@ describe('NodeRequestHandler', () => { await expect(request.responsePromise).rejects.toThrow(); }); + it('should return a rejected response promise when the URL is malformed', async () => { + const malformedUrl = 'not a valid url'; + const request = nodeRequestHandler.makeRequest(malformedUrl, {}, 'get'); + + const error = await request.responsePromise.catch((e) => e); + expect(error).toBeInstanceOf(OptimizelyError); + expect(error.baseMessage).toBe(INVALID_REQUEST_URL); + expect(error.params).toEqual([malformedUrl]); + }); + it('should return a rejected promise when there is a request error', async () => { const scope = nock(host) .get(path) @@ -202,6 +215,79 @@ describe('NodeRequestHandler', () => { scope.done(); }); + describe('multi-byte unicode data', () => { + let server: http.Server; + let port: number; + let receivedBody: string; + + beforeAll(async () => { + nock.enableNetConnect('localhost'); + server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + receivedBody = Buffer.concat(chunks).toString('utf8'); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + }); + + await new Promise((resolve) => { + server.listen(0, () => { + port = (server.address() as { port: number }).port; + resolve(); + }); + }); + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + nock.disableNetConnect(); + }); + + it('should correctly transmit ASCII data', async () => { + const data = '{"key":"value"}'; + + const { responsePromise } = nodeRequestHandler.makeRequest( + `http://localhost:${port}/test`, + { 'content-type': 'application/json' }, + 'POST', + data, + ); + await responsePromise; + + expect(receivedBody).toBe(data); + }); + + it('should correctly transmit emoji data', async () => { + const data = JSON.stringify({ message: '🚀 launch' }); + + const { responsePromise } = nodeRequestHandler.makeRequest( + `http://localhost:${port}/test`, + { 'content-type': 'application/json' }, + 'POST', + data, + ); + await responsePromise; + + expect(receivedBody).toBe(data); + }); + + it('should correctly transmit CJK character data', async () => { + const data = JSON.stringify({ greeting: '你好世界' }); + + const { responsePromise } = nodeRequestHandler.makeRequest( + `http://localhost:${port}/test`, + { 'content-type': 'application/json' }, + 'POST', + data, + ); + await responsePromise; + + expect(receivedBody).toBe(data); + }); + }); + describe('timeout', () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/lib/utils/http_request_handler/request_handler.node.ts b/lib/utils/http_request_handler/request_handler.node.ts index b972c0526..51202ca13 100644 --- a/lib/utils/http_request_handler/request_handler.node.ts +++ b/lib/utils/http_request_handler/request_handler.node.ts @@ -15,12 +15,11 @@ */ import http from 'http'; import https from 'https'; -import url from 'url'; import { AbortableRequest, Headers, RequestHandler, Response } from './http'; import decompressResponse from 'decompress-response'; import { LoggerFacade } from '../../logging/logger'; import { REQUEST_TIMEOUT_MS } from '../enums'; -import { NO_STATUS_CODE_IN_RESPONSE, REQUEST_ERROR, REQUEST_TIMEOUT, UNSUPPORTED_PROTOCOL } from 'error_message'; +import { INVALID_REQUEST_URL, NO_STATUS_CODE_IN_RESPONSE, REQUEST_ERROR, REQUEST_TIMEOUT, UNSUPPORTED_PROTOCOL } from 'error_message'; import { OptimizelyError } from '../../error/optimizly_error'; import { Platform } from '../../platform_support'; /** @@ -46,7 +45,15 @@ export class NodeRequestHandler implements RequestHandler { * @returns AbortableRequest contains both the response Promise and capability to abort() */ makeRequest(requestUrl: string, headers: Headers, method: string, data?: string): AbortableRequest { - const parsedUrl = url.parse(requestUrl); + let parsedUrl: URL; + try { + parsedUrl = new URL(requestUrl); + } catch { + return { + responsePromise: Promise.reject(new OptimizelyError(INVALID_REQUEST_URL, requestUrl)), + abort: () => {}, + }; + } if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') { return { @@ -62,16 +69,13 @@ export class NodeRequestHandler implements RequestHandler { headers: { ...headers, 'accept-encoding': 'gzip,deflate', - 'content-length': String(data?.length || 0) + 'content-length': String(data ? Buffer.byteLength(data) : 0), }, timeout: this.timeout, }); const abortableRequest = this.getAbortableRequestFromRequest(request); - if (data) { - request.write(data); - } - request.end(); + request.end(data); return abortableRequest; } @@ -82,11 +86,11 @@ export class NodeRequestHandler implements RequestHandler { * @private * @returns http.RequestOptions Standard request options dictionary compatible with both http and https */ - private getRequestOptionsFromUrl(url: url.UrlWithStringQuery): http.RequestOptions { + private getRequestOptionsFromUrl(url: URL): http.RequestOptions { return { hostname: url.hostname, - path: url.path, - port: url.port, + path: url.pathname + url.search, + port: url.port || null, protocol: url.protocol, }; } diff --git a/message_generator.ts b/message_generator.ts deleted file mode 100644 index 5571f84e7..000000000 --- a/message_generator.ts +++ /dev/null @@ -1,36 +0,0 @@ -import path from 'path'; -import { writeFile } from 'fs/promises'; - -const generate = async () => { - const inp = process.argv.slice(2); - for(const filePath of inp) { - console.log('generating messages for: ', filePath); - const parsedPath = path.parse(filePath); - const fileName = parsedPath.name; - const dirName = parsedPath.dir; - const ext = parsedPath.ext; - - const genFilePath = path.join(dirName, `${fileName}.gen${ext}`); - console.log('generated file path: ', genFilePath); - const exports = await import(filePath); - const messages : Array = []; - - let genOut = ''; - - Object.keys(exports).forEach((key, i) => { - if (key === 'messages' || key === '__platforms') return; - genOut += `export const ${key} = '${i}';\n`; - messages.push(exports[key]) - }); - - genOut += `export const messages = ${JSON.stringify(messages, null, 2)};` - await writeFile(genFilePath, genOut, 'utf-8'); - } -} - -generate().then(() => { - console.log('successfully generated messages'); -}).catch((e) => { - console.error(e); - process.exit(1); -}); diff --git a/package-lock.json b/package-lock.json index 7edefb829..3812dc53d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "@optimizely/optimizely-sdk", - "version": "6.3.1", + "version": "6.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@optimizely/optimizely-sdk", - "version": "6.3.1", + "version": "6.5.0", "license": "Apache-2.0", "dependencies": { "decompress-response": "^7.0.0", "json-schema": "^0.4.0", "murmurhash": "^2.0.1", - "uuid": "^10.0.0" + "uuid": "^13.0.2" }, "devDependencies": { "@react-native-async-storage/async-storage": "^2", @@ -26,10 +26,10 @@ "@types/nise": "^1.4.0", "@types/node": "^18.7.18", "@types/ua-parser-js": "^0.7.36", - "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^5.33.0", "@typescript-eslint/parser": "^5.33.0", "@vitest/browser": "3.2.4", + "browserstack-local": "^1.5.13", "chai": "^4.2.0", "coveralls-next": "^5.0.0", "eslint": "^8.21.0", @@ -37,14 +37,6 @@ "eslint-plugin-local-rules": "^3.0.2", "eslint-plugin-prettier": "^3.1.2", "happy-dom": "^20.0.11", - "jiti": "^2.4.1", - "karma": "^6.4.0", - "karma-browserstack-launcher": "^1.5.1", - "karma-chai": "^0.1.0", - "karma-chrome-launcher": "^2.1.1", - "karma-mocha": "^2.0.1", - "karma-webpack": "^5.0.1", - "lodash": "^4.17.11", "minimatch": "^9.0.5", "mocha": "^10.2.0", "mocha-lcov-reporter": "^1.3.0", @@ -56,7 +48,7 @@ "rollup": "^4.55.1", "sinon": "^2.3.1", "ts-loader": "^9.3.1", - "ts-node": "^8.10.2", + "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "tslib": "^2.8.1", "typescript": "^4.7.4", @@ -624,14 +616,26 @@ "node": ">=6.9.0" } }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "dev": true, - "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, "engines": { - "node": ">=0.1.90" + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" } }, "node_modules/@esbuild/aix-ppc64": { @@ -1130,9 +1134,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -1141,9 +1145,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -1180,9 +1184,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -1191,9 +1195,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -1295,13 +1299,13 @@ } }, "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -1381,9 +1385,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "dev": true, "license": "MIT", "dependencies": { @@ -1776,9 +1780,9 @@ } }, "node_modules/@puppeteer/browsers/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "dev": true, "license": "MIT", "dependencies": { @@ -1878,9 +1882,9 @@ } }, "node_modules/@react-native/codegen/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "dev": true, "license": "MIT", "peer": true, @@ -1992,9 +1996,9 @@ } }, "node_modules/@react-native/dev-middleware/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", "dev": true, "license": "MIT", "peer": true, @@ -2580,13 +2584,6 @@ "dev": true, "license": "(Unlicense OR Apache-2.0)" }, - "node_modules/@socket.io/component-emitter": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", - "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", - "dev": true, - "license": "MIT" - }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -2628,6 +2625,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2691,16 +2712,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/cors": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", - "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2845,13 +2856,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", @@ -3451,13 +3455,13 @@ } }, "node_modules/@wdio/logger/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -3487,9 +3491,9 @@ } }, "node_modules/@wdio/repl/node_modules/@types/node": { - "version": "20.19.28", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.28.tgz", - "integrity": "sha512-VyKBr25BuFDzBFCK5sUM6ZXiWfqgCTwTAOK8qzGV/m9FCirXYDlmczJ+d5dXBAQALGCdRRdbteKYfJ84NGEusw==", + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "dev": true, "license": "MIT", "dependencies": { @@ -3517,9 +3521,9 @@ } }, "node_modules/@wdio/types/node_modules/@types/node": { - "version": "20.19.28", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.28.tgz", - "integrity": "sha512-VyKBr25BuFDzBFCK5sUM6ZXiWfqgCTwTAOK8qzGV/m9FCirXYDlmczJ+d5dXBAQALGCdRRdbteKYfJ84NGEusw==", + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "dev": true, "license": "MIT", "dependencies": { @@ -3778,6 +3782,7 @@ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" @@ -3822,12 +3827,23 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "dev": true, - "license": "MIT", "dependencies": { "debug": "4" }, @@ -3850,9 +3866,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -3885,9 +3901,9 @@ } }, "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { @@ -3967,9 +3983,9 @@ } }, "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -4444,16 +4460,6 @@ ], "license": "MIT" }, - "node_modules/base64id": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", - "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^4.5.0 || >= 5.9" - } - }, "node_modules/baseline-browser-mapping": { "version": "2.9.14", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", @@ -4487,48 +4493,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.14.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -4537,9 +4501,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -4600,42 +4564,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/browserstack": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/browserstack/-/browserstack-1.5.3.tgz", - "integrity": "sha512-AO+mECXsW4QcqC9bxwM29O7qWa7bJT94uBFzeb5brylIQwawuEziwq20dPYbins95GlWzOawgyDNdjYAo32EKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "https-proxy-agent": "^2.2.1" - } - }, "node_modules/browserstack-local": { - "version": "1.5.8", - "resolved": "https://registry.npmjs.org/browserstack-local/-/browserstack-local-1.5.8.tgz", - "integrity": "sha512-8p8APDD7bY8E806pZBratRQmd9quqB8o3jiOqxVHWTiYGAW5ePMqFDKbZaedUkFM52Dnfl5Gxviz2bHXiiV3DQ==", + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/browserstack-local/-/browserstack-local-1.5.13.tgz", + "integrity": "sha512-7helY+Ms3ss4BtIQZTIyshdAFZSvS9A7ZpEB9stRaobeZ9BM1BkJFTuMakQNTOj78llv0+/qDI5Ak+bkGWV1xg==", "dev": true, - "license": "MIT", "dependencies": { "agent-base": "^6.0.2", "https-proxy-agent": "^5.0.1", "is-running": "^2.1.0", - "ps-tree": "=1.2.0", - "temp-fs": "^0.9.9" - } - }, - "node_modules/browserstack-local/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" + "tree-kill": "^1.2.2" } }, "node_modules/bser": { @@ -4691,16 +4629,6 @@ "dev": true, "license": "MIT" }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -4727,37 +4655,6 @@ "node": ">=8" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -5117,6 +5014,7 @@ "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "debug": "2.6.9", "finalhandler": "1.1.2", @@ -5133,6 +5031,7 @@ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -5142,17 +5041,8 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true, - "license": "MIT" - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.6" - } + "peer": true }, "node_modules/convert-source-map": { "version": "1.9.0", @@ -5161,16 +5051,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -5178,20 +5058,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/coveralls-next": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/coveralls-next/-/coveralls-next-5.0.0.tgz", @@ -5238,6 +5104,12 @@ "node": ">= 14" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5296,13 +5168,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/custom-event": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", - "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==", - "dev": true, - "license": "MIT" - }, "node_modules/data-uri-to-buffer": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", @@ -5313,16 +5178,6 @@ "node": ">= 14" } }, - "node_modules/date-format": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", - "integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4.0" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5443,6 +5298,7 @@ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -5463,22 +5319,16 @@ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/di": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", - "integrity": "sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==", - "dev": true, - "license": "MIT" - }, "node_modules/diff": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", - "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -5518,19 +5368,6 @@ "dev": true, "license": "MIT" }, - "node_modules/dom-serialize": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", - "integrity": "sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "custom-event": "~1.0.0", - "ent": "~2.2.0", - "extend": "^3.0.0", - "void-elements": "^2.0.0" - } - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -5590,28 +5427,6 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/duplexer": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", - "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", - "dev": true, - "license": "MIT" - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -5698,23 +5513,23 @@ } }, "node_modules/edgedriver/node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=16" + "node": ">=20" } }, "node_modules/edgedriver/node_modules/which": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.0.tgz", - "integrity": "sha512-f+gEpIKMR9faW/JgAgPK1D7mekkFoqbmiwvNzuhsHetni20QSgzg9Vhn0g2JSJkkfehQnqdUAx7/e15qS1lPxg==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", "dev": true, "license": "ISC", "dependencies": { - "isexe": "^3.1.1" + "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" @@ -5728,7 +5543,8 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/electron-to-chromium": { "version": "1.5.267", @@ -5750,6 +5566,7 @@ "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -5768,19 +5585,6 @@ "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" } }, - "node_modules/encoding-sniffer/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -5791,106 +5595,37 @@ "once": "^1.4.0" } }, - "node_modules/engine.io": { - "version": "6.6.5", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz", - "integrity": "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==", + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", "dev": true, "license": "MIT", "dependencies": { - "@types/cors": "^2.8.12", - "@types/node": ">=10.0.0", - "accepts": "~1.3.4", - "base64id": "2.0.0", - "cookie": "~0.7.2", - "cors": "~2.8.5", - "debug": "~4.4.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.18.3" + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" }, "engines": { - "node": ">=10.2.0" + "node": ">=10.13.0" } }, - "node_modules/engine.io-parser": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", - "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "engines": { - "node": ">=10.0.0" + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/engine.io/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/enhanced-resolve": { - "version": "5.18.4", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", - "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/ent": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz", - "integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "punycode": "^1.4.1", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/error-stack-parser": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", - "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", "dev": true, "license": "MIT", "peer": true, @@ -5898,26 +5633,6 @@ "stackframe": "^1.3.4" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -5925,19 +5640,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es6-error": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", @@ -5945,23 +5647,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es6-promise": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", - "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/es6-promisify": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", - "integrity": "sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es6-promise": "^4.0.3" - } - }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -6019,7 +5704,8 @@ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/escape-string-regexp": { "version": "4.0.0", @@ -6196,9 +5882,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -6234,9 +5920,9 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -6362,22 +6048,6 @@ "node": ">= 0.6" } }, - "node_modules/event-stream": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", - "integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==", - "dev": true, - "license": "MIT", - "dependencies": { - "duplexer": "~0.1.1", - "from": "~0", - "map-stream": "~0.1.0", - "pause-stream": "0.0.11", - "split": "0.3", - "stream-combiner": "~0.0.4", - "through": "~2.3.1" - } - }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -6388,13 +6058,6 @@ "node": ">=6" } }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "dev": true, - "license": "MIT" - }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -6433,13 +6096,6 @@ "license": "Apache-2.0", "peer": true }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true, - "license": "MIT" - }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -6657,6 +6313,7 @@ "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", @@ -6676,6 +6333,7 @@ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -6685,7 +6343,8 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/finalhandler/node_modules/on-finished": { "version": "2.3.0", @@ -6693,6 +6352,7 @@ "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ee-first": "1.1.1" }, @@ -6775,27 +6435,6 @@ "license": "MIT", "peer": true }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/foreground-child": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", @@ -6832,13 +6471,6 @@ "node": ">= 0.6" } }, - "node_modules/from": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", - "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", - "dev": true, - "license": "MIT" - }, "node_modules/fromentries": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz", @@ -6860,34 +6492,6 @@ ], "license": "MIT" }, - "node_modules/fs-access": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fs-access/-/fs-access-1.0.1.tgz", - "integrity": "sha512-05cXDIwNbFaoFWaz5gNHlUTbH5whiss/hr/ibzPd4MH3cR4w0ZKeIPiVdbyJurg3O5r/Bjpvn9KOb1/rPMf3nA==", - "dev": true, - "license": "MIT", - "dependencies": { - "null-check": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -7009,31 +6613,6 @@ "node": "*" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -7057,20 +6636,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/get-stdin": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-6.0.0.tgz", @@ -7155,9 +6720,9 @@ "license": "BSD-2-Clause" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -7166,9 +6731,9 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -7215,19 +6780,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -7267,9 +6819,9 @@ } }, "node_modules/happy-dom/node_modules/@types/node": { - "version": "20.19.28", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.28.tgz", - "integrity": "sha512-VyKBr25BuFDzBFCK5sUM6ZXiWfqgCTwTAOK8qzGV/m9FCirXYDlmczJ+d5dXBAQALGCdRRdbteKYfJ84NGEusw==", + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "dev": true, "license": "MIT", "dependencies": { @@ -7293,35 +6845,6 @@ "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasha": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", @@ -7452,6 +6975,7 @@ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", @@ -7473,25 +6997,11 @@ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -7517,50 +7027,26 @@ } }, "node_modules/https-proxy-agent": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz", - "integrity": "sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^4.3.0", - "debug": "^3.1.0" - }, - "engines": { - "node": ">= 4.5.0" - } - }, - "node_modules/https-proxy-agent/node_modules/agent-base": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz", - "integrity": "sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "dev": true, - "license": "MIT", "dependencies": { - "es6-promisify": "^5.0.0" + "agent-base": "6", + "debug": "4" }, "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/https-proxy-agent/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" + "node": ">= 6" } }, "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" @@ -7835,31 +7321,11 @@ "@types/estree": "*" } }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-running": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-running/-/is-running-2.1.0.tgz", "integrity": "sha512-mjJd3PujZMl7j+D395WTIO5tU5RIDBfVSRtRR4VOJou3H66E38UjbjvDGh3slJzPuolsb+yQFqwHNNdyp5jg3w==", - "dev": true, - "license": "BSD" + "dev": true }, "node_modules/is-stream": { "version": "2.0.1", @@ -7925,19 +7391,6 @@ "dev": true, "license": "MIT" }, - "node_modules/isbinaryfile": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", - "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/gjtorikian/" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -8262,9 +7715,9 @@ } }, "node_modules/jest-util/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "peer": true, @@ -8386,6 +7839,8 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -8485,16 +7940,6 @@ "node": ">=6" } }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -8548,174 +7993,6 @@ "dev": true, "license": "MIT" }, - "node_modules/karma": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", - "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@colors/colors": "1.5.0", - "body-parser": "^1.19.0", - "braces": "^3.0.2", - "chokidar": "^3.5.1", - "connect": "^3.7.0", - "di": "^0.0.1", - "dom-serialize": "^2.2.1", - "glob": "^7.1.7", - "graceful-fs": "^4.2.6", - "http-proxy": "^1.18.1", - "isbinaryfile": "^4.0.8", - "lodash": "^4.17.21", - "log4js": "^6.4.1", - "mime": "^2.5.2", - "minimatch": "^3.0.4", - "mkdirp": "^0.5.5", - "qjobs": "^1.2.0", - "range-parser": "^1.2.1", - "rimraf": "^3.0.2", - "socket.io": "^4.7.2", - "source-map": "^0.6.1", - "tmp": "^0.2.1", - "ua-parser-js": "^0.7.30", - "yargs": "^16.1.1" - }, - "bin": { - "karma": "bin/karma" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/karma-browserstack-launcher": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/karma-browserstack-launcher/-/karma-browserstack-launcher-1.6.0.tgz", - "integrity": "sha512-Y/UWPdHZkHIVH2To4GWHCTzmrsB6H7PBWy6pw+TWz5sr4HW2mcE+Uj6qWgoVNxvQU1Pfn5LQQzI6EQ65p8QbiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "browserstack": "~1.5.1", - "browserstack-local": "^1.3.7", - "q": "~1.5.0" - }, - "peerDependencies": { - "karma": ">=0.9" - } - }, - "node_modules/karma-chai": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/karma-chai/-/karma-chai-0.1.0.tgz", - "integrity": "sha512-mqKCkHwzPMhgTYca10S90aCEX9+HjVjjrBFAsw36Zj7BlQNbokXXCAe6Ji04VUMsxcY5RLP7YphpfO06XOubdg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "chai": "*", - "karma": ">=0.10.9" - } - }, - "node_modules/karma-chrome-launcher": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/karma-chrome-launcher/-/karma-chrome-launcher-2.2.0.tgz", - "integrity": "sha512-uf/ZVpAabDBPvdPdveyk1EPgbnloPvFFGgmRhYLTDH7gEB4nZdSBk8yTU47w1g/drLSx5uMOkjKk7IWKfWg/+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "fs-access": "^1.0.0", - "which": "^1.2.1" - } - }, - "node_modules/karma-chrome-launcher/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, - "node_modules/karma-mocha": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/karma-mocha/-/karma-mocha-2.0.1.tgz", - "integrity": "sha512-Tzd5HBjm8his2OA4bouAsATYEpZrp9vC7z5E5j4C5Of5Rrs1jY67RAwXNcVmd/Bnk1wgvQRou0zGVLey44G4tQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.3" - } - }, - "node_modules/karma-webpack": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/karma-webpack/-/karma-webpack-5.0.1.tgz", - "integrity": "sha512-oo38O+P3W2mSPCSUrQdySSPv1LvPpXP+f+bBimNomS5sW+1V4SuhCuW8TfJzV+rDv921w2fDSDw0xJbPe6U+kQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob": "^7.1.3", - "minimatch": "^9.0.3", - "webpack-merge": "^4.1.5" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "webpack": "^5.0.0" - } - }, - "node_modules/karma/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/karma/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/karma/node_modules/ua-parser-js": { - "version": "0.7.41", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz", - "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/ua-parser-js" - }, - { - "type": "paypal", - "url": "https://paypal.me/faisalman" - }, - { - "type": "github", - "url": "https://github.com/sponsors/faisalman" - } - ], - "license": "MIT", - "bin": { - "ua-parser-js": "script/cli.js" - }, - "engines": { - "node": "*" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -8983,23 +8260,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log4js": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", - "integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "date-format": "^4.0.14", - "debug": "^4.3.4", - "flatted": "^3.2.7", - "rfdc": "^1.3.0", - "streamroller": "^3.1.5" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/loglevel": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", @@ -9123,12 +8383,6 @@ "tmpl": "1.0.5" } }, - "node_modules/map-stream": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", - "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==", - "dev": true - }, "node_modules/marky": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", @@ -9137,26 +8391,6 @@ "license": "Apache-2.0", "peer": true }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -9576,9 +8810,9 @@ } }, "node_modules/metro/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", "dev": true, "license": "MIT", "peer": true, @@ -9599,9 +8833,9 @@ } }, "node_modules/metro/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "dev": true, "license": "MIT", "peer": true, @@ -9644,9 +8878,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -9656,19 +8890,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -9705,13 +8926,13 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -9828,9 +9049,9 @@ } }, "node_modules/mocha/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, "license": "ISC", "dependencies": { @@ -9935,6 +9156,7 @@ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -10038,16 +9260,6 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "node_modules/null-check": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/null-check/-/null-check-1.0.0.tgz", - "integrity": "sha512-j8ZNHg19TyIQOWCGeeQJBuu6xZYIEurf8M1Qsfd8mFrGEfIZytbw18YjKWg+LcO25NowXGZXZpKAx+Ui3TFfDw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/nullthrows": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", @@ -10249,35 +9461,13 @@ "node": ">=20.19.4" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ee-first": "1.1.1" }, @@ -10546,6 +9736,7 @@ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -10612,9 +9803,9 @@ "license": "ISC" }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "dev": true, "license": "MIT", "funding": { @@ -10649,19 +9840,6 @@ "node": "*" } }, - "node_modules/pause-stream": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", - "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", - "dev": true, - "license": [ - "MIT", - "Apache2" - ], - "dependencies": { - "through": "~2.3" - } - }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -10677,9 +9855,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -10991,76 +10169,15 @@ "dev": true, "license": "MIT" }, - "node_modules/ps-tree": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-1.2.0.tgz", - "integrity": "sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "event-stream": "=3.3.4" - }, - "bin": { - "ps-tree": "bin/ps-tree.js" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/q": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", - "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", - "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6.0", - "teleport": ">=0.2.0" - } - }, - "node_modules/qjobs": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz", - "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.9" - } - }, - "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" } }, "node_modules/query-selector-shadow-dom": { @@ -11118,26 +10235,11 @@ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", @@ -11162,9 +10264,9 @@ } }, "node_modules/react-devtools-core/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", "dev": true, "license": "MIT", "peer": true, @@ -11306,9 +10408,9 @@ "peer": true }, "node_modules/react-native/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", "dev": true, "license": "MIT", "peer": true, @@ -11329,9 +10431,9 @@ } }, "node_modules/react-native/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "dev": true, "license": "MIT", "peer": true, @@ -11398,9 +10500,9 @@ } }, "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, "license": "ISC", "dependencies": { @@ -11424,9 +10526,9 @@ } }, "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -11484,13 +10586,6 @@ "dev": true, "license": "ISC" }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "license": "MIT" - }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -11560,13 +10655,6 @@ "node": ">=0.10.0" } }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, "node_modules/rgb2hex": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/rgb2hex/-/rgb2hex-0.2.5.tgz", @@ -11691,24 +10779,6 @@ ], "license": "MIT" }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/safe-regex2": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", @@ -11773,9 +10843,9 @@ } }, "node_modules/schema-utils/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { @@ -11810,9 +10880,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -11971,7 +11041,8 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/shebang-command": { "version": "2.0.0", @@ -12010,82 +11081,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -12122,9 +11117,9 @@ } }, "node_modules/sinon/node_modules/diff": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", - "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.1.tgz", + "integrity": "sha512-Z3u54A8qGyqFOSr2pk0ijYs8mOE9Qz8kTvtKeBI+upoG9j04Sq+oI7W8zAJiQybDcESET8/uIdHzs0p3k4fZlw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -12191,72 +11186,6 @@ "dev": true, "license": "MIT" }, - "node_modules/socket.io": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", - "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "~1.3.4", - "base64id": "~2.0.0", - "cors": "~2.8.5", - "debug": "~4.4.1", - "engine.io": "~6.6.0", - "socket.io-adapter": "~2.5.2", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.2.0" - } - }, - "node_modules/socket.io-adapter": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", - "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "~4.4.1", - "ws": "~8.18.3" - } - }, - "node_modules/socket.io-adapter/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/socket.io-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", - "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/socks": { "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", @@ -12363,19 +11292,6 @@ "node": ">=8" } }, - "node_modules/split": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", - "integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "through": "2" - }, - "engines": { - "node": "*" - } - }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -12464,6 +11380,7 @@ "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -12475,31 +11392,6 @@ "dev": true, "license": "MIT" }, - "node_modules/stream-combiner": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", - "integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "duplexer": "~0.1.1" - } - }, - "node_modules/streamroller": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", - "integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "date-format": "^4.0.14", - "debug": "^4.3.4", - "fs-extra": "^8.1.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/streamx": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", @@ -12703,33 +11595,6 @@ "streamx": "^2.15.0" } }, - "node_modules/temp-fs": { - "version": "0.9.9", - "resolved": "https://registry.npmjs.org/temp-fs/-/temp-fs-0.9.9.tgz", - "integrity": "sha512-WfecDCR1xC9b0nsrzSaxPf3ZuWeWLUWblW4vlDQAa1biQaKHiImHnJfeQocQe/hXKMcolRzgkcVX/7kK4zoWbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "rimraf": "~2.5.2" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/temp-fs/node_modules/rimraf": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.5.4.tgz", - "integrity": "sha512-Lw7SHMjssciQb/rRz7JyPIy9+bbUshEucPoLRvWqy09vC5zQixl8Uet+Zl+SROBB/JMWHJRdCk1qdxNWHNMvlQ==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.0.5" - }, - "bin": { - "rimraf": "bin.js" - } - }, "node_modules/terser": { "version": "5.44.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", @@ -12838,9 +11703,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -12849,9 +11714,9 @@ } }, "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -12894,13 +11759,6 @@ "license": "MIT", "peer": true }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true, - "license": "MIT" - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -12962,16 +11820,6 @@ "node": ">=14.0.0" } }, - "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -12999,6 +11847,7 @@ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.6" } @@ -13013,6 +11862,15 @@ "node": ">=6" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/ts-loader": { "version": "9.5.4", "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.4.tgz", @@ -13045,35 +11903,52 @@ } }, "node_modules/ts-node": { - "version": "8.10.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-8.10.2.tgz", - "integrity": "sha512-ISJJGgkIpDdBhWVu3jufsWpK3Rzo7bdiIXJjQc0ynKxVOVcg2oIrf2H2cejminGrptVc6q6/uynAHNCuWGbpVA==", - "dev": true, - "license": "MIT", - "dependencies": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", "arg": "^4.1.0", + "create-require": "^1.1.0", "diff": "^4.0.1", "make-error": "^1.1.1", - "source-map-support": "^0.5.17", + "v8-compile-cache-lib": "^3.0.1", "yn": "3.1.1" }, "bin": { "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", "ts-node-script": "dist/bin-script.js", "ts-node-transpile-only": "dist/bin-transpile.js", "ts-script": "dist/bin-script-deprecated.js" }, - "engines": { - "node": ">=6.0.0" - }, "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } } }, "node_modules/ts-node/node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -13171,20 +12046,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", @@ -13210,9 +12071,9 @@ } }, "node_modules/undici": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz", - "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { @@ -13226,22 +12087,13 @@ "dev": true, "license": "MIT" }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -13327,32 +12179,28 @@ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4.0" } }, "node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], - "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true }, "node_modules/vite": { "version": "6.4.1", @@ -13601,16 +12449,6 @@ "license": "MIT", "peer": true }, - "node_modules/void-elements": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", - "integrity": "sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/wait-port": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/wait-port/-/wait-port-1.1.0.tgz", @@ -13688,9 +12526,9 @@ } }, "node_modules/webdriver/node_modules/@types/node": { - "version": "20.19.28", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.28.tgz", - "integrity": "sha512-VyKBr25BuFDzBFCK5sUM6ZXiWfqgCTwTAOK8qzGV/m9FCirXYDlmczJ+d5dXBAQALGCdRRdbteKYfJ84NGEusw==", + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "dev": true, "license": "MIT", "dependencies": { @@ -13722,9 +12560,9 @@ } }, "node_modules/webdriver/node_modules/undici": { - "version": "6.23.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", - "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", "dev": true, "license": "MIT", "engines": { @@ -13784,9 +12622,9 @@ } }, "node_modules/webdriverio/node_modules/@types/node": { - "version": "20.19.28", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.28.tgz", - "integrity": "sha512-VyKBr25BuFDzBFCK5sUM6ZXiWfqgCTwTAOK8qzGV/m9FCirXYDlmczJ+d5dXBAQALGCdRRdbteKYfJ84NGEusw==", + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "dev": true, "license": "MIT", "dependencies": { @@ -13891,16 +12729,6 @@ } } }, - "node_modules/webpack-merge": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-4.2.2.tgz", - "integrity": "sha512-TUE1UGoTX2Cd42j3krGYqObZbOD+xF7u28WB7tfUordytSjbWTIjK/8V0amkBfTYN4/pB/GIDlJZZ657BGG19g==", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash": "^4.17.15" - } - }, "node_modules/webpack-sources": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", @@ -13912,9 +12740,9 @@ } }, "node_modules/webpack/node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", "dev": true, "license": "MIT" }, @@ -13932,19 +12760,6 @@ "node": ">=18" } }, - "node_modules/whatwg-encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/whatwg-fetch": { "version": "3.6.20", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", @@ -14078,9 +12893,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { @@ -14134,9 +12949,9 @@ } }, "node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 545b9eed5..f4f5fad6c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@optimizely/optimizely-sdk", - "version": "6.3.1", + "version": "6.5.0", "description": "JavaScript SDK for Optimizely Feature Experimentation, Optimizely Full Stack (legacy), and Optimizely Rollouts", "main": "./dist/index.node.min.js", "browser": "./dist/index.browser.es.min.js", @@ -67,13 +67,10 @@ "test-umd": "node ./scripts/run-umd-tests.js", "test-umd-local": "USE_LOCAL_BROWSER=true node ./scripts/run-umd-tests.js", "test-umd-browserstack": "USE_LOCAL_BROWSER=false node ./scripts/run-umd-tests.js", - "test-mocha": "TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\" }' mocha -r ts-node/register -r tsconfig-paths/register -r lib/tests/exit_on_unhandled_rejection.js 'lib/**/*.tests.ts' 'lib/**/*.tests.js'", + "test-mocha": "mocha -r lib/tests/uuid_cjs_shim.js -r ts-node/register -r tsconfig-paths/register -r lib/tests/exit_on_unhandled_rejection.js 'lib/**/*.tests.ts' 'lib/**/*.tests.js'", "test": "npm run test-mocha && npm run test-vitest", "posttest": "npm run lint", - "test-ci": "npm run test-xbrowser && npm run test-umdbrowser", - "test-xbrowser": "karma start karma.bs.conf.js --single-run", - "test-umdbrowser": "npm run build-browser-umd && karma start karma.umd.conf.js --single-run", - "test-karma-local": "karma start karma.local_chrome.bs.conf.js && npm run build-browser-umd && karma start karma.local_chrome.umd.conf.js", + "test-ci": "npm run test", "prebuild": "npm run clean", "build": "npm run validate-platform-isolation && npm run genmsg && tsc && rollup -c rollup.config.mjs && cp dist/index.browser.d.ts dist/index.d.ts", "build:win": "npm run validate-platform-isolation && npm run genmsg && tsc && rollup -c rollup.config.mjs && copy dist\\index.browser.d.ts dist\\index.d.ts", @@ -81,8 +78,9 @@ "coveralls": "nyc --reporter=lcov npm test", "prepare": "npm run build", "prepublishOnly": "npm test", - "genmsg": "jiti message_generator ./lib/message/error_message.ts ./lib/message/log_message.ts", - "test-typescript": "node ./scripts/run-ts-example.js" + "genmsg": "./scripts/generate-messages.js ./lib/message/error_message.ts ./lib/message/log_message.ts", + "test-typescript": "node ./scripts/run-ts-example.js", + "test-commonjs": "node ./scripts/run-cjs-example.js" }, "repository": { "type": "git", @@ -103,7 +101,7 @@ "decompress-response": "^7.0.0", "json-schema": "^0.4.0", "murmurhash": "^2.0.1", - "uuid": "^10.0.0" + "uuid": "^13.0.2" }, "devDependencies": { "@react-native-async-storage/async-storage": "^2", @@ -117,10 +115,10 @@ "@types/nise": "^1.4.0", "@types/node": "^18.7.18", "@types/ua-parser-js": "^0.7.36", - "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^5.33.0", "@typescript-eslint/parser": "^5.33.0", "@vitest/browser": "3.2.4", + "browserstack-local": "^1.5.13", "chai": "^4.2.0", "coveralls-next": "^5.0.0", "eslint": "^8.21.0", @@ -128,14 +126,6 @@ "eslint-plugin-local-rules": "^3.0.2", "eslint-plugin-prettier": "^3.1.2", "happy-dom": "^20.0.11", - "jiti": "^2.4.1", - "karma": "^6.4.0", - "karma-browserstack-launcher": "^1.5.1", - "karma-chai": "^0.1.0", - "karma-chrome-launcher": "^2.1.1", - "karma-mocha": "^2.0.1", - "karma-webpack": "^5.0.1", - "lodash": "^4.17.11", "minimatch": "^9.0.5", "mocha": "^10.2.0", "mocha-lcov-reporter": "^1.3.0", @@ -147,7 +137,7 @@ "rollup": "^4.55.1", "sinon": "^2.3.1", "ts-loader": "^9.3.1", - "ts-node": "^8.10.2", + "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "tslib": "^2.8.1", "typescript": "^4.7.4", diff --git a/rollup.config.mjs b/rollup.config.mjs index 020778d8f..fe3df3d85 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -28,15 +28,22 @@ const __dirname = dirname(__filename); const require = createRequire(import.meta.url); const { dependencies, peerDependencies } = require('./package.json'); -const resolvePlugin = nodeResolve({ - extensions: ['.js'] +const resolvePlugin = nodeResolve(); + +const resolvePluginNode = nodeResolve({ + exportConditions: ['node'], }); const resolvePluginBrowser = nodeResolve({ browser: true, - extensions: ['.js'] + exportConditions: ['browser'], }); +const resolvePluginByPlatform = { + node: resolvePluginNode, + browser: resolvePluginBrowser, +}; + const aliasPlugin = alias({ entries: [ { find: 'error_message', replacement: join(__dirname, '.build/message/error_message.gen.js') }, @@ -44,6 +51,13 @@ const aliasPlugin = alias({ ] }); +const externalDeps = ['https', 'http', 'url'].concat(Object.keys({ ...dependencies, ...peerDependencies } || {})); + +// uuid@13 is ESM-only and does not provide a CommonJS export. +// Since our CJS bundles must work in CommonJS environments, we bundle uuid +// into the CJS output instead of leaving it as an external dependency. +const externalDepsWithoutUuid = externalDeps.filter(dep => dep !== 'uuid'); + const cjsBundleFor = (platform, opt = {}) => { const { minify, ext } = { minify: true, @@ -54,8 +68,8 @@ const cjsBundleFor = (platform, opt = {}) => { const min = minify ? '.min' : ''; return { - plugins: [aliasPlugin, resolvePlugin, commonjs()], - external: ['https', 'http', 'url'].concat(Object.keys({ ...dependencies, ...peerDependencies } || {})), + plugins: [aliasPlugin, resolvePluginByPlatform[platform] || resolvePlugin, commonjs()], + external: externalDepsWithoutUuid, input: `.build/index.${platform}.js`, output: { exports: 'named', @@ -78,6 +92,7 @@ const esmBundleFor = (platform, opt) => { return { ...cjsBundleFor(platform), + external: externalDeps, output: [ { format: 'es', @@ -100,7 +115,7 @@ const cjsBundleForUAParser = (opt = {}) => { return { plugins: [aliasPlugin, resolvePlugin, commonjs()], - external: ['https', 'http', 'url'].concat(Object.keys({ ...dependencies, ...peerDependencies } || {})), + external: externalDepsWithoutUuid, input: `.build/odp/ua_parser/ua_parser.js`, output: { exports: 'named', @@ -123,6 +138,7 @@ const esmBundleForUAParser = (opt = {}) => { return { ...cjsBundleForUAParser(), + external: externalDeps, output: [ { format: 'es', @@ -162,7 +178,7 @@ const umdBundle = { // A separate bundle for json schema validator. const jsonSchemaBundle = { plugins: [aliasPlugin, resolvePlugin, commonjs()], - external: ['json-schema', 'uuid'], + external: ['json-schema'], input: '.build/utils/json_schema_validator/index.js', output: { exports: 'named', diff --git a/scripts/generate-messages.js b/scripts/generate-messages.js new file mode 100755 index 000000000..bd347fc01 --- /dev/null +++ b/scripts/generate-messages.js @@ -0,0 +1,121 @@ +#!/usr/bin/env node + +/** + * Copyright 2026, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const ts = require('typescript'); +const fs = require('fs'); +const path = require('path'); + +function extractMessages(filePath) { + const source = fs.readFileSync(filePath, 'utf-8'); + const sourceFile = ts.createSourceFile( + filePath, + source, + ts.ScriptTarget.Latest, + true, + ); + + const exports = []; + + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue; + + const hasExport = statement.modifiers?.some( + (m) => m.kind === ts.SyntaxKind.ExportKeyword, + ); + if (!hasExport) continue; + + for (const decl of statement.declarationList.declarations) { + const name = decl.name.getText(sourceFile); + if (name === 'messages' || name === '__platforms') continue; + if (!decl.initializer || !ts.isStringLiteral(decl.initializer)) continue; + + exports.push({ name, value: decl.initializer.text }); + } + } + + return exports; +} + +function buildOutputAst(exports) { + const { factory } = ts; + const statements = []; + + exports.forEach((exp, i) => { + statements.push( + factory.createVariableStatement( + [factory.createModifier(ts.SyntaxKind.ExportKeyword)], + factory.createVariableDeclarationList( + [factory.createVariableDeclaration(exp.name, undefined, undefined, factory.createStringLiteral(String(i)))], + ts.NodeFlags.Const, + ), + ), + ); + }); + + statements.push( + factory.createVariableStatement( + [factory.createModifier(ts.SyntaxKind.ExportKeyword)], + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + 'messages', + undefined, + undefined, + factory.createArrayLiteralExpression( + exports.map((exp) => factory.createStringLiteral(exp.value)), + true, + ), + ), + ], + ts.NodeFlags.Const, + ), + ), + ); + + return factory.createSourceFile(statements, factory.createToken(ts.SyntaxKind.EndOfFileToken), ts.NodeFlags.None); +} + +function generate(filePath) { + const absPath = path.resolve(filePath); + const parsed = path.parse(absPath); + const genPath = path.join(parsed.dir, `${parsed.name}.gen${parsed.ext}`); + + console.log('generating messages for:', absPath); + + const exports = extractMessages(absPath); + const outputAst = buildOutputAst(exports); + + const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); + const output = printer.printFile(outputAst); + + fs.writeFileSync(genPath, output, 'utf-8'); + console.log('generated file path:', genPath); +} + +const files = process.argv.slice(2); + +if (files.length === 0) { + console.error('Usage: generate-messages.js [file2.ts] ...'); + process.exit(1); +} + +for (const file of files) { + generate(file); +} + +console.log('successfully generated messages'); diff --git a/scripts/publish.sh b/scripts/publish.sh new file mode 100755 index 000000000..a10f0d0b0 --- /dev/null +++ b/scripts/publish.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# +# Registry-agnostic, idempotent publish for @optimizely/optimizely-sdk. +# +# Usage: +# scripts/publish.sh [tarball] +# +# Args: +# registry-url Target registry, e.g. https://registry.npmjs.org +# or https://npm.pkg.github.com +# tarball Optional. When given, publishes that packed tarball instead +# of the current working directory (used by the GHR backfill). +# +# Env: +# NODE_AUTH_TOKEN Auth token for the target registry. Used both to read the +# registry (existence/dist-tag lookup) and, via the caller's +# .npmrc entry (`///:_authToken=${NODE_AUTH_TOKEN}`, +# which setup-node writes), to publish. This script passes +# --registry explicitly, so no scope-to-registry routing is +# required (and the GHR job intentionally does NOT route +# @optimizely to GHR, so that `npm ci` still installs +# dependencies from npm). +# DRY_RUN When "true", report what would happen (publish vs. skip) +# without actually publishing. +# +# Behavior: +# - Reads the target registry's packument once (a single HTTP GET) and: +# * 200 -> package exists; skip (exit 0) if this exact version is already +# present, so re-running a release or the backfill is a safe no-op. +# * 404 -> package/version absent; proceed to publish. +# * any other status or a network failure -> abort (exit 1) rather than +# guess. A flaky lookup must never be misread as "not published" and +# trigger a publish. +# - Computes the dist-tag from the version: +# * pre-releases (beta/alpha/rc) get their own tag, never `latest`. +# * a stable release gets `latest` ONLY when it is strictly greater (by +# semver) than the registry's current `latest`, so `latest` never moves +# backwards onto an older release — e.g. 5.x shipped after 6.x, or a +# 6.4.1 patch shipped while latest is 6.5.0. Otherwise it is tagged +# `v-last-published`: a per-major pointer to the most recently +# published version on that major line. It is named for recency (not +# "latest") on purpose — an out-of-order backport can leave it below the +# highest version in the major. Consumers install a line via +# @@v-last-published. +set -euo pipefail + +dry_run="${DRY_RUN:-false}" + +registry="${1:?usage: publish.sh [tarball]}" +tarball="${2:-}" + +if [[ -n "$tarball" ]]; then + # Derive name/version from the tarball's own package.json so the guard matches + # exactly what we're about to publish (backfill of historical versions). + meta=$(tar -xzO -f "$tarball" package/package.json) + pkg=$(printf '%s' "$meta" | jq -r '.name') + version=$(printf '%s' "$meta" | jq -r '.version') +else + pkg=$(jq -r '.name' package.json) + version=$(jq -r '.version' package.json) +fi + +# Returns 0 if $1 is strictly greater than $2 (numeric major.minor.patch). Only +# called for stable versions, so no pre-release precedence handling is needed. +version_gt() { + local -a a b + local i x y + IFS=. read -ra a <<< "$1" + IFS=. read -ra b <<< "$2" + for i in 0 1 2; do + x=${a[i]:-0} + y=${b[i]:-0} + (( 10#$x > 10#$y )) && return 0 + (( 10#$x < 10#$y )) && return 1 + done + return 1 +} + +# --- Existence / current-latest lookup (single packument GET) ---------------- +# npm scoped names are URL-encoded with the slash as %2f: @scope/name. +pkg_encoded="${pkg//\//%2f}" +packument_url="${registry%/}/${pkg_encoded}" + +body_file=$(mktemp) +trap 'rm -f "$body_file"' EXIT + +# curl -sS (no --fail) returns 0 for any HTTP response, non-zero only on +# network/protocol errors — so we can cleanly separate "server answered" from +# "could not reach server". +auth_header="" +if [[ -n "${NODE_AUTH_TOKEN:-}" ]]; then + auth_header="Authorization: Bearer ${NODE_AUTH_TOKEN}" +fi +if ! http_code=$(curl -sS -m 30 -o "$body_file" -w '%{http_code}' \ + ${auth_header:+-H "$auth_header"} "$packument_url"); then + echo "ERROR: request to ${packument_url} failed (network/timeout); refusing to publish on an ambiguous result." >&2 + exit 1 +fi + +current_latest="" +case "$http_code" in + 200) + if jq -e --arg v "$version" '.versions[$v] != null' "$body_file" >/dev/null 2>&1; then + echo "Version ${pkg}@${version} already on ${registry}, skipping." + exit 0 + fi + current_latest=$(jq -r '.["dist-tags"].latest // empty' "$body_file") + ;; + 404) + # Package (or this version) not published yet; nothing to compare against. + current_latest="" + ;; + *) + echo "ERROR: unexpected HTTP ${http_code} querying ${packument_url}; refusing to publish on an ambiguous result." >&2 + exit 1 + ;; +esac + +# --- dist-tag selection ------------------------------------------------------ +case "$version" in + *-beta*) tag=beta ;; + *-alpha*) tag=alpha ;; + *-rc*) tag=rc ;; + *) + # Stable release: `latest` only if strictly greater than the current latest. + if [[ -z "$current_latest" ]] || version_gt "$version" "$current_latest"; then + tag=latest + else + major=${version%%.*} + tag="v${major}-last-published" + echo "Current latest is ${current_latest}; ${version} is not newer, tagging as ${tag} (latest preserved)." + fi + ;; +esac + +if [[ "$dry_run" == "true" ]]; then + echo "[dry-run] would publish ${pkg}@${version} (tag: ${tag}) to ${registry}" + exit 0 +fi + +# Add --provenance for npm registry (OIDC trusted publishing generates +# supply-chain attestations). Not supported by GitHub Package Registry. +provenance_flag="" +if [[ "$registry" == *"registry.npmjs.org"* ]]; then + provenance_flag="--provenance" +fi + +echo "Publishing ${pkg}@${version} (tag: ${tag}) to ${registry}" +if [[ -n "$tarball" ]]; then + # The tarball is a prebuilt artifact; skip lifecycle scripts (prepublishOnly + # = test + build) that would otherwise run from the current package.json. + npm publish "$tarball" --registry "$registry" --tag "$tag" --ignore-scripts $provenance_flag +else + npm publish --registry "$registry" --tag "$tag" $provenance_flag +fi diff --git a/scripts/run-cjs-example.js b/scripts/run-cjs-example.js new file mode 100644 index 000000000..fa213d54c --- /dev/null +++ b/scripts/run-cjs-example.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node + +/** + * Copyright 2026, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { execSync } = require('child_process'); +const path = require('path'); +const fs = require('fs'); + +const rootDir = path.join(__dirname, '..'); +const exampleDir = path.join(rootDir, 'examples', 'node-commonjs'); + +function run(command, cwd) { + console.log(`\n> ${command}`); + console.log(` (in ${cwd})\n`); + try { + execSync(command, { + cwd, + stdio: 'inherit', + shell: true + }); + } catch (error) { + console.error(`\nError executing: ${command}`); + process.exit(1); + } +} + +function runQuiet(command, cwd) { + try { + const output = execSync(command, { + cwd, + encoding: 'utf8', + shell: true + }).trim(); + const lines = output.split('\n'); + return lines[lines.length - 1].trim(); + } catch (error) { + console.error(`\nError executing: ${command}`); + process.exit(1); + } +} + +function main() { + console.log('=== Building SDK and Running CommonJS Example ===\n'); + + console.log('Installing SDK dependencies...'); + run('npm install', rootDir); + + console.log('\nPacking SDK tarball...'); + const packOutput = runQuiet('npm pack', rootDir); + const tarballPath = path.join(rootDir, packOutput); + console.log(`Created: ${packOutput}`); + + console.log('\nInstalling SDK tarball in cjs-example...'); + run(`npm install --no-save --no-package-lock ${tarballPath}`, exampleDir); + + console.log('\nRunning cjs-example...'); + run('npm start', exampleDir); + + console.log('\nCleaning up tarball...'); + fs.unlinkSync(tarballPath); + console.log(`Removed: ${packOutput}`); + + console.log('\n=== Example completed successfully! ===\n'); +} + +main(); diff --git a/scripts/run-ts-example.js b/scripts/run-ts-example.js index 32c7f7ec6..ee1c7255d 100755 --- a/scripts/run-ts-example.js +++ b/scripts/run-ts-example.js @@ -62,7 +62,7 @@ function startDatafileServer() { console.log('\n=== Starting Datafile Server ==='); console.log('Starting server at http://localhost:8910...\n'); - const serverPath = path.join(exampleDir, 'datafile-server.js'); + const serverPath = path.join(rootDir, 'examples', 'shared', 'datafile-server.js'); datafileServer = spawn('node', [serverPath], { stdio: 'inherit', detached: false @@ -114,7 +114,7 @@ async function main() { run('npm install', exampleDir); console.log('\nInstalling SDK tarball in ts-example...'); - run(`npm install --no-save ${tarballPath}`, exampleDir); + run(`npm install --no-save --no-package-lock ${tarballPath}`, exampleDir); console.log('\nBuilding ts-example...'); run('npm run build', exampleDir); diff --git a/scripts/test-generate-messages.js b/scripts/test-generate-messages.js new file mode 100644 index 000000000..93f8edd60 --- /dev/null +++ b/scripts/test-generate-messages.js @@ -0,0 +1,90 @@ +#!/usr/bin/env node + +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const ts = require('typescript'); + +const SCRIPT = path.resolve(__dirname, 'generate-messages.js'); + +const FILE1_EXPECTED = [ + { name: 'HELLO', value: 'Hello, %s!' }, + { name: 'GOODBYE', value: 'Goodbye, %s!' }, + { name: 'WARNING', value: 'Warning: %s' }, +]; + +const FILE2_EXPECTED = [ + { name: 'ERR_NOT_FOUND', value: 'Resource %s not found' }, + { name: 'ERR_TIMEOUT', value: 'Request timed out after %s ms' }, + { name: 'ERR_INVALID', value: 'Invalid input: %s' }, + { name: 'ERR_PERMISSION', value: 'Permission denied for %s' }, +]; + +function buildFileContent(expected, { includeMessages = true, includePlatforms = false } = {}) { + let content = expected.map((e) => `export const ${e.name} = '${e.value}';`).join('\n'); + if (includeMessages) content += `\n\nexport const messages: string[] = [];`; + if (includePlatforms) content += `\nexport const __platforms: string[] = ['__universal__'];`; + return content; +} + +function compileAndLoad(tsPath) { + const source = fs.readFileSync(tsPath, 'utf-8'); + const { outputText } = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS }, + }); + const jsPath = tsPath.replace(/\.ts$/, '.js'); + fs.writeFileSync(jsPath, outputText, 'utf-8'); + return require(jsPath); +} + +function runTest() { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gen-msg-test-')); + + try { + const file1 = path.join(tmpDir, 'file1.ts'); + const file2 = path.join(tmpDir, 'file2.ts'); + fs.writeFileSync(file1, buildFileContent(FILE1_EXPECTED, { includePlatforms: true }), 'utf-8'); + fs.writeFileSync(file2, buildFileContent(FILE2_EXPECTED), 'utf-8'); + + execFileSync(process.execPath, [SCRIPT, file1, file2], { stdio: 'pipe' }); + + const genFile1 = path.join(tmpDir, 'file1.gen.ts'); + const genFile2 = path.join(tmpDir, 'file2.gen.ts'); + assert.ok(fs.existsSync(genFile1), 'file1.gen.ts should be created'); + assert.ok(fs.existsSync(genFile2), 'file2.gen.ts should be created'); + + for (const [expected, genPath, label] of [ + [FILE1_EXPECTED, genFile1, 'file1'], + [FILE2_EXPECTED, genFile2, 'file2'], + ]) { + const mod = compileAndLoad(genPath); + + assert.ok(Array.isArray(mod.messages), `${label}: messages should be an array`); + assert.equal(mod.messages.length, expected.length, + `${label}: messages array length should be ${expected.length}, got ${mod.messages.length}`); + + assert.equal(mod.__platforms, undefined, `${label}: __platforms should not be exported`); + + const constantCount = Object.keys(mod).filter((k) => k !== 'messages').length; + assert.equal(constantCount, expected.length, + `${label}: should have ${expected.length} constants, got ${constantCount}`); + + for (const exp of expected) { + assert.ok(exp.name in mod, + `${label}: constant "${exp.name}" should be present`); + + const index = Number(mod[exp.name]); + assert.equal(mod.messages[index], exp.value, + `${label}: messages[${index}] should be "${exp.value}", got "${mod.messages[index]}"`); + } + } + + console.log('All tests passed.'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +runTest(); diff --git a/srcclr.yml b/srcclr.yml deleted file mode 100644 index 5941418eb..000000000 --- a/srcclr.yml +++ /dev/null @@ -1 +0,0 @@ -scope: production diff --git a/tsconfig.json b/tsconfig.json index b3d1744e4..25be5af80 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,6 +33,12 @@ "skipLibCheck": true, "useUnknownInCatchVariables": false }, + "ts-node": { + "compilerOptions": { + "module": "commonjs" + }, + "transpileOnly": true + }, "exclude": [ "./dist", "./.build",