diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ff25f3e587a..002089dab61 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -392,3 +392,72 @@ jobs: mkdir -p screenshots videos tar zcvf "$ARTIFACT_ARCHIVE_NAME" screenshots videos curl -o /tmp/uploader.log -u "${{ secrets.BOX_UPLOAD_AUTH }}" ${{ secrets.BOX_UPLOAD_PATH }} -T "$ARTIFACT_ARCHIVE_NAME" + + ui-test-sdk: + runs-on: ubuntu-latest + + services: + mongodb: + image: mongo:8.0 + options: >- + --health-cmd mongosh + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 27017:27017 + + container: + image: countly/countly-core:pipelines-${{ inputs.custom_tag || github.base_ref || github.ref_name }} + env: + COUNTLY_CONFIG__MONGODB_HOST: mongodb + COUNTLY_CONFIG_API_PREVENT_JOBS: true + + steps: + - uses: actions/checkout@v2 + + - name: Install Chrome + shell: bash + run: | + apt update + apt install -y libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb wget + wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb -O /tmp/chrome.deb + apt install -y /tmp/chrome.deb + + - name: Copy code + shell: bash + run: cp -rf ./* /opt/countly + + - name: Prepare files to use correct MongoDB host + shell: bash + run: "sed -i 's/mongosh --quiet/mongosh --host mongodb --quiet/' /opt/countly/bin/backup/import_events.sh && sed -i 's/mongoimport --db/mongoimport --host mongodb --db/' /opt/countly/bin/backup/import_events.sh" + + - name: NPM install + shell: bash + working-directory: /opt/countly + run: npm install + + - name: Prepare environment + shell: bash + working-directory: /opt/countly + run: | + sed -i 's/port: 3001,/port: 3001, workers: 1,/' /opt/countly/api/config.js + cp "./plugins/plugins.default.json" "/opt/countly/plugins/plugins.json" + npm install + sudo countly task dist-all + bash bin/scripts/countly.prepare.ce.tests.sh + cd ui-tests + echo '{"username": "${{ secrets.CYPRESS_USER_USERNAME }}","email": "${{ secrets.CYPRESS_USER_EMAIL }}","password": "${{ secrets.CYPRESS_USER_PASSWORD }}"}' > cypress/fixtures/user.json + sed -i 's/00000000-0000-0000-0000-000000000000/${{ secrets.CYPRESS_KEY }}/g' package.json + cp cypress.config.sample.js cypress.config.js + sed -i 's/000000/${{ secrets.CYPRESS_PROJECT_ID }}/g' cypress.config.js + + - name: Run UI tests + shell: bash + working-directory: /opt/countly + run: | + /sbin/my_init & + cd ui-tests + npm install + xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" \ + npm run cy:run:sdk diff --git a/frontend/express/public/javascripts/countly/vue/components/helpers.js b/frontend/express/public/javascripts/countly/vue/components/helpers.js index 5d33d3ce526..ee485222293 100644 --- a/frontend/express/public/javascripts/countly/vue/components/helpers.js +++ b/frontend/express/public/javascripts/countly/vue/components/helpers.js @@ -405,6 +405,10 @@ placement: { type: String, default: 'auto' + }, + tooltipClass: { + type: String, + default: '' } }, computed: { @@ -415,7 +419,7 @@ }; } }, - template: '' + template: '' })); Vue.component("cly-remover", countlyBaseComponent.extend({ diff --git a/plugins/sdk/api/api.js b/plugins/sdk/api/api.js index bf3a3a0c17c..bd3f095fecb 100644 --- a/plugins/sdk/api/api.js +++ b/plugins/sdk/api/api.js @@ -12,6 +12,49 @@ plugins.register("/permissions/features", function(ob) { (function() { + /** + * @api {get} /o/sdk?method=sc Get SDK config + * @apiName GetSDKConfig + * @apiGroup SDK Config + * @apiPermission app + * @apiDescription Get SDK configuration for this SDK and this user + * + * @apiQuery {String} app_key Application key + * + * @apiSuccess {Object} v - version + * @apiSuccess {Object} t - timestamp + * @apiSuccess {Object} c - sdk config + * + * @apiSuccessExample {json} Success-Response: + * { + "v":1, + "t":1682328445330, + "c":{ + "tracking":true, + "networking":true, + "crt":true, + "vt":true, + "st":true, + "cet":true, + "ecz":true, + "cr":true, + "sui":true, + "eqs":true, + "rqs":true, + "czi":true, + "dort":true, + "scui":true, + "lkl":true, + "lvs":true, + "lsv":true, + "lbc":true, + "ltlpt":true, + "ltl":true, + "lt":true, + "rcz":true + } + * } + */ plugins.register("/o/sdk", function(ob) { var params = ob.params; if (params.qstring.method !== "sc") { @@ -36,36 +79,24 @@ plugins.register("/permissions/features", function(ob) { }); /** - * @api {get} /o?method=sc Get SDK config - * @apiName GetSDKConfig + * @api {get} /o?method=config-upload Save SDK config + * @apiName SaveSDKConfig * @apiGroup SDK Config - * @apiPermission app - * @apiDescription Get SDK configuration for this SDK and this user - * - * @apiQuery {String} app_key Application key - * - * @apiSuccess {Object} v - version - * @apiSuccess {Object} t - timestamp - * @apiSuccess {Object} c - sdk config - * - * @apiSuccessExample {json} Success-Response: + * @apiPermission admin + * @apiDescription Save SDK configuration for the given app + * + * @apiQuery {String} app_id Application ID + * @apiQuery {String} config SDK config object + * + * @apiSuccess {json} Success-Response: * { - "v":1, - "t":1682328445330, - "c":{ - "tracking":false, - "networking":false, - "crashes":false, - "views":false, - "heartbeat":61, - "event_queue":11, - "request_queue":1001 - } + * "result": "Success" * } */ plugins.register("/o", function(ob) { var params = ob.params; + // returns server config for the given app if (params.qstring.method === "sdk-config") { validateRead(params, FEATURE_NAME, function() { getSDKConfig(params).then(function(res) { @@ -78,6 +109,77 @@ plugins.register("/permissions/features", function(ob) { return true; } + + // saves the given server configuration for the given app + if (params.qstring.method === "config-upload") { + return new Promise(function(resolve) { + validateUpdate(params, FEATURE_NAME, function() { + var uploadConfig = params.qstring.config; + if (uploadConfig && typeof uploadConfig === "string") { + try { + uploadConfig = JSON.parse(uploadConfig); + } + catch (ex) { + common.returnMessage(params, 400, 'Invalid config format'); + return resolve(); + } + } + + if (!uploadConfig || typeof uploadConfig !== "object") { + common.returnMessage(params, 400, 'Config must be a valid object'); + return resolve(); + } + + var configToSave = uploadConfig.c || uploadConfig; // incase they provide the config object directly + var validOptions = [ + "tracking", + "networking", + "crt", + "vt", + "st", + "cet", + "ecz", + "cr", + "sui", + "eqs", + "rqs", + "czi", + "dort", + "scui", + "lkl", + "lvs", + "lsv", + "lbc", + "ltlpt", + "ltl", + "lt", + "rcz" + ]; + for (var key in configToSave) { + if (validOptions.indexOf(key) === -1) { + delete configToSave[key]; + } + } + + common.outDb.collection('sdk_configs').updateOne( + {_id: params.qstring.app_id + ""}, + {$set: {config: configToSave}}, + {upsert: true}, + function(err) { + if (err) { + common.returnMessage(params, 500, 'Error saving config to database'); + } + else { + common.returnOutput(params, {result: 'Success'}); + } + resolve(); + } + ); + }); + }); + } + + return false; }); plugins.register("/i/sdk-config", function(ob) { @@ -359,7 +461,7 @@ plugins.register("/permissions/features", function(ob) { }); /** - * Updated SDK config + * Updates SDK config (used internally when configuration is changed in the dashboard) * @param {params} params - request params * @returns {void} */ diff --git a/plugins/sdk/frontend/public/javascripts/countly.views.js b/plugins/sdk/frontend/public/javascripts/countly.views.js index 865853634c9..c1bf80bacba 100644 --- a/plugins/sdk/frontend/public/javascripts/countly.views.js +++ b/plugins/sdk/frontend/public/javascripts/countly.views.js @@ -1,5 +1,37 @@ -/*global app, countlyVue, countlySDK, CV, countlyCommon*/ +/*global app, countlyVue, countlySDK, CV, countlyCommon, CountlyHelpers*/ (function() { + var SC_VER = 1; // check/update sdk/api/api.js for this + var v0_android = "22.09.4"; + var v0_ios = "23.02.2"; + var v1_android = "25.4.0"; + var v1_ios = "25.4.0"; + var v1_web = "25.4.0"; + // Supporting SDK Versions for the SC options + var supportedSDKVersion = { + tracking: { android: v0_android, ios: v0_ios, web: v1_web }, + networking: { android: v0_android, ios: v0_ios, web: v1_web }, + crt: { android: v1_android, ios: v1_ios, web: v1_web }, + vt: { android: v1_android, ios: v1_ios, web: v1_web }, + st: { android: v1_android, ios: v1_ios, web: v1_web }, + cet: { android: v1_android, ios: v1_ios, web: v1_web }, + ecz: { android: v1_android, ios: v1_ios, web: v1_web }, + cr: { android: v1_android, ios: v1_ios, web: v1_web }, + sui: { android: v1_android, ios: v1_ios, web: v1_web }, + eqs: { android: v1_android, ios: v1_ios, web: v1_web }, + rqs: { android: v1_android, ios: v1_ios, web: v1_web }, + czi: { android: v1_android, ios: v1_ios, web: v1_web }, + dort: { android: v1_android, ios: v1_ios, web: v1_web }, + scui: { android: v1_android, ios: v1_ios, web: v1_web }, + lkl: { android: v1_android, ios: v1_ios, web: v1_web }, + lvs: { android: v1_android, ios: v1_ios, web: v1_web }, + lsv: { android: v1_android, ios: v1_ios, web: v1_web }, + lbc: { android: v1_android, ios: v1_ios, web: v1_web }, + ltlpt: { android: v1_android, ios: v1_ios, web: v1_web }, + ltl: { android: v1_android, ios: v1_ios, web: v1_web }, + lt: { android: v1_android, ios: v1_ios, web: v1_web }, + rcz: { android: v1_android, ios: v1_ios, web: v1_web } + }; + var FEATURE_NAME = "sdk"; var SDK = countlyVue.views.create({ template: CV.T('/sdk/templates/sdk-main.html'), @@ -49,7 +81,10 @@ template: CV.T('/sdk/templates/config.html'), created: function() { var self = this; - this.$store.dispatch("countlySDK/initialize").then(function() { + Promise.all([ + this.$store.dispatch("countlySDK/initialize"), + this.$store.dispatch("countlySDK/fetchSDKStats") // fetch sdk version data for tooltips + ]).then(function() { self.$store.dispatch("countlySDK/sdk/setTableLoading", false); }); }, @@ -77,7 +112,7 @@ }, features: { label: "SDK Features", - list: ["crt", "vt", "st", "cet", "ecz", "cr"] + list: ["crt", "vt", "st", "cet", "lt", "ecz", "cr", "rcz"] }, settings: { label: "SDK Settings", @@ -99,7 +134,7 @@ networking: { type: "switch", name: "Allow Networking", - description: "Enable or disable all networking calls from SDK except SDK config call. Does not effect tracking of data (default: enabled)", + description: "Enable or disable all networking calls from SDK except SDK behavior call. Does not effect tracking of data (default: enabled)", default: true, value: null }, @@ -138,17 +173,24 @@ default: true, value: null }, + lt: { + type: "switch", + name: "Allow Location Tracking", + description: "Enable or disable tracking of location (default: enabled)", + default: true, + value: null + }, ecz: { type: "switch", name: "Enable Content Zone", - description: "Enable or disable listening to Journey related contents (default: false)", + description: "Enable or disable listening to Journey related contents (default: disabled)", default: false, value: null }, cr: { type: "switch", name: "Require Consent", - description: "Enable or disable requiring consent for tracking (default: false)", + description: "Enable or disable requiring consent for tracking (default: disabled)", default: false, value: null }, @@ -176,14 +218,14 @@ dort: { type: "number", name: "Request Drop Age", - description: "Provide time in hours after which an old request should be dropped if they are not sent to server (default: 0 = not enabled)", + description: "Provide time in hours after which an old request should be dropped if they are not sent to server (default: 0 = disabled)", default: 0, value: null }, lkl: { type: "number", name: "Max Key Length", - description: "Maximum length of an Event's key (including name) (default: 128)", + description: "Maximum length of Event and segment keys (including name) (default: 128)", default: 128, value: null }, @@ -224,17 +266,29 @@ }, scui: { type: "number", - name: "Server Config Update Interval", - description: "How often to check for new server config in hours (default: 4)", + name: "SDK Behavior Update Interval", + description: "How often to check for new behavior settings in hours (default: 4)", default: 4, value: null + }, + rcz: { + type: "switch", + name: "Allow Refresh Content Zone", + description: "Enable or disable refreshing Journey content (default: enabled)", + default: true, + value: null } }, diff: [], - description: "This is experimental feature and not all SDKs and SDK versions yet support it. Refer to the SDK documentation for more information", - downloadDescription: "Download the current SDK configuration as a JSON file to provide to the SDK", + description: "Not all SDKs and SDK versions yet support this feature. Refer to respective SDK documentation for more information" }; }, + mounted: function() { + var self = this; + this.$nextTick(function() { + self.checkSdkSupport(); + }); + }, methods: { onChange: function(key, value) { this.configs[key].value = value; @@ -257,7 +311,7 @@ downloadConfig: function() { var params = this.$store.getters["countlySDK/sdk/all"]; var data = {}; - data.v = 1; // check sdk/api/api.js for version + data.v = SC_VER; data.t = Date.now(); data.c = params || {}; var configData = JSON.stringify(data, null, 2); @@ -271,6 +325,27 @@ document.body.removeChild(a); URL.revokeObjectURL(url); }, + resetSDKConfiguration: function() { + var helper_msg = "You are about to reset your SDK behavior to default state. This would override all these settings if set in your SDK. Do you want to continue?"; + var helper_title = "Reset Behavior?"; + var self = this; + + CountlyHelpers.confirm(helper_msg, "red", function(result) { + if (!result) { + return true; + } + + var params = self.$store.getters["countlySDK/sdk/all"]; + var data = params || {}; + for (var key in self.configs) { + self.configs[key].value = self.configs[key].default; + data[key] = self.configs[key].value; + } + self.$store.dispatch("countlySDK/sdk/update", data).then(function() { + self.$store.dispatch("countlySDK/initialize"); + }); + }, ["No, don't reset", "Yes, reset"], {title: helper_title}); + }, save: function() { var params = this.$store.getters["countlySDK/sdk/all"]; var data = params || {}; @@ -290,6 +365,120 @@ for (var key in this.configs) { this.configs[key].value = typeof data[key] !== "undefined" ? data[key] : this.configs[key].default; } + }, + semverToNumber: function(version) { + if (typeof version !== 'string') { + return -1; + } + + version = version.split("-")[0]; + var letterIndex = version.search(/[a-zA-Z]/); + if (letterIndex !== -1) { + version = version.substring(0, letterIndex); + } + + const semverRegex = /^(\d+)\.(\d+)\.(\d+)$/; + const match = version.match(semverRegex); + + if (!match) { + return -1; + } + + const major = parseInt(match[1], 10); + const minor = parseInt(match[2], 10); + const patch = parseInt(match[3], 10); + + return major * 1000000 + minor * 1000 + patch; + }, + compareVersions: function(context, a, b, text) { + if (!a) { + return; + } + + const aValue = this.semverToNumber(a); + const bValue = this.semverToNumber(b); + + if (aValue === -1 || bValue === -1) { + context.unsupportedList.push(text); + return; + } + + if (aValue >= bValue) { + context.supportLevel += 1; + } + else { + context.unsupportedList.push(text); + } + }, + checkSdkSupport: function() { + for (var key in this.configs) { + this.configs[key].tooltipMessage = "No SDK data present. Please use the latest versions of Android, Web, iOS, Flutter or RN SDKs to use this option."; + this.configs[key].tooltipClass = 'tooltip-neutral'; + } + + if (!this.$store.state.countlySDK || + !this.$store.state.countlySDK.stats || + !this.$store.state.countlySDK.stats.sdk || + !this.$store.state.countlySDK.stats.sdk.versions || + this.$store.state.countlySDK.stats.sdk.versions.length === 0) { + setTimeout(() => { + this.checkSdkSupport(); + }, 500); + return; + } + + const availableData = this.$store.state.countlySDK.stats.sdk.versions; + const latestVersions = availableData.reduce((acc, sdk) => { + if (!sdk.data || sdk.data.length === 0) { + return acc; + } + acc[sdk.label] = sdk.data[0].sdk_version; + for (var i = 1; i < sdk.data.length; i++) { + if (this.semverToNumber(acc[sdk.label]) < this.semverToNumber(sdk.data[i].sdk_version)) { + acc[sdk.label] = sdk.data[i].sdk_version; + } + } + return acc; + }, {}); + + var viableSDKCount = 0; + if (latestVersions.javascript_native_web) { + viableSDKCount++; + } + if (latestVersions["java-native-android"]) { + viableSDKCount++; + } + if (latestVersions["objc-native-ios"]) { + viableSDKCount++; + } + + const configKeyList = Object.keys(this.configs); + configKeyList.forEach(configKey => { + const configSupportedVersions = supportedSDKVersion[configKey]; + if (!configSupportedVersions) { + return; + } + + var context = { supportLevel: 0, unsupportedList: [] }; + this.compareVersions(context, latestVersions.javascript_native_web, configSupportedVersions.web, "Web SDK"); + this.compareVersions(context, latestVersions["java-native-android"], configSupportedVersions.android, "Android SDK"); + this.compareVersions(context, latestVersions["objc-native-ios"], configSupportedVersions.ios, "iOS SDK"); + + if (viableSDKCount > 0 && context.supportLevel === viableSDKCount) { // all correct version + this.configs[configKey].tooltipMessage = 'You are using SDKs that support this option.'; + this.configs[configKey].tooltipClass = 'tooltip-success'; + } + else if (context.unsupportedList.length > 0) { // some/all wrong version + this.configs[configKey].tooltipMessage = 'Some SDKs you use do not support this option: ' + context.unsupportedList.join(', ') + '. Try upgrading to the latest version.'; + this.configs[configKey].tooltipClass = 'tooltip-warning'; + } + else { // none supported + this.configs[configKey].tooltipMessage = 'None of the SDKs you use support this option. Please use the latest versions of Android, Web, iOS, Flutter or RN SDKs to use this option.'; + this.configs[configKey].tooltipClass = 'tooltip-danger'; + } + + }); + this.$forceUpdate(); } } }); @@ -297,7 +486,7 @@ priority: 2, route: "#/manage/sdk/configurations", component: SDKConfigurationView, - title: "SDK Configuration", + title: "SDK Behavior Settings", name: "configurations", permission: FEATURE_NAME, vuex: [ @@ -747,4 +936,5 @@ permission: FEATURE_NAME, vuex: [] }); + })(); \ No newline at end of file diff --git a/plugins/sdk/frontend/public/localization/sdk.properties b/plugins/sdk/frontend/public/localization/sdk.properties index 505879c78ea..8b71fd2f9b6 100644 --- a/plugins/sdk/frontend/public/localization/sdk.properties +++ b/plugins/sdk/frontend/public/localization/sdk.properties @@ -1,4 +1,4 @@ #sdk sdk.plugin-title = SDK Manager -sdk.plugin-description = SDK configuration and data +sdk.plugin-description = SDK behavior and data diff --git a/plugins/sdk/frontend/public/stylesheets/main.scss b/plugins/sdk/frontend/public/stylesheets/main.scss index 247756f0c6d..7c68c43777e 100644 --- a/plugins/sdk/frontend/public/stylesheets/main.scss +++ b/plugins/sdk/frontend/public/stylesheets/main.scss @@ -71,3 +71,19 @@ margin-left: 8px; } } + +.cly-vue-tooltip-icon.ion.ion-help-circled.tooltip-success { + color: #6c3 !important; +} + +.cly-vue-tooltip-icon.ion.ion-help-circled.tooltip-warning { + color: #fc0 !important; +} + +.cly-vue-tooltip-icon.ion.ion-help-circled.tooltip-danger { + color: #f55 !important; +} + +.cly-vue-tooltip-icon.ion.ion-help-circled.tooltip-neutral { + color: #A7AEB8 !important; +} \ No newline at end of file diff --git a/plugins/sdk/frontend/public/templates/config.html b/plugins/sdk/frontend/public/templates/config.html index 6b2ca770287..41c3ffb8bde 100644 --- a/plugins/sdk/frontend/public/templates/config.html +++ b/plugins/sdk/frontend/public/templates/config.html @@ -1,8 +1,13 @@