diff --git a/karma/config.js b/karma/config.js index 4463cf767..fd7aea269 100644 --- a/karma/config.js +++ b/karma/config.js @@ -3,7 +3,6 @@ const puppeteer = require('puppeteer'); process.env.CHROME_BIN = puppeteer.executablePath(); -const webpack = require('webpack'); const NodePolyfillPlugin = require('node-polyfill-webpack-plugin'); module.exports = { @@ -79,11 +78,7 @@ module.exports = { ] }, plugins: [ - new NodePolyfillPlugin(), - new webpack.DefinePlugin({ - 'process.env.NODE_ENV': JSON.stringify('test'), - __DEV__: true - }) + new NodePolyfillPlugin() ], resolve: { extensions: ['.ts', '.js'], diff --git a/package-lock.json b/package-lock.json index 7768445cc..fedca55d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio", - "version": "10.17.3", + "version": "10.17.4-rc.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -437,9 +437,9 @@ "dev": true }, "@splitsoftware/splitio-commons": { - "version": "1.2.1-rc.5", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-1.2.1-rc.5.tgz", - "integrity": "sha512-Pk5subG7tWke4KuXS3wBnxnsCZ6cNSFSGJsv/BLYmm5pH3SUTDTL0biq80xJdtzRET+9ESAvO8ECqzCePre7BA==", + "version": "1.2.1-rc.8", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-1.2.1-rc.8.tgz", + "integrity": "sha512-K/qQZqBaJa4fssNJgTWtBMkWFlwJBKemSHhhlF+8hQnOv8Tra4tAz7ObPQwnZ9Esjdmd2jEd2SJ1LeSi9haIqQ==", "requires": { "tslib": "^2.3.1" } diff --git a/package.json b/package.json index 45f3b83f9..82ecf76a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio", - "version": "10.17.3", + "version": "10.17.4-rc.0", "description": "Split SDK", "files": [ "README.md", @@ -32,7 +32,7 @@ "node": ">=6" }, "dependencies": { - "@splitsoftware/splitio-commons": "1.2.1-rc.5", + "@splitsoftware/splitio-commons": "1.2.1-rc.8", "@types/google.analytics": "0.0.40", "ioredis": "^4.28.0", "ip": "1.1.5", diff --git a/src/__tests__/browser.spec.js b/src/__tests__/browser.spec.js index bd40fece1..c1993c405 100644 --- a/src/__tests__/browser.spec.js +++ b/src/__tests__/browser.spec.js @@ -19,6 +19,7 @@ import useBeaconApiSuite from './browserSuites/use-beacon-api.spec'; import useBeaconDebugApiSuite from './browserSuites/use-beacon-api.debug.spec'; import readyPromiseSuite from './browserSuites/ready-promise.spec'; import fetchSpecificSplits from './browserSuites/fetch-specific-splits.spec'; +import userConsent from './browserSuites/user-consent.spec'; import { settingsFactory } from '../settings'; @@ -116,6 +117,8 @@ tape('## E2E CI Tests ##', function(assert) { /* Check shared clients */ assert.test('E2E / Shared instances', sharedInstantiationSuite.bind(null, false, fetchMock)); assert.test('E2E / Shared instances with Traffic Type on factory settings', sharedInstantiationSuite.bind(null, true, fetchMock)); + /* Validate user consent */ + assert.test('E2E / User consent', userConsent.bind(null, fetchMock)); /* Check basic manager functionality */ assert.test('E2E / Manager API', managerSuite.bind(null, settings, fetchMock)); /* Validate readiness */ diff --git a/src/__tests__/browserSuites/user-consent.spec.js b/src/__tests__/browserSuites/user-consent.spec.js new file mode 100644 index 000000000..84fa504e6 --- /dev/null +++ b/src/__tests__/browserSuites/user-consent.spec.js @@ -0,0 +1,186 @@ +import sinon from 'sinon'; +import { SplitFactory } from '../../'; +import { triggerUnloadEvent } from '../testUtils/browser'; +import { nearlyEqual, url } from '../testUtils'; + +const trackedImpressions = []; + +const baseConfig = { + core: { + authorizationKey: '', + key: 'facundo@split.io' + }, + startup: { + eventsFirstPushWindow: 0 + }, + urls: { + events: 'https://events.user-consent.io/api' + }, + streamingEnabled: false, + impressionListener: { + logImpression: (impression) => { + trackedImpressions.push(impression); + } + } +}; + +const usageFlows = [{ + // Consent granted after usage: user consent is unknown initially, and then set to granted + initialUserConsent: 'UNKNOWN', + setUserConsent: true, +}, { + // Consent declined after usage: user consent is unknown initially, and then set to declined + initialUserConsent: 'UNKNOWN', + setUserConsent: false, +}, { + // Consent granted before usage (default behavior): userConsent config param is granted + initialUserConsent: 'GRANTED', + setUserConsent: true, // no transition +}, { + // Consent granted before usage (default behavior): userConsent config param is not defined + initialUserConsent: undefined, + setUserConsent: true, // no transition +}, { + // Consent declined before usage: userConsent config param is declined + initialUserConsent: 'DECLINED', + setUserConsent: false, // no transition +}, { + // Consent granted after declined: user consent is declined initially, and then set to granted + initialUserConsent: 'DECLINED', + setUserConsent: true, +}, { + // Consent declined after granted: user consent is granted initially, and then set to declined + initialUserConsent: 'GRANTED', + setUserConsent: false, +}]; + +function mockSubmittersRequests(fetchMock, assert, impressionFeature, eventTypeId) { + fetchMock.postOnce(url(baseConfig, '/testImpressions/count'), 200); // OPTIMIZED impressions mode + fetchMock.postOnce(url(baseConfig, '/testImpressions/bulk'), (url, opts) => { + const resp = JSON.parse(opts.body); + assert.equal(resp[0].f, impressionFeature, 'The expected impression is submitted'); + assert.equal(resp[0].i.length, 2, '2 impressions are expected'); + return 200; + }); + fetchMock.postOnce(url(baseConfig, '/events/bulk'), (url, opts) => { + const resp = JSON.parse(opts.body); + assert.equal(resp[0].eventTypeId, eventTypeId, 'The expected event is submitted'); + assert.equal(resp.length, 2, '2 events are expected'); + return 200; + }); +} + +export default function userConsent(fetchMock, t) { + + // Validate trackers, submitters and browser listener behaviour on different consent status transitions + t.test(async (assert) => { + const sendBeaconSpy = sinon.spy(window.navigator, 'sendBeacon'); + let expectedTrackedImpressions = 0; + + for (let i = 0; i < usageFlows.length; i++) { + const { initialUserConsent, setUserConsent } = usageFlows[i]; + const config = { ...baseConfig, userConsent: initialUserConsent }; + const factory = SplitFactory(config); + const client = factory.client(); + const sharedClient = factory.client('marcio@split.io'); + + await client.ready(); + await sharedClient.ready(); + + let isTracking = factory.getUserConsent() !== 'DECLINED'; + assert.deepEqual([client.track('user', 'event1'), sharedClient.track('user', 'event1')], [isTracking, isTracking], 'tracking events on SDK ready'); + assert.deepEqual([ + client.getTreatment('always_on'), sharedClient.getTreatment('always_on'), + client.getTreatments(['always_on'])['always_on'], sharedClient.getTreatments(['always_on'])['always_on'], + client.getTreatmentWithConfig('always_on').treatment, sharedClient.getTreatmentWithConfig('always_on').treatment, + client.getTreatmentsWithConfig(['always_on'])['always_on'].treatment, sharedClient.getTreatmentsWithConfig(['always_on'])['always_on'].treatment, + ], ['on', 'on', 'on', 'on', 'on', 'on', 'on', 'on'], 'evaluating on SDK ready'); + if (isTracking) expectedTrackedImpressions += 8; + + // Trigger unload event to validate browser listener behaviour + // Beacon API is used only if user consent is GRANTED + triggerUnloadEvent(); + if (factory.getUserConsent() === 'GRANTED') { + assert.ok(sendBeaconSpy.calledThrice, 'sendBeacon should have been called thrice'); + } else { + assert.ok(sendBeaconSpy.notCalled, 'sendBeacon should not be called if user consent is not granted'); + } + sendBeaconSpy.resetHistory(); + + // If transitioning from UNKNOWN to GRANTED, data was tracked and will be submitted + if (factory.getUserConsent() === 'UNKNOWN' && setUserConsent) { + mockSubmittersRequests(fetchMock, assert, 'always_on', 'event1'); + } + if (setUserConsent !== undefined) factory.setUserConsent(setUserConsent); + + // Await to track events and impressions with empty queues + await new Promise(res => setTimeout(res)); + isTracking = factory.getUserConsent() !== 'DECLINED'; + assert.deepEqual([client.track('user', 'event2'), sharedClient.track('user', 'event2')], [isTracking, isTracking], 'tracking events after updating user consent'); + assert.deepEqual([ + client.getTreatment('always_off'), sharedClient.getTreatment('always_off'), + client.getTreatments(['always_off'])['always_off'], sharedClient.getTreatments(['always_off'])['always_off'], + client.getTreatmentWithConfig('always_off').treatment, sharedClient.getTreatmentWithConfig('always_off').treatment, + client.getTreatmentsWithConfig(['always_off'])['always_off'].treatment, sharedClient.getTreatmentsWithConfig(['always_off'])['always_off'].treatment, + ], ['off', 'off', 'off', 'off', 'off', 'off', 'off', 'off'], 'evaluating after updating user consent'); + if (isTracking) expectedTrackedImpressions += 8; + + // If destroyed while user consent is GRANTED, last tracked data is submitted + if (factory.getUserConsent() === 'GRANTED') { + mockSubmittersRequests(fetchMock, assert, 'always_off', 'event2'); + } + await sharedClient.destroy(); + await client.destroy(); + + } + + assert.equal(trackedImpressions.length, expectedTrackedImpressions, 'Tracked impressions are the expected'); + sendBeaconSpy.restore(); + assert.end(); + }, 'Validate trackers, submitters and browser listener behaviour on different consent status transitions'); + + // Validate submitter's behaviour with full queues and with events first push window + t.test(async (assert) => { + const config = { + ...baseConfig, + userConsent: 'UNKNOWN', + scheduler: { + eventsQueueSize: 1, + impressionsQueueSize: 1 + }, + startup: { + eventsFirstPushWindow: 0.1 // 100 millis + }, + }; + const factory = SplitFactory(config); + const client = factory.client(); + + await client.ready(); + + assert.equal(client.track('user', 'event1'), true, 'Events queue is full, but submitter is not executed'); + assert.equal(client.getTreatment('always_on'), 'on', 'Impressions queue is full, but submitter is not executed'); + + let submitterCalls = 0; + const start = Date.now(); + fetchMock.postOnce(url(baseConfig, '/testImpressions/count'), () => { submitterCalls++; return 200; }); // OPTIMIZED impressions mode + fetchMock.postOnce(url(baseConfig, '/testImpressions/bulk'), () => { submitterCalls++; return 200; }); + fetchMock.postOnce(url(baseConfig, '/events/bulk'), () => { + const lapseSinceConsentGranted = Date.now() - start; + assert.true(nearlyEqual(lapseSinceConsentGranted, config.startup.eventsFirstPushWindow * 1000), 'Events should be posted considering first push window'); + submitterCalls++; return 200; + }); + + factory.setUserConsent(true); + + assert.equal(submitterCalls, 2, 'Submitter is resumed and POST requests executed when consent status change to GRANTED, except for events due to first push window'); + + // Awaits until events PUSH request is resolved + await new Promise(res => setTimeout(res, config.startup.eventsFirstPushWindow * 1000 + 50)); + assert.equal(submitterCalls, 3, 'Events POST requests have been executed'); + + await client.destroy(); + + assert.end(); + }, 'Validate submitter\'s behaviour with full queues and with events first push window'); + +} diff --git a/src/__tests__/destroy/browser.spec.js b/src/__tests__/destroy/browser.spec.js index dea476523..e61e9aaf0 100644 --- a/src/__tests__/destroy/browser.spec.js +++ b/src/__tests__/destroy/browser.spec.js @@ -107,23 +107,23 @@ tape('SDK destroy for BrowserJS', async function (assert) { assert.equal(client3.getTreatment('Single_Test'), 'control', 'After destroy, getTreatment returns control for every destroyed client.'); assert.deepEqual(client3.getTreatments(['Single_Test']), { 'Single_Test': 'control' }, 'After destroy, getTreatments returns map of controls for every destroyed client.'); assert.ok(manager.names().length > 0, 'control assertion'); - assert.notOk(client3.track('tt2', 'otherEventType', 3), 'After destroy, track calls return false.'); + assert.notOk(client3.track('tt2', 'otherEventType', 3), 'After destroy, track calls return false.'); await client2.destroy(); assert.equal(client2.getTreatment('Single_Test'), 'control', 'After destroy, getTreatment returns control for every destroyed client.'); assert.deepEqual(client2.getTreatments(['Single_Test']), { 'Single_Test': 'control' }, 'After destroy, getTreatments returns map of controls for every destroyed client.'); assert.ok(manager.names().length > 0, 'control assertion'); - assert.notOk(client2.track('tt', 'eventType', 2), 'After destroy, track calls return false.'); + assert.notOk(client2.track('tt', 'eventType', 2), 'After destroy, track calls return false.'); await client.destroy(); fetchMock.restore(); assert.equal(client.getTreatment('Single_Test'), 'control', 'After destroy, getTreatment returns control for every destroyed client.'); assert.deepEqual(client.getTreatments(['Single_Test']), { 'Single_Test': 'control' }, 'After destroy, getTreatments returns map of controls for every destroyed client.'); - assert.notOk(client2.track('tt2', 'eventType', 1), 'After destroy, track calls return false.'); + assert.notOk(client2.track('tt2', 'eventType', 1), 'After destroy, track calls return false.'); assert.equal(manager.splits().length, 0, 'After the main client is destroyed, manager.splits will return empty array'); - assert.equal(manager.names().length, 0, 'After the main client is destroyed, manager.names will return empty array'); + assert.equal(manager.names().length, 0, 'After the main client is destroyed, manager.names will return empty array'); assert.equal(manager.split('Single_Test'), null, 'After the main client is destroyed, manager.split will return null'); assert.end(); diff --git a/src/__tests__/destroy/node.spec.js b/src/__tests__/destroy/node.spec.js index 1da87e25e..e24b3c8ef 100644 --- a/src/__tests__/destroy/node.spec.js +++ b/src/__tests__/destroy/node.spec.js @@ -57,8 +57,8 @@ tape('SDK destroy for NodeJS', async function (assert) { }); // Events tracking do not need to wait for ready. - client.track('nicolas.zelaya@split.io','tt', 'invalidEventType', 'invalid value' /* Invalid values are not tracked */); - client.track('nicolas.zelaya@gmail.com','tt', 'validEventType', 1); + client.track('nicolas.zelaya@split.io', 'tt', 'invalidEventType', 'invalid value' /* Invalid values are not tracked */); + client.track('nicolas.zelaya@gmail.com', 'tt', 'validEventType', 1); // Assert we are sending the events while doing the destroy fetchMock.postOnce(url(settings, '/events/bulk'), (url, opts) => { @@ -86,14 +86,14 @@ tape('SDK destroy for NodeJS', async function (assert) { await destroyPromise; - assert.equal( client.getTreatment('ut1', 'Single_Test'), 'control', 'After destroy, getTreatment returns control.'); - assert.deepEqual( client.getTreatments('ut1', ['Single_Test', 'another_split']), { + assert.equal(client.getTreatment('ut1', 'Single_Test'), 'control', 'After destroy, getTreatment returns control.'); + assert.deepEqual(client.getTreatments('ut1', ['Single_Test', 'another_split']), { Single_Test: 'control', another_split: 'control' }, 'After destroy, getTreatments returns a map of control.'); - assert.notOk( client.track('key', 'tt', 'event'), 'After destroy, track calls return false.'); - assert.equal( manager.splits().length , 0 , 'After destroy, manager.splits returns empty array.'); - assert.equal( manager.names().length , 0 , 'After destroy, manager.names returns empty array.'); - assert.equal( manager.split('Single_Test') , null , 'After destroy, manager.split returns null.'); + assert.notOk(client.track('key', 'tt', 'event'), 'After destroy, track calls return false.'); + assert.equal(manager.splits().length, 0, 'After destroy, manager.splits returns empty array.'); + assert.equal(manager.names().length, 0, 'After destroy, manager.names returns empty array.'); + assert.equal(manager.split('Single_Test'), null, 'After destroy, manager.split returns null.'); assert.end(); }); diff --git a/src/__tests__/nodeSuites/evaluations.spec.js b/src/__tests__/nodeSuites/evaluations.spec.js index 7909aa5b5..4e76750c6 100644 --- a/src/__tests__/nodeSuites/evaluations.spec.js +++ b/src/__tests__/nodeSuites/evaluations.spec.js @@ -259,6 +259,9 @@ export default async function(config, key, assert) { assert.deepEqual(client.getAttributes, undefined, 'should not be available'); assert.deepEqual(client.clearAttributes, undefined, 'should not be available'); + assert.deepEqual(splitio.setUserConsent, undefined, 'setUserConsent should not be available'); + assert.deepEqual(splitio.getUserConsent, undefined, 'getUserConsent should not be available'); + getTreatmentTests(client, i); getTreatmentsTests(client, i); getTreatmentsWithConfigTests(client, i); diff --git a/src/factory/browser.js b/src/factory/browser.js index 323faf9cb..5745fcb98 100644 --- a/src/factory/browser.js +++ b/src/factory/browser.js @@ -50,8 +50,6 @@ function getModules(settings) { SignalListener, - impressionListener: settings.impressionListener, - integrationsManagerFactory: settings.integrations && settings.integrations.length > 0 ? integrationsManagerFactory.bind(null, settings.integrations) : undefined, impressionsObserverFactory: shouldAddPt(settings) ? impressionObserverCSFactory : undefined, diff --git a/src/factory/node.js b/src/factory/node.js index 99b762a89..b01a3dfd4 100644 --- a/src/factory/node.js +++ b/src/factory/node.js @@ -45,8 +45,6 @@ function getModules(settings) { SignalListener, - impressionListener: settings.impressionListener, - impressionsObserverFactory: shouldAddPt(settings) ? impressionObserverSSFactory : undefined, }; diff --git a/src/settings/__tests__/browser.spec.js b/src/settings/__tests__/browser.spec.js index 225b802ab..0a37e0772 100644 --- a/src/settings/__tests__/browser.spec.js +++ b/src/settings/__tests__/browser.spec.js @@ -41,5 +41,8 @@ tape('SETTINGS / Consent is overwritable and "GRANTED" by default in client-side settings = settingsFactory({ userConsent: 'UNKNOWN' }); assert.equal(settings.userConsent, 'UNKNOWN', 'userConsent can be overwritten.'); + settings = settingsFactory({ userConsent: 'declined' }); + assert.equal(settings.userConsent, 'DECLINED', 'userConsent can be overwritten.'); + assert.end(); }); diff --git a/src/settings/defaults/version.js b/src/settings/defaults/version.js index db8d48cef..bac3b57cd 100644 --- a/src/settings/defaults/version.js +++ b/src/settings/defaults/version.js @@ -1 +1 @@ -export const packageVersion = '10.17.3'; +export const packageVersion = '10.17.4-rc.0'; diff --git a/types/splitio.d.ts b/types/splitio.d.ts index e8ef67730..0f5b19198 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -1316,7 +1316,7 @@ declare namespace SplitIO { /** * Add an attribute to client's in memory attributes storage. * - * @param {string} attributeName Attrinute name + * @param {string} attributeName Attribute name * @param {AttributeType} attributeValue Attribute value * @returns {boolean} true if the attribute was stored and false otherwise */