From 3b3a7fc74f3a7b57b6b916939a4586428f681a1c Mon Sep 17 00:00:00 2001 From: Lisa Smith Date: Tue, 10 Feb 2026 10:51:36 +0000 Subject: [PATCH] fix(e2e): add ability to read debug feature toggles from local storage --- .../internal-plugin-device/src/config.js | 25 +- .../internal-plugin-device/src/device.js | 43 +++ .../test/unit/spec/device.js | 300 +++++++++++++++--- 3 files changed, 309 insertions(+), 59 deletions(-) diff --git a/packages/@webex/internal-plugin-device/src/config.js b/packages/@webex/internal-plugin-device/src/config.js index 598c604231e..9dfd2c98fa0 100644 --- a/packages/@webex/internal-plugin-device/src/config.js +++ b/packages/@webex/internal-plugin-device/src/config.js @@ -62,14 +62,21 @@ export default { * @type {boolean} */ energyForecast: false, - }, - /** - * installationId is used exclusively as web client for fraud prevention, - * and is aliased to as machineId by CA. - * - * @alias device.machineId - * @type {string} - */ - installationId: undefined, + /** + * debugFeatureTogglesKey + * The session storage key for debug feature toggles + * @type {string} + */ + debugFeatureTogglesKey: undefined, + + /** + * installationId is used exclusively as web client for fraud prevention, + * and is aliased to as machineId by CA. + * + * @alias device.machineId + * @type {string} + */ + installationId: undefined, + }, }; diff --git a/packages/@webex/internal-plugin-device/src/device.js b/packages/@webex/internal-plugin-device/src/device.js index 671019c0dc4..8f8891a5a0c 100644 --- a/packages/@webex/internal-plugin-device/src/device.js +++ b/packages/@webex/internal-plugin-device/src/device.js @@ -786,6 +786,34 @@ const Device = WebexPlugin.extend({ return Promise.reject(new Error('device: failed to get the current websocket url')); }, + /** + * Get sanitized processed debug features from session storage + * these should be JSON encoded and in the form {feature1: true, feature2: false} + * + * @returns {Array} - Array of sanitized debug feature toggles + */ + getDebugFeatures() { + const sanitizedDebugFeatures = []; + if (this.config.debugFeatureTogglesKey) { + const debugFeaturesString = this.webex + .getWindow() + .sessionStorage.getItem(this.config.debugFeatureTogglesKey); + if (debugFeaturesString) { + const debugFeatures = JSON.parse(debugFeaturesString); + Object.entries(debugFeatures).forEach(([key, value]) => { + sanitizedDebugFeatures.push({ + key, + val: value ? 'true' : 'false', + mutable: true, + lastModified: new Date().toISOString(), + }); + }); + } + } + + return sanitizedDebugFeatures; + }, + /** * Process a successful device registration. * @@ -814,6 +842,14 @@ const Device = WebexPlugin.extend({ // When using the etag feature cache, user and entitlement features are still returned this.features.user.reset(features.user); this.features.entitlement.reset(features.entitlement); + } else if (this.config.debugFeatureTogglesKey && body?.features?.developer) { + // Add the debug feature toggles from session storage if available + try { + const debugFeatures = this.getDebugFeatures(); + body.features.developer.push(...debugFeatures); + } catch (error) { + this.logger.error('Failed to parse debug feature toggles from session storage:', error); + } } // Assign the recieved DTO from **WDM** to this device. @@ -946,6 +982,13 @@ const Device = WebexPlugin.extend({ // Prototype the extended class in order to preserve the parent member. Reflect.apply(WebexPlugin.prototype.initialize, this, args); + this.listenToOnce(this.webex, 'change:config', () => { + // If debug feature toggles exist, clear the etag to ensure developer feature toggles are fetched + if (this.getDebugFeatures(this.config.debugFeatureTogglesKey).length > 0) { + this.set('etag', undefined); + } + }); + // Initialize feature events and listeners. FEATURE_COLLECTION_NAMES.forEach((collectionName) => { this.features.on(`change:${collectionName}`, (model, value, options) => { diff --git a/packages/@webex/internal-plugin-device/test/unit/spec/device.js b/packages/@webex/internal-plugin-device/test/unit/spec/device.js index 4e14d3df2a6..a5b90550e5f 100644 --- a/packages/@webex/internal-plugin-device/test/unit/spec/device.js +++ b/packages/@webex/internal-plugin-device/test/unit/spec/device.js @@ -20,10 +20,19 @@ describe('plugin-device', () => { let device; beforeEach(() => { + const fakeStorage = {}; webex = new MockWebex({ children: { device: Device, }, + getWindow: () => ({ + sessionStorage: { + setItem: (key, value) => { + fakeStorage[key] = value; + }, + getItem: (key) => fakeStorage[key], + }, + }), }); const clonedDTO = cloneDeep(dto); @@ -103,6 +112,54 @@ describe('plugin-device', () => { }); }); }); + + describe('when the config is changed', () => { + it("should unset the 'etag' if debug features are set", () => { + device.set('etag', 'etag-value'); + device.config.debugFeatureTogglesKey = 'debug-feature-toggles'; + + webex.getWindow().sessionStorage.setItem( + 'debug-feature-toggles', + JSON.stringify({ + test_feature: true, + }) + ); + assert.equal(device.etag, 'etag-value'); + + webex.trigger('change:config'); + assert.isUndefined(device.etag); + }); + + it("should not unset the 'etag' if debug features are not set", () => { + device.set('etag', 'etag-value'); + device.config.debugFeatureTogglesKey = 'debug-feature-toggles'; + + assert.equal(device.etag, 'etag-value'); + + webex.trigger('change:config'); + assert.equal(device.etag, 'etag-value'); + }); + + it("should only unset the 'etag' the first time the event is sent", () => { + device.set('etag', 'etag-value'); + device.config.debugFeatureTogglesKey = 'debug-feature-toggles'; + + webex.getWindow().sessionStorage.setItem( + 'debug-feature-toggles', + JSON.stringify({ + test_feature: true, + }) + ); + assert.equal(device.etag, 'etag-value'); + + webex.trigger('change:config'); + assert.isUndefined(device.etag); + + device.set('etag', 'etag-value'); + webex.trigger('change:config'); + assert.equal(device.etag, 'etag-value'); + }); + }); }); describe('derived properties', () => { @@ -424,18 +481,17 @@ describe('plugin-device', () => { assert.calledOnce(device.processRegistrationSuccess); }); - }); describe('deleteDevices()', () => { const setup = (deviceType) => { device.config.defaults = {body: {deviceType}}; }; - ['WEB', 'WEBCLIENT'].forEach(deviceType => { - it(`should delete correct number of devices for ${deviceType}`, async () => { - setup(deviceType); - const response = { - body: { + ['WEB', 'WEBCLIENT'].forEach((deviceType) => { + it(`should delete correct number of devices for ${deviceType}`, async () => { + setup(deviceType); + const response = { + body: { devices: [ {url: 'url3', modificationTime: '2023-10-03T10:00:00Z', deviceType}, {url: 'url4', modificationTime: '2023-10-04T10:00:00Z', deviceType: 'notweb'}, @@ -445,49 +501,50 @@ describe('plugin-device', () => { {url: 'url6', modificationTime: '2023-09-50T10:00:00Z', deviceType}, {url: 'url7', modificationTime: '2023-09-30T10:00:00Z', deviceType}, {url: 'url8', modificationTime: '2023-08-30T10:00:00Z', deviceType}, - ] - } - }; - const requestStub = sinon.stub(device, 'request'); - requestStub.withArgs(sinon.match({method: 'GET'})).resolves(response); - requestStub.withArgs(sinon.match({method: 'DELETE'})).resolves(); + ], + }, + }; + const requestStub = sinon.stub(device, 'request'); + requestStub.withArgs(sinon.match({method: 'GET'})).resolves(response); + requestStub.withArgs(sinon.match({method: 'DELETE'})).resolves(); - await device.deleteDevices(); + await device.deleteDevices(); - const expectedDeletions = ['url8', 'url7', 'url1']; + const expectedDeletions = ['url8', 'url7', 'url1']; - expectedDeletions.forEach(url => { - assert(requestStub.calledWith(sinon.match({uri: url, method: 'DELETE'}))); - }); + expectedDeletions.forEach((url) => { + assert(requestStub.calledWith(sinon.match({uri: url, method: 'DELETE'}))); + }); - const notDeletedUrls = ['url2', 'url3', 'url5', 'url6', 'url4']; - notDeletedUrls.forEach(url => { - assert(requestStub.neverCalledWith(sinon.match({uri: url, method: 'DELETE'}))); + const notDeletedUrls = ['url2', 'url3', 'url5', 'url6', 'url4']; + notDeletedUrls.forEach((url) => { + assert(requestStub.neverCalledWith(sinon.match({uri: url, method: 'DELETE'}))); + }); + }); }); - });}); - - it('does not delete when there are just 2 devices', async () => { - setup('WEB'); - const response = { - body: { - devices: [ - {url: 'url1', modificationTime: '2023-10-01T10:00:00Z', deviceType: 'WEB'}, - {url: 'url2', modificationTime: '2023-10-02T10:00:00Z', deviceType: 'WEB'}, - ] - } - }; - const requestStub = sinon.stub(device, 'request'); - requestStub.withArgs(sinon.match({method: 'GET'})).resolves(response); - requestStub.withArgs(sinon.match({method: 'DELETE'})).resolves(); + it('does not delete when there are just 2 devices', async () => { + setup('WEB'); + const response = { + body: { + devices: [ + {url: 'url1', modificationTime: '2023-10-01T10:00:00Z', deviceType: 'WEB'}, + {url: 'url2', modificationTime: '2023-10-02T10:00:00Z', deviceType: 'WEB'}, + ], + }, + }; + + const requestStub = sinon.stub(device, 'request'); + requestStub.withArgs(sinon.match({method: 'GET'})).resolves(response); + requestStub.withArgs(sinon.match({method: 'DELETE'})).resolves(); - await device.deleteDevices(); - const notDeletedUrls = ['url1', 'url2']; - notDeletedUrls.forEach(url => { + await device.deleteDevices(); + const notDeletedUrls = ['url1', 'url2']; + notDeletedUrls.forEach((url) => { assert(requestStub.neverCalledWith(sinon.match({uri: url, method: 'DELETE'}))); + }); }); }); - }); describe('#unregister()', () => { it('resolves immediately if the device is not registered', async () => { @@ -563,11 +620,17 @@ describe('plugin-device', () => { it('calls delete devices when errors with User has excessive device registrations', async () => { setup(); sinon.stub(device, 'canRegister').callsFake(() => Promise.resolve()); - const deleteDeviceSpy = sinon.stub(device, 'deleteDevices').callsFake(() => Promise.resolve()); + const deleteDeviceSpy = sinon + .stub(device, 'deleteDevices') + .callsFake(() => Promise.resolve()); const registerStub = sinon.stub(device, '_registerInternal'); - - registerStub.onFirstCall().rejects({body: {message: 'User has excessive device registrations'}}); - registerStub.onSecondCall().callsFake(() => Promise.resolve({exampleKey: 'example response value',})); + + registerStub + .onFirstCall() + .rejects({body: {message: 'User has excessive device registrations'}}); + registerStub + .onSecondCall() + .callsFake(() => Promise.resolve({exampleKey: 'example response value'})); const result = await device.register(); @@ -582,8 +645,12 @@ describe('plugin-device', () => { setup(); sinon.stub(device, 'canRegister').callsFake(() => Promise.resolve()); - const deleteDeviceSpy = sinon.stub(device, 'deleteDevices').callsFake(() => Promise.resolve()); - const registerStub = sinon.stub(device, '_registerInternal').rejects(new Error('some error')); + const deleteDeviceSpy = sinon + .stub(device, 'deleteDevices') + .callsFake(() => Promise.resolve()); + const registerStub = sinon + .stub(device, '_registerInternal') + .rejects(new Error('some error')); try { await device.register({deleteFlag: true}); @@ -633,7 +700,7 @@ describe('plugin-device', () => { resolve({ body: { exampleKey: 'example response value', - } + }, }); await resultPromise; @@ -670,7 +737,6 @@ describe('plugin-device', () => { assert.calledOnce(device.processRegistrationSuccess); }); - it('checks that submitInternalEvent gets called with internal.register.device.response on success', async () => { setup(); sinon.stub(device, 'canRegister').callsFake(() => Promise.resolve()); @@ -822,7 +888,7 @@ describe('plugin-device', () => { it('works when request returns 404 when already registered', async () => { setup(); - + sinon.stub(device, 'canRegister').callsFake(() => Promise.resolve()); const requestStub = sinon.stub(device, 'request'); @@ -838,7 +904,52 @@ describe('plugin-device', () => { }); }); + describe('getDebugFeatures()', () => { + it('returns empty list if debugFeatureTogglesKey is not set', () => { + assert.isUndefined(device.config.debugFeatureTogglesKey); + const debugFeatures = device.getDebugFeatures(); + + assert.deepEqual(debugFeatures, []); + }); + + it('returns empty list if no debug features in session storage', () => { + device.config.debugFeatureTogglesKey = 'debug-feature-toggles'; + assert.isUndefined(webex.getWindow().sessionStorage.getItem('debug-feature-toggles')); + const debugFeatures = device.getDebugFeatures(); + + assert.deepEqual(debugFeatures, []); + }); + + it('returns debug features from session storage', () => { + device.config.debugFeatureTogglesKey = 'debug-feature-toggles'; + webex.getWindow().sessionStorage.setItem( + 'debug-feature-toggles', + JSON.stringify({ + feature_to_debug_enable: true, + feature_to_debug_disable: false, + }) + ); + const debugFeatures = device.getDebugFeatures(); + + assert.equal(debugFeatures.length, 2); + + assert.properties(debugFeatures[0], ['key', 'val', 'mutable', 'lastModified']); + assert.equal(debugFeatures[0].key, 'feature_to_debug_enable'); + assert.equal(debugFeatures[0].val, 'true'); + assert.isTrue(debugFeatures[0].mutable); + assert.isISODate(debugFeatures[0].lastModified); + + assert.properties(debugFeatures[1], ['key', 'val', 'mutable', 'lastModified']); + assert.equal(debugFeatures[1].key, 'feature_to_debug_disable'); + assert.equal(debugFeatures[1].val, 'false'); + assert.isTrue(debugFeatures[1].mutable); + assert.isISODate(debugFeatures[1].lastModified); + }); + }); + describe('#processRegistrationSuccess()', () => { + const initialDTOFeatureCounts = {developer: 2, entitlement: 1, user: 1}; + const getClonedDTO = (overrides) => { const clonedDTO = cloneDeep(dto); @@ -852,6 +963,22 @@ describe('plugin-device', () => { mutable: true, lastModified: '2015-06-29T20:02:48.033Z', }, + { + key: 'feature_to_debug_enable', + type: 'boolean', + val: 'false', + value: false, + mutable: true, + lastModified: '2015-06-29T20:02:48.033Z', + }, + { + key: 'feature_to_debug_disable', + type: 'boolean', + val: 'true', + value: true, + mutable: true, + lastModified: '2015-06-29T20:02:48.033Z', + }, ], entitlement: [ { @@ -875,16 +1002,21 @@ describe('plugin-device', () => { return clonedDTO; }; + const checkFeatureTypeCounts = (expectedCounts) => { + Object.entries(expectedCounts).forEach(([type, expectedCount]) => { + assert.equal(device.features[type].length, expectedCount); + }); + }; + const checkFeatureNotPresent = (type, key) => { assert.isUndefined(device.features[type].get(key)); }; const checkFeature = (type, key, expectedValue) => { - assert.equal(device.features[type].length, 1); assert.deepEqual(device.features[type].get(key).get('value'), expectedValue); }; - it('features are set correctly if etag not in headers', () => { + it('features are set correctly if etag not in headers, no debug features', () => { const clonedDTO = getClonedDTO(); const response = { @@ -894,13 +1026,61 @@ describe('plugin-device', () => { headers: {}, }; + checkFeatureTypeCounts(initialDTOFeatureCounts); checkFeatureNotPresent('developer', '1'); + checkFeatureNotPresent('developer', 'feature_to_debug_enable'); + checkFeatureNotPresent('developer', 'feature_to_debug_disable'); + checkFeatureNotPresent('developer', 'feature_debug_only'); checkFeatureNotPresent('entitlement', '2'); checkFeatureNotPresent('user', '3'); device.processRegistrationSuccess(response); + checkFeatureTypeCounts({developer: 3, entitlement: 1, user: 1}); checkFeature('developer', '1', true); + checkFeature('developer', 'feature_to_debug_enable', false); + checkFeature('developer', 'feature_to_debug_disable', true); + checkFeatureNotPresent('developer', 'feature_debug_only'); + checkFeature('entitlement', '2', true); + checkFeature('user', '3', true); + }); + + it('features are set correctly if etag not in headers, debug features in session storage', () => { + const clonedDTO = getClonedDTO(); + + const response = { + body: { + ...clonedDTO, + }, + headers: {}, + }; + + device.config.debugFeatureTogglesKey = 'debug-feature-toggles'; + + webex.getWindow().sessionStorage.setItem( + 'debug-feature-toggles', + JSON.stringify({ + feature_to_debug_enable: true, + feature_to_debug_disable: false, + feature_debug_only: true, + }) + ); + + checkFeatureTypeCounts(initialDTOFeatureCounts); + checkFeatureNotPresent('developer', '1'); + checkFeatureNotPresent('developer', 'feature_to_debug_enable'); + checkFeatureNotPresent('developer', 'feature_to_debug_disable'); + checkFeatureNotPresent('developer', 'feature_debug_only'); + checkFeatureNotPresent('entitlement', '2'); + checkFeatureNotPresent('user', '3'); + + device.processRegistrationSuccess(response); + + checkFeatureTypeCounts({developer: 4, entitlement: 1, user: 1}); + checkFeature('developer', '1', true); + checkFeature('developer', 'feature_to_debug_enable', true); + checkFeature('developer', 'feature_to_debug_disable', false); + checkFeature('developer', 'feature_debug_only', true); checkFeature('entitlement', '2', true); checkFeature('user', '3', true); }); @@ -919,12 +1099,17 @@ describe('plugin-device', () => { }, }; + checkFeatureTypeCounts(initialDTOFeatureCounts); checkFeatureNotPresent('developer', '1'); + checkFeatureNotPresent('developer', 'feature_to_debug_enable'); + checkFeatureNotPresent('developer', 'feature_to_debug_disable'); + checkFeatureNotPresent('developer', 'feature_debug_only'); checkFeatureNotPresent('entitlement', '2'); checkFeatureNotPresent('user', '3'); device.processRegistrationSuccess(response); + checkFeatureTypeCounts(initialDTOFeatureCounts.developer); checkFeatureNotPresent('developer', '1'); checkFeature('entitlement', '2', true); checkFeature('user', '3', true); @@ -947,12 +1132,21 @@ describe('plugin-device', () => { }, }; + checkFeatureTypeCounts(initialDTOFeatureCounts); checkFeatureNotPresent('developer', '1'); + checkFeatureNotPresent('developer', 'feature_to_debug_enable'); + checkFeatureNotPresent('developer', 'feature_to_debug_disable'); + checkFeatureNotPresent('developer', 'feature_debug_only'); checkFeatureNotPresent('entitlement', '2'); checkFeatureNotPresent('user', '3'); device.processRegistrationSuccess(response); + checkFeatureTypeCounts({ + developer: initialDTOFeatureCounts.developer, + entitlement: 1, + user: 1, + }); checkFeatureNotPresent('developer', '1'); checkFeature('entitlement', '2', true); checkFeature('user', '3', true); @@ -975,12 +1169,17 @@ describe('plugin-device', () => { }, }; + checkFeatureTypeCounts(initialDTOFeatureCounts); checkFeatureNotPresent('developer', '1'); + checkFeatureNotPresent('developer', 'feature_to_debug_enable'); + checkFeatureNotPresent('developer', 'feature_to_debug_disable'); + checkFeatureNotPresent('developer', 'feature_debug_only'); checkFeatureNotPresent('entitlement', '2'); checkFeatureNotPresent('user', '3'); device.processRegistrationSuccess(response); + checkFeatureTypeCounts({developer: 3, entitlement: 1, user: 1}); checkFeature('developer', '1', true); checkFeature('entitlement', '2', true); checkFeature('user', '3', true); @@ -1029,6 +1228,7 @@ describe('plugin-device', () => { device.processRegistrationSuccess(newResponse); // only the entitlement and user features should have been changed to false + checkFeatureTypeCounts({developer: 3, entitlement: 1, user: 1}); checkFeature('developer', '1', true); checkFeature('entitlement', '2', false); checkFeature('user', '3', false);