diff --git a/package-lock.json b/package-lock.json index 0331a98d2..02b2b91cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "devDependencies": { "@duckduckgo/eslint-config": "github:duckduckgo/eslint-config#v0.1.0", "@duckduckgo/pixel-schema": "github:duckduckgo/pixel-schema#v1.0.3", + "@duckduckgo/privacy-configuration": "github:duckduckgo/privacy-configuration", "@fingerprintjs/fingerprintjs": "^4.5.1", "@playwright/test": "^1.49.1", "@types/chrome": "^0.0.269", @@ -272,6 +273,16 @@ "node": ">=6" } }, + "node_modules/@duckduckgo/privacy-configuration": { + "version": "1.0.0", + "resolved": "git+ssh://git@github.com/duckduckgo/privacy-configuration.git#6d3cdbbb0801cfd0b0f16a864e4f5fd27576ca90", + "dev": true, + "license": "Apache 2.0", + "dependencies": { + "node-fetch": "^3.3.2", + "tldts": "^6.1.65" + } + }, "node_modules/@duckduckgo/privacy-dashboard": { "version": "7.3.2", "resolved": "git+ssh://git@github.com/duckduckgo/privacy-dashboard.git#2e2baf7d31c7d8e158a58bc1cb79498c1c727fd2", @@ -11732,6 +11743,15 @@ } } }, + "@duckduckgo/privacy-configuration": { + "version": "git+ssh://git@github.com/duckduckgo/privacy-configuration.git#6d3cdbbb0801cfd0b0f16a864e4f5fd27576ca90", + "dev": true, + "from": "@duckduckgo/privacy-configuration@github:duckduckgo/privacy-configuration", + "requires": { + "node-fetch": "^3.3.2", + "tldts": "^6.1.65" + } + }, "@duckduckgo/privacy-dashboard": { "version": "git+ssh://git@github.com/duckduckgo/privacy-dashboard.git#2e2baf7d31c7d8e158a58bc1cb79498c1c727fd2", "integrity": "sha512-mGnYaSZt9xeGyUX3wtIOGCmu/tiIpQLpG1osCjWtJFk+wixigoXu/1AmanBjuzmdG3DyM1MRZ9kJE4pNi4bVfQ==", diff --git a/package.json b/package.json index bd7d89153..b51741ccf 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "devDependencies": { "@duckduckgo/eslint-config": "github:duckduckgo/eslint-config#v0.1.0", "@duckduckgo/pixel-schema": "github:duckduckgo/pixel-schema#v1.0.3", + "@duckduckgo/privacy-configuration": "github:duckduckgo/privacy-configuration", "@fingerprintjs/fingerprintjs": "^4.5.1", "@playwright/test": "^1.49.1", "@types/chrome": "^0.0.269", diff --git a/shared/js/background/background.js b/shared/js/background/background.js index b38155c42..d400bae90 100644 --- a/shared/js/background/background.js +++ b/shared/js/background/background.js @@ -28,6 +28,7 @@ import TrackersGlobal from './components/trackers'; import DebuggerConnection from './components/debugger-connection'; import Devtools from './components/devtools'; import DNRListeners from './components/dnr-listeners'; +import RemoteConfig from './components/remote-config'; import initDebugBuild from './devbuild'; import initReloader from './devbuild-reloader'; import tabManager from './tab-manager'; @@ -43,7 +44,8 @@ settings.ready().then(() => { onStartup(); }); -const tds = new TDSStorage({ settings }); +const remoteConfig = new RemoteConfig({ settings }); +const tds = new TDSStorage({ settings, remoteConfig }); const devtools = new Devtools({ tds }); /** * @type {{ @@ -54,6 +56,7 @@ const devtools = new Devtools({ tds }); * tds: TDSStorage; * tabTracking: TabTracker; * trackers: TrackersGlobal; + * remoteConfig: RemoteConfig; * }} */ const components = { @@ -66,6 +69,7 @@ const components = { trackers: new TrackersGlobal({ tds }), debugger: new DebuggerConnection({ tds, devtools }), devtools, + remoteConfig, }; // Chrome-only components diff --git a/shared/js/background/components/debugger-connection.js b/shared/js/background/components/debugger-connection.js index 096a8af71..799149dfe 100644 --- a/shared/js/background/components/debugger-connection.js +++ b/shared/js/background/components/debugger-connection.js @@ -56,7 +56,7 @@ export default class DebuggerConnection { if (messageType === 'status') { if (payload.lastBuild > lastUpdate) { lastUpdate = payload.lastBuild; - this.tds.config.checkForUpdates(true); + this.tds.remoteConfig.checkForUpdates(true); } } else if (messageType === 'subscribe') { const tabId = parseInt(payload.tabId, 10); @@ -123,7 +123,7 @@ export default class DebuggerConnection { } async forceReloadConfig() { - this.tds.config.checkForUpdates(true); + this.tds.remoteConfig.checkForUpdates(true); } async disableDebugging() { diff --git a/shared/js/background/components/devtools.js b/shared/js/background/components/devtools.js index 085eadeed..f239d84b8 100644 --- a/shared/js/background/components/devtools.js +++ b/shared/js/background/components/devtools.js @@ -74,7 +74,7 @@ class DevtoolsConnection { */ async toggleFeature({ feature }) { if (feature === 'trackerAllowlist') { - await this.devtools.tds.config.modify((config) => { + await this.devtools.tds.remoteConfig.modify((config) => { const currentState = config.features.trackerAllowlist.state; config.features.trackerAllowlist.state = currentState === 'enabled' ? 'disabled' : 'enabled'; return config; @@ -85,7 +85,7 @@ class DevtoolsConnection { const enabled = tab.site?.enabledFeatures.includes(feature); const tabDomain = tldts.getDomain(tab.site.domain); - await this.devtools.tds.config.modify((config) => { + await this.devtools.tds.remoteConfig.modify((config) => { const excludedSites = config.features[feature].exceptions; if (enabled) { excludedSites.push({ @@ -111,7 +111,7 @@ class DevtoolsConnection { const tabId = await this.tabId; const tab = tabManager.get({ tabId }); if (tab.site?.isBroken && tab.url) { - await this.devtools.tds.config.modify((config) => { + await this.devtools.tds.remoteConfig.modify((config) => { removeBroken(tab.site.domain, config); if (tab.url) { removeBroken(new URL(tab.url).hostname, config); diff --git a/shared/js/background/components/dnr-listeners.js b/shared/js/background/components/dnr-listeners.js index c59103d69..dea7eaef6 100644 --- a/shared/js/background/components/dnr-listeners.js +++ b/shared/js/background/components/dnr-listeners.js @@ -31,11 +31,11 @@ export default class DNRListeners { this.settings = settings; this.tds = tds; browser.runtime.onInstalled.addListener(this.postInstall.bind(this)); - tds.config.onUpdate(onConfigUpdate); + tds.remoteConfig.onUpdate(onConfigUpdate); tds.tds.onUpdate(onConfigUpdate); this.settings.onSettingUpdate.addEventListener('GPC', async () => { - await this.tds.config.ready; - ensureGPCHeaderRule(this.tds.config.data); + await this.tds.remoteConfig.ready; + ensureGPCHeaderRule(this.tds.remoteConfig.config); }); } diff --git a/shared/js/background/components/remote-config.js b/shared/js/background/components/remote-config.js new file mode 100644 index 000000000..18c7bc6e9 --- /dev/null +++ b/shared/js/background/components/remote-config.js @@ -0,0 +1,130 @@ +/** + * @typedef {import('../settings.js')} Settings + * @typedef {import('./tds').default} TDSStorage + * @typedef {import('@duckduckgo/privacy-configuration/schema/config.js').GenericV4Config} Config + */ + +import { getFeatureSettings, isFeatureEnabled, satisfiesMinVersion } from '../utils'; +import { getExtensionVersion, getFromSessionStorage } from '../wrapper'; +import ResourceLoader from './resource-loader'; +import constants from '../../../data/constants'; + +/** + * @returns {Promise} + */ +async function getConfigUrl() { + const override = await getFromSessionStorage('configURLOverride'); + if (override) { + return override; + } + return constants.tdsLists[2].url; +} + +export default class RemoteConfig extends ResourceLoader { + /** + * @param {{ + * settings: Settings + * }} opts + */ + constructor({ settings }) { + super( + { + name: 'config', + remoteUrl: getConfigUrl, + localUrl: '/data/bundled/extension-config.json', + updateIntervalMinutes: 15, + }, + { settings }, + ); + /** @type {Config?} */ + this.config = null; + this.settings = settings; + this.onUpdate(async (_, etag, v) => { + this.updateConfig(v); + }); + } + + /** + * + * @param {Config} configValue + */ + updateConfig(configValue) { + // copy config value before modification + const configCopy = structuredClone(configValue); + this.config = this._processRawConfig(configCopy); + } + + /** + * + * @param {string} featureName + * @returns {boolean} + */ + isFeatureEnabled(featureName) { + return isFeatureEnabled(featureName, this.config || undefined); + } + + /** + * + * @param {string} featureName + * @returns {object} + */ + getFeatureSettings(featureName) { + return getFeatureSettings(featureName, this.config || undefined); + } + + isSubFeatureEnabled(featureName, subFeatureName) { + if (this.config) { + return isSubFeatureEnabled(featureName, subFeatureName, this.config); + } else { + return false; + } + } + + /** + * Process config to apply rollout, targets and cohorts options to derive sub-feature enabled state. + * @param {Config} configValue + * @returns {Config} + */ + _processRawConfig(configValue) { + Object.entries(configValue.features).forEach(([featureName, feature]) => { + Object.entries(feature.features || {}).forEach(([name, subfeature]) => { + if (subfeature.rollout && subfeature.state === 'enabled') { + /* Handle a rollout: Dice roll is stored in settings and used that to decide + * whether the feature is set as 'enabled' or not. + */ + const rolloutSettingsKey = `rollouts.${featureName}.${name}.roll`; + const validSteps = subfeature.rollout.steps.filter((v) => v.percent > 0 && v.percent <= 100); + const rolloutPercent = validSteps.length > 0 ? validSteps.reverse()[0].percent : 0.0; + if (!this.settings.getSetting(rolloutSettingsKey)) { + this.settings.updateSetting(rolloutSettingsKey, Math.random() * 100); + } + const dieRoll = this.settings.getSetting(rolloutSettingsKey); + subfeature.state = rolloutPercent >= dieRoll ? 'enabled' : 'disabled'; + } + }); + }); + return configValue; + } +} + +/** + * + * @param {string} featureName + * @param {string} subFeatureName + * @param {Config} config + * @returns {boolean} + */ +export function isSubFeatureEnabled(featureName, subFeatureName, config) { + const feature = config.features[featureName]; + const subFeature = (feature?.features || {})[subFeatureName]; + if (!feature || !subFeature) { + return false; + } + if (subFeature.minSupportedVersion) { + const extensionVersionString = getExtensionVersion(); + if (!satisfiesMinVersion(subFeature.minSupportedVersion, extensionVersionString)) { + return false; + } + } + return subFeature.state === 'enabled'; +} diff --git a/shared/js/background/components/tds.js b/shared/js/background/components/tds.js index d98b99cbe..4111f6894 100644 --- a/shared/js/background/components/tds.js +++ b/shared/js/background/components/tds.js @@ -1,37 +1,22 @@ import ResourceLoader from './resource-loader.js'; import constants from '../../../data/constants'; -import { getFromSessionStorage } from '../wrapper.js'; /** * @typedef {import('../settings.js')} Settings + * @typedef {import('./remote-config.js').default} RemoteConfig */ -/** - * @returns {Promise} - */ -async function getConfigUrl() { - const override = await getFromSessionStorage('configURLOverride'); - if (override) { - return override; - } - return constants.tdsLists[2].url; -} - export default class TDSStorage { /** * @param {{ - * settings: Settings + * settings: Settings, + * remoteConfig: RemoteConfig * }} opts */ - constructor({ settings }) { - this.tds = new ResourceLoader( - { - name: 'tds', - remoteUrl: constants.tdsLists[1].url, - updateIntervalMinutes: 15, - }, - { settings }, - ); + constructor({ settings, remoteConfig }) { + this.remoteConfig = remoteConfig; + /** @deprecated config is an alias of remoteConfig */ + this.config = this.remoteConfig; this.surrogates = new ResourceLoader( { name: 'surrogates', @@ -40,11 +25,10 @@ export default class TDSStorage { }, { settings }, ); - this.config = new ResourceLoader( + this.tds = new ResourceLoader( { - name: 'config', - remoteUrl: getConfigUrl, - localUrl: '/data/bundled/extension-config.json', + name: 'tds', + remoteUrl: constants.tdsLists[1].url, updateIntervalMinutes: 15, }, { settings }, @@ -52,6 +36,6 @@ export default class TDSStorage { } ready() { - return Promise.all([this.tds.ready, this.surrogates.ready, this.config.ready]); + return Promise.all([this.tds.ready, this.surrogates.ready, this.remoteConfig.ready]); } } diff --git a/shared/js/background/storage/tds.js b/shared/js/background/storage/tds.js index c5a776e30..0168ec480 100644 --- a/shared/js/background/storage/tds.js +++ b/shared/js/background/storage/tds.js @@ -16,7 +16,7 @@ export default { _tds: { entities: {}, trackers: {}, domains: {}, cnames: {} }, _surrogates: '', get config() { - return globalThis.components?.tds.config.data || this._config; + return globalThis.components?.remoteConfig.config || this._config; }, get tds() { return globalThis.components?.tds.tds.data || this._tds; diff --git a/shared/js/background/utils.js b/shared/js/background/utils.js index ceb6be5c2..30bb08007 100644 --- a/shared/js/background/utils.js +++ b/shared/js/background/utils.js @@ -7,6 +7,10 @@ import parseUserAgentString from '../shared-utils/parse-user-agent-string'; import sha1 from '../shared-utils/sha1'; const browserInfo = parseUserAgentString(); +/** + * @typedef {import('@duckduckgo/privacy-configuration/schema/config.js').GenericV4Config} Config + */ + /** * Produce a random float, matches the output of Math.random() but much more cryptographically psudo-random. * @returns {number} @@ -376,10 +380,11 @@ export function satisfiesMinVersion(minVersionString, extensionVersionString) { * parameter to check if the state is equeal to other states (i.e. state === 'beta'). * * @param {String} featureName - the name of the feature + * @param {Config} config * @returns {boolean} - if feature is enabled */ -export function isFeatureEnabled(featureName) { - const feature = tdsStorage.config.features[featureName]; +export function isFeatureEnabled(featureName, config = tdsStorage.config) { + const feature = config.features[featureName]; if (!feature) { return false; } @@ -399,10 +404,11 @@ export function isFeatureEnabled(featureName) { * Returns the settings object associated with featureName in the config * * @param {String} featureName - the name of the feature + * @param {Config} config * @returns {Object} - Settings associated in the config with featureName */ -export function getFeatureSettings(featureName) { - const feature = tdsStorage.config.features[featureName]; +export function getFeatureSettings(featureName, config = tdsStorage.config) { + const feature = config.features[featureName]; if (typeof feature !== 'object' || feature === null || !feature.settings) { return {}; } diff --git a/unit-test/background/remote-config.js b/unit-test/background/remote-config.js new file mode 100644 index 000000000..0a8ea2e6e --- /dev/null +++ b/unit-test/background/remote-config.js @@ -0,0 +1,683 @@ +const { default: RemoteConfig } = require('../../shared/js/background/components/remote-config'); + +class MockSettings { + constructor() { + this.mockSettingData = new Map(); + this.ready = () => Promise.resolve(); + } + + getSetting(key) { + return this.mockSettingData.get(key); + } + updateSetting(key, value) { + this.mockSettingData.set(key, value); + } +} + +describe('rollouts', () => { + function constructMockRemoteConfig() { + return new RemoteConfig({ settings: new MockSettings() }); + } + + // Rollout tests: specs copied from the Android browser project. + // https://github.com/duckduckgo/Android/blob/develop/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/ContributesRemoteFeatureCodeGeneratorTest.kt#L624 + it('test staged rollout for default-enabled feature flag', () => { + const config = constructMockRemoteConfig(); + config.updateConfig({ + features: { + testFeature: { + state: 'enabled', + features: { + defaultTrue: { + state: 'enabled', + rollout: { + steps: [ + { + percent: 0.1, + }, + ], + }, + }, + }, + }, + }, + }); + expect(config.isFeatureEnabled('testFeature')).toBeTrue(); + expect(config.isSubFeatureEnabled('testFeature', 'defaultTrue')).toBeFalse(); + }); + + it('the disable state of the feature always wins', () => { + const config = constructMockRemoteConfig(); + config.updateConfig({ + features: { + testFeature: { + state: 'disabled', + features: { + fooFeature: { + state: 'disabled', + rollout: { + steps: [ + { + percent: 0, + }, + ], + }, + }, + }, + }, + }, + }); + expect(config.isFeatureEnabled('testFeature')).toBeFalse(); + expect(config.isSubFeatureEnabled('testFeature', 'defaultTrue')).toBeFalse(); + + config.updateConfig({ + features: { + testFeature: { + state: 'disabled', + features: { + fooFeature: { + state: 'disabled', + rollout: { + steps: [ + { + percent: 100, + }, + ], + }, + }, + }, + }, + }, + }); + expect(config.isFeatureEnabled('testFeature')).toBeFalse(); + expect(config.isSubFeatureEnabled('testFeature', 'defaultTrue')).toBeFalse(); + }); + + it('the rollout step set to 0 disables the feature', () => { + const config = constructMockRemoteConfig(); + config.updateConfig({ + features: { + testFeature: { + state: 'enabled', + features: { + fooFeature: { + state: 'enabled', + rollout: { + steps: [ + { + percent: 0, + }, + ], + }, + }, + }, + }, + }, + }); + expect(config.isFeatureEnabled('testFeature')).toBeTrue(); + expect(config.isSubFeatureEnabled('testFeature', 'fooFeature')).toBeFalse(); + }); + + it("the parent feature disabled doesn't interfer with the sub-feature state", () => { + const config = constructMockRemoteConfig(); + config.updateConfig({ + features: { + testFeature: { + state: 'disabled', + features: { + fooFeature: { + state: 'enabled', + rollout: { + steps: [ + { + percent: 100, + }, + ], + }, + }, + }, + }, + }, + }); + expect(config.isFeatureEnabled('testFeature')).toBeFalse(); + expect(config.isSubFeatureEnabled('testFeature', 'fooFeature')).toBeTrue(); + }); + + it('the feature incremental steps are ignored when feature disabled', () => { + const config = constructMockRemoteConfig(); + config.updateConfig({ + features: { + testFeature: { + state: 'enabled', + features: { + fooFeature: { + state: 'disabled', + rollout: { + steps: [ + { + percent: 1, + }, + { + percent: 2, + }, + { + percent: 100, + }, + ], + }, + }, + }, + }, + }, + }); + expect(config.isFeatureEnabled('testFeature')).toBeTrue(); + expect(config.isSubFeatureEnabled('testFeature', 'fooFeature')).toBeFalse(); + }); + + it('the feature incremental steps are executed when feature is enabled', () => { + const config = constructMockRemoteConfig(); + config.updateConfig({ + features: { + testFeature: { + state: 'enabled', + features: { + fooFeature: { + state: 'enabled', + rollout: { + steps: [ + { + percent: 0.5, + }, + { + percent: 1.5, + }, + { + percent: 2, + }, + { + percent: 100, + }, + ], + }, + }, + }, + }, + }, + }); + expect(config.isFeatureEnabled('testFeature')).toBeTrue(); + expect(config.isSubFeatureEnabled('testFeature', 'fooFeature')).toBeTrue(); + }); + + it('the invalid rollout steps are ignored and not executed', () => { + const config = constructMockRemoteConfig(); + config.updateConfig({ + features: { + testFeature: { + state: 'enabled', + features: { + fooFeature: { + state: 'enabled', + rollout: { + steps: [ + { + percent: -1, + }, + { + percent: 100, + }, + { + percent: 200, + }, + ], + }, + }, + }, + }, + }, + }); + expect(config.isFeatureEnabled('testFeature')).toBeTrue(); + expect(config.isSubFeatureEnabled('testFeature', 'fooFeature')).toBeTrue(); + }); + + it('disable a previously enabled incremental rollout', () => { + const config = constructMockRemoteConfig(); + config.updateConfig({ + features: { + testFeature: { + state: 'enabled', + features: { + fooFeature: { + state: 'enabled', + rollout: { + steps: [ + { + percent: 100, + }, + ], + }, + }, + }, + }, + }, + }); + expect(config.isFeatureEnabled('testFeature')).toBeTrue(); + expect(config.isSubFeatureEnabled('testFeature', 'fooFeature')).toBeTrue(); + + config.updateConfig({ + features: { + testFeature: { + state: 'enabled', + features: { + fooFeature: { + state: 'disabled', + rollout: { + steps: [ + { + percent: 100, + }, + ], + }, + }, + }, + }, + }, + }); + expect(config.isFeatureEnabled('testFeature')).toBeTrue(); + expect(config.isSubFeatureEnabled('testFeature', 'fooFeature')).toBeFalse(); + }); + + it('re-enable a previously disabled incremental rollout', () => { + const config = constructMockRemoteConfig(); + // incremental rollout + config.updateConfig({ + features: { + testFeature: { + state: 'enabled', + features: { + fooFeature: { + state: 'enabled', + rollout: { + steps: [ + { + percent: 100, + }, + ], + }, + }, + }, + }, + }, + }); + // disable the previously enabled incremental rollout + config.updateConfig({ + features: { + testFeature: { + state: 'enabled', + features: { + fooFeature: { + state: 'disabled', + rollout: { + steps: [ + { + percent: 100, + }, + ], + }, + }, + }, + }, + }, + }); + + expect(config.isFeatureEnabled('testFeature')).toBeTrue(); + expect(config.isSubFeatureEnabled('testFeature', 'fooFeature')).toBeFalse(); + + // re-enable the incremental rollout + config.updateConfig({ + features: { + testFeature: { + state: 'enabled', + features: { + fooFeature: { + state: 'enabled', + rollout: { + steps: [ + { + percent: 100, + }, + ], + }, + }, + }, + }, + }, + }); + + expect(config.isFeatureEnabled('testFeature')).toBeTrue(); + expect(config.isSubFeatureEnabled('testFeature', 'fooFeature')).toBeTrue(); + }); + + it('feature was enabled remains enabled and rollout threshold is set', () => { + const config = constructMockRemoteConfig(); + config.settings.updateSetting('rollouts.testFeature.fooFeature.roll', 10.0); + config.updateConfig({ + features: { + testFeature: { + state: 'enabled', + features: { + fooFeature: { + state: 'enabled', + rollout: { + steps: [ + { + percent: 50, + }, + ], + }, + }, + }, + }, + }, + }); + expect(config.isFeatureEnabled('testFeature')).toBeTrue(); + expect(config.isSubFeatureEnabled('testFeature', 'fooFeature')).toBeTrue(); + }); + + it('full feature lifecycle', () => { + const config = constructMockRemoteConfig(); + let mockExtensionVersion = '2024.10.12'; + spyOn(chrome.runtime, 'getManifest').and.callFake(() => ({ + version: mockExtensionVersion, + })); + // all disabled + config.updateConfig({ + features: { + testFeature: { + state: 'disabled', + features: { + fooFeature: { + state: 'disabled', + }, + }, + }, + }, + }); + expect(config.isFeatureEnabled('testFeature')).toBeFalse(); + expect(config.isSubFeatureEnabled('testFeature', 'fooFeature')).toBeFalse(); + + // enable parent feature + config.updateConfig({ + features: { + testFeature: { + state: 'enabled', + features: { + fooFeature: { + state: 'disabled', + }, + }, + }, + }, + }); + + // add rollout information to sub-feature, still disabled + config.updateConfig({ + features: { + testFeature: { + state: 'enabled', + features: { + fooFeature: { + state: 'disabled', + rollout: { + steps: [ + { + percent: 10, + }, + ], + }, + }, + }, + }, + }, + }); + expect(config.isFeatureEnabled('testFeature')).toBeTrue(); + expect(config.isSubFeatureEnabled('testFeature', 'fooFeature')).toBeFalse(); + + // add more rollout information to sub-feature, still disabled + config.updateConfig({ + features: { + testFeature: { + state: 'enabled', + features: { + fooFeature: { + state: 'disabled', + rollout: { + steps: [ + { + percent: 10, + }, + { + percent: 20, + }, + { + percent: 30, + }, + ], + }, + }, + }, + }, + }, + }); + expect(config.isFeatureEnabled('testFeature')).toBeTrue(); + expect(config.isSubFeatureEnabled('testFeature', 'fooFeature')).toBeFalse(); + + // enable rollout + config.updateConfig({ + features: { + testFeature: { + state: 'enabled', + features: { + fooFeature: { + state: 'enabled', + rollout: { + steps: [ + { + percent: 0, + }, + ], + }, + }, + }, + }, + }, + }); + expect(config.isFeatureEnabled('testFeature')).toBeTrue(); + expect(config.isSubFeatureEnabled('testFeature', 'fooFeature')).toBeFalse(); + + const rolloutThreshold = config.settings.getSetting('rollouts.testFeature.fooFeature.roll'); + // increment rollout but just disabled + config.updateConfig({ + features: { + testFeature: { + state: 'disabled', + features: { + fooFeature: { + state: 'enabled', + rollout: { + steps: [ + { + percent: rolloutThreshold - 1.0, + }, + ], + }, + }, + }, + }, + }, + }); + expect(config.isFeatureEnabled('testFeature')).toBeFalse(); + expect(config.isSubFeatureEnabled('testFeature', 'fooFeature')).toBeFalse(); + + // increment rollout but just disabled, still + config.updateConfig({ + features: { + testFeature: { + state: 'enabled', + features: { + fooFeature: { + state: 'enabled', + rollout: { + steps: [ + { + percent: rolloutThreshold, + }, + ], + }, + }, + }, + }, + }, + }); + expect(config.isFeatureEnabled('testFeature')).toBeTrue(); + expect(config.isSubFeatureEnabled('testFeature', 'fooFeature')).toBeTrue(); + + // increment rollout but just enabled + config.updateConfig({ + features: { + testFeature: { + state: 'enabled', + features: { + fooFeature: { + state: 'enabled', + rollout: { + steps: [ + { + percent: rolloutThreshold + 1.0, + }, + ], + }, + }, + }, + }, + }, + }); + expect(config.isFeatureEnabled('testFeature')).toBeTrue(); + expect(config.isSubFeatureEnabled('testFeature', 'fooFeature')).toBeTrue(); + + // halt rollout + config.updateConfig({ + features: { + testFeature: { + state: 'enabled', + features: { + fooFeature: { + state: 'disabled', + rollout: { + steps: [ + { + percent: rolloutThreshold + 1.0, + }, + ], + }, + }, + }, + }, + }, + }); + expect(config.isFeatureEnabled('testFeature')).toBeTrue(); + expect(config.isSubFeatureEnabled('testFeature', 'fooFeature')).toBeFalse(); + + // resume rollout just of certain app versions + config.updateConfig({ + features: { + testFeature: { + state: 'enabled', + features: { + fooFeature: { + state: 'enabled', + minSupportedVersion: '2024.12.25', + rollout: { + steps: [ + { + percent: rolloutThreshold + 1.0, + }, + ], + }, + }, + }, + }, + }, + }); + expect(config.isFeatureEnabled('testFeature')).toBeTrue(); + expect(config.isSubFeatureEnabled('testFeature', 'fooFeature')).toBeFalse(); + + // resume rollout and update app version + mockExtensionVersion = '2024.12.25'; + config.updateConfig({ + features: { + testFeature: { + state: 'disabled', + features: { + fooFeature: { + state: 'enabled', + minSupportedVersion: '2024.12.25', + rollout: { + steps: [ + { + percent: rolloutThreshold + 1.0, + }, + ], + }, + }, + }, + }, + }, + }); + expect(config.isFeatureEnabled('testFeature')).toBeFalse(); + expect(config.isSubFeatureEnabled('testFeature', 'fooFeature')).toBeTrue(); + + // finish rollout + config.updateConfig({ + features: { + testFeature: { + state: 'enabled', + features: { + fooFeature: { + state: 'enabled', + minSupportedVersion: '2024.12.25', + rollout: { + steps: [ + { + percent: rolloutThreshold + 1.0, + }, + { + percent: 100, + }, + ], + }, + }, + }, + }, + }, + }); + expect(config.isFeatureEnabled('testFeature')).toBeTrue(); + expect(config.isSubFeatureEnabled('testFeature', 'fooFeature')).toBeTrue(); + + // remove steps + config.updateConfig({ + features: { + testFeature: { + state: 'enabled', + features: { + fooFeature: { + state: 'enabled', + minSupportedVersion: '2024.12.25', + }, + }, + }, + }, + }); + expect(config.isFeatureEnabled('testFeature')).toBeTrue(); + expect(config.isSubFeatureEnabled('testFeature', 'fooFeature')).toBeTrue(); + }); +});