From b52a34fd74d9584dd5b36c193fba0f41243fdf49 Mon Sep 17 00:00:00 2001 From: turtledreams Date: Tue, 5 Dec 2023 11:48:23 +0900 Subject: [PATCH] module and test --- .github/workflows/main.yml | 51 + .gitignore | 5 + CHANGELOG.md | 3 + Countly.js | 182 + LICENSE | 19 + README.md | 52 +- SECURITY.md | 3 + cypress.config.js | 10 + cypress/.eslintrc.js | 26 + cypress/e2e/consents.cy.js | 114 + cypress/e2e/crashes.cy.js | 36 + cypress/e2e/device_id.cy.js | 313 ++ cypress/e2e/device_id_change.cy.js | 212 + cypress/e2e/device_id_init_scenarios.cy.js | 1083 +++++ cypress/e2e/events.cy.js | 210 + cypress/e2e/health_check.cy.js | 37 + cypress/e2e/heatmaps.cy.js | 174 + cypress/e2e/integration.cy.js | 60 + cypress/e2e/internal_limits.cy.js | 158 + cypress/e2e/manual_widget_reporting.cy.js | 257 + cypress/e2e/multi_instance.cy.js | 25 + cypress/e2e/remaining_requests.cy.js | 81 + cypress/e2e/reponse_validation.cy.js | 170 + cypress/e2e/sessions.cy.js | 237 + cypress/e2e/storage.cy.js | 216 + cypress/e2e/storage_change.cy.js | 129 + cypress/e2e/user_agent.cy.js | 63 + cypress/e2e/user_details.cy.js | 503 ++ cypress/e2e/utm.cy.js | 204 + cypress/e2e/view_utm_referrer.cy.js | 295 ++ cypress/e2e/views.cy copy.js | 305 ++ cypress/e2e/views.cy.js | 305 ++ cypress/e2e/web_worker_queues.cy.js | 33 + cypress/e2e/web_worker_requests.cy.js | 126 + cypress/fixtures/base.html | 8 + cypress/fixtures/click_test.html | 40 + cypress/fixtures/multi_instance.html | 163 + cypress/fixtures/referrer.html | 20 + cypress/fixtures/scroll_test.html | 60 + cypress/fixtures/scroll_test_2.html | 48 + cypress/fixtures/scroll_test_3.html | 73 + cypress/fixtures/session_test_auto.html | 52 + cypress/fixtures/session_test_manual_1.html | 59 + cypress/fixtures/session_test_manual_2.html | 55 + cypress/fixtures/user_agent.html | 19 + cypress/fixtures/variables.json | 1 + cypress/plugins/index.js | 22 + cypress/support/commands.js | 386 ++ cypress/support/e2e.js | 20 + cypress/support/helper.js | 286 ++ cypress/support/index.js | 2 + cypress/support/integration_helper.js | 36 + examples/Angular/countly.d.ts | 4 + examples/Angular/main.ts | 24 + examples/README.md | 44 + examples/React/src/App-WithEffect.js | 33 + examples/React/src/App-WithRouter.js | 46 + examples/React/src/App.test.js | 9 + examples/React/src/Components/Contact.js | 29 + examples/React/src/Components/Header.js | 89 + examples/React/src/Components/Home.js | 24 + examples/React/src/Components/Users.js | 44 + examples/React/src/Components/countly.jpg | Bin 0 -> 66790 bytes examples/React/src/Components/styles.css | 26 + examples/React/src/ErrorBoundary.js | 28 + examples/React/src/Location-WithEffect.js | 27 + examples/React/src/Location-WithRouter.js | 24 + examples/React/src/index.css | 6 + examples/React/src/index.js | 33 + examples/React/src/serviceWorker.js | 141 + examples/React/src/setupTests.js | 5 + examples/Symbolication/public/index.html | 21 + examples/Symbolication/src/main.js | 47 + examples/create_examples.py | 63 + examples/example_apm.html | 40 + examples/example_apm_async.html | 68 + examples/example_async.html | 63 + examples/example_fb.html | 65 + examples/example_formdata.html | 63 + examples/example_ga_adapter.html | 190 + examples/example_gdpr.html | 87 + examples/example_helpers.html | 93 + examples/example_multiple_instances.html | 63 + examples/example_opt_out.html | 77 + examples/example_rating_widgets.html | 55 + examples/example_remote_config.html | 71 + examples/example_sync.html | 49 + examples/example_web_worker.html | 55 + examples/examples_feedback_widgets.html | 131 + examples/images/logo.png | Bin 0 -> 128674 bytes examples/images/team_countly.jpg | Bin 0 -> 66790 bytes examples/style/style.css | 250 + examples/worker.js | 35 + modules/Constants.js | 113 + modules/CountlyClass.js | 4733 +++++++++++++++++++ modules/Platform.js | 3 + modules/Utils.js | 618 +++ package.json | 39 + plugin/boomerang/boomerang.min.js | 10 + plugin/boomerang/countly_boomerang.js | 176 + plugin/ga_adapter/doc.md | 78 + plugin/ga_adapter/ga_adapter.js | 455 ++ test_workers/worker.js | 23 + test_workers/worker_for_test.js | 27 + 104 files changed, 15543 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/main.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Countly.js create mode 100644 LICENSE create mode 100644 SECURITY.md create mode 100644 cypress.config.js create mode 100644 cypress/.eslintrc.js create mode 100644 cypress/e2e/consents.cy.js create mode 100644 cypress/e2e/crashes.cy.js create mode 100644 cypress/e2e/device_id.cy.js create mode 100644 cypress/e2e/device_id_change.cy.js create mode 100644 cypress/e2e/device_id_init_scenarios.cy.js create mode 100644 cypress/e2e/events.cy.js create mode 100644 cypress/e2e/health_check.cy.js create mode 100644 cypress/e2e/heatmaps.cy.js create mode 100644 cypress/e2e/integration.cy.js create mode 100644 cypress/e2e/internal_limits.cy.js create mode 100644 cypress/e2e/manual_widget_reporting.cy.js create mode 100644 cypress/e2e/multi_instance.cy.js create mode 100644 cypress/e2e/remaining_requests.cy.js create mode 100644 cypress/e2e/reponse_validation.cy.js create mode 100644 cypress/e2e/sessions.cy.js create mode 100644 cypress/e2e/storage.cy.js create mode 100644 cypress/e2e/storage_change.cy.js create mode 100644 cypress/e2e/user_agent.cy.js create mode 100644 cypress/e2e/user_details.cy.js create mode 100644 cypress/e2e/utm.cy.js create mode 100644 cypress/e2e/view_utm_referrer.cy.js create mode 100644 cypress/e2e/views.cy copy.js create mode 100644 cypress/e2e/views.cy.js create mode 100644 cypress/e2e/web_worker_queues.cy.js create mode 100644 cypress/e2e/web_worker_requests.cy.js create mode 100644 cypress/fixtures/base.html create mode 100644 cypress/fixtures/click_test.html create mode 100644 cypress/fixtures/multi_instance.html create mode 100644 cypress/fixtures/referrer.html create mode 100644 cypress/fixtures/scroll_test.html create mode 100644 cypress/fixtures/scroll_test_2.html create mode 100644 cypress/fixtures/scroll_test_3.html create mode 100644 cypress/fixtures/session_test_auto.html create mode 100644 cypress/fixtures/session_test_manual_1.html create mode 100644 cypress/fixtures/session_test_manual_2.html create mode 100644 cypress/fixtures/user_agent.html create mode 100644 cypress/fixtures/variables.json create mode 100644 cypress/plugins/index.js create mode 100644 cypress/support/commands.js create mode 100644 cypress/support/e2e.js create mode 100644 cypress/support/helper.js create mode 100644 cypress/support/index.js create mode 100644 cypress/support/integration_helper.js create mode 100644 examples/Angular/countly.d.ts create mode 100644 examples/Angular/main.ts create mode 100644 examples/README.md create mode 100644 examples/React/src/App-WithEffect.js create mode 100644 examples/React/src/App-WithRouter.js create mode 100644 examples/React/src/App.test.js create mode 100644 examples/React/src/Components/Contact.js create mode 100644 examples/React/src/Components/Header.js create mode 100644 examples/React/src/Components/Home.js create mode 100644 examples/React/src/Components/Users.js create mode 100644 examples/React/src/Components/countly.jpg create mode 100644 examples/React/src/Components/styles.css create mode 100644 examples/React/src/ErrorBoundary.js create mode 100644 examples/React/src/Location-WithEffect.js create mode 100644 examples/React/src/Location-WithRouter.js create mode 100644 examples/React/src/index.css create mode 100644 examples/React/src/index.js create mode 100644 examples/React/src/serviceWorker.js create mode 100644 examples/React/src/setupTests.js create mode 100644 examples/Symbolication/public/index.html create mode 100644 examples/Symbolication/src/main.js create mode 100644 examples/create_examples.py create mode 100644 examples/example_apm.html create mode 100644 examples/example_apm_async.html create mode 100644 examples/example_async.html create mode 100644 examples/example_fb.html create mode 100644 examples/example_formdata.html create mode 100644 examples/example_ga_adapter.html create mode 100644 examples/example_gdpr.html create mode 100644 examples/example_helpers.html create mode 100644 examples/example_multiple_instances.html create mode 100644 examples/example_opt_out.html create mode 100644 examples/example_rating_widgets.html create mode 100644 examples/example_remote_config.html create mode 100644 examples/example_sync.html create mode 100644 examples/example_web_worker.html create mode 100644 examples/examples_feedback_widgets.html create mode 100644 examples/images/logo.png create mode 100644 examples/images/team_countly.jpg create mode 100644 examples/style/style.css create mode 100644 examples/worker.js create mode 100644 modules/Constants.js create mode 100644 modules/CountlyClass.js create mode 100644 modules/Platform.js create mode 100644 modules/Utils.js create mode 100644 package.json create mode 100644 plugin/boomerang/boomerang.min.js create mode 100644 plugin/boomerang/countly_boomerang.js create mode 100644 plugin/ga_adapter/doc.md create mode 100644 plugin/ga_adapter/ga_adapter.js create mode 100644 test_workers/worker.js create mode 100644 test_workers/worker_for_test.js diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..52b3fe2 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,51 @@ +name: Cypress Tests with Dependency and Artifact Caching + +on: [push, pull_request] + +jobs: + install: + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Cypress install + uses: cypress-io/github-action@v6 + with: + # Disable running of tests within install job + runTests: false + build: npm run build + + - name: Save build folder + uses: actions/upload-artifact@v3 + with: + name: build + if-no-files-found: error + path: build + + cypress-run: + runs-on: ubuntu-22.04 + needs: install + strategy: + # don't fail the entire matrix on failure + fail-fast: false + matrix: + # run copies of the current job in parallel + containers: [1, 2, 3] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download the build folder + uses: actions/download-artifact@v3 + with: + name: build + path: build + + - name: Cypress run + uses: cypress-io/github-action@v6 + with: + start: npm start + parallel: true + group: 'UI-Chrome' + browser: chrome \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c9d057 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +package-lock.json +rollup.config.js +``` \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2899987 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 23.10.0 + +* Modularized the Web SDK diff --git a/Countly.js b/Countly.js new file mode 100644 index 0000000..b5fff36 --- /dev/null +++ b/Countly.js @@ -0,0 +1,182 @@ +import CountlyClass from "./modules/CountlyClass.js"; +import { featureEnums, DeviceIdTypeInternalEnums, CDN } from "./modules/Constants.js"; +import { checkIfLoggingIsOn } from "./modules/Utils.js"; +import { isBrowser, Countly } from "./modules/Platform.js"; + +var apmLibrariesNotLoaded = true; // used to prevent loading apm scripts multiple times. + +Countly.features = [featureEnums.APM, featureEnums.ATTRIBUTION, featureEnums.CLICKS, featureEnums.CRASHES, featureEnums.EVENTS, featureEnums.FEEDBACK, featureEnums.FORMS, featureEnums.LOCATION, featureEnums.REMOTE_CONFIG, featureEnums.SCROLLS, featureEnums.SESSIONS, featureEnums.STAR_RATING, featureEnums.USERS, featureEnums.VIEWS]; +Countly.q = Countly.q || []; +Countly.onload = Countly.onload || []; +Countly.CountlyClass = CountlyClass; + +Countly.init = function (conf) { + conf = conf || {}; + if (Countly.loadAPMScriptsAsync && apmLibrariesNotLoaded) { + apmLibrariesNotLoaded = false; + initAfterLoadingAPM(conf); + return; + } + var appKey = conf.app_key || Countly.app_key; + if (!Countly.i || !Countly.i[appKey]) { + var inst = new CountlyClass(conf); + if (!Countly.i) { + Countly.i = {}; + for (var key in inst) { + Countly[key] = inst[key]; + } + } + Countly.i[appKey] = inst; + } + return Countly.i[appKey]; +}; + +function initAfterLoadingAPM(conf) { + // TODO: We assume we are in browser context. If browser context checks at top are removed this code should have its own check. + // TODO: We already have a loadFile and loadJS functions but they are not used here. If readability would improve that way, they can also be considered here. + + // Create boomerang script + var boomerangScript = document.createElement("script"); + var countlyBoomerangScript = document.createElement("script"); + + // Set boomerang script attributes + boomerangScript.async = true; + countlyBoomerangScript.async = true; + + // Set boomerang script source + boomerangScript.src = Countly.customSourceBoomerang || CDN.BOOMERANG_SRC; + countlyBoomerangScript.src = Countly.customSourceCountlyBoomerang || CDN.CLY_BOOMERANG_SRC; + + // Append boomerang script to the head + document.getElementsByTagName("head")[0].appendChild(boomerangScript); + document.getElementsByTagName("head")[0].appendChild(countlyBoomerangScript); + + var boomLoaded = false; + var countlyBoomLoaded = false; + boomerangScript.onload = function () { + boomLoaded = true; + }; + countlyBoomerangScript.onload = function () { + countlyBoomLoaded = true; + }; + + var timeoutCounter = 0; + var intervalDuration = 50; + var timeoutLimit = 1500; // TODO: Configurable? Mb with Countly.apmScriptLoadTimeout? + // init Countly only after boomerang is loaded + var intervalID = setInterval(function () { + timeoutCounter += intervalDuration; + if ((boomLoaded && countlyBoomLoaded) || (timeoutCounter >= timeoutLimit)) { + if (Countly.debug) { + var message = "BoomerangJS loaded:[" + boomLoaded + "], countly_boomerang loaded:[" + countlyBoomLoaded + "]."; + if (boomLoaded && countlyBoomLoaded) { + message = "[DEBUG] " + message; + // eslint-disable-next-line no-console + console.log(message); + } + else { + message = "[WARNING] " + message + " Initializing without APM."; + // eslint-disable-next-line no-console + console.warn(message); + } + } + Countly.init(conf); + clearInterval(intervalID); + } + }, intervalDuration); +} + +/** +* Overwrite serialization function for extending SDK with encryption, etc +* @param {any} value - value to serialize +* @return {string} serialized value +* */ +Countly.serialize = function (value) { + // Convert object values to JSON + if (typeof value === "object") { + value = JSON.stringify(value); + } + return value; +}; + +/** +* Overwrite deserialization function for extending SDK with encryption, etc +* @param {string} data - value to deserialize +* @return {varies} deserialized value +* */ +Countly.deserialize = function (data) { + if (data === "") { // we expect string or null only. Empty sting would throw an error. + return data; + } + // Try to parse JSON... + try { + data = JSON.parse(data); + } + catch (e) { + if (checkIfLoggingIsOn()) { + // eslint-disable-next-line no-console + console.warn("[WARNING] [Countly] deserialize, Could not parse the file:[" + data + "], error: " + e); + } + } + + return data; +}; + +/** +* Overwrite a way to retrieve view name +* @return {string} view name +* */ +Countly.getViewName = function () { + if (!isBrowser) { + return "web_worker"; + } + return window.location.pathname; +}; + +/** +* Overwrite a way to retrieve view url +* @return {string} view url +* */ +Countly.getViewUrl = function () { + if (!isBrowser) { + return "web_worker"; + } + return window.location.pathname; +}; + +/** +* Overwrite a way to get search query +* @return {string} view url +* */ +Countly.getSearchQuery = function () { + if (!isBrowser) { + return; + } + return window.location.search; +}; + +/** +* Possible device Id types are: DEVELOPER_SUPPLIED, SDK_GENERATED, TEMPORARY_ID +* @enum DeviceIdType +* */ +Countly.DeviceIdType = { + DEVELOPER_SUPPLIED: DeviceIdTypeInternalEnums.DEVELOPER_SUPPLIED, + SDK_GENERATED: DeviceIdTypeInternalEnums.SDK_GENERATED, + TEMPORARY_ID: DeviceIdTypeInternalEnums.TEMPORARY_ID +}; + +/** + * Monitor parallel storage changes like other opened tabs + */ +if (isBrowser) { + window.addEventListener("storage", function (e) { + var parts = (e.key + "").split("/"); + var key = parts.pop(); + var appKey = parts.pop(); + if (Countly.i && Countly.i[appKey]) { + Countly.i[appKey].onStorageChange(key, e.newValue); + } + }); +} + +export default Countly; \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..910dda0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2012, 2013 Countly + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index fcf9f6c..9543d83 100644 --- a/README.md +++ b/README.md @@ -1 +1,51 @@ -# countly-sdk-js \ No newline at end of file +# Countly JavaScript SDK + +This repository contains the Countly JS SDK, which can be integrated into websites, web workers and web applications. The Countly JS SDK is intended to be used with [Countly Lite](https://countly.com/lite), [Countly Flex](https://countly.com/flex) [Countly Enterprise](https://countly.com/enterprise). + +## What is Countly? + +[Countly](https://countly.com) is a product analytics solution and innovation enabler that helps teams track product performance and customer journey and behavior across [mobile](https://countly.com/mobile-analytics), [web](https://countly.com/web-analytics), +and [desktop](https://countly.com/desktop-analytics) applications. [Ensuring privacy by design](https://countly.com/privacy-by-design), Countly allows you to innovate and enhance your products to provide personalized and customized customer experiences, and meet key business and revenue goals. + +Track, measure, and take action - all without leaving Countly. + +* **Questions or feature requests?** [Join the Countly Community on Discord](https://discord.gg/countly) +* **Looking for the Countly Server?** [Countly Server repository](https://github.com/Countly/countly-server) +* **Looking for other Countly SDKs?** [An overview of all Countly SDKs for mobile, web and desktop](https://support.count.ly/hc/en-us/articles/360037236571-Downloading-and-Installing-SDKs#officially-supported-sdks) + +## Integrating Countly SDK in your projects + +This SDK supports the following features: + +* [Analytics](https://support.count.ly/hc/en-us/articles/4431589003545-Analytics) +* [User Profiles](https://support.count.ly/hc/en-us/articles/4403281285913-User-Profiles) +* [Crash Reports](https://support.count.ly/hc/en-us/articles/4404213566105-Crashes-Errors) +* [A/B Testing](https://support.count.ly/hc/en-us/articles/4416496362393-A-B-Testing-) +* [Performance Monitoring](https://support.count.ly/hc/en-us/articles/4734457847705-Performance) +* [Feedback Widgets](https://support.count.ly/hc/en-us/articles/4652903481753-Feedback-Surveys-NPS-and-Ratings-) + +## Security + +Security is very important to us. If you discover any issue regarding security, please disclose the information responsibly by sending an email to and **not by creating a GitHub issue**. + +## Badges + +If you like Countly, [why not use one of our badges](https://countly.com/brand-guidelines) and give a link back to us so others know about this wonderful platform? + +Countly - Product Analytics + +```JS +Countly - Product Analytics +``` + +Countly - Product Analytics + +```JS +Countly - Product Analytics +``` + +## How can I help you with your efforts? + +Glad you asked! For community support, feature requests, and engaging with the Countly Community, please join us at [our Discord Server](https://discord.gg/countly). We're excited to have you there! + +Also, we are on [Twitter](https://twitter.com/gocountly) and [LinkedIn](https://www.linkedin.com/company/countly) if you would like to keep up with Countly related updates. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..2f47049 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,3 @@ +# Security Policy + +Security is very important to us. If you discover any issue regarding security, please disclose the information responsibly by sending an email to security@count.ly and not by creating a GitHub issue. diff --git a/cypress.config.js b/cypress.config.js new file mode 100644 index 0000000..7fb2fb4 --- /dev/null +++ b/cypress.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from "cypress"; + +export default defineConfig({ + e2e: { + setupNodeEvents(on, config) { + // implement node event listeners here + }, + }, + userAgent: "abcd", +}); diff --git a/cypress/.eslintrc.js b/cypress/.eslintrc.js new file mode 100644 index 0000000..3048bbd --- /dev/null +++ b/cypress/.eslintrc.js @@ -0,0 +1,26 @@ +module.exports = { + plugins: [ + "cypress" + ], + parserOptions: { + ecmaVersion: 6 + }, + rules: { + "cypress/no-assigning-return-values": "error", + "cypress/no-unnecessary-waiting": "off", + "cypress/assertion-before-screenshot": "warn", + "cypress/unsafe-to-chain-command": "off", + "cypress/no-force": "warn", + "cypress/no-async-tests": "error", + "cypress/no-pause": "error", + "comma-dangle": ["error", "never"], + "no-multiple-empty-lines": [2, { max: 1, maxEOF: 0 }] + }, + env: { + "cypress/globals": true + }, + extends: [ + "plugin:cypress/recommended", + "plugin:chai-friendly/recommended" + ] +}; \ No newline at end of file diff --git a/cypress/e2e/consents.cy.js b/cypress/e2e/consents.cy.js new file mode 100644 index 0000000..cc22781 --- /dev/null +++ b/cypress/e2e/consents.cy.js @@ -0,0 +1,114 @@ +/* eslint-disable cypress/no-unnecessary-waiting */ +/* eslint-disable require-jsdoc */ +var Countly = require("../../Countly.js"); +// import * as Countly from "../../dist/countly_umd.js"; +var hp = require("../support/helper.js"); + +function initMain(consent) { + Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + require_consent: consent, + device_id: "György Ligeti", + test_mode: true, + test_mode_eq: true, + debug: true + }); +} + +/** + * Checks a queue object for valid/correct values/limits during consent tests + * @param {Array} eq - events queue + * @param {Array} eventArr - events array for the events to test + * @param {boolean} custom - custom event check + * @param {boolean} internalOnly - internal event check + */ +function consent_check(eq, eventArr, custom, internalOnly) { + var i = 0; + var b = i; + var len = eventArr.length; + if (custom) { + len = 0; + } + if (internalOnly) { + b = i + 1; + len = eventArr.length - 1; + } + while (i < len) { + expect(eq[i].key).to.equal(eventArr[b].key); + expect(eq[i].count).to.equal(eventArr[b].count); + expect(eq[i].segmentation[eventArr[b].count]).to.equal(eventArr[b].segmentation[eventArr[b].count]); + i++; + b++; + } +} + +// tests +describe("Consent tests", () => { + it("Only custom event should be sent to the queue", () => { + hp.haltAndClearStorage(() => { + initMain(true); + Countly.add_consent(["events"]); + hp.events(); + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(1); + consent_check(eq, hp.eventArray, true); + }); + }); + }); + it("All but custom event should be sent to the queue", () => { + hp.haltAndClearStorage(() => { + initMain(true); + Countly.add_consent(["sessions", "views", "users", "star-rating", "apm", "feedback", "push", "clicks"]); + hp.events(); + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(6); + consent_check(eq, hp.eventArray, false, true); + }); + }); + }); + it("All consents given and all events should be recorded", () => { + hp.haltAndClearStorage(() => { + initMain(true); + Countly.add_consent(["sessions", "views", "users", "star-rating", "apm", "feedback", "events", "push", "clicks"]); + hp.events(); + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(7); + consent_check(eq, hp.eventArray, false, false); + }); + }); + }); + it("No consent required and all events should be recorded", () => { + hp.haltAndClearStorage(() => { + initMain(false); + hp.events(); + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(7); + consent_check(eq, hp.eventArray, false, false); + }); + }); + }); + it("Non-merge ID change should reset all consents", () => { + hp.haltAndClearStorage(() => { + initMain(true); + Countly.add_consent(["sessions", "views", "users", "star-rating", "apm", "feedback", "push", "clicks"]); + Countly.change_id("Richard Wagner II", false); + hp.events(); + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(0); + }); + }); + }); + it("Merge ID change should not reset consents", () => { + hp.haltAndClearStorage(() => { + initMain(true); + Countly.add_consent(["sessions", "views", "users", "star-rating", "apm", "feedback", "push", "clicks"]); + Countly.change_id("Richard Wagner the second", true); + hp.events(); + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(6); + consent_check(eq, hp.eventArray, false, true); + }); + }); + }); +}); \ No newline at end of file diff --git a/cypress/e2e/crashes.cy.js b/cypress/e2e/crashes.cy.js new file mode 100644 index 0000000..879fddb --- /dev/null +++ b/cypress/e2e/crashes.cy.js @@ -0,0 +1,36 @@ +/* eslint-disable require-jsdoc */ +var Countly = require("../../Countly.js"); +var hp = require("../support/helper"); + +function initMain() { + Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + test_mode: true + }); +} + +function cause_error() { + // eslint-disable-next-line no-undef + undefined_function(); +} + +describe("Crashes tests ", () => { + it("Checks if a caught crash is reported correctly", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.track_errors(); + try { + cause_error(); + } + catch (err) { + Countly.log_error(err); + } + cy.wait(1000).then(() => { + cy.fetch_local_request_queue().then((rq) => { + cy.check_crash(rq[0], hp.appKey); + }); + }); + }); + }); +}); diff --git a/cypress/e2e/device_id.cy.js b/cypress/e2e/device_id.cy.js new file mode 100644 index 0000000..0d65cdd --- /dev/null +++ b/cypress/e2e/device_id.cy.js @@ -0,0 +1,313 @@ +/* eslint-disable require-jsdoc */ +var Countly = require("../../Countly.js"); +var hp = require("../support/helper"); + +function initMain(deviceId, offline, searchQuery, clear, rq, eq) { + Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + device_id: deviceId, + test_mode: rq, + test_mode_eq: eq, + debug: true, + clear_stored_id: clear, + getSearchQuery: function() { + return searchQuery; + }, + offline_mode: offline + }); +} + +const event = { + key: "buttonClick", + segmentation: { + id: "id" + } +}; + +// ==================================== +// Session cookie checks: We check situations where the session cookie is updated/erased +// ==================================== + +describe("Device ID init tests for session cookies", ()=>{ + // situations that keeps the session cookie + it("Default behavior", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined, false); + Countly.begin_session(); + cy.fetch_from_storage(undefined, "cly_session").then((c1) => { + cy.log("session cookie: " + c1); + const cookie1 = c1; + Countly.halt(); + cy.wait(1000).then(() => { + initMain(undefined, false, undefined, false); + cy.fetch_from_storage(undefined, "cly_session").then((c2) => { + cy.log("session cookie: " + c2); + const cookie2 = c2; + assert.equal(cookie1, cookie2); + }); + }); + }); + }); + }); + it("Default behavior, change_id, merge", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined, false); + Countly.begin_session(); + cy.fetch_from_storage(undefined, "cly_session").then((c1) => { + cy.log("session cookie: " + c1); + const cookie1 = c1; + Countly.halt(); + cy.wait(1000).then(() => { + initMain(undefined, false, undefined, false); + Countly.change_id("new_id", true); + cy.fetch_from_storage(undefined, "cly_session").then((c2) => { + cy.log("session cookie: " + c2); + const cookie2 = c2; + assert.equal(cookie1, cookie2); + }); + }); + }); + }); + }); + + // situations that changes the session cookie + it("Default behavior, change_id, no merge", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined, false); + Countly.begin_session(); + cy.fetch_from_storage(undefined, "cly_session").then((c1) => { + cy.log("session cookie: " + c1); + const cookie1 = c1; + Countly.halt(); + cy.wait(1000).then(() => { + initMain(undefined, false, undefined, false); + Countly.change_id("new_id"); + cy.fetch_from_storage(undefined, "cly_session").then((c2) => { + cy.log("session cookie: " + c2); + const cookie2 = c2; + assert.notEqual(cookie1, cookie2); + }); + }); + }); + }); + }); + it("Clear storage behavior", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined, false); + Countly.begin_session(); + cy.fetch_from_storage(undefined, "cly_session").then((c1) => { + cy.log("session cookie: " + c1); + const cookie1 = c1; + Countly.halt(); + cy.wait(1000).then(() => { + initMain(undefined, false, undefined, true); // clear stored id + cy.fetch_from_storage(undefined, "cly_session").then((c2) => { + cy.log("session cookie: " + c2); + const cookie2 = c2; + assert.notEqual(cookie1, cookie2); + }); + }); + }); + }); + }); +}); + +// ==================================== +// Event queue checks : We block the event queue processing to observe if internal event queue flushing works. +// ==================================== + +describe("Device ID init tests for event flushing", ()=>{ + // situations where the event queue is kept (no flushing should happen) + it("Default behavior", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined, false, undefined, true); + Countly.add_event(event); + cy.fetch_local_event_queue().then((e1) => { + cy.log("event queue: " + e1); + const event1 = e1; + + Countly.halt(); + cy.wait(1000).then(() => { + initMain(undefined, false, undefined, false, undefined, true); + cy.fetch_local_event_queue().then((e2) => { + cy.log("event queue: " + e2); + const event2 = e2; + assert.deepEqual(event1, event2); + }); + }); + }); + }); + }); + it("Default behavior, change_id, merge", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined, false, undefined, true); + Countly.add_event(event); + cy.fetch_local_event_queue().then((e1) => { + cy.log("event queue: " + e1); + const event1 = e1; + + Countly.halt(); + cy.wait(1000).then(() => { + initMain(undefined, false, undefined, false, undefined, true); + Countly.change_id("new_id", true); + cy.fetch_local_event_queue().then((e2) => { + cy.log("event queue: " + e2); + const event2 = e2; + assert.deepEqual(event1, event2); + }); + }); + }); + }); + }); + + // situations where the event queue is cleared/flushed. + it("Default behavior, change_id, no merge", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined, false, undefined, true); + Countly.add_event(event); + cy.fetch_local_event_queue().then((e1) => { + cy.log("event queue: " + e1); + const event1 = e1; + + Countly.halt(); + cy.wait(1000).then(() => { + initMain(undefined, false, undefined, false, undefined, true); + Countly.change_id("new_id"); + cy.fetch_local_event_queue().then((e2) => { + cy.log("event queue: " + e2); + const event2 = e2; + assert.notDeepEqual(event1, event2); + }); + }); + }); + }); + }); + it("Clear storage behavior", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined, false, undefined, true); + Countly.add_event(event); + cy.fetch_local_event_queue().then((e1) => { + cy.log("event queue: " + e1); + const event1 = e1; + + Countly.halt(); + cy.wait(1000).then(() => { + initMain(undefined, false, undefined, true, undefined, true); + cy.wait(1000).then(() => { + cy.fetch_local_event_queue().then((e2) => { + cy.log("event queue: " + e2); + const event2 = e2; + assert.notDeepEqual(event1, event2); + }); + }); + }); + }); + }); + }); +}); + +// ==================================== +// Request queue checks: Situations where both event queue and request queue processes are stopped to observe if internal event queue flushing works +// ==================================== + +describe("Device ID init tests for request state", ()=>{ + it("Default behavior", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined, false, true, true); + Countly.add_event(event); + cy.fetch_local_request_queue().then((r1) => { + cy.log("request queue: " + r1); + const req1 = r1; + assert.equal(r1.length, 0); + + Countly.halt(); + cy.wait(1000).then(() => { + initMain(undefined, false, undefined, false, true, true); + cy.fetch_local_request_queue().then((r2) => { + cy.log("request queue: " + r2); + const req2 = r2; + assert.equal(r2.length, 0); + assert.deepEqual(req1, req2); + }); + }); + }); + }); + }); + it("Default behavior, change_id, merge", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined, false, true, true); + Countly.add_event(event); + cy.fetch_local_request_queue().then((r1) => { + cy.log("request queue: " + r1); + const req1 = r1; + assert.equal(r1.length, 0); + + Countly.halt(); + cy.wait(1000).then(() => { + initMain(undefined, false, undefined, false, true, true); + Countly.change_id("new_id", true); + cy.fetch_local_request_queue().then((r2) => { + cy.log("request queue: " + r2); + const req2 = r2; + expect(r2[0].old_device_id).to.be.ok; + assert.notDeepEqual(req1, req2); + }); + }); + }); + }); + }); + it("Default behavior, change_id, no merge", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined, false, true, true); + Countly.add_event(event); + cy.fetch_local_request_queue().then((r1) => { + cy.log("request queue: " + r1); + const req1 = r1; + assert.equal(r1.length, 0); + + Countly.halt(); + cy.wait(1000).then(() => { + initMain(undefined, false, undefined, false, true, true); + Countly.change_id("new_id"); + cy.fetch_local_request_queue().then((r2) => { + cy.log("request queue: " + r2); + const req2 = r2; + assert.equal(r2.length, 2); + expect(r2[0].events).to.be.ok; + expect(r2[1].begin_session).to.be.ok; + assert.notDeepEqual(req1, req2); + }); + }); + }); + }); + }); + it("Clear storage behavior", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined, false, true, true); + Countly.add_event(event); + cy.fetch_local_request_queue().then((r1) => { + cy.log("request queue: " + r1); + const req1 = r1; + assert.equal(r1.length, 0); + + Countly.halt(); + cy.wait(1000).then(() => { + initMain(undefined, false, undefined, true, true, true); + cy.fetch_local_request_queue().then((r2) => { + cy.log("request queue: " + r2); + const req2 = r2; + assert.notDeepEqual(req1, req2); + }); + }); + }); + }); + }); + it("Start with a long numerical device ID", () => { + hp.haltAndClearStorage(() => { + localStorage.setItem("YOUR_APP_KEY/cly_id", "12345678901234567890123456789012345678901234567890123456789012345678901234567890"); + initMain(undefined, false, undefined, false, true, true); + expect(Countly.get_device_id()).to.equal("12345678901234567890123456789012345678901234567890123456789012345678901234567890"); + }); + }); +}); \ No newline at end of file diff --git a/cypress/e2e/device_id_change.cy.js b/cypress/e2e/device_id_change.cy.js new file mode 100644 index 0000000..44dcc3a --- /dev/null +++ b/cypress/e2e/device_id_change.cy.js @@ -0,0 +1,212 @@ +var Countly = require("../../Countly.js"); +var hp = require("../support/helper.js"); + +// ======================================== +// Device ID change tests +// These tests are to test the device id change functionality after init +// Four situation this occurs is temp id enabling, disabling, change ID with and without merge +// ======================================== + +/** + * + * @param {*} offline - offline mode + */ +function initMain(offline) { + Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + test_mode: true, + debug: true, + offline_mode: offline + }); +} + +/** + * + * @param {String} param - event param for value of key + * @returns {Object}- event object + */ +function eventObj(param) { + return { + key: param, + segmentation: { + id: "id" + } + }; +} + +/** + * This function tests the device id change after init + * @param {Function} callbackSecond - callback to be called for device ID change + * @param {Function} callbackInitial - callback to be called after init + * @param {Boolean} generatedID - if the device ID is generated, + */ +function testDeviceIdInReqs(callbackSecond, callbackInitial, generatedID) { + callbackSecond = callbackSecond || function() { }; + generatedID = generatedID || "new ID"; + let initialID; + + if (callbackInitial) { + callbackInitial(); // this is for enabling offline mode + } + + Countly.add_event(eventObj("1")); // record an event. + + cy.fetch_local_request_queue().then((eq) => { + if (callbackInitial) { // testing default config + cy.log(eq); + expect(eq.length).to.equal(3); // 3 requests + initialID = eq[0].device_id; // get the new id from first item in the queue + expect(initialID.length).to.equal(36); // it should be a valid uuid + + expect(eq[0].device_id).to.not.equal("[CLY]_temp_id"); // it should not be the temp id + expect(eq[0].device_id).to.equal(initialID); + + expect(eq[1].device_id).to.not.equal("[CLY]_temp_id"); // it should not be the temp id + expect(eq[1].device_id).to.equal(initialID); + + expect(eq[2].device_id).to.equal("[CLY]_temp_id"); // last recorded has the temp id + } + else { // testing offline init + expect(eq.length).to.equal(1); // only 1 request which is the event we recorded + expect(eq[0].device_id).to.equal("[CLY]_temp_id"); // and it has the temp id + } + + // now lets disable temp mode + callbackSecond(); // and give a new device id or not + Countly.add_event(eventObj("2")); // record another event + Countly.user_details({ name: "name" }); // record user details + cy.wait(500); // wait for the request to be sent + + cy.fetch_local_request_queue().then((eq2) => { + expect(eq2.length).to.equal(callbackInitial ? 5 : 3); // now 3 or 5 requests depending test mode + if (generatedID && generatedID !== "new ID") { // if we have a generated id, in case disable_offline_mode is called without new id + generatedID = eq2[0].device_id; // get the new id from any item in the queue + expect(generatedID).to.not.equal("[CLY]_temp_id"); // it should not be the temp id + expect(generatedID.length).to.equal(36); // it should be a valid uuid + } + + // TODO: maybe make this part shorter + if (callbackInitial) { // testing default config + expect(eq2[0].device_id).to.equal(initialID); + expect(eq2[1].device_id).to.equal(initialID); + expect(eq2[2].device_id).to.equal(generatedID === "new ID" ? generatedID : initialID); + expect(eq2[3].device_id).to.equal(generatedID === "new ID" ? generatedID : initialID); + expect(eq2[4].device_id).to.equal(generatedID === "new ID" ? generatedID : initialID); + } + else { // testing offline init + expect(eq2[0].device_id).to.equal(generatedID); + expect(eq2[1].device_id).to.equal(generatedID); + expect(eq2[2].device_id).to.equal(generatedID); + } + }); + }); +} + +describe("Device ID change tests ", ()=>{ + // ======================================== + // init time offline mode tests + // start offline -> + // record an even -> + // change id/ disable offline mode -> + // record another event and user details -> + // check the device id in the requests + // ======================================== + + it("Check init time temp mode with disable_offline_mode with new ID", ()=>{ + hp.haltAndClearStorage(() => { + initMain(true); // init in offline mode + testDeviceIdInReqs(()=>{ + Countly.disable_offline_mode("new ID"); + }); + }); + }); + it("Check init time temp mode with disable_offline_mode without new ID", ()=>{ + hp.haltAndClearStorage(() => { + initMain(true); // init in offline mode + testDeviceIdInReqs(()=>{ + Countly.disable_offline_mode(); + }, undefined, true); + }); + }); + it("Check init time temp mode with merge change_id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(true); // init in offline mode + testDeviceIdInReqs(()=>{ + Countly.change_id("new ID", true); + }); + }); + }); + it("Check init time temp mode with non-merge change_id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(true); // init in offline mode + testDeviceIdInReqs(()=>{ + Countly.change_id("new ID", false); + }); + }); + }); + + // ======================================== + // default init configuration tests + // start online -> + // record an even and user details -> + // enable offline mode -> + // record another event -> + // change id/ disable offline mode -> + // record another event and user details -> + // check the device id in the requests + // ======================================== + + it("Check default init with enable_offline_mode then disable_offline_mode with new ID", ()=>{ + hp.haltAndClearStorage(() => { + initMain(false); // init normally + testDeviceIdInReqs(()=>{ + Countly.disable_offline_mode("new ID"); + }, ()=>{ + Countly.add_event(eventObj("0")); // record an event prior + Countly.user_details({ name: "name2" }); // record user details + cy.wait(1000); // wait for the request to be sent + Countly.enable_offline_mode(); + }); + }); + }); + it("Check default init with enable_offline_mode then disable_offline_mode with no ID", ()=>{ + hp.haltAndClearStorage(() => { + initMain(false); // init normally + testDeviceIdInReqs(()=>{ + Countly.disable_offline_mode(); + }, ()=>{ + Countly.add_event(eventObj("0")); // record an event prior + Countly.user_details({ name: "name2" }); // record user details + cy.wait(1000); // wait for the request to be sent + Countly.enable_offline_mode(); + }, true); + }); + }); + it("Check default init with enable_offline_mode then change_id with merge", ()=>{ + hp.haltAndClearStorage(() => { + initMain(false); // init normally + testDeviceIdInReqs(()=>{ + Countly.change_id("new ID", true); + }, ()=>{ + Countly.add_event(eventObj("0")); // record an event prior + Countly.user_details({ name: "name2" }); // record user details + cy.wait(1000); // wait for the request to be sent + Countly.enable_offline_mode(); + }); + }); + }); + it("Check default init with enable_offline_mode then change_id with non-merge", ()=>{ + hp.haltAndClearStorage(() => { + initMain(false); // init normally + testDeviceIdInReqs(()=>{ + Countly.change_id("new ID", false); + }, ()=>{ + Countly.add_event(eventObj("0")); // record an event prior + Countly.user_details({ name: "name2" }); // record user details + cy.wait(1000); // wait for the request to be sent + Countly.enable_offline_mode(); + }); + }); + }); +}); \ No newline at end of file diff --git a/cypress/e2e/device_id_init_scenarios.cy.js b/cypress/e2e/device_id_init_scenarios.cy.js new file mode 100644 index 0000000..feb4776 --- /dev/null +++ b/cypress/e2e/device_id_init_scenarios.cy.js @@ -0,0 +1,1083 @@ +/* eslint-disable cypress/no-unnecessary-waiting */ +/* eslint-disable require-jsdoc */ +/** + * +--------------------------------------------------+------------------------------------+----------------------+ + * | SDK state at the end of the previous app session | Provided configuration during init | Action taken by SDK | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | Custom | SDK used a | Temp ID | Custom | Temporary | URL | Flag | flag | + * | device ID | generated | mode was | device ID | device ID | | not | | + * | was set | ID | enabled | provided | enabled | | set | set | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | First init | - | - | - | 1 | - | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | First init | x | - | - | 2 | - | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | First init | - | x | - | 3 | - | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | First init | - | - | x | 4 | - | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | First init | x | x | - | 5 | - | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | First init | x | - | x | 6 | - | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | First init | - | x | x | 7 | - | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | First init | x | x | x | 8 | - | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | x | - | - | - | - | - | 17 | 33 | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | x | - | - | x | - | - | 18 | 34 | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | x | - | - | - | x | - | 19 | 35 | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | x | - | - | - | - | x | 20 | 36 | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | x | - | - | x | x | - | 21 | 37 | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | x | - | - | x | - | x | 22 | 38 | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | x | - | - | - | x | x | 23 | 39 | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | x | - | - | x | x | x | 24 | 40 | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | - | - | x | - | - | - | 25 | 41 | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | - | - | x | x | - | - | 26 | 42 | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | - | - | x | - | x | - | 27 | 43 | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | - | - | x | - | - | x | 28 | 44 | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | - | - | x | x | x | - | 29 | 45 | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | - | - | x | x | - | x | 30 | 46 | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | - | - | x | - | x | x | 31 | 47 | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | - | - | x | x | x | x | 32 | 48 | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | - | x | - | - | - | - | 49 | 57 | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | - | x | - | x | - | - | 50 | 58 | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | - | x | - | - | x | - | 51 | 59 | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | - | x | - | - | - | x | 52 | 60 | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | - | x | - | x | x | - | 53 | 61 | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | - | x | - | x | - | x | 54 | 62 | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | - | x | - | - | x | x | 55 | 63 | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | - | x | - | x | x | x | 56 | 64 | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | Change ID and offline mode tests | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | First init | - | - | - | 9-10 | - | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | First init | x | - | - | 11-12 | - | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | First init | - | x | - | 13-14 | - | + * +--------------------------------------------------+------------------------------------+----------------------+ + * | First init | - | - | x | 15-16 | - | + * +--------------------------------------------------+------------------------------------+----------------------+ + */ + +var Countly = require("../../Countly.js"); +var hp = require("../support/helper"); + +function initMain(deviceId, offline, searchQuery, clear) { + Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + device_id: deviceId, + test_mode: true, + debug: true, + clear_stored_id: clear, + getSearchQuery: function() { + return searchQuery; + }, + offline_mode: offline + }); +} +function validateSdkGeneratedId(providedDeviceId) { + expect(providedDeviceId).to.exist; + expect(providedDeviceId.length).to.eq(36); + expect(Countly._internals.isUUID(providedDeviceId)).to.be.ok; +} +function validateInternalDeviceIdType(expectedType) { + expect(expectedType).to.eq(Countly._internals.getInternalDeviceIdType()); +} +function checkRequestsForT(queue, expectedInternalType) { + for (var i = 0; i < queue.length; i++) { + expect(queue[i].t).to.exist; + expect(queue[i].t).to.eq(Countly._internals.getInternalDeviceIdType()); + expect(queue[i].t).to.eq(expectedInternalType); + } +} + +/** + *device ID type: + *0 - device ID was set by the developer during init + *1 - device ID was auto generated by Countly + *2 - device ID was temporarily given by Countly + *3 - device ID was provided from location.search + */ +var DeviceIdTypeInternalEnumsTest = { + DEVELOPER_SUPPLIED: 0, + SDK_GENERATED: 1, + TEMPORARY_ID: 2, + URL_PROVIDED: 3 +}; +describe("Device Id tests during first init", ()=>{ + // sdk is initialized w/o custom device id, w/o offline mode, w/o utm device id + + // we provide no device id information sdk should generate the id + it("1-SDK is initialized without custom device id, without offline mode, without utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined); + expect(Countly.get_device_id_type()).to.eq(Countly.DeviceIdType.SDK_GENERATED); + validateSdkGeneratedId(Countly.get_device_id()); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + }); + }); + }); + // we provide device id information sdk should use it + it("2-SDK is initialized with custom device id, without offline mode, without utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("gerwutztreimer", false, undefined); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("gerwutztreimer"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + // we provide no device id information sdk should generate the id + it("3-SDK is initialized without custom device id, with offline mode, without utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, true, undefined); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.TEMPORARY_ID); + expect(Countly.get_device_id()).to.eq("[CLY]_temp_id"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.TEMPORARY_ID); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.TEMPORARY_ID); + }); + }); + }); + it("4-SDK is initialized without custom device id, without offline mode, with utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, "?cly_device_id=abab"); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("abab"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + }); + }); + }); + it("5-SDK is initialized with custom device id, with offline mode, without utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("customID", true, undefined); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("customID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + it("6-SDK is initialized with custom device id, without offline mode, with utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("customID2", false, "?cly_device_id=someID"); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("someID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + }); + }); + }); + it("7-SDK is initialized without custom device id, with offline mode, with utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, true, "?cly_device_id=someID"); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("someID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + }); + }); + }); + it("8-SDK is initialized with custom device id, with offline mode, with utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("customID3", true, "?cly_device_id=someID2"); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("someID2"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + }); + }); + }); + + // Here tests focus the device id change and offline mode + // first pair + it("9-SDK is initialized with no device id, not offline mode, not utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.SDK_GENERATED); + validateSdkGeneratedId(Countly.get_device_id()); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + Countly.change_id("newID"); + Countly.begin_session(); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("newID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + it("10-SDK is initialized with no device id, not offline mode, not utm device id, but then offline", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.SDK_GENERATED); + validateSdkGeneratedId(Countly.get_device_id()); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + Countly.enable_offline_mode(); + Countly.change_id("newID"); + Countly.begin_session(); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("newID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + // second pair + it("11-SDK is initialized with user defined device id, not offline mode, not utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("userID", false, undefined); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("userID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + Countly.change_id("newID"); + Countly.begin_session(); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("newID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + it("12-SDK is initialized with user defined device id, not offline mode, not utm device id, but then offline", ()=>{ + hp.haltAndClearStorage(() => { + initMain("userID", false, undefined); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("userID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + Countly.enable_offline_mode(); + Countly.change_id("newID"); + Countly.begin_session(); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("newID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + // third pair + it("13-SDK is initialized with no device id, not offline mode, with utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, "?cly_device_id=abab"); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("abab"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + Countly.change_id("newID"); + Countly.begin_session(); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("newID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + it("14-SDK is initialized with no device id, not offline mode, with utm device id, but then offline", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, "?cly_device_id=abab"); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("abab"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + Countly.enable_offline_mode(); + Countly.change_id("newID"); + Countly.begin_session(); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("newID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + // fourth pair + it("15-SDK is initialized with no device id, with offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, true, undefined); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.TEMPORARY_ID); + expect(Countly.get_device_id()).to.eq("[CLY]_temp_id"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.TEMPORARY_ID); + Countly.change_id("newID"); + Countly.begin_session(); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("newID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + it("16-SDK is initialized with no device id, with offline mode, no utm device id, but then offline", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, true, undefined); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.TEMPORARY_ID); + expect(Countly.get_device_id()).to.eq("[CLY]_temp_id"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.TEMPORARY_ID); + Countly.enable_offline_mode(); + Countly.change_id("newID"); + Countly.begin_session(); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("newID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + + // Auto generated or developer set device ID was present in the local storage before initialization + it("17-Stored ID precedence, SDK is initialized with no device id, not offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("storedID", false, undefined); + Countly.halt(); + initMain(undefined, false, undefined); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("storedID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + it("18-Stored ID precedence, SDK is initialized with device id, not offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("storedID", false, undefined); + Countly.halt(); + initMain("counterID", false, undefined); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("storedID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + it("19-Stored ID precedence, SDK is initialized with no device id, offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("storedID", false, undefined); + Countly.halt(); + initMain(undefined, true, undefined); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("storedID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + it("20-Stored ID precedence, SDK is initialized with no device id, no offline mode, utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("storedID", false, undefined); + Countly.halt(); + initMain(undefined, false, "?cly_device_id=abab"); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("storedID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + it("21-Stored ID precedence, SDK is initialized with device id, offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("storedID", false, undefined); + Countly.halt(); + initMain("counterID", true, undefined); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("storedID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + it("22-Stored ID precedence, SDK is initialized with device id, no offline mode, utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("storedID", false, undefined); + Countly.halt(); + initMain("counterID", false, "?cly_device_id=abab"); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("storedID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + it("23-Stored ID precedence, SDK is initialized no device id, offline mode, utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("storedID", false, undefined); + Countly.halt(); + initMain(undefined, true, "?cly_device_id=abab"); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("storedID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + it("24-Stored ID precedence, SDK is initialized with device id, offline mode, utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("storedID", false, undefined); + Countly.halt(); + initMain("counterID", true, "?cly_device_id=abab"); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("storedID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + + // Temporary ID was present in the local storage before initialization + it("25-Stored temp ID precedence, SDK is initialized with no device id, not offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("[CLY]_temp_id", false, undefined); + Countly.halt(); + initMain(undefined, false, undefined); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.TEMPORARY_ID); + expect(Countly.get_device_id()).to.eq("[CLY]_temp_id"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.TEMPORARY_ID); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.TEMPORARY_ID); + }); + }); + }); + it("26-Stored temp ID precedence, SDK is initialized with device id, not offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("[CLY]_temp_id", false, undefined); + Countly.halt(); + initMain("counterID", false, undefined); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("counterID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + it("27-Stored temp ID precedence, SDK is initialized with no device id, offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("[CLY]_temp_id", false, undefined); + Countly.halt(); + initMain(undefined, true, undefined); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.TEMPORARY_ID); + expect(Countly.get_device_id()).to.eq("[CLY]_temp_id"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.TEMPORARY_ID); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.TEMPORARY_ID); + }); + }); + }); + it("28-Stored temp ID precedence, SDK is initialized with no device id, no offline mode, utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("[CLY]_temp_id", false, undefined); + Countly.halt(); + initMain(undefined, false, "?cly_device_id=abab"); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("abab"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + }); + }); + }); + it("29-Stored temp ID precedence, SDK is initialized with device id, offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("[CLY]_temp_id", false, undefined); + Countly.halt(); + initMain("counterID", true, undefined); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("counterID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + it("30-Stored temp ID precedence, SDK is initialized with device id, no offline mode, utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("[CLY]_temp_id", false, undefined); + Countly.halt(); + initMain("counterID", false, "?cly_device_id=abab"); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("abab"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + }); + }); + }); + it("31-Stored temp ID precedence, SDK is initialized with no device id, offline mode, utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("[CLY]_temp_id", false, undefined); + Countly.halt(); + initMain(undefined, true, "?cly_device_id=abab"); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("abab"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + }); + }); + }); + it("32-Stored temp ID precedence, SDK is initialized with device id, offline mode, utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("[CLY]_temp_id", false, undefined); + Countly.halt(); + initMain("counterID", true, "?cly_device_id=abab"); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("abab"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + }); + }); + }); + + // Same tests with clear device ID flag set to true + // Auto generated or developer set device ID was present in the local storage before initialization + it("33-Cleared ID precedence, SDK is initialized with no device id, not offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("storedID", false, undefined); + Countly.halt(); + initMain(undefined, false, undefined, true); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.SDK_GENERATED); + validateSdkGeneratedId(Countly.get_device_id()); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + }); + }); + }); + it("34-Cleared ID precedence, SDK is initialized with device id, not offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("storedID", false, undefined); + Countly.halt(); + initMain("counterID", false, undefined, true); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("counterID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + it("35-Cleared ID precedence, SDK is initialized with no device id, offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("storedID", false, undefined); + Countly.halt(); + initMain(undefined, true, undefined, true); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.TEMPORARY_ID); + expect(Countly.get_device_id()).to.eq("[CLY]_temp_id"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.TEMPORARY_ID); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.TEMPORARY_ID); + }); + }); + }); + it("36-Cleared ID precedence, SDK is initialized with no device id, no offline mode, utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("storedID", false, undefined); + Countly.halt(); + initMain(undefined, false, "?cly_device_id=abab", true); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("abab"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + }); + }); + }); + it("37-Cleared ID precedence, SDK is initialized with device id, offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("storedID", false, undefined); + Countly.halt(); + initMain("counterID", true, undefined, true); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("counterID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + it("38-Cleared ID precedence, SDK is initialized with device id, no offline mode, utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("storedID", false, undefined); + Countly.halt(); + initMain("counterID", false, "?cly_device_id=abab", true); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("abab"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + }); + }); + }); + it("39-Cleared ID precedence, SDK is initialized with no device id, offline mode, utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("storedID", false, undefined); + Countly.halt(); + initMain(undefined, true, "?cly_device_id=abab", true); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("abab"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + }); + }); + }); + it("40-Cleared ID precedence, SDK is initialized with device id, offline mode, utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("storedID", false, undefined); + Countly.halt(); + initMain("counterID", true, "?cly_device_id=abab", true); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("abab"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + }); + }); + }); + + // Temporary ID was present in the local storage before initialization + it("41-Cleared temp ID precedence, SDK is initialized with no device id, not offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("[CLY]_temp_id", false, undefined); + Countly.halt(); + initMain(undefined, false, undefined, true); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.SDK_GENERATED); + validateSdkGeneratedId(Countly.get_device_id()); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + }); + }); + }); + it("42-Cleared temp ID precedence, SDK is initialized with device id, not offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("[CLY]_temp_id", false, undefined); + Countly.halt(); + initMain("counterID", false, undefined, true); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("counterID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + it("43-Cleared temp ID precedence, SDK is initialized with no device id, offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("[CLY]_temp_id", false, undefined); + Countly.halt(); + initMain(undefined, true, undefined, true); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.TEMPORARY_ID); + expect(Countly.get_device_id()).to.eq("[CLY]_temp_id"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.TEMPORARY_ID); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.TEMPORARY_ID); + }); + }); + }); + it("44-Cleared temp ID precedence, SDK is initialized with no device id, no offline mode, utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("[CLY]_temp_id", false, undefined); + Countly.halt(); + initMain(undefined, false, "?cly_device_id=abab", true); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("abab"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + }); + }); + }); + it("45-Cleared temp ID precedence, SDK is initialized with device id, offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("[CLY]_temp_id", false, undefined); + Countly.halt(); + initMain("counterID", true, undefined, true); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("counterID"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + it("46-Cleared temp ID precedence, SDK is initialized with device id, no offline mode, utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("[CLY]_temp_id", false, undefined); + Countly.halt(); + initMain("counterID", false, "?cly_device_id=abab", true); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("abab"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + }); + }); + }); + it("47-Cleared temp ID precedence, SDK is initialized with no device id, offline mode, utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("[CLY]_temp_id", false, undefined); + Countly.halt(); + initMain(undefined, true, "?cly_device_id=abab", true); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("abab"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + }); + }); + }); + it("48-Cleared temp ID precedence, SDK is initialized with device id, offline mode, utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain("[CLY]_temp_id", false, undefined); + Countly.halt(); + initMain("counterID", true, "?cly_device_id=abab", true); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.eq("abab"); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + }); + }); + }); + + // SDK generated ID was present prior the second init + it("49-Stored UUID precedence, SDK is initialized with no device id, not offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined); + var oldUUID = Countly.get_device_id(); + Countly.halt(); + initMain(undefined, false, undefined); + validateSdkGeneratedId(Countly.get_device_id()); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.SDK_GENERATED); + expect(Countly.get_device_id()).to.eq(oldUUID); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + }); + }); + }); + it("50-Stored UUID precedence, SDK is initialized with device id, not offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined); + var oldUUID = Countly.get_device_id(); + Countly.halt(); + initMain("counterID", false, undefined); + validateSdkGeneratedId(Countly.get_device_id()); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.SDK_GENERATED); + expect(Countly.get_device_id()).to.eq(oldUUID); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + }); + }); + }); + it("51-Stored UUID precedence, SDK is initialized with no device id, offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined); + var oldUUID = Countly.get_device_id(); + Countly.halt(); + initMain(undefined, true, undefined); + validateSdkGeneratedId(Countly.get_device_id()); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.SDK_GENERATED); + expect(Countly.get_device_id()).to.eq(oldUUID); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + }); + }); + }); + it("52-Stored UUID precedence, SDK is initialized with no device id, no offline mode, utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined); + var oldUUID = Countly.get_device_id(); + Countly.halt(); + initMain(undefined, false, "?cly_device_id=abab"); + validateSdkGeneratedId(Countly.get_device_id()); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.SDK_GENERATED); + expect(Countly.get_device_id()).to.eq(oldUUID); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + }); + }); + }); + it("53-Stored UUID precedence, SDK is initialized with device id, offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined); + var oldUUID = Countly.get_device_id(); + Countly.halt(); + initMain("counterID", true, undefined); + validateSdkGeneratedId(Countly.get_device_id()); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.SDK_GENERATED); + expect(Countly.get_device_id()).to.eq(oldUUID); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + }); + }); + }); + it("54-Stored UUID precedence, SDK is initialized with device id, no offline mode, utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined); + var oldUUID = Countly.get_device_id(); + Countly.halt(); + initMain("counterID", false, "?cly_device_id=abab"); + validateSdkGeneratedId(Countly.get_device_id()); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.SDK_GENERATED); + expect(Countly.get_device_id()).to.eq(oldUUID); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + }); + }); + }); + it("55-Stored UUID precedence, SDK is initialized no device id, offline mode, utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined); + var oldUUID = Countly.get_device_id(); + Countly.halt(); + initMain(undefined, true, "?cly_device_id=abab"); + validateSdkGeneratedId(Countly.get_device_id()); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.SDK_GENERATED); + expect(Countly.get_device_id()).to.eq(oldUUID); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + }); + }); + }); + it("56-Stored UUID precedence, SDK is initialized with device id, offline mode, utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined); + var oldUUID = Countly.get_device_id(); + Countly.halt(); + initMain("counterID", true, "?cly_device_id=abab"); + validateSdkGeneratedId(Countly.get_device_id()); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.SDK_GENERATED); + expect(Countly.get_device_id()).to.eq(oldUUID); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + }); + }); + }); + + // SDK generated ID was present prior the second init (same tests with flag set to true) + it("57-Stored UUID precedence, SDK is initialized with no device id, not offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined); + var oldUUID = Countly.get_device_id(); + Countly.halt(); + initMain(undefined, false, undefined, true); + validateSdkGeneratedId(Countly.get_device_id()); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.SDK_GENERATED); + expect(Countly.get_device_id()).to.not.eq(oldUUID); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.SDK_GENERATED); + }); + }); + }); + it("58-Stored UUID precedence, SDK is initialized with device id, not offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined); + var oldUUID = Countly.get_device_id(); + Countly.halt(); + initMain("counterID", false, undefined, true); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.not.eq(oldUUID); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + it("59-Stored UUID precedence, SDK is initialized with no device id, offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined); + var oldUUID = Countly.get_device_id(); + Countly.halt(); + initMain(undefined, true, undefined, true); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.TEMPORARY_ID); + expect(Countly.get_device_id()).to.not.eq(oldUUID); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.TEMPORARY_ID); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.TEMPORARY_ID); + }); + }); + }); + it("60-Stored UUID precedence, SDK is initialized with no device id, no offline mode, utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined); + var oldUUID = Countly.get_device_id(); + Countly.halt(); + initMain(undefined, false, "?cly_device_id=abab", true); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.not.eq(oldUUID); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + }); + }); + }); + it("61-Stored UUID precedence, SDK is initialized with device id, offline mode, no utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined); + var oldUUID = Countly.get_device_id(); + Countly.halt(); + initMain("counterID", true, undefined, true); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.not.eq(oldUUID); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.DEVELOPER_SUPPLIED); + }); + }); + }); + it("62-Stored UUID precedence, SDK is initialized with device id, no offline mode, utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined); + var oldUUID = Countly.get_device_id(); + Countly.halt(); + initMain("counterID", false, "?cly_device_id=abab", true); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.not.eq(oldUUID); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + }); + }); + }); + it("63-Stored UUID precedence, SDK is initialized no device id, offline mode, utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined); + var oldUUID = Countly.get_device_id(); + Countly.halt(); + initMain(undefined, true, "?cly_device_id=abab", true); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.not.eq(oldUUID); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + }); + }); + }); + it("64-Stored UUID precedence, SDK is initialized with device id, offline mode, utm device id", ()=>{ + hp.haltAndClearStorage(() => { + initMain(undefined, false, undefined); + var oldUUID = Countly.get_device_id(); + Countly.halt(); + initMain("counterID", true, "?cly_device_id=abab", true); + expect(Countly.get_device_id_type()).to.equal(Countly.DeviceIdType.DEVELOPER_SUPPLIED); + expect(Countly.get_device_id()).to.not.eq(oldUUID); + validateInternalDeviceIdType(DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + Countly.begin_session(); + cy.fetch_local_request_queue().then((eq) => { + checkRequestsForT(eq, DeviceIdTypeInternalEnumsTest.URL_PROVIDED); + }); + }); + }); +}); diff --git a/cypress/e2e/events.cy.js b/cypress/e2e/events.cy.js new file mode 100644 index 0000000..d61283c --- /dev/null +++ b/cypress/e2e/events.cy.js @@ -0,0 +1,210 @@ +/* eslint-disable cypress/no-unnecessary-waiting */ +/* eslint-disable require-jsdoc */ +var Countly = require("../../Countly.js"); +var hp = require("../support/helper"); + +function initMain() { + Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + session_update: 3, + test_mode: true, + test_mode_eq: true, + debug: true + }); +} +// an event object to use +const eventObj = { + key: "in_app_purchase", + count: 3, + sum: 2.97, + dur: 1000, + segmentation: { + app_version: "1.0", + country: "Tahiti" + } +}; +// a timed event object +const timedEventObj = { + key: "timed", + count: 1, + segmentation: { + app_version: "1.0", + country: "Tahiti" + } +}; + +const longStringName = "LongStringNameLongStringNameLongStringNameLongStringNameLongStringNameLongStringNameLongStringNameLongStringName"; + +// a timed event object with long string key +const timedEventObjLong = { + key: longStringName, + count: 1, + segmentation: { + app_version: "1.0", + country: "Tahiti" + } +}; + +describe("Events tests ", () => { + it("Checks if adding events works", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.add_event(eventObj); + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(1); + cy.check_event(eq[0], eventObj, undefined, false); + }); + }); + }); + it("Checks if timed events works", () => { + hp.haltAndClearStorage(() => { + initMain(); + // start the timer + Countly.start_event("timed"); + // wait for a while + cy.wait(3000).then(() => { + cy.fetch_local_event_queue().then((eq) => { + // there should be nothing in the queue + expect(eq.length).to.equal(0); + }); + cy.wait(1000).then(() => { + // end the event and check duration + Countly.end_event(timedEventObj); + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(1); + // we waited 3000 milliseconds so duration must be 3 to 4 + cy.check_event(eq[0], timedEventObj, 4, false); + }); + }); + }); + }); + }); + it("Checks if timed events works with long string", () => { + hp.haltAndClearStorage(() => { + initMain(); + // start the timer + Countly.start_event(longStringName); + // wait for a while + cy.wait(3000).then(() => { + cy.fetch_local_event_queue().then((eq) => { + // there should be nothing in the queue + expect(eq.length).to.equal(0); + }); + cy.wait(1000).then(() => { + // end the event and check duration + Countly.end_event(timedEventObjLong); + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(1); + // we waited 3000 milliseconds so duration must be 3 to 4 + cy.check_event(eq[0], timedEventObjLong, 4, false); + }); + }); + }); + }); + }); + it("Checks if canceling timed events works", () => { + hp.haltAndClearStorage(() => { + initMain(); + // start the timer + Countly.start_event("timed"); + // wait for a while + cy.wait(1000).then(() => { + const didCancel = Countly.cancel_event("timed"); + expect(didCancel).to.be.true; + Countly.end_event(timedEventObj); + cy.fetch_local_event_queue().then((eq) => { + // queue should be empty and end_event should not create an event + expect(eq.length).to.equal(0); + }); + }); + }); + }); + it("Checks if canceling timed events works with long string key", () => { + hp.haltAndClearStorage(() => { + initMain(); + // start the timer + Countly.start_event(longStringName); + // wait for a while + cy.wait(1000).then(() => { + const didCancel = Countly.cancel_event(longStringName); + expect(didCancel).to.be.true; + Countly.end_event(timedEventObjLong); + cy.fetch_local_event_queue().then((eq) => { + // queue should be empty and end_event should not create an event + expect(eq.length).to.equal(0); + }); + }); + }); + }); + it("Checks if canceling timed events with wrong key does nothing", () => { + hp.haltAndClearStorage(() => { + initMain(); + // start the timer + Countly.start_event("timed"); + // wait for a while + cy.wait(3000).then(() => { + const didCancel = Countly.cancel_event("false_key"); + expect(didCancel).to.be.false; // did not cancel as the key was wrong + Countly.end_event(timedEventObj); + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(1); + // we waited 3000 milliseconds so duration must be 3 to 4 + cy.check_event(eq[0], timedEventObj, 3, false); + }); + }); + }); + }); + it("Checks if canceling timed events with empty key does nothing", () => { + hp.haltAndClearStorage(() => { + initMain(); + // start the timer + Countly.start_event("timed"); + // wait for a while + cy.wait(3000).then(() => { + const didCancel = Countly.cancel_event(); + expect(didCancel).to.be.false; // did not cancel as the key was wrong + Countly.end_event(timedEventObj); + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(1); + // we waited 3000 milliseconds so duration must be 3 to 4 + cy.check_event(eq[0], timedEventObj, 3, false); + }); + }); + }); + }); + it("Checks if canceling non existent timed events with false key does nothing", () => { + hp.haltAndClearStorage(() => { + initMain(); + // start the timer + Countly.start_event(); + // wait for a while + cy.wait(3000).then(() => { + const didCancel = Countly.cancel_event("false_key"); + expect(didCancel).to.be.false; // did not cancel as the key was wrong + Countly.end_event(); + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(0); + }); + }); + }); + }); + it("Checks if canceling timed events with wrong key does nothing with Long string key", () => { + hp.haltAndClearStorage(() => { + initMain(); + // start the timer + Countly.start_event(longStringName); + // wait for a while + cy.wait(3000).then(() => { + const didCancel = Countly.cancel_event("false_key"); + expect(didCancel).to.be.false; // did not cancel as the key was wrong + Countly.end_event(timedEventObjLong); + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(1); + // we waited 3000 milliseconds so duration must be 3 to 4 + cy.check_event(eq[0], timedEventObjLong, 3, false); + }); + }); + }); + }); +}); diff --git a/cypress/e2e/health_check.cy.js b/cypress/e2e/health_check.cy.js new file mode 100644 index 0000000..131bb7d --- /dev/null +++ b/cypress/e2e/health_check.cy.js @@ -0,0 +1,37 @@ +/* eslint-disable require-jsdoc */ +var Countly = require("../../Countly.js"); +var hp = require("../support/helper"); + +function initMain() { + Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + test_mode: true + }); +} + +describe("Health Check tests ", () => { + it("Check if health check is sent at the beginning", () => { + hp.haltAndClearStorage(() => { + initMain(); + cy.intercept("https://your.domain.count.ly/i?*").as("getXhr"); + cy.wait("@getXhr").then((xhr) => { + const url = new URL(xhr.request.url); + + // Test the 'hc' parameter + const hcParam = url.searchParams.get("hc"); + const hcParamObj = JSON.parse(hcParam); + expect(hcParamObj).to.eql({ el: 0, wl: 0, sc: -1, em: "\"\"" }); + + // Test the 'metrics' parameter + const metricsParam = url.searchParams.get("metrics"); + expect(metricsParam).to.equal("{\"_app_version\":\"0.0\",\"_ua\":\"abcd\"}"); + + // check nothing in the request queue + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(0); + }); + }); + }); + }); +}); diff --git a/cypress/e2e/heatmaps.cy.js b/cypress/e2e/heatmaps.cy.js new file mode 100644 index 0000000..ca5d469 --- /dev/null +++ b/cypress/e2e/heatmaps.cy.js @@ -0,0 +1,174 @@ +// TODO: click and scrolls tests but scrolls first, with html files + +/* eslint-disable require-jsdoc */ +var hp = require("../support/helper"); +const clickX = 20; +const clickY = 20; + +function click_check(segmentation, offX, offY) { + expect(segmentation.domain).to.be.ok; + expect(segmentation.type).to.equal("click"); + expect(segmentation.height).to.be.ok; + expect(segmentation.view).to.be.ok; + expect(segmentation.width).to.be.ok; + expect(segmentation.x).to.be.above(clickX + offX - 2); + expect(segmentation.x).to.be.below(clickX + offX + 2); + expect(segmentation.y).to.be.above(clickY + offY - 2); + expect(segmentation.y).to.be.below(clickY + offY + 2); +} + +describe("Browser heatmap tests, scrolls", () => { + it("Check if scrolls are sent if page url changes, multi page", () => { + cy.visit("./cypress/fixtures/scroll_test.html"); + cy.scrollTo("bottom"); + cy.visit("./cypress/fixtures/scroll_test_2.html"); + hp.waitFunction(hp.getTimestampMs(), 1000, 100, () => { + cy.fetch_local_request_queue(hp.appKey).then((rq) => { + cy.log(rq); + // There should be 4 requests: session -> event batch 1 -> session_duration -> event batch 2 + expect(rq.length).to.equal(4); + const beginSessionReq = rq[0]; + const eventBatch1 = JSON.parse(rq[1].events); + const sessionDurationReq = rq[2]; + const eventBatch2 = JSON.parse(rq[3].events); + + // 1st req + cy.check_session(beginSessionReq, undefined, undefined, hp.appKey); + + // 2nd req + expect(eventBatch1.length).to.equal(2); + expect(eventBatch1[0].key).to.equal("[CLY]_orientation"); + expect(eventBatch1[0].segmentation.mode).to.be.ok; + cy.check_view_event(eventBatch1[1], "/cypress/fixtures/scroll_test.html", undefined, false); // start view + + // 3rd object of the req queue should be about session extension, also input the expected duration range, we expect 0 here so we enter a value lower than that but not deviated more than 1 + cy.check_session(sessionDurationReq, -0.5, undefined); + + // 4th object of the queue should be events in the queue, there must be 4 of them + expect(eventBatch2.length).to.equal(4); + cy.check_view_event(eventBatch2[0], "/cypress/fixtures/scroll_test.html", 0, false); // end view + cy.check_scroll_event(eventBatch2[1]); + expect(eventBatch2[2].key).to.equal("[CLY]_orientation"); + expect(eventBatch2[2].segmentation.mode).to.be.ok; + cy.check_view_event(eventBatch2[3], "/cypress/fixtures/scroll_test_2.html", undefined, false); // new page + }); + }); + }); + it("Check if scrolls are sent if for single page apps/sites", () => { + cy.visit("./cypress/fixtures/scroll_test_3.html"); + // TODO: gave exact coordinates and check those exact coordinates are recorded or not for scrolls + cy.scrollTo("bottom"); + // click button that triggers view change + cy.get("#b1").click(); + cy.scrollTo("bottom"); + // click button that triggers view change + cy.get("#b2").click(); + // There should be 3 requests: session -> event batch 1 -> event batch 2 + hp.waitFunction(hp.getTimestampMs(), 1000, 100, () => { + cy.fetch_local_request_queue(hp.appKey).then((rq) => { + expect(rq.length).to.equal(3); + + cy.check_session(rq[0], undefined, undefined, hp.appKey); + + const eventBatch1 = JSON.parse(rq[1].events); // 0 is orientation, 1 is view + expect(eventBatch1[0].key).to.equal("[CLY]_orientation"); + expect(eventBatch1[0].segmentation.mode).to.be.ok; + cy.check_view_event(eventBatch1[1], "/cypress/fixtures/scroll_test_3.html", undefined, false); + + const eventBatch2 = JSON.parse(rq[2].events); // 0 is view, 1 is scroll, 2 is view, 3 is scroll + cy.check_scroll_event(eventBatch2[0]); + cy.check_view_event(eventBatch2[1], "/cypress/fixtures/scroll_test_3.html", 0, false); + cy.check_view_event(eventBatch2[2], "v1", undefined, true); + cy.check_scroll_event(eventBatch2[3]); + cy.check_view_event(eventBatch2[4], "v1", 0, true); + cy.check_view_event(eventBatch2[5], "v2", undefined, true); + }); + }); + }); +}); +describe("Browser heatmap tests, clicks", () => { + it("Check if the clicks are send", () => { + cy.visit("./cypress/fixtures/click_test.html"); + cy.get("#click").click(clickX, clickY); + // There should be 3 requests: session -> event batch 1 -> event batch 2 + hp.waitFunction(hp.getTimestampMs(), 1000, 100, () => { + cy.fetch_local_request_queue(hp.appKey).then((rq) => { + cy.log(rq); + expect(rq.length).to.equal(3); + const beginSessionReq = rq[0]; + const eventBatch1 = JSON.parse(rq[1].events); + const eventBatch2 = JSON.parse(rq[2].events); + + // 1st req + cy.check_session(beginSessionReq, undefined, undefined, hp.appKey); + + // 2nd req + expect(eventBatch1.length).to.equal(2); + expect(eventBatch1[0].key).to.equal("[CLY]_orientation"); + expect(eventBatch1[0].segmentation.mode).to.be.ok; + cy.check_view_event(eventBatch1[1], "/cypress/fixtures/click_test.html", undefined, false); // start view + + // 3rd req + expect(eventBatch2[0].key).to.equal("[CLY]_action"); + cy.check_commons(eventBatch2[0]); + const seg = eventBatch2[0].segmentation; + click_check(seg, 8, 8); + }); + }); + }); + it("Check if the DOM restriction works if non targeted child clicked", () => { + cy.visit("./cypress/fixtures/click_test.html?dom=click2"); + // this click must be ignored as it is not click2 + cy.get("#click").click(clickX, clickY); + // There should be 2 requests: session -> event batch 1 + hp.waitFunction(hp.getTimestampMs(), 1000, 100, () => { + cy.fetch_local_request_queue(hp.appKey).then((rq) => { + cy.log(rq); + expect(rq.length).to.equal(2); + const beginSessionReq = rq[0]; + const eventBatch1 = JSON.parse(rq[1].events); + + // 1st req + cy.check_session(beginSessionReq, undefined, undefined, hp.appKey); + + // 2nd req + expect(eventBatch1.length).to.equal(2); + expect(eventBatch1[0].key).to.equal("[CLY]_orientation"); + expect(eventBatch1[0].segmentation.mode).to.be.ok; + cy.check_view_event(eventBatch1[1], "/cypress/fixtures/click_test.html", undefined, false); // start view + }); + }); + }); + it("Check if the DOM restriction works only the child is clicked", () => { + cy.visit("./cypress/fixtures/click_test.html?dom=click2"); + // only click2 must be perceived + cy.get("#click").click(clickX, clickY); + cy.get("#click2").click(clickX, clickY); + cy.get("#click3").click(clickX, clickY); + hp.waitFunction(hp.getTimestampMs(), 1000, 100, () => { + // There should be 3 requests: session -> event batch 1 -> event batch 2 + cy.fetch_local_request_queue(hp.appKey).then((rq) => { + cy.log(rq); + expect(rq.length).to.equal(3); + const beginSessionReq = rq[0]; + const eventBatch1 = JSON.parse(rq[1].events); + const eventBatch2 = JSON.parse(rq[2].events); + + // 1st req + cy.check_session(beginSessionReq, undefined, undefined, hp.appKey); + + // 2nd req + expect(eventBatch1.length).to.equal(2); + expect(eventBatch1[0].key).to.equal("[CLY]_orientation"); + expect(eventBatch1[0].segmentation.mode).to.be.ok; + cy.check_view_event(eventBatch1[1], "/cypress/fixtures/click_test.html", undefined, false); // start view + + // 3rd req + expect(eventBatch2[0].key).to.equal("[CLY]_action"); + cy.check_commons(eventBatch2[0]); + const seg = eventBatch2[0].segmentation; + click_check(seg, 80, 8); + }); + }); + }); +}); \ No newline at end of file diff --git a/cypress/e2e/integration.cy.js b/cypress/e2e/integration.cy.js new file mode 100644 index 0000000..b22b906 --- /dev/null +++ b/cypress/e2e/integration.cy.js @@ -0,0 +1,60 @@ +var Countly = require("../../Countly.js"); +var hp = require("../support/helper"); + +/** + * init countly + */ +function initMain() { + Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + debug: true, + test_mode: true + }); +} + +describe("Integration test", () => { + it("int, no consent, no offline_mode", () => { + initMain(); + const idType = Countly.get_device_id_type(); + const id = Countly.get_device_id(); + const consentStatus = Countly.check_any_consent(); + Countly.remove_consent(); + Countly.disable_offline_mode(); + Countly.add_event({ key: "test", count: 1, sum: 1, dur: 1, segmentation: { test: "test" } }); + Countly.start_event("test"); + Countly.cancel_event("gobbledygook"); + Countly.end_event("test"); + Countly.report_conversion("camp_id", "camp_user_id"); + Countly.recordDirectAttribution("camp_id", "camp_user_id"); + Countly.user_details({ name: "name" }); + Countly.userData.set("set", "set"); + Countly.userData.save(); + Countly.report_trace({ name: "name", stz: 1, type: "type" }); + Countly.log_error({ error: "error", stack: "stack" }); + Countly.add_log("error"); + Countly.fetch_remote_config(); + Countly.enrollUserToAb(); + const remote = Countly.get_remote_config(); + Countly.track_sessions(); + Countly.track_pageview(); + Countly.track_errors(); + Countly.track_clicks(); + Countly.track_scrolls(); + Countly.track_links(); + Countly.track_forms(); + Countly.collect_from_forms(); + Countly.collect_from_facebook(); + Countly.opt_in(); + // TODO: widgets + // TODO: make better + cy.fetch_local_request_queue().then((rq) => { + cy.log(rq); + hp.testNormalFlow(rq, "/__cypress/iframes/cypress%5Ce2e%5Cintegration.cy.js", hp.appKey); + expect(consentStatus).to.equal(true); // no consent necessary + expect(remote).to.eql({}); // deepEqual + expect(rq[0].device_id).to.equal(id); + expect(rq[0].t).to.equal(idType); + }); + }); +}); \ No newline at end of file diff --git a/cypress/e2e/internal_limits.cy.js b/cypress/e2e/internal_limits.cy.js new file mode 100644 index 0000000..1185860 --- /dev/null +++ b/cypress/e2e/internal_limits.cy.js @@ -0,0 +1,158 @@ +/* eslint-disable cypress/no-unnecessary-waiting */ +/* eslint-disable require-jsdoc */ +var Countly = require("../../Countly.js"); +var hp = require("../support/helper"); + +const limits = { + key: 8, + value: 8, + segment: 3, + breadcrumb: 2, + line_thread: 3, + line_length: 10 +}; + +function initMain() { + Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + test_mode_eq: true, + test_mode: true, + debug: true, + max_key_length: limits.key, // set maximum key length here + max_value_size: limits.value, // set maximum value length here + max_segmentation_values: limits.segment, // set maximum segmentation number here + max_breadcrumb_count: limits.breadcrumb, // set maximum number of logs that will be stored before erasing old ones + max_stack_trace_lines_per_thread: limits.line_thread, // set maximum number of lines for stack trace + max_stack_trace_line_length: limits.line_length // set maximum length of a line for stack + }); +} +const error = { + stack: "Lorem ipsum dolor sit amet,\n consectetur adipiscing elit, sed do eiusmod tempor\n incididunt ut labore et dolore magna\n aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n Duis aute irure dolor in reprehenderit in voluptate\n velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia\n deserunt mollit anim id\n est laborum." +}; +const bread = { + one: "log1", + two: "log2", + three: "log3", + four: "log4", + five: "log5 too many", + six: "log6", + seven: "log7" +}; +const customEvent = { + key: "Enter your key here", + count: 1, + segmentation: { + "key of 1st seg": "Value of 1st seg", + "key of 2nd seg": "Value of 2nd seg", + "key of 3rd seg": "Value of 3rd seg", + "key of 4th seg": "Value of 4th seg", + "key of 5th seg": "Value of 5th seg" + } +}; +const viewName = "a very long page name"; + +const userDetail = { + name: "Gottlob Frege", + username: "Grundgesetze", + email: "test@isatest.com", + organization: "Bialloblotzsky", + phone: "+4555999423", + // Web URL pointing to user picture + picture: + "https://ih0.redbubble.net/image.276305970.7419/flat,550x550,075,f.u3.jpg", + gender: "M", + byear: 1848, // birth year + custom: { + "SEGkey 1st one": "SEGVal 1st one", + "SEGkey 2st one": "SEGVal 2st one", + "SEGkey 3st one": "SEGVal 3st one", + "SEGkey 4st one": "SEGVal 4st one", + "SEGkey 5st one": "SEGVal 5st one" + } +}; + +const customProperties = { + set: ["name of a character", "Bertrand Arthur William Russell"], + set_once: ["A galaxy far far away", "Called B48FF"], + increment_by: ["byear", 123456789012345], + multiply: ["byear", 2345678901234567], + max: ["byear", 3456789012345678], + min: ["byear", 4567890123456789], + push: ["gender", "II Fernando Valdez"], + push_unique: ["gender", "III Fernando Valdez"], + pull: ["gender", "III Fernando Valdez"] +}; + +describe("Internal limit tests ", () => { + it("Checks if custom event limits works", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.add_event(customEvent); + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(1); + cy.check_custom_event_limit(eq[0], customEvent, limits); + }); + }); + }); + it("Checks if view event limits works", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.track_pageview(viewName); + cy.fetch_local_event_queue().then((eq) => { // TODO: when view event is truncated we provide cvid instead of pvid. fix this + cy.log(eq); + expect(eq.length).to.equal(1); + cy.check_view_event_limit(eq[0], viewName, limits); + expect(eq[0].id).to.be.ok; + expect(eq[0].id.length).to.equal(21); + expect(eq[0].pvid).to.equal(""); + }); + }); + }); + it("Checks if view event limits works", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.add_log(bread.one); + Countly.add_log(bread.two); + Countly.add_log(bread.three); + Countly.add_log(bread.four); + Countly.add_log(bread.five); + Countly.add_log(bread.six); + Countly.add_log(bread.seven); + Countly.log_error(error); + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + cy.check_error_limit(rq[0], limits); + }); + }); + }); + it("Checks if user detail limits works", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.user_details(userDetail); + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + cy.check_user_details(rq[0], userDetail, limits); + }); + }); + }); + it("Checks if custom property limits works", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.set(customProperties.set[0], customProperties.set[1]); // set custom property + Countly.userData.set_once(customProperties.set_once[0], customProperties.set_once[1]); // set custom property only if property does not exist + Countly.userData.increment_by(customProperties.increment_by[0], customProperties.increment_by[1]); // increment value in key by provided value + Countly.userData.multiply(customProperties.multiply[0], customProperties.multiply[1]); // multiply value in key by provided value + Countly.userData.max(customProperties.max[0], customProperties.max[1]); // save max value between current and provided + Countly.userData.min(customProperties.min[0], customProperties.min[1]); // save min value between current and provided + Countly.userData.push(customProperties.push[0], customProperties.push[1]); // add value to key as array element + Countly.userData.push_unique(customProperties.push_unique[0], customProperties.push_unique[1]); // add value to key as array element, but only store unique values in array + Countly.userData.pull(customProperties.pull[0], customProperties.pull[1]); // remove value from array under property with key as name + Countly.userData.save(); + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + cy.check_custom_properties_limit(rq[0], customProperties, limits); + }); + }); + }); +}); \ No newline at end of file diff --git a/cypress/e2e/manual_widget_reporting.cy.js b/cypress/e2e/manual_widget_reporting.cy.js new file mode 100644 index 0000000..2d62813 --- /dev/null +++ b/cypress/e2e/manual_widget_reporting.cy.js @@ -0,0 +1,257 @@ +/* eslint-disable cypress/no-unnecessary-waiting */ +/* eslint-disable require-jsdoc */ +var Countly = require("../../Countly.js"); +var hp = require("../support/helper"); + +const contactMe = true; +const platform = "platform"; +const email = "email"; +const app_version = "app_version"; +const comment = "comment"; +const CountlyWidgetData = { true: true }; + +function CountlyFeedbackWidgetMaker(a, b) { + return { _id: a, type: b }; +} + +function widgetResponseMakerNpsRating(a) { + return { + contactMe: contactMe, // boolean + rating: a, // number + email: email, + comment: comment // string + }; +} +function widgetResponseMakerSurvey(a, b, c, d) { + return { + a: b, + c: d + }; +} + +function ratingMaker(a, b) { + return { + widget_id: a, // string + contactMe: contactMe, // boolean + platform: platform, // string + app_version: app_version, // string + rating: b, // number + comment: comment, // string + email: email // string + }; +} +// num is 1 for ratings, 2 for nps, 3 for surveys +function common_rating_check(param, num) { + // eslint-disable-next-line no-nested-ternary + cy.expect(param[0].key).to.equal(num === 1 ? "[CLY]_star_rating" : num === 2 ? "[CLY]_nps" : "[CLY]_survey"); + cy.expect(param[0].segmentation.app_version).to.equal(app_version); + cy.expect(param[0].segmentation.platform).to.equal(platform); + if (num !== 3) { + cy.expect(param[0].segmentation.comment).to.equal(comment); + if (num === 1) { + cy.expect(param[0].segmentation.contactMe).to.equal(contactMe); + cy.expect(param[0].segmentation.email).to.equal(email); + } + } +} + +function initMain() { + Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + test_mode_eq: true, + test_mode: true, + debug: true + + }); +} +// TODO: Add more tests +describe("Manual Rating Widget recording tests, old call ", () => { + it("Checks if a rating object is send correctly", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.recordRatingWidgetWithID(ratingMaker("123", 1)); + cy.fetch_local_event_queue().then((eq) => { + cy.log(eq); + expect(eq.length).to.equal(1); + cy.check_commons(eq[0], 1); + common_rating_check(eq, 1); + cy.expect(eq[0].segmentation.rating).to.equal(1); + cy.expect(eq[0].segmentation.widget_id).to.equal("123"); + }); + }); + }); + it("Checks if rating recording without id would be stopped", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.recordRatingWidgetWithID(ratingMaker(undefined, 1)); + cy.fetch_local_event_queue().then((eq) => { + cy.log(eq); + expect(eq.length).to.equal(0); + }); + }); + }); + it("Checks if rating recording without rating would be stopped", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.recordRatingWidgetWithID(ratingMaker("123", undefined)); + cy.fetch_local_event_queue().then((eq) => { + cy.log(eq); + expect(eq.length).to.equal(0); + }); + }); + }); + it("Checks if id and rating is enough", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.recordRatingWidgetWithID({ widget_id: "123", rating: 1 }); + cy.fetch_local_event_queue().then((eq) => { + cy.log(eq); + expect(eq.length).to.equal(1); + cy.check_commons(eq[0], 1); + cy.expect(eq[0].segmentation.rating).to.equal(1); + cy.expect(eq[0].segmentation.widget_id).to.equal("123"); + }); + }); + }); + it("Check improper rating number in fixed", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.recordRatingWidgetWithID({ widget_id: "123", rating: 11 }); + cy.fetch_local_event_queue().then((eq) => { + cy.log(eq); + expect(eq.length).to.equal(1); + cy.check_commons(eq[0], 1); + cy.expect(eq[0].segmentation.rating).to.equal(5); + cy.expect(eq[0].segmentation.widget_id).to.equal("123"); + }); + }); + }); +}); +describe("Manual nps recording tests ", () => { + it("Checks if a nps is send correctly", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.reportFeedbackWidgetManually(CountlyFeedbackWidgetMaker("123", "nps"), CountlyWidgetData, widgetResponseMakerNpsRating(2)); + cy.fetch_local_event_queue().then((eq) => { + cy.log(eq); + expect(eq.length).to.equal(1); + cy.check_commons(eq[0], 2); + cy.expect(eq[0].segmentation.rating).to.equal(2); + cy.expect(eq[0].segmentation.widget_id).to.equal("123"); + }); + }); + }); + it("Checks if nps would be omitted with no id", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.reportFeedbackWidgetManually(CountlyFeedbackWidgetMaker(undefined, "nps"), CountlyWidgetData, widgetResponseMakerNpsRating(2)); + cy.fetch_local_event_queue().then((eq) => { + cy.log(eq); + expect(eq.length).to.equal(0); + }); + }); + }); + it("Checks if rating would be curbed", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.reportFeedbackWidgetManually(CountlyFeedbackWidgetMaker("123", "nps"), CountlyWidgetData, widgetResponseMakerNpsRating(11)); + cy.fetch_local_event_queue().then((eq) => { + cy.log(eq); + expect(eq.length).to.equal(1); + cy.check_commons(eq[0], 2); + cy.expect(eq[0].segmentation.rating).to.equal(10); + cy.expect(eq[0].segmentation.widget_id).to.equal("123"); + }); + }); + }); +}); +describe("Manual survey recording tests ", () => { + it("Checks if a survey is send correctly", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.reportFeedbackWidgetManually(CountlyFeedbackWidgetMaker("123", "survey"), CountlyWidgetData, widgetResponseMakerSurvey("a", "b", "c", 7)); + cy.fetch_local_event_queue().then((eq) => { + cy.log(eq); + expect(eq.length).to.equal(1); + cy.check_commons(eq[0], 3); + cy.expect(eq[0].segmentation.widget_id).to.equal("123"); + cy.expect(eq[0].segmentation.a).to.equal("b"); + cy.expect(eq[0].segmentation.c).to.equal(7); + }); + }); + }); + it("Checks if null response would have closed flag", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.reportFeedbackWidgetManually(CountlyFeedbackWidgetMaker("123", "survey"), CountlyWidgetData, null); + cy.fetch_local_event_queue().then((eq) => { + cy.log(eq); + expect(eq.length).to.equal(1); + cy.check_commons(eq[0], 3); + cy.expect(eq[0].segmentation.widget_id).to.equal("123"); + cy.expect(eq[0].segmentation.closed).to.equal(1); + }); + }); + }); + it("Checks if no id would be rejected", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.reportFeedbackWidgetManually(CountlyFeedbackWidgetMaker(undefined, "survey"), CountlyWidgetData, widgetResponseMakerSurvey("a", "b", "c", 7)); + cy.fetch_local_event_queue().then((eq) => { + cy.log(eq); + expect(eq.length).to.equal(0); + }); + }); + }); +}); +describe("Manual Rating widget recording tests, new call ", () => { + it("Checks if a rating is send correctly", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.reportFeedbackWidgetManually(CountlyFeedbackWidgetMaker("123", "rating"), CountlyWidgetData, widgetResponseMakerNpsRating(3)); + cy.fetch_local_event_queue().then((eq) => { + cy.log(eq); + expect(eq.length).to.equal(1); + cy.check_commons(eq[0], 1); + cy.expect(eq[0].segmentation.widget_id).to.equal("123"); + cy.expect(eq[0].segmentation.rating).to.equal(3); + }); + }); + }); + it("Checks if null response would have closed flag", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.reportFeedbackWidgetManually(CountlyFeedbackWidgetMaker("123", "rating"), CountlyWidgetData, null); + cy.fetch_local_event_queue().then((eq) => { + cy.log(eq); + expect(eq.length).to.equal(1); + cy.expect(eq[0].segmentation.widget_id).to.equal("123"); + cy.expect(eq[0].segmentation.closed).to.equal(1); + }); + }); + }); + it("Checks if no id would be rejected", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.reportFeedbackWidgetManually(CountlyFeedbackWidgetMaker(undefined, "rating"), CountlyWidgetData, widgetResponseMakerNpsRating(3)); + cy.fetch_local_event_queue().then((eq) => { + cy.log(eq); + expect(eq.length).to.equal(0); + }); + }); + }); + it("Checks if rating would be curbed", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.reportFeedbackWidgetManually(CountlyFeedbackWidgetMaker("123", "rating"), CountlyWidgetData, widgetResponseMakerNpsRating(6)); + cy.fetch_local_event_queue().then((eq) => { + cy.log(eq); + expect(eq.length).to.equal(1); + cy.check_commons(eq[0], 1); + cy.expect(eq[0].segmentation.rating).to.equal(5); + cy.expect(eq[0].segmentation.widget_id).to.equal("123"); + }); + }); + }); +}); \ No newline at end of file diff --git a/cypress/e2e/multi_instance.cy.js b/cypress/e2e/multi_instance.cy.js new file mode 100644 index 0000000..ea0a45e --- /dev/null +++ b/cypress/e2e/multi_instance.cy.js @@ -0,0 +1,25 @@ +/* eslint-disable require-jsdoc */ +var hp = require("../support/helper"); + +describe("Multi instancing tests", () => { + it("Check if request queue has the correct info", () => { + cy.visit("./cypress/fixtures/multi_instance.html"); + hp.waitFunction(hp.getTimestampMs(), 1000, 100, () => { + cy.fetch_local_request_queue(hp.appKey + "1").then((rq) => { + cy.fetch_local_request_queue(hp.appKey + "2").then((rq2) => { + cy.fetch_local_request_queue(hp.appKey + "3").then((rq3) => { + cy.fetch_local_request_queue(hp.appKey + "4").then((rq4) => { + hp.testNormalFlow(rq, "/cypress/fixtures/multi_instance.html", hp.appKey + "1"); + hp.testNormalFlow(rq2, "/cypress/fixtures/multi_instance.html", hp.appKey + "2"); + hp.testNormalFlow(rq3, "/cypress/fixtures/multi_instance.html", hp.appKey + "3"); + hp.testNormalFlow(rq4, "/cypress/fixtures/multi_instance.html", hp.appKey + "4"); + expect(rq[0].device_id).to.not.equal(rq2[0].device_id); + expect(rq3[0].device_id).to.not.equal(rq4[0].device_id); + expect(rq[0].device_id).to.not.equal(rq3[0].device_id); + }); + }); + }); + }); + }); + }); +}); diff --git a/cypress/e2e/remaining_requests.cy.js b/cypress/e2e/remaining_requests.cy.js new file mode 100644 index 0000000..09a6ad1 --- /dev/null +++ b/cypress/e2e/remaining_requests.cy.js @@ -0,0 +1,81 @@ +/* eslint-disable require-jsdoc */ +var Countly = require("../../Countly.js"); +var hp = require("../support/helper"); + +function initMain(shouldStopRequests) { + Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + app_version: "1.0", + // would prevent requests from being sent to the server if true + test_mode: shouldStopRequests + }); +} +const av = "1.0"; +describe("Remaining requests tests ", () => { + it("Checks the requests for rr", () => { + hp.haltAndClearStorage(() => { + initMain(false); + + // We will expect 4 requests: health check, begin_session, end_session, orientation + hp.interceptAndCheckRequests(undefined, undefined, undefined, "?hc=*", "hc", (requestParams) => { + expect(requestParams.get("hc")).to.equal(JSON.stringify({ el: 0, wl: 0, sc: -1, em: "\"\"" })); + expect(requestParams.get("rr")).to.equal(null); + }); + cy.wait(1000).then(() => { + // Create a session + Countly.begin_session(); + hp.interceptAndCheckRequests(undefined, undefined, undefined, "?begin_session=*", "begin_session", (requestParams) => { + expect(requestParams.get("begin_session")).to.equal("1"); + expect(requestParams.get("rr")).to.equal("3"); + expect(requestParams.get("av")).to.equal(av); + }); + // End the session + Countly.end_session(undefined, true); + hp.interceptAndCheckRequests(undefined, undefined, undefined, undefined, "end_session", (requestParams) => { + expect(requestParams.get("end_session")).to.equal("1"); + expect(requestParams.get("rr")).to.equal("2"); + expect(requestParams.get("av")).to.equal(av); + }); + hp.interceptAndCheckRequests(undefined, undefined, undefined, undefined, "orientation", (requestParams) => { + expect(JSON.parse(requestParams.get("events"))[0].key).to.equal("[CLY]_orientation"); + expect(requestParams.get("rr")).to.equal("1"); + expect(requestParams.get("av")).to.equal(av); + }); + cy.wait(100).then(() => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(0); + }); + }); + }); + }); + }); + it("No rr if no request was made to the server", () => { + hp.haltAndClearStorage(() => { + initMain(true); + + // Create a session and end it + Countly.begin_session(); + Countly.end_session(undefined, true); + cy.fetch_local_request_queue().then((rq) => { + // We expect 3 requests in queue: begin_session, end_session, orientation. health check was not in the queue + expect(rq.length).to.equal(3); + expect(rq[0].rr).to.equal(3); + expect(rq[1].rr).to.equal(undefined); + expect(rq[2].rr).to.equal(undefined); + + // Change ID + Countly.change_id("newID"); + cy.fetch_local_request_queue().then((rq2) => { + // We expect 4 requests in queue: begin_session, end_session, orientation and change ID + cy.log(rq2); + expect(rq2.length).to.equal(4); + expect(rq2[0].rr).to.equal(3); // still 3 as it was assigned at the time of the first request creation + expect(rq2[1].rr).to.equal(undefined); + expect(rq2[2].rr).to.equal(undefined); + expect(rq2[3].rr).to.equal(undefined); + }); + }); + }); + }); +}); diff --git a/cypress/e2e/reponse_validation.cy.js b/cypress/e2e/reponse_validation.cy.js new file mode 100644 index 0000000..aa6a6b5 --- /dev/null +++ b/cypress/e2e/reponse_validation.cy.js @@ -0,0 +1,170 @@ +/* eslint-disable comma-spacing */ +/* eslint-disable key-spacing */ +/* eslint-disable quote-props */ +/* eslint-disable object-curly-spacing */ +/* eslint-disable cypress/no-unnecessary-waiting */ +/* eslint-disable require-jsdoc */ +var Countly = require("../../Countly.js"); +var hp = require("../support/helper"); + +function initMain() { + Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + test_mode: true, + test_mode_eq: true, + debug: true + }); +} +// a convenience function to test wrong status code options +function variedStatusCodeTestPack(validationFunction, response, result) { + expect(validationFunction(response)).to.equal(result); + expect(validationFunction(-500, response)).to.equal(result); + expect(validationFunction(-400, response)).to.equal(result); + expect(validationFunction(-301, response)).to.equal(result); + expect(validationFunction(-300, response)).to.equal(result); + expect(validationFunction(-201, response)).to.equal(result); + expect(validationFunction(-200, response)).to.equal(result); + expect(validationFunction(-100, response)).to.equal(result); + expect(validationFunction(0, response)).to.equal(result); + expect(validationFunction(100, response)).to.equal(result); + expect(validationFunction(300, response)).to.equal(result); + expect(validationFunction(301, response)).to.equal(result); + expect(validationFunction(400, response)).to.equal(result); + expect(validationFunction(500, response)).to.equal(result); +} + +function fakeResponseTestPack(validationFunction, result) { + variedStatusCodeTestPack(validationFunction, numberResponse, result); + variedStatusCodeTestPack(validationFunction, stringResponse, result); + variedStatusCodeTestPack(validationFunction, arrayResponse1, result); + variedStatusCodeTestPack(validationFunction, arrayResponse2, result); + variedStatusCodeTestPack(validationFunction, arrayResponse3, result); + variedStatusCodeTestPack(validationFunction, arrayResponse4, result); + variedStatusCodeTestPack(validationFunction, objectResponse1, result); + variedStatusCodeTestPack(validationFunction, objectResponse2, result); + variedStatusCodeTestPack(validationFunction, objectResponse3, result); + variedStatusCodeTestPack(validationFunction, objectResponse4, result); + variedStatusCodeTestPack(validationFunction, nullResponse, result); + variedStatusCodeTestPack(validationFunction, undefinedResponse, result); +} + +function fakeResponseKeyTestPack(validationFunction, response, result) { + expect(validationFunction(200, response)).to.equal(result); + expect(validationFunction(201, response)).to.equal(result); +} +// responses, stringified from actual parsed responses from the server +const enableRatingResponse = JSON.stringify([{"_id":"619b8dd77730596209194f7e","popup_header_text":"hohoho","popup_comment_callout":"Add comment","popup_email_callout":"Contact me via e-mail","popup_button_callout":"Submit feedback","popup_thanks_message":"Thank you for your feedback","trigger_position":"bleft","trigger_bg_color":"13B94D","trigger_font_color":"FFFFFF","trigger_button_text":"Feedback","target_devices":{"phone":false,"desktop":true,"tablet":false},"target_page":"all","target_pages":["/"],"is_active":"true","hide_sticker":false,"app_id":"6181431e09e272efa5f64305","contact_enable":"true","comment_enable":"true","trigger_size":"l","type":"rating","ratings_texts":["Very dissatisfied","Somewhat dissatisfied","Neither satisfied Nor Dissatisfied","Somewhat Satisfied","Very Satisfied"],"status":true,"targeting":null,"timesShown":22,"ratingsCount":4,"ratingsSum":13}]); +const popupResponse = JSON.stringify({"_id":"619b8dd77730596209194f7e","popup_header_text":"hohoho","popup_comment_callout":"Add comment","popup_email_callout":"Contact me via e-mail","popup_button_callout":"Submit feedback","popup_thanks_message":"Thank you for your feedback","trigger_position":"bleft","trigger_bg_color":"13B94D","trigger_font_color":"FFFFFF","trigger_button_text":"Feedback","target_devices":{"phone":false,"desktop":true,"tablet":false},"target_page":"all","target_pages":["/"],"is_active":"true","hide_sticker":false,"app_id":"6181431e09e272efa5f64305","contact_enable":"true","comment_enable":"true","trigger_size":"l","type":"rating","ratings_texts":["Very dissatisfied","Somewhat dissatisfied","Neither satisfied Nor Dissatisfied","Somewhat Satisfied","Very Satisfied"],"status":true,"targeting":null,"timesShown":23,"ratingsCount":4,"ratingsSum":13}); +const remoteConfigResponse = JSON.stringify({"Nightfox":{"test":"250 mg"},"firefox":{"clen":"20 mg"}}); + +// fake responses for other testing purposes +const numberResponse = 551; +const stringResponse = "551"; +const arrayResponse1 = []; +const arrayResponse2 = [{}]; +// passing response for isResponseValidBroad +const arrayResponse3 = "[{}]"; +// passing response for isResponseValidBroad +const arrayResponse4 = "[]"; +const objectResponse1 = {}; +const objectResponse2 = {[5]:{}}; +const objectResponse3 = "{[]}"; +// passing response for isResponseValid and isResponseValidBroad +const objectResponse4 = "{}"; +const nullResponse = null; +const undefinedResponse = undefined; + +describe("Response validation tests ", () => { + // enableRating call => only isResponseValidBroad field should yield true + it("isResponseValid, enableRatingResponse", () => { + hp.haltAndClearStorage(() => { + initMain(); + variedStatusCodeTestPack(Countly._internals.isResponseValid, enableRatingResponse, false); + expect(Countly._internals.isResponseValid(200, enableRatingResponse)).to.equal(false); + expect(Countly._internals.isResponseValid(201, enableRatingResponse)).to.equal(false); + }); + }); + it("isResponseValid, enableRatingResponse", () => { + hp.haltAndClearStorage(() => { + initMain(); + variedStatusCodeTestPack(Countly._internals.isResponseValidBroad, enableRatingResponse, false); + expect(Countly._internals.isResponseValidBroad(200, enableRatingResponse)).to.equal(true); + expect(Countly._internals.isResponseValidBroad(201, enableRatingResponse)).to.equal(true); + }); + }); + + // popup call => both isResponseValidBroad and isResponseValid fields should yield true + it("isResponseValid, popupResponse", () => { + hp.haltAndClearStorage(() => { + initMain(); + variedStatusCodeTestPack(Countly._internals.isResponseValid, popupResponse, false); + expect(Countly._internals.isResponseValid(200, popupResponse)).to.equal(false); + expect(Countly._internals.isResponseValid(201, popupResponse)).to.equal(false); + }); + }); + it("isResponseValidBroad, popupResponse", () => { + hp.haltAndClearStorage(() => { + initMain(); + variedStatusCodeTestPack(Countly._internals.isResponseValidBroad, popupResponse, false); + expect(Countly._internals.isResponseValidBroad(200, popupResponse)).to.equal(true); + expect(Countly._internals.isResponseValidBroad(201, popupResponse)).to.equal(true); + }); + }); + + // remoteconfig call => both isResponseValidBroad and isResponseValid fields should yield true + it("isResponseValid, remoteConfigResponse", () => { + hp.haltAndClearStorage(() => { + initMain(); + variedStatusCodeTestPack(Countly._internals.isResponseValid, remoteConfigResponse, false); + expect(Countly._internals.isResponseValid(200, remoteConfigResponse)).to.equal(false); + expect(Countly._internals.isResponseValid(201, remoteConfigResponse)).to.equal(false); + }); + }); + it("isResponseValidBroad, remoteConfigResponse", () => { + hp.haltAndClearStorage(() => { + initMain(); + variedStatusCodeTestPack(Countly._internals.isResponseValidBroad, remoteConfigResponse, false); + expect(Countly._internals.isResponseValidBroad(200, remoteConfigResponse)).to.equal(true); + expect(Countly._internals.isResponseValidBroad(201, remoteConfigResponse)).to.equal(true); + }); + }); + + // fake response calls => both isResponseValidBroad and isResponseValid fields should yield true + it("isResponseValid, fake responses", () => { + hp.haltAndClearStorage(() => { + initMain(); + fakeResponseTestPack(Countly._internals.isResponseValid, false); + fakeResponseKeyTestPack(Countly._internals.isResponseValid, numberResponse, false); + fakeResponseKeyTestPack(Countly._internals.isResponseValid, stringResponse, false); + fakeResponseKeyTestPack(Countly._internals.isResponseValid, arrayResponse1, false); + fakeResponseKeyTestPack(Countly._internals.isResponseValid, arrayResponse2, false); + fakeResponseKeyTestPack(Countly._internals.isResponseValid, arrayResponse3, false); + fakeResponseKeyTestPack(Countly._internals.isResponseValid, arrayResponse4, false); + fakeResponseKeyTestPack(Countly._internals.isResponseValid, objectResponse1, false); + fakeResponseKeyTestPack(Countly._internals.isResponseValid, objectResponse2, false); + fakeResponseKeyTestPack(Countly._internals.isResponseValid, objectResponse3, false); + fakeResponseKeyTestPack(Countly._internals.isResponseValid, objectResponse4, false); + fakeResponseKeyTestPack(Countly._internals.isResponseValid, nullResponse, false); + fakeResponseKeyTestPack(Countly._internals.isResponseValid, undefinedResponse, false); + }); + }); + it("isResponseValidBroad, fake responses", () => { + hp.haltAndClearStorage(() => { + initMain(); + fakeResponseTestPack(Countly._internals.isResponseValidBroad, false); + fakeResponseKeyTestPack(Countly._internals.isResponseValidBroad, numberResponse, false); + fakeResponseKeyTestPack(Countly._internals.isResponseValidBroad, stringResponse, false); + fakeResponseKeyTestPack(Countly._internals.isResponseValidBroad, arrayResponse1, false); + fakeResponseKeyTestPack(Countly._internals.isResponseValidBroad, arrayResponse2, false); + fakeResponseKeyTestPack(Countly._internals.isResponseValidBroad, arrayResponse3, true); + fakeResponseKeyTestPack(Countly._internals.isResponseValidBroad, arrayResponse4, true); + fakeResponseKeyTestPack(Countly._internals.isResponseValidBroad, objectResponse1, false); + fakeResponseKeyTestPack(Countly._internals.isResponseValidBroad, objectResponse2, false); + fakeResponseKeyTestPack(Countly._internals.isResponseValidBroad, objectResponse3, false); + fakeResponseKeyTestPack(Countly._internals.isResponseValidBroad, objectResponse4, true); + fakeResponseKeyTestPack(Countly._internals.isResponseValidBroad, nullResponse, false); + fakeResponseKeyTestPack(Countly._internals.isResponseValidBroad, undefinedResponse, false); + }); + }); +}); diff --git a/cypress/e2e/sessions.cy.js b/cypress/e2e/sessions.cy.js new file mode 100644 index 0000000..1b47548 --- /dev/null +++ b/cypress/e2e/sessions.cy.js @@ -0,0 +1,237 @@ +/* eslint-disable require-jsdoc */ +var Countly = require("../../Countly.js"); +var hp = require("../support/helper"); +// if you are testing on an app +const app_key = hp.appKey; +const waitTime = 7000; +const eventObj = { + key: "buttonClick", + count: 1, + segmentation: { + id: "id" + } +}; + +function initMain() { + Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + session_update: 3, + test_mode: true + }); +} +const dummyQueue = [ + { begin_session: 1, metrics: "{\"_app_version\":\"0.0\",\"_ua\":\"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:96.0) Gecko/20100101 Firefox/96.0\",\"_resolution\":\"1568x882\",\"_density\":1.2244897959183674,\"_locale\":\"en-US\"}", app_key: "YOUR_APP_KEY", device_id: "55669b9b-f9d7-4ed5-bc77-ec5ebb65ddd8", sdk_name: "javascript_native_web", sdk_version: "21.11.0", timestamp: 1644909864950, hour: 10, dow: 2, t: 1, rr: 0 }, + { events: "[{\"key\":\"[CLY]_orientation\",\"count\":1,\"segmentation\":{\"mode\":\"portrait\"},\"timestamp\":1644909864949,\"hour\":10,\"dow\":2}]", app_key: "YOUR_APP_KEY", device_id: "55669b9b-f9d7-4ed5-bc77-ec5ebb65ddd8", sdk_name: "javascript_native_web", sdk_version: "21.11.0", timestamp: 1644909864958, hour: 10, dow: 2, t: 1, rr: 0 }, + { session_duration: 4, metrics: "{\"_ua\":\"hey\"}", app_key: "YOUR_APP_KEY", device_id: "55669b9b-f9d7-4ed5-bc77-ec5ebb65ddd8", sdk_name: "javascript_native_web", sdk_version: "21.11.0", timestamp: 1644909868015, hour: 10, dow: 2, t: 1, rr: 0 }, + { end_session: 1, session_duration: 10, metrics: "{\"_ua\":\"hey\"}", app_key: "YOUR_APP_KEY", device_id: "55669b9b-f9d7-4ed5-bc77-ec5ebb65ddd8", sdk_name: "javascript_native_web", sdk_version: "21.11.0", timestamp: 1644909869459, hour: 10, dow: 2, t: 1, rr: 0 } +]; + +describe("Session tests ", () => { + it("Checks if session start, extension and ending works with a dummy queue", () => { + hp.haltAndClearStorage(() => { + // initialize countly + initMain(); + // begin session + Countly.begin_session(); + // wait for session extension + cy.wait(3000).then(() => { + // end the session + Countly.end_session(10, true); + var queue = dummyQueue; + // first object of the queue should be about begin session + cy.check_session(queue[0]); + // third object of the queue should be about session extension, also input the expected duration + cy.check_session(queue[2], 3); + // fourth object of the queue should be about end session, input the parameters that were used during the end session call + cy.check_session(queue[3], 10, true); + }); + }); + }); + it("Checks if session start, extension and ending works", () => { + hp.haltAndClearStorage(() => { + // initialize countly + initMain(); + // begin session + Countly.begin_session(); + // wait for session extension + cy.wait(4250).then(() => { + // end the session + Countly.end_session(10, true); + // get the JSON string from local storage + cy.fetch_local_request_queue().then((rq) => { + // 3 sessions and 1 orientation + expect(rq.length).to.equal(4); + // first object of the queue should be about begin session, second is orientation + cy.check_session(rq[0]); + // third object of the queue should be about session extension, also input the expected duration + cy.check_session(rq[2], 3); + // fourth object of the queue should be about end session, input the parameters that were used during the end session call + cy.check_session(rq[3], 10, true); + }); + }); + }); + }); +}); +describe("Browser session tests, auto", () => { + it("Single session test with auto sessions", () => { + cy.visit("./cypress/fixtures/session_test_auto.html?use_session_cookie=true") + .wait(waitTime); + cy.contains("Event").click().wait(300); + cy.visit("./cypress/fixtures/base.html"); + cy.fetch_local_request_queue(app_key).then((rq) => { + cy.log(rq); + // 3 session and 1 orientation 1 event + expect(rq.length).to.equal(5); + // first object of the queue should be about begin session, second is orientation + cy.check_session(rq[0], undefined, undefined, app_key); + // third object of the queue should be about session extension, also input the expected duration + cy.check_session(rq[2], 5, undefined, app_key); + // fourth object of the queue should be about event sent + cy.check_event(JSON.parse(rq[3].events)[0], eventObj, undefined, false); + // fifth object of the queue should be about session extension, also input the expected duration + cy.check_session(rq[4], 1, undefined, app_key); + }); + }); +}); +describe("Browser session tests, manual 1", () => { + it("Single sessions test with manual sessions", () => { + cy.visit("./cypress/fixtures/session_test_manual_1.html?use_session_cookie=true"); + cy.contains("Start").click().wait(waitTime); + cy.contains("Event").click().wait(300); + cy.contains("End").click().wait(300); + cy.visit("./cypress/fixtures/base.html"); + cy.fetch_local_request_queue(app_key).then((rq) => { + cy.log(rq); + // 3 session and 1 orientation 1 event + expect(rq.length).to.equal(5); + // first object of the queue should be about begin session, second is orientation + cy.check_session(rq[0], undefined, undefined, app_key); + // third object of the queue should be about session extension, also input the expected duration + cy.check_session(rq[2], 5, undefined, app_key); + // fourth object of the queue should be about event sent + cy.check_event(JSON.parse(rq[3].events)[0], eventObj, undefined, false); + // fifth object of the queue should be about session extension, also input the expected duration + cy.check_session(rq[4], 1, undefined, app_key); + }); + }); +}); +describe("Browser session tests, manual 2", () => { + it("Single bounce test with manual sessions 2", () => { + cy.visit("./cypress/fixtures/session_test_manual_2.html?use_session_cookie=true").wait(waitTime); + cy.contains("Event").click().wait(300); + cy.visit("./cypress/fixtures/base.html"); + cy.fetch_local_request_queue(app_key).then((rq) => { + cy.log(rq); + // 3 session and 1 orientation 1 event + expect(rq.length).to.equal(5); + // first object of the queue should be about begin session, second is orientation + cy.check_session(rq[0], undefined, undefined, app_key); + // third object of the queue should be about session extension, also input the expected duration + cy.check_session(rq[2], 5, undefined, app_key); + // fourth object of the queue should be about event sent + cy.check_event(JSON.parse(rq[3].events)[0], eventObj, undefined, false); + // fifth object of the queue should be about session extension, also input the expected duration + cy.check_session(rq[4], 1, undefined, app_key); + }); + }); +}); +describe("Browser session tests, auto, no cookie", () => { + it("Single bounce test with auto sessions and no cookies", () => { + cy.visit("./cypress/fixtures/session_test_auto.html") + .wait(waitTime); + cy.contains("Event").click().wait(300); + cy.visit("./cypress/fixtures/base.html"); + cy.fetch_local_request_queue(app_key).then((rq) => { + cy.log(rq); + // 3 session and 1 orientation 1 event + expect(rq.length).to.equal(5); + // first object of the queue should be about begin session, second is orientation + cy.check_session(rq[0], undefined, undefined, app_key); + // third object of the queue should be about session extension, also input the expected duration + cy.check_session(rq[2], 5, undefined, app_key); + // fourth object of the queue should be about event sent + cy.check_event(JSON.parse(rq[3].events)[0], eventObj, undefined, false); + // fifth object of the queue should be about session extension, also input the expected duration + cy.check_session(rq[4], 1, true, app_key); + }); + }); +}); +describe("Browser session tests, manual 1, no cookie", () => { + it("Single bounce test with manual sessions with no cookies", () => { + cy.visit("./cypress/fixtures/session_test_manual_1.html"); + cy.contains("Start").click(); + cy.wait(waitTime); + cy.contains("Event").click(); + cy.wait(300); + cy.contains("End").click(); + cy.wait(300); + cy.visit("./cypress/fixtures/base.html"); + cy.fetch_local_request_queue(app_key).then((rq) => { + cy.log(rq); + // 3 session and 1 orientation 1 event + expect(rq.length).to.equal(5); + // first object of the queue should be about begin session, second is orientation + cy.check_session(rq[0], undefined, undefined, app_key); + // third object of the queue should be about session extension, also input the expected duration + cy.check_session(rq[2], 5, undefined, app_key); + // fourth object of the queue should be about event sent + cy.check_event(JSON.parse(rq[3].events)[0], eventObj, undefined, false); + // fifth object of the queue should be about session extension, also input the expected duration + cy.check_session(rq[4], 1, true, app_key); + }); + }); +}); +describe("Browser session tests, manual 2, no cookie", () => { + it("Single bounce test with manual sessions 2 and no cookies", () => { + cy.visit("./cypress/fixtures/session_test_manual_2.html").wait(waitTime); + cy.contains("Event").click().wait(500); + cy.visit("./cypress/fixtures/base.html"); + cy.fetch_local_request_queue(app_key).then((rq) => { + cy.log(rq); + // 3 session and 1 orientation 1 event + expect(rq.length).to.equal(5); + // first object of the queue should be about begin session, second is orientation + cy.check_session(rq[0], undefined, undefined, app_key); + // third object of the queue should be about session extension, also input the expected duration + cy.check_session(rq[2], 5, undefined, app_key); + // fourth object of the queue should be about event sent + cy.check_event(JSON.parse(rq[3].events)[0], eventObj, undefined, false); + // fifth object of the queue should be about session extension, also input the expected duration + cy.check_session(rq[4], 1, true, app_key); + }); + }); +}); +describe("Check request related functions", () => { + it("Check if prepareRequest forms a proper request object", () => { + hp.haltAndClearStorage(() => { + // initialize countly + initMain(); + let reqObject = {}; + Countly._internals.prepareRequest(reqObject); + cy.check_commons(reqObject); + cy.check_request_commons(reqObject); + }); + }); + it("Check if prepareRequest forms a proper request object from a bad one ", () => { + hp.haltAndClearStorage(() => { + // initialize countly + initMain(); + let reqObject = { app_key: null, device_id: null }; + Countly._internals.prepareRequest(reqObject); + cy.check_commons(reqObject); + cy.check_request_commons(reqObject); + }); + }); + it("Check if prepareRequest forms a proper request object and not erase an extra value ", () => { + hp.haltAndClearStorage(() => { + // initialize countly + initMain(); + let reqObject = { extraKey: "value" }; + Countly._internals.prepareRequest(reqObject); + expect(reqObject.extraKey).to.equal("value"); + cy.check_commons(reqObject); + cy.check_request_commons(reqObject); + }); + }); +}); \ No newline at end of file diff --git a/cypress/e2e/storage.cy.js b/cypress/e2e/storage.cy.js new file mode 100644 index 0000000..96ebe57 --- /dev/null +++ b/cypress/e2e/storage.cy.js @@ -0,0 +1,216 @@ +/* eslint-disable require-jsdoc */ +var Countly = require("../../Countly.js"); +var hp = require("../support/helper"); + +function initMain(val) { + Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + test_mode_eq: true, + test_mode: true, + debug: true, + storage: val + }); +} + +const valueToStore = "value"; +const key = "key"; +const testArray = ["default", "cookie", "none", "localstorage", "randomValue"]; + +for (let i = 0; i < 5; i++) { + const flag = testArray[i]; + const isCookie = flag === "cookie"; + const isLocal = flag === "localstorage"; + const isNone = flag === "none"; + + describe("Storage tests, storage: " + flag, () => { + // for everything at default + describe("basic setting", () => { + it("Checks if setValueInStorage function stores a value correctly", () => { + hp.haltAndClearStorage(() => { + initMain(flag); + Countly._internals.setValueInStorage(key, valueToStore); + cy.getLocalStorage(`${hp.appKey}/${key}`).then((value) => { + if (isCookie) { + expect(value).to.equal(null); + expect(document.cookie).to.include(`${hp.appKey}/${key}=${valueToStore}`); + } + else if (isNone) { + expect(value).to.equal(null); + expect(document.cookie).to.equal("__cypress.initial=true"); // since cypress 13.6 + } + else { + expect(value).to.equal(valueToStore); + } + }); + }); + }); + it("Checks if getValueFromStorage function gets a value correctly", () => { + hp.haltAndClearStorage(() => { + initMain(flag); + if (isNone) { + expect(document.cookie).to.equal("__cypress.initial=true"); // since cypress 13.6 + } + Countly._internals.setValueInStorage(key, valueToStore); + expect(isNone ? undefined : valueToStore).to.equal(Countly._internals.getValueFromStorage(key)); + }); + }); + it("Checks if getValueFromStorage function can not get a value if it does not exist", () => { + hp.haltAndClearStorage(() => { + initMain(flag); + expect(isNone ? undefined : null).to.equal(Countly._internals.getValueFromStorage(key)); + }); + }); + it("Checks if removeValueFromStorage function removes a value correctly", () => { + hp.haltAndClearStorage(() => { + initMain(flag); + if (isNone) { + expect(document.cookie).to.equal("__cypress.initial=true"); // since cypress 13.6 + } + Countly._internals.setValueInStorage(key, valueToStore); + expect(isNone ? undefined : valueToStore).to.equal(Countly._internals.getValueFromStorage(key)); + Countly._internals.removeValueFromStorage(key); + expect(isNone ? undefined : null).to.equal(Countly._internals.getValueFromStorage(key)); + }); + }); + }); + + // check basic functionality for cookies. No rawKey or useLocalstorage. + describe("uselocalstorage: false ", () => { + it("Checks if setValueInStorage function stores a cookie correctly", () => { + hp.haltAndClearStorage(() => { + initMain(flag); + Countly._internals.setValueInStorage(key, valueToStore, false); + cy.getLocalStorage(`${hp.appKey}/${key}`).then((value) => { + expect(value).to.equal(null); + }); + if (isNone || isLocal) { + expect(document.cookie).to.equal("__cypress.initial=true"); // since cypress 13.6 + } + else { + expect(document.cookie).to.include(`${hp.appKey}/${key}=${valueToStore}`); + } + }); + }); + it("Checks if getValueFromStorage function gets a value correctly", () => { + hp.haltAndClearStorage(() => { + initMain(flag); + Countly._internals.setValueInStorage(key, valueToStore, false); + if (isCookie) { + expect(isNone || isLocal ? undefined : null).to.equal(Countly._internals.getValueFromStorage(key, !!isCookie)); + } + expect(isNone || isLocal ? undefined : valueToStore).to.equal(Countly._internals.getValueFromStorage(key, false)); + }); + }); + it("Checks if getValueFromStorage function can not get a value if it does not exist", () => { + hp.haltAndClearStorage(() => { + initMain(flag); + Countly._internals.setValueInStorage(key, valueToStore); + expect(isNone || isLocal ? undefined : null).to.equal(Countly._internals.getValueFromStorage(key, !!isCookie)); + }); + }); + it("Checks if removeValueFromStorage function removes a value correctly", () => { + hp.haltAndClearStorage(() => { + initMain(flag); + Countly._internals.setValueInStorage(key, valueToStore, false); + expect(isNone || isLocal ? undefined : valueToStore).to.equal(Countly._internals.getValueFromStorage(key, false)); + Countly._internals.removeValueFromStorage(key, false); + expect(isNone || isLocal ? undefined : null).to.equal(Countly._internals.getValueFromStorage(key, false)); + }); + }); + }); + + // check for local storage functionality with rawKey but no cookies. + describe("useRawKey: true", () => { + it("Checks if setValueInStorage function stores a value correctly", () => { + hp.haltAndClearStorage(() => { + initMain(flag); + Countly._internals.setValueInStorage(key, valueToStore, undefined, true); + cy.getLocalStorage(`${key}`).then((value) => { + if (isCookie) { + expect(value).to.equal(null); + expect(document.cookie).to.contain(`${key}=${valueToStore}`); + } + else if (isNone) { + expect(value).to.equal(null); + } + else { + expect(value).to.equal(valueToStore); + } + }); + cy.getLocalStorage(`${hp.appKey}/${key}`).then((value) => { + expect(value).to.equal(null); + }); + }); + }); + it("Checks if getValueFromStorage function gets a value correctly", () => { + hp.haltAndClearStorage(() => { + initMain(flag); + Countly._internals.setValueInStorage(key, valueToStore, undefined, true); + expect(isNone ? undefined : valueToStore).to.equal(Countly._internals.getValueFromStorage(key, undefined, true)); + }); + }); + it("Checks if getValueFromStorage function can not get a value if it does not exist", () => { + hp.haltAndClearStorage(() => { + initMain(flag); + expect(isNone ? undefined : null).to.equal(Countly._internals.getValueFromStorage(key, undefined, true)); + }); + }); + it("Checks if removeValueFromStorage function removes a value correctly", () => { + hp.haltAndClearStorage(() => { + initMain(flag); + Countly._internals.setValueInStorage(key, valueToStore, undefined, true); + expect(isNone ? undefined : valueToStore).to.equal(Countly._internals.getValueFromStorage(key, undefined, true)); + Countly._internals.removeValueFromStorage(key, undefined, true); + expect(isNone ? undefined : null).to.equal(Countly._internals.getValueFromStorage(key, undefined, true)); + }); + }); + }); + + // check for cookies functionality with rawKey but no uselocalstorage. + describe("uselocalstorage: false, useRawKey: true", () => { + it("Checks if setValueInStorage function stores a value correctly", () => { + hp.haltAndClearStorage(() => { + initMain(flag); + Countly._internals.setValueInStorage(key, valueToStore, false, true); + cy.getLocalStorage(`${key}`).then((value) => { + expect(value).to.equal(null); + }); + cy.getLocalStorage(`${hp.appKey}/${key}`).then((value) => { + expect(value).to.equal(null); + }); + if (isNone || isLocal) { + expect(document.cookie).to.include(""); + } + else { + expect(document.cookie).to.include(`${key}=${valueToStore}`); + } + }); + }); + it("Checks if getValueFromStorage function gets a value correctly", () => { + hp.haltAndClearStorage(() => { + initMain(flag); + Countly._internals.setValueInStorage(key, valueToStore, false, true); + expect(isNone || isLocal ? undefined : valueToStore).to.equal(Countly._internals.getValueFromStorage(key, false, true)); + expect(isNone || isLocal ? undefined : null).to.equal(Countly._internals.getValueFromStorage(key, false)); + }); + }); + it("Checks if getValueFromStorage function can not get a value if it does not exist", () => { + hp.haltAndClearStorage(() => { + initMain(flag); + expect(isNone || isLocal ? undefined : null).to.equal(Countly._internals.getValueFromStorage(key, false, true)); + }); + }); + it("Checks if removeValueFromStorage function removes a value correctly", () => { + hp.haltAndClearStorage(() => { + initMain(flag); + Countly._internals.setValueInStorage(key, valueToStore, false, true); + expect(isNone || isLocal ? undefined : valueToStore).to.equal(Countly._internals.getValueFromStorage(key, false, true)); + Countly._internals.removeValueFromStorage(key, false, true); + expect(isNone || isLocal ? undefined : null).to.equal(Countly._internals.getValueFromStorage(key, false, true)); + }); + }); + }); + }); + document.cookie = ""; // clear cookies +} \ No newline at end of file diff --git a/cypress/e2e/storage_change.cy.js b/cypress/e2e/storage_change.cy.js new file mode 100644 index 0000000..4a9a0f5 --- /dev/null +++ b/cypress/e2e/storage_change.cy.js @@ -0,0 +1,129 @@ +/* eslint-disable require-jsdoc */ +var Countly = require("../../Countly.js"); +var hp = require("../support/helper"); +import { triggerStorageChange } from "../support/integration_helper"; + +function initMain(val) { + Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + device_id: 0, // provide number + test_mode_eq: true, + test_mode: true, + debug: true, + storage: val + }); +} + +const userDetailObj = hp.userDetailObj; + +describe("Multi tab storage change test", () => { + // onStorageChange is a ram only operation + it("Check device ID changes at a different tab are reflected correctly", () => { + hp.haltAndClearStorage(() => { + initMain("localstorage"); + + // numerical device ID provided at init is converted to string + var id = Countly.get_device_id(); + expect(id).to.equal("0"); + + // Check short string id is set correctly + triggerStorageChange("YOUR_APP_KEY/cly_id", "123"); + id = Countly.get_device_id(); + expect(id).to.equal("123"); + + // Check empty string id is set correctly + triggerStorageChange("YOUR_APP_KEY/cly_id", ""); + id = Countly.get_device_id(); + expect(id).to.equal(""); + + // Check null id is set correctly + triggerStorageChange("YOUR_APP_KEY/cly_id", null); + id = Countly.get_device_id(); + expect(id).to.equal(null); + + // Check long string numerical id is set correctly + triggerStorageChange("YOUR_APP_KEY/cly_id", "12345678901234567890123456789012345678901234567890123456789012345678901234567890"); + id = Countly.get_device_id(); + expect(id).to.equal("12345678901234567890123456789012345678901234567890123456789012345678901234567890"); + Countly.user_details(userDetailObj); + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + cy.check_user_details(rq[0], userDetailObj); + expect(rq[0].device_id).to.equal("12345678901234567890123456789012345678901234567890123456789012345678901234567890"); + }); + + // Check numerical conversion to string (positive) + triggerStorageChange("YOUR_APP_KEY/cly_id", 123); + id = Countly.get_device_id(); + expect(id).to.equal("123"); + + // Check numerical conversion to string (negative) + triggerStorageChange("YOUR_APP_KEY/cly_id", -123); + id = Countly.get_device_id(); + expect(id).to.equal("-123"); + + // Check numerical conversion to string (0) + triggerStorageChange("YOUR_APP_KEY/cly_id", 0); + id = Countly.get_device_id(); + expect(id).to.equal("0"); + + // Check numerical conversion to string (0) + triggerStorageChange("YOUR_APP_KEY/cly_id", "{}"); + id = Countly.get_device_id(); + expect(id).to.equal("{}"); + }); + }); + it("Check in multi instance scenarions device ID assigned correctly", () => { + hp.haltAndClearStorage(() => { + let firstIns = Countly.init({ + app_key: "YOUR_APP_KEY1", + url: "https://your.domain.count.ly", + device_id: 0, + test_mode_eq: true, + test_mode: true, + debug: true, + storage: "localstorage" + }); + let secondIns = Countly.init({ + app_key: "YOUR_APP_KEY2", + url: "https://your.domain.count.ly", + device_id: -1, + test_mode_eq: true, + test_mode: true, + debug: false, + storage: "localstorage" + }); + + // numerical device ID provided at init is converted to string + var id1 = firstIns.get_device_id(); + var id2 = secondIns.get_device_id(); + expect(id1).to.equal("0"); + expect(id2).to.equal("-1"); + + // Check long string numerical id is set correctly + triggerStorageChange("YOUR_APP_KEY1/cly_id", "12345678901234567890123456789012345678901234567890123456789012345678901234567890"); + id1 = firstIns.get_device_id(); + id2 = secondIns.get_device_id(); + expect(id1).to.equal("12345678901234567890123456789012345678901234567890123456789012345678901234567890"); + expect(id2).to.equal("-1"); + + // Check ID in request queue is set correctly for the correct instance + firstIns.user_details(userDetailObj); + cy.fetch_local_request_queue("YOUR_APP_KEY1").then((rq) => { + expect(rq.length).to.equal(1); + cy.check_user_details(rq[0], userDetailObj, undefined, "YOUR_APP_KEY1"); + expect(rq[0].device_id).to.equal("12345678901234567890123456789012345678901234567890123456789012345678901234567890"); + }); + secondIns.user_details(userDetailObj); + cy.fetch_local_request_queue("YOUR_APP_KEY2").then((rq) => { + expect(rq.length).to.equal(1); + cy.check_user_details(rq[0], userDetailObj, undefined, "YOUR_APP_KEY2"); + expect(rq[0].device_id).to.equal("-1"); + }); + + firstIns = null; + secondIns = null; + }); + }); +}); diff --git a/cypress/e2e/user_agent.cy.js b/cypress/e2e/user_agent.cy.js new file mode 100644 index 0000000..244ac1a --- /dev/null +++ b/cypress/e2e/user_agent.cy.js @@ -0,0 +1,63 @@ +/* eslint-disable cypress/no-unnecessary-waiting */ +/* eslint-disable require-jsdoc */ +var Countly = require("../../Countly.js"); +var hp = require("../support/helper"); + +function initMain() { + Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + test_mode_eq: true + }); +} +// TODO: Make tests browser specific as all browsers does not support userAgentData yet. +// TODO: check if userAgentData is configurable in cypress config file (currently not) +describe("User Agent tests ", () => { + it("Check if the user agent set by the developer was recognized by the SDK", () => { + hp.haltAndClearStorage(() => { + cy.visit("./cypress/fixtures/user_agent.html"); + // we set an attribute in documentElement (html tag for html files) called data-countly-useragent at our SDK with the currentUserAgentString function value, check if it corresponds to user agent string + cy.get("html") + .invoke("attr", "data-countly-useragent") + // this value was set at the cypress.json file + .should("eq", "abcd"); + // in test html file we created a button and set its value to detect_device(), check if it returns the correct device type + cy.get("button") + .invoke("attr", "value") + // useragent had no info on device type so should return desktop by default + .should("eq", "desktop"); + // in test html file we created a button and set its name to is_bot(), check if it returns the correct value + cy.get("button") + .invoke("attr", "name") + // useragent has no info about search bots so returns false + .should("eq", "false"); + }); + }); + it("Check if currentUserAgentString works as intended", () => { + hp.haltAndClearStorage(() => { + initMain(); + // from the config file set ua value + expect(Countly._internals.currentUserAgentString()).to.equal("abcd"); + // we override the ua string + expect(Countly._internals.currentUserAgentString("123")).to.equal("123"); + }); + }); + it("Check if userAgentDeviceDetection works as intended", () => { + hp.haltAndClearStorage(() => { + initMain(); + // setting ua value to strings that can pass the regex test + expect(Countly._internals.userAgentDeviceDetection("123")).to.equal("desktop"); + expect(Countly._internals.userAgentDeviceDetection("mobile")).to.equal("phone"); + expect(Countly._internals.userAgentDeviceDetection("tablet")).to.equal("tablet"); + }); + }); + it("Check if userAgentSearchBotDetection works as intended", () => { + hp.haltAndClearStorage(() => { + initMain(); + // setting ua value to strings that can pass the regex test + expect(Countly._internals.userAgentSearchBotDetection("123")).to.equal(false); + expect(Countly._internals.userAgentSearchBotDetection("Googlebot")).to.equal(true); + expect(Countly._internals.userAgentSearchBotDetection("Google")).to.equal(false); + }); + }); +}); diff --git a/cypress/e2e/user_details.cy.js b/cypress/e2e/user_details.cy.js new file mode 100644 index 0000000..a9ffc63 --- /dev/null +++ b/cypress/e2e/user_details.cy.js @@ -0,0 +1,503 @@ +/* eslint-disable cypress/no-unnecessary-waiting */ +/* eslint-disable require-jsdoc */ +var Countly = require("../../Countly.js"); +var hp = require("../support/helper"); + +function initMain() { + Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + test_mode_eq: true, + test_mode: true + }); +} + +const userDetailObj = hp.userDetailObj; + +// an event object to use +const eventObj = { + key: "in_app_purchase", + count: 3, + sum: 2.97, + dur: 300, + segmentation: { + app_version: "1.0", + country: "Tahiti" + } +}; + +describe("User details tests ", () => { + it("Checks if user detail recording works (normal flow)", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.user_details(userDetailObj); + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + cy.check_user_details(rq[0], userDetailObj); + }); + }); + }); + it("Checks if user detail recording works (events are flushed)", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.add_event(eventObj); + cy.fetch_local_event_queue().then((eq) => { // event should be in event queue + expect(eq.length).to.equal(1); + cy.check_event(eq[0], eventObj, undefined, false); + }); + cy.wait(300).then(() => { + Countly.user_details(userDetailObj); + cy.fetch_local_request_queue().then((rq) => { // events and user details must be here + expect(rq.length).to.equal(2); + cy.check_event(JSON.parse(rq[0].events)[0], eventObj, undefined, false); + cy.check_user_details(rq[1], userDetailObj); + }); + cy.fetch_local_event_queue().then((eq) => { // event queue should be empty + expect(eq.length).to.equal(0); + }); + }); + }); + }); + it("Checks if custom detail recording works (set)", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.set("key", "value"); + Countly.userData.save(); + hp.waitFunction(hp.getTimestampMs(), 300, 100, () => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + const custom = JSON.parse(rq[0].user_details).custom; + expect(custom.key).to.equal("value"); + expect(Object.keys(custom).length).to.equal(1); + }); + }); + }); + }); + it("Checks if custom detail recording works (set, array)", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.set("key", ["value"]); + Countly.userData.save(); + hp.waitFunction(hp.getTimestampMs(), 300, 100, () => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + const custom = JSON.parse(rq[0].user_details).custom; + expect(custom.key).to.eql(["value"]); // eql is deepequal + expect(Object.keys(custom).length).to.equal(1); + }); + }); + }); + }); + it("Checks if custom detail recording works (unset)", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.unset("key"); // unset works by sending an empty string with the key + Countly.userData.save(); + hp.waitFunction(hp.getTimestampMs(), 300, 100, () => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + const custom = JSON.parse(rq[0].user_details).custom; + expect(custom.key).to.equal(""); + expect(Object.keys(custom).length).to.equal(1); + }); + }); + }); + }); + it("Checks if custom detail recording works (set_once)", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.set_once("key", "value"); + Countly.userData.save(); + hp.waitFunction(hp.getTimestampMs(), 300, 100, () => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + const custom = JSON.parse(rq[0].user_details).custom; + expect(custom.key).to.eql({ $setOnce: "value" }); + expect(Object.keys(custom).length).to.equal(1); + }); + }); + }); + }); + it("Checks if custom detail recording works (increment)", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.increment("key"); + Countly.userData.save(); + hp.waitFunction(hp.getTimestampMs(), 300, 100, () => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + const custom = JSON.parse(rq[0].user_details).custom; + expect(custom.key).to.eql({ $inc: 1 }); + expect(Object.keys(custom).length).to.equal(1); + }); + }); + }); + }); + it("Checks if custom detail recording works (increment_by, + number )", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.increment_by("key", 10); + Countly.userData.save(); + hp.waitFunction(hp.getTimestampMs(), 300, 100, () => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + const custom = JSON.parse(rq[0].user_details).custom; + expect(custom.key).to.eql({ $inc: 10 }); + expect(Object.keys(custom).length).to.equal(1); + }); + }); + }); + }); + it("Checks if custom detail recording works (increment_by, - number )", () => { // TODO: Investigate + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.increment_by("key", -10); + Countly.userData.save(); + hp.waitFunction(hp.getTimestampMs(), 300, 100, () => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + const custom = JSON.parse(rq[0].user_details).custom; + expect(custom.key).to.eql({ $inc: -10 }); // eql is deepequal + expect(Object.keys(custom).length).to.equal(1); + }); + }); + }); + }); + it("Checks if custom detail recording works (increment_by, string)", () => { // TODO: Investigate + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.increment_by("key", "10"); + Countly.userData.save(); + hp.waitFunction(hp.getTimestampMs(), 300, 100, () => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + const custom = JSON.parse(rq[0].user_details).custom; + expect(custom.key).to.eql({ $inc: "10" }); + expect(Object.keys(custom).length).to.equal(1); + }); + }); + }); + }); + it("Checks if custom detail recording works (multiply, + number)", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.multiply("key", 10); + Countly.userData.save(); + hp.waitFunction(hp.getTimestampMs(), 300, 100, () => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + const custom = JSON.parse(rq[0].user_details).custom; + expect(custom.key).to.eql({ $mul: 10 }); + expect(Object.keys(custom).length).to.equal(1); + }); + }); + }); + }); + it("Checks if custom detail recording works (multiply, - number)", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.multiply("key", -10); + Countly.userData.save(); + hp.waitFunction(hp.getTimestampMs(), 300, 100, () => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + const custom = JSON.parse(rq[0].user_details).custom; + expect(custom.key).to.eql({ $mul: -10 }); + expect(Object.keys(custom).length).to.equal(1); + }); + }); + }); + }); + it("Checks if custom detail recording works (multiply, string)", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.multiply("key", "10"); + Countly.userData.save(); + hp.waitFunction(hp.getTimestampMs(), 300, 100, () => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + const custom = JSON.parse(rq[0].user_details).custom; + expect(custom.key).to.eql({ $mul: "10" }); + expect(Object.keys(custom).length).to.equal(1); + }); + }); + }); + }); + it("Checks if custom detail recording works (max, number)", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.max("key", 10); + Countly.userData.save(); + hp.waitFunction(hp.getTimestampMs(), 300, 100, () => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + const custom = JSON.parse(rq[0].user_details).custom; + expect(custom.key).to.eql({ $max: 10 }); + expect(Object.keys(custom).length).to.equal(1); + }); + }); + }); + }); + it("Checks if custom detail recording works (max, string)", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.max("key", "10"); + Countly.userData.save(); + hp.waitFunction(hp.getTimestampMs(), 300, 100, () => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + const custom = JSON.parse(rq[0].user_details).custom; + expect(custom.key).to.eql({ $max: "10" }); + expect(Object.keys(custom).length).to.equal(1); + }); + }); + }); + }); + it("Checks if custom detail recording works (min, number)", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.min("key", 10); + Countly.userData.save(); + hp.waitFunction(hp.getTimestampMs(), 300, 100, () => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + const custom = JSON.parse(rq[0].user_details).custom; + expect(custom.key).to.eql({ $min: 10 }); + expect(Object.keys(custom).length).to.equal(1); + }); + }); + }); + }); + it("Checks if custom detail recording works (min, string)", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.min("key", "10"); + Countly.userData.save(); + hp.waitFunction(hp.getTimestampMs(), 300, 100, () => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + const custom = JSON.parse(rq[0].user_details).custom; + expect(custom.key).to.eql({ $min: "10" }); + expect(Object.keys(custom).length).to.equal(1); + }); + }); + }); + }); + it("Checks if custom detail recording works (push, number)", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.push("key", 10); + Countly.userData.save(); + hp.waitFunction(hp.getTimestampMs(), 300, 100, () => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + const custom = JSON.parse(rq[0].user_details).custom; + expect(custom.key).to.eql({ $push: [10] }); + expect(Object.keys(custom).length).to.equal(1); + }); + }); + }); + }); + it("Checks if custom detail recording works (push, string)", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.push("key", "10"); + Countly.userData.save(); + hp.waitFunction(hp.getTimestampMs(), 300, 100, () => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + const custom = JSON.parse(rq[0].user_details).custom; + expect(custom.key).to.eql({ $push: ["10"] }); + expect(Object.keys(custom).length).to.equal(1); + }); + }); + }); + }); + it("Checks if custom detail recording works (push_unique, number)", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.push_unique("key", 10); + Countly.userData.save(); + hp.waitFunction(hp.getTimestampMs(), 300, 100, () => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + const custom = JSON.parse(rq[0].user_details).custom; + expect(custom.key).to.eql({ $addToSet: [10] }); + expect(Object.keys(custom).length).to.equal(1); + }); + }); + }); + }); + it("Checks if custom detail recording works (push_unique, string)", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.push_unique("key", "10"); + Countly.userData.save(); + hp.waitFunction(hp.getTimestampMs(), 300, 100, () => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + const custom = JSON.parse(rq[0].user_details).custom; + expect(custom.key).to.eql({ $addToSet: ["10"] }); + expect(Object.keys(custom).length).to.equal(1); + }); + }); + }); + }); + it("Checks if custom detail recording works (pull, number)", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.pull("key", 10); + Countly.userData.save(); + hp.waitFunction(hp.getTimestampMs(), 300, 100, () => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + const custom = JSON.parse(rq[0].user_details).custom; + expect(custom.key).to.eql({ $pull: [10] }); + expect(Object.keys(custom).length).to.equal(1); + }); + }); + }); + }); + it("Checks if custom detail recording works (pull, string)", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.pull("key", "10"); + Countly.userData.save(); + hp.waitFunction(hp.getTimestampMs(), 300, 100, () => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + const custom = JSON.parse(rq[0].user_details).custom; + expect(custom.key).to.eql({ $pull: ["10"] }); + expect(Object.keys(custom).length).to.equal(1); + }); + }); + }); + }); + it("Checks if all custom detail recording works together", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.set("key", "value"); + Countly.userData.unset("key2"); + Countly.userData.set_once("key3", 1); + Countly.userData.increment("key4"); + Countly.userData.increment_by("key5", 2); + Countly.userData.multiply("key6", 3); + Countly.userData.max("key7", 4); + Countly.userData.min("key8", 5); + Countly.userData.push("key9", 6); + Countly.userData.push_unique("key10", 7); + Countly.userData.pull("key11", 8); + Countly.userData.save(); + hp.waitFunction(hp.getTimestampMs(), 300, 100, () => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + const custom = JSON.parse(rq[0].user_details).custom; + expect(Object.keys(custom).length).to.equal(11); + expect(custom.key).to.equal("value"); + expect(custom.key2).to.equal(""); + expect(custom.key3).to.eql({ $setOnce: 1 }); + expect(custom.key4).to.eql({ $inc: 1 }); + expect(custom.key5).to.eql({ $inc: 2 }); + expect(custom.key6).to.eql({ $mul: 3 }); + expect(custom.key7).to.eql({ $max: 4 }); + expect(custom.key8).to.eql({ $min: 5 }); + expect(custom.key9).to.eql({ $push: [6] }); + expect(custom.key10).to.eql({ $addToSet: [7] }); + expect(custom.key11).to.eql({ $pull: [8] }); + }); + }); + }); + }); + it("Checks if all custom detail recording works together (early save)", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.set("key", "value"); + Countly.userData.unset("key2"); + Countly.userData.set_once("key3", 1); + Countly.userData.increment("key4"); + Countly.userData.save(); + Countly.userData.increment_by("key5", 2); + Countly.userData.multiply("key6", 3); + Countly.userData.max("key7", 4); + Countly.userData.min("key8", 5); + Countly.userData.push("key9", 6); + Countly.userData.push_unique("key10", 7); + Countly.userData.pull("key11", 8); + hp.waitFunction(hp.getTimestampMs(), 300, 100, () => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(1); + const custom = JSON.parse(rq[0].user_details).custom; + expect(Object.keys(custom).length).to.equal(4); + expect(custom.key).to.equal("value"); + expect(custom.key2).to.equal(""); + expect(custom.key3).to.eql({ $setOnce: 1 }); + expect(custom.key4).to.eql({ $inc: 1 }); + }); + }); + }); + }); + it("Checks if all custom detail recording wont works without save", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.userData.set("key", "value"); + Countly.userData.unset("key2"); + Countly.userData.set_once("key3", 1); + Countly.userData.increment("key4"); + Countly.userData.increment_by("key5", 2); + Countly.userData.multiply("key6", 3); + Countly.userData.max("key7", 4); + Countly.userData.min("key8", 5); + Countly.userData.push("key9", 6); + Countly.userData.push_unique("key10", 7); + Countly.userData.pull("key11", 8); + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(0); + }); + }); + }); + it("Checks all custom detail recording, event flush (with save)", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.add_event(eventObj); + cy.fetch_local_event_queue().then((eq) => { // event should be in event queue + expect(eq.length).to.equal(1); + cy.check_event(eq[0], eventObj, undefined, false); + }); + cy.wait(300).then(() => { + Countly.userData.set("key", "value"); + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(0); + }); + cy.fetch_local_event_queue().then((eq) => { // event should be in event queue + expect(eq.length).to.equal(1); + cy.check_event(eq[0], eventObj, undefined, false); + }); + }); + }); + }); + it("Checks all custom detail recording, event flush (without save)", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.add_event(eventObj); + cy.fetch_local_event_queue().then((eq) => { // event should be in event queue + expect(eq.length).to.equal(1); + cy.check_event(eq[0], eventObj, undefined, false); + }); + cy.wait(300).then(() => { + Countly.userData.set("key", "value"); + Countly.userData.save(); + + cy.fetch_local_request_queue().then((rq) => { + expect(rq.length).to.equal(2); + cy.check_event(JSON.parse(rq[0].events)[0], eventObj, undefined, false); + const custom = JSON.parse(rq[1].user_details).custom; + expect(Object.keys(custom).length).to.equal(1); + expect(custom.key).to.equal("value"); + }); + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(0); + }); + }); + }); + }); +}); diff --git a/cypress/e2e/utm.cy.js b/cypress/e2e/utm.cy.js new file mode 100644 index 0000000..57aa43c --- /dev/null +++ b/cypress/e2e/utm.cy.js @@ -0,0 +1,204 @@ +/* eslint-disable require-jsdoc */ +var Countly = require("../../Countly.js"); +var hp = require("../support/helper"); + +function initMulti(appKey, searchQuery, utmStuff) { + Countly.init({ + app_key: appKey, + url: "https://your.domain.count.ly", + test_mode: true, + test_mode_eq: true, + utm: utmStuff, + getSearchQuery: function() { + return searchQuery; + } + }); +} + +describe("UTM tests ", () => { + it("Checks if a single default utm tag works", () => { + hp.haltAndClearStorage(() => { + initMulti("YOUR_APP_KEY", "?utm_source=hehe", undefined); + cy.fetch_local_request_queue().then((rq) => { + cy.log(rq); + const custom = JSON.parse(rq[0].user_details).custom; + hp.validateDefaultUtmTags(custom, "hehe", "", "", "", ""); + }); + }); + }); + it("Checks if default utm tags works", () => { + hp.haltAndClearStorage(() => { + initMulti("YOUR_APP_KEY", "?utm_source=hehe&utm_medium=hehe1&utm_campaign=hehe2&utm_term=hehe3&utm_content=hehe4", undefined); + cy.fetch_local_request_queue().then((rq) => { + cy.log(rq); + const custom = JSON.parse(rq[0].user_details).custom; + hp.validateDefaultUtmTags(custom, "hehe", "hehe1", "hehe2", "hehe3", "hehe4"); + }); + }); + }); + it("Checks if a single custom utm tag works", () => { + hp.haltAndClearStorage(() => { + initMulti("YOUR_APP_KEY", "?utm_aa=hehe", { aa: true, bb: true }); + cy.fetch_local_request_queue().then((rq) => { + cy.log(rq); + const custom = JSON.parse(rq[0].user_details).custom; + hp.validateDefaultUtmTags(custom, undefined, undefined, undefined, undefined, undefined); + expect(custom.utm_aa).to.eq("hehe"); + expect(custom.utm_bb).to.eq(""); + }); + }); + }); + it("Checks if custom utm tags works", () => { + hp.haltAndClearStorage(() => { + initMulti("YOUR_APP_KEY", "?utm_aa=hehe&utm_bb=hoho", { aa: true, bb: true }); + cy.fetch_local_request_queue().then((rq) => { + cy.log(rq); + const custom = JSON.parse(rq[0].user_details).custom; + hp.validateDefaultUtmTags(custom, undefined, undefined, undefined, undefined, undefined); + expect(custom.utm_aa).to.eq("hehe"); + expect(custom.utm_bb).to.eq("hoho"); + }); + }); + }); + it("Checks if utm tag works in multi instancing", () => { + hp.haltAndClearStorage(() => { + // utm object provided with appropriate query + initMulti("Countly_2", "?utm_ss=hehe2", { ss: true }); + + // utm object provided with inappropriate query + initMulti("Countly_4", "?utm_source=hehe4", { ss: true }); + + // utm object not provided with default query + initMulti("Countly_3", "?utm_source=hehe3", undefined); + + // utm object not provided with inappropriate query + initMulti("Countly_5", "?utm_ss=hehe5", undefined); + + // default (original) init with no custom tags and default query + initMulti("YOUR_APP_KEY", "?utm_source=hehe", undefined); + + // check original + cy.fetch_local_request_queue().then((rq) => { + const custom = JSON.parse(rq[0].user_details).custom; + hp.validateDefaultUtmTags(custom, "hehe", "", "", "", ""); + }); + + // check if custom utm tags works + cy.fetch_local_request_queue("Countly_2").then((rq) => { + const custom = JSON.parse(rq[0].user_details).custom; + hp.validateDefaultUtmTags(custom, undefined, undefined, undefined, undefined, undefined); + expect(custom.utm_ss).to.eq("hehe2"); + }); + // check if default utm tags works + cy.fetch_local_request_queue("Countly_3").then((rq) => { + const custom = JSON.parse(rq[0].user_details).custom; + hp.validateDefaultUtmTags(custom, "hehe3", "", "", "", ""); + }); + // check if no utm tag in request queue if the query is wrong + cy.fetch_local_request_queue("Countly_4").then((rq) => { + expect(rq.length).to.eq(0); + }); + // check if no utm tag in request queue if the query is wrong + cy.fetch_local_request_queue("Countly_5").then((rq) => { + expect(rq.length).to.eq(0); + }); + }); + }); + it("Checks if multi instancing works plus", () => { + hp.haltAndClearStorage(() => { + // default (original) init with no custom tags and short default query for multi instance base + initMulti("YOUR_APP_KEY", "?utm_source=hehe", undefined); + + // utm object not provided with full + weird query + initMulti("Countly_multi_1", "?utm_source=hehe&utm_medium=hehe1&utm_campaign=hehe2&utm_term=hehe3&utm_content=hehe4&fdsjhflkjhsdlkfjhsdlkjfhksdjhfkj+dsf;jsdlkjflk+=skdjflksjd=fksdfl;sd=sdkfmk&&&", undefined); + + // utm object given that includes 2 default 1 custom, full plus custom query + gabledeboop + initMulti("Countly_multi_2", "?utm_source=hehe&utm_medium=hehe1&utm_campaign=hehe2&utm_term=hehe3&utm_content=hehe4&utm_sthelse=hehe5&fdsjhflkjhsdlkfjhsdlkjfhksdjhfkj+dsf;jsdlkjflk+=skdjflksjd=fksdfl;sd=sdkfmk&&&", { source: true, term: true, sthelse: true }); + + // empty init, garbage query + 1 default + initMulti("Countly_multi_3", "?dasdashdjkhaslkjdhsakj=dasmndlask=asdkljska&&utm_source=hehe", undefined); + + // full default utm obj + custom 1, full query + 1 + initMulti("Countly_multi_4", "?utm_source=hehe&utm_medium=hehe1&utm_campaign=hehe2&utm_term=hehe3&utm_content=hehe4&utm_next=hehe5", { source: true, medium: true, campaign: true, term: true, content: true, next: true }); + + // full default utm obj + custom 1, no query + initMulti("Countly_multi_5", "", { source: true, medium: true, campaign: true, term: true, content: true, next: true }); + + // check original + cy.fetch_local_request_queue().then((rq) => { + const custom = JSON.parse(rq[0].user_details).custom; + hp.validateDefaultUtmTags(custom, "hehe", "", "", "", ""); + }); + + // check if custom utm tags works for multi 1 + cy.fetch_local_request_queue("Countly_multi_1").then((rq) => { + const custom = JSON.parse(rq[0].user_details).custom; + hp.validateDefaultUtmTags(custom, "hehe", "hehe1", "hehe2", "hehe3", "hehe4"); + }); + + // check if custom utm tags works for multi 2 + cy.fetch_local_request_queue("Countly_multi_2").then((rq) => { + const custom = JSON.parse(rq[0].user_details).custom; + hp.validateDefaultUtmTags(custom, "hehe", undefined, undefined, "hehe3", undefined); + expect(custom.utm_sthelse).to.eq("hehe5"); + }); + + // check if custom utm tags works for multi 3 + cy.fetch_local_request_queue("Countly_multi_3").then((rq) => { + const custom = JSON.parse(rq[0].user_details).custom; + hp.validateDefaultUtmTags(custom, "hehe", "", "", "", ""); + }); + + // check if custom utm tags works for multi 4 + cy.fetch_local_request_queue("Countly_multi_4").then((rq) => { + const custom = JSON.parse(rq[0].user_details).custom; + hp.validateDefaultUtmTags(custom, "hehe", "hehe1", "hehe2", "hehe3", "hehe4"); + expect(custom.utm_next).to.eq("hehe5"); + }); + + // check if custom utm tags works for multi 5 + cy.fetch_local_request_queue("Countly_multi_5").then((rq) => { + expect(rq.length).to.eq(0); + }); + }); + }); + it("Checks if multi instancing works plus plus", () => { + hp.haltAndClearStorage(() => { + // default (original) init with no custom tags and short default query for multi instance base + initMulti("YOUR_APP_KEY", "?utm_source=hehe", undefined); + + // utm object empty, custom query + gabledeboop + initMulti("Countly_multi_next_1", "?utm_sourcer=hehe&utm_mediumr=hehe1&utm_campaignr=hehe2&utm_rterm=hehe3&utm_corntent=hehe4&fdsjhflkjhsdlkfjhsdlkjfhksdjhfkj+dsf;jsdlkjflk+=skdjflksjd=fksdfl;sd=sdkfmk&&&", undefined); + + // utm object default, custom query + gabledeboop + initMulti("Countly_multi_next_2", "?utm_sourcer=hehe&utm_mediumr=hehe1&utm_campaignr=hehe2&utm_rterm=hehe3&utm_corntent=hehe4&fdsjhflkjhsdlkfjhsdlkjfhksdjhfkj+dsf;jsdlkjflk+=skdjflksjd=fksdfl;sd=sdkfmk&&&", { source: true, medium: true, campaign: true, term: true, content: true }); + + // custom utm object, custom query + gabledeboop + initMulti("Countly_multi_next_3", "?utm_sauce=hehe&utm_pan=hehe2&dasdashdjkhaslkjdhsakj=dasmndlask=asdkljska&&utm_source=hehe", { sauce: true, pan: true }); + + // check original + cy.fetch_local_request_queue().then((rq) => { + const custom = JSON.parse(rq[0].user_details).custom; + hp.validateDefaultUtmTags(custom, "hehe", "", "", "", ""); + }); + + // check if custom utm tags works for multi 1 + cy.fetch_local_request_queue("Countly_multi_next_1").then((rq) => { + expect(rq.length).to.eq(0); + }); + + // check if custom utm tags works for multi 2 + cy.fetch_local_request_queue("Countly_multi_next_2").then((rq) => { + expect(rq.length).to.eq(0); + }); + + // check if custom utm tags works for multi 3 + cy.fetch_local_request_queue("Countly_multi_next_3").then((rq) => { + const custom = JSON.parse(rq[0].user_details).custom; + hp.validateDefaultUtmTags(custom, undefined, undefined, undefined, undefined, undefined); + expect(custom.utm_sauce).to.eq("hehe"); + expect(custom.utm_pan).to.eq("hehe2"); + }); + }); + }); +}); diff --git a/cypress/e2e/view_utm_referrer.cy.js b/cypress/e2e/view_utm_referrer.cy.js new file mode 100644 index 0000000..cbfe86b --- /dev/null +++ b/cypress/e2e/view_utm_referrer.cy.js @@ -0,0 +1,295 @@ +/* eslint-disable require-jsdoc */ +var Countly = require("../../Countly.js"); +var hp = require("../support/helper"); + +function init(appKey, searchQuery, utmStuff) { + Countly.init({ + app_key: appKey, + url: "https://your.domain.count.ly", + test_mode: true, + test_mode_eq: true, + utm: utmStuff, // utm object provided in init + getSearchQuery: function() { // override default search query + return searchQuery; + } + }); +} + +var pageNameOne = "test view page name1"; +var pageNameTwo = "test view page name2"; + +describe("View with utm and referrer tests ", () => { + // we check with no utm object if a default utm tag is recorded in the view event if it is in the query + it("Checks if a single default utm tag is at view segmentation", () => { + hp.haltAndClearStorage(() => { + init("YOUR_APP_KEY", "?utm_source=hehe", undefined); + Countly.track_view(pageNameOne); // first view + // View event should have the utm tag + cy.fetch_local_event_queue().then((eq) => { + cy.check_view_event(eq[0], pageNameOne, undefined, false); + hp.validateDefaultUtmTags(eq[0].segmentation, "hehe", undefined, undefined, undefined, undefined); + expect(eq[0].segmentation.referrer).to.eq(undefined); + }); + // adding utm creates a user_details request + cy.fetch_local_request_queue().then((rq) => { + cy.log(rq); + const custom = JSON.parse(rq[0].user_details).custom; + hp.validateDefaultUtmTags(custom, "hehe", "", "", "", ""); + }); + }); + }); + + // we record 2 views and check if both have the same utm tag + it("Checks if a single default utm tag is at view segmentation of both views", () => { + hp.haltAndClearStorage(() => { + init("YOUR_APP_KEY", "?utm_source=hehe", undefined); + Countly.track_view(pageNameOne); // first view + Countly.track_view(pageNameTwo); // second view + // View event should have the utm tag + cy.fetch_local_event_queue().then((eq) => { + cy.log(eq); + cy.check_view_event(eq[0], pageNameOne, undefined, false); + hp.validateDefaultUtmTags(eq[0].segmentation, "hehe", undefined, undefined, undefined, undefined); + cy.check_view_event(eq[1], pageNameOne, 0, false); // end of view 1 + hp.validateDefaultUtmTags(eq[1].segmentation, undefined, undefined, undefined, undefined, undefined); + cy.check_view_event(eq[2], pageNameTwo, undefined, true); // second view + hp.validateDefaultUtmTags(eq[2].segmentation, "hehe", undefined, undefined, undefined, undefined); + expect(eq[0].segmentation.referrer).to.eq(undefined); + expect(eq[1].segmentation.referrer).to.eq(undefined); + expect(eq[2].segmentation.referrer).to.eq(undefined); + }); + // adding utm creates a user_details request + cy.fetch_local_request_queue().then((rq) => { + cy.log(rq); + const custom = JSON.parse(rq[0].user_details).custom; + hp.validateDefaultUtmTags(custom, "hehe", "", "", "", ""); + expect(rq.length).to.eq(1); + }); + }); + }); + + // we check if multiple default utm tags are recorded in the view event if they are in the query + // and no utm object is provided + it("Checks if default utm tags appear in view", () => { + hp.haltAndClearStorage(() => { + init("YOUR_APP_KEY", "?utm_source=hehe&utm_medium=hehe1&utm_campaign=hehe2&utm_term=hehe3&utm_content=hehe4", undefined); + Countly.track_view(pageNameOne); + cy.fetch_local_event_queue().then((eq) => { + cy.check_view_event(eq[0], pageNameOne, undefined, false); + hp.validateDefaultUtmTags(eq[0].segmentation, "hehe", "hehe1", "hehe2", "hehe3", "hehe4"); + expect(eq[0].segmentation.referrer).to.eq(undefined); + }); + cy.fetch_local_request_queue().then((rq) => { + cy.log(rq); + const custom = JSON.parse(rq[0].user_details).custom; + hp.validateDefaultUtmTags(custom, "hehe", "hehe1", "hehe2", "hehe3", "hehe4"); + }); + }); + }); + + // we check if a single custom utm tag is recorded in the view event if it is in the utm object + // and utm object includes more than one utm tags + it("Checks if a single custom utm tag appears in view", () => { + hp.haltAndClearStorage(() => { + init("YOUR_APP_KEY", "?utm_aa=hehe", { aa: true, bb: true }); + Countly.track_view(pageNameOne); + cy.fetch_local_event_queue().then((eq) => { + cy.check_view_event(eq[0], pageNameOne, undefined, false); + hp.validateDefaultUtmTags(eq[0].segmentation, undefined, undefined, undefined, undefined, undefined); + expect(eq[0].segmentation.utm_aa).to.eq("hehe"); + expect(eq[0].segmentation.utm_bb).to.eq(undefined); + expect(eq[0].segmentation.referrer).to.eq(undefined); + }); + cy.fetch_local_request_queue().then((rq) => { + cy.log(rq); + const custom = JSON.parse(rq[0].user_details).custom; + hp.validateDefaultUtmTags(custom, undefined, undefined, undefined, undefined, undefined); + expect(custom.utm_aa).to.eq("hehe"); + expect(custom.utm_bb).to.eq(""); + }); + }); + }); + + // we check if multiple custom utm tags are recorded in the view event if they are in the utm object + it("Checks if multiple custom utm tags appears in view", () => { + hp.haltAndClearStorage(() => { + init("YOUR_APP_KEY", "?utm_aa=hehe&utm_bb=hoho", { aa: true, bb: true }); + Countly.track_view(pageNameOne); + cy.fetch_local_event_queue().then((eq) => { + cy.check_view_event(eq[0], pageNameOne, undefined, false); + hp.validateDefaultUtmTags(eq[0].segmentation, undefined, undefined, undefined, undefined, undefined); + expect(eq[0].segmentation.utm_aa).to.eq("hehe"); + expect(eq[0].segmentation.utm_bb).to.eq("hoho"); + expect(eq[0].segmentation.referrer).to.eq(undefined); + }); + cy.fetch_local_request_queue().then((rq) => { + cy.log(rq); + const custom = JSON.parse(rq[0].user_details).custom; + hp.validateDefaultUtmTags(custom, undefined, undefined, undefined, undefined, undefined); + expect(custom.utm_aa).to.eq("hehe"); + expect(custom.utm_bb).to.eq("hoho"); + }); + }); + }); + + // we check if we add a custom utm tag that is not in the utm object + // it is not recorded in the view event + it("Checks if extra custom utm tags are ignored in view", () => { + hp.haltAndClearStorage(() => { + init("YOUR_APP_KEY", "?utm_aa=hehe&utm_bb=hoho&utm_cc=ignore", { aa: true, bb: true }); + Countly.track_view(pageNameOne); + cy.fetch_local_event_queue().then((eq) => { + cy.check_view_event(eq[0], pageNameOne, undefined, false); + hp.validateDefaultUtmTags(eq[0].segmentation, undefined, undefined, undefined, undefined, undefined); + expect(eq[0].segmentation.utm_aa).to.eq("hehe"); + expect(eq[0].segmentation.utm_bb).to.eq("hoho"); + expect(eq[0].segmentation.utm_cc).to.eq(undefined); + expect(eq[0].segmentation.referrer).to.eq(undefined); + }); + cy.fetch_local_request_queue().then((rq) => { + cy.log(rq); + const custom = JSON.parse(rq[0].user_details).custom; + hp.validateDefaultUtmTags(custom, undefined, undefined, undefined, undefined, undefined); + expect(custom.utm_aa).to.eq("hehe"); + expect(custom.utm_bb).to.eq("hoho"); + expect(custom.utm_cc).to.eq(undefined); + }); + }); + }); + + // we create 2 instances of countly with different configurations + // then we record the same view with both instances + // then we check if the utm tags are recorded correctly + // and no referrer is recorded (because localhost) + it("Checks if utm tag appears in segmentation in multi instancing", () => { + hp.haltAndClearStorage(() => { + // default (original) init with no custom tags and default query + var C1 = Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + test_mode: true, + test_mode_eq: true, + utm: undefined, // utm object provided in init + getSearchQuery: function() { // override default search query + return "?utm_source=hehe"; + } + }); + C1.track_view(pageNameOne); + + // utm object provided with appropriate query + var C2 = Countly.init({ + app_key: "Countly_2", + url: "https://your.domain.count.ly", + test_mode: true, + test_mode_eq: true, + utm: { ss: true }, // utm object provided in init + getSearchQuery: function() { // override default search query + return "?utm_ss=hehe2"; + } + }); + C2.track_view(pageNameOne); + + // check original + cy.fetch_local_event_queue().then((eq) => { + cy.log(eq); + cy.check_view_event(eq[0], pageNameOne, undefined, false); + hp.validateDefaultUtmTags(eq[0].segmentation, "hehe", undefined, undefined, undefined, undefined); + expect(eq[0].segmentation.referrer).to.eq(undefined); + }); + cy.fetch_local_request_queue().then((rq) => { + cy.log(rq); + const custom = JSON.parse(rq[0].user_details).custom; + hp.validateDefaultUtmTags(custom, "hehe", "", "", "", ""); + }); + + // second instance + cy.fetch_local_event_queue("Countly_2").then((eq) => { + cy.log(eq); + cy.check_view_event(eq[0], pageNameOne, undefined, false); + hp.validateDefaultUtmTags(eq[0].segmentation, undefined, undefined, undefined, undefined, undefined); + expect(eq[0].segmentation.utm_ss).to.eq("hehe2"); + expect(eq[0].segmentation.referrer).to.eq(undefined); + }); + }); + }); + + // we use a custom html at fixtures folder ('referrer.html') + // then we set the referrer to be 'http://www.baidu.com' here manually + // then we check if the referrer is recorded correctly + // also we verify utms are recorded properly too + it("Check if referrer is recorded correctly", () => { + cy.visit("./cypress/fixtures/referrer.html", { + onBeforeLoad(win) { + Object.defineProperty(win.document, "referrer", { + configurable: true, + get() { + return "http://www.baidu.com"; // set custom referrer + } + }); + } + }); + hp.waitFunction(hp.getTimestampMs(), 1000, 100, () => { + cy.fetch_local_event_queue().then((eq) => { + cy.log(eq); + cy.check_view_event(eq[0], "/cypress/fixtures/referrer.html", undefined, false); + hp.validateDefaultUtmTags(eq[0].segmentation, undefined, undefined, undefined, undefined, undefined); + expect(eq[0].segmentation.utm_aa).to.eq("hehe"); + expect(eq[0].segmentation.utm_bb).to.eq(undefined); + expect(eq[0].segmentation.referrer).to.eq("http://www.baidu.com"); + }); + cy.fetch_local_request_queue().then((rq) => { + cy.log(rq); + const custom = JSON.parse(rq[0].user_details).custom; + hp.validateDefaultUtmTags(custom, undefined, undefined, undefined, undefined, undefined); + expect(custom.utm_aa).to.eq("hehe"); + expect(custom.utm_bb).to.eq(undefined); + }); + }); + }); +}); + +describe("isReferrerUsable tests", () => { + it("should return false if document.referrer is undefined", () => { + const result = Countly._internals.isReferrerUsable(undefined); + expect(result).to.eq(false); + }); + + it("should return false if document.referrer is null", () => { + const result = Countly._internals.isReferrerUsable(null); + expect(result).to.eq(false); + }); + + it("should return false if document.referrer is an empty string", () => { + const result = Countly._internals.isReferrerUsable(""); + expect(result).to.eq(false); + }); + + it("should return false if the referrer is the same as the current hostname", () => { + const result = Countly._internals.isReferrerUsable("http://localhost:3000"); + expect(result).to.eq(false); + }); + + it("should return false if the referrer is not a valid URL", () => { + const result = Countly._internals.isReferrerUsable("invalid-url"); + expect(result).to.eq(false); + }); + + it("should return false if the referrer is in the ignore list", () => { + hp.haltAndClearStorage(() => { + Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + ignore_referrers: ["http://example.com"] + }); + const result = Countly._internals.isReferrerUsable("http://example.com/something"); + expect(result).to.eq(false); + }); + }); + + it("should return true if the referrer is valid and not in the ignore list", () => { + hp.haltAndClearStorage(() => { + const result = Countly._internals.isReferrerUsable("http://example.com/path"); + expect(result).to.eq(true); + }); + }); +}); \ No newline at end of file diff --git a/cypress/e2e/views.cy copy.js b/cypress/e2e/views.cy copy.js new file mode 100644 index 0000000..b5d8eba --- /dev/null +++ b/cypress/e2e/views.cy copy.js @@ -0,0 +1,305 @@ +/* eslint-disable cypress/no-unnecessary-waiting */ +/* eslint-disable require-jsdoc */ +var Countly = require("../../Countly.js"); +var hp = require("../support/helper"); + +function initMain() { + Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + test_mode_eq: true, + test_mode: true + }); +} + +/** + * Checks if the cvid is the same for all events in the queue but ids are different and pvid is undefined + * @param {string} expectedCvid - expected view id + * @param {Array} eventQ - events queue + * @param {number} startIndex - start index of the queue + * @param {number} endIndex - end index of the queue +*/ +function listIdChecker(expectedCvid, eventQ, startIndex, endIndex) { + if (!endIndex || !startIndex || endIndex < startIndex) { // prevent infinite loop + cy.log("Wrong index information"); + return; + } + var i = startIndex; + var lastIdList = []; // pool of ids + while (i < endIndex) { + expect(eventQ[i].cvid).to.equal(expectedCvid); + expect(eventQ[i].pvid).to.be.undefined; // there should not be pvid + if (lastIdList.length > 0) { + expect(lastIdList.indexOf(eventQ[i].id)).to.equal(-1); // we check this id against all ids in the list + } + lastIdList.push(eventQ[i].id); // we add this id to the list of ids + i++; + } +} + +var pageNameOne = "test view page name1"; +var pageNameTwo = "test view page name2"; + +describe("View ID tests ", () => { + it("Checks if UUID and secureRandom works as intended", () => { + hp.haltAndClearStorage(() => { + initMain(); + const uuid = Countly._internals.generateUUID(); + const id = Countly._internals.secureRandom(); + assert.equal(uuid.length, 36); + assert.equal(id.length, 21); + const uuid2 = Countly._internals.generateUUID(); + const id2 = Countly._internals.secureRandom(); + assert.equal(uuid2.length, 36); + assert.equal(id2.length, 21); + assert.notEqual(uuid, uuid2); + assert.notEqual(id, id2); + }); + }); + it("Checks if recording page view works", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.track_view(pageNameOne); + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(1); + cy.check_view_event(eq[0], pageNameOne, undefined, false); + }); + }); + }); + it("Checks if recording timed page views with same name works", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.track_view(pageNameOne); + cy.wait(3000).then(() => { + Countly.track_view(pageNameOne); + cy.fetch_local_event_queue().then((eq) => { + cy.log(eq); + expect(eq.length).to.equal(3); + cy.check_view_event(eq[0], pageNameOne, undefined, false); + const id1 = eq[0].id; + + cy.check_view_event(eq[1], pageNameOne, 3, false); + const id2 = eq[1].id; + assert.equal(id1, id2); + + cy.check_view_event(eq[2], pageNameOne, undefined, true); + const id3 = eq[2].id; + const pvid = eq[2].pvid; + assert.equal(id1, pvid); + assert.notEqual(id3, pvid); + }); + }); + }); + }); + it("Checks if recording timed page views with different name works", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.track_view(pageNameOne); + hp.waitFunction(hp.getTimestampMs(), 4000, 500, ()=>{ + Countly.track_view(pageNameTwo); + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(3); + cy.check_view_event(eq[0], pageNameOne, undefined, false); + const id1 = eq[0].id; + + // this test is flaky we are expecting 3 and +1 (4) to make test more reliable + cy.check_view_event(eq[1], pageNameOne, 4, false); + const id2 = eq[1].id; + assert.equal(id1, id2); + + cy.check_view_event(eq[2], pageNameTwo, undefined, true); + const id3 = eq[2].id; + const pvid = eq[2].pvid; + assert.equal(id1, pvid); + assert.notEqual(id3, pvid); + }); + }); + }); + }); + + // =========================== + // Confirms: + // view A's id and event A's cvid are same + // view B's id and event B's cvid are same. Also view B's pvid and view A's id are same + // view C's id and event C's cvid are same. Also view C's pvid and view B's id are same + // + // request order: view A start -> internal can custom events -> event A -> view A end -> view B start -> internal can custom events -> event B -> view B end -> view C start -> internal can custom events -> event C + // =========================== + it("Checks a sequence of events and page views", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.track_view("A"); + hp.events(["[CLY]_view"]); + Countly.add_event({ key: "A" }); + Countly.track_view("B"); + hp.events(["[CLY]_view"]); + + Countly.add_event({ key: "B" }); + Countly.track_view("C"); + hp.events(["[CLY]_view"]); + Countly.add_event({ key: "C" }); + + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(26); + cy.log(eq); + + // event A and view A + cy.check_view_event(eq[0], "A", undefined, false); // no pvid + const idA = eq[0].id; // idA + listIdChecker(idA, eq, 1, 7); // check all internal events in view A + cy.check_event(eq[7], { key: "A" }, undefined, idA); // cvid should be idA + cy.check_view_event(eq[8], "A", 0, false); // no pvid + + // event B and view B + cy.check_view_event(eq[9], "B", undefined, idA); // pvid is idA + const idB = eq[9].id; // idB + listIdChecker(idB, eq, 10, 16); // check all internal events in view B + cy.check_event(eq[16], { key: "B" }, undefined, idB); // cvid should be idB + cy.check_view_event(eq[17], "B", 0, idA); // pvid is idA + + // event C and view C + cy.check_view_event(eq[18], "C", undefined, idB); // pvid is idB + const idC = eq[18].id; // idC + listIdChecker(idC, eq, 19, 25); // check all internal events in view C + cy.check_event(eq[25], { key: "C" }, undefined, idC); // cvid should be idC + }); + }); + }); + + // =========================== + // Confirms: CVID | PVID | ID + // ++--------+-----------+-------++ + // record events before first view => "" undefined rnd + // record A view => undefined "" idA + // record events under view A => idA undefined rnd + // record A view (close) => undefined "" idA + // record B view => undefined idA idB + // record events under view B => idB undefined rnd + // record B view (close) => undefined idA idB + // record C view => undefined idB idC + // record events under view C => idC undefined rnd + // ++--------+-----------+-------++ + // request order: internal can custom events -> view A start -> event A -> view A end -> view B start -> internal can custom events -> event B -> view B end -> view C start -> internal can custom events -> event C + // =========================== + it("Checks a sequence of events and page views, with events before first view", () => { + hp.haltAndClearStorage(() => { + initMain(); + hp.events(["[CLY]_view"]); // first events + + Countly.track_view("A"); + Countly.add_event({ key: "A" }); + Countly.track_view("B"); + hp.events(["[CLY]_view"]); + + Countly.add_event({ key: "B" }); + Countly.track_view("C"); + hp.events(["[CLY]_view"]); + Countly.add_event({ key: "C" }); + + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(26); + cy.log(eq); + + listIdChecker("", eq, 0, 6); // check all internal events before view A + + // event A and view A + cy.check_view_event(eq[6], "A", undefined, false); // no pvid + const idA = eq[6].id; // idA + cy.check_event(eq[7], { key: "A" }, undefined, idA); // cvid should be idA + cy.check_view_event(eq[8], "A", 0, false); // no pvid + + // event B and view B + cy.check_view_event(eq[9], "B", undefined, idA); // pvid is idA + const idB = eq[9].id; // idB + listIdChecker(idB, eq, 10, 16); // check all internal events in view B + cy.check_event(eq[16], { key: "B" }, undefined, idB); // cvid should be idB + cy.check_view_event(eq[17], "B", 0, idA); // pvid is idA + + // event C and view C + cy.check_view_event(eq[18], "C", undefined, idB); // pvid is idB + const idC = eq[18].id; // idC + listIdChecker(idC, eq, 19, 25); // check all internal events in view C + cy.check_event(eq[25], { key: "C" }, undefined, idC); // cvid should be idC + }); + }); + }); + + // check end_session usage + it("Checks a sequence of events and page views, with end_session, no session started", () => { + hp.haltAndClearStorage(() => { + initMain(); + hp.events(["[CLY]_view"]); // first events + Countly.end_session(); // no session started must be ignored + Countly.track_view("A"); + Countly.add_event({ key: "A" }); + + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(8); + cy.log(eq); + + listIdChecker("", eq, 0, 6); // check all internal events before view A + + // event A and view A + cy.check_view_event(eq[6], "A", undefined, false); // no pvid + const idA = eq[6].id; // idA + cy.check_event(eq[7], { key: "A" }, undefined, idA); // cvid should be idA + }); + }); + }); + it("Checks a sequence of events and page views, with end_session, with session started", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.track_sessions(); + hp.events(["[CLY]_view"]); // first events + Countly.end_session(); // no view started so must be ignored + Countly.track_view("A"); + Countly.add_event({ key: "A" }); + + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(9); // orientation added + cy.log(eq); + + cy.check_event(eq[0], { key: "[CLY]_orientation" }, undefined, ""); // internal event + + listIdChecker("", eq, 1, 7); // check all internal events before view A + + // event A and view A + cy.check_view_event(eq[7], "A", undefined, false); // no pvid + const idA = eq[7].id; // idA + cy.check_event(eq[8], { key: "A" }, undefined, idA); // cvid should be idA + }); + }); + }); + it("Checks a sequence of events and page views, with end_session, with session started and called after view", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.track_sessions(); + hp.events(["[CLY]_view"]); // first events + Countly.track_view("A"); + Countly.end_session(); // no view started so must be ignored + Countly.add_event({ key: "A" }); + + Countly.track_view("B"); + hp.events(["[CLY]_view"]); + + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(17); // orientation added + cy.log(eq); + + cy.check_event(eq[0], { key: "[CLY]_orientation" }, undefined, ""); // internal event + + listIdChecker("", eq, 1, 7); // check all internal events before view A + + // event A and view A + cy.check_view_event(eq[7], "A", undefined, false); // no pvid + const idA = eq[7].id; // idA + cy.check_view_event(eq[8], "A", 0, false); // no pvid + cy.check_event(eq[9], { key: "A" }, undefined, idA); // cvid should be idA + + cy.check_view_event(eq[10], "B", undefined, idA); // pvid is idA + const idB = eq[10].id; // idB + listIdChecker(idB, eq, 11, 17); // check all internal events in view B + }); + }); + }); +}); \ No newline at end of file diff --git a/cypress/e2e/views.cy.js b/cypress/e2e/views.cy.js new file mode 100644 index 0000000..b5d8eba --- /dev/null +++ b/cypress/e2e/views.cy.js @@ -0,0 +1,305 @@ +/* eslint-disable cypress/no-unnecessary-waiting */ +/* eslint-disable require-jsdoc */ +var Countly = require("../../Countly.js"); +var hp = require("../support/helper"); + +function initMain() { + Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + test_mode_eq: true, + test_mode: true + }); +} + +/** + * Checks if the cvid is the same for all events in the queue but ids are different and pvid is undefined + * @param {string} expectedCvid - expected view id + * @param {Array} eventQ - events queue + * @param {number} startIndex - start index of the queue + * @param {number} endIndex - end index of the queue +*/ +function listIdChecker(expectedCvid, eventQ, startIndex, endIndex) { + if (!endIndex || !startIndex || endIndex < startIndex) { // prevent infinite loop + cy.log("Wrong index information"); + return; + } + var i = startIndex; + var lastIdList = []; // pool of ids + while (i < endIndex) { + expect(eventQ[i].cvid).to.equal(expectedCvid); + expect(eventQ[i].pvid).to.be.undefined; // there should not be pvid + if (lastIdList.length > 0) { + expect(lastIdList.indexOf(eventQ[i].id)).to.equal(-1); // we check this id against all ids in the list + } + lastIdList.push(eventQ[i].id); // we add this id to the list of ids + i++; + } +} + +var pageNameOne = "test view page name1"; +var pageNameTwo = "test view page name2"; + +describe("View ID tests ", () => { + it("Checks if UUID and secureRandom works as intended", () => { + hp.haltAndClearStorage(() => { + initMain(); + const uuid = Countly._internals.generateUUID(); + const id = Countly._internals.secureRandom(); + assert.equal(uuid.length, 36); + assert.equal(id.length, 21); + const uuid2 = Countly._internals.generateUUID(); + const id2 = Countly._internals.secureRandom(); + assert.equal(uuid2.length, 36); + assert.equal(id2.length, 21); + assert.notEqual(uuid, uuid2); + assert.notEqual(id, id2); + }); + }); + it("Checks if recording page view works", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.track_view(pageNameOne); + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(1); + cy.check_view_event(eq[0], pageNameOne, undefined, false); + }); + }); + }); + it("Checks if recording timed page views with same name works", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.track_view(pageNameOne); + cy.wait(3000).then(() => { + Countly.track_view(pageNameOne); + cy.fetch_local_event_queue().then((eq) => { + cy.log(eq); + expect(eq.length).to.equal(3); + cy.check_view_event(eq[0], pageNameOne, undefined, false); + const id1 = eq[0].id; + + cy.check_view_event(eq[1], pageNameOne, 3, false); + const id2 = eq[1].id; + assert.equal(id1, id2); + + cy.check_view_event(eq[2], pageNameOne, undefined, true); + const id3 = eq[2].id; + const pvid = eq[2].pvid; + assert.equal(id1, pvid); + assert.notEqual(id3, pvid); + }); + }); + }); + }); + it("Checks if recording timed page views with different name works", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.track_view(pageNameOne); + hp.waitFunction(hp.getTimestampMs(), 4000, 500, ()=>{ + Countly.track_view(pageNameTwo); + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(3); + cy.check_view_event(eq[0], pageNameOne, undefined, false); + const id1 = eq[0].id; + + // this test is flaky we are expecting 3 and +1 (4) to make test more reliable + cy.check_view_event(eq[1], pageNameOne, 4, false); + const id2 = eq[1].id; + assert.equal(id1, id2); + + cy.check_view_event(eq[2], pageNameTwo, undefined, true); + const id3 = eq[2].id; + const pvid = eq[2].pvid; + assert.equal(id1, pvid); + assert.notEqual(id3, pvid); + }); + }); + }); + }); + + // =========================== + // Confirms: + // view A's id and event A's cvid are same + // view B's id and event B's cvid are same. Also view B's pvid and view A's id are same + // view C's id and event C's cvid are same. Also view C's pvid and view B's id are same + // + // request order: view A start -> internal can custom events -> event A -> view A end -> view B start -> internal can custom events -> event B -> view B end -> view C start -> internal can custom events -> event C + // =========================== + it("Checks a sequence of events and page views", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.track_view("A"); + hp.events(["[CLY]_view"]); + Countly.add_event({ key: "A" }); + Countly.track_view("B"); + hp.events(["[CLY]_view"]); + + Countly.add_event({ key: "B" }); + Countly.track_view("C"); + hp.events(["[CLY]_view"]); + Countly.add_event({ key: "C" }); + + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(26); + cy.log(eq); + + // event A and view A + cy.check_view_event(eq[0], "A", undefined, false); // no pvid + const idA = eq[0].id; // idA + listIdChecker(idA, eq, 1, 7); // check all internal events in view A + cy.check_event(eq[7], { key: "A" }, undefined, idA); // cvid should be idA + cy.check_view_event(eq[8], "A", 0, false); // no pvid + + // event B and view B + cy.check_view_event(eq[9], "B", undefined, idA); // pvid is idA + const idB = eq[9].id; // idB + listIdChecker(idB, eq, 10, 16); // check all internal events in view B + cy.check_event(eq[16], { key: "B" }, undefined, idB); // cvid should be idB + cy.check_view_event(eq[17], "B", 0, idA); // pvid is idA + + // event C and view C + cy.check_view_event(eq[18], "C", undefined, idB); // pvid is idB + const idC = eq[18].id; // idC + listIdChecker(idC, eq, 19, 25); // check all internal events in view C + cy.check_event(eq[25], { key: "C" }, undefined, idC); // cvid should be idC + }); + }); + }); + + // =========================== + // Confirms: CVID | PVID | ID + // ++--------+-----------+-------++ + // record events before first view => "" undefined rnd + // record A view => undefined "" idA + // record events under view A => idA undefined rnd + // record A view (close) => undefined "" idA + // record B view => undefined idA idB + // record events under view B => idB undefined rnd + // record B view (close) => undefined idA idB + // record C view => undefined idB idC + // record events under view C => idC undefined rnd + // ++--------+-----------+-------++ + // request order: internal can custom events -> view A start -> event A -> view A end -> view B start -> internal can custom events -> event B -> view B end -> view C start -> internal can custom events -> event C + // =========================== + it("Checks a sequence of events and page views, with events before first view", () => { + hp.haltAndClearStorage(() => { + initMain(); + hp.events(["[CLY]_view"]); // first events + + Countly.track_view("A"); + Countly.add_event({ key: "A" }); + Countly.track_view("B"); + hp.events(["[CLY]_view"]); + + Countly.add_event({ key: "B" }); + Countly.track_view("C"); + hp.events(["[CLY]_view"]); + Countly.add_event({ key: "C" }); + + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(26); + cy.log(eq); + + listIdChecker("", eq, 0, 6); // check all internal events before view A + + // event A and view A + cy.check_view_event(eq[6], "A", undefined, false); // no pvid + const idA = eq[6].id; // idA + cy.check_event(eq[7], { key: "A" }, undefined, idA); // cvid should be idA + cy.check_view_event(eq[8], "A", 0, false); // no pvid + + // event B and view B + cy.check_view_event(eq[9], "B", undefined, idA); // pvid is idA + const idB = eq[9].id; // idB + listIdChecker(idB, eq, 10, 16); // check all internal events in view B + cy.check_event(eq[16], { key: "B" }, undefined, idB); // cvid should be idB + cy.check_view_event(eq[17], "B", 0, idA); // pvid is idA + + // event C and view C + cy.check_view_event(eq[18], "C", undefined, idB); // pvid is idB + const idC = eq[18].id; // idC + listIdChecker(idC, eq, 19, 25); // check all internal events in view C + cy.check_event(eq[25], { key: "C" }, undefined, idC); // cvid should be idC + }); + }); + }); + + // check end_session usage + it("Checks a sequence of events and page views, with end_session, no session started", () => { + hp.haltAndClearStorage(() => { + initMain(); + hp.events(["[CLY]_view"]); // first events + Countly.end_session(); // no session started must be ignored + Countly.track_view("A"); + Countly.add_event({ key: "A" }); + + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(8); + cy.log(eq); + + listIdChecker("", eq, 0, 6); // check all internal events before view A + + // event A and view A + cy.check_view_event(eq[6], "A", undefined, false); // no pvid + const idA = eq[6].id; // idA + cy.check_event(eq[7], { key: "A" }, undefined, idA); // cvid should be idA + }); + }); + }); + it("Checks a sequence of events and page views, with end_session, with session started", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.track_sessions(); + hp.events(["[CLY]_view"]); // first events + Countly.end_session(); // no view started so must be ignored + Countly.track_view("A"); + Countly.add_event({ key: "A" }); + + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(9); // orientation added + cy.log(eq); + + cy.check_event(eq[0], { key: "[CLY]_orientation" }, undefined, ""); // internal event + + listIdChecker("", eq, 1, 7); // check all internal events before view A + + // event A and view A + cy.check_view_event(eq[7], "A", undefined, false); // no pvid + const idA = eq[7].id; // idA + cy.check_event(eq[8], { key: "A" }, undefined, idA); // cvid should be idA + }); + }); + }); + it("Checks a sequence of events and page views, with end_session, with session started and called after view", () => { + hp.haltAndClearStorage(() => { + initMain(); + Countly.track_sessions(); + hp.events(["[CLY]_view"]); // first events + Countly.track_view("A"); + Countly.end_session(); // no view started so must be ignored + Countly.add_event({ key: "A" }); + + Countly.track_view("B"); + hp.events(["[CLY]_view"]); + + cy.fetch_local_event_queue().then((eq) => { + expect(eq.length).to.equal(17); // orientation added + cy.log(eq); + + cy.check_event(eq[0], { key: "[CLY]_orientation" }, undefined, ""); // internal event + + listIdChecker("", eq, 1, 7); // check all internal events before view A + + // event A and view A + cy.check_view_event(eq[7], "A", undefined, false); // no pvid + const idA = eq[7].id; // idA + cy.check_view_event(eq[8], "A", 0, false); // no pvid + cy.check_event(eq[9], { key: "A" }, undefined, idA); // cvid should be idA + + cy.check_view_event(eq[10], "B", undefined, idA); // pvid is idA + const idB = eq[10].id; // idB + listIdChecker(idB, eq, 11, 17); // check all internal events in view B + }); + }); + }); +}); \ No newline at end of file diff --git a/cypress/e2e/web_worker_queues.cy.js b/cypress/e2e/web_worker_queues.cy.js new file mode 100644 index 0000000..d612d86 --- /dev/null +++ b/cypress/e2e/web_worker_queues.cy.js @@ -0,0 +1,33 @@ +describe("Web Worker Local Queue Tests", () => { + it("Verify queues for all features", () => { + // create a worker + const myWorker = new Worker("../../test_workers/worker_for_test.js", { type: "module" }); + + // send an event to worker + myWorker.postMessage({ data: { key: "key" }, type: "event" }); + myWorker.postMessage({ data: "begin_session", type: "session" }); + myWorker.postMessage({ data: "end_session", type: "session" }); + myWorker.postMessage({ data: "home_page", type: "view" }); + + // ask for local queues + myWorker.postMessage({ data: "queues", type: "get" }); + + let requestQueue; + let eventQueue; + myWorker.onmessage = function(e) { + requestQueue = e.data.requestQ; // Array of requests + eventQueue = e.data.eventQ; // Array of events + myWorker.terminate(); // terminate worker + + // verify event queue + expect(eventQueue.length).to.equal(2); + cy.check_event(eventQueue[0], { key: "key" }, undefined, false); + cy.check_view_event(eventQueue[1], "home_page", undefined, false); + + // verify request queue + expect(requestQueue.length).to.equal(2); + cy.check_session(requestQueue[0], undefined, false, false, true); + cy.check_session(requestQueue[1], 0, false, false, false); + }; + }); +}); \ No newline at end of file diff --git a/cypress/e2e/web_worker_requests.cy.js b/cypress/e2e/web_worker_requests.cy.js new file mode 100644 index 0000000..ad166a9 --- /dev/null +++ b/cypress/e2e/web_worker_requests.cy.js @@ -0,0 +1,126 @@ +import { appKey } from "../support/helper"; + +const myEvent = { + key: "buttonClick", + segmentation: { + id: "id" + } +}; + +describe("Web Worker Request Intercepting Tests", () => { + it("SDK able to send requests for most basic calls", () => { + // create a worker + const myWorker = new Worker("../../test_workers/worker.js", { type: "module" }); + + // send an event to worker + myWorker.postMessage({ data: myEvent, type: "event" }); + myWorker.postMessage({ data: "begin_session", type: "session" }); + myWorker.postMessage({ data: "end_session", type: "session" }); + myWorker.postMessage({ data: "home_page", type: "view" }); + + // intercept requests + cy.intercept("GET", "**/i?**", (req) => { + const { url } = req; + + // check url starts with https://your.domain.count.ly/i? + assert.isTrue(url.startsWith("https://your.domain.count.ly/i?")); + + // turn query string into object + const paramsObject = turnSearchStringToObject(url.split("?")[1]); + + // check common params + check_commons(paramsObject); + + // we expect 4 requests: begin_session, end_session, healthcheck, event(event includes view and buttonClick) + let expectedRequests = 4; + if (paramsObject.hc) { + // check hc params types, values can change + assert.isTrue(typeof paramsObject.hc.el === "number"); + assert.isTrue(typeof paramsObject.hc.wl === "number"); + assert.isTrue(typeof paramsObject.hc.sc === "number"); + assert.isTrue(typeof paramsObject.hc.em === "string"); + expectedRequests--; + } + else if (paramsObject.events) { + // check event params with accordance to event sent (myEvent above) + for (const eventInRequest of paramsObject.events) { + if (eventInRequest.key === "[CLY]_view") { // view event + expect(eventInRequest.segmentation.name).to.equal("home_page"); + expect(eventInRequest.segmentation.visit).to.equal(1); + expect(eventInRequest.segmentation.start).to.equal(1); + expect(eventInRequest.segmentation.view).to.equal("web_worker"); + expect(eventInRequest.pvid).to.equal(""); + } + else { // buttonClick event + expect(eventInRequest.key).to.equal(myEvent.key); + expect(eventInRequest.segmentation).to.deep.equal(myEvent.segmentation); + assert.isTrue(eventInRequest.cvid === ""); + } + assert.isTrue(eventInRequest.count === 1); + expect(eventInRequest.id).to.be.ok; + expect(eventInRequest.id.toString().length).to.equal(21); + expect(eventInRequest.timestamp).to.be.ok; + expect(eventInRequest.timestamp.toString().length).to.equal(13); + expect(eventInRequest.hour).to.be.within(0, 23); + expect(eventInRequest.dow).to.be.within(0, 7); + } + expectedRequests--; + } + else if (paramsObject.begin_session === 1) { // check metrics + expect(paramsObject.metrics._app_version).to.equal("0.0"); + expect(paramsObject.metrics._ua).to.equal("abcd"); + assert.isTrue(typeof paramsObject.metrics._locale === "string"); + expectedRequests--; + } + else if (paramsObject.end_session === 1) { // check metrics and session_duration + expect(paramsObject.metrics._ua).to.equal("abcd"); + expect(paramsObject.session_duration).to.be.above(-1); + expectedRequests--; + } + if (expectedRequests === 0) { + myWorker.terminate(); // we checked everything, terminate worker + } + }); + }); +}); + +/** + * Check common params for all requests + * @param {Object} paramsObject - object from search string + */ +function check_commons(paramsObject) { + expect(paramsObject.timestamp).to.be.ok; + expect(paramsObject.timestamp.toString().length).to.equal(13); + expect(paramsObject.hour).to.be.within(0, 23); + expect(paramsObject.dow).to.be.within(0, 7); + expect(paramsObject.app_key).to.equal(appKey); + expect(paramsObject.device_id).to.be.ok; + expect(paramsObject.sdk_name).to.equal("javascript_native_web"); + expect(paramsObject.sdk_version).to.be.ok; + expect(paramsObject.t).to.be.within(0, 3); + expect(paramsObject.av).to.equal(0); // av is 0 as we parsed parsable things + if (!paramsObject.hc) { // hc is direct request + expect(paramsObject.rr).to.be.above(-1); + } + expect(paramsObject.metrics._ua).to.be.ok; +} + +/** + * Turn search string into object with values parsed + * @param {String} searchString - search string + * @returns {object} - object from search string + */ +function turnSearchStringToObject(searchString) { + const searchParams = new URLSearchParams(searchString); + const paramsObject = {}; + for (const [key, value] of searchParams.entries()) { + try { + paramsObject[key] = JSON.parse(value); // try to parse value + } + catch (e) { + paramsObject[key] = value; + } + } + return paramsObject; +} + diff --git a/cypress/fixtures/base.html b/cypress/fixtures/base.html new file mode 100644 index 0000000..2b33b76 --- /dev/null +++ b/cypress/fixtures/base.html @@ -0,0 +1,8 @@ + + + + + +

Base

+ + diff --git a/cypress/fixtures/click_test.html b/cypress/fixtures/click_test.html new file mode 100644 index 0000000..f2337d3 --- /dev/null +++ b/cypress/fixtures/click_test.html @@ -0,0 +1,40 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/cypress/fixtures/multi_instance.html b/cypress/fixtures/multi_instance.html new file mode 100644 index 0000000..35e30ab --- /dev/null +++ b/cypress/fixtures/multi_instance.html @@ -0,0 +1,163 @@ + + + + + \ No newline at end of file diff --git a/cypress/fixtures/referrer.html b/cypress/fixtures/referrer.html new file mode 100644 index 0000000..25f4a85 --- /dev/null +++ b/cypress/fixtures/referrer.html @@ -0,0 +1,20 @@ + + + + + + + diff --git a/cypress/fixtures/scroll_test.html b/cypress/fixtures/scroll_test.html new file mode 100644 index 0000000..8e1c8f7 --- /dev/null +++ b/cypress/fixtures/scroll_test.html @@ -0,0 +1,60 @@ + + + + + +

page1

+

text

+

text

+

text

+
text
+
text
+

text

+

text

+

text

+

text

+
text
+
text
+

text

+

text

+

text

+

text

+
text
+
text
+

text

+

text

+

text

+

text

+
text
+
text
+

text

+

text

+

text

+

text

+
text
+
text
+ + \ No newline at end of file diff --git a/cypress/fixtures/scroll_test_2.html b/cypress/fixtures/scroll_test_2.html new file mode 100644 index 0000000..643852c --- /dev/null +++ b/cypress/fixtures/scroll_test_2.html @@ -0,0 +1,48 @@ + + + + + +

page2

+

text

+

text

+

text

+
text
+
text
+

text

+

text

+

text

+

text

+
text
+
text
+

text

+

text

+

text

+

text

+
text
+
text
+

text

+

text

+

text

+

text

+
text
+
text
+

text

+

text

+

text

+

text

+
text
+
text
+ + \ No newline at end of file diff --git a/cypress/fixtures/scroll_test_3.html b/cypress/fixtures/scroll_test_3.html new file mode 100644 index 0000000..8fbcb0e --- /dev/null +++ b/cypress/fixtures/scroll_test_3.html @@ -0,0 +1,73 @@ + + + + + + + + +

page3

+

text

+

text

+

text

+
text
+
text
+

text

+

text

+

text

+

text

+
text
+
text
+

text

+

text

+

text

+

text

+
text
+
text
+

text

+

text

+

text

+

text

+
text
+
text
+

text

+

text

+

text

+

text

+
text
+
text
+ + \ No newline at end of file diff --git a/cypress/fixtures/session_test_auto.html b/cypress/fixtures/session_test_auto.html new file mode 100644 index 0000000..8fe3d7e --- /dev/null +++ b/cypress/fixtures/session_test_auto.html @@ -0,0 +1,52 @@ + + + + + + + + + + + diff --git a/cypress/fixtures/session_test_manual_1.html b/cypress/fixtures/session_test_manual_1.html new file mode 100644 index 0000000..e40bd86 --- /dev/null +++ b/cypress/fixtures/session_test_manual_1.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + diff --git a/cypress/fixtures/session_test_manual_2.html b/cypress/fixtures/session_test_manual_2.html new file mode 100644 index 0000000..63183f6 --- /dev/null +++ b/cypress/fixtures/session_test_manual_2.html @@ -0,0 +1,55 @@ + + + + + + + + + + + diff --git a/cypress/fixtures/user_agent.html b/cypress/fixtures/user_agent.html new file mode 100644 index 0000000..5499bf5 --- /dev/null +++ b/cypress/fixtures/user_agent.html @@ -0,0 +1,19 @@ + + + + + + + diff --git a/cypress/fixtures/variables.json b/cypress/fixtures/variables.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/cypress/fixtures/variables.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js new file mode 100644 index 0000000..b4f3b7a --- /dev/null +++ b/cypress/plugins/index.js @@ -0,0 +1,22 @@ +// +// *********************************************************** +// This example plugins/index.js can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) + +/** + * @type {Cypress.PluginConfig} + */ +// eslint-disable-next-line no-unused-vars +module.exports = (on, config) => { + // `on` is used to hook into various events Cypress emits + // `config` is the resolved Cypress config +}; diff --git a/cypress/support/commands.js b/cypress/support/commands.js new file mode 100644 index 0000000..c023d30 --- /dev/null +++ b/cypress/support/commands.js @@ -0,0 +1,386 @@ +import "./index"; +import "cypress-localstorage-commands"; + +var hp = require("./helper"); + +// // uncomment for stopping uncaught:exception fail +// Cypress.on("uncaught:exception", (err, runnable) => { +// // returning false here prevents Cypress from +// // failing the test +// return false; +// }); + +/** + * Checks a queue object for valid timestamp, hour and dow values + * @param {Object} testObject - object to be checked + */ +Cypress.Commands.add("check_commons", (testObject) => { + expect(testObject.timestamp).to.be.ok; + expect(testObject.timestamp.toString().length).to.equal(13); + expect(testObject.hour).to.be.within(0, 23); + expect(testObject.dow).to.be.within(0, 7); +}); + +/** + * Checks a queue object for valid app key, device id, sdk name and sdk version + * @param {Object} testObject - object to be checked +*/ +Cypress.Commands.add("check_request_commons", (testObject, appKey) => { + appKey = appKey || hp.appKey; + expect(testObject.app_key).to.equal(appKey); + expect(testObject.device_id).to.be.ok; + expect(testObject.sdk_name).to.be.exist; + expect(testObject.sdk_version).to.be.ok; + expect(testObject.t).to.be.within(0, 3); + const metrics = JSON.parse(testObject.metrics); + expect(metrics._ua).to.be.ok; +}); + +/** + * Checks a crash request for valid/correct formation + * @param {Object} testObject - crash object to be checked + */ +Cypress.Commands.add("check_crash", (testObject, appKey) => { + appKey = appKey || hp.appKey; + const metrics = JSON.parse(testObject.metrics); + const crash = JSON.parse(testObject.crash); + const metricKeys = Object.keys(metrics); + cy.check_request_commons(testObject, appKey); + cy.check_commons(testObject); + expect(metrics._ua).to.be.exist; + expect(metricKeys.length).to.equal(1); + expect(crash._app_version).to.be.exist; + expect(crash._background).to.be.exist; + expect(crash._error).to.be.exist; + expect(crash._javascript).to.be.exist; + expect(crash._nonfatal).to.be.exist; + expect(crash._not_os_specific).to.be.exist; + expect(crash._online).to.be.exist; + expect(crash._opengl).to.be.exist; + expect(crash._resolution).to.be.exist; + expect(crash._run).to.be.exist; + expect(crash._view).to.be.exist; +}); + +/** + * Checks a queue object for valid/correct begin session, end session and session extension values + * @param {Object} queueObject - queue object to check + * @param {Number} duration - session extension or end session duration to validate + * @param {Boolean} isSessionEnd - a boolean to mark this check is intended for end_session validation + */ +Cypress.Commands.add("check_session", (queueObject, duration, isSessionEnd, appKey, worker) => { + if (duration === undefined) { // if duration is not given that means its begin session + expect(queueObject.begin_session).to.equal(1); + const metrics = JSON.parse(queueObject.metrics); + expect(metrics._app_version).to.be.ok; + expect(metrics._ua).to.be.ok; + expect(metrics._locale).to.be.ok; + if (!worker) { + expect(metrics._resolution).to.be.ok; + expect(metrics._density).to.be.ok; + } + } + else if (!isSessionEnd) { + expect(queueObject.session_duration).to.be.within(duration, duration + 2); + } + else { + expect(queueObject.end_session).to.equal(1); + expect(queueObject.session_duration).to.be.within(duration, duration + 1); + } + cy.check_request_commons(queueObject, appKey); + cy.check_commons(queueObject); +}); + +/** + * Checks a queue object for valid/correct event values + * @param {Object} queueObject - queue object to check + * @param {Object} comparisonObject - an event object to compare with queue values + * @param {Number} duration - timed event duration to validate + */ +Cypress.Commands.add("check_event", (queueObject, comparisonObject, duration, hasCvid) => { + expect(queueObject.key).to.equal(comparisonObject.key); + expect(queueObject.id).to.be.ok; + expect(queueObject.pvid).to.be.undefined; + expect(queueObject.id.length).to.equal(21); + if (hasCvid) { + expect(queueObject.cvid).to.be.ok; + expect(queueObject.id.length).to.equal(21); + } + else { + expect(queueObject.cvid).to.equal(""); + } + if (comparisonObject.count === undefined) { + expect(queueObject.count).to.equal(1); + } + else { + expect(queueObject.count).to.equal(comparisonObject.count); + } + if (comparisonObject.sum !== undefined) { + expect(queueObject.sum).to.equal(comparisonObject.sum); + } + if (comparisonObject.dur !== undefined || duration !== undefined) { + if (duration !== undefined) { + comparisonObject.dur = duration; + } + expect(queueObject.dur).to.be.within(comparisonObject.dur, comparisonObject.dur + 1); + } + if (comparisonObject.segmentation !== undefined) { + for (var key in comparisonObject.segmentation) { + expect(queueObject.segmentation[key]).to.equal(comparisonObject.segmentation[key]); + } + } + cy.check_commons(queueObject); +}); + +/** + * Checks a queue object for valid/correct view event values + * @param {Object} queueObject - queue object to check + * @param {string} name - a view name + * @param {Number} duration - view event duration to validate + */ +Cypress.Commands.add("check_view_event", (queueObject, name, duration, hasPvid) => { + expect(queueObject.key).to.equal("[CLY]_view"); + expect(queueObject.id).to.be.ok; + expect(queueObject.cvid).to.be.undefined; + expect(queueObject.id.length).to.equal(21); + if (hasPvid) { + expect(queueObject.pvid).to.be.ok; + expect(queueObject.pvid.length).to.equal(21); + } + else { + expect(queueObject.pvid).to.equal(""); + } + expect(queueObject.count).to.equal(1); + if (duration === undefined) { + expect(queueObject.segmentation.visit).to.equal(1); + expect(queueObject.segmentation.view).to.be.ok; + if (queueObject.segmentation.view !== "web_worker") { + expect(queueObject.segmentation.domain).to.be.ok; + } + // expect(queue.segmentation.start).to.be.ok; // TODO: this is only for manual tracking? + } + else { + expect(queueObject.dur).to.be.within(duration, duration + 1); + } + expect(queueObject.segmentation.name).to.equal(name); + cy.check_commons(queueObject); +}); + +// TODO: make scroll tests better +/** + * Checks a queue object for valid/correct scroll event values + * @param {Object} queueObject - queue object to check + */ +Cypress.Commands.add("check_scroll_event", (queueObject) => { + expect(queueObject.key).to.equal("[CLY]_action"); + expect(queueObject.segmentation.domain).to.be.ok; + expect(queueObject.segmentation.height).to.be.ok; + expect(queueObject.segmentation.type).to.equal("scroll"); + expect(queueObject.segmentation.view).to.be.ok; + expect(queueObject.segmentation.width).to.be.ok; + expect(queueObject.segmentation.y).to.be.ok; + cy.check_commons(queueObject); +}); + +/** + * Checks a queue object for valid/correct user details values/limits + * @param {Object} detailsObject - queue object to check + * @param {Object} userDetails - user details object to compare queue values with + * @param {Object} limits - optional, if internal limits are going to be checked this should be provided as an object like this (values can change): + * {key: 8, value: 8, segment: 3, breadcrumb: 2, line_thread: 3, line_length: 10}; + */ +Cypress.Commands.add("check_user_details", (detailsObject, userDetails, limits, appKey) => { + const obj = detailsObject; + cy.check_commons(obj); + cy.check_request_commons(obj, appKey); + const queue = JSON.parse(obj.user_details); + if (limits !== undefined) { + expect(queue.name).to.equal(userDetails.name.substring(0, limits.value)); + expect(queue.username).to.equal(userDetails.username.substring(0, limits.value)); + expect(queue.email).to.equal(userDetails.email.substring(0, limits.value)); + expect(queue.organization).to.equal(userDetails.organization.substring(0, limits.value)); + expect(queue.phone).to.equal(userDetails.phone.substring(0, limits.value)); + expect(queue.picture).to.equal(userDetails.picture); + expect(queue.gender).to.equal(userDetails.gender.substring(0, limits.value)); + expect(queue.byear.toString()).to.equal(userDetails.byear.toString().substring(0, limits.value)); + if (userDetails.custom !== undefined) { + const truncatedKeyLen = Object.keys(queue.custom).length; + const keyList = Object.keys(userDetails.custom).map((e) => e.substring(0, limits.key)); + // check segments are truncated + expect(truncatedKeyLen).to.be.within(0, limits.segment); + for (const key in userDetails.custom) { + expect(queue.custom[key]).to.equal(userDetails.custom[key].substring(0, limits.value)); + // check keys truncated + expect(keyList).to.include(key); + } + } + return; + } + expect(queue.name).to.equal(userDetails.name); + expect(queue.username).to.equal(userDetails.username); + expect(queue.email).to.equal(userDetails.email); + expect(queue.organization).to.equal(userDetails.organization); + expect(queue.phone).to.equal(userDetails.phone); + expect(queue.picture).to.equal(userDetails.picture); + expect(queue.gender).to.equal(userDetails.gender); + expect(queue.byear).to.equal(userDetails.byear); + if (userDetails.custom !== undefined) { + for (const key in userDetails.custom) { + expect(queue.custom[key]).to.equal(userDetails.custom[key]); + } + } +}); + +/** + * Checks a queue object for valid/correct custom event values/limits + * @param {Object} queueObject - queue object to check + * @param {Object} customEvent - custom event object to compare queue values with + * @param {Object} limits - a limits object that has internal limits like this (values can change): + * {key: 8, value: 8, segment: 3, breadcrumb: 2, line_thread: 3, line_length: 10}; + */ +Cypress.Commands.add("check_custom_event_limit", (queueObject, customEvent, limits) => { + const obj = queueObject; + cy.check_commons(obj); + // check key + expect(obj.key).to.equal((customEvent.key).substring(0, limits.key)); + expect(obj.count).to.equal(1); + if (obj.segmentation !== undefined) { + const truncatedKeyLen = Object.keys(obj.segmentation).length; + const keyList = Object.keys(customEvent.segmentation).map((e) => e.substring(0, limits.key)); + // check segments are truncated + expect(truncatedKeyLen).to.be.within(0, limits.segment); + for (var key in obj.segmentation) { + // check values truncated + expect(obj.segmentation[key]).to.equal(customEvent.segmentation[key].substring(0, limits.value)); + // check keys truncated + expect(keyList).to.include(key); + } + } +}); + +/** + * Checks a queue object for valid/correct view event values/limits + * @param {Object} queueObject - queue object to check + * @param {Object} viewName - view name to compare queue values with + * @param {Object} limits - a limits object that has internal limits like this (values can change): + * {key: 8, value: 8, segment: 3, breadcrumb: 2, line_thread: 3, line_length: 10}; + */ +Cypress.Commands.add("check_view_event_limit", (queueObject, viewName, limits) => { + const obj = queueObject; + cy.check_commons(obj); + // check key + expect(obj.key).to.equal("[CLY]_view"); + expect(obj.segmentation.name).to.equal(viewName.substring(0, limits.value)); + expect(obj.segmentation.visit).to.equal(1); + expect(obj.segmentation.view.length).to.be.within(0, limits.value); +}); + +/** + * Checks a queue object for valid/correct error logging values/limits + * @param {Object} queueObject - queue object to check + * @param {Object} limits - a limits object that has internal limits like this (values can change): + * {key: 8, value: 8, segment: 3, breadcrumb: 2, line_thread: 3, line_length: 10}; + */ +Cypress.Commands.add("check_error_limit", (queueObject, limits) => { + const obj = queueObject; + const crash = JSON.parse(obj.crash); + cy.check_commons(obj); + cy.check_request_commons(obj); + expect(crash._resolution).to.be.exist; + expect(crash._app_version).to.be.exist; + expect(crash._run).to.be.exist; + expect(crash._not_os_specific).to.be.exist; + expect(crash._javascript).to.be.exist; + expect(crash._online).to.be.exist; + expect(crash._background).to.be.exist; + expect(crash._nonfatal).to.be.exist; + expect(crash._view).to.be.exist; + expect(crash._custom).to.be.exist; + expect(crash._opengl).to.be.exist; + expect(crash._logs).to.be.exist; + const err = crash._error.split("\n"); + for (let i = 0, len = err.length; i < len; i++) { + expect(err[i].length).to.be.within(0, limits.line_length); + expect(err.length).to.be.within(0, limits.line_thread); + } + const log = crash._logs.split("\n"); + for (let i = 0, len = log.length; i < len; i++) { + expect(log[i].length).to.be.within(0, limits.line_length); + expect(log.length).to.be.within(0, limits.line_thread); + } +}); + +/** + * Checks a queue object for valid/correct custom property values/limits + * @param {Object} propertiesObject - queue object to check + * @param {Object} customProperties - custom properties object to compare queue values with + * @param {Object} limits - optional, if internal limits are going to be checked this should be provided as an object like this (values can change): + * {key: 8, value: 8, segment: 3, breadcrumb: 2, line_thread: 3, line_length: 10}; + */ +Cypress.Commands.add("check_custom_properties_limit", (propertiesObject, customProperties, limits) => { + const obj = propertiesObject; + cy.check_commons(obj); + cy.check_request_commons(obj); + const queue = JSON.parse(obj.user_details).custom; + expect(queue[customProperties.set[0].substring(0, limits.key)]).to.equal(customProperties.set[1].substring(0, limits.value)); + expect(queue[customProperties.set_once[0].substring(0, limits.key)].$setOnce).to.equal(customProperties.set_once[1].substring(0, limits.value)); + expect(queue[customProperties.increment_by[0].substring(0, limits.key)].$inc).to.equal(customProperties.increment_by[1].toString().substring(0, limits.value)); + expect(queue[customProperties.multiply[0].substring(0, limits.key)].$mul).to.equal(customProperties.multiply[1].toString().substring(0, limits.value)); + expect(queue[customProperties.max[0].substring(0, limits.key)].$max).to.equal(customProperties.max[1].toString().substring(0, limits.value)); + expect(queue[customProperties.min[0].substring(0, limits.key)].$min).to.equal(customProperties.min[1].toString().substring(0, limits.value)); + expect(queue[customProperties.push[0].substring(0, limits.key)].$push[0]).to.equal(customProperties.push[1].substring(0, limits.value)); + expect(queue[customProperties.push_unique[0].substring(0, limits.key)].$addToSet[0]).to.equal(customProperties.push_unique[1].substring(0, limits.value)); + expect(queue[customProperties.pull[0].substring(0, limits.key)].$pull[0]).to.equal(customProperties.pull[1]); +}); + +/** + * fetches request queue from the local storage + */ +Cypress.Commands.add("fetch_local_request_queue", (appKey) => { + cy.wait(hp.sWait).then(() => { + appKey = appKey || hp.appKey; + cy.getLocalStorage(`${appKey}/cly_queue`).then((e) => { + if (e === undefined) { + expect.fail("request queue inside the local storage should not be undefined"); + } + if (e === null) { + // assume the queue is empty + return []; + } + const queue = JSON.parse(e); + return queue; + }); + }); +}); + +/** + * fetches event queue from the local storage + */ +Cypress.Commands.add("fetch_local_event_queue", (appKey) => { + cy.wait(hp.sWait).then(() => { + appKey = appKey || hp.appKey; + cy.getLocalStorage(`${appKey}/cly_event`).then((e) => { + if (e === undefined) { + expect.fail("event queue inside the local storage should not be undefined"); + } + if (e === null) { + // assume the queue is empty + return []; + } + const queue = JSON.parse(e); + return queue; + }); + }); +}); + +/** + * fetches values from local storage + */ +Cypress.Commands.add("fetch_from_storage", (appKey, key) => { + cy.wait(hp.sWait).then(() => { + appKey = appKey || hp.appKey; + cy.getLocalStorage(`${appKey}/${key}`).then((e) => { + return JSON.parse(e); + }); + }); +}); \ No newline at end of file diff --git a/cypress/support/e2e.js b/cypress/support/e2e.js new file mode 100644 index 0000000..0e7290a --- /dev/null +++ b/cypress/support/e2e.js @@ -0,0 +1,20 @@ +// *********************************************************** +// This example support/e2e.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands' + +// Alternatively you can use CommonJS syntax: +// require('./commands') \ No newline at end of file diff --git a/cypress/support/helper.js b/cypress/support/helper.js new file mode 100644 index 0000000..9209e7b --- /dev/null +++ b/cypress/support/helper.js @@ -0,0 +1,286 @@ +var Countly = require("../../Countly.js"); + +const appKey = "YOUR_APP_KEY"; +const sWait = 100; +const mWait = 4000; +const lWait = 10000; +/** + * resets Countly + * @param {Function} callback - callback function that includes the Countly init and the tests + */ +function haltAndClearStorage(callback) { + if (Countly.i !== undefined) { + Countly.halt(); + } + cy.wait(sWait).then(() => { + cy.clearLocalStorage(); + cy.wait(sWait).then(() => { + callback(); + }); + }); +} + +/** + * user details object for user details tests (used in user_details.js and storage_change.js) + * @type {Object} + */ +const userDetailObj = { + name: "Barturiana Sosinsiava", + username: "bar2rawwen", + email: "test@test.com", + organization: "Dukely", + phone: "+123456789", + picture: "https://ps.timg.com/profile_images/52237/011_n_400x400.jpg", + gender: "Non-binary", + byear: 1987, + custom: { + "key1 segment": "value1 segment", + "key2 segment": "value2 segment" + } +}; + +/** + * get timestamp + * @returns {number} -timestamp + */ +function getTimestampMs() { + return new Date().getTime(); +} + +/** + * Fine tuner for flaky tests. Retries test for a certain amount + * @param {number} startTime - starting time, timestamp + * @param {number} waitTime - real wait time for tests you want to test + * @param {number} waitIncrement - time increment to retry the tests + * @param {Function} continueCallback - callback function with tests + */ +var waitFunction = function(startTime, waitTime, waitIncrement, continueCallback) { + if (waitTime <= getTimestampMs() - startTime) { + // we have waited enough + continueCallback(); + } + else { + // we need to wait more + cy.wait(waitIncrement).then(()=>{ + waitFunction(startTime, waitTime, waitIncrement, continueCallback); + }); + } +}; + +/** + * This intercepts the request the SDK makes and returns the request parameters to the callback function + * @param {String} requestType - GET, POST, PUT, DELETE + * @param {String} requestUrl - request url (https://your.domain.count.ly) + * @param {String} endPoint - endpoint (/i) + * @param {String} requestParams - request parameters (?begin_session=**) + * @param {String} alias - alias for the request + * @param {Function} callback - callback function + */ +function interceptAndCheckRequests(requestType, requestUrl, endPoint, requestParams, alias, callback) { + requestType = requestType || "GET"; + requestUrl = requestUrl || "https://your.domain.count.ly"; // TODO: might be needed in the future but not yet + endPoint = endPoint || "/i"; + requestParams = requestParams || "?**"; + alias = alias || "getXhr"; + + cy.intercept(requestType, endPoint + requestParams).as(alias); + cy.wait("@" + alias).then((xhr) => { + const url = new URL(xhr.request.url); + const searchParams = url.searchParams; + callback(searchParams); + }); +} + +// gathered events. count and segmentation key/values must be consistent +const eventArray = [ + // first event must be custom event + { + key: "a", + count: 1, + segmentation: { + 1: "1" + } + }, + // rest can be internal events + { + key: "[CLY]_view", + count: 2, + segmentation: { + 2: "2" + } + }, + { + key: "[CLY]_nps", + count: 3, + segmentation: { + 3: "3" + } + }, + { + key: "[CLY]_survey", + count: 4, + segmentation: { + 4: "4" + } + }, + { + key: "[CLY]_star_rating", + count: 5, + segmentation: { + 5: "5" + } + }, + { + key: "[CLY]_orientation", + count: 6, + segmentation: { + 6: "6" + } + }, + { + key: "[CLY]_action", + count: 7, + segmentation: { + 7: "7" + } + } + +]; +// event adding loop +/** + * adds events to the queue + * @param {Array} omitList - events to omit from the queue. If not provided, all events will be added. Must be an array of string key values + */ +function events(omitList) { + for (var i = 0, len = eventArray.length; i < len; i++) { + if (omitList) { + if (omitList.indexOf(eventArray[i].key) === -1) { + Countly.add_event(eventArray[i]); + } + } + else { + Countly.add_event(eventArray[i]); + } + } +} + +// TODO: this validator is so rigid. Must be modified to be more flexible (accepting more variables) +/** + * Validates requests in the request queue for normal flow test + * @param {Array} rq - request queue + * @param {string} viewName - name of the view + * @param {string} countlyAppKey - app key +*/ +function testNormalFlow(rq, viewName, countlyAppKey) { + cy.log(rq); + expect(rq.length).to.equal(8); + const idType = rq[0].t; + const id = rq[0].device_id; + + // 1 - 2 + expect(rq[0].campaign_id).to.equal("camp_id"); + expect(rq[0].campaign_user).to.equal("camp_user_id"); + expect(rq[1].campaign_id).to.equal("camp_id"); + expect(rq[1].campaign_user).to.equal("camp_user_id"); + + // 3 + const thirdRequest = JSON.parse(rq[2].events); + expect(thirdRequest.length).to.equal(2); + cy.check_event(thirdRequest[0], { key: "test", count: 1, sum: 1, dur: 1, segmentation: { test: "test" } }, undefined, ""); + cy.check_event(thirdRequest[0], { key: "test", count: 1, sum: 1, dur: 1, segmentation: { } }, undefined, ""); + + // 4 + const fourthRequest = JSON.parse(rq[3].user_details); + expect(fourthRequest.name).to.equal("name"); + expect(fourthRequest.custom).to.eql({}); + + // 5 + const fifthRequest = JSON.parse(rq[4].user_details); + expect(fifthRequest).to.eql({ custom: { set: "set" } }); + + // 6 + const sixthRequest = JSON.parse(rq[5].crash); + expect(sixthRequest._error).to.equal("stack"); + + // 7 + expect(rq[6].begin_session).to.equal(1); + + // 8 + const eighthRequest = JSON.parse(rq[7].events); + expect(eighthRequest.length).to.equal(2); + cy.check_event(eighthRequest[0], { key: "[CLY]_orientation" }, undefined, ""); + cy.check_view_event(eighthRequest[1], viewName, undefined, false); + + // each request should have same device id, device id type and app key + rq.forEach(element => { + expect(element.device_id).to.equal(id); + expect(element.t).to.equal(idType); + expect(element.app_key).to.equal(countlyAppKey); + expect(element.metrics).to.be.ok; + expect(element.dow).to.exist; + expect(element.hour).to.exist; + expect(element.sdk_name).to.be.ok; + expect(element.sdk_version).to.be.ok; + expect(element.timestamp).to.be.ok; + }); +} + +/** + * Validates utm tags in the request queue/given object + * You can pass undefined if you want to check if utm tags do not exist + * + * @param {*} aq - object to check + * @param {*} source - utm_source + * @param {*} medium - utm_medium + * @param {*} campaign - utm_campaign + * @param {*} term - utm_term + * @param {*} content - utm_content + */ +function validateDefaultUtmTags(aq, source, medium, campaign, term, content) { + if (typeof source === "string") { + expect(aq.utm_source).to.eq(source); + } + else { + expect(aq.utm_source).to.not.exist; + } + if (typeof medium === "string") { + expect(aq.utm_medium).to.eq(medium); + } + else { + expect(aq.utm_medium).to.not.exist; + } + if (typeof campaign === "string") { + expect(aq.utm_campaign).to.eq(campaign); + } + else { + expect(aq.utm_campaign).to.not.exist; + } + if (typeof term === "string") { + expect(aq.utm_term).to.eq(term); + } + else { + expect(aq.utm_term).to.not.exist; + } + if (typeof content === "string") { + expect(aq.utm_content).to.eq(content); + } + else { + expect(aq.utm_content).to.not.exist; + } +} + +module.exports = { + haltAndClearStorage, + sWait, + mWait, + lWait, + appKey, + getTimestampMs, + waitFunction, + events, + eventArray, + testNormalFlow, + interceptAndCheckRequests, + validateDefaultUtmTags, + userDetailObj +}; \ No newline at end of file diff --git a/cypress/support/index.js b/cypress/support/index.js new file mode 100644 index 0000000..02c3b97 --- /dev/null +++ b/cypress/support/index.js @@ -0,0 +1,2 @@ +import "./commands"; +import "../../Main.js"; diff --git a/cypress/support/integration_helper.js b/cypress/support/integration_helper.js new file mode 100644 index 0000000..89c0109 --- /dev/null +++ b/cypress/support/integration_helper.js @@ -0,0 +1,36 @@ +/* eslint-disable no-unused-vars */ + +/** + * Extracts the query from url and returns an object with config values + * @param {string} query - url query + * @returns {Object} config object + */ +function queryExtractor(query) { + // split the values + var returnVal = {}; + if (query) { + var parts = query.substring(1).split("&"); + for (var i = 0; i < parts.length; i++) { + var conf = parts[i].split("="); + returnVal[conf[0]] = conf[1]; + } + } + return returnVal; +} + +/** + * Sets a value to local storage and triggers a storage event + * @param {*} key - storage key + * @param {*} value - storage value + */ +function triggerStorageChange(key, value) { + localStorage.setItem(key, value); + const storageEvent = new StorageEvent("storage", { + key: key, + newValue: value + }); + + window.dispatchEvent(storageEvent); +} + +export { queryExtractor, triggerStorageChange }; \ No newline at end of file diff --git a/examples/Angular/countly.d.ts b/examples/Angular/countly.d.ts new file mode 100644 index 0000000..3f1865a --- /dev/null +++ b/examples/Angular/countly.d.ts @@ -0,0 +1,4 @@ +declare module 'countly-sdk-web'; +interface Window { + Countly: any; +} diff --git a/examples/Angular/main.ts b/examples/Angular/main.ts new file mode 100644 index 0000000..85da81a --- /dev/null +++ b/examples/Angular/main.ts @@ -0,0 +1,24 @@ +import { enableProdMode } from '@angular/core'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { AppModule } from './app/app.module'; +import { environment } from './environments/environment'; + +import Countly from 'countly-sdk-web'; + +window.Countly = Countly; + +Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + debug: true +}); +Countly.track_sessions(); + +if (environment.production) { + enableProdMode(); + +} + +platformBrowserDynamic().bootstrapModule(AppModule) + .catch(err => console.error(err)); diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..ca75b37 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,44 @@ +# Countly Implementation Examples + +This folder is filled with examples of Countly implementations to help you to get an idea +how to integrate Countly into your own personal project too. + +For all projects you should change 'YOUR_APP_KEY' value with your own application's app key, 'https://your.domain.count.ly' value with your own server url. + +If you have not separated 'examples' folder from the 'COUNTLY-SDK-WEB' project folder nor made some changes to file names in the periphery, any path to countly.js or the plugins must be still correct. + +But if you did make some changes you should check if the paths, like '../dist/countly.js', are correct or not inside the example files. + +## Generating Examples + +You can use the create_examples.py to generate example implementations of Countly Web SDK for React or Angular. +It also can create an example that can be used to demonstrate the symbolication for Web SDK. + +To use the script: + +```bash +python create_examples.py +# or python3 create_examples.py +``` + +It would ask for the example you want to create (react/angular/symbolication/all). +After it creates the example(s) you want, you will have to run the following command(s) to serve/run the example(s): + +```bash +# For angular-example +cd angular-example +ng serve +``` + +```bash +# For react-example +cd react-example +npm start +``` + +```bash +# For symbolication-example +cd symbolication-example +npm run build +npm start +``` diff --git a/examples/React/src/App-WithEffect.js b/examples/React/src/App-WithEffect.js new file mode 100644 index 0000000..5c8f119 --- /dev/null +++ b/examples/React/src/App-WithEffect.js @@ -0,0 +1,33 @@ +import React, { useState } from "react"; +import { BrowserRouter as Router, Switch, Route } from "react-router-dom"; + +import Home from "./Components/Home"; +import Contact from "./Components/Contact"; +import Header from "./Components/Header"; + +import Location from "./Location-WithEffect"; +import ErrorBoundary from "./ErrorBoundary"; + +const App = () => { + const [loginState, setLoginState] = useState(localStorage.getItem("clydemo-login-state") ? true : false); + + return ( + + + +
+ + + + + + + + + + + + ); +}; + +export default App; diff --git a/examples/React/src/App-WithRouter.js b/examples/React/src/App-WithRouter.js new file mode 100644 index 0000000..5116694 --- /dev/null +++ b/examples/React/src/App-WithRouter.js @@ -0,0 +1,46 @@ +import React from "react"; +import { BrowserRouter as Router, Switch, Route } from "react-router-dom"; + +import Home from "./Components/Home"; +import Contact from "./Components/Contact"; +import Header from "./Components/Header"; + +import Location from "./Location-WithRouter"; +import ErrorBoundary from "./ErrorBoundary"; + +class App extends React.Component { + constructor(props) { + super(props); + this.state = { + loginState: localStorage.getItem("clydemo-login-state") ? true : false + }; + } + + setLoginState(state) { + this.setState({ + loginState: state + }); + } + + render() { + return ( + + + +
this.setLoginState(state)} /> + + + + + + + + + + + + ); + } +} + +export default App; diff --git a/examples/React/src/App.test.js b/examples/React/src/App.test.js new file mode 100644 index 0000000..4db7ebc --- /dev/null +++ b/examples/React/src/App.test.js @@ -0,0 +1,9 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import App from './App'; + +test('renders learn react link', () => { + const { getByText } = render(); + const linkElement = getByText(/learn react/i); + expect(linkElement).toBeInTheDocument(); +}); diff --git a/examples/React/src/Components/Contact.js b/examples/React/src/Components/Contact.js new file mode 100644 index 0000000..284c8cb --- /dev/null +++ b/examples/React/src/Components/Contact.js @@ -0,0 +1,29 @@ +import React from 'react'; +import Countly from 'countly-sdk-web'; +import countlyImage from './countly.jpg'; + +function Contact() { + const emailUsClick = () => { + Countly.q.push(['add_event', { + "key": "email-us-clicked", + "count": 1 + }]); + alert("Email us event triggered"); + } + + return ( +
+
+ Home +
+
+

support@countly

+
+
+ +
+
+ ); +} + +export default Contact; \ No newline at end of file diff --git a/examples/React/src/Components/Header.js b/examples/React/src/Components/Header.js new file mode 100644 index 0000000..320ab27 --- /dev/null +++ b/examples/React/src/Components/Header.js @@ -0,0 +1,89 @@ +import React from 'react'; +import { + Link, + useHistory +} from "react-router-dom"; + +import Users from './Users'; +import Countly from 'countly-sdk-web'; +import './styles.css'; + +const Header = (props) => { + let loginState = props.loginState; + let history = useHistory(); + + const onSignIn = () => { + let login = window.confirm("Do you want to login ?"); + if(login) { + let userIndex = getRandomUserIndex(); + let user = Users[userIndex]; + localStorage.setItem("clydemo-user", userIndex); + localStorage.setItem("clydemo-login-state", true); + + //Change user's device on login - He is now an identified user + Countly.q.push(['change_id', user.device_id]); + props.setLoginState(true); + setUserData(user); + history.push("/"); + } + } + + const onSignOut = () => { + localStorage.removeItem("clydemo-login-state"); + localStorage.removeItem("clydemo-user"); + + //Change user's device on logout - He is now an anonymous user + Countly.q.push(['change_id', "cly-device-demo-id"]); + props.setLoginState(false); + history.push("/"); + } + + const getRandomUserIndex = () => { + return getRandomNumber(0, Users.length - 1); + } + + const setUserData = (user) => { + Countly.q.push(['user_details', { + "name": user.name, + "username": user.username, + "email": user.email, + "organization": user.organization, + "phone": user.phone, + "gender": user.gender, + "byear": user.byear + }]); + } + + const getRandomNumber = (min, max) => { + return parseInt(Math.random() * (max - min) + min, 10); + } + + return ( +
+
+ Home +
+
+

Countly React Demo

+
+
+

+ Contact +

+ { + !loginState &&

+ Sign in +

+ } + { + loginState &&

+ Sign out +

+ } + +
+
+ ); +} + +export default Header; \ No newline at end of file diff --git a/examples/React/src/Components/Home.js b/examples/React/src/Components/Home.js new file mode 100644 index 0000000..c94a1d9 --- /dev/null +++ b/examples/React/src/Components/Home.js @@ -0,0 +1,24 @@ +import React from 'react'; + +import Users from './Users'; +import countlyImage from './countly.jpg'; + +function Home() { + let userIndex = localStorage.getItem("clydemo-user"); + let user = {}; + + if (userIndex !== undefined && userIndex !== null) { + user = Users[userIndex] || {}; + } + + return ( +
+
+ Home +

Welcome {user.name}

+
+
+ ); +} + +export default Home; \ No newline at end of file diff --git a/examples/React/src/Components/Users.js b/examples/React/src/Components/Users.js new file mode 100644 index 0000000..0d3ac55 --- /dev/null +++ b/examples/React/src/Components/Users.js @@ -0,0 +1,44 @@ +const Users = [ + { + "device_id": "prikshit", + "name": "Prikshit", + "username": "prikshit", + "email": "prikshit@cly.com", + "organization": "Google", + "phone": "+919882201994", + "gender": "M", + "byear": "1994" + }, + { + "device_id": "alex", + "name": "Alex", + "username": "alex", + "email": "alex@cly.com", + "organization": "Amazom", + "phone": "+919882202010", + "gender": "M", + "byear": "2010" + }, + { + "device_id": "hannah", + "name": "Hannah", + "username": "hannah", + "email": "hannah@cly.com", + "organization": "Facebook", + "phone": "+919882202000", + "gender": "F", + "byear": "2000" + }, + { + "device_id": "jarvis", + "name": "Jarvis", + "username": "jarvis", + "email": "jarvis@cly.com", + "organization": "Tony Stark corp", + "phone": "+919882202013", + "gender": "F", + "byear": "2013" + }, +]; + +export default Users; \ No newline at end of file diff --git a/examples/React/src/Components/countly.jpg b/examples/React/src/Components/countly.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9d5ff9df511072b1c38aa456a6b27cffdba1a806 GIT binary patch literal 66790 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

_gw6{T-sCeY=46?C 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

R$gBla#n^ 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⪼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 literal 0 HcmV?d00001 diff --git a/examples/React/src/Components/styles.css b/examples/React/src/Components/styles.css new file mode 100644 index 0000000..6660c48 --- /dev/null +++ b/examples/React/src/Components/styles.css @@ -0,0 +1,26 @@ +.link { + text-decoration: none; + padding: 20px; + color: #000; +} + +.contact { + text-align: center; +} + +.header { + width: 100%; + height: 100px; + display: flex; + justify-content: space-between; + align-items: center; + background-color: #cff96b; +} + +.email-us { + padding: 20px; + font-size: 20px; + background-color: #cff96b; + border: none; + border-radius: 2px; +} diff --git a/examples/React/src/ErrorBoundary.js b/examples/React/src/ErrorBoundary.js new file mode 100644 index 0000000..a8402b5 --- /dev/null +++ b/examples/React/src/ErrorBoundary.js @@ -0,0 +1,28 @@ +import React from "react"; +import Countly from "countly-sdk-web"; + +// Error boundaries only apply to errors that happen during rendering. +// So errors originating anywhere else will not trigger this mechanism. +// This includes errors in event handlers, and errors in async calls (e.g. setTimeout(...) and similar). + +// Production and development builds of React slightly differ in the way componentDidCatch() handles errors. + +// On development, the errors will bubble up to window, this means that any window.onerror or +// window.addEventListener('error', callback) will intercept the errors that have been caught by componentDidCatch(). + +// On production, instead, the errors will not bubble up, which means any ancestor error handler will +// only receive errors not explicitly caught by componentDidCatch(). + +class ErrorBoundary extends React.Component { + componentDidCatch(error, errorInfo) { + //You can provide your own segments here too. + let segments = {}; + Countly.q.push(["log_error", error, segments]); + } + + render() { + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/examples/React/src/Location-WithEffect.js b/examples/React/src/Location-WithEffect.js new file mode 100644 index 0000000..8b16eaa --- /dev/null +++ b/examples/React/src/Location-WithEffect.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { + useLocation +} from "react-router-dom"; + +import Countly from 'countly-sdk-web'; + +const Location = (props) => { + const location = useLocation(); + + React.useEffect(() => { + //You can also check for page redirect logic or going back/forward from the browser logic here + //Check if pathname is not changing dont track the view + //So that you dont end up tracking the same view again and again + Countly.q.push(['track_pageview', location.pathname]); + // Initialize rating widget popup by current page/pathname + Countly.q.push(['initializeRatingWidgets']); + }, [location]); + + return ( + + {props.children} + + ); +} + +export default Location; diff --git a/examples/React/src/Location-WithRouter.js b/examples/React/src/Location-WithRouter.js new file mode 100644 index 0000000..1e7bc8d --- /dev/null +++ b/examples/React/src/Location-WithRouter.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { + withRouter +} from "react-router-dom"; + +import Countly from 'countly-sdk-web'; + +class Location extends React.Component { + componentDidUpdate(prevProps) { + if (this.props.location.pathname !== prevProps.location.pathname) { + Countly.q.push(["track_pageview", this.props.location.pathname]); + } + } + + render () { + return ( + + {this.props.children} + + ) + } +} + +export default withRouter(Location); \ No newline at end of file diff --git a/examples/React/src/index.css b/examples/React/src/index.css new file mode 100644 index 0000000..26f3b0a --- /dev/null +++ b/examples/React/src/index.css @@ -0,0 +1,6 @@ +body { + margin: 0; + font-family: "Lucida Sans Unicode", "Lucida Grande", sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/examples/React/src/index.js b/examples/React/src/index.js new file mode 100644 index 0000000..d5461ab --- /dev/null +++ b/examples/React/src/index.js @@ -0,0 +1,33 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import './index.css'; +import App from './App-WithEffect'; +import * as serviceWorker from './serviceWorker'; +import Countly from 'countly-sdk-web'; + +//Exposing Countly to the DOM as a global variable +//Usecase - Heatmaps +window.Countly = Countly; +Countly.init({ + app_key: 'YOUR_APP_KEY', + url: 'YOUR_SERVER_URL', + debug: true +}); + +Countly.q.push(['track_sessions']); +Countly.q.push(['track_scrolls']); +Countly.q.push(['track_clicks']); +Countly.q.push(['track_links']); +Countly.q.push(["track_errors"]); + +ReactDOM.render( + + + , + document.getElementById('root') +); + +// If you want your app to work offline and load faster, you can change +// unregister() to register() below. Note this comes with some pitfalls. +// Learn more about service workers: https://bit.ly/CRA-PWA +serviceWorker.unregister(); diff --git a/examples/React/src/serviceWorker.js b/examples/React/src/serviceWorker.js new file mode 100644 index 0000000..c4838eb --- /dev/null +++ b/examples/React/src/serviceWorker.js @@ -0,0 +1,141 @@ +// This optional code is used to register a service worker. +// register() is not called by default. + +// This lets the app load faster on subsequent visits in production, and gives +// it offline capabilities. However, it also means that developers (and users) +// will only see deployed updates on subsequent visits to a page, after all the +// existing tabs open on the page have been closed, since previously cached +// resources are updated in the background. + +// To learn more about the benefits of this model and instructions on how to +// opt-in, read https://bit.ly/CRA-PWA + +const isLocalhost = Boolean( + window.location.hostname === 'localhost' || + // [::1] is the IPv6 localhost address. + window.location.hostname === '[::1]' || + // 127.0.0.0/8 are considered localhost for IPv4. + window.location.hostname.match( + /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ + ) +); + +export function register(config) { + if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { + // The URL constructor is available in all browsers that support SW. + const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); + if (publicUrl.origin !== window.location.origin) { + // Our service worker won't work if PUBLIC_URL is on a different origin + // from what our page is served on. This might happen if a CDN is used to + // serve assets; see https://github.com/facebook/create-react-app/issues/2374 + return; + } + + window.addEventListener('load', () => { + const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; + + if (isLocalhost) { + // This is running on localhost. Let's check if a service worker still exists or not. + checkValidServiceWorker(swUrl, config); + + // Add some additional logging to localhost, pointing developers to the + // service worker/PWA documentation. + navigator.serviceWorker.ready.then(() => { + console.log( + 'This web app is being served cache-first by a service ' + + 'worker. To learn more, visit https://bit.ly/CRA-PWA' + ); + }); + } else { + // Is not localhost. Just register service worker + registerValidSW(swUrl, config); + } + }); + } +} + +function registerValidSW(swUrl, config) { + navigator.serviceWorker + .register(swUrl) + .then(registration => { + registration.onupdatefound = () => { + const installingWorker = registration.installing; + if (installingWorker == null) { + return; + } + installingWorker.onstatechange = () => { + if (installingWorker.state === 'installed') { + if (navigator.serviceWorker.controller) { + // At this point, the updated precached content has been fetched, + // but the previous service worker will still serve the older + // content until all client tabs are closed. + console.log( + 'New content is available and will be used when all ' + + 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' + ); + + // Execute callback + if (config && config.onUpdate) { + config.onUpdate(registration); + } + } else { + // At this point, everything has been precached. + // It's the perfect time to display a + // "Content is cached for offline use." message. + console.log('Content is cached for offline use.'); + + // Execute callback + if (config && config.onSuccess) { + config.onSuccess(registration); + } + } + } + }; + }; + }) + .catch(error => { + console.error('Error during service worker registration:', error); + }); +} + +function checkValidServiceWorker(swUrl, config) { + // Check if the service worker can be found. If it can't reload the page. + fetch(swUrl, { + headers: { 'Service-Worker': 'script' } + }) + .then(response => { + // Ensure service worker exists, and that we really are getting a JS file. + const contentType = response.headers.get('content-type'); + if ( + response.status === 404 || + (contentType != null && contentType.indexOf('javascript') === -1) + ) { + // No service worker found. Probably a different app. Reload the page. + navigator.serviceWorker.ready.then(registration => { + registration.unregister().then(() => { + window.location.reload(); + }); + }); + } else { + // Service worker found. Proceed as normal. + registerValidSW(swUrl, config); + } + }) + .catch(() => { + console.log( + 'No internet connection found. App is running in offline mode.' + ); + }); +} + +export function unregister() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready + .then(registration => { + registration.unregister(); + }) + .catch(error => { + console.error(error.message); + }); + } +} diff --git a/examples/React/src/setupTests.js b/examples/React/src/setupTests.js new file mode 100644 index 0000000..74b1a27 --- /dev/null +++ b/examples/React/src/setupTests.js @@ -0,0 +1,5 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import '@testing-library/jest-dom/extend-expect'; diff --git a/examples/Symbolication/public/index.html b/examples/Symbolication/public/index.html new file mode 100644 index 0000000..f360ea7 --- /dev/null +++ b/examples/Symbolication/public/index.html @@ -0,0 +1,21 @@ + + + + + +

+

+

+

+

+ +

+

+

Dummy link

+

Countly

+ + diff --git a/examples/Symbolication/src/main.js b/examples/Symbolication/src/main.js new file mode 100644 index 0000000..39d8e79 --- /dev/null +++ b/examples/Symbolication/src/main.js @@ -0,0 +1,47 @@ +import Countly from "countly-sdk-web"; + +Countly.init({ + app_key: "YOUR_APP_KEY", + app_version: "1.0", + url: "https://your.domain.count.ly", + debug: true +}); + +//track sessions automatically +Countly.track_sessions(); + +//track pageviews automatically +Countly.track_pageview(); + +//track any clicks to webpages automatically +Countly.track_clicks(); + +//track link clicks automatically +Countly.track_links(); + +//track form submissions automatically +Countly.track_forms(); + +//track javascript errors +Countly.track_errors(); + +//let's cause some errors +function cause_error(){ + undefined_function(); +} + +window.onload = function() { + document.getElementById("handled_error").onclick = function handled_error(){ + Countly.add_log('Pressed handled button'); + try { + cause_error(); + } catch(err){ + Countly.log_error(err) + } + }; + + document.getElementById("unhandled_error").onclick = function unhandled_error(){ + Countly.add_log('Pressed unhandled button'); + cause_error(); + }; +} diff --git a/examples/create_examples.py b/examples/create_examples.py new file mode 100644 index 0000000..6273ae8 --- /dev/null +++ b/examples/create_examples.py @@ -0,0 +1,63 @@ +import shutil +import os +import platform + +# Creates an example React or Angular or a bundled JS example (or all) +# Depends on the content of React and Angular and Symbolication folders respectively + +def setup_react_example(): + print("Creating React example...") + os.system("npx create-react-app react-example") + + # Remove existing src folder + if os.path.exists("react-example/src"): + shutil.rmtree("react-example/src") + + # Copy contents of React folder over to React example + shutil.copytree("React", "react-example", dirs_exist_ok=True) + + os.chdir("react-example") + # Add countly-sdk-web to dependencies in package.json + os.system("npm install --save countly-sdk-web@latest react-router-dom@5.3.3") + os.chdir("..") + +def setup_angular_example(): + print("Creating Angular example...") + os.system("npx @angular/cli new angular-example --defaults") + + # Copy contents of Angular folder over to Angular example + shutil.copytree("Angular", "angular-example/src", dirs_exist_ok=True) + + os.chdir("angular-example") + # Add countly-sdk-web to dependencies in package.json + os.system("npm install --save countly-sdk-web@latest") + os.chdir("..") + +def setup_symbolication_example(): + print("Creating Symbolication example...") + os.system('npx degit "rollup/rollup-starter-app" symbolication-example') + + # Copy contents of Symbolication folder over to Symbolication example + shutil.copytree("Symbolication/public", "symbolication-example/public", dirs_exist_ok=True) + shutil.copytree("Symbolication/src", "symbolication-example/src", dirs_exist_ok=True) + + os.chdir("symbolication-example") + # Add countly-sdk-web to dependencies in package.json + os.system("npm install --save countly-sdk-web@latest") + os.chdir("..") + +if __name__ == "__main__": + example = input('Select an example to create (react/angular/symbolication/all): ') + if example == "react": + setup_react_example() + elif example == "angular": + setup_angular_example() + elif example == "symbolication": + setup_symbolication_example() + elif example == "all": + setup_react_example() + setup_angular_example() + setup_symbolication_example() + else: + print("Invalid input. Exiting...") + exit(1) diff --git a/examples/example_apm.html b/examples/example_apm.html new file mode 100644 index 0000000..c63d959 --- /dev/null +++ b/examples/example_apm.html @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + +
+ +
+ + + \ No newline at end of file diff --git a/examples/example_apm_async.html b/examples/example_apm_async.html new file mode 100644 index 0000000..dcb93fd --- /dev/null +++ b/examples/example_apm_async.html @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + +
+ +
+ +

Countly

+
+ + + \ No newline at end of file diff --git a/examples/example_async.html b/examples/example_async.html new file mode 100644 index 0000000..ac68a00 --- /dev/null +++ b/examples/example_async.html @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + +
+ +
+ +

Countly

+
+ + + \ No newline at end of file diff --git a/examples/example_fb.html b/examples/example_fb.html new file mode 100644 index 0000000..9edf033 --- /dev/null +++ b/examples/example_fb.html @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + +
+ +
+ + + + + \ No newline at end of file diff --git a/examples/example_formdata.html b/examples/example_formdata.html new file mode 100644 index 0000000..4e3fd2f --- /dev/null +++ b/examples/example_formdata.html @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + +
+ +
+ +

+ +

+ + +

+ +

+ +

+ +

+ +

+ +

+

+
+
+ + + \ No newline at end of file diff --git a/examples/example_ga_adapter.html b/examples/example_ga_adapter.html new file mode 100644 index 0000000..8058924 --- /dev/null +++ b/examples/example_ga_adapter.html @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + +
+ +
+ + + \ No newline at end of file diff --git a/examples/example_gdpr.html b/examples/example_gdpr.html new file mode 100644 index 0000000..dc2365d --- /dev/null +++ b/examples/example_gdpr.html @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + +
+ +

+

+
+ + + \ No newline at end of file diff --git a/examples/example_helpers.html b/examples/example_helpers.html new file mode 100644 index 0000000..b68a7ed --- /dev/null +++ b/examples/example_helpers.html @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + +
+ +
+

+

+

+

+
+

+

+

Dummy link

+

Countly

+
+ + + + \ No newline at end of file diff --git a/examples/example_multiple_instances.html b/examples/example_multiple_instances.html new file mode 100644 index 0000000..de8153b --- /dev/null +++ b/examples/example_multiple_instances.html @@ -0,0 +1,63 @@ + + + + + +

Countly

+ + + + + + + + + +
+ +
+ + + \ No newline at end of file diff --git a/examples/example_opt_out.html b/examples/example_opt_out.html new file mode 100644 index 0000000..71ad8b2 --- /dev/null +++ b/examples/example_opt_out.html @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + +
+ +

+

+
+ + + \ No newline at end of file diff --git a/examples/example_rating_widgets.html b/examples/example_rating_widgets.html new file mode 100644 index 0000000..955f64b --- /dev/null +++ b/examples/example_rating_widgets.html @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + +
+ +

Countly

+

+

+

+
+ + + \ No newline at end of file diff --git a/examples/example_remote_config.html b/examples/example_remote_config.html new file mode 100644 index 0000000..1bd7b09 --- /dev/null +++ b/examples/example_remote_config.html @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + +
+ +
+ + + + +

Countly

+
+ + + + \ No newline at end of file diff --git a/examples/example_sync.html b/examples/example_sync.html new file mode 100644 index 0000000..6eb164e --- /dev/null +++ b/examples/example_sync.html @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + +
+ +
+ +

Countly

+
+ + + \ No newline at end of file diff --git a/examples/example_web_worker.html b/examples/example_web_worker.html new file mode 100644 index 0000000..7d49769 --- /dev/null +++ b/examples/example_web_worker.html @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + +
+ +
+ + + + +
+
+ + + + \ No newline at end of file diff --git a/examples/examples_feedback_widgets.html b/examples/examples_feedback_widgets.html new file mode 100644 index 0000000..4d73fa0 --- /dev/null +++ b/examples/examples_feedback_widgets.html @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + +
+ +

Countly

+

+

+

+

+
+ + + \ No newline at end of file diff --git a/examples/images/logo.png b/examples/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..5f0e72b547ef2859d368757e7f0589ea2dc84095 GIT binary patch literal 128674 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

_gw6{T-sCeY=46?C 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

R$gBla#n^ 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⪼`P;3+hLw2$GI>3?4Nn)H$5{azgKvyb=Rf0T6{HnT9Owe1_^YH83SOg)ius5 z85Dn?>=T$yy$2lAe!X%wM#RB3Yqt%w zGHh6RuyOD$z`RZ`HR&bmT|`y6l*<3Ka8!ey!9Rn~@WTf3l(8Qm-MFS_6$#thshWW6 ziFtbs7vZLKE|SA_!iBTs)>74-bD#2nCp{G>Jz4JDnAE%^Srr87t98kKJwkS27eN*h z-0iqnJdnxd@aUSp_pfoSu%C)+5=m)Vqi?R0EP7!xy^#t;!MAxQZbAtTR~g%Qwd4G= zVq-o&%LG0VJn|M4r3571uQ%I!+a^xnwIdNc9Wk{WQNDluwC`SzuEJUx%TKnfC|5qI zVTA6DYb?JobEuW7yc*CBs!%)jW{q$-Fi=Md%4gtAJx-*e?wt%+_4C1VL(W~>%JHFb)`RsHKaD;B~qlsX;#~aDyt(I_P4ogkgH$e@x#sQ)t<4>ZDv>F4 z^-L%Ht?B-2`#oao;SiEkQ9+vB`00$#MR2ipNsH%(PN!} zM+noU_I30VO%?&~lyCf2OO~K3Ra8?&#B1A&-j2 z2`i851JtuN&KVz!SE-xF&Q8!#cZ4HzBQrAcd7hPSVMY=pBEQtl$h0L3MPj@Ye`cWd zz|HkH+wLe$)m-@1(Z1bS-?g`YpOg_$%NF(qkU`@Tp2^;;Rs8I7=9Wql4!-o~y8#={ zrj5U#&RD}X_!yU{2^9V{vUHknj1#eC`|YZaVK$!S`5TVf1i5p37ig>`p7`TMQdyoI?yDA2}=>M9U8Z_O(MqFhsy>LFvu zNLzK-#mYuczpjbnOMER?Pu}^r7dh|euZ`K{ou`!@ph_!AVvCOUM0rFSSe0NE9WLb^ zQ6~X%Iw`!j6i(l?R^Ok^#&3qdH7OA@RTGSr5Y!`|fnFWx5fV&RR(S6@oQWCH_@t^l z5jx)$bHr-LaiO9>h-O(;myg2Pr_EI2CCT4mtvDqh6_^(ov6)?-B!SRWF+dwCPRxZ#K!f~;7660X`_ZWA?rQgEao{VV_q8U&O` zPdLmd1AG`?b}*vve06AaE4F&})G%TktII9JZ;9?f!TT?J!(PR48mdmeePq`A#fFcg zdSiRFIH%8^m{RJYltF*h35ozgM~6Eb%a1x50;+J(9lL5v$TX2TA2y~8yc9)n;MpIy zQ6H=kne$rKG>G?MC<04q$zhLh!777%+}R}kLq7@m!%%?RAQH)F96 zTH)V;)u$?rG)s%+qO3pDQda^fOB6W2rCc19 z1~)f)?9e+=WduyN@eUi^YChtqC#){2q$c4<--cjTmdlJ-j##NGu2328S2IeX{kne1 zfn$U4)MACJle}SXoFB>!q-tM+)#wQ-2$kVyc2`JID1Dg!MSZ0dHk3iwx4JX@Dy1%1 zV;J(r+sdo#&}pCT4Pj(j5Ralq{P@Mlap7s$eM-CV`9M%zI%hBO?dwIk8>cIF{c2vG!Yp^j%T-z?MP(e%XUgv__VV z0HS~)K{8b>N2v>Gfd~7&A{Ng1N9Z&+^x3l3fdkhiDUERm$Dfh~fG4`+6^X7_`*e9; z?=zNbXeUbT7ArR6A5c1#x&S*kQajxr!T3#bv9r_@KR_Pd1iz9OR1qh9Y}LT_lK$4A zy}galS#JtuHL1+J+hc}bm)v|8k$~s7ze1#{%$%IorYnf^&d-q1+iEhK+R(SJ^30wg zpz99*fId`bmW`pfXZABl$B}$d&DOBRlZ$@*%c z4lw6k<=-y|%nwO$10c?Gld3E0R9k0dornM!=2Ae*6vdo)9zw3ya|+GG?5zqQjHdW1 zipV@L&}C~_=`bd`rna~&ep=#rew~F!HGz7i^||yzlk+97d-fdx5TQYTRe>7o@+GWN zq@K#A^?L(P@;iT=8BhM@G9vwqhWh*y75h1QpN0r&OSshdyP%1TkL)|DdY$al7+dIt z+Gj1{so_m2o2OC<8vN#tnwkA=N1GqNUF+H2xs=ntVT+JFizRH=U9WH#_SA?P-mrV! z-78nH@(I?YJXSrMr|XO013dk3B5+$nHgvcGb^Z!H1rB zv;IO})Ec?o{AZ{_k4wc;DZ`caBAo}9L6aCwKcW6&D$2TK0vPu*$`7(;G znQH!6P~}PbV_-s|<&D4(W-gAG0&Ksz6ARo`dQt@MQe1wpLvE{@f;ZjB_8GF!%Lxgx zf8Rl=e)YWp;W`~#KI!v-^qrU-i-4o$LN?|0dVqC9Xe#dh1g=VD%h`3qJ%)(xY1RlW zo1Ib*j80gJ8Q=JI+lBgtb{=nKB_Nqwq0btW_(Go3RUe4`+{c=25352zH^{ACHh?Xg zC=)amI}q!-b6HliWUet-3s+03_0f$HCJ>d?`0%E0>zU6)H(0s4rDIPW$vi%-XAB7^ zw8%zC2R9rYO?? zB4Hn(4M3+Z;Mtb>rh+wiqGzk&8>I%-`!_&TQwunG{=SAv-WEL|g`t3E3mz&$+<5jk zWH%L~+%CN!@VsAhXX-EyrCADj(W)xMzlmn&yz%w#Il+$%!j7F(UkjXL*kPoJ#1!`t z-IR;v=d%VuCz>Ik5Mo3>u0Km3 zCAG<_AY^MqP3eXU-7(N}_hlxz@=~AbdiCeK*|MX;d4{})C_*rwD)ANskWe!>^qcY* zD&h-utbSse)<+tv+vYL!%Ug*>)3ik#PAhAZ{rV9I>^?!JvG$+>EZX&A!d(cF%fOV7 zNVBt$NY*aLYD~ckz(J^sH`;7^Twxf$5>0=;7oMm-XJ$ikXD=z#!42hz-*TIJsOzdb z*?K(4gQ#<>hh6HWHm%mG)4dtR6>s+~C>JlaW&^K`BCbpted{uzQT%iS3G`=#Cny|*N^3>-W*)7JoCF`zl;#ev zKj;_>|7>#TX0M-!m}o+9;Avo<{c|H7q=3r|K^Sh7jYJ_hbveZs8_K?GWIguSaFgNyszdDW!n+EGzu` zNDp%_3dr5@&agqwH*b1c`OG`{#+1Wo`>@R-0#mgp&psGRf#+(m~}wX`7=+bqmi?Y1i{^W(kVR;}RO&Ou@00@eEzxR~0wh3y)9C*kE4d9=i|G;| z>fc9WZL9=}PtDoW;6pwoqG?XEVXk=DS(p=m_@D||>|N{!s8T@1H!z!})}&j{V^WkF z-@2i_FhC}@I|UD=Q6~XrGi1>dGG>Lv@lvd~22qU&OHY44Y|vg&`PNhV61<^Yo}b{2 zte4u%hVz}CroLP~Mqu-I{v1W+Zwl`r2l7043xOcToCOg{|to9Fd?n7A3F zVNZuZ5%XXx=RB9W_Fgh)~C}hSwmYPJ+b`~`-9zBWCaAFARp$6VWA)x%OLK~fH?%rfiPVeYj(qY_ z6qBU{^x-Fv+%zF@b*vL literal 0 HcmV?d00001 diff --git a/examples/style/style.css b/examples/style/style.css new file mode 100644 index 0000000..b716ee6 --- /dev/null +++ b/examples/style/style.css @@ -0,0 +1,250 @@ +* { + margin: 0; + padding: 0; +} + +body { + font-family: "Lucida Sans Unicode", "Lucida Grande", sans-serif; + box-sizing: border-box; +} + +a { + text-decoration: none; + color: #000; + padding: 20px; +} + +#header { + padding: 15px; + display: flex; + flex-direction: row; + justify-content: space-between; + font-family: "Basier circle", -apple-system, system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + align-items: center; + background-color: #1a2a39; + color: rgb(255 255 255); + font-size: 12px; +} + +#logo { + height: 50px; +} + +.header-button { + align-items: center; + border-radius: 6px; + box-sizing: border-box; + color: #fff; + cursor: pointer; + font-family: Inter, sans-serif; + justify-content: center; + line-height: 1; + margin: 0; + outline: none; + padding: 7px 10px; + text-align: center; + text-decoration: none; + transition: box-shadow 0.2s, -webkit-box-shadow 0.2s; + white-space: nowrap; + border: 0; + user-select: none; + touch-action: manipulation; +} + +.header-button:hover { + box-shadow: #fff 0 0 0 3px, transparent 0 0 0 0; +} + +.selective-button { + background-color: #fff; + border: 0 solid #e2e8f0; + border-radius: 1.5rem; + box-sizing: border-box; + color: #0d172a; + cursor: pointer; + display: inline-block; + font-family: "Basier circle", "Noto Color Emoji"; + font-size: 10px; + font-weight: 600; + line-height: 1; + padding: 6px; + text-align: center; + text-decoration: none #0d172a solid; + text-decoration-thickness: auto; + transition: all 0.1s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 1px 2px rgb(166 175 195 / 25%); + user-select: none; + touch-action: manipulation; +} + +.selective-button:hover { + background-color: #1e293b; + color: #fff; +} + +#mid-section { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; +} + +#wallpaper { + width: 50%; +} + +#widget-info { + display: none; + text-align: right; + padding-right: 5%; + padding-bottom: 6px; + font-size: 12px; +} + +#center-column { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.widget-info-lines { + overflow-y: scroll; + width: 80%; + height: 2px; + scroll-behavior: auto; + background-color: rgb(217 217 217 / 80.3%); +} + +@media (min-width: 768px) { + .selective-button { + font-size: 14px; + padding: 7px 12px; + } + + #header { + font-size: 16px; + } +} + +#form-div { + display: none; + position: fixed; + z-index: 8; + width: 100%; + height: 100%; + overflow: auto; +} + +#form-container { + display: block; + padding: 1em; + width: 30%; + position: fixed; + left: 35%; + top: 0%; + box-shadow: 0 0 5px 5px rgb(0 0 0 / 20%); + background-color: white; + border-radius: 2%; +} + +#id-input { + border: 0.5px solid #adadad; + width: 90%; +} + +#id-input:focus-within { + border: 3px solid #5185f6; + outline-color: #5185f6; +} + +#accept-btn { + background-color: #3573ea; + border-radius: 10%; + border-color: white; + color: white; + padding: 10px 25px; + opacity: 1; + width: 80px; + position: relative; + right: 6px; +} + +#cancel-btn { + background-color: white; + border-radius: 10%; + border: 0.5px solid #adadad; + color: #3573ea; + padding: 10px 20px; + opacity: 1; + width: 80px; +} + +#check-box { + display: flex; + align-items: center; + padding: 12px 0 0; +} + +#accept-btn:hover { + opacity: 0.8; +} + +#cancel-btn:hover { + background-color: #ecf3fd; +} + +#form-btn { + display: flex; + float: right; +} + +#buttons { + display: grid; + justify-items: center; + grid-gap: 12px; +} + +#new-ratings { + width: max-content; + border: 1px solid #ac777f; + padding: 0.5rem; +} + +#widget-buttons { + display: flex; + gap: 5px; + align-items: stretch; + flex-wrap: wrap; +} + +#old-ratings { + width: 80%; + border: 1px solid #ac777f; + padding: 0.5rem; +} + +#miscellaneous-buttons { + width: 80%; + border: 1px solid #ac777f; + padding: 0.5rem; +} + +#widget-info-icon { + background-color: #30ae62; + color: white; + font-size: 19px; + cursor: pointer; + padding: 5px; + border-radius: 4px; + border: none; +} + +#widget-info-icon:hover { + background-color: #0f5e18; +} + +input { + padding: 8px; + font-size: 15px; +} diff --git a/examples/worker.js b/examples/worker.js new file mode 100644 index 0000000..49dcef5 --- /dev/null +++ b/examples/worker.js @@ -0,0 +1,35 @@ +import Countly from "../Countly.js"; + +const STORAGE = {}; +Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + debug: true, + storage: { + getItem: (key) => { + return STORAGE[key]; + }, + setItem: (key, value) => { + STORAGE[key] = value; + }, + removeItem: (key) => { + delete STORAGE[key]; + } + } +}); + +onmessage = function (e) { + console.log(`Worker: Message received from main script:[${JSON.stringify(e.data)}]`); + const data = e.data.data; const type = e.data.type; + if (type === "event") { + Countly.add_event(data); + } else if (type === "view") { + Countly.track_pageview(data); + } else if (type === "session") { + if (data === "begin_session") { + Countly.begin_session(); + return; + } + Countly.end_session(null, true); + } +} \ No newline at end of file diff --git a/modules/Constants.js b/modules/Constants.js new file mode 100644 index 0000000..bc17a16 --- /dev/null +++ b/modules/Constants.js @@ -0,0 +1,113 @@ + +// Feature ENUMS +var featureEnums = { + SESSIONS: "sessions", + EVENTS: "events", + VIEWS: "views", + SCROLLS: "scrolls", + CLICKS: "clicks", + FORMS: "forms", + CRASHES: "crashes", + ATTRIBUTION: "attribution", + USERS: "users", + STAR_RATING: "star-rating", + LOCATION: "location", + APM: "apm", + FEEDBACK: "feedback", + REMOTE_CONFIG: "remote-config", +}; + +/** + * At the current moment there are following internal events and their respective required consent: + [CLY]_nps - "feedback" consent + [CLY]_survey - "feedback" consent + [CLY]_star_rating - "star_rating" consent + [CLY]_view - "views" consent + [CLY]_orientation - "users" consent + [CLY]_push_action - "push" consent + [CLY]_action - "clicks" or "scroll" consent + */ +var internalEventKeyEnums = { + NPS: "[CLY]_nps", + SURVEY: "[CLY]_survey", + STAR_RATING: "[CLY]_star_rating", + VIEW: "[CLY]_view", + ORIENTATION: "[CLY]_orientation", + ACTION: "[CLY]_action", +}; + +var internalEventKeyEnumsArray = Object.values(internalEventKeyEnums); +/** + * + *log level Enums: + *Error - this is a issues that needs attention right now. + *Warning - this is something that is potentially a issue. Maybe a deprecated usage of something, maybe consent is enabled but consent is not given. + *Info - All publicly exposed functions should log a call at this level to indicate that they were called. These calls should include the function name. + *Debug - this should contain logs from the internal workings of the SDK and it's important calls. This should include things like the SDK configuration options, success or fail of the current network request, "request queue is full" and the oldest request get's dropped, etc. + *Verbose - this should give a even deeper look into the SDK's inner working and should contain things that are more noisy and happen often. + */ +var logLevelEnums = { + ERROR: "[ERROR] ", + WARNING: "[WARNING] ", + INFO: "[INFO] ", + DEBUG: "[DEBUG] ", + VERBOSE: "[VERBOSE] ", +}; +/** + * + *device ID type: + *0 - device ID was set by the developer during init + *1 - device ID was auto generated by Countly + *2 - device ID was temporarily given by Countly + *3 - device ID was provided from location.search + */ +var DeviceIdTypeInternalEnums = { + DEVELOPER_SUPPLIED: 0, + SDK_GENERATED: 1, + TEMPORARY_ID: 2, + URL_PROVIDED: 3, +}; +/** + * to be used as a default value for certain configuration key values + */ +var configurationDefaultValues = { + BEAT_INTERVAL: 500, + QUEUE_SIZE: 1000, + FAIL_TIMEOUT_AMOUNT: 60, + INACTIVITY_TIME: 20, + SESSION_UPDATE: 60, + MAX_EVENT_BATCH: 100, + SESSION_COOKIE_TIMEOUT: 30, + MAX_KEY_LENGTH: 128, + MAX_VALUE_SIZE: 256, + MAX_SEGMENTATION_VALUES: 100, + MAX_BREADCRUMB_COUNT: 100, + MAX_STACKTRACE_LINES_PER_THREAD: 30, + MAX_STACKTRACE_LINE_LENGTH: 200, +}; + +/** + * BoomerangJS and countly + */ +var CDN = { + BOOMERANG_SRC: "https://cdn.jsdelivr.net/npm/countly-sdk-web@latest/plugin/boomerang/boomerang.min.js", + CLY_BOOMERANG_SRC: "https://cdn.jsdelivr.net/npm/countly-sdk-web@latest/plugin/boomerang/countly_boomerang.js", +}; + +/** + * Health check counters' local storage keys + */ +var healthCheckCounterEnum = Object.freeze({ + errorCount: "cly_hc_error_count", + warningCount: "cly_hc_warning_count", + statusCode: "cly_hc_status_code", + errorMessage: "cly_hc_error_message", +}); + +var SDK_VERSION = "23.6.3"; +var SDK_NAME = "javascript_native_web"; + +// Using this on document.referrer would return an array with 15 elements in it. The 12th element (array[11]) would be the path we are looking for. Others would be things like password and such (use https://regex101.com/ to check more) +var urlParseRE = /^(((([^:\/#\?]+:)?(?:(\/\/)((?:(([^:@\/#\?]+)(?:\:([^:@\/#\?]+))?)@)?(([^:\/#\?\]\[]+|\[[^\/\]@#?]+\])(?:\:([0-9]+))?))?)?)?((\/?(?:[^\/\?#]+\/+)*)([^\?#]*)))?(\?[^#]+)?)(#.*)?/; + +export { CDN, DeviceIdTypeInternalEnums, SDK_NAME, SDK_VERSION, configurationDefaultValues, featureEnums, healthCheckCounterEnum, internalEventKeyEnums, internalEventKeyEnumsArray, logLevelEnums, urlParseRE }; \ No newline at end of file diff --git a/modules/CountlyClass.js b/modules/CountlyClass.js new file mode 100644 index 0000000..3dea2cf --- /dev/null +++ b/modules/CountlyClass.js @@ -0,0 +1,4733 @@ + + +import { DeviceIdTypeInternalEnums, SDK_NAME, SDK_VERSION, configurationDefaultValues, featureEnums, healthCheckCounterEnum, internalEventKeyEnums, internalEventKeyEnumsArray, logLevelEnums, urlParseRE } from "./Constants.js"; +import { + getMultiSelectValues, + secureRandom, + generateUUID, + getTimestamp, + getMsTimestamp, + getConfig, + dispatchErrors, + prepareParams, + stripTrailingSlash, + createNewObjectFromProperties, + addNewProperties, + truncateObject, + truncateSingleValue, + get_closest_element, + add_event_listener, + get_event_target, + currentUserAgentString, + userAgentDeviceDetection, + userAgentSearchBotDetection, + get_page_coord, + getDocHeight, + getDocWidth, + getViewportHeight, + getOrientation, + loadJS, + loadCSS, + showLoader, + checkIfLoggingIsOn, + hideLoader +} from "./Utils.js"; +import { isBrowser, Countly } from "./Platform.js"; + +class CountlyClass { + constructor(ob) { + var self = this; + var global = !Countly.i; + var sessionStarted = false; + var apiPath = "/i"; + var readPath = "/o/sdk"; + var beatInterval = getConfig("interval", ob, configurationDefaultValues.BEAT_INTERVAL); + var queueSize = getConfig("queue_size", ob, configurationDefaultValues.QUEUE_SIZE); + var requestQueue = []; + var eventQueue = []; + var remoteConfigs = {}; + var crashLogs = []; + var timedEvents = {}; + var ignoreReferrers = getConfig("ignore_referrers", ob, []); + var crashSegments = null; + var autoExtend = true; + var lastBeat; + var storedDuration = 0; + var lastView = null; + var lastViewTime = 0; + var lastViewStoredDuration = 0; + var failTimeout = 0; + var failTimeoutAmount = getConfig("fail_timeout", ob, configurationDefaultValues.FAIL_TIMEOUT_AMOUNT); + var inactivityTime = getConfig("inactivity_time", ob, configurationDefaultValues.INACTIVITY_TIME); + var inactivityCounter = 0; + var sessionUpdate = getConfig("session_update", ob, configurationDefaultValues.SESSION_UPDATE); + var maxEventBatch = getConfig("max_events", ob, configurationDefaultValues.MAX_EVENT_BATCH); + var maxCrashLogs = getConfig("max_logs", ob, null); + var useSessionCookie = getConfig("use_session_cookie", ob, true); + var sessionCookieTimeout = getConfig("session_cookie_timeout", ob, configurationDefaultValues.SESSION_COOKIE_TIMEOUT); + var readyToProcess = true; + var hasPulse = false; + var offlineMode = getConfig("offline_mode", ob, false); + var lastParams = {}; + var trackTime = true; + var startTime = getTimestamp(); + var lsSupport = true; + var firstView = null; + var deviceIdType = DeviceIdTypeInternalEnums.SDK_GENERATED; + var isScrollRegistryOpen = false; + var scrollRegistryTopPosition = 0; + var trackingScrolls = false; + var currentViewId = null; // this is the global variable for tracking the current view's ID. Used in view tracking. Becomes previous view ID at the end. + var previousViewId = null; // this is the global variable for tracking the previous view's ID. Used in view tracking. First view has no previous view ID. + var freshUTMTags = null; + + try { + localStorage.setItem("cly_testLocal", true); + // clean up test + localStorage.removeItem("cly_testLocal"); + } + catch (e) { + log(logLevelEnums.ERROR, "Local storage test failed, Halting local storage support: " + e); + lsSupport = false; + } + + // create object to store consents + var consents = {}; + for (var it = 0; it < Countly.features.length; it++) { + consents[Countly.features[it]] = {}; + } + + this.initialize = function () { + this.serialize = getConfig("serialize", ob, Countly.serialize); + this.deserialize = getConfig("deserialize", ob, Countly.deserialize); + this.getViewName = getConfig("getViewName", ob, Countly.getViewName); + this.getViewUrl = getConfig("getViewUrl", ob, Countly.getViewUrl); + this.getSearchQuery = getConfig("getSearchQuery", ob, Countly.getSearchQuery); + this.DeviceIdType = Countly.DeviceIdType; // it is Countly device Id type Enums for clients to use + this.namespace = getConfig("namespace", ob, ""); + this.clearStoredId = !isBrowser ? undefined : getConfig("clear_stored_id", ob, false); + this.app_key = getConfig("app_key", ob, null); + this.onload = getConfig("onload", ob, []); + this.utm = getConfig("utm", ob, { source: true, medium: true, campaign: true, term: true, content: true }); + this.ignore_prefetch = getConfig("ignore_prefetch", ob, true); + this.rcAutoOptinAb = getConfig("rc_automatic_optin_for_ab", ob, true); + this.useExplicitRcApi = getConfig("use_explicit_rc_api", ob, false); + this.debug = getConfig("debug", ob, false); + this.test_mode = getConfig("test_mode", ob, false); + this.test_mode_eq = getConfig("test_mode_eq", ob, false); + this.metrics = getConfig("metrics", ob, {}); + this.headers = getConfig("headers", ob, {}); + this.url = stripTrailingSlash(getConfig("url", ob, "")); + this.app_version = getConfig("app_version", ob, "0.0"); + this.country_code = getConfig("country_code", ob, null); + this.city = getConfig("city", ob, null); + this.ip_address = getConfig("ip_address", ob, null); + this.ignore_bots = !isBrowser ? undefined : getConfig("ignore_bots", ob, true); + this.force_post = getConfig("force_post", ob, false); + this.remote_config = getConfig("remote_config", ob, false); + this.ignore_visitor = getConfig("ignore_visitor", ob, false); + this.require_consent = getConfig("require_consent", ob, false); + this.track_domains = !isBrowser ? undefined : getConfig("track_domains", ob, true); + this.storage = getConfig("storage", ob, "default"); + this.enableOrientationTracking = !isBrowser ? undefined : getConfig("enable_orientation_tracking", ob, true); + this.maxKeyLength = getConfig("max_key_length", ob, configurationDefaultValues.MAX_KEY_LENGTH); + this.maxValueSize = getConfig("max_value_size", ob, configurationDefaultValues.MAX_VALUE_SIZE); + this.maxSegmentationValues = getConfig("max_segmentation_values", ob, configurationDefaultValues.MAX_SEGMENTATION_VALUES); + this.maxBreadcrumbCount = getConfig("max_breadcrumb_count", ob, null); + this.maxStackTraceLinesPerThread = getConfig("max_stack_trace_lines_per_thread", ob, configurationDefaultValues.MAX_STACKTRACE_LINES_PER_THREAD); + this.maxStackTraceLineLength = getConfig("max_stack_trace_line_length", ob, configurationDefaultValues.MAX_STACKTRACE_LINE_LENGTH); + this.heatmapWhitelist = getConfig("heatmap_whitelist", ob, []); + self.hcErrorCount = getValueFromStorage(healthCheckCounterEnum.errorCount) || 0; + self.hcWarningCount = getValueFromStorage(healthCheckCounterEnum.warningCount) || 0; + self.hcStatusCode = getValueFromStorage(healthCheckCounterEnum.statusCode) || -1; + self.hcErrorMessage = getValueFromStorage(healthCheckCounterEnum.errorMessage) || ""; + + if (maxCrashLogs && !this.maxBreadcrumbCount) { + this.maxBreadcrumbCount = maxCrashLogs; + log(logLevelEnums.WARNING, "initialize, 'maxCrashLogs' is deprecated. Use 'maxBreadcrumbCount' instead!"); + } + else if (!maxCrashLogs && !this.maxBreadcrumbCount) { + this.maxBreadcrumbCount = 100; + } + + if (this.storage === "cookie") { + lsSupport = false; + } + + if (!this.rcAutoOptinAb && !this.useExplicitRcApi) { + log(logLevelEnums.WARNING, "initialize, Auto opting is disabled, switching to explicit RC API"); + this.useExplicitRcApi = true; + } + + if (!Array.isArray(ignoreReferrers)) { + ignoreReferrers = []; + } + + if (this.url === "") { + log(logLevelEnums.ERROR, "initialize, Please provide server URL"); + this.ignore_visitor = true; + } + if (getValueFromStorage("cly_ignore")) { + // opted out user + this.ignore_visitor = true; + } + + migrate(); + + requestQueue = getValueFromStorage("cly_queue") || []; + eventQueue = getValueFromStorage("cly_event") || []; + remoteConfigs = getValueFromStorage("cly_remote_configs") || {}; + + if (this.clearStoredId) { + // retrieve stored device ID and type from local storage and use it to flush existing events + if (getValueFromStorage("cly_id") && !tempIdModeWasEnabled) { + this.device_id = getValueFromStorage("cly_id"); + log(logLevelEnums.DEBUG, "initialize, temporarily using the previous device ID to flush existing events"); + deviceIdType = getValueFromStorage("cly_id_type"); + if (!deviceIdType) { + log(logLevelEnums.DEBUG, "initialize, No device ID type info from the previous session, falling back to DEVELOPER_SUPPLIED, for event flushing"); + deviceIdType = DeviceIdTypeInternalEnums.DEVELOPER_SUPPLIED; + } + sendEventsForced(); + // set them back to their initial values + this.device_id = undefined; + deviceIdType = DeviceIdTypeInternalEnums.SDK_GENERATED; + } + // then clear the storage so that a new device ID is set again later + log(logLevelEnums.INFO, "initialize, Clearing the device ID storage"); + localStorage.removeItem(this.app_key + "/cly_id"); + localStorage.removeItem(this.app_key + "/cly_id_type"); + localStorage.removeItem(this.app_key + "/cly_session"); + } + + checkIgnore(); + + if (isBrowser) { + if (window.name && window.name.indexOf("cly:") === 0) { + try { + this.passed_data = JSON.parse(window.name.replace("cly:", "")); + } + catch (ex) { + log(logLevelEnums.ERROR, "initialize, Could not parse name: " + window.name + ", error: " + ex); + } + } + else if (location.hash && location.hash.indexOf("#cly:") === 0) { + try { + this.passed_data = JSON.parse(location.hash.replace("#cly:", "")); + } + catch (ex) { + log(logLevelEnums.ERROR, "initialize, Could not parse hash: " + location.hash + ", error: " + ex); + } + } + } + + if ((this.passed_data && this.passed_data.app_key && this.passed_data.app_key === this.app_key) || (this.passed_data && !this.passed_data.app_key && global)) { + if (this.passed_data.token && this.passed_data.purpose) { + if (this.passed_data.token !== getValueFromStorage("cly_old_token")) { + setToken(this.passed_data.token); + setValueInStorage("cly_old_token", this.passed_data.token); + } + var strippedList = []; + // if whitelist is provided is an array + if (Array.isArray(this.heatmapWhitelist)) { + this.heatmapWhitelist.push(this.url); + strippedList = this.heatmapWhitelist.map(function (e) { + // remove trailing slashes from the entries + return stripTrailingSlash(e); + }); + } + else { + strippedList = [this.url]; + } + // if the passed url is in the whitelist proceed + if (strippedList.includes(this.passed_data.url)) { + if (this.passed_data.purpose === "heatmap") { + this.ignore_visitor = true; + showLoader(); + loadJS(this.passed_data.url + "/views/heatmap.js", hideLoader); + } + } + } + } + + if (this.ignore_visitor) { + log(logLevelEnums.WARNING, "initialize, ignore_visitor:[" + this.ignore_visitor + "], this user will not be tracked"); + return; + } + + // init configuration is printed out here: + // key values should be printed out as is + log(logLevelEnums.DEBUG, "initialize, app_key:[" + this.app_key + "], url:[" + this.url + "]"); + log(logLevelEnums.DEBUG, "initialize, device_id:[" + getConfig("device_id", ob, undefined) + "]"); + log(logLevelEnums.DEBUG, "initialize, require_consent is enabled:[" + this.require_consent + "]"); + try { + log(logLevelEnums.DEBUG, "initialize, metric override:[" + JSON.stringify(this.metrics) + "]"); + log(logLevelEnums.DEBUG, "initialize, header override:[" + JSON.stringify(this.headers) + "]"); + // empty array is truthy and so would be printed if provided + log(logLevelEnums.DEBUG, "initialize, number of onload callbacks provided:[" + this.onload.length + "]"); + // if the utm object is different to default utm object print it here + log(logLevelEnums.DEBUG, "initialize, utm tags:[" + JSON.stringify(this.utm) + "]"); + // empty array printed if non provided + if (ignoreReferrers) { + log(logLevelEnums.DEBUG, "initialize, referrers to ignore :[" + JSON.stringify(ignoreReferrers) + "]"); + } + } + catch (e) { + log(logLevelEnums.ERROR, "initialize, Could not stringify some config object values"); + } + log(logLevelEnums.DEBUG, "initialize, app_version:[" + this.app_version + "]"); + + // location info printed here + log(logLevelEnums.DEBUG, "initialize, provided location info; country_code:[" + this.country_code + "], city:[" + this.city + "], ip_address:[" + this.ip_address + "]"); + + // print non vital values only if provided by the developer or differs from the default value + if (this.namespace !== "") { + log(logLevelEnums.DEBUG, "initialize, namespace given:[" + this.namespace + "]"); + } + if (this.clearStoredId) { + log(logLevelEnums.DEBUG, "initialize, clearStoredId flag set to:[" + this.clearStoredId + "]"); + } + if (this.ignore_prefetch) { + log(logLevelEnums.DEBUG, "initialize, ignoring pre-fetching and pre-rendering from counting as real website visits :[" + this.ignore_prefetch + "]"); + } + // if test mode is enabled warn the user + if (this.test_mode) { + log(logLevelEnums.WARNING, "initialize, test_mode:[" + this.test_mode + "], request queue won't be processed"); + } + if (this.test_mode_eq) { + log(logLevelEnums.WARNING, "initialize, test_mode_eq:[" + this.test_mode_eq + "], event queue won't be processed"); + } + // if test mode is enabled warn the user + if (this.heatmapWhitelist) { + log(logLevelEnums.DEBUG, "initialize, heatmap whitelist:[" + JSON.stringify(this.heatmapWhitelist) + "], these domains will be whitelisted"); + } + // if storage is se to something other than local storage + if (this.storage !== "default") { + log(logLevelEnums.DEBUG, "initialize, storage is set to:[" + this.storage + "]"); + } + if (this.ignore_bots) { + log(logLevelEnums.DEBUG, "initialize, ignore traffic from bots :[" + this.ignore_bots + "]"); + } + if (this.force_post) { + log(logLevelEnums.DEBUG, "initialize, forced post method for all requests:[" + this.force_post + "]"); + } + if (this.remote_config) { + log(logLevelEnums.DEBUG, "initialize, remote_config callback provided:[" + !!this.remote_config + "]"); + } + if (typeof this.rcAutoOptinAb === "boolean") { + log(logLevelEnums.DEBUG, "initialize, automatic RC optin is enabled:[" + this.rcAutoOptinAb + "]"); + } + if (!this.useExplicitRcApi) { + log(logLevelEnums.WARNING, "initialize, will use legacy RC API. Consider enabling new API during init with use_explicit_rc_api flag"); + } + if (this.track_domains) { + log(logLevelEnums.DEBUG, "initialize, tracking domain info:[" + this.track_domains + "]"); + } + if (this.enableOrientationTracking) { + log(logLevelEnums.DEBUG, "initialize, enableOrientationTracking:[" + this.enableOrientationTracking + "]"); + } + if (!useSessionCookie) { + log(logLevelEnums.WARNING, "initialize, use_session_cookie is enabled:[" + useSessionCookie + "]"); + } + if (offlineMode) { + log(logLevelEnums.DEBUG, "initialize, offline_mode:[" + offlineMode + "], user info won't be send to the servers"); + } + if (offlineMode) { + log(logLevelEnums.DEBUG, "initialize, stored remote configs:[" + JSON.stringify(remoteConfigs) + "]"); + } + // functions, if provided, would be printed as true without revealing their content + log(logLevelEnums.DEBUG, "initialize, 'getViewName' callback override provided:[" + (this.getViewName !== Countly.getViewName) + "]"); + log(logLevelEnums.DEBUG, "initialize, 'getSearchQuery' callback override provided:[" + (this.getSearchQuery !== Countly.getSearchQuery) + "]"); + + // limits are printed here if they were modified + if (this.maxKeyLength !== configurationDefaultValues.MAX_KEY_LENGTH) { + log(logLevelEnums.DEBUG, "initialize, maxKeyLength set to:[" + this.maxKeyLength + "] characters"); + } + if (this.maxValueSize !== configurationDefaultValues.MAX_VALUE_SIZE) { + log(logLevelEnums.DEBUG, "initialize, maxValueSize set to:[" + this.maxValueSize + "] characters"); + } + if (this.maxSegmentationValues !== configurationDefaultValues.MAX_SEGMENTATION_VALUES) { + log(logLevelEnums.DEBUG, "initialize, maxSegmentationValues set to:[" + this.maxSegmentationValues + "] key/value pairs"); + } + if (this.maxBreadcrumbCount !== configurationDefaultValues.MAX_BREADCRUMB_COUNT) { + log(logLevelEnums.DEBUG, "initialize, maxBreadcrumbCount for custom logs set to:[" + this.maxBreadcrumbCount + "] entries"); + } + if (this.maxStackTraceLinesPerThread !== configurationDefaultValues.MAX_STACKTRACE_LINES_PER_THREAD) { + log(logLevelEnums.DEBUG, "initialize, maxStackTraceLinesPerThread set to:[" + this.maxStackTraceLinesPerThread + "] lines"); + } + if (this.maxStackTraceLineLength !== configurationDefaultValues.MAX_STACKTRACE_LINE_LENGTH) { + log(logLevelEnums.DEBUG, "initialize, maxStackTraceLineLength set to:[" + this.maxStackTraceLineLength + "] characters"); + } + if (beatInterval !== configurationDefaultValues.BEAT_INTERVAL) { + log(logLevelEnums.DEBUG, "initialize, interval for heartbeats set to:[" + beatInterval + "] milliseconds"); + } + if (queueSize !== configurationDefaultValues.QUEUE_SIZE) { + log(logLevelEnums.DEBUG, "initialize, queue_size set to:[" + queueSize + "] items max"); + } + if (failTimeoutAmount !== configurationDefaultValues.FAIL_TIMEOUT_AMOUNT) { + log(logLevelEnums.DEBUG, "initialize, fail_timeout set to:[" + failTimeoutAmount + "] seconds of wait time after a failed connection to server"); + } + if (inactivityTime !== configurationDefaultValues.INACTIVITY_TIME) { + log(logLevelEnums.DEBUG, "initialize, inactivity_time set to:[" + inactivityTime + "] minutes to consider a user as inactive after no observable action"); + } + if (sessionUpdate !== configurationDefaultValues.SESSION_UPDATE) { + log(logLevelEnums.DEBUG, "initialize, session_update set to:[" + sessionUpdate + "] seconds to check if extending a session is needed while the user is active"); + } + if (maxEventBatch !== configurationDefaultValues.MAX_EVENT_BATCH) { + log(logLevelEnums.DEBUG, "initialize, max_events set to:[" + maxEventBatch + "] events to send in one batch"); + } + if (maxCrashLogs) { + log(logLevelEnums.WARNING, "initialize, max_logs set to:[" + maxCrashLogs + "] breadcrumbs to store for crash logs max, deprecated "); + } + if (sessionCookieTimeout !== configurationDefaultValues.SESSION_COOKIE_TIMEOUT) { + log(logLevelEnums.DEBUG, "initialize, session_cookie_timeout set to:[" + sessionCookieTimeout + "] minutes to expire a cookies session"); + } + + log(logLevelEnums.INFO, "initialize, Countly initialized"); + + var deviceIdParamValue = null; + var searchQuery = self.getSearchQuery(); + var hasUTM = false; + var utms = {}; + if (searchQuery) { + var parts = searchQuery.substring(1).split("&"); + for (var i = 0; i < parts.length; i++) { + var nv = parts[i].split("="); + if (nv[0] === "cly_id") { + setValueInStorage("cly_cmp_id", nv[1]); + } + else if (nv[0] === "cly_uid") { + setValueInStorage("cly_cmp_uid", nv[1]); + } + else if (nv[0] === "cly_device_id") { + deviceIdParamValue = nv[1]; + } + else if ((nv[0] + "").indexOf("utm_") === 0 && this.utm[nv[0].replace("utm_", "")]) { + utms[nv[0].replace("utm_", "")] = nv[1]; + hasUTM = true; + } + } + } + + // flag that indicates that the offline mode was enabled at the end of the previous app session + var tempIdModeWasEnabled = (getValueFromStorage("cly_id") === "[CLY]_temp_id"); + var developerSetDeviceId = getConfig("device_id", ob, undefined); + if (typeof developerSetDeviceId === "number") { // device ID should always be string + developerSetDeviceId = developerSetDeviceId.toString(); + } + + // check if there wqs stored ID + if (getValueFromStorage("cly_id") && !tempIdModeWasEnabled) { + this.device_id = getValueFromStorage("cly_id"); + log(logLevelEnums.INFO, "initialize, Set the stored device ID"); + deviceIdType = getValueFromStorage("cly_id_type"); + if (!deviceIdType) { + log(logLevelEnums.INFO, "initialize, No device ID type info from the previous session, falling back to DEVELOPER_SUPPLIED"); + // there is a device ID saved but there is no device ID information saved + deviceIdType = DeviceIdTypeInternalEnums.DEVELOPER_SUPPLIED; + } + } + // if not check if device ID was provided with URL + else if (deviceIdParamValue !== null) { + log(logLevelEnums.INFO, "initialize, Device ID set by URL"); + this.device_id = deviceIdParamValue; + deviceIdType = DeviceIdTypeInternalEnums.URL_PROVIDED; + } + // if not check if developer provided any ID + else if (developerSetDeviceId) { + log(logLevelEnums.INFO, "initialize, Device ID set by developer"); + this.device_id = developerSetDeviceId; + if (ob && Object.keys(ob).length) { + if (ob.device_id !== undefined) { + deviceIdType = DeviceIdTypeInternalEnums.DEVELOPER_SUPPLIED; + } + } + else if (Countly.device_id !== undefined) { + deviceIdType = DeviceIdTypeInternalEnums.DEVELOPER_SUPPLIED; + } + } + // if not check if offline mode is on + else if (offlineMode || tempIdModeWasEnabled) { + this.device_id = "[CLY]_temp_id"; + deviceIdType = DeviceIdTypeInternalEnums.TEMPORARY_ID; + if (offlineMode && tempIdModeWasEnabled) { + log(logLevelEnums.INFO, "initialize, Temp ID set, continuing offline mode from previous app session"); + } + else if (offlineMode && !tempIdModeWasEnabled) { + // this if we get here then it means either first init we enter offline mode or we cleared the device ID during the init and still user entered the offline mode + log(logLevelEnums.INFO, "initialize, Temp ID set, entering offline mode"); + } + else { + // no device ID was provided, no offline mode flag was provided, in the previous app session we entered offline mode and now we carry on + offlineMode = true; + log(logLevelEnums.INFO, "initialize, Temp ID set, enabling offline mode"); + } + } + // if all fails generate an ID + else { + log(logLevelEnums.INFO, "initialize, Generating the device ID"); + this.device_id = getConfig("device_id", ob, getStoredIdOrGenerateId()); + if (ob && Object.keys(ob).length) { + if (ob.device_id !== undefined) { + deviceIdType = DeviceIdTypeInternalEnums.DEVELOPER_SUPPLIED; + } + } + else if (Countly.device_id !== undefined) { + deviceIdType = DeviceIdTypeInternalEnums.DEVELOPER_SUPPLIED; + } + } + + // Store the device ID and device ID type + setValueInStorage("cly_id", this.device_id); + setValueInStorage("cly_id_type", deviceIdType); + + // as we have assigned the device ID now we can save the tags + if (hasUTM) { + freshUTMTags = {}; + for (var tag in this.utm) { // this.utm is a filter for allowed tags + if (utms[tag]) { // utms is the tags that were passed in the URL + this.userData.set("utm_" + tag, utms[tag]); + freshUTMTags[tag] = utms[tag]; + } + else { + this.userData.unset("utm_" + tag); + } + } + this.userData.save(); + } + + notifyLoaders(); + + setTimeout(function () { + heartBeat(); + if (self.remote_config) { + self.fetch_remote_config(self.remote_config); + } + }, 1); + if (isBrowser) { + document.documentElement.setAttribute("data-countly-useragent", currentUserAgentString()); + } + // send instant health check request + HealthCheck.sendInstantHCRequest(); + }; + + /** + * WARNING!!! + * Should be used only for testing purposes!!! + * + * Resets Countly to its initial state (used mainly to wipe the queues in memory). + * Calling this will result in a loss of data + */ + this.halt = function () { + log(logLevelEnums.WARNING, "halt, Resetting Countly"); + Countly.i = undefined; + global = !Countly.i; + sessionStarted = false; + apiPath = "/i"; + readPath = "/o/sdk"; + beatInterval = 500; + queueSize = 1000; + requestQueue = []; + eventQueue = []; + remoteConfigs = {}; + crashLogs = []; + timedEvents = {}; + ignoreReferrers = []; + crashSegments = null; + autoExtend = true; + storedDuration = 0; + lastView = null; + lastViewTime = 0; + lastViewStoredDuration = 0; + failTimeout = 0; + failTimeoutAmount = 60; + inactivityTime = 20; + inactivityCounter = 0; + sessionUpdate = 60; + maxEventBatch = 100; + maxCrashLogs = null; + useSessionCookie = true; + sessionCookieTimeout = 30; + readyToProcess = true; + hasPulse = false; + offlineMode = false; + lastParams = {}; + trackTime = true; + startTime = getTimestamp(); + lsSupport = true; + firstView = null; + deviceIdType = DeviceIdTypeInternalEnums.SDK_GENERATED; + isScrollRegistryOpen = false; + scrollRegistryTopPosition = 0; + trackingScrolls = false; + currentViewId = null; + previousViewId = null; + freshUTMTags = null; + + try { + localStorage.setItem("cly_testLocal", true); + // clean up test + localStorage.removeItem("cly_testLocal"); + } + catch (e) { + log(logLevelEnums.ERROR, "halt, Local storage test failed, will fallback to cookies"); + lsSupport = false; + } + + Countly.features = [featureEnums.SESSIONS, featureEnums.EVENTS, featureEnums.VIEWS, featureEnums.SCROLLS, featureEnums.CLICKS, featureEnums.FORMS, featureEnums.CRASHES, featureEnums.ATTRIBUTION, featureEnums.USERS, featureEnums.STAR_RATING, featureEnums.LOCATION, featureEnums.APM, featureEnums.FEEDBACK, featureEnums.REMOTE_CONFIG]; + + // CONSENTS + consents = {}; + for (var a = 0; a < Countly.features.length; a++) { + consents[Countly.features[a]] = {}; + } + + self.app_key = undefined; + self.device_id = undefined; + self.onload = undefined; + self.utm = undefined; + self.ignore_prefetch = undefined; + self.debug = undefined; + self.test_mode = undefined; + self.test_mode_eq = undefined; + self.metrics = undefined; + self.headers = undefined; + self.url = undefined; + self.app_version = undefined; + self.country_code = undefined; + self.city = undefined; + self.ip_address = undefined; + self.ignore_bots = undefined; + self.force_post = undefined; + self.rcAutoOptinAb = undefined; + self.useExplicitRcApi = undefined; + self.remote_config = undefined; + self.ignore_visitor = undefined; + self.require_consent = undefined; + self.track_domains = undefined; + self.storage = undefined; + self.enableOrientationTracking = undefined; + self.maxKeyLength = undefined; + self.maxValueSize = undefined; + self.maxSegmentationValues = undefined; + self.maxBreadcrumbCount = undefined; + self.maxStackTraceLinesPerThread = undefined; + self.maxStackTraceLineLength = undefined; + }; + + /** + * Modify feature groups for consent management. Allows you to group multiple features under one feature group + * @param {object} features - object to define feature name as key and core features as value + * @example Adding all features under one group + * Countly.group_features({all:["sessions","events","views","scrolls","clicks","forms","crashes","attribution","users"]}); + * //After this call Countly.add_consent("all") to allow all features + @example Grouping features + * Countly.group_features({ + * activity:["sessions","events","views"], + * interaction:["scrolls","clicks","forms"] + * }); + * //After this call Countly.add_consent("activity") to allow "sessions","events","views" + * //or call Countly.add_consent("interaction") to allow "scrolls","clicks","forms" + * //or call Countly.add_consent("crashes") to allow some separate feature + */ + this.group_features = function (features) { + log(logLevelEnums.INFO, "group_features, Grouping features"); + if (features) { + for (var i in features) { + if (!consents[i]) { + if (typeof features[i] === "string") { + consents[i] = { features: [features[i]] }; + } + else if (features[i] && Array.isArray(features[i]) && features[i].length) { + consents[i] = { features: features[i] }; + } + else { + log(logLevelEnums.ERROR, "group_features, Incorrect feature list for [" + i + "] value: [" + features[i] + "]"); + } + } + else { + log(logLevelEnums.WARNING, "group_features, Feature name [" + i + "] is already reserved"); + } + } + } + else { + log(logLevelEnums.ERROR, "group_features, Incorrect features:[" + features + "]"); + } + }; + + /** + * Check if consent is given for specific feature (either core feature of from custom feature group) + * @param {string} feature - name of the feature, possible values, "sessions","events","views","scrolls","clicks","forms","crashes","attribution","users" or custom provided through {@link Countly.group_features} + * @returns {Boolean} true if consent was given for the feature or false if it was not + */ + this.check_consent = function (feature) { + log(logLevelEnums.INFO, "check_consent, Checking if consent is given for specific feature:[" + feature + "]"); + if (!this.require_consent) { + // we don't need to have specific consents + log(logLevelEnums.INFO, "check_consent, require_consent is off, no consent is necessary"); + return true; + } + if (consents[feature]) { + return !!(consents[feature] && consents[feature].optin); + } + log(logLevelEnums.ERROR, "check_consent, No feature available for [" + feature + "]"); + return false; + }; + + /** + * Check and return the current device id type + * @returns {number} a number that indicates the device id type + */ + this.get_device_id_type = function () { + log(logLevelEnums.INFO, "check_device_id_type, Retrieving the current device id type.[" + deviceIdType + "]"); + var type; + switch (deviceIdType) { + case DeviceIdTypeInternalEnums.SDK_GENERATED: + type = self.DeviceIdType.SDK_GENERATED; + break; + case DeviceIdTypeInternalEnums.URL_PROVIDED: + case DeviceIdTypeInternalEnums.DEVELOPER_SUPPLIED: + type = self.DeviceIdType.DEVELOPER_SUPPLIED; + break; + case DeviceIdTypeInternalEnums.TEMPORARY_ID: + type = self.DeviceIdType.TEMPORARY_ID; + break; + default: + type = -1; + break; + } + return type; + }; + + /** + * Gets the current device id (of the CountlyClass instance) + * @returns {string} device id + */ + this.get_device_id = function () { + log(logLevelEnums.INFO, "get_device_id, Retrieving the device id: [" + self.device_id + "]"); + return self.device_id; + }; + + /** + * Check if any consent is given, for some cases, when crucial parts are like device_id are needed for any request + * @returns {Boolean} true is has any consent given, false if no consents given + */ + this.check_any_consent = function () { + log(logLevelEnums.INFO, "check_any_consent, Checking if any consent is given"); + if (!this.require_consent) { + // we don't need to have consents + log(logLevelEnums.INFO, "check_any_consent, require_consent is off, no consent is necessary"); + return true; + } + for (var i in consents) { + if (consents[i] && consents[i].optin) { + return true; + } + } + log(logLevelEnums.INFO, "check_any_consent, No consents given"); + return false; + }; + + /** + * Add consent for specific feature, meaning, user allowed to track that data (either core feature of from custom feature group) + * @param {string|array} feature - name of the feature, possible values, "sessions","events","views","scrolls","clicks","forms","crashes","attribution","users", etc or custom provided through {@link Countly.group_features} + */ + this.add_consent = function (feature) { + log(logLevelEnums.INFO, "add_consent, Adding consent for [" + feature + "]"); + if (Array.isArray(feature)) { + for (var i = 0; i < feature.length; i++) { + this.add_consent(feature[i]); + } + } + else if (consents[feature]) { + if (consents[feature].features) { + consents[feature].optin = true; + // this is added group, let's iterate through sub features + this.add_consent(consents[feature].features); + } + else { + // this is core feature + if (consents[feature].optin !== true) { + consents[feature].optin = true; + updateConsent(); + setTimeout(function () { + if (feature === featureEnums.SESSIONS && lastParams.begin_session) { + self.begin_session.apply(self, lastParams.begin_session); + lastParams.begin_session = null; + } + else if (feature === featureEnums.VIEWS && lastParams.track_pageview) { + lastView = null; + self.track_pageview.apply(self, lastParams.track_pageview); + lastParams.track_pageview = null; + } + }, 1); + } + } + } + else { + log(logLevelEnums.ERROR, "add_consent, No feature available for [" + feature + "]"); + } + }; + + /** + * Remove consent for specific feature, meaning, user opted out to track that data (either core feature of from custom feature group) + * @param {string|array} feature - name of the feature, possible values, "sessions","events","views","scrolls","clicks","forms","crashes","attribution","users", etc or custom provided through {@link Countly.group_features} + * @param {Boolean} enforceConsentUpdate - regulates if a request will be sent to the server or not. If true, removing consents will send a request to the server and if false, consents will be removed without a request + */ + this.remove_consent = function (feature) { + log(logLevelEnums.INFO, "remove_consent, Removing consent for [" + feature + "]"); + this.remove_consent_internal(feature, true); + }; + // removes consent without updating + this.remove_consent_internal = function (feature, enforceConsentUpdate) { + // if true updateConsent will execute when possible + enforceConsentUpdate = enforceConsentUpdate || false; + if (Array.isArray(feature)) { + for (var i = 0; i < feature.length; i++) { + this.remove_consent_internal(feature[i], enforceConsentUpdate); + } + } + else if (consents[feature]) { + if (consents[feature].features) { + // this is added group, let's iterate through sub features + this.remove_consent_internal(consents[feature].features, enforceConsentUpdate); + } + else { + consents[feature].optin = false; + // this is core feature + if (enforceConsentUpdate && consents[feature].optin !== false) { + updateConsent(); + } + } + } + else { + log(logLevelEnums.WARNING, "remove_consent, No feature available for [" + feature + "]"); + } + }; + + var consentTimer; + var updateConsent = function () { + if (consentTimer) { + // delay syncing consents + clearTimeout(consentTimer); + consentTimer = null; + } + consentTimer = setTimeout(function () { + var consentMessage = {}; + for (var i = 0; i < Countly.features.length; i++) { + if (consents[Countly.features[i]].optin === true) { + consentMessage[Countly.features[i]] = true; + } + else { + consentMessage[Countly.features[i]] = false; + } + } + toRequestQueue({ consent: JSON.stringify(consentMessage) }); + log(logLevelEnums.DEBUG, "Consent update request has been sent to the queue."); + }, 1000); + }; + + this.enable_offline_mode = function () { + log(logLevelEnums.INFO, "enable_offline_mode, Enabling offline mode"); + // clear consents + this.remove_consent_internal(Countly.features, false); + offlineMode = true; + this.device_id = "[CLY]_temp_id"; + self.device_id = this.device_id; + deviceIdType = DeviceIdTypeInternalEnums.TEMPORARY_ID; + }; + + this.disable_offline_mode = function (device_id) { + if (!offlineMode) { + log(logLevelEnums.WARNING, "disable_offline_mode, Countly was not in offline mode."); + return; + } + log(logLevelEnums.INFO, "disable_offline_mode, Disabling offline mode"); + offlineMode = false; + if (device_id && this.device_id !== device_id) { + this.device_id = device_id; + self.device_id = this.device_id; + deviceIdType = DeviceIdTypeInternalEnums.DEVELOPER_SUPPLIED; + setValueInStorage("cly_id", this.device_id); + setValueInStorage("cly_id_type", DeviceIdTypeInternalEnums.DEVELOPER_SUPPLIED); + log(logLevelEnums.INFO, "disable_offline_mode, Changing id to: " + this.device_id); + } + else { + this.device_id = getStoredIdOrGenerateId(); + if (this.device_id === "[CLY]_temp_id") { + this.device_id = generateUUID(); + } + self.device_id = this.device_id; + if (this.device_id !== getValueFromStorage("cly_id")) { + setValueInStorage("cly_id", this.device_id); + setValueInStorage("cly_id_type", DeviceIdTypeInternalEnums.SDK_GENERATED); + } + } + var needResync = false; + if (requestQueue.length > 0) { + for (var i = 0; i < requestQueue.length; i++) { + if (requestQueue[i].device_id === "[CLY]_temp_id") { + requestQueue[i].device_id = this.device_id; + needResync = true; + } + } + } + if (needResync) { + setValueInStorage("cly_queue", requestQueue, true); + } + }; + + /** + * Start session + * @param {boolean} noHeartBeat - true if you don't want to use internal heartbeat to manage session + * @param {bool} force - force begin session request even if session cookie is enabled + */ + this.begin_session = function (noHeartBeat, force) { + log(logLevelEnums.INFO, "begin_session, Starting the session. There was an ongoing session: [" + sessionStarted + "]"); + if (noHeartBeat) { + log(logLevelEnums.INFO, "begin_session, Heartbeats are disabled"); + } + if (force) { + log(logLevelEnums.INFO, "begin_session, Session starts irrespective of session cookie"); + } + if (this.check_consent(featureEnums.SESSIONS)) { + if (!sessionStarted) { + if (this.enableOrientationTracking) { + // report orientation + this.report_orientation(); + add_event_listener(window, "resize", function () { + self.report_orientation(); + }); + } + lastBeat = getTimestamp(); + sessionStarted = true; + autoExtend = !(noHeartBeat); + var expire = getValueFromStorage("cly_session"); + log(logLevelEnums.VERBOSE, "begin_session, Session state, forced: [" + force + "], useSessionCookie: [" + useSessionCookie + "], seconds to expire: [" + (expire - lastBeat) + "], expired: [" + (parseInt(expire) <= getTimestamp()) + "] "); + if (force || !useSessionCookie || !expire || parseInt(expire) <= getTimestamp()) { + log(logLevelEnums.INFO, "begin_session, Session started"); + if (firstView === null) { + firstView = true; + } + var req = {}; + req.begin_session = 1; + req.metrics = JSON.stringify(getMetrics()); + toRequestQueue(req); + } + setValueInStorage("cly_session", getTimestamp() + (sessionCookieTimeout * 60)); + } + } + else { + lastParams.begin_session = arguments; + } + }; + + /** + * Report session duration + * @param {int} sec - amount of seconds to report for current session + */ + this.session_duration = function (sec) { + log(logLevelEnums.INFO, "session_duration, Reporting session duration"); + if (this.check_consent(featureEnums.SESSIONS)) { + if (sessionStarted) { + log(logLevelEnums.INFO, "session_duration, Session extended: ", sec); + toRequestQueue({ session_duration: sec }); + extendSession(); + } + } + }; + + /** + * End current session + * @param {int} sec - amount of seconds to report for current session, before ending it + * @param {bool} force - force end session request even if session cookie is enabled + */ + this.end_session = function (sec, force) { + log(logLevelEnums.INFO, "end_session, Ending the current session. There was an on going session:[" + sessionStarted + "]"); + if (this.check_consent(featureEnums.SESSIONS)) { + if (sessionStarted) { + sec = sec || getTimestamp() - lastBeat; + reportViewDuration(); + if (!useSessionCookie || force) { + log(logLevelEnums.INFO, "end_session, Session ended"); + toRequestQueue({ end_session: 1, session_duration: sec }); + } + else { + this.session_duration(sec); + } + sessionStarted = false; + } + } + }; + + /** + * Change current user/device id + * @param {string} newId - new user/device ID to use. Must be a non-empty string value. Invalid values (like null, empty string or undefined) will be rejected + * @param {boolean} merge - move data from old ID to new ID on server + * */ + this.change_id = function (newId, merge) { + log(logLevelEnums.INFO, "change_id, Changing the ID"); + if (merge) { + log(logLevelEnums.INFO, "change_id, Will merge the IDs"); + } + if (!newId || typeof newId !== "string" || newId.length === 0) { + log(logLevelEnums.ERROR, "change_id, The provided ID: [" + newId + "] is not a valid ID"); + return; + } + if (offlineMode) { + log(logLevelEnums.WARNING, "change_id, Offline mode was on, initiating disabling sequence instead."); + this.disable_offline_mode(newId); + return; + } + // eqeq is used here since we want to catch number to string checks too. type conversion might happen at a new init + // eslint-disable-next-line eqeqeq + if (this.device_id != newId) { + if (!merge) { + // empty event queue + sendEventsForced(); + // end current session + this.end_session(null, true); + // clear timed events + timedEvents = {}; + // clear all consents + this.remove_consent_internal(Countly.features, false); + } + var oldId = this.device_id; + this.device_id = newId; + self.device_id = this.device_id; + deviceIdType = DeviceIdTypeInternalEnums.DEVELOPER_SUPPLIED; + setValueInStorage("cly_id", this.device_id); + setValueInStorage("cly_id_type", DeviceIdTypeInternalEnums.DEVELOPER_SUPPLIED); + log(logLevelEnums.INFO, "change_id, Changing ID from:[" + oldId + "] to [" + newId + "]"); + if (merge) { + // no consent check here since 21.11.0 + toRequestQueue({ old_device_id: oldId }); + } + else { + // start new session for new ID + this.begin_session(!autoExtend, true); + } + // if init time remote config was enabled with a callback function, remove currently stored remote configs and fetch remote config again + if (this.remote_config) { + remoteConfigs = {}; + setValueInStorage("cly_remote_configs", remoteConfigs); + this.fetch_remote_config(this.remote_config); + } + } + }; + + /** + * Report custom event + * @param {Object} event - Countly {@link Event} object + * @param {string} event.key - name or id of the event + * @param {number} [event.count=1] - how many times did event occur + * @param {number=} event.sum - sum to report with event (if any) + * @param {number=} event.dur - duration to report with event (if any) + * @param {Object=} event.segmentation - object with segments key /values + * */ + this.add_event = function (event) { + log(logLevelEnums.INFO, "add_event, Adding event: ", event); + // initially no consent is given + var respectiveConsent = false; + switch (event.key) { + case internalEventKeyEnums.NPS: + respectiveConsent = this.check_consent(featureEnums.FEEDBACK); + break; + case internalEventKeyEnums.SURVEY: + respectiveConsent = this.check_consent(featureEnums.FEEDBACK); + break; + case internalEventKeyEnums.STAR_RATING: + respectiveConsent = this.check_consent(featureEnums.STAR_RATING); + break; + case internalEventKeyEnums.VIEW: + respectiveConsent = this.check_consent(featureEnums.VIEWS); + break; + case internalEventKeyEnums.ORIENTATION: + respectiveConsent = this.check_consent(featureEnums.USERS); + break; + case internalEventKeyEnums.ACTION: + respectiveConsent = this.check_consent(featureEnums.CLICKS) || this.check_consent(featureEnums.SCROLLS); + break; + default: + respectiveConsent = this.check_consent(featureEnums.EVENTS); + } + // if consent is given adds event to the queue + if (respectiveConsent) { + add_cly_events(event); + } + }; + + /** + * Add events to event queue + * @memberof Countly._internals + * @param {Event} event - countly event + * @param {String} eventIdOverride - countly event ID + */ + function add_cly_events(event, eventIdOverride) { + // ignore bots + if (self.ignore_visitor) { + log(logLevelEnums.ERROR, "Adding event failed. Possible bot or user opt out"); + return; + } + + if (!event.key) { + log(logLevelEnums.ERROR, "Adding event failed. Event must have a key property"); + return; + } + + if (!event.count) { + event.count = 1; + } + // we omit the internal event keys from truncation. TODO: This is not perfect as it would omit a key that includes an internal event key and more too. But that possibility seems negligible. + if (!internalEventKeyEnumsArray.includes(event.key)) { + // truncate event name and segmentation to internal limits + event.key = truncateSingleValue(event.key, self.maxKeyLength, "add_cly_event", log); + } + event.segmentation = truncateObject(event.segmentation, self.maxKeyLength, self.maxValueSize, self.maxSegmentationValues, "add_cly_event", log); + var props = ["key", "count", "sum", "dur", "segmentation"]; + var e = createNewObjectFromProperties(event, props); + e.timestamp = getMsTimestamp(); + var date = new Date(); + e.hour = date.getHours(); + e.dow = date.getDay(); + e.id = eventIdOverride || secureRandom(); + if (e.key === internalEventKeyEnums.VIEW) { + e.pvid = previousViewId || ""; + } + else { + e.cvid = currentViewId || ""; + } + eventQueue.push(e); + setValueInStorage("cly_event", eventQueue); + log(logLevelEnums.INFO, "With event ID: [" + e.id + "], successfully adding the last event:", e); + } + + /** + * Start timed event, which will fill in duration property upon ending automatically + * This works basically as a timer and does not create an event till end_event is called + * @param {string} key - event name that will be used as key property + * */ + this.start_event = function (key) { + if (!key || typeof key !== "string") { + log(logLevelEnums.WARNING, "start_event, you have to provide a valid string key instead of: [" + key + "]"); + return; + } + log(logLevelEnums.INFO, "start_event, Starting timed event with key: [" + key + "]"); + // truncate event name to internal limits + key = truncateSingleValue(key, self.maxKeyLength, "start_event", log); + if (timedEvents[key]) { + log(logLevelEnums.WARNING, "start_event, Timed event with key: [" + key + "] already started"); + return; + } + timedEvents[key] = getTimestamp(); + }; + + /** + * Cancel timed event, cancels a timed event if it exists + * @param {string} key - event name that will canceled + * @returns {boolean} - returns true if the event was canceled and false if no event with that key was found + * */ + this.cancel_event = function (key) { + if (!key || typeof key !== "string") { + log(logLevelEnums.WARNING, "cancel_event, you have to provide a valid string key instead of: [" + key + "]"); + return false; + } + log(logLevelEnums.INFO, "cancel_event, Canceling timed event with key: [" + key + "]"); + // truncate event name to internal limits. This is done incase start_event key was truncated. + key = truncateSingleValue(key, self.maxKeyLength, "cancel_event", log); + if (timedEvents[key]) { + delete timedEvents[key]; + log(logLevelEnums.INFO, "cancel_event, Timed event with key: [" + key + "] is canceled"); + return true; + } + log(logLevelEnums.WARNING, "cancel_event, Timed event with key: [" + key + "] was not found"); + return false; + }; + + /** + * End timed event + * @param {string|object} event - event key if string or Countly event same as passed to {@link Countly.add_event} + * */ + this.end_event = function (event) { + if (!event) { + log(logLevelEnums.WARNING, "end_event, you have to provide a valid string key or event object instead of: [" + event + "]"); + return; + } + log(logLevelEnums.INFO, "end_event, Ending timed event"); + if (typeof event === "string") { + // truncate event name to internal limits. This is done incase start_event key was truncated. + event = truncateSingleValue(event, self.maxKeyLength, "end_event", log); + event = { key: event }; + } + if (!event.key) { + log(logLevelEnums.ERROR, "end_event, Timed event must have a key property"); + return; + } + if (!timedEvents[event.key]) { + log(logLevelEnums.ERROR, "end_event, Timed event with key: [" + event.key + "] was not started"); + return; + } + event.dur = getTimestamp() - timedEvents[event.key]; + this.add_event(event); + delete timedEvents[event.key]; + }; + + /** + * Report device orientation + * @param {string=} orientation - orientation as landscape or portrait + * */ + this.report_orientation = function (orientation) { + log(logLevelEnums.INFO, "report_orientation, Reporting orientation"); + if (this.check_consent(featureEnums.USERS)) { + add_cly_events({ + key: internalEventKeyEnums.ORIENTATION, + segmentation: { + mode: orientation || getOrientation(), + }, + }); + } + }; + + /** + * Report user conversion to the server (when user signup or made a purchase, or whatever your conversion is), if there is no campaign data, user will be reported as organic + * @param {string=} campaign_id - id of campaign, or will use the one that is stored after campaign link click + * @param {string=} campaign_user_id - id of user's click on campaign, or will use the one that is stored after campaign link click + * + * @deprecated use 'recordDirectAttribution' in place of this call + * */ + this.report_conversion = function (campaign_id, campaign_user_id) { + log(logLevelEnums.WARNING, "report_conversion, Deprecated function call! Use 'recordDirectAttribution' in place of this call. Call will be redirected now!"); + this.recordDirectAttribution(campaign_id, campaign_user_id); + }; + /** + * Report user conversion to the server (when user signup or made a purchase, or whatever your conversion is), if there is no campaign data, user will be reported as organic + * @param {string=} campaign_id - id of campaign, or will use the one that is stored after campaign link click + * @param {string=} campaign_user_id - id of user's click on campaign, or will use the one that is stored after campaign link click + * */ + this.recordDirectAttribution = function (campaign_id, campaign_user_id) { + log(logLevelEnums.INFO, "recordDirectAttribution, Recording the attribution for campaign ID: [" + campaign_id + "] and the user ID: [" + campaign_user_id + "]"); + if (this.check_consent(featureEnums.ATTRIBUTION)) { + campaign_id = campaign_id || getValueFromStorage("cly_cmp_id") || "cly_organic"; + campaign_user_id = campaign_user_id || getValueFromStorage("cly_cmp_uid"); + + if (campaign_user_id) { + toRequestQueue({ campaign_id: campaign_id, campaign_user: campaign_user_id }); + } + else { + toRequestQueue({ campaign_id: campaign_id }); + } + } + }; + + /** + * Provide information about user + * @param {Object} user - Countly {@link UserDetails} object + * @param {string=} user.name - user's full name + * @param {string=} user.username - user's username or nickname + * @param {string=} user.email - user's email address + * @param {string=} user.organization - user's organization or company + * @param {string=} user.phone - user's phone number + * @param {string=} user.picture - url to user's picture + * @param {string=} user.gender - M value for male and F value for female + * @param {number=} user.byear - user's birth year used to calculate current age + * @param {Object=} user.custom - object with custom key value properties you want to save with user + * */ + this.user_details = function (user) { + log(logLevelEnums.INFO, "user_details, Trying to add user details: ", user); + if (this.check_consent(featureEnums.USERS)) { + sendEventsForced(); // flush events to event queue to prevent a drill issue + log(logLevelEnums.INFO, "user_details, flushed the event queue"); + // truncating user values and custom object key value pairs + user.name = truncateSingleValue(user.name, self.maxValueSize, "user_details", log); + user.username = truncateSingleValue(user.username, self.maxValueSize, "user_details", log); + user.email = truncateSingleValue(user.email, self.maxValueSize, "user_details", log); + user.organization = truncateSingleValue(user.organization, self.maxValueSize, "user_details", log); + user.phone = truncateSingleValue(user.phone, self.maxValueSize, "user_details", log); + user.picture = truncateSingleValue(user.picture, 4096, "user_details", log); + user.gender = truncateSingleValue(user.gender, self.maxValueSize, "user_details", log); + user.byear = truncateSingleValue(user.byear, self.maxValueSize, "user_details", log); + user.custom = truncateObject(user.custom, self.maxKeyLength, self.maxValueSize, self.maxSegmentationValues, "user_details", log); + var props = ["name", "username", "email", "organization", "phone", "picture", "gender", "byear", "custom"]; + toRequestQueue({ user_details: JSON.stringify(createNewObjectFromProperties(user, props)) }); + } + }; + + /** ************************ + * Modifying custom property values of user details + * Possible modification commands + * - inc, to increment existing value by provided value + * - mul, to multiply existing value by provided value + * - max, to select maximum value between existing and provided value + * - min, to select minimum value between existing and provided value + * - setOnce, to set value only if it was not set before + * - push, creates an array property, if property does not exist, and adds value to array + * - pull, to remove value from array property + * - addToSet, creates an array property, if property does not exist, and adds unique value to array, only if it does not yet exist in array + ************************* */ + var customData = {}; + var change_custom_property = function (key, value, mod) { + if (self.check_consent(featureEnums.USERS)) { + if (!customData[key]) { + customData[key] = {}; + } + if (mod === "$push" || mod === "$pull" || mod === "$addToSet") { + if (!customData[key][mod]) { + customData[key][mod] = []; + } + customData[key][mod].push(value); + } + else { + customData[key][mod] = value; + } + } + }; + + /** + * Control user related custom properties. Don't forget to call save after finishing manipulation of custom data + * @namespace Countly.userData + * @name Countly.userData + * @example + * //set custom key value property + * Countly.userData.set("twitter", "hulk@rowboat"); + * //create or increase specific number property + * Countly.userData.increment("login_count"); + * //add new value to array property if it is not already there + * Countly.userData.push_unique("selected_category", "IT"); + * //send all custom property modified data to server + * Countly.userData.save(); + */ + this.userData = { + /** + * Sets user's custom property value + * @memberof Countly.userData + * @param {string} key - name of the property to attach to user + * @param {string|number} value - value to store under provided property + * */ + set: function (key, value) { + log(logLevelEnums.INFO, "[userData] set, Setting user's custom property value: [" + value + "] under the key: [" + key + "]"); + // truncate user's custom property value to internal limits + key = truncateSingleValue(key, self.maxKeyLength, "userData set", log); + value = truncateSingleValue(value, self.maxValueSize, "userData set", log); + customData[key] = value; + }, + /** + * Unset/deletes user's custom property + * @memberof Countly.userData + * @param {string} key - name of the property to delete + * */ + unset: function (key) { + log(logLevelEnums.INFO, "[userData] unset, Resetting user's custom property with key: [" + key + "] "); + customData[key] = ""; + }, + /** + * Sets user's custom property value only if it was not set before + * @memberof Countly.userData + * @param {string} key - name of the property to attach to user + * @param {string|number} value - value to store under provided property + * */ + set_once: function (key, value) { + log(logLevelEnums.INFO, "[userData] set_once, Setting user's unique custom property value: [" + value + "] under the key: [" + key + "] "); + // truncate user's custom property value to internal limits + key = truncateSingleValue(key, self.maxKeyLength, "userData set_once", log); + value = truncateSingleValue(value, self.maxValueSize, "userData set_once", log); + change_custom_property(key, value, "$setOnce"); + }, + /** + * Increment value under the key of this user's custom properties by one + * @memberof Countly.userData + * @param {string} key - name of the property to attach to user + * */ + increment: function (key) { + log(logLevelEnums.INFO, "[userData] increment, Increasing user's custom property value under the key: [" + key + "] by one"); + // truncate property name wrt internal limits + key = truncateSingleValue(key, self.maxKeyLength, "userData increment", log); + change_custom_property(key, 1, "$inc"); + }, + /** + * Increment value under the key of this user's custom properties by provided value + * @memberof Countly.userData + * @param {string} key - name of the property to attach to user + * @param {number} value - value by which to increment server value + * */ + increment_by: function (key, value) { + log(logLevelEnums.INFO, "[userData] increment_by, Increasing user's custom property value under the key: [" + key + "] by: [" + value + "]"); + // truncate property name and value wrt internal limits + key = truncateSingleValue(key, self.maxKeyLength, "userData increment_by", log); + value = truncateSingleValue(value, self.maxValueSize, "userData increment_by", log); + change_custom_property(key, value, "$inc"); + }, + /** + * Multiply value under the key of this user's custom properties by provided value + * @memberof Countly.userData + * @param {string} key - name of the property to attach to user + * @param {number} value - value by which to multiply server value + * */ + multiply: function (key, value) { + log(logLevelEnums.INFO, "[userData] multiply, Multiplying user's custom property value under the key: [" + key + "] by: [" + value + "]"); + // truncate key value pair wrt internal limits + key = truncateSingleValue(key, self.maxKeyLength, "userData multiply", log); + value = truncateSingleValue(value, self.maxValueSize, "userData multiply", log); + change_custom_property(key, value, "$mul"); + }, + /** + * Save maximal value under the key of this user's custom properties + * @memberof Countly.userData + * @param {string} key - name of the property to attach to user + * @param {number} value - value which to compare to server's value and store maximal value of both provided + * */ + max: function (key, value) { + log(logLevelEnums.INFO, "[userData] max, Saving user's maximum custom property value compared to the value: [" + value + "] under the key: [" + key + "]"); + // truncate key value pair wrt internal limits + key = truncateSingleValue(key, self.maxKeyLength, "userData max", log); + value = truncateSingleValue(value, self.maxValueSize, "userData max", log); + change_custom_property(key, value, "$max"); + }, + /** + * Save minimal value under the key of this user's custom properties + * @memberof Countly.userData + * @param {string} key - name of the property to attach to user + * @param {number} value - value which to compare to server's value and store minimal value of both provided + * */ + min: function (key, value) { + log(logLevelEnums.INFO, "[userData] min, Saving user's minimum custom property value compared to the value: [" + value + "] under the key: [" + key + "]"); + // truncate key value pair wrt internal limits + key = truncateSingleValue(key, self.maxKeyLength, "userData min", log); + value = truncateSingleValue(value, self.maxValueSize, "userData min", log); + change_custom_property(key, value, "$min"); + }, + /** + * Add value to array under the key of this user's custom properties. If property is not an array, it will be converted to array + * @memberof Countly.userData + * @param {string} key - name of the property to attach to user + * @param {string|number} value - value which to add to array + * */ + push: function (key, value) { + log(logLevelEnums.INFO, "[userData] push, Pushing a value: [" + value + "] under the key: [" + key + "] to user's custom property array"); + // truncate key value pair wrt internal limits + key = truncateSingleValue(key, self.maxKeyLength, "userData push", log); + value = truncateSingleValue(value, self.maxValueSize, "userData push", log); + change_custom_property(key, value, "$push"); + }, + /** + * Add value to array under the key of this user's custom properties, storing only unique values. If property is not an array, it will be converted to array + * @memberof Countly.userData + * @param {string} key - name of the property to attach to user + * @param {string|number} value - value which to add to array + * */ + push_unique: function (key, value) { + log(logLevelEnums.INFO, "[userData] push_unique, Pushing a unique value: [" + value + "] under the key: [" + key + "] to user's custom property array"); + // truncate key value pair wrt internal limits + key = truncateSingleValue(key, self.maxKeyLength, "userData push_unique", log); + value = truncateSingleValue(value, self.maxValueSize, "userData push_unique", log); + change_custom_property(key, value, "$addToSet"); + }, + /** + * Remove value from array under the key of this user's custom properties + * @memberof Countly.userData + * @param {string} key - name of the property + * @param {string|number} value - value which to remove from array + * */ + pull: function (key, value) { + log(logLevelEnums.INFO, "[userData] pull, Removing the value: [" + value + "] under the key: [" + key + "] from user's custom property array"); + change_custom_property(key, value, "$pull"); + }, + /** + * Save changes made to user's custom properties object and send them to server + * @memberof Countly.userData + * */ + save: function () { + log(logLevelEnums.INFO, "[userData] save, Saving changes to user's custom property"); + if (self.check_consent(featureEnums.USERS)) { + sendEventsForced(); // flush events to event queue to prevent a drill issue + log(logLevelEnums.INFO, "user_details, flushed the event queue"); + toRequestQueue({ user_details: JSON.stringify({ custom: customData }) }); + } + customData = {}; + } + }; + + /** + * Report performance trace + * @param {Object} trace - apm trace object + * @param {string} trace.type - device or network + * @param {string} trace.name - url or view of the trace + * @param {number} trace.stz - start timestamp + * @param {number} trace.etz - end timestamp + * @param {Object} trace.app_metrics - key/value metrics like duration, to report with trace where value is number + * @param {Object=} trace.apm_attr - object profiling attributes (not yet supported) + */ + this.report_trace = function (trace) { + log(logLevelEnums.INFO, "report_trace, Reporting performance trace"); + if (this.check_consent(featureEnums.APM)) { + var props = ["type", "name", "stz", "etz", "apm_metrics", "apm_attr"]; + for (var i = 0; i < props.length; i++) { + if (props[i] !== "apm_attr" && typeof trace[props[i]] === "undefined") { + log(logLevelEnums.WARNING, "report_trace, APM trace don't have the property: " + props[i]); + return; + } + } + // truncate trace name and metrics wrt internal limits + trace.name = truncateSingleValue(trace.name, self.maxKeyLength, "report_trace", log); + trace.app_metrics = truncateObject(trace.app_metrics, self.maxKeyLength, self.maxValueSize, self.maxSegmentationValues, "report_trace", log); + var e = createNewObjectFromProperties(trace, props); + e.timestamp = trace.stz; + var date = new Date(); + e.hour = date.getHours(); + e.dow = date.getDay(); + toRequestQueue({ apm: JSON.stringify(e) }); + log(logLevelEnums.INFO, "report_trace, Successfully adding APM trace: ", e); + } + }; + + /** + * Automatically track javascript errors that happen on the website and report them to the server + * @param {string=} segments - additional key value pairs you want to provide with error report, like versions of libraries used, etc. + * */ + this.track_errors = function (segments) { + if (!isBrowser) { + log(logLevelEnums.WARNING, "track_errors, window object is not available. Not tracking errors."); + return; + } + log(logLevelEnums.INFO, "track_errors, Started tracking errors"); + // Indicated that for this instance of the countly error tracking is enabled + Countly.i[this.app_key].tracking_crashes = true; + if (!window.cly_crashes) { + window.cly_crashes = true; + crashSegments = segments; + // override global 'uncaught error' handler + window.onerror = function errorBundler(msg, url, line, col, err) { + // old browsers like IE 10 and Safari 9 won't give this value 'err' to us, but if it is provided we can trigger error recording immediately + if (err !== undefined && err !== null) { + // false indicates fatal error (as in non_fatal:false) + dispatchErrors(err, false); + } + // fallback if no error object is present for older browsers, we create it instead + else { + col = col || (window.event && window.event.errorCharacter); + var error = ""; + if (typeof msg !== "undefined") { + error += msg + "\n"; + } + if (typeof url !== "undefined") { + error += "at " + url; + } + if (typeof line !== "undefined") { + error += ":" + line; + } + if (typeof col !== "undefined") { + error += ":" + col; + } + error += "\n"; + + try { + var stack = []; + // deprecated, must be changed + // eslint-disable-next-line no-caller + var f = errorBundler.caller; + while (f) { + stack.push(f.name); + f = f.caller; + } + error += stack.join("\n"); + } + catch (ex) { + log(logLevelEnums.ERROR, "track_errors, Call stack generation experienced a problem: " + ex); + } + // false indicates fatal error (as in non_fatal:false) + dispatchErrors(error, false); + } + }; + + // error handling for 'uncaught rejections' + window.addEventListener("unhandledrejection", function (event) { + // true indicates non fatal error (as in non_fatal: true) + dispatchErrors(new Error("Unhandled rejection (reason: " + (event.reason && event.reason.stack ? event.reason.stack : event.reason) + ")."), true); + }); + } + }; + + /** + * Log an exception that you caught through try and catch block and handled yourself and just want to report it to server + * @param {Object} err - error exception object provided in catch block + * @param {string=} segments - additional key value pairs you want to provide with error report, like versions of libraries used, etc. + * */ + this.log_error = function (err, segments) { + log(logLevelEnums.INFO, "log_error, Logging errors"); + // true indicates non fatal error (as in non_fatal:true) + this.recordError(err, true, segments); + }; + + /** + * Add new line in the log of breadcrumbs of what user did, will be included together with error report + * @param {string} record - any text describing what user did + * */ + this.add_log = function (record) { + log(logLevelEnums.INFO, "add_log, Adding a new log of breadcrumbs: [ " + record + " ]"); + if (this.check_consent(featureEnums.CRASHES)) { + // truncate description wrt internal limits + record = truncateSingleValue(record, self.maxValueSize, "add_log", log); + while (crashLogs.length >= self.maxBreadcrumbCount) { + crashLogs.shift(); + log(logLevelEnums.WARNING, "add_log, Reached maximum crashLogs size. Will erase the oldest one."); + } + crashLogs.push(record); + } + }; + /** + * Fetch remote config from the server (old one for method=fetch_remote_config API) + * @param {array=} keys - Array of keys to fetch, if not provided will fetch all keys + * @param {array=} omit_keys - Array of keys to omit, if provided will fetch all keys except provided ones + * @param {function=} callback - Callback to notify with first param error and second param remote config object + * */ + this.fetch_remote_config = function (keys, omit_keys, callback) { + var keysFiltered = null; + var omitKeysFiltered = null; + var callbackFiltered = null; + + // check first param is truthy + if (keys) { + // if third parameter is falsy and first param is a function assign it as the callback function + if (!callback && typeof keys === "function") { + callbackFiltered = keys; + } + // else if first param is an array assign it as 'keys' + else if (Array.isArray(keys)) { + keysFiltered = keys; + } + } + // check second param is truthy + if (omit_keys) { + // if third parameter is falsy and second param is a function assign it as the callback function + if (!callback && typeof omit_keys === "function") { + callbackFiltered = omit_keys; + } + // else if second param is an array assign it as 'omit_keys' + else if (Array.isArray(omit_keys)) { + omitKeysFiltered = omit_keys; + } + } + // assign third param as a callback function if it was not assigned yet in first two params + if (!callbackFiltered && typeof callback === "function") { + callbackFiltered = callback; + } + + // use new RC API + if (this.useExplicitRcApi) { + log(logLevelEnums.INFO, "fetch_remote_config, Fetching remote config"); + // opt in is true(1) or false(0) + var opt = this.rcAutoOptinAb ? 1 : 0; + fetch_remote_config_explicit(keysFiltered, omitKeysFiltered, opt, null, callbackFiltered); + return; + } + + log(logLevelEnums.WARNING, "fetch_remote_config, Fetching remote config, with legacy API"); + fetch_remote_config_explicit(keysFiltered, omitKeysFiltered, null, "legacy", callbackFiltered); + }; + + /** + * Fetch remote config from the server (new one with method=rc API) + * @param {array=} keys - Array of keys to fetch, if not provided will fetch all keys + * @param {array=} omit_keys - Array of keys to omit, if provided will fetch all keys except provided ones + * @param {number=} optIn - an inter to indicate if the user is opted in for the AB testing or not (1 is opted in, 0 is opted out) + * @param {string=} api - which API to use, if not provided would use default ("legacy" is for method="fetch_remote_config", default is method="rc") + * @param {function=} callback - Callback to notify with first param error and second param remote config object + * */ + function fetch_remote_config_explicit(keys, omit_keys, optIn, api, callback) { + log(logLevelEnums.INFO, "fetch_remote_config_explicit, Fetching sequence initiated"); + var request = { + method: "rc", + av: self.app_version + }; + // check if keys were provided + if (keys) { + request.keys = JSON.stringify(keys); + } + // check if omit_keys were provided + if (omit_keys) { + request.omit_keys = JSON.stringify(omit_keys); + } + var providedCall; + // legacy api prompt check + if (api === "legacy") { + request.method = "fetch_remote_config"; + } + // opted out/in check + if (optIn === 0) { + request.oi = 0; + } + if (optIn === 1) { + request.oi = 1; + } + // callback check + if (typeof callback === "function") { + providedCall = callback; + } + if (self.check_consent(featureEnums.SESSIONS)) { + request.metrics = JSON.stringify(getMetrics()); + } + if (self.check_consent(featureEnums.REMOTE_CONFIG)) { + prepareRequest(request); + makeNetworkRequest("fetch_remote_config_explicit", self.url + readPath, request, function (err, params, responseText) { + if (err) { + // error has been logged by the request function + return; + } + try { + var configs = JSON.parse(responseText); + if (request.keys || request.omit_keys) { + // we merge config + for (var i in configs) { + remoteConfigs[i] = configs[i]; + } + } + else { + // we replace config + remoteConfigs = configs; + } + setValueInStorage("cly_remote_configs", remoteConfigs); + } + catch (ex) { + log(logLevelEnums.ERROR, "fetch_remote_config_explicit, Had an issue while parsing the response: " + ex); + } + if (providedCall) { + log(logLevelEnums.INFO, "fetch_remote_config_explicit, Callback function is provided"); + providedCall(err, remoteConfigs); + } + // JSON array can pass + }, true); + } + else { + log(logLevelEnums.ERROR, "fetch_remote_config_explicit, Remote config requires explicit consent"); + if (providedCall) { + providedCall(new Error("Remote config requires explicit consent"), remoteConfigs); + } + } + } + + /** + * AB testing key provider, opts the user in for the selected keys + * @param {array=} keys - Array of keys opt in FOR + * */ + this.enrollUserToAb = function (keys) { + log(logLevelEnums.INFO, "enrollUserToAb, Providing AB test keys to opt in for"); + if (!keys || !Array.isArray(keys) || keys.length === 0) { + log(logLevelEnums.ERROR, "enrollUserToAb, No keys provided"); + return; + } + var request = { + method: "ab", + keys: JSON.stringify(keys), + av: self.app_version + }; + prepareRequest(request); + makeNetworkRequest("enrollUserToAb", this.url + readPath, request, function (err, params, responseText) { + if (err) { + // error has been logged by the request function + return; + } + try { + var resp = JSON.parse(responseText); + log(logLevelEnums.DEBUG, "enrollUserToAb, Parsed the response's result: [" + resp.result + "]"); + } + catch (ex) { + log(logLevelEnums.ERROR, "enrollUserToAb, Had an issue while parsing the response: " + ex); + } + // JSON array can pass + }, true); + }; + + /** + * Gets remote config object (all key/value pairs) or specific value for provided key from the storage + * @param {string=} key - if provided, will return value for key, or return whole object + * @returns {object} remote configs + * */ + this.get_remote_config = function (key) { + log(logLevelEnums.INFO, "get_remote_config, Getting remote config from storage"); + if (typeof key !== "undefined") { + return remoteConfigs[key]; + } + return remoteConfigs; + }; + + /** + * Stop tracking duration time for this user + * */ + this.stop_time = function () { + log(logLevelEnums.INFO, "stop_time, Stopping tracking duration"); + if (trackTime) { + trackTime = false; + storedDuration = getTimestamp() - lastBeat; + lastViewStoredDuration = getTimestamp() - lastViewTime; + } + }; + + /** + * Start tracking duration time for this user, by default it is automatically tracked if you are using internal session handling + * */ + this.start_time = function () { + log(logLevelEnums.INFO, "start_time, Starting tracking duration"); + if (!trackTime) { + trackTime = true; + lastBeat = getTimestamp() - storedDuration; + lastViewTime = getTimestamp() - lastViewStoredDuration; + lastViewStoredDuration = 0; + extendSession(); + } + }; + + /** + * Track user sessions automatically, including time user spent on your website + * */ + this.track_sessions = function () { + if (!isBrowser) { + log(logLevelEnums.WARNING, "track_sessions, window object is not available. Not tracking sessions."); + return; + } + log(logLevelEnums.INFO, "track_session, Starting tracking user session"); + // start session + this.begin_session(); + this.start_time(); + // end session on unload + add_event_listener(window, "beforeunload", function () { + // empty the event queue + sendEventsForced(); + self.end_session(); + }); + + // manage sessions on window visibility events + var hidden = "hidden"; + + /** + * Handle visibility change events + */ + function onchange() { + if (document[hidden] || !document.hasFocus()) { + self.stop_time(); + } + else { + self.start_time(); + } + } + + // add focus handling eventListeners + add_event_listener(window, "focus", onchange); + add_event_listener(window, "blur", onchange); + + // newer mobile compatible way + add_event_listener(window, "pageshow", onchange); + add_event_listener(window, "pagehide", onchange); + + // IE 9 and lower: + if ("onfocusin" in document) { + add_event_listener(window, "focusin", onchange); + add_event_listener(window, "focusout", onchange); + } + + // Page Visibility API for changing tabs and minimizing browser + if (hidden in document) { + document.addEventListener("visibilitychange", onchange); + } + else if ("mozHidden" in document) { + hidden = "mozHidden"; + document.addEventListener("mozvisibilitychange", onchange); + } + else if ("webkitHidden" in document) { + hidden = "webkitHidden"; + document.addEventListener("webkitvisibilitychange", onchange); + } + else if ("msHidden" in document) { + hidden = "msHidden"; + document.addEventListener("msvisibilitychange", onchange); + } + + /** + * Reset inactivity counter and time + */ + function resetInactivity() { + if (inactivityCounter >= inactivityTime) { + self.start_time(); + } + inactivityCounter = 0; + } + + add_event_listener(window, "mousemove", resetInactivity); + add_event_listener(window, "click", resetInactivity); + add_event_listener(window, "keydown", resetInactivity); + add_event_listener(window, "scroll", resetInactivity); + + // track user inactivity + setInterval(function () { + inactivityCounter++; + if (inactivityCounter >= inactivityTime) { + self.stop_time(); + } + }, 60000); + }; + + /** + * Track page views user visits + * @param {string=} page - optional name of the page, by default uses current url path + * @param {array=} ignoreList - optional array of strings or regexps to test for the url/view name to ignore and not report + * @param {object=} viewSegments - optional key value object with segments to report with the view + * */ + this.track_pageview = function (page, ignoreList, viewSegments) { + if (!isBrowser && !page) { + log(logLevelEnums.WARNING, "track_pageview, window object is not available. Not tracking page views is page is not provided."); + return; + } + log(logLevelEnums.INFO, "track_pageview, Tracking page views"); + log(logLevelEnums.VERBOSE, "track_pageview, last view is:[" + lastView + "], current view ID is:[" + currentViewId + "], previous view ID is:[" + previousViewId + "]"); + if (lastView && trackingScrolls) { + log(logLevelEnums.DEBUG, "track_pageview, Scroll registry triggered"); + processScrollView(); // for single page site's view change + isScrollRegistryOpen = true; + scrollRegistryTopPosition = 0; + } + reportViewDuration(); + previousViewId = currentViewId; + currentViewId = secureRandom(); + // truncate page name and segmentation wrt internal limits + page = truncateSingleValue(page, self.maxKeyLength, "track_pageview", log); + // if the first parameter we got is an array we got the ignoreList first, assign it here + if (page && Array.isArray(page)) { + ignoreList = page; + page = null; + } + // no page or ignore list provided, get the current view name/url + if (!page) { + page = this.getViewName(); + } + if (page === undefined || page === "") { + log(logLevelEnums.ERROR, "track_pageview, No page name to track (it is either undefined or empty string). No page view can be tracked."); + return; + } + if (page === null) { + log(logLevelEnums.ERROR, "track_pageview, View name returned as null. Page view will be ignored."); + return; + } + + if (ignoreList && ignoreList.length) { + for (var i = 0; i < ignoreList.length; i++) { + try { + var reg = new RegExp(ignoreList[i]); + if (reg.test(page)) { + log(logLevelEnums.INFO, "track_pageview, Ignoring the page: " + page); + return; + } + } + catch (ex) { + log(logLevelEnums.ERROR, "track_pageview, Problem with finding ignore list item: " + ignoreList[i] + ", error: " + ex); + } + } + } + lastView = page; + lastViewTime = getTimestamp(); + log(logLevelEnums.VERBOSE, "track_pageview, last view is assigned:[" + lastView + "], current view ID is:[" + currentViewId + "], previous view ID is:[" + previousViewId + "]"); + var segments = { + name: page, + visit: 1, + view: self.getViewUrl() + }; + // truncate new segment + segments = truncateObject(segments, self.maxKeyLength, self.maxValueSize, self.maxSegmentationValues, "track_pageview", log); + if (this.track_domains) { + segments.domain = window.location.hostname; + } + + if (useSessionCookie) { + if (!sessionStarted) { + // tracking view was called before tracking session, so we check expiration ourselves + var expire = getValueFromStorage("cly_session"); + if (!expire || parseInt(expire) <= getTimestamp()) { + firstView = false; + segments.start = 1; + } + } + // tracking views called after tracking session, so we can rely on tracking session decision + else if (firstView) { + firstView = false; + segments.start = 1; + } + } + // if we are not using session cookie, there is no session state between refreshes + // so we fallback to old logic of landing + else if (isBrowser && typeof document.referrer !== "undefined" && document.referrer.length) { + var matches = urlParseRE.exec(document.referrer); + // do not report referrers of current website + if (matches && matches[11] && matches[11] !== window.location.hostname) { + segments.start = 1; + } + } + + // add utm tags + if (freshUTMTags && Object.keys(freshUTMTags).length) { + log(logLevelEnums.INFO, "track_pageview, Adding fresh utm tags to segmentation:[" + JSON.stringify(freshUTMTags) + "]"); + for (var utm in freshUTMTags) { + if (typeof segments["utm_" + utm] === "undefined") { + segments["utm_" + utm] = freshUTMTags[utm]; + } + } + // TODO: Current logic adds utm tags to each view if the user landed with utm tags for that session(in non literal sense) + // we might want to change this logic to add utm tags only to the first view's segmentation by freshUTMTags = null; here + } + + // add referrer if it is usable + if (isBrowser && isReferrerUsable()) { + log(logLevelEnums.INFO, "track_pageview, Adding referrer to segmentation:[" + document.referrer + "]"); + segments.referrer = document.referrer; // add referrer + } + + if (viewSegments) { + viewSegments = truncateObject(viewSegments, self.maxKeyLength, self.maxValueSize, self.maxSegmentationValues, "track_pageview", log); + + for (var key in viewSegments) { + if (typeof segments[key] === "undefined") { + segments[key] = viewSegments[key]; + } + } + } + + // track pageview + if (this.check_consent(featureEnums.VIEWS)) { + add_cly_events({ + key: internalEventKeyEnums.VIEW, + segmentation: segments + }, currentViewId); + } + else { + lastParams.track_pageview = arguments; + } + }; + + /** + * Track page views user visits. Alias of {@link track_pageview} method for compatibility with NodeJS SDK + * @param {string=} page - optional name of the page, by default uses current url path + * @param {array=} ignoreList - optional array of strings or regexps to test for the url/view name to ignore and not report + * @param {object=} segments - optional view segments to track with the view + * */ + this.track_view = function (page, ignoreList, segments) { + log(logLevelEnums.INFO, "track_view, Initiating tracking page views"); + this.track_pageview(page, ignoreList, segments); + }; + + /** + * Track all clicks on this page + * @param {Object=} parent - DOM object which children to track, by default it is document body + * */ + this.track_clicks = function (parent) { + if (!isBrowser) { + log(logLevelEnums.WARNING, "track_clicks, window object is not available. Not tracking clicks."); + return; + } + log(logLevelEnums.INFO, "track_clicks, Starting to track clicks"); + if (parent) { + log(logLevelEnums.INFO, "track_clicks, Tracking the specified children:[" + parent + "]"); + } + parent = parent || document; + var shouldProcess = true; + /** + * Process click information + * @param {Event} event - click event + */ + function processClick(event) { + if (shouldProcess) { + shouldProcess = false; + + // cross browser click coordinates + get_page_coord(event); + if (typeof event.pageX !== "undefined" && typeof event.pageY !== "undefined") { + var height = getDocHeight(); + var width = getDocWidth(); + + // record click event + if (self.check_consent(featureEnums.CLICKS)) { + var segments = { + type: "click", + x: event.pageX, + y: event.pageY, + width: width, + height: height, + view: self.getViewUrl() + }; + // truncate new segment + segments = truncateObject(segments, self.maxKeyLength, self.maxValueSize, self.maxSegmentationValues, "processClick", log); + if (self.track_domains) { + segments.domain = window.location.hostname; + } + add_cly_events({ + key: internalEventKeyEnums.ACTION, + segmentation: segments + }); + } + } + setTimeout(function () { + shouldProcess = true; + }, 1000); + } + } + // add any events you want + add_event_listener(parent, "click", processClick); + }; + + /** + * Track all scrolls on this page + * @param {Object=} parent - DOM object which children to track, by default it is document body + * */ + this.track_scrolls = function (parent) { + if (!isBrowser) { + log(logLevelEnums.WARNING, "track_scrolls, window object is not available. Not tracking scrolls."); + return; + } + log(logLevelEnums.INFO, "track_scrolls, Starting to track scrolls"); + if (parent) { + log(logLevelEnums.INFO, "track_scrolls, Tracking the specified children"); + } + parent = parent || window; + isScrollRegistryOpen = true; + trackingScrolls = true; + + add_event_listener(parent, "scroll", processScroll); + add_event_listener(parent, "beforeunload", processScrollView); + }; + + /** + * Generate custom event for all links that were clicked on this page + * @param {Object=} parent - DOM object which children to track, by default it is document body + * */ + this.track_links = function (parent) { + if (!isBrowser) { + log(logLevelEnums.WARNING, "track_links, window object is not available. Not tracking links."); + return; + } + log(logLevelEnums.INFO, "track_links, Starting to track clicks to links"); + if (parent) { + log(logLevelEnums.INFO, "track_links, Tracking the specified children"); + } + parent = parent || document; + /** + * Process click information + * @param {Event} event - click event + */ + function processClick(event) { + // get element which was clicked + var elem = get_closest_element(get_event_target(event), "a"); + + if (elem) { + // cross browser click coordinates + get_page_coord(event); + + // record click event + if (self.check_consent(featureEnums.CLICKS)) { + add_cly_events({ + key: "linkClick", + segmentation: { + href: elem.href, + text: elem.innerText, + id: elem.id, + view: self.getViewUrl() + } + }); + } + } + } + + // add any events you want + add_event_listener(parent, "click", processClick); + }; + + /** + * Generate custom event for all forms that were submitted on this page + * @param {Object=} parent - DOM object which children to track, by default it is document body + * @param {boolean=} trackHidden - provide true to also track hidden inputs, default false + * */ + this.track_forms = function (parent, trackHidden) { + if (!isBrowser) { + log(logLevelEnums.WARNING, "track_forms, window object is not available. Not tracking forms."); + return; + } + log(logLevelEnums.INFO, "track_forms, Starting to track form submissions. DOM object provided:[" + (!!parent) + "] Tracking hidden inputs :[" + (!!trackHidden) + "]"); + parent = parent || document; + /** + * Get name of the input + * @param {HTMLElement} input - HTML input from which to get name + * @returns {String} name of the input + */ + function getInputName(input) { + return input.name || input.id || input.type || input.nodeName; + } + /** + * Process form data + * @param {Event} event - form submission event + */ + function processForm(event) { + var form = get_event_target(event); + var segmentation = { + id: form.attributes.id && form.attributes.id.nodeValue, + name: form.attributes.name && form.attributes.name.nodeValue, + action: form.attributes.action && form.attributes.action.nodeValue, + method: form.attributes.method && form.attributes.method.nodeValue, + view: self.getViewUrl() + }; + + // get input values + var input; + if (typeof form.elements !== "undefined") { + for (var i = 0; i < form.elements.length; i++) { + input = form.elements[i]; + if (input && input.type !== "password" && input.className.indexOf("cly_user_ignore") === -1) { + if (typeof segmentation["input:" + getInputName(input)] === "undefined") { + segmentation["input:" + getInputName(input)] = []; + } + if (input.nodeName.toLowerCase() === "select") { + if (typeof input.multiple !== "undefined") { + segmentation["input:" + getInputName(input)].push(getMultiSelectValues(input)); + } + else { + segmentation["input:" + getInputName(input)].push(input.options[input.selectedIndex].value); + } + } + else if (input.nodeName.toLowerCase() === "input") { + if (typeof input.type !== "undefined") { + if (input.type.toLowerCase() === "checkbox" || input.type.toLowerCase() === "radio") { + if (input.checked) { + segmentation["input:" + getInputName(input)].push(input.value); + } + } + else if (input.type.toLowerCase() !== "hidden" || trackHidden) { + segmentation["input:" + getInputName(input)].push(input.value); + } + } + else { + segmentation["input:" + getInputName(input)].push(input.value); + } + } + else if (input.nodeName.toLowerCase() === "textarea") { + segmentation["input:" + getInputName(input)].push(input.value); + } + else if (typeof input.value !== "undefined") { + segmentation["input:" + getInputName(input)].push(input.value); + } + } + } + for (var key in segmentation) { + if (segmentation[key] && typeof segmentation[key].join === "function") { + segmentation[key] = segmentation[key].join(", "); + } + } + } + + // record submit event + if (self.check_consent(featureEnums.FORMS)) { + add_cly_events({ + key: "formSubmit", + segmentation: segmentation + }); + } + } + + // add any events you want + add_event_listener(parent, "submit", processForm); + }; + + /** + * Collect possible user data from submitted forms. Add cly_user_ignore class to ignore inputs in forms or cly_user_{key} to collect data from this input as specified key, as cly_user_username to save collected value from this input as username property. If not class is provided, Countly SDK will try to determine type of information automatically. + * @param {Object=} parent - DOM object which children to track, by default it is document body + * @param {boolean} [useCustom=false] - submit collected data as custom user properties, by default collects as main user properties + * */ + this.collect_from_forms = function (parent, useCustom) { + if (!isBrowser) { + log(logLevelEnums.WARNING, "collect_from_forms, window object is not available. Not collecting from forms."); + return; + } + log(logLevelEnums.INFO, "collect_from_forms, Starting to collect possible user data. DOM object provided:[" + (!!parent) + "] Submitting custom user property:[" + (!!useCustom) + "]"); + parent = parent || document; + /** + * Process form data + * @param {Event} event - form submission event + */ + function processForm(event) { + var form = get_event_target(event); + var userdata = {}; + var hasUserInfo = false; + + // get input values + var input; + if (typeof form.elements !== "undefined") { + // load labels for inputs + var labelData = {}; + var labels = parent.getElementsByTagName("LABEL"); + var i; + var j; + for (i = 0; i < labels.length; i++) { + if (labels[i].htmlFor && labels[i].htmlFor !== "") { + labelData[labels[i].htmlFor] = labels[i].innerText || labels[i].textContent || labels[i].innerHTML; + } + } + for (i = 0; i < form.elements.length; i++) { + input = form.elements[i]; + if (input && input.type !== "password") { + // check if element should be ignored + if (input.className.indexOf("cly_user_ignore") === -1) { + var value = ""; + // get value from input + if (input.nodeName.toLowerCase() === "select") { + if (typeof input.multiple !== "undefined") { + value = getMultiSelectValues(input); + } + else { + value = input.options[input.selectedIndex].value; + } + } + else if (input.nodeName.toLowerCase() === "input") { + if (typeof input.type !== "undefined") { + if (input.type.toLowerCase() === "checkbox" || input.type.toLowerCase() === "radio") { + if (input.checked) { + value = input.value; + } + } + else { + value = input.value; + } + } + else { + value = input.value; + } + } + else if (input.nodeName.toLowerCase() === "textarea") { + value = input.value; + } + else if (typeof input.value !== "undefined") { + value = input.value; + } + // check if input was marked to be collected + if (input.className && input.className.indexOf("cly_user_") !== -1) { + var classes = input.className.split(" "); + for (j = 0; j < classes.length; j++) { + if (classes[j].indexOf("cly_user_") === 0) { + userdata[classes[j].replace("cly_user_", "")] = value; + hasUserInfo = true; + break; + } + } + } + // check for email + else if ((input.type && input.type.toLowerCase() === "email") + || (input.name && input.name.toLowerCase().indexOf("email") !== -1) + || (input.id && input.id.toLowerCase().indexOf("email") !== -1) + || (input.id && labelData[input.id] && labelData[input.id].toLowerCase().indexOf("email") !== -1) + || (/[^@\s]+@[^@\s]+\.[^@\s]+/).test(value)) { + if (!userdata.email) { + userdata.email = value; + } + hasUserInfo = true; + } + else if ((input.name && input.name.toLowerCase().indexOf("username") !== -1) + || (input.id && input.id.toLowerCase().indexOf("username") !== -1) + || (input.id && labelData[input.id] && labelData[input.id].toLowerCase().indexOf("username") !== -1)) { + if (!userdata.username) { + userdata.username = value; + } + hasUserInfo = true; + } + else if ((input.name && (input.name.toLowerCase().indexOf("tel") !== -1 || input.name.toLowerCase().indexOf("phone") !== -1 || input.name.toLowerCase().indexOf("number") !== -1)) + || (input.id && (input.id.toLowerCase().indexOf("tel") !== -1 || input.id.toLowerCase().indexOf("phone") !== -1 || input.id.toLowerCase().indexOf("number") !== -1)) + || (input.id && labelData[input.id] && (labelData[input.id].toLowerCase().indexOf("tel") !== -1 || labelData[input.id].toLowerCase().indexOf("phone") !== -1 || labelData[input.id].toLowerCase().indexOf("number") !== -1))) { + if (!userdata.phone) { + userdata.phone = value; + } + hasUserInfo = true; + } + else if ((input.name && (input.name.toLowerCase().indexOf("org") !== -1 || input.name.toLowerCase().indexOf("company") !== -1)) + || (input.id && (input.id.toLowerCase().indexOf("org") !== -1 || input.id.toLowerCase().indexOf("company") !== -1)) + || (input.id && labelData[input.id] && (labelData[input.id].toLowerCase().indexOf("org") !== -1 || labelData[input.id].toLowerCase().indexOf("company") !== -1))) { + if (!userdata.organization) { + userdata.organization = value; + } + hasUserInfo = true; + } + else if ((input.name && input.name.toLowerCase().indexOf("name") !== -1) + || (input.id && input.id.toLowerCase().indexOf("name") !== -1) + || (input.id && labelData[input.id] && labelData[input.id].toLowerCase().indexOf("name") !== -1)) { + if (!userdata.name) { + userdata.name = ""; + } + userdata.name += value + " "; + hasUserInfo = true; + } + } + } + } + } + + // record user info, if any + if (hasUserInfo) { + log(logLevelEnums.INFO, "collect_from_forms, Gathered user data", userdata); + if (useCustom) { + self.user_details({ custom: userdata }); + } + else { + self.user_details(userdata); + } + } + } + + // add any events you want + add_event_listener(parent, "submit", processForm); + }; + + /** + * Collect information about user from Facebook, if your website integrates Facebook SDK. Call this method after Facebook SDK is loaded and user is authenticated. + * @param {Object=} custom - Custom keys to collected from Facebook, key will be used to store as key in custom user properties and value as key in Facebook graph object. For example, {"tz":"timezone"} will collect Facebook's timezone property, if it is available and store it in custom user's property under "tz" key. If you want to get value from some sub object properties, then use dot as delimiter, for example, {"location":"location.name"} will collect data from Facebook's {"location":{"name":"MyLocation"}} object and store it in user's custom property "location" key + * */ + this.collect_from_facebook = function (custom) { + if (!isBrowser) { + log(logLevelEnums.WARNING, "collect_from_facebook, window object is not available. Not collecting from Facebook."); + return; + } + if (typeof FB === "undefined" || !FB || !FB.api) { + log(logLevelEnums.ERROR, "collect_from_facebook, Facebook SDK is not available"); + return; + } + log(logLevelEnums.INFO, "collect_from_facebook, Starting to collect possible user data"); + /* globals FB */ + FB.api("/me", function (resp) { + var data = {}; + if (resp.name) { + data.name = resp.name; + } + if (resp.email) { + data.email = resp.email; + } + if (resp.gender === "male") { + data.gender = "M"; + } + else if (resp.gender === "female") { + data.gender = "F"; + } + if (resp.birthday) { + var byear = resp.birthday.split("/").pop(); + if (byear && byear.length === 4) { + data.byear = byear; + } + } + if (resp.work && resp.work[0] && resp.work[0].employer && resp.work[0].employer.name) { + data.organization = resp.work[0].employer.name; + } + // check if any custom keys to collect + if (custom) { + data.custom = {}; + for (var i in custom) { + var parts = custom[i].split("."); + var get = resp; + for (var j = 0; j < parts.length; j++) { + get = get[parts[j]]; + if (typeof get === "undefined") { + break; + } + } + if (typeof get !== "undefined") { + data.custom[i] = get; + } + } + } + self.user_details(data); + }); + }; + /** + * Opts out user of any metric tracking + * */ + this.opt_out = function () { + log(logLevelEnums.INFO, "opt_out, Opting out the user"); + this.ignore_visitor = true; + setValueInStorage("cly_ignore", true); + }; + + /** + * Opts in user for tracking, if complies with other user ignore rules like bot useragent and prefetch settings + * */ + this.opt_in = function () { + log(logLevelEnums.INFO, "opt_in, Opting in the user"); + setValueInStorage("cly_ignore", false); + this.ignore_visitor = false; + checkIgnore(); + if (!this.ignore_visitor && !hasPulse) { + heartBeat(); + } + }; + /** + * Provide information about user + * @param {Object} ratingWidget - object with rating widget properties + * @param {string} ratingWidget.widget_id - id of the widget in the dashboard + * @param {boolean=} ratingWidget.contactMe - did user give consent to contact him + * @param {string=} ratingWidget.platform - user's platform (will be filled if not provided) + * @param {string=} ratingWidget.app_version - app's app version (will be filled if not provided) + * @param {number} ratingWidget.rating - user's rating from 1 to 5 + * @param {string=} ratingWidget.email - user's email + * @param {string=} ratingWidget.comment - user's comment + * + * @deprecated use 'recordRatingWidgetWithID' in place of this call + * */ + this.report_feedback = function (ratingWidget) { + log(logLevelEnums.WARNING, "report_feedback, Deprecated function call! Use 'recordRatingWidgetWithID' or 'reportFeedbackWidgetManually' in place of this call. Call will be redirected to 'recordRatingWidgetWithID' now!"); + this.recordRatingWidgetWithID(ratingWidget); + }; + /** + * Provide information about user + * @param {Object} ratingWidget - object with rating widget properties + * @param {string} ratingWidget.widget_id - id of the widget in the dashboard + * @param {boolean=} ratingWidget.contactMe - did user give consent to contact him + * @param {string=} ratingWidget.platform - user's platform (will be filled if not provided) + * @param {string=} ratingWidget.app_version - app's app version (will be filled if not provided) + * @param {number} ratingWidget.rating - user's rating from 1 to 5 + * @param {string=} ratingWidget.email - user's email + * @param {string=} ratingWidget.comment - user's comment + * */ + this.recordRatingWidgetWithID = function (ratingWidget) { + log(logLevelEnums.INFO, "recordRatingWidgetWithID, Providing information about user with ID: [ " + ratingWidget.widget_id + " ]"); + if (!this.check_consent(featureEnums.STAR_RATING)) { + return; + } + if (!ratingWidget.widget_id) { + log(logLevelEnums.ERROR, "recordRatingWidgetWithID, Rating Widget must contain widget_id property"); + return; + } + if (!ratingWidget.rating) { + log(logLevelEnums.ERROR, "recordRatingWidgetWithID, Rating Widget must contain rating property"); + return; + } + var props = ["widget_id", "contactMe", "platform", "app_version", "rating", "email", "comment"]; + var event = { + key: internalEventKeyEnums.STAR_RATING, + count: 1, + segmentation: {} + }; + event.segmentation = createNewObjectFromProperties(ratingWidget, props); + if (!event.segmentation.app_version) { + event.segmentation.app_version = this.metrics._app_version || this.app_version; + } + if (event.segmentation.rating > 5) { + log(logLevelEnums.WARNING, "recordRatingWidgetWithID, You have entered a rating higher than 5. Changing it back to 5 now."); + event.segmentation.rating = 5; + } + else if (event.segmentation.rating < 1) { + log(logLevelEnums.WARNING, "recordRatingWidgetWithID, You have entered a rating lower than 1. Changing it back to 1 now."); + event.segmentation.rating = 1; + } + log(logLevelEnums.INFO, "recordRatingWidgetWithID, Reporting Rating Widget: ", event); + add_cly_events(event); + }; + /** + * Report information about survey, nps or rating widget answers/results + * @param {Object} CountlyFeedbackWidget - it is the widget object retrieved from get_available_feedback_widgets + * @param {Object} CountlyWidgetData - it is the widget data object retrieved from getFeedbackWidgetData + * @param {Object} widgetResult - it is the widget results that need to be reported, different for all widgets, if provided as null it means the widget was closed + * widgetResult For NPS + * Should include rating and comment from the nps. Example: + * widgetResult = {rating: 3, comment: "comment"} + * + * widgetResult For Survey + * Should include questions ids and their answers as key/value pairs. Keys should be formed as “answ-”+[question.id]. Example: + * widgetResult = { + * "answ-1602694029-0": "Some text field long answer", //for text fields + * "answ-1602694029-1": 7, //for rating + * "answ-1602694029-2": "ch1602694029-0", //There is a question with choices like multi or radio. It is a choice key. + * "answ-1602694029-3": "ch1602694030-0,ch1602694030-1" //In case 2 selected + * } + * + * widgetResult For Rating Widget + * Should include rating, email, comment and contact consent information. Example: + * widgetResult = { + * rating: 2, + * email: "email@mail.com", + * contactMe: true, + * comment: "comment" + * } + * */ + this.reportFeedbackWidgetManually = function (CountlyFeedbackWidget, CountlyWidgetData, widgetResult) { + if (!this.check_consent(featureEnums.FEEDBACK)) { + return; + } + if (!(CountlyFeedbackWidget && CountlyWidgetData)) { + log(logLevelEnums.ERROR, "reportFeedbackWidgetManually, Widget data and/or Widget object not provided. Aborting."); + return; + } + if (!CountlyFeedbackWidget._id) { + log(logLevelEnums.ERROR, "reportFeedbackWidgetManually, Feedback Widgets must contain _id property"); + return; + } + if (offlineMode) { + log(logLevelEnums.ERROR, "reportFeedbackWidgetManually, Feedback Widgets can not be reported in offline mode"); + return; + } + + log(logLevelEnums.INFO, "reportFeedbackWidgetManually, Providing information about user with, provided result of the widget with ID: [ " + CountlyFeedbackWidget._id + " ] and type: [" + CountlyFeedbackWidget.type + "]"); + + // type specific checks to see if everything was provided + var props = []; + var type = CountlyFeedbackWidget.type; + var eventKey; + if (type === "nps") { + if (widgetResult) { + if (!widgetResult.rating) { + log(logLevelEnums.ERROR, "reportFeedbackWidgetManually, Widget must contain rating property"); + return; + } + widgetResult.rating = Math.round(widgetResult.rating); + if (widgetResult.rating > 10) { + log(logLevelEnums.WARNING, "reportFeedbackWidgetManually, You have entered a rating higher than 10. Changing it back to 10 now."); + widgetResult.rating = 10; + } + else if (widgetResult.rating < 0) { + log(logLevelEnums.WARNING, "reportFeedbackWidgetManually, You have entered a rating lower than 0. Changing it back to 0 now."); + widgetResult.rating = 0; + } + props = ["rating", "comment"]; + } + eventKey = internalEventKeyEnums.NPS; + } + else if (type === "survey") { + if (widgetResult) { + if (Object.keys(widgetResult).length < 1) { + log(logLevelEnums.ERROR, "reportFeedbackWidgetManually, Widget should have answers to be reported"); + return; + } + props = Object.keys(widgetResult); + } + eventKey = internalEventKeyEnums.SURVEY; + } + else if (type === "rating") { + if (widgetResult) { + if (!widgetResult.rating) { + log(logLevelEnums.ERROR, "reportFeedbackWidgetManually, Widget must contain rating property"); + return; + } + widgetResult.rating = Math.round(widgetResult.rating); + if (widgetResult.rating > 5) { + log(logLevelEnums.WARNING, "reportFeedbackWidgetManually, You have entered a rating higher than 5. Changing it back to 5 now."); + widgetResult.rating = 5; + } + else if (widgetResult.rating < 1) { + log(logLevelEnums.WARNING, "reportFeedbackWidgetManually, You have entered a rating lower than 1. Changing it back to 1 now."); + widgetResult.rating = 1; + } + props = ["rating", "comment", "email", "contactMe"]; + } + eventKey = internalEventKeyEnums.STAR_RATING; + } + else { + log(logLevelEnums.ERROR, "reportFeedbackWidgetManually, Widget has an unacceptable type"); + return; + } + + // event template + var event = { + key: eventKey, + count: 1, + segmentation: { + widget_id: CountlyFeedbackWidget._id, + platform: this.platform, + app_version: this.metrics._app_version || this.app_version + } + }; + + if (widgetResult === null) { + event.segmentation.closed = 1; + } + else { + // add response to the segmentation + event.segmentation = addNewProperties(event.segmentation, widgetResult, props); + } + + // add event + log(logLevelEnums.INFO, "reportFeedbackWidgetManually, Reporting " + type + ": ", event); + add_cly_events(event); + }; + /** + * Show specific widget popup by the widget id + * @param {string} id - id value of related rating widget, you can get this value by click "Copy ID" button in row menu at "Feedback widgets" screen + * + * @deprecated use 'presentRatingWidgetWithID' in place of this call + */ + this.show_feedback_popup = function (id) { + if (!isBrowser) { + log(logLevelEnums.WARNING, "show_feedback_popup, window object is not available. Not showing feedback popup."); + return; + } + log(logLevelEnums.WARNING, "show_feedback_popup, Deprecated function call! Use 'presentRatingWidgetWithID' in place of this call. Call will be redirected now!"); + this.presentRatingWidgetWithID(id); + }; + /** + * Show specific widget popup by the widget id + * @param {string} id - id value of related rating widget, you can get this value by click "Copy ID" button in row menu at "Feedback widgets" screen + */ + this.presentRatingWidgetWithID = function (id) { + if (!isBrowser) { + log(logLevelEnums.WARNING, "presentRatingWidgetWithID, window object is not available. Not showing rating widget popup."); + return; + } + log(logLevelEnums.INFO, "presentRatingWidgetWithID, Showing rating widget popup for the widget with ID: [ " + id + " ]"); + if (!this.check_consent(featureEnums.STAR_RATING)) { + return; + } + if (offlineMode) { + log(logLevelEnums.ERROR, "presentRatingWidgetWithID, Cannot show ratingWidget popup in offline mode"); + } + else { + makeNetworkRequest("presentRatingWidgetWithID,", this.url + "/o/feedback/widget", { widget_id: id, av: self.app_version }, function (err, params, responseText) { + if (err) { + // error has been logged by the request function + return; + } + try { + // widget object + var currentWidget = JSON.parse(responseText); + processWidget(currentWidget, false); + } + catch (JSONParseError) { + log(logLevelEnums.ERROR, "presentRatingWidgetWithID, JSON parse failed: " + JSONParseError); + } + // JSON array can pass + }, true); + } + }; + + /** + * Prepare rating widgets according to the current options + * @param {array=} enableWidgets - widget ids array + * + * @deprecated use 'initializeRatingWidgets' in place of this call + */ + this.initialize_feedback_popups = function (enableWidgets) { + if (!isBrowser) { + log(logLevelEnums.WARNING, "initialize_feedback_popups, window object is not available. Not initializing feedback popups."); + return; + } + log(logLevelEnums.WARNING, "initialize_feedback_popups, Deprecated function call! Use 'initializeRatingWidgets' in place of this call. Call will be redirected now!"); + this.initializeRatingWidgets(enableWidgets); + }; + /** + * Prepare rating widgets according to the current options + * @param {array=} enableWidgets - widget ids array + */ + this.initializeRatingWidgets = function (enableWidgets) { + if (!isBrowser) { + log(logLevelEnums.WARNING, "initializeRatingWidgets, window object is not available. Not initializing rating widgets."); + return; + } + log(logLevelEnums.INFO, "initializeRatingWidgets, Initializing rating widget with provided widget IDs:[ " + enableWidgets + "]"); + if (!this.check_consent(featureEnums.STAR_RATING)) { + return; + } + if (!enableWidgets) { + enableWidgets = getValueFromStorage("cly_fb_widgets"); + } + + // remove all old stickers before add new one + var stickers = document.getElementsByClassName("countly-feedback-sticker"); + while (stickers.length > 0) { + stickers[0].remove(); + } + + makeNetworkRequest("initializeRatingWidgets,", this.url + "/o/feedback/multiple-widgets-by-id", { widgets: JSON.stringify(enableWidgets), av: self.app_version }, function (err, params, responseText) { + if (err) { + // error has been logged by the request function + return; + } + try { + // widgets array + var widgets = JSON.parse(responseText); + for (var i = 0; i < widgets.length; i++) { + if (widgets[i].is_active === "true") { + var target_devices = widgets[i].target_devices; + var currentDevice = userAgentDeviceDetection(); + // device match check + if (target_devices[currentDevice]) { + // is hide sticker option selected? + if (typeof widgets[i].hide_sticker === "string") { + widgets[i].hide_sticker = widgets[i].hide_sticker === "true"; + } + // is target_page option provided as "All"? + if (widgets[i].target_page === "all" && !widgets[i].hide_sticker) { + processWidget(widgets[i], true); + } + // is target_page option provided as "selected"? + else { + var pages = widgets[i].target_pages; + for (var k = 0; k < pages.length; k++) { + var isWildcardMatched = pages[k].substr(0, pages[k].length - 1) === window.location.pathname.substr(0, pages[k].length - 1); + var isFullPathMatched = pages[k] === window.location.pathname; + var isContainAsterisk = pages[k].includes("*"); + if (((isContainAsterisk && isWildcardMatched) || isFullPathMatched) && !widgets[i].hide_sticker) { + processWidget(widgets[i], true); + } + } + } + } + } + } + } + catch (JSONParseError) { + log(logLevelEnums.ERROR, "initializeRatingWidgets, JSON parse error: " + JSONParseError); + } + // JSON array can pass + }, true); + }; + + /** + * Show rating widget popup by passed widget ids array + * @param {object=} params - required - includes "popups" property as string array of widgets ("widgets" for old versions) + * example params: {"popups":["5b21581b967c4850a7818617"]} + * + * @deprecated use 'enableRatingWidgets' in place of this call + * */ + this.enable_feedback = function (params) { + if (!isBrowser) { + log(logLevelEnums.WARNING, "enable_feedback, window object is not available. Not enabling feedback."); + return; + } + log(logLevelEnums.WARNING, "enable_feedback, Deprecated function call! Use 'enableRatingWidgets' in place of this call. Call will be redirected now!"); + this.enableRatingWidgets(params); + }; + /** + * Show rating widget popup by passed widget ids array + * @param {object=} params - required - includes "popups" property as string array of widgets ("widgets" for old versions) + * example params: {"popups":["5b21581b967c4850a7818617"]} + * */ + this.enableRatingWidgets = function (params) { + if (!isBrowser) { + log(logLevelEnums.WARNING, "enableRatingWidgets, window object is not available. Not enabling rating widgets."); + return; + } + log(logLevelEnums.INFO, "enableRatingWidgets, Enabling rating widget with params:", params); + if (!this.check_consent(featureEnums.STAR_RATING)) { + return; + } + if (offlineMode) { + log(logLevelEnums.ERROR, "enableRatingWidgets, Cannot enable rating widgets in offline mode"); + } + else { + setValueInStorage("cly_fb_widgets", params.popups || params.widgets); + // inject feedback styles + loadCSS(this.url + "/star-rating/stylesheets/countly-feedback-web.css"); + // get enable widgets by app_key + // define xhr object + var enableWidgets = params.popups || params.widgets; + + if (enableWidgets.length > 0) { + document.body.insertAdjacentHTML("beforeend", "

"); + this.initializeRatingWidgets(enableWidgets); + } + else { + log(logLevelEnums.ERROR, "enableRatingWidgets, You should provide at least one widget id as param. Read documentation for more detail. https://resources.count.ly/plugins/feedback"); + } + } + }; + + /** + * This function retrieves all associated widget information (IDs, type, name etc in an array/list of objects) of your app + * @param {Function} callback - Callback function with two parameters, 1st for returned list, 2nd for error + * */ + this.get_available_feedback_widgets = function (callback) { + log(logLevelEnums.INFO, "get_available_feedback_widgets, Getting the feedback list, callback function is provided:[" + (!!callback) + "]"); + if (!this.check_consent(featureEnums.FEEDBACK)) { + if (callback) { + callback(null, new Error("Consent for feedback not provided.")); + } + return; + } + + if (offlineMode) { + log(logLevelEnums.ERROR, "get_available_feedback_widgets, Cannot enable feedback widgets in offline mode."); + return; + } + + var url = this.url + readPath; + var data = { + method: featureEnums.FEEDBACK, + device_id: this.device_id, + app_key: this.app_key, + av: self.app_version + }; + + makeNetworkRequest("get_available_feedback_widgets,", url, data, function (err, params, responseText) { + if (err) { + // error has been logged by the request function + if (callback) { + callback(null, err); + } + return; + } + + try { + var response = JSON.parse(responseText); + var feedbacks = response.result || []; + if (callback) { + callback(feedbacks, null); + } + } + catch (error) { + log(logLevelEnums.ERROR, "get_available_feedback_widgets, Error while parsing feedback widgets list: " + error); + if (callback) { + callback(null, error); + } + } + // expected response is JSON object + }, false); + }; + + /** + * Get feedback (nps, survey or rating) widget data, like questions, message etc. + * @param {Object} CountlyFeedbackWidget - Widget object, retrieved from 'get_available_feedback_widgets' + * @param {Function} callback - Callback function with two parameters, 1st for returned widget data, 2nd for error + * */ + this.getFeedbackWidgetData = function (CountlyFeedbackWidget, callback) { + if (!CountlyFeedbackWidget.type) { + log(logLevelEnums.ERROR, "getFeedbackWidgetData, Expected the provided widget object to have a type but got: [" + JSON.stringify(CountlyFeedbackWidget) + "], aborting."); + return; + } + log(logLevelEnums.INFO, "getFeedbackWidgetData, Retrieving data for: [" + JSON.stringify(CountlyFeedbackWidget) + "], callback function is provided:[" + (!!callback) + "]"); + if (!this.check_consent(featureEnums.FEEDBACK)) { + if (callback) { + callback(null, new Error("Consent for feedback not provided.")); + } + return; + } + + if (offlineMode) { + log(logLevelEnums.ERROR, "getFeedbackWidgetData, Cannot enable feedback widgets in offline mode."); + return; + } + + var url = this.url; + var data = { + widget_id: CountlyFeedbackWidget._id, + shown: 1, + sdk_version: SDK_VERSION, + sdk_name: SDK_NAME, + platform: this.platform, + app_version: this.app_version + }; + + if (CountlyFeedbackWidget.type === "nps") { + url += "/o/surveys/nps/widget"; + } + else if (CountlyFeedbackWidget.type === "survey") { + url += "/o/surveys/survey/widget"; + } + else if (CountlyFeedbackWidget.type === "rating") { + url += "/o/surveys/rating/widget"; + } + else { + log(logLevelEnums.ERROR, "getFeedbackWidgetData, Unknown type info: [" + CountlyFeedbackWidget.type + "]"); + return; + } + + makeNetworkRequest("getFeedbackWidgetData,", url, data, responseCallback, true); + + /** + * Server response would be evaluated here + * @param {*} err - error object + * @param {*} params - parameters + * @param {*} responseText - server reponse text + */ + function responseCallback(err, params, responseText) { + if (err) { + // error has been logged by the request function + if (callback) { + callback(null, err); + } + return; + } + + try { + var response = JSON.parse(responseText); + // return parsed response + if (callback) { + callback(response, null); + } + } + catch (error) { + log(logLevelEnums.ERROR, "getFeedbackWidgetData, Error while parsing feedback widgets list: " + error); + if (callback) { + callback(null, error); + } + } + } + }; + + /** + * Present the feedback widget in webview + * @param {Object} presentableFeedback - Current presentable feedback + * @param {String} [id] - DOM id to append the feedback widget (optional, in case not used pass undefined) + * @param {String} [className] - Class name to append the feedback widget (optional, in case not used pass undefined) + * @param {Object} [feedbackWidgetSegmentation] - Segmentation object to be passed to the feedback widget (optional) + * */ + this.present_feedback_widget = function (presentableFeedback, id, className, feedbackWidgetSegmentation) { + if (!isBrowser) { + log(logLevelEnums.WARNING, "present_feedback_widget, window object is not available. Not presenting feedback widget."); + return; + } + // TODO: feedbackWidgetSegmentation implementation only assumes we want to send segmentation data. Change it if we add more data to the custom object. + log(logLevelEnums.INFO, "present_feedback_widget, Presenting the feedback widget by appending to the element with ID: [ " + id + " ] and className: [ " + className + " ]"); + + if (!this.check_consent(featureEnums.FEEDBACK)) { + return; + } + + if (!presentableFeedback + || (typeof presentableFeedback !== "object") + || Array.isArray(presentableFeedback) + ) { + log(logLevelEnums.ERROR, "present_feedback_widget, Please provide at least one feedback widget object."); + return; + } + + log(logLevelEnums.INFO, "present_feedback_widget, Adding segmentation to feedback widgets:[" + JSON.stringify(feedbackWidgetSegmentation) + "]"); + if (!feedbackWidgetSegmentation || typeof feedbackWidgetSegmentation !== "object" || Object.keys(feedbackWidgetSegmentation).length === 0) { + log(logLevelEnums.DEBUG, "present_feedback_widget, Segmentation is not an object or empty"); + feedbackWidgetSegmentation = null; + } + + try { + var url = this.url; + + if (presentableFeedback.type === "nps") { + log(logLevelEnums.DEBUG, "present_feedback_widget, Widget type: nps."); + url += "/feedback/nps"; + } + else if (presentableFeedback.type === "survey") { + log(logLevelEnums.DEBUG, "present_feedback_widget, Widget type: survey."); + url += "/feedback/survey"; + } + else if (presentableFeedback.type === "rating") { + log(logLevelEnums.DEBUG, "present_feedback_widget, Widget type: rating."); + url += "/feedback/rating"; + } + else { + log(logLevelEnums.ERROR, "present_feedback_widget, Feedback widget only accepts nps, rating and survey types."); + return; + } + + var passedOrigin = window.origin || window.location.origin; + var feedbackWidgetFamily; + + // set feedback widget family as ratings and load related style file when type is ratings + if (presentableFeedback.type === "rating") { + log(logLevelEnums.DEBUG, "present_feedback_widget, Loading css for rating widget."); + feedbackWidgetFamily = "ratings"; + loadCSS(this.url + "/star-rating/stylesheets/countly-feedback-web.css"); + } + // if it's not ratings, it means we need to name it as surveys and load related style file + // (at least until we add new type in future) + else { + log(logLevelEnums.DEBUG, "present_feedback_widget, Loading css for survey or nps."); + loadCSS(this.url + "/surveys/stylesheets/countly-surveys.css"); + feedbackWidgetFamily = "surveys"; + } + + url += "?widget_id=" + presentableFeedback._id; + url += "&app_key=" + this.app_key; + url += "&device_id=" + this.device_id; + url += "&sdk_name=" + SDK_NAME; + url += "&platform=" + this.platform; + url += "&app_version=" + this.app_version; + url += "&sdk_version=" + SDK_VERSION; + if (feedbackWidgetSegmentation) { + var customObjectToSendWithTheWidget = {}; + customObjectToSendWithTheWidget.sg = feedbackWidgetSegmentation; + url += "&custom=" + JSON.stringify(customObjectToSendWithTheWidget); + } + // Origin is passed to the popup so that it passes it back in the postMessage event + // Only web SDK passes origin and web + url += "&origin=" + passedOrigin; + url += "&widget_v=web"; + + var iframe = document.createElement("iframe"); + iframe.src = url; + iframe.name = "countly-" + feedbackWidgetFamily + "-iframe"; + iframe.id = "countly-" + feedbackWidgetFamily + "-iframe"; + + var initiated = false; + iframe.onload = function () { + // This is used as a fallback for browsers where postMessage API doesn't work. + + if (initiated) { + // On iframe reset remove the iframe and the overlay. + document.getElementById("countly-" + feedbackWidgetFamily + "-wrapper-" + presentableFeedback._id).style.display = "none"; + document.getElementById("csbg").style.display = "none"; + } + + // Setting initiated marks the first time initiation of the iframe. + // When initiated for the first time, do not hide the survey because you want + // the survey to be shown for the first time. + // Any subsequent onload means that the survey is being refreshed or reset. + // This time hide it as being done in the above check. + initiated = true; + log(logLevelEnums.DEBUG, "present_feedback_widget, Loaded iframe."); + }; + + var overlay = document.getElementById("csbg"); + while (overlay) { + // Remove any existing overlays + overlay.remove(); + overlay = document.getElementById("csbg"); + log(logLevelEnums.DEBUG, "present_feedback_widget, Removing past overlay."); + } + + var wrapper = document.getElementsByClassName("countly-" + feedbackWidgetFamily + "-wrapper"); + for (var i = 0; i < wrapper.length; i++) { + // Remove any existing feedback wrappers + wrapper[i].remove(); + log(logLevelEnums.DEBUG, "present_feedback_widget, Removed a wrapper."); + } + + wrapper = document.createElement("div"); + wrapper.className = "countly-" + feedbackWidgetFamily + "-wrapper"; + wrapper.id = "countly-" + feedbackWidgetFamily + "-wrapper-" + presentableFeedback._id; + + if (presentableFeedback.type === "survey") { + // Set popup position + wrapper.className = wrapper.className + " " + presentableFeedback.appearance.position; + } + + var element = document.body; + var found = false; + + if (id) { + if (document.getElementById(id)) { + element = document.getElementById(id); + found = true; + } + else { + log(logLevelEnums.ERROR, "present_feedback_widget, Provided ID not found."); + } + } + + if (!found) { + // If the id element is not found check if a class was provided + if (className) { + if (document.getElementsByClassName(className)[0]) { + element = document.getElementsByClassName(className)[0]; + } + else { + log(logLevelEnums.ERROR, "present_feedback_widget, Provided class not found."); + } + } + } + + element.insertAdjacentHTML("beforeend", "
"); + element.appendChild(wrapper); + if (presentableFeedback.type === "rating") { + // create a overlay div and inject it to wrapper + var ratingsOverlay = document.createElement("div"); + ratingsOverlay.className = "countly-ratings-overlay"; + ratingsOverlay.id = "countly-ratings-overlay-" + presentableFeedback._id; + wrapper.appendChild(ratingsOverlay); + log(logLevelEnums.DEBUG, "present_feedback_widget, appended the rating overlay to wrapper"); + + // add an event listener for the overlay + // so if someone clicked on the overlay, we can close popup + add_event_listener(document.getElementById("countly-ratings-overlay-" + presentableFeedback._id), "click", function () { + document.getElementById("countly-ratings-wrapper-" + presentableFeedback._id).style.display = "none"; + }); + } + + wrapper.appendChild(iframe); + log(logLevelEnums.DEBUG, "present_feedback_widget, Appended the iframe"); + + add_event_listener(window, "message", function (e) { + var data = {}; + try { + data = JSON.parse(e.data); + log(logLevelEnums.DEBUG, "present_feedback_widget, Parsed response message " + data); + } + catch (ex) { + log(logLevelEnums.ERROR, "present_feedback_widget, Error while parsing message body " + ex); + } + + if (!data.close) { + log(logLevelEnums.DEBUG, "present_feedback_widget, Closing signal not sent yet"); + return; + } + + document.getElementById("countly-" + feedbackWidgetFamily + "-wrapper-" + presentableFeedback._id).style.display = "none"; + document.getElementById("csbg").style.display = "none"; + }); + + if (presentableFeedback.type === "survey") { + var surveyShown = false; + + // Set popup show policy + switch (presentableFeedback.showPolicy) { + case "afterPageLoad": + if (document.readyState === "complete") { + if (!surveyShown) { + surveyShown = true; + showSurvey(presentableFeedback); + } + } + else { + add_event_listener(document, "readystatechange", function (e) { + if (e.target.readyState === "complete") { + if (!surveyShown) { + surveyShown = true; + showSurvey(presentableFeedback); + } + } + }); + } + + break; + + case "afterConstantDelay": + setTimeout(function () { + if (!surveyShown) { + surveyShown = true; + showSurvey(presentableFeedback); + } + }, 10000); + + break; + + case "onAbandon": + if (document.readyState === "complete") { + add_event_listener(document, "mouseleave", function () { + if (!surveyShown) { + surveyShown = true; + showSurvey(presentableFeedback); + } + }); + } + else { + add_event_listener(document, "readystatechange", function (e) { + if (e.target.readyState === "complete") { + add_event_listener(document, "mouseleave", function () { + if (!surveyShown) { + surveyShown = true; + showSurvey(presentableFeedback); + } + }); + } + }); + } + + break; + + case "onScrollHalfwayDown": + add_event_listener(window, "scroll", function () { + if (!surveyShown) { + var scrollY = Math.max(window.scrollY, document.body.scrollTop, document.documentElement.scrollTop); + var documentHeight = getDocHeight(); + if (scrollY >= (documentHeight / 2)) { + surveyShown = true; + showSurvey(presentableFeedback); + } + } + }); + + break; + + default: + if (!surveyShown) { + surveyShown = true; + showSurvey(presentableFeedback); + } + } + } + else if (presentableFeedback.type === "nps") { + document.getElementById("countly-" + feedbackWidgetFamily + "-wrapper-" + presentableFeedback._id).style.display = "block"; + document.getElementById("csbg").style.display = "block"; + } + else if (presentableFeedback.type === "rating") { + var ratingShown = false; + + if (document.readyState === "complete") { + if (!ratingShown) { + ratingShown = true; + showRatingForFeedbackWidget(presentableFeedback); + } + } + else { + add_event_listener(document, "readystatechange", function (e) { + if (e.target.readyState === "complete") { + if (!ratingShown) { + ratingShown = true; + showRatingForFeedbackWidget(presentableFeedback); + } + } + }); + } + } + } + catch (e) { + log(logLevelEnums.ERROR, "present_feedback_widget, Something went wrong while presenting the widget: " + e); + } + + /** + * Function to show survey popup + * @param {Object} feedback - feedback object + */ + function showSurvey(feedback) { + document.getElementById("countly-surveys-wrapper-" + feedback._id).style.display = "block"; + document.getElementById("csbg").style.display = "block"; + } + + /** + * Function to prepare rating sticker and feedback widget + * @param {Object} feedback - feedback object + */ + function showRatingForFeedbackWidget(feedback) { + // render sticker if hide sticker property isn't set + if (!feedback.appearance.hideS) { + log(logLevelEnums.DEBUG, "present_feedback_widget, handling the sticker as it was not set to hidden"); + // create sticker wrapper element + var sticker = document.createElement("div"); + sticker.innerText = feedback.appearance.text; + sticker.style.color = ((feedback.appearance.text_color.length < 7) ? "#" + feedback.appearance.text_color : feedback.appearance.text_color); + sticker.style.backgroundColor = ((feedback.appearance.bg_color.length < 7) ? "#" + feedback.appearance.bg_color : feedback.appearance.bg_color); + sticker.className = "countly-feedback-sticker " + feedback.appearance.position + "-" + feedback.appearance.size; + sticker.id = "countly-feedback-sticker-" + feedback._id; + document.body.appendChild(sticker); + + // sticker event handler + add_event_listener(document.getElementById("countly-feedback-sticker-" + feedback._id), "click", function () { + document.getElementById("countly-ratings-wrapper-" + feedback._id).style.display = "flex"; + document.getElementById("csbg").style.display = "block"; + }); + } + + // feedback widget close event handler + // TODO: Check if this is still valid + add_event_listener(document.getElementById("countly-feedback-close-icon-" + feedback._id), "click", function () { + document.getElementById("countly-ratings-wrapper-" + feedback._id).style.display = "none"; + document.getElementById("csbg").style.display = "none"; + }); + } + }; + + /** + * Record and report error, this is were tracked errors are modified and send to the request queue + * @param {Error} err - Error object + * @param {Boolean} nonfatal - nonfatal if true and false if fatal + * @param {Object} segments - custom crash segments + */ + this.recordError = function (err, nonfatal, segments) { + log(logLevelEnums.INFO, "recordError, Recording error"); + if (this.check_consent(featureEnums.CRASHES) && err) { + // crashSegments, if not null, was set while enabling error tracking + segments = segments || crashSegments; + var error = ""; + if (typeof err === "object") { + if (typeof err.stack !== "undefined") { + error = err.stack; + } + else { + if (typeof err.name !== "undefined") { + error += err.name + ":"; + } + if (typeof err.message !== "undefined") { + error += err.message + "\n"; + } + if (typeof err.fileName !== "undefined") { + error += "in " + err.fileName + "\n"; + } + if (typeof err.lineNumber !== "undefined") { + error += "on " + err.lineNumber; + } + if (typeof err.columnNumber !== "undefined") { + error += ":" + err.columnNumber; + } + } + } + else { + error = err + ""; + } + // character limit check + if (error.length > (self.maxStackTraceLineLength * self.maxStackTraceLinesPerThread)) { + log(logLevelEnums.DEBUG, "record_error, Error stack is too long will be truncated"); + // convert error into an array split from each newline + var splittedError = error.split("\n"); + // trim the array if it is too long + if (splittedError.length > self.maxStackTraceLinesPerThread) { + splittedError = splittedError.splice(0, self.maxStackTraceLinesPerThread); + } + // trim each line to a given limit + for (var i = 0, len = splittedError.length; i < len; i++) { + if (splittedError[i].length > self.maxStackTraceLineLength) { + splittedError[i] = splittedError[i].substring(0, self.maxStackTraceLineLength); + } + } + // turn modified array back into error string + error = splittedError.join("\n"); + } + + nonfatal = !!(nonfatal); + var metrics = getMetrics(); + var obj = { _resolution: metrics._resolution, _error: error, _app_version: metrics._app_version, _run: getTimestamp() - startTime }; + + obj._not_os_specific = true; + obj._javascript = true; + + var battery = navigator.battery || navigator.webkitBattery || navigator.mozBattery || navigator.msBattery; + if (battery) { + obj._bat = Math.floor(battery.level * 100); + } + + if (typeof navigator.onLine !== "undefined") { + obj._online = !!(navigator.onLine); + } + + if (isBrowser) { + obj._background = !(document.hasFocus()); + } + + if (crashLogs.length > 0) { + obj._logs = crashLogs.join("\n"); + } + crashLogs = []; + + obj._nonfatal = nonfatal; + + obj._view = this.getViewName(); + + if (typeof segments !== "undefined") { + // truncate custom crash segment's key value pairs + segments = truncateObject(segments, self.maxKeyLength, self.maxValueSize, self.maxSegmentationValues, "record_error", log); + obj._custom = segments; + } + + try { + var canvas = document.createElement("canvas"); + var gl = canvas.getContext("experimental-webgl"); + obj._opengl = gl.getParameter(gl.VERSION); + } + catch (ex) { + log(logLevelEnums.ERROR, "Could not get the experimental-webgl context: " + ex); + } + + // send userAgent string with the crash object incase it gets removed by a gateway + var req = {}; + req.crash = JSON.stringify(obj); + req.metrics = JSON.stringify({ _ua: metrics._ua }); + + toRequestQueue(req); + } + }; + + /** + * Check if user or visit should be ignored + */ + function checkIgnore() { + if (self.ignore_prefetch && isBrowser && typeof document.visibilityState !== "undefined" && document.visibilityState === "prerender") { + self.ignore_visitor = true; + } + if (self.ignore_bots && userAgentSearchBotDetection()) { + self.ignore_visitor = true; + } + } + + /** + * Check and send the events to request queue if there are any, empty the event queue + */ + function sendEventsForced() { + if (eventQueue.length > 0) { + log(logLevelEnums.DEBUG, "Flushing events"); + toRequestQueue({ events: JSON.stringify(eventQueue) }); + eventQueue = []; + setValueInStorage("cly_event", eventQueue); + } + } + + /** + * Prepare widget data for displaying + * @param {Object} currentWidget - widget object + * @param {Boolean} hasSticker - if widget has sticker + */ + function processWidget(currentWidget, hasSticker) { + if (!isBrowser) { + log(logLevelEnums.WARNING, "processWidget, window object is not available. Not processing widget."); + return; + } + // prevent widget create process if widget exist with same id + var isDuplicate = !!document.getElementById("countly-feedback-sticker-" + currentWidget._id); + if (isDuplicate) { + log(logLevelEnums.ERROR, "Widget with same ID exists"); + return; + } + try { + // create wrapper div + var wrapper = document.createElement("div"); + wrapper.className = "countly-iframe-wrapper"; + wrapper.id = "countly-iframe-wrapper-" + currentWidget._id; + // create close icon for iframe popup + var closeIcon = document.createElement("span"); + closeIcon.className = "countly-feedback-close-icon"; + closeIcon.id = "countly-feedback-close-icon-" + currentWidget._id; + closeIcon.innerText = "x"; + + // create iframe + var iframe = document.createElement("iframe"); + iframe.name = "countly-feedback-iframe"; + iframe.id = "countly-feedback-iframe"; + iframe.src = self.url + "/feedback?widget_id=" + currentWidget._id + "&app_key=" + self.app_key + "&device_id=" + self.device_id + "&sdk_version=" + SDK_VERSION; + // inject them to dom + document.body.appendChild(wrapper); + wrapper.appendChild(closeIcon); + wrapper.appendChild(iframe); + add_event_listener(document.getElementById("countly-feedback-close-icon-" + currentWidget._id), "click", function () { + document.getElementById("countly-iframe-wrapper-" + currentWidget._id).style.display = "none"; + document.getElementById("cfbg").style.display = "none"; + }); + if (hasSticker) { + // create svg element + var svgIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svgIcon.id = "feedback-sticker-svg"; + svgIcon.setAttribute("aria-hidden", "true"); + svgIcon.setAttribute("data-prefix", "far"); + svgIcon.setAttribute("data-icon", "grin"); + svgIcon.setAttribute("class", "svg-inline--fa fa-grin fa-w-16"); + svgIcon.setAttribute("role", "img"); + svgIcon.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + svgIcon.setAttribute("viewBox", "0 0 496 512"); + // create path for svg + var svgPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); + svgPath.id = "smileyPathInStickerSvg"; + svgPath.setAttribute("fill", "white"); + svgPath.setAttribute("d", "M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm0 448c-110.3 0-200-89.7-200-200S137.7 56 248 56s200 89.7 200 200-89.7 200-200 200zm105.6-151.4c-25.9 8.3-64.4 13.1-105.6 13.1s-79.6-4.8-105.6-13.1c-9.9-3.1-19.4 5.4-17.7 15.3 7.9 47.1 71.3 80 123.3 80s115.3-32.9 123.3-80c1.6-9.8-7.7-18.4-17.7-15.3zM168 240c17.7 0 32-14.3 32-32s-14.3-32-32-32-32 14.3-32 32 14.3 32 32 32zm160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32-32 14.3-32 32 14.3 32 32 32z"); + // create sticker text wrapper + var stickerText = document.createElement("span"); + stickerText.innerText = currentWidget.trigger_button_text; + // create sticker wrapper element + var sticker = document.createElement("div"); + sticker.style.color = ((currentWidget.trigger_font_color.length < 7) ? "#" + currentWidget.trigger_font_color : currentWidget.trigger_font_color); + sticker.style.backgroundColor = ((currentWidget.trigger_bg_color.length < 7) ? "#" + currentWidget.trigger_bg_color : currentWidget.trigger_bg_color); + sticker.className = "countly-feedback-sticker " + currentWidget.trigger_position + "-" + currentWidget.trigger_size; + sticker.id = "countly-feedback-sticker-" + currentWidget._id; + svgIcon.appendChild(svgPath); + sticker.appendChild(svgIcon); + sticker.appendChild(stickerText); + document.body.appendChild(sticker); + var smileySvg = document.getElementById("smileyPathInStickerSvg"); + if (smileySvg) { + smileySvg.style.fill = ((currentWidget.trigger_font_color.length < 7) ? "#" + currentWidget.trigger_font_color : currentWidget.trigger_font_color); + } + add_event_listener(document.getElementById("countly-feedback-sticker-" + currentWidget._id), "click", function () { + document.getElementById("countly-iframe-wrapper-" + currentWidget._id).style.display = "block"; + document.getElementById("cfbg").style.display = "block"; + }); + } + else { + document.getElementById("countly-iframe-wrapper-" + currentWidget._id).style.display = "block"; + document.getElementById("cfbg").style.display = "block"; + } + } + catch (e) { + log(logLevelEnums.ERROR, "Somethings went wrong while element injecting process: " + e); + } + } + + /** + * Notify all waiting callbacks that script was loaded and instance created + */ + function notifyLoaders() { + // notify load waiters + var i; + if (typeof self.onload !== "undefined" && self.onload.length > 0) { + for (i = 0; i < self.onload.length; i++) { + if (typeof self.onload[i] === "function") { + self.onload[i](self); + } + } + self.onload = []; + } + } + + /** + * Report duration of how long user was on this view + * @memberof Countly._internals + */ + function reportViewDuration() { + if (lastView) { + var segments = { + name: lastView + }; + + // track pageview + if (self.check_consent(featureEnums.VIEWS)) { + add_cly_events({ + key: internalEventKeyEnums.VIEW, + dur: (trackTime) ? getTimestamp() - lastViewTime : lastViewStoredDuration, + segmentation: segments + }, currentViewId); + lastView = null; + } + } + } + + /** + * Get last view that user visited + * @memberof Countly._internals + * @returns {String} view name + */ + function getLastView() { + return lastView; + } + + /** + * Extend session's cookie's time + */ + function extendSession() { + if (useSessionCookie) { + // if session expired, we should start a new one + var expire = getValueFromStorage("cly_session"); + if (!expire || parseInt(expire) <= getTimestamp()) { + sessionStarted = false; + self.begin_session(!autoExtend); + } + setValueInStorage("cly_session", getTimestamp() + (sessionCookieTimeout * 60)); + } + } + + /** + * Prepare request params by adding common properties to it + * @param {Object} request - request object + */ + function prepareRequest(request) { + request.app_key = self.app_key; + request.device_id = self.device_id; + request.sdk_name = SDK_NAME; + request.sdk_version = SDK_VERSION; + request.t = deviceIdType; + request.av = self.app_version; + + var ua = getUA(); + if (!request.metrics) { // if metrics not provided pass useragent with this event + request.metrics = JSON.stringify({ _ua: ua }); + } + else { // if metrics provided + var currentMetrics = JSON.parse(request.metrics); + if (!currentMetrics._ua) { // check if ua is present and if not add that + currentMetrics._ua = ua; + request.metrics = JSON.stringify(currentMetrics); + } + } + + if (self.check_consent(featureEnums.LOCATION)) { + if (self.country_code) { + request.country_code = self.country_code; + } + + if (self.city) { + request.city = self.city; + } + + if (self.ip_address !== null) { + request.ip_address = self.ip_address; + } + } + else { + request.location = ""; + } + + request.timestamp = getMsTimestamp(); + + var date = new Date(); + request.hour = date.getHours(); + request.dow = date.getDay(); + } + + /** + * Add request to request queue + * @memberof Countly._internals + * @param {Object} request - object with request parameters + */ + function toRequestQueue(request) { + if (self.ignore_visitor) { + log(logLevelEnums.WARNING, "User is opt_out will ignore the request: " + request); + return; + } + + if (!self.app_key || !self.device_id) { + log(logLevelEnums.ERROR, "app_key or device_id is missing ", self.app_key, self.device_id); + return; + } + + prepareRequest(request); + + if (requestQueue.length > queueSize) { + requestQueue.shift(); + } + + requestQueue.push(request); + setValueInStorage("cly_queue", requestQueue, true); + } + + /** + * Making request making and data processing loop + * @memberof Countly._internals + * @returns {void} void + */ + function heartBeat() { + notifyLoaders(); + + // ignore bots + if (self.ignore_visitor) { + hasPulse = false; + log(logLevelEnums.WARNING, "User opt_out, no heartbeat"); + return; + } + + hasPulse = true; + var i = 0; + // process queue + if (global && typeof Countly.q !== "undefined" && Countly.q.length > 0) { + var req; + var q = Countly.q; + Countly.q = []; + for (i = 0; i < q.length; i++) { + req = q[i]; + log(logLevelEnums.DEBUG, "Processing queued call", req); + if (typeof req === "function") { + req(); + } + else if (Array.isArray(req) && req.length > 0) { + var inst = self; + var arg = 0; + // check if it is meant for other tracker + if (Countly.i[req[arg]]) { + inst = Countly.i[req[arg]]; + arg++; + } + if (typeof inst[req[arg]] === "function") { + inst[req[arg]].apply(inst, req.slice(arg + 1)); + } + else if (req[arg].indexOf("userData.") === 0) { + var userdata = req[arg].replace("userData.", ""); + if (typeof inst.userData[userdata] === "function") { + inst.userData[userdata].apply(inst, req.slice(arg + 1)); + } + } + else if (typeof Countly[req[arg]] === "function") { + Countly[req[arg]].apply(Countly, req.slice(arg + 1)); + } + } + } + } + + // extend session if needed + if (sessionStarted && autoExtend && trackTime) { + var last = getTimestamp(); + if (last - lastBeat > sessionUpdate) { + self.session_duration(last - lastBeat); + lastBeat = last; + // save health check logging counters if there are any + if (self.hcErrorCount > 0) { + setValueInStorage(healthCheckCounterEnum.errorCount, self.hcErrorCount); + } + if (self.hcWarningCount > 0) { + setValueInStorage(healthCheckCounterEnum.warningCount, self.hcWarningCount); + } + } + } + + // process event queue + if (eventQueue.length > 0 && !self.test_mode_eq) { + if (eventQueue.length <= maxEventBatch) { + toRequestQueue({ events: JSON.stringify(eventQueue) }); + eventQueue = []; + } + else { + var events = eventQueue.splice(0, maxEventBatch); + toRequestQueue({ events: JSON.stringify(events) }); + } + setValueInStorage("cly_event", eventQueue); + } + + // process request queue with event queue + if (!offlineMode && requestQueue.length > 0 && readyToProcess && getTimestamp() > failTimeout) { + readyToProcess = false; + var params = requestQueue[0]; + params.rr = requestQueue.length; // added at 23.2.3. It would give the current length of the queue. That includes the current request. + log(logLevelEnums.DEBUG, "Processing request", params); + setValueInStorage("cly_queue", requestQueue, true); + if (!self.test_mode) { + makeNetworkRequest("send_request_queue", self.url + apiPath, params, function (err, parameters) { + if (err) { + // error has been logged by the request function + failTimeout = getTimestamp() + failTimeoutAmount; + } + else { + // remove first item from queue + requestQueue.shift(); + } + setValueInStorage("cly_queue", requestQueue, true); + readyToProcess = true; + // expected response is only JSON object + }, false); + } + } + + setTimeout(heartBeat, beatInterval); + } + + /** + * Get device ID, stored one, or generate new one + * @memberof Countly._internals + * @returns {String} device id + */ + function getStoredIdOrGenerateId() { + var storedDeviceId = getValueFromStorage("cly_id"); + if (storedDeviceId) { + deviceIdType = getValueFromStorage("cly_id_type"); + return storedDeviceId; + } + return generateUUID(); + } + + /** + * Check if value is in UUID format + * @memberof Countly._internals + * @param {string} providedId - Id to check + * @returns {Boolean} true if it is in UUID format + */ + function isUUID(providedId) { + return /[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-4[0-9a-fA-F]{3}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}/.test(providedId); + } + + /** + * Get and return user agentAgent + * @memberof Countly._internals + * @returns {string} returns userAgent string + */ + function getUA() { + return self.metrics._ua || currentUserAgentString(); + } + + /** + * Get metrics of the browser or config object + * @memberof Countly._internals + * @returns {Object} Metrics object + */ + function getMetrics() { + var metrics = JSON.parse(JSON.stringify(self.metrics || {})); + + // getting app version + metrics._app_version = metrics._app_version || self.app_version; + metrics._ua = metrics._ua || currentUserAgentString(); + + // getting resolution + if (isBrowser && screen.width) { + var width = (screen.width) ? parseInt(screen.width) : 0; + var height = (screen.height) ? parseInt(screen.height) : 0; + if (width !== 0 && height !== 0) { + var iOS = !!navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform); + if (iOS && window.devicePixelRatio) { + // ios provides dips, need to multiply + width = Math.round(width * window.devicePixelRatio); + height = Math.round(height * window.devicePixelRatio); + } + else { + if (Math.abs(window.orientation) === 90) { + // we have landscape orientation + // switch values for all except ios + var temp = width; + width = height; + height = temp; + } + } + metrics._resolution = metrics._resolution || "" + width + "x" + height; + } + } + + // getting density ratio + if (isBrowser && window.devicePixelRatio) { + metrics._density = metrics._density || window.devicePixelRatio; + } + + // getting locale + var locale = navigator.language || navigator.browserLanguage || navigator.systemLanguage || navigator.userLanguage; + if (typeof locale !== "undefined") { + metrics._locale = metrics._locale || locale; + } + + if (isReferrerUsable()) { + metrics._store = metrics._store || document.referrer; + } + + log(logLevelEnums.DEBUG, "Got metrics", metrics); + return metrics; + } + + /** + * @memberof Countly._internals + * document.referrer returns the full URL of the page the user was on before they came to your site. + * If the user open your site from bookmarks or by typing the URL in the address bar, then document.referrer is an empty string. + * Inside an iframe, document.referrer will initially be set to the same value as the href of the parent window's Window.location. + * + * @param {string} customReferrer - custom referrer for testing + * @returns {boolean} true if document.referrer is not empty string, undefined, current host or in the ignore list. + */ + function isReferrerUsable(customReferrer) { + if (!isBrowser) { + return false; + } + var referrer = customReferrer || document.referrer; + var isReferrerLegit = false; + + // do not report referrer if it is empty string or undefined + if (typeof referrer === "undefined" || referrer.length === 0) { + log(logLevelEnums.DEBUG, "Invalid referrer:[" + referrer + "], ignoring."); + } + else { + // dissect the referrer (check urlParseRE's comments for more info on this process) + var matches = urlParseRE.exec(referrer); // this can return null + if (!matches) { + log(logLevelEnums.DEBUG, "Referrer is corrupt:[" + referrer + "], ignoring."); + } + else if (!matches[11]) { + log(logLevelEnums.DEBUG, "No path found in referrer:[" + referrer + "], ignoring."); + } + else if (matches[11] === window.location.hostname) { + log(logLevelEnums.DEBUG, "Referrer is current host:[" + referrer + "], ignoring."); + } + else { + if (ignoreReferrers && ignoreReferrers.length) { + isReferrerLegit = true; + for (var k = 0; k < ignoreReferrers.length; k++) { + if (referrer.indexOf(ignoreReferrers[k]) >= 0) { + log(logLevelEnums.DEBUG, "Referrer in ignore list:[" + referrer + "], ignoring."); + isReferrerLegit = false; + break; + } + } + } + else { + log(logLevelEnums.DEBUG, "Valid referrer:[" + referrer + "]"); + isReferrerLegit = true; + } + } + } + + return isReferrerLegit; + } + + /** + * Logging stuff, works only when debug mode is true + * @param {string} level - log level (error, warning, info, debug, verbose) + * @param {string} message - any string message + * @memberof Countly._internals + */ + function log(level, message) { + if (self.debug && typeof console !== "undefined") { + // parse the arguments into a string if it is an object + if (arguments[2] && typeof arguments[2] === "object") { + arguments[2] = JSON.stringify(arguments[2]); + } + // append app_key to the start of the message if it is not the first instance (for multi instancing) + if (!global) { + message = "[" + self.app_key + "] " + message; + } + // if the provided level is not a proper log level re-assign it as [DEBUG] + if (!level) { + level = logLevelEnums.DEBUG; + } + // append level, message and args + var extraArguments = ""; + for (var i = 2; i < arguments.length; i++) { + extraArguments += arguments[i]; + } + // eslint-disable-next-line no-shadow + var log = level + "[Countly] " + message + extraArguments; + // decide on the console + if (level === logLevelEnums.ERROR) { + // eslint-disable-next-line no-console + console.error(log); + HealthCheck.incrementErrorCount(); + } + else if (level === logLevelEnums.WARNING) { + // eslint-disable-next-line no-console + console.warn(log); + HealthCheck.incrementWarningCount(); + } + else if (level === logLevelEnums.INFO) { + // eslint-disable-next-line no-console + console.info(log); + } + else if (level === logLevelEnums.VERBOSE) { + // eslint-disable-next-line no-console + console.log(log); + } + // if none of the above must be [DEBUG] + else { + // eslint-disable-next-line no-console + console.debug(log); + } + } + } + + /** + * Decides to use which type of request method + * @memberof Countly._internals + * @param {String} functionName - Name of the function making the request for more detailed logging + * @param {String} url - URL where to make request + * @param {Object} params - key value object with URL params + * @param {Function} callback - callback when request finished or failed + * @param {Boolean} useBroadResponseValidator - if true that means the expected response is either a JSON object or a JSON array, if false only JSON + */ + function makeNetworkRequest(functionName, url, params, callback, useBroadResponseValidator) { + if (!isBrowser) { + sendFetchRequest(functionName, url, params, callback, useBroadResponseValidator); + } + else { + sendXmlHttpRequest(functionName, url, params, callback, useBroadResponseValidator); + } + } + + /** + * Making xml HTTP request + * @memberof Countly._internals + * @param {String} functionName - Name of the function making the request for more detailed logging + * @param {String} url - URL where to make request + * @param {Object} params - key value object with URL params + * @param {Function} callback - callback when request finished or failed + * @param {Boolean} useBroadResponseValidator - if true that means the expected response is either a JSON object or a JSON array, if false only JSON + */ + function sendXmlHttpRequest(functionName, url, params, callback, useBroadResponseValidator) { + useBroadResponseValidator = useBroadResponseValidator || false; + try { + log(logLevelEnums.DEBUG, "Sending XML HTTP request"); + var xhr = new XMLHttpRequest(); + params = params || {}; + var data = prepareParams(params); + var method = "GET"; + if (self.force_post || data.length >= 2000) { + method = "POST"; + } + if (method === "GET") { + xhr.open("GET", url + "?" + data, true); + } + else { + xhr.open("POST", url, true); + xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); + } + for (var header in self.headers) { + xhr.setRequestHeader(header, self.headers[header]); + } + // fallback on error + xhr.onreadystatechange = function () { + if (this.readyState === 4) { + log(logLevelEnums.DEBUG, functionName + " HTTP request completed with status code: [" + this.status + "] and response: [" + this.responseText + "]"); + // response validation function will be selected to also accept JSON arrays if useBroadResponseValidator is true + var isResponseValidated; + if (useBroadResponseValidator) { + // JSON array/object both can pass + isResponseValidated = isResponseValidBroad(this.status, this.responseText); + } + else { + // only JSON object can pass + isResponseValidated = isResponseValid(this.status, this.responseText); + } + if (isResponseValidated) { + if (typeof callback === "function") { + callback(false, params, this.responseText); + } + } + else { + log(logLevelEnums.ERROR, functionName + " Invalid response from server"); + if (functionName === "send_request_queue") { + HealthCheck.saveRequestCounters(this.status, this.responseText); + } + if (typeof callback === "function") { + callback(true, params, this.status, this.responseText); + } + } + } + }; + if (method === "GET") { + xhr.send(); + } + else { + xhr.send(data); + } + } + catch (e) { + // fallback + log(logLevelEnums.ERROR, functionName + " Something went wrong while making an XML HTTP request: " + e); + if (typeof callback === "function") { + callback(true, params); + } + } + } + + /** + * Make a fetch request + * @memberof Countly._internals + * @param {String} functionName - Name of the function making the request for more detailed logging + * @param {String} url - URL where to make request + * @param {Object} params - key value object with URL params + * @param {Function} callback - callback when request finished or failed + * @param {Boolean} useBroadResponseValidator - if true that means the expected response is either a JSON object or a JSON array, if false only JSON + */ + function sendFetchRequest(functionName, url, params, callback, useBroadResponseValidator) { + useBroadResponseValidator = useBroadResponseValidator || false; + var response; + + try { + log(logLevelEnums.DEBUG, "Sending Fetch request"); + + // Prepare request options + var method = "GET"; + var headers = { "Content-type": "application/x-www-form-urlencoded" }; + var body = null; + + params = params || {}; + if (self.force_post || prepareParams(params).length >= 2000) { + method = "POST"; + body = prepareParams(params); + } + else { + url += "?" + prepareParams(params); + } + + // Add custom headers + for (var header in self.headers) { + headers[header] = self.headers[header]; + } + + // Make the fetch request + fetch(url, { + method: method, + headers: headers, + body: body, + }).then(function (res) { + response = res; + return response.text(); + }).then(function (data) { + log(logLevelEnums.DEBUG, functionName + " Fetch request completed wit status code: [" + response.status + "] and response: [" + data + "]"); + var isResponseValidated; + if (useBroadResponseValidator) { + isResponseValidated = isResponseValidBroad(response.status, data); + } + else { + isResponseValidated = isResponseValid(response.status, data); + } + + if (isResponseValidated) { + if (typeof callback === "function") { + callback(false, params, data); + } + } + else { + log(logLevelEnums.ERROR, functionName + " Invalid response from server"); + if (functionName === "send_request_queue") { + HealthCheck.saveRequestCounters(response.status, data); + } + if (typeof callback === "function") { + callback(true, params, response.status, data); + } + } + }).catch(function (error) { + log(logLevelEnums.ERROR, functionName + " Failed Fetch request: " + error); + if (typeof callback === "function") { + callback(true, params); + } + }); + } + catch (e) { + // fallback + log(logLevelEnums.ERROR, functionName + " Something went wrong with the Fetch request attempt: " + e); + if (typeof callback === "function") { + callback(true, params); + } + } + } + + /** + * Check if the http response fits the bill of: + * 1. The HTTP response code was successful (which is any 2xx code or code between 200 <= x < 300) + * 2. The returned request is a JSON object + * @memberof Countly._internals + * @param {Number} statusCode - http incoming statusCode. + * @param {String} str - response from server, ideally must be: {"result":"Success"} or should contain at least result field + * @returns {Boolean} - returns true if response passes the tests + */ + function isResponseValid(statusCode, str) { + // status code and response format check + if (!(statusCode >= 200 && statusCode < 300)) { + log(logLevelEnums.ERROR, "Http response status code:[" + statusCode + "] is not within the expected range"); + return false; + } + + // Try to parse JSON + try { + var parsedResponse = JSON.parse(str); + + // check if parsed response is a JSON object, if not the response is not valid + if (Object.prototype.toString.call(parsedResponse) !== "[object Object]") { + log(logLevelEnums.ERROR, "Http response is not JSON Object"); + return false; + } + + return !!(parsedResponse.result); + } + catch (e) { + log(logLevelEnums.ERROR, "Http response is not JSON: " + e); + return false; + } + } + + /** + * Check if the http response fits the bill of: + * 1. The HTTP response code was successful (which is any 2xx code or code between 200 <= x < 300) + * 2. The returned request is a JSON object or JSON Array + * @memberof Countly._internals + * @param {Number} statusCode - http incoming statusCode. + * @param {String} str - response from server, ideally must be: {"result":"Success"} or should contain at least result field + * @returns {Boolean} - returns true if response passes the tests + */ + function isResponseValidBroad(statusCode, str) { + // status code and response format check + if (!(statusCode >= 200 && statusCode < 300)) { + log(logLevelEnums.ERROR, "Http response status code:[" + statusCode + "] is not within the expected range"); + return false; + } + + // Try to parse JSON + try { + var parsedResponse = JSON.parse(str); + // check if parsed response is a JSON object or JSON array, if not it is not valid + if ((Object.prototype.toString.call(parsedResponse) !== "[object Object]") && (!Array.isArray(parsedResponse))) { + log(logLevelEnums.ERROR, "Http response is not JSON Object nor JSON Array"); + return false; + } + + // request should be accepted even if does not have result field + return true; + } + catch (e) { + log(logLevelEnums.ERROR, "Http response is not JSON: " + e); + return false; + } + } + + /** + * Get max scroll position + * @memberof Countly._internals + * + */ + function processScroll() { + if (!isBrowser) { + log(logLevelEnums.WARNING, "processScroll, window object is not available. Not processing scroll."); + return; + } + scrollRegistryTopPosition = Math.max(scrollRegistryTopPosition, window.scrollY, document.body.scrollTop, document.documentElement.scrollTop); + } + + /** + * Process scroll data + * @memberof Countly._internals + */ + function processScrollView() { + if (!isBrowser) { + log(logLevelEnums.WARNING, "processScrollView, window object is not available. Not processing scroll view."); + return; + } + if (isScrollRegistryOpen) { + isScrollRegistryOpen = false; + var height = getDocHeight(); + var width = getDocWidth(); + + var viewportHeight = getViewportHeight(); + + if (self.check_consent(featureEnums.SCROLLS)) { + var segments = { + type: "scroll", + y: scrollRegistryTopPosition + viewportHeight, + width: width, + height: height, + view: self.getViewUrl() + }; + // truncate new segment + segments = truncateObject(segments, self.maxKeyLength, self.maxValueSize, self.maxSegmentationValues, "processScrollView", log); + if (self.track_domains) { + segments.domain = window.location.hostname; + } + add_cly_events({ + key: internalEventKeyEnums.ACTION, + segmentation: segments + }); + } + } + } + + /** + * Fetches the current device Id type + * @memberof Countly._internals + * @returns {String} token - auth token + */ + function getInternalDeviceIdType() { + return deviceIdType; + } + + /** + * Set auth token + * @memberof Countly._internals + * @param {String} token - auth token + */ + function setToken(token) { + setValueInStorage("cly_token", token); + } + + /** + * Get auth token + * @memberof Countly._internals + * @returns {String} auth token + */ + function getToken() { + var token = getValueFromStorage("cly_token"); + removeValueFromStorage("cly_token"); + return token; + } + + /** + * Get event queue + * @memberof Countly._internals + * @returns {Array} event queue + */ + function getEventQueue() { + return eventQueue; + } + + /** + * Get request queue + * @memberof Countly._internals + * @returns {Array} request queue + */ + function getRequestQueue() { + return requestQueue; + } + + /** + * Returns contents of a cookie + * @param {String} cookieKey - The key, name or identifier for the cookie + * @returns {Varies} stored value + */ + function readCookie(cookieKey) { + var cookieID = cookieKey + "="; + // array of all cookies available + var cookieArray = document.cookie.split(";"); + for (var i = 0, max = cookieArray.length; i < max; i++) { + // cookie from the cookie array to be checked + var cookie = cookieArray[i]; + // get rid of empty spaces at the beginning + while (cookie.charAt(0) === " ") { + cookie = cookie.substring(1, cookie.length); + } + // return the cookie if it is the one we are looking for + if (cookie.indexOf(cookieID) === 0) { + // just return the value part after '=' + return cookie.substring(cookieID.length, cookie.length); + } + } + return null; + } + + /** + * Creates new cookie or removes cookie with negative expiration + * @param {String} cookieKey - The key or identifier for the storage + * @param {String} cookieVal - Contents to store + * @param {Number} exp - Expiration in days + */ + function createCookie(cookieKey, cookieVal, exp) { + var date = new Date(); + date.setTime(date.getTime() + (exp * 24 * 60 * 60 * 1000)); + // TODO: If we offer the developer the ability to manipulate the expiration date in the future, this part must be reworked + var expires = "; expires=" + date.toGMTString(); + document.cookie = cookieKey + "=" + cookieVal + expires + "; path=/"; + } + + /** + * Storage function that acts as getter, can be used for fetching data from local storage or cookies + * @memberof Countly._internals + * @param {String} key - storage key + * @param {Boolean} useLocalStorage - if false, will fallback to cookie storage + * @param {Boolean} useRawKey - if true, raw key will be used without any prefix + * @returns {Varies} values stored for key + */ + function getValueFromStorage(key, useLocalStorage, useRawKey) { + // check if we should use storage at all. If in worker context but no storage is available, return early + if (self.storage === "none" || (typeof self.storage !== "object" && !isBrowser)) { + log(logLevelEnums.DEBUG, "Storage is disabled. Value with key: [" + key + "] won't be retrieved"); + return; + } + + // apply namespace or app_key + if (!useRawKey) { + key = self.app_key + "/" + key; + if (self.namespace) { + key = stripTrailingSlash(self.namespace) + "/" + key; + } + } + + var data; + // use dev provided storage if available + if (typeof self.storage === "object" && typeof self.storage.getItem === "function") { + data = self.storage.getItem(key); + return key.endsWith("cly_id") ? data : self.deserialize(data); + } + + // developer set values takes priority + if (useLocalStorage === undefined) { + useLocalStorage = lsSupport; + } + + // Get value + if (useLocalStorage) { // Native support + data = localStorage.getItem(key); + } + else if (self.storage !== "localstorage") { // Use cookie + data = readCookie(key); + } + + // we return early without parsing if we are trying to get the device ID. This way we are keeping it as a string incase it was numerical. + if (key.endsWith("cly_id")) { + return data; + } + + return self.deserialize(data); + } + + /** + * Storage function that acts as setter, can be used for setting data into local storage or as cookies + * @memberof Countly._internals + * @param {String} key - storage key + * @param {Varies} value - value to set for key + * @param {Boolean} useLocalStorage - if false, will fallback to storing as cookies + * @param {Boolean} useRawKey - if true, raw key will be used without any prefix + */ + function setValueInStorage(key, value, useLocalStorage, useRawKey) { + // check if we should use storage options at all + if (self.storage === "none" || (typeof self.storage !== "object" && !isBrowser)) { + log(logLevelEnums.DEBUG, "Storage is disabled. Value with key: " + key + " won't be stored"); + return; + } + + // apply namespace + if (!useRawKey) { + key = self.app_key + "/" + key; + if (self.namespace) { + key = stripTrailingSlash(self.namespace) + "/" + key; + } + } + + if (typeof value !== "undefined" && value !== null) { + // use dev provided storage if available + if (typeof self.storage === "object" && typeof self.storage.setItem === "function") { + self.storage.setItem(key, value); + return; + } + + // developer set values takes priority + if (useLocalStorage === undefined) { + useLocalStorage = lsSupport; + } + + value = self.serialize(value); + // Set the store + if (useLocalStorage) { // Native support + localStorage.setItem(key, value); + } + else if (self.storage !== "localstorage") { // Use Cookie + createCookie(key, value, 30); + } + } + } + + /** + * A function that can be used for removing data from local storage or cookies + * @memberof Countly._internals + * @param {String} key - storage key + * @param {Boolean} useLocalStorage - if false, will fallback to removing cookies + * @param {Boolean} useRawKey - if true, raw key will be used without any prefix + */ + function removeValueFromStorage(key, useLocalStorage, useRawKey) { + // check if we should use storage options at all + if (self.storage === "none" || (typeof self.storage !== "object" && !isBrowser)) { + log(logLevelEnums.DEBUG, "Storage is disabled. Value with key: " + key + " won't be removed"); + return; + } + + // apply namespace + if (!useRawKey) { + key = self.app_key + "/" + key; + if (self.namespace) { + key = stripTrailingSlash(self.namespace) + "/" + key; + } + } + + // use dev provided storage if available + if (typeof self.storage === "object" && typeof self.storage.removeItem === "function") { + self.storage.removeItem(key); + return; + } + + // developer set values takes priority + if (useLocalStorage === undefined) { + useLocalStorage = lsSupport; + } + + if (useLocalStorage) { // Native support + localStorage.removeItem(key); + } + else if (self.storage !== "localstorage") { // Use cookie + createCookie(key, "", -1); + } + } + + /** + * Migrate from old storage to new app_key prefixed storage + */ + function migrate() { + if (getValueFromStorage(self.namespace + "cly_id", false, true)) { + // old data exists, we should migrate it + setValueInStorage("cly_id", getValueFromStorage(self.namespace + "cly_id", false, true)); + setValueInStorage("cly_id_type", getValueFromStorage(self.namespace + "cly_id_type", false, true)); + setValueInStorage("cly_event", getValueFromStorage(self.namespace + "cly_event", false, true)); + setValueInStorage("cly_session", getValueFromStorage(self.namespace + "cly_session", false, true)); + + // filter out requests with correct app_key + var requests = getValueFromStorage(self.namespace + "cly_queue", false, true); + if (Array.isArray(requests)) { + requests = requests.filter(function (req) { + return req.app_key === self.app_key; + }); + setValueInStorage("cly_queue", requests); + } + if (getValueFromStorage(self.namespace + "cly_cmp_id", false, true)) { + setValueInStorage("cly_cmp_id", getValueFromStorage(self.namespace + "cly_cmp_id", false, true)); + setValueInStorage("cly_cmp_uid", getValueFromStorage(self.namespace + "cly_cmp_uid", false, true)); + } + if (getValueFromStorage(self.namespace + "cly_ignore", false, true)) { + setValueInStorage("cly_ignore", getValueFromStorage(self.namespace + "cly_ignore", false, true)); + } + + // now deleting old data, so we won't migrate again + removeValueFromStorage("cly_id", false, true); + removeValueFromStorage("cly_id_type", false, true); + removeValueFromStorage("cly_event", false, true); + removeValueFromStorage("cly_session", false, true); + removeValueFromStorage("cly_queue", false, true); + removeValueFromStorage("cly_cmp_id", false, true); + removeValueFromStorage("cly_cmp_uid", false, true); + removeValueFromStorage("cly_ignore", false, true); + } + } + + /** + * Apply modified storage changes + * @param {String} key - key of storage modified + * @param {Varies} newValue - new value for storage + */ + this.onStorageChange = function (key, newValue) { + log(logLevelEnums.DEBUG, "onStorageChange, Applying storage changes for key:", key); + log(logLevelEnums.DEBUG, "onStorageChange, Applying storage changes for value:", newValue); + switch (key) { + // queue of requests + case "cly_queue": + requestQueue = self.deserialize(newValue || "[]"); + break; + // queue of events + case "cly_event": + eventQueue = self.deserialize(newValue || "[]"); + break; + case "cly_remote_configs": + remoteConfigs = self.deserialize(newValue || "{}"); + break; + case "cly_ignore": + self.ignore_visitor = self.deserialize(newValue); + break; + case "cly_id": + self.device_id = newValue; + break; + case "cly_id_type": + deviceIdType = self.deserialize(newValue); + break; + default: + // do nothing + } + }; + + /** + * Expose internal methods to end user for usability + * @namespace Countly._internals + * @name Countly._internals + */ + this._internals = { + // TODO: looks like we do not use this function. Either use it for something or eliminate. + store: setValueInStorage, + getDocWidth: getDocWidth, + getDocHeight: getDocHeight, + getViewportHeight: getViewportHeight, + get_page_coord: get_page_coord, + get_event_target: get_event_target, + add_event_listener: add_event_listener, + createNewObjectFromProperties: createNewObjectFromProperties, + truncateObject: truncateObject, + truncateSingleValue: truncateSingleValue, + stripTrailingSlash: stripTrailingSlash, + prepareParams: prepareParams, + sendXmlHttpRequest: sendXmlHttpRequest, + isResponseValid: isResponseValid, + getInternalDeviceIdType: getInternalDeviceIdType, + getMsTimestamp: getMsTimestamp, + getTimestamp: getTimestamp, + isResponseValidBroad: isResponseValidBroad, + secureRandom: secureRandom, + log: log, + checkIfLoggingIsOn: checkIfLoggingIsOn, + getMetrics: getMetrics, + getUA: getUA, + prepareRequest: prepareRequest, + generateUUID: generateUUID, + sendEventsForced: sendEventsForced, + isUUID: isUUID, + isReferrerUsable: isReferrerUsable, + getId: getStoredIdOrGenerateId, + heartBeat: heartBeat, + toRequestQueue: toRequestQueue, + reportViewDuration: reportViewDuration, + loadJS: loadJS, + loadCSS: loadCSS, + getLastView: getLastView, + setToken: setToken, + getToken: getToken, + showLoader: showLoader, + hideLoader: hideLoader, + setValueInStorage: setValueInStorage, + getValueFromStorage: getValueFromStorage, + removeValueFromStorage: removeValueFromStorage, + add_cly_events: add_cly_events, + processScrollView: processScrollView, + processScroll: processScroll, + currentUserAgentString: currentUserAgentString, + userAgentDeviceDetection: userAgentDeviceDetection, + userAgentSearchBotDetection: userAgentSearchBotDetection, + getRequestQueue: getRequestQueue, + getEventQueue: getEventQueue, + sendFetchRequest: sendFetchRequest, + makeNetworkRequest: makeNetworkRequest, + /** + * Clear queued data + * @memberof Countly._internals + */ + clearQueue: function () { + requestQueue = []; + setValueInStorage("cly_queue", []); + eventQueue = []; + setValueInStorage("cly_event", []); + }, + /** + * For testing pusposes only + * @returns {Object} - returns the local queues + */ + getLocalQueues: function () { + return { + eventQ: eventQueue, + requestQ: requestQueue + }; + } + }; + + /** + * Health Check Interface: + * {sendInstantHCRequest} Sends instant health check request + * {resetAndSaveCounters} Resets and saves health check counters + * {incrementErrorCount} Increments health check error count + * {incrementWarningCount} Increments health check warning count + * {resetCounters} Resets health check counters + * {saveRequestCounters} Saves health check request counters + */ + var HealthCheck = {}; + HealthCheck.sendInstantHCRequest = sendInstantHCRequest; + HealthCheck.resetAndSaveCounters = resetAndSaveCounters; + HealthCheck.incrementErrorCount = incrementErrorCount; + HealthCheck.incrementWarningCount = incrementWarningCount; + HealthCheck.resetCounters = resetCounters; + HealthCheck.saveRequestCounters = saveRequestCounters; + /** + * Increments health check error count + */ + function incrementErrorCount() { + self.hcErrorCount++; + } + /** + * Increments health check warning count + */ + function incrementWarningCount() { + self.hcWarningCount++; + } + /** + * Resets health check counters + */ + function resetCounters() { + self.hcErrorCount = 0; + self.hcWarningCount = 0; + self.hcStatusCode = -1; + self.hcErrorMessage = ""; + } + /** + * Sets and saves the status code and error message counters + * @param {number} status - response status code of the request + * @param {string} responseText - response text of the request + */ + function saveRequestCounters(status, responseText) { + self.hcStatusCode = status; + self.hcErrorMessage = responseText; + setValueInStorage(healthCheckCounterEnum.statusCode, self.hcStatusCode); + setValueInStorage(healthCheckCounterEnum.errorMessage, self.hcErrorMessage); + } + /** + * Resets and saves health check counters + */ + function resetAndSaveCounters() { + HealthCheck.resetCounters(); + setValueInStorage(healthCheckCounterEnum.errorCount, self.hcErrorCount); + setValueInStorage(healthCheckCounterEnum.warningCount, self.hcWarningCount); + setValueInStorage(healthCheckCounterEnum.statusCode, self.hcStatusCode); + setValueInStorage(healthCheckCounterEnum.errorMessage, self.hcErrorMessage); + } + /** + * Countly health check request sender + */ + function sendInstantHCRequest() { + // truncate error message to 1000 characters + var curbedMessage = truncateSingleValue(self.hcErrorMessage, 1000, "healthCheck", log); + // prepare hc object + var hc = { + el: self.hcErrorCount, + wl: self.hcWarningCount, + sc: self.hcStatusCode, + em: JSON.stringify(curbedMessage) + }; + // prepare request + var request = { + hc: JSON.stringify(hc), + metrics: JSON.stringify({ _app_version: self.app_version }) + }; + // add common request params + prepareRequest(request); + // send request + makeNetworkRequest("[healthCheck]", self.url + apiPath, request, function (err) { + // request maker already logs the error. No need to log it again here + if (!err) { + // reset and save health check counters if request was successful + HealthCheck.resetAndSaveCounters(); + } + }, true); + } + + // initialize Countly Class + this.initialize(); + }; +} +export default CountlyClass; \ No newline at end of file diff --git a/modules/Platform.js b/modules/Platform.js new file mode 100644 index 0000000..c8e9d08 --- /dev/null +++ b/modules/Platform.js @@ -0,0 +1,3 @@ +const isBrowser = typeof window !== "undefined"; +let Countly = isBrowser ? window.Countly || {} : {}; +export { isBrowser, Countly }; \ No newline at end of file diff --git a/modules/Utils.js b/modules/Utils.js new file mode 100644 index 0000000..b64795c --- /dev/null +++ b/modules/Utils.js @@ -0,0 +1,618 @@ +import { isBrowser, Countly } from "./Platform.js"; +import { logLevelEnums } from "./Constants.js"; + +/** + * Get selected values from multi select input + * @param {HTMLElement} input - select with multi true option + * @returns {String} coma concatenated values + */ +function getMultiSelectValues(input) { + var values = []; + if (typeof input.options !== "undefined") { + for (var j = 0; j < input.options.length; j++) { + if (input.options[j].selected) { + values.push(input.options[j].value); + } + } + } + return values.join(", "); +} + +/** + * Return a crypto-safe random string + * @memberof Countly._internals + * @returns {string} - random string + */ +function secureRandom() { + var id = "xxxxxxxx"; + id = replacePatternWithRandomValues(id, "[x]"); + + // timestamp in milliseconds + var timestamp = Date.now().toString(); + + return (id + timestamp); +} + +/** + * Generate random UUID value + * @memberof Countly._internals + * @returns {String} random UUID value + */ +function generateUUID() { + var uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"; + uuid = replacePatternWithRandomValues(uuid, "[xy]"); + return uuid; +} + +/** + * Generate random value based on pattern + * + * @param {string} str - string to replace + * @param {string} pattern - pattern to replace + * @returns {string} - replaced string +*/ +function replacePatternWithRandomValues(str, pattern) { + var d = new Date().getTime(); + var regex = new RegExp(pattern, "g"); + return str.replace(regex, function (c) { + var r = (d + Math.random() * 16) % 16 | 0; + return (c === "x" ? r : (r & 0x3 | 0x8)).toString(16); + }); +} + +/** + * Get unix timestamp + * @memberof Countly._internals + * @returns {Number} unix timestamp + */ +function getTimestamp() { + return Math.floor(new Date().getTime() / 1000); +} + +var lastMsTs = 0; +/** + * Get unique timestamp in milliseconds + * @memberof Countly._internals + * @returns {Number} milliseconds timestamp + */ +function getMsTimestamp() { + var ts = new Date().getTime(); + if (lastMsTs >= ts) { + lastMsTs++; + } + else { + lastMsTs = ts; + } + return lastMsTs; +} + +/** + * Get config value from multiple sources + * like config object, global object or fallback value + * @param {String} key - config key + * @param {Object} ob - config object + * @param {Varies} override - fallback value + * @returns {Varies} value to be used as config + */ +function getConfig(key, ob, override) { + if (ob && Object.keys(ob).length) { + if (typeof ob[key] !== "undefined") { + return ob[key]; + } + } + else if (typeof Countly[key] !== "undefined") { + return Countly[key]; + } + return override; +} + +/** + * Dispatch errors to instances that lister to errors + * @param {Error} error - Error object + * @param {Boolean} fatality - fatal if false and nonfatal if true + * @param {Object} segments - custom crash segments + */ +function dispatchErrors(error, fatality, segments) { + // Check each instance like Countly.i[app_key_1], Countly.i[app_key_2] ... + for (var app_key in Countly.i) { + // If track_errors is enabled for that instance + if (Countly.i[app_key].tracking_crashes) { + // Trigger recordError function for that instance + Countly.i[app_key].recordError(error, fatality, segments); + } + } +} + +/** + * Convert JSON object to URL encoded query parameter string + * @memberof Countly._internals + * @param {Object} params - object with query parameters + * @returns {String} URL encode query string + */ +function prepareParams(params) { + var str = []; + for (var i in params) { + str.push(i + "=" + encodeURIComponent(params[i])); + } + return str.join("&"); +} + +/** + * Removing trailing slashes + * @memberof Countly._internals + * @param {String} str - string from which to remove trailing slash + * @returns {String} modified string + */ +function stripTrailingSlash(str) { + if (typeof str === "string") { + if (str.substring(str.length - 1) === "/") { + return str.substring(0, str.length - 1); + } + } + return str; +} + +/** + * Retrieve only specific properties from object + * @memberof Countly._internals + * @param {Object} orig - original object + * @param {Array} props - array with properties to get from object + * @returns {Object} new object with requested properties + */ +function createNewObjectFromProperties(orig, props) { + var ob = {}; + var prop; + for (var i = 0, len = props.length; i < len; i++) { + prop = props[i]; + if (typeof orig[prop] !== "undefined") { + ob[prop] = orig[prop]; + } + } + return ob; +} + +/** + * Add specified properties to an object from another object + * @memberof Countly._internals + * @param {Object} orig - original object + * @param {Object} transferOb - object to copy values from + * @param {Array} props - array with properties to get from object + * @returns {Object} original object with additional requested properties + */ +function addNewProperties(orig, transferOb, props) { + if (!props) { + return; + } + var prop; + for (var i = 0, len = props.length; i < len; i++) { + prop = props[i]; + if (typeof transferOb[prop] !== "undefined") { + orig[prop] = transferOb[prop]; + } + } + return orig; +} + +/** + * Truncates an object's key/value pairs to a certain length + * @param {Object} obj - original object to be truncated + * @param {Number} keyLimit - limit for key length + * @param {Number} valueLimit - limit for value length + * @param {Number} segmentLimit - limit for segments pairs + * @param {string} errorLog - prefix for error log + * @param {function} logCall - internal logging function + * @returns {Object} - the new truncated object + */ +function truncateObject(obj, keyLimit, valueLimit, segmentLimit, errorLog, logCall) { + var ob = {}; + if (obj) { + if (Object.keys(obj).length > segmentLimit) { + var resizedObj = {}; + var i = 0; + for (var e in obj) { + if (i < segmentLimit) { + resizedObj[e] = obj[e]; + i++; + } + } + obj = resizedObj; + } + for (var key in obj) { + var newKey = truncateSingleValue(key, keyLimit, errorLog, logCall); + var newValue = truncateSingleValue(obj[key], valueLimit, errorLog, logCall); + ob[newKey] = newValue; + } + } + return ob; +} + +/** + * Truncates a single value to a certain length + * @param {string|number} str - original value to be truncated + * @param {Number} limit - limit length + * @param {string} errorLog - prefix for error log + * @param {function} logCall - internal logging function + * @returns {string|number} - the new truncated value + */ +function truncateSingleValue(str, limit, errorLog, logCall) { + var newStr = str; + if (typeof str === "number") { + str = str.toString(); + } + if (typeof str === "string") { + if (str.length > limit) { + newStr = str.substring(0, limit); + logCall(logLevelEnums.DEBUG, errorLog + ", Key: [ " + str + " ] is longer than accepted length. It will be truncated."); + } + } + return newStr; +} + +/** + * Polyfill to get closest parent matching nodeName + * @param {HTMLElement} el - element from which to search + * @param {String} nodeName - tag/node name + * @returns {HTMLElement} closest parent element + */ +function get_closest_element(el, nodeName) { + nodeName = nodeName.toUpperCase(); + while (el) { + if (el.nodeName.toUpperCase() === nodeName) { + return el; + } + el = el.parentElement; + } +}; + +/** + * Listen to specific browser event + * @memberof Countly._internals + * @param {HTMLElement} element - HTML element that should listen to event + * @param {String} type - event name or action + * @param {Function} listener - callback when event is fired + */ +function add_event_listener(element, type, listener) { + if (!isBrowser) { + return; + } + if (element === null || typeof element === "undefined") { // element can be null so lets check it first + if (checkIfLoggingIsOn()) { + // eslint-disable-next-line no-console + console.warn("[WARNING] [Countly] add_event_listener, Can't bind [" + type + "] event to nonexisting element"); + } + return; + } + if (typeof element.addEventListener !== "undefined") { + element.addEventListener(type, listener, false); + } + // for old browser use attachEvent instead + else { + element.attachEvent("on" + type, listener); + } +}; + +/** + * Get element that fired event + * @memberof Countly._internals + * @param {Event} event - event that was filed + * @returns {HTMLElement} HTML element that caused event to fire + */ +function get_event_target(event) { + if (!event) { + return window.event.srcElement; + } + if (typeof event.target !== "undefined") { + return event.target; + } + return event.srcElement; +}; + +/** + * Returns raw user agent string + * @memberof Countly._internals + * @param {string} uaOverride - a string value to pass instead of ua value + * @returns {string} currentUserAgentString - raw user agent string + */ +function currentUserAgentString(uaOverride) { + if (uaOverride) { + return uaOverride; + } + + var ua_raw = navigator.userAgent; + // check if userAgentData is supported and userAgent is not available, use it + if (!ua_raw) { + if (navigator.userAgentData) { + // turn brands array into string + ua_raw = navigator.userAgentData.brands.map(function (e) { + return e.brand + ":" + e.version; + }).join(); + // add mobile info + ua_raw += (navigator.userAgentData.mobile ? " mobi " : " "); + // add platform info + ua_raw += navigator.userAgentData.platform; + } + } + // RAW USER AGENT STRING + return ua_raw; +} + +/** + * Returns device type information according to user agent string + * @memberof Countly._internals + * @param {string} uaOverride - a string value to pass instead of ua value + * @returns {string} userAgentDeviceDetection - current device type (desktop, tablet, phone) + */ +function userAgentDeviceDetection(uaOverride) { + var userAgent; + // TODO: refactor here + if (uaOverride) { + userAgent = uaOverride; + } + else if (navigator.userAgentData.mobile) { + return "phone"; + } + else { + userAgent = currentUserAgentString(); + } + // make it lowercase for regex to work properly + userAgent = userAgent.toLowerCase(); + + // assign the default device + var device = "desktop"; + + // regexps corresponding to tablets or phones that can be found in userAgent string + var tabletCheck = /(ipad|tablet|(android(?!.*mobile))|(windows(?!.*phone)(.*touch))|kindle|playbook|silk|(puffin(?!.*(IP|AP|WP))))/; + var phoneCheck = /(mobi|ipod|phone|blackberry|opera mini|fennec|minimo|symbian|psp|nintendo ds|archos|skyfire|puffin|blazer|bolt|gobrowser|iris|maemo|semc|teashark|uzard)/; + + // check whether the regexp values corresponds to something in the user agent string + if (tabletCheck.test(userAgent)) { + device = "tablet"; + } + else if (phoneCheck.test(userAgent)) { + device = "phone"; + } + + // set the device type + return device; +} + +/** + * Returns information regarding if the current user is a search bot or not + * @memberof Countly._internals + * @param {string} uaOverride - a string value to pass instead of ua value + * @returns {boolean} userAgentSearchBotDetection - if a search bot is reaching the site or not + */ +function userAgentSearchBotDetection(uaOverride) { + // search bot regexp + var searchBotRE = /(CountlySiteBot|nuhk|Googlebot|GoogleSecurityScanner|Yammybot|Openbot|Slurp|MSNBot|Ask Jeeves\/Teoma|ia_archiver|bingbot|Google Web Preview|Mediapartners-Google|AdsBot-Google|Baiduspider|Ezooms|YahooSeeker|AltaVista|AVSearch|Mercator|Scooter|InfoSeek|Ultraseek|Lycos|Wget|YandexBot|Yandex|YaDirectFetcher|SiteBot|Exabot|AhrefsBot|MJ12bot|TurnitinBot|magpie-crawler|Nutch Crawler|CMS Crawler|rogerbot|Domnutch|ssearch_bot|XoviBot|netseer|digincore|fr-crawler|wesee|AliasIO|contxbot|PingdomBot|BingPreview|HeadlessChrome)/; + // true if the user agent string contains a search bot string pattern + return searchBotRE.test(uaOverride || currentUserAgentString()); +} + +/** + * Modify event to set standard coordinate properties if they are not available + * @memberof Countly._internals + * @param {Event} e - event object + * @returns {Event} modified event object + */ +function get_page_coord(e) { + // checking if pageY and pageX is already available + if (typeof e.pageY === "undefined" + && typeof e.clientX === "number" + && document.documentElement) { + // if not, then add scrolling positions + e.pageX = e.clientX + document.body.scrollLeft + document.documentElement.scrollLeft; + e.pageY = e.clientY + document.body.scrollTop + document.documentElement.scrollTop; + } + // return e which now contains pageX and pageY attributes + return e; +} + +/** + * Get height of whole document + * @memberof Countly._internals + * @returns {Number} height in pixels + */ +function getDocHeight() { + var D = document; + return Math.max( + Math.max(D.body.scrollHeight, D.documentElement.scrollHeight), + Math.max(D.body.offsetHeight, D.documentElement.offsetHeight), + Math.max(D.body.clientHeight, D.documentElement.clientHeight) + ); +} + +/** + * Get width of whole document + * @memberof Countly._internals + * @returns {Number} width in pixels + */ +function getDocWidth() { + var D = document; + return Math.max( + Math.max(D.body.scrollWidth, D.documentElement.scrollWidth), + Math.max(D.body.offsetWidth, D.documentElement.offsetWidth), + Math.max(D.body.clientWidth, D.documentElement.clientWidth) + ); +} + +/** + * Get height of viewable area + * @memberof Countly._internals + * @returns {Number} height in pixels + */ +function getViewportHeight() { + var D = document; + return Math.min( + Math.min(D.body.clientHeight, D.documentElement.clientHeight), + Math.min(D.body.offsetHeight, D.documentElement.offsetHeight), + window.innerHeight + ); +} + +/** + * Get device's orientation + * @returns {String} device orientation + */ +function getOrientation() { + return window.innerWidth > window.innerHeight ? "landscape" : "portrait"; +} + + + +/** + * Load external js files + * @param {String} tag - Tag/node name to load file in + * @param {String} attr - Attribute name for type + * @param {String} type - Type value + * @param {String} src - Attribute name for file path + * @param {String} data - File path + * @param {Function} callback - callback when done + */ +function loadFile(tag, attr, type, src, data, callback) { + var fileRef = document.createElement(tag); + var loaded; + fileRef.setAttribute(attr, type); + fileRef.setAttribute(src, data); + var callbackFunction = function () { + if (!loaded) { + callback(); + } + loaded = true; + }; + if (callback) { + fileRef.onreadystatechange = callbackFunction; + fileRef.onload = callbackFunction; + } + document.getElementsByTagName("head")[0].appendChild(fileRef); +} + +/** + * Load external js files + * @memberof Countly._internals + * @param {String} js - path to JS file + * @param {Function} callback - callback when done + */ +function loadJS(js, callback) { + loadFile("script", "type", "text/javascript", "src", js, callback); +} + +/** + * Load external css files + * @memberof Countly._internals + * @param {String} css - path to CSS file + * @param {Function} callback - callback when done + */ +function loadCSS(css, callback) { + loadFile("link", "rel", "stylesheet", "href", css, callback); +} + +/** + * Show loader UI when loading external data + * @memberof Countly._internals + */ +function showLoader() { + if (!isBrowser) { + return; + } + var loader = document.getElementById("cly-loader"); + if (!loader) { + var css = "#cly-loader {height: 4px; width: 100%; position: absolute; z-index: 99999; overflow: hidden; background-color: #fff; top:0px; left:0px;}" + + "#cly-loader:before{display: block; position: absolute; content: ''; left: -200px; width: 200px; height: 4px; background-color: #2EB52B; animation: cly-loading 2s linear infinite;}" + + "@keyframes cly-loading { from {left: -200px; width: 30%;} 50% {width: 30%;} 70% {width: 70%;} 80% { left: 50%;} 95% {left: 120%;} to {left: 100%;}}"; + var head = document.head || document.getElementsByTagName("head")[0]; + var style = document.createElement("style"); + style.type = "text/css"; + if (style.styleSheet) { + style.styleSheet.cssText = css; + } + else { + style.appendChild(document.createTextNode(css)); + } + head.appendChild(style); + loader = document.createElement("div"); + loader.setAttribute("id", "cly-loader"); + document.body.onload = function () { + // check if hideLoader is on and if so return + if (Countly.showLoaderProtection) { + if (checkIfLoggingIsOn()) { + // eslint-disable-next-line no-console + console.warn("[WARNING] [Countly] showloader, Loader is already on"); + } + return; + } + try { + document.body.appendChild(loader); + } + catch (e) { + if (checkIfLoggingIsOn()) { + // eslint-disable-next-line no-console + console.error("[ERROR] [Countly] showLoader, Body is not loaded for loader to append: " + e); + } + } + }; + } + loader.style.display = "block"; +} + +/** + * Checks if debug is true and console is available in Countly object + * @memberof Countly._internals + * @returns {Boolean} true if debug is true and console is available in Countly object + */ +function checkIfLoggingIsOn() { + // check if logging is enabled + if (Countly && Countly.debug && typeof console !== "undefined") { + return true; + } + return false; +} + +/** + * Hide loader UI + * @memberof Countly._internals + */ +function hideLoader() { + if (!isBrowser) { + return; + } + // Inform showLoader that it should not append the loader + Countly.showLoaderProtection = true; + var loader = document.getElementById("cly-loader"); + if (loader) { + loader.style.display = "none"; + } +} + +export { + getMultiSelectValues, + secureRandom, + generateUUID, + replacePatternWithRandomValues, + getTimestamp, + getMsTimestamp, + getConfig, + dispatchErrors, + prepareParams, + stripTrailingSlash, + createNewObjectFromProperties, + addNewProperties, + truncateObject, + truncateSingleValue, + get_closest_element, + add_event_listener, + get_event_target, + currentUserAgentString, + userAgentDeviceDetection, + userAgentSearchBotDetection, + get_page_coord, + getDocHeight, + getDocWidth, + getViewportHeight, + getOrientation, + loadJS, + loadCSS, + showLoader, + checkIfLoggingIsOn, + hideLoader +}; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..1d04c5c --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "countly-sdk-js", + "version": "23.10.0", + "description": "Countly JavaScript SDK", + "type": "module", + "main": "Countly.js", + "scripts": { + "test": "test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Countly/countly-sdk-js.git" + }, + "keywords": [ + "sdk", + "countly", + "js", + "analytics" + ], + "author": "Countly (https://countly.com/)", + "license": "MIT", + "bugs": { + "url": "https://github.com/Countly/countly-sdk-js/issues" + }, + "homepage": "https://github.com/Countly/countly-sdk-js#readme", + "devDependencies": { + "@babel/core": "^7.22.9", + "@babel/preset-env": "^7.22.9", + "@rollup/plugin-babel": "^6.0.4", + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-terser": "^0.4.4", + "babel-loader": "^9.1.3", + "babel-preset-env": "^1.7.0", + "cypress-localstorage-commands": "^2.2.5", + "rollup": "^4.6.0", + "cypress": "^13.6.0" + } +} diff --git a/plugin/boomerang/boomerang.min.js b/plugin/boomerang/boomerang.min.js new file mode 100644 index 0000000..010d44e --- /dev/null +++ b/plugin/boomerang/boomerang.min.js @@ -0,0 +1,10 @@ +/* + * Copyright (c) 2011, Yahoo! Inc. All rights reserved. + * Copyright (c) 2011-2012, Log-Normal, Inc. All rights reserved. + * Copyright (c) 2012-2017, SOASTA, Inc. All rights reserved. + * Copyright (c) 2017, Akamai Technologies, Inc. All rights reserved. + * Copyrights licensed under the BSD License. See the accompanying LICENSE.txt file for terms. + */ +/* Boomerang Version: 1.0.0 7dcaaef8353cdf4e09a1fc74ad2de97bf60e24b2 */ + +BOOMR_start=(new Date).getTime();function BOOMR_check_doc_domain(e){if(window){if(!e){if(window.parent===window||!document.getElementById("boomr-if-as"))return;if(window.BOOMR&&BOOMR.boomerang_frame&&BOOMR.window)try{BOOMR.boomerang_frame.document.domain!==BOOMR.window.document.domain&&(BOOMR.boomerang_frame.document.domain=BOOMR.window.document.domain)}catch(t){BOOMR.isCrossOriginError(t)||BOOMR.addError(t,"BOOMR_check_doc_domain.domainFix")}e=document.domain}if(e&&-1!==e.indexOf(".")&&window.parent){try{window.parent.document;return}catch(t){try{document.domain=e}catch(n){return}}try{window.parent.document;return}catch(t){e=e.replace(/^[\w\-]+\./,"")}BOOMR_check_doc_domain(e)}}}BOOMR_check_doc_domain();!function(l){var u,t,a,o,s,e,c,n=l;l.parent!==l&&document.getElementById("boomr-if-as")&&"script"===document.getElementById("boomr-if-as").nodeName.toLowerCase()&&(l=l.parent);a=l.document;l.BOOMR||(l.BOOMR={});BOOMR=l.BOOMR;if(!BOOMR.version){BOOMR.version="1.0.0";BOOMR.window=l;BOOMR.boomerang_frame=n;BOOMR.plugins||(BOOMR.plugins={});!function(){try{new l.CustomEvent("CustomEvent")!==undefined&&(o=function(e,t){return new l.CustomEvent(e,t)})}catch(e){}try{!o&&a.createEvent&&a.createEvent("CustomEvent")&&(o=function(e,t){var n=a.createEvent("CustomEvent");n.initCustomEvent(e,(t=t||{cancelable:!1,bubbles:!1}).bubbles,t.cancelable,t.detail);return n})}catch(e){}o=(o=!o&&a.createEventObject?function(e,t){var n=a.createEventObject();n.type=n.propertyName=e;n.detail=t.detail;return n}:o)||function(){return undefined}}();s=function(e,t,n){var r=o(e,{detail:t});r&&(n?BOOMR.setImmediate(i):i());function i(){try{a.dispatchEvent?a.dispatchEvent(r):a.fireEvent&&a.fireEvent("onpropertychange",r)}catch(e){}}};if("undefined"!=typeof a.hidden){e="visibilityState";c="visibilitychange"}else if("undefined"!=typeof a.mozHidden){e="mozVisibilityState";c="mozvisibilitychange"}else if("undefined"!=typeof a.msHidden){e="msVisibilityState";c="msvisibilitychange"}else if("undefined"!=typeof a.webkitHidden){e="webkitVisibilityState";c="webkitvisibilitychange"}u={beacon_url:"",beacon_url_force_https:!0,beacon_urls_allowed:[],beacon_type:"AUTO",beacon_auth_key:"Authorization",beacon_auth_token:undefined,beacon_with_credentials:!1,beacon_disable_sendbeacon:!1,site_domain:l.location.hostname.replace(/.*?([^.]+\.[^.]+)\.?$/,"$1").toLowerCase(),user_ip:"",autorun:!0,hasSentPageLoadBeacon:!1,r:undefined,same_site_cookie:"Lax",secure_cookie:!1,forced_same_site_cookie_none:!1,events:{page_ready:[],page_unload:[],before_unload:[],dom_loaded:[],visibility_changed:[],prerender_to_visible:[],before_beacon:[],beacon:[],page_load_beacon:[],xhr_load:[],click:[],form_submit:[],config:[],xhr_init:[],spa_init:[],spa_navigation:[],spa_cancel:[],xhr_send:[],xhr_error:[],error:[],netinfo:[],rage_click:[],before_early_beacon:[]},public_events:{before_beacon:"onBeforeBoomerangBeacon",beacon:"onBoomerangBeacon",onboomerangloaded:"onBoomerangLoaded"},translate_events:{onbeacon:"beacon",onconfig:"config",onerror:"error",onxhrerror:"xhr_error"},unloadEventsCount:0,unloadEventCalled:0,listenerCallbacks:{},vars:{},singleBeaconVars:{},varPriority:{"-1":{},1:{}},errors:{},disabled_plugins:{},localStorageSupported:!1,LOCAL_STORAGE_PREFIX:"_boomr_",nativeOverwrites:[],xb_handler:function(n){return function(e){var t;(e=e||l.event).target?t=e.target:e.srcElement&&(t=e.srcElement);(t=3===t.nodeType?t.parentNode:t)&&t.nodeName&&"OBJECT"===t.nodeName.toUpperCase()&&"application/x-shockwave-flash"===t.type||u.fireEvent(n,t)}},clearEvents:function(){for(var e in this.events)this.events.hasOwnProperty(e)&&(this.events[e]=[])},clearListeners:function(){for(var e in u.listenerCallbacks)if(u.listenerCallbacks.hasOwnProperty(e))for(;u.listenerCallbacks[e].length;)BOOMR.utils.removeListener(u.listenerCallbacks[e][0].el,e,u.listenerCallbacks[e][0].fn);u.listenerCallbacks={}},fireEvent:function(e,t){var n,r,i,o;e=e.toLowerCase();this.translate_events[e]&&(e=this.translate_events[e]);if(this.events.hasOwnProperty(e)){this.public_events.hasOwnProperty(e)&&s(this.public_events[e],t);i=this.events[e];"before_beacon"!==e&&"beacon"!==e&&"before_early_beacon"!==e&&BOOMR.real_sendBeacon();o=i.length;for(n=0;n")}for(n=0;n=n.expires){this.removeLocalStorage(e);return undefined}return n.items},setLocalStorage:function(e,t,n){var r;if(!e||!u.localStorageSupported||"object"!=typeof t)return!1;t={items:t};"number"==typeof n&&(t.expires=BOOMR.now()+1e3*n);if((t=l.JSON.stringify(t)).length<5e4){try{l.localStorage.setItem(u.LOCAL_STORAGE_PREFIX+e,t);if(t===(r=l.localStorage.getItem(u.LOCAL_STORAGE_PREFIX+e)))return!0}catch(i){}BOOMR.warn("Saved storage value doesn't match what we tried to set:\n"+t+"\n"+r)}else BOOMR.warn("Storage items too large: "+t.length+" "+t);return!1},removeLocalStorage:function(e){if(!e||!u.localStorageSupported)return!1;try{l.localStorage.removeItem(u.LOCAL_STORAGE_PREFIX+e);return!0}catch(t){}return!1},cleanupURL:function(e,t){if(!e||BOOMR.utils.isArray(e))return"";u.strip_query_string&&(e=e.replace(/\?.*/,"?qs-redacted"));if(void 0!==t&&e&&e.length>t){var n=e.indexOf("?");e=-1!==n&&n>>0).toString()+e.length;return parseInt(r).toString(36)},isCurrentUASameSiteNoneCompatible:function(){return!(l&&l.navigator&&l.navigator.userAgent&&"string"==typeof l.navigator.userAgent)||this.isUASameSiteNoneCompatible(l.navigator.userAgent)},isUASameSiteNoneCompatible:function(e){var t=e.match(/(UCBrowser)\/(\d+\.\d+)\.(\d+)/);if(t){var n=parseFloat(t[2]),r=t[3];return 12.13===n?!(r<=2):!(n<12.13)}if(t=e.match(/(Chrome)\/(\d+)\.(\d+)\.(\d+)\.(\d+)/)){n=t[2];return 51<=n&&n<=66?!1:!0}return(t=e.match(/(Macintosh;.*Mac OS X 10_14[_\d]*.*) AppleWebKit\//))?(!(t=e.match(/Version\/.* Safari\//))||null!==(t=e.match(/Chrom(?:e|ium)/)))&&!(t=e.match(/^Mozilla\/\d+(?:\.\d+)* \(Macintosh;.*Mac OS X \d+(?:_\d+)*\) AppleWebKit\/\d+(?:\.\d+)* \(KHTML, like Gecko\)$/)):!(t=e.match(/(iP.+; CPU .*OS 12(?:_\d+)*.*)/))}},browser:{results:{},supportsPassive:function(){if("undefined"==typeof BOOMR.browser.results.supportsPassive){BOOMR.browser.results.supportsPassive=!1;if(!Object.defineProperty)return!1;try{var e=Object.defineProperty({},"passive",{get:function(){BOOMR.browser.results.supportsPassive=!0}});window.addEventListener("test",null,e)}catch(t){}}return BOOMR.browser.results.supportsPassive}},init:function(e){var t,n,r=["autorun","beacon_auth_key","beacon_auth_token","beacon_with_credentials","beacon_disable_sendbeacon","beacon_url","beacon_url_force_https","beacon_type","site_domain","strip_query_string","user_ip","same_site_cookie","secure_cookie"];BOOMR_check_doc_domain();(e=e||{}).log!==undefined&&(this.log=e.log);this.log||(this.log=function(){});this.pageId||(this.pageId=BOOMR.utils.generateId(8));if(e.primary&&u.handlers_attached)return this;if("undefined"!=typeof e.site_domain){/:/.test(e.site_domain)&&(e.site_domain=l.location.hostname.toLowerCase());this.session.domain=e.site_domain}BOOMR.session.enabled&&"undefined"==typeof BOOMR.session.ID&&(BOOMR.session.ID=BOOMR.utils.generateUUID());"undefined"!=typeof e.autorun&&(u.autorun=e.autorun);for(n in this.plugins)if(this.plugins.hasOwnProperty(n))if(e[n]&&e[n].hasOwnProperty("enabled")&&!1===e[n].enabled){u.disabled_plugins[n]=1;"function"==typeof this.plugins[n].disable&&this.plugins[n].disable()}else{if(u.disabled_plugins[n]){if(!e[n]||!e[n].hasOwnProperty("enabled")||!0!==e[n].enabled)continue;"function"==typeof this.plugins[n].enable&&this.plugins[n].enable();delete u.disabled_plugins[n]}if("function"==typeof this.plugins[n].init)try{this.plugins[n].init(e)}catch(i){BOOMR.addError(i,n+".init")}}for(t=0;tBOOMR.constants.MAX_GET_LENGTH&&(window.console&&(console.warn||console.log)||function(){})("Boomerang: Warning: Beacon may not be sent via GET due to payload size > 2000 bytes")}else("POST"===u.beacon_type||e.length>BOOMR.constants.MAX_GET_LENGTH)&&(r=!1);if(l&&l.navigator&&"function"==typeof l.navigator.sendBeacon&&BOOMR.utils.isNative(l.navigator.sendBeacon)&&"function"==typeof l.Blob&&"GET"!==u.beacon_type&&"undefined"==typeof u.beacon_auth_token&&!u.beacon_disable_sendbeacon){var i=new l.Blob([n+"&sb=1"],{type:"application/x-www-form-urlencoded"});if(l.navigator.sendBeacon(u.beacon_url,i))return!0}if(r=!(BOOMR.orig_XMLHttpRequest||l&&l.XMLHttpRequest)?!0:r){try{t=new Image}catch(o){return!1}t.src=e}else{e=new(BOOMR.window.orig_XMLHttpRequest||BOOMR.orig_XMLHttpRequest||BOOMR.window.XMLHttpRequest);try{this.sendXhrPostBeacon(e,n)}catch(o){e=new BOOMR.boomerang_frame.XMLHttpRequest;this.sendXhrPostBeacon(e,n)}}return!0},hasSentPageLoadBeacon:function(){return u.hasSentPageLoadBeacon},sendXhrPostBeacon:function(e,t){e.open("POST",u.beacon_url);e.setRequestHeader("Content-type","application/x-www-form-urlencoded");if("undefined"!=typeof u.beacon_auth_token){"undefined"==typeof u.beacon_auth_key&&(u.beacon_auth_key="Authorization");e.setRequestHeader(u.beacon_auth_key,u.beacon_auth_token)}u.beacon_with_credentials&&(e.withCredentials=!0);e.send(t)},getVarsOfPriority:function(e,t){var n,r=[],i=0!==t?u.varPriority[t]:e;for(n in i)if(i.hasOwnProperty(n)&&e.hasOwnProperty(n)){r.push(this.getUriEncodedVar(n,"undefined"==typeof e[n]?"":e[n]));0!==t&&delete e[n]}return r},getUriEncodedVar:function(e,t){"object"==typeof(t=t===undefined||null===t?"":t)&&(t=BOOMR.utils.serializeForUrl(t));return encodeURIComponent(e)+"="+encodeURIComponent(t)},getResourceTiming:function(e,t,n){var r,i=BOOMR.getPerformance();try{if(i&&"function"==typeof i.getEntriesByName){if(!(r=i.getEntriesByName(e))||!r.length)return;if(!("function"!=typeof n||(r=BOOMR.utils.arrayFilter(r,n))&&r.length))return;1x.logMaxEntries&&Array.prototype.splice.call(a,0,a.length-x.logMaxEntries)},increment:function g(e,t,n){void 0===n&&(n=O());void 0===t&&(t=1);if(l[e]){l[e][n]||(l[e][n]=0);l[e][n]+=t}},getTimeBucket:O,getStats:function h(e,t){var n,r,i=0,o=0,a=Infinity,s=0,u=Math.floor((t-c)/v);if(!l[e])return 0;for(r in l[e]){r=parseInt(r,10);if(u<=r&&l[e].hasOwnProperty(r)){i++;o+=n=l[e][r];a=Math.min(a,n);s=Math.max(s,n)}}return{total:o,count:i,min:a,max:s}},analyze:function R(e){var t=O(),n=0,r=0;x.sendLog&&void 0!==e&&function i(){for(var e="",t=0;ta){i.log(0,t,{y:n});u=n}c+=Math.round(r/g*100);l+=Math.round(r/g*100);s=n}},s=function(e,o,a){o.register("click",b);var s=10,u=3,c=0,l=0,d=0,f=0,O=0,p=null;function t(e){var t=BOOMR.now(),n=e.clientX,r=e.clientY;c++;var i=Math.round(Math.sqrt(Math.pow(O-r,2)+Math.pow(f-n,2)));if(p===e.target||i<=s){if(u<=++l+1){d++;BOOMR.fireEvent("rage_click",e)}}else l=0;f=n;O=r;p=e.target;o.increment("click");o.log(1,t,{x:n,y:r});e.cancelable&&a.interact("click",t,e)}S.clicksCount=function(){return c};S.clicksRage=function(){return d};BOOMR.utils.addListener(e.document,"click",t,w);return{analyze:function n(e){x.addToBeacon("c.c",S.clicksCount());x.addToBeacon("c.c.r",S.clicksRage())},stop:function r(){BOOMR.utils.removeListener(e.document,"click",t)},onBeacon:function i(){d=l=c=0}}},u=function(e,n,r){n.register("key",b);var i=0,o=0;function t(e){var t=BOOMR.now();i++;27===e.keyCode&&o++;n.increment("key");n.log(3,t);e.cancelable&&r.interact("key",t,e)}S.keyCount=function(){return i};S.keyEscapes=function(){return o};BOOMR.utils.addListener(e.document,"keydown",t,w);return{analyze:function a(e){x.addToBeacon("c.k",S.keyCount());x.addToBeacon("c.k.e",S.keyEscapes())},stop:function s(){BOOMR.utils.removeListener(e.document,"keydown",t)},onBeacon:function u(){o=i=0}}},c=function(e,i,t){i.register("mouse",b);i.register("mousepct",_);var o=0,a=0,n=0,r=0,s=0,u=0,c=0,l=!1,d=!1,f=Math.round(Math.sqrt(Math.pow(BOOMR.utils.windowHeight(),2)+Math.pow(BOOMR.utils.windowWidth(),2)));function O(e){var t=e.clientX,n=e.clientY,r=Math.round(Math.sqrt(Math.pow(a-n,2)+Math.pow(o-t,2))),e=Math.round(r/f*100);s+=e;u+=e;c+=r;o=t;a=n;i.increment("mouse",r)}S.mousePct=function(){return u};S.mousePixels=function(){return c};l=setInterval(function p(){var e=Math.min(s,100);0!==e&&i.set("mousepct",e);s=0},v);d=setInterval(function m(){if(n!==o||r!==a)if(10<=Math.round(Math.sqrt(Math.pow(r-a,2)+Math.pow(n-o,2)))){i.log(2,BOOMR.now(),{x:o,y:a});n=o;r=a}},250);BOOMR.utils.addListener(e.document,"mousemove",O,w);return{analyze:function g(e){x.addToBeacon("c.m.p",S.mousePct());x.addToBeacon("c.m.n",S.mousePixels())},stop:function h(){if(l){clearInterval(l);l=!1}if(d){clearInterval(d);d=!1}BOOMR.utils.removeListener(e.document,"mousemove",O)},onBeacon:function R(){c=u=0}}},l=function(i,o,e){o.register("inter",b);o.register("interdly",b);var a=e,s=0,u=null,c=0,l=0,d=0,f=0,O=!0,p=0,m=0,g=!1,h=!1,R=!1,n=!1;function t(){if(g){clearTimeout(g);g=!1}if(h){clearTimeout(h);h=!1}}function M(){BOOMR.sendBeaconWhenReady({"rt.start":"manual","http.initiator":"interaction","rt.tstart":p,"rt.end":m},function(){t();BOOMR.fireEvent("interaction")},x)}S.interactionDelayed=function(){return d};S.interactionDelayedTime=function(){return Math.ceil(f)};S.interactionAvgDelay=function(){if(0x.waitAfterOnload){x.complete=!0;BOOMR.sendBeacon()}else{x.timeline.analyze();if(S.timeToInteractive()){x.complete=!0;BOOMR.sendBeacon()}else setTimeout(t,500)}},500)}else x.complete=!0},addToBeacon:function(e,t,n){0!==t&&void 0!==t||n?BOOMR.addVar(e,t,!0):BOOMR.removeVar(e)}};BOOMR.plugins.Continuity={init:function(e){BOOMR.utils.pluginConfig(x,e,"Continuity",["monitorLongTasks","monitorPageBusy","monitorFrameRate","monitorInteractions","monitorStats","afterOnload","afterOnloadMaxLength","afterOnloadMinWait","waitAfterOnload","ttiWaitForFrameworkReady","ttiWaitForHeroImages","sendLog","logMaxEntries","sendTimeline","monitorLayoutShifts"]);if(x.initialized)return this;x.initialized=!0;x.timeline=new t(BOOMR.now());if(BOOMR.window){if(x.monitorLongTasks&&BOOMR.window.PerformanceObserver&&BOOMR.window.PerformanceLongTaskTiming){x.longTaskMonitor=new r(BOOMR.window,x.timeline);x.ttiMethod="lt"}if(x.monitorFrameRate&&"function"==typeof BOOMR.window.requestAnimationFrame){x.frameRateMonitor=new o(BOOMR.window,x.timeline);x.ttiMethod||(x.ttiMethod="raf")}if(x.monitorPageBusy&&(!BOOMR.window.PerformanceObserver||!BOOMR.window.PerformanceLongTaskTiming||!x.monitorLongTasks)){x.pageBusyMonitor=new i(BOOMR.window,x.timeline);x.ttiMethod||(x.ttiMethod="b")}if(x.monitorInteractions){x.interactionMonitor=new l(BOOMR.window,x.timeline,x.afterOnloadMinWait);x.scrollMonitor=new a(BOOMR.window,x.timeline,x.interactionMonitor);x.keyMonitor=new u(BOOMR.window,x.timeline,x.interactionMonitor);x.clickMonitor=new s(BOOMR.window,x.timeline,x.interactionMonitor);x.mouseMonitor=new c(BOOMR.window,x.timeline,x.interactionMonitor);x.visibilityMonitor=new p(BOOMR.window,x.timeline,x.interactionMonitor);x.orientationMonitor=new m(BOOMR.window,x.timeline,x.interactionMonitor);x.touchStartMonitor=new O(BOOMR.window,x.timeline,x.interactionMonitor);x.mouseDownMonitor=new f(BOOMR.window,x.timeline,x.interactionMonitor);x.pointerDownMonitor=new d(BOOMR.window,x.timeline,x.interactionMonitor)}x.monitorStats&&(x.statsMonitor=new g(BOOMR.window,x.timeline,x.interactionMonitor));x.monitorLayoutShifts&&BOOMR.window.PerformanceObserver&&(x.layoutShiftMonitor=new n(BOOMR.window))}BOOMR.addVar("c.e",T.toString(36));BOOMR.addVar("c.tti.m",x.ttiMethod);BOOMR.subscribe("before_beacon",x.onBeforeBeacon,null,x);BOOMR.subscribe("beacon",x.onBeacon,null,x);BOOMR.subscribe("page_ready",x.onPageReady,null,x);BOOMR.subscribe("xhr_load",x.onXhrLoad,null,x);return this},is_complete:function(e){return x.complete||e&&("error"===e["http.initiator"]||"undefined"!=typeof e.early)},frameworkReady:function(){x.frameworkReady=BOOMR.now()},metrics:S}}}();!function(){if(!BOOMR.plugins.IFrameDelay){var r=BOOMR.window,i={initialized:!1,registerParent:!1,monitoredCount:0,finishedCount:0,runningCount:0,runningFrames:{},loadingIntervalID:undefined,loadedIntervalID:undefined,loadEnd:0,messages:{start:"boomrIframeLoading",done:"boomrIframeLoaded",startACK:"boomrIframeLoadingACK",doneACK:"boomrIframeLoadedACK"},onIFrameMessageAsParent:function(e){var t;if(e&&e.data&&"string"==typeof e.data&&"{"===e.data.charAt(0)&&e.source){try{t=JSON.parse(e.data)}catch(n){return}if(t.msg===i.messages.start){if(!i.runningFrames[t.pid]){e.source.postMessage(JSON.stringify({msg:i.messages.startACK}),e.origin);i.runningCount+=1;i.runningFrames[t.pid]=1}}else if(t.msg===i.messages.done){e.source.postMessage(JSON.stringify({msg:i.messages.doneACK}),e.origin);--i.runningCount;i.finishedCount+=1;t.loadEnd>i.loadEnd&&(i.loadEnd=t.loadEnd);i.checkCompleteness()}}},onIFrameMessageAsChild:function(e){var t;if(e&&e.data&&"string"==typeof e.data&&"{"===e.data.charAt(0)&&e.source){try{t=JSON.parse(e.data)}catch(n){return}if(t.msg===i.messages.startACK){clearInterval(i.loadingIntervalID);i.loadingIntervalID=undefined}else if(t.msg===i.messages.doneACK){clearInterval(i.loadedIntervalID);i.loadedIntervalID=undefined}}},checkCompleteness:function(){if(i.is_complete()){BOOMR.addVar("ifdl.done",BOOMR.now());BOOMR.addVar("ifdl.ct",i.finishedCount);BOOMR.addVar("ifdl.r",i.runningCount);BOOMR.addVar("ifdl.mon",i.monitoredCount);BOOMR.hasBrowserOnloadFired()?BOOMR.page_ready(0=i.monitoredCount&&0===i.runningCount}};BOOMR.plugins.IFrameDelay={init:function(e){BOOMR.utils.pluginConfig(i,e,"IFrameDelay",["enabled","registerParent","monitoredCount"]);if(i.initialized)return this;i.initialized=!0;if(this.is_supported())if(i.registerParent){BOOMR.utils.addListener(window,"message",i.onIFrameMessageAsChild);function t(){r.parent.postMessage(JSON.stringify({msg:i.messages.start,pid:BOOMR.pageId}),"*")}t();i.loadingIntervalID=setInterval(t,250);BOOMR.subscribe("page_load_beacon",function(e){var t=e&&e["rt.end"]?e["rt.end"]:BOOMR.now();function n(){i.loadingIntervalID||r.parent.postMessage(JSON.stringify({msg:i.messages.done,pid:BOOMR.pageId,loadEnd:t}),"*")}n();i.loadedIntervalID=setInterval(n,250)})}else if(!i.registerParent&&i.monitoredCount&&0i){n[o].responseStart=i;n[o].responseEnd=i}else n[o].responseEnd>i&&(n[o].responseEnd=i);var a=Math.round(BOOMR.plugins.ResourceTiming.calculateResourceTimingUnion(n)),t=r-a;a<0||r<0||t<0?BOOMR.addError("Incorrect SPA time calculation"):e.timers={t_resp:a,t_page:t,t_done:r}}else e.timers={t_resp:0,t_page:r,t_done:r}}};s.prototype.setTimeout=function(e,t){var n=this;if(e){this.clearTimeout(t);this.timer=setTimeout(function(){n.timedout(t)},e)}};s.prototype.timedout=function(e){var t;this.clearTimeout(e);(t=this.pending_events[e])&&(!BOOMR.utils.inArray(t.type,BOOMR.constants.BEACON_TYPE_SPAS)||BOOMR.hasBrowserOnloadFired()?0===t.nodes_to_wait&&this.sendEvent(e):this.setTimeout(p.spaIdleTimeout,e))};s.prototype.clearTimeout=function(e){if(this.timer&&e===this.pending_events.length-1){clearTimeout(this.timer);this.timer=null}};s.prototype.load_cb=function(e,t){var n=BOOMR.now(),r=e.target||e.srcElement;if(r&&r._bmr){e=r._bmr.idx;t=void 0!==t?t:r._bmr.res||0;if(!r._bmr.end[t]){r._bmr.end[t]=n;this.load_finished(e,n)}}};s.prototype.monitorMO=function(e){e=this.pending_events[e];e&&delete e.ignoreMO};s.prototype.load_finished=function(e,t){var n=this.pending_events[e];if(n){n.nodes_to_wait--;if(0===n.nodes_to_wait){n.resource.timing.loadEventEnd=t||BOOMR.now();if(e===this.pending_events.length-1)if(BOOMR.utils.inArray(n.type,BOOMR.constants.BEACON_TYPE_SPAS)){if(!n.firedEarlyBeacon&&BOOMR.plugins.Early&&BOOMR.plugins.Early.is_supported()){if(this.timerEarlyBeacon){clearTimeout(this.timerEarlyBeacon);this.timerEarlyBeacon=null}this.timerEarlyBeacon=setTimeout(function(){d.timerEarlyBeacon=null;if(!n.firedEarlyBeacon&&0===n.nodes_to_wait){n.firedEarlyBeacon=!0;BOOMR.plugins.Early.sendEarlyBeacon(n.resource,n.type)}},100)}this.setTimeout(p.spaIdleTimeout,e)}else this.setTimeout(p.xhrIdleTimeout,e);else this.sendEvent(e)}}};s.prototype.wait_for_node=function(t,n){var r,i,o,e,a,s,u,c,l=this,d=!1,f=!1;if(t&&t.nodeName&&(t.nodeName.toUpperCase().match(/^(IMG|IFRAME|IMAGE)$/)||"LINK"===t.nodeName.toUpperCase()&&t.rel&&t.rel.match(/\bstylesheet\b/i))){t._bmr&&"number"==typeof t._bmr.res&&t._bmr.end[t._bmr.res]&&(f=!0);e=t.src||"function"==typeof t.getAttribute&&t.getAttribute("xlink:href")||t.href;t._bmr&&t._bmr.url!==e&&(f=!0);if(f&&"function"==typeof t._bmr.listener){l.load_cb({target:t,type:"changed"});t.removeEventListener("load",t._bmr.listener);t.removeEventListener("error",t._bmr.listener);delete t._bmr.listener}if(!e||e.match(/^(about:|javascript:|data:)/i))return!1;if("IMG"===t.nodeName){if(t.naturalWidth&&!f)return!1;if("function"==typeof t.getAttribute&&""===t.getAttribute("src"))return!1;if("lazy"===t.loading)return!1}if("IFRAME"===t.nodeName&&f)return!1;if("function"==typeof t.getAttribute){s=parseInt(t.getAttribute("height"),10);u=parseInt(t.getAttribute("width"),10)}isNaN(s)&&(s=!t.style||"0"!==t.style.height&&"0px"!==t.style.height&&"1px"!==t.style.height?undefined:0);isNaN(u)&&(u=!t.style||"0"!==t.style.width&&"0px"!==t.style.width&&"1px"!==t.style.width?undefined:0);if(!isNaN(s)&&s<=1&&!isNaN(u)&&u<=1)return!1;if(0===s||0===u)return!1;if(t.style&&("none"===t.style.display||"hidden"===t.style.visibility||"0"===t.style.opacity))return!1;if(p.domExcludeFilter(t))return!1;if(!(u=this.pending_events[n]))return!1;a=u.resources.length;u.urls||(u.urls={});if(u.urls[e])return!1;if(!u.resource.url){O.href=e;if(p.xhrExcludeFilter(O))return!1;u.resource.url=O.href}t._bmr||(t._bmr={end:{}});t._bmr.res=a;t._bmr.idx=n;delete t._bmr.end[a];t._bmr.url=e;c=function(e){l.load_cb(e,a);t.removeEventListener("load",c);t.removeEventListener("error",c);delete t._bmr.listener};t._bmr.listener=c;t.addEventListener("load",c);t.addEventListener("error",c);u.nodes_to_wait++;this.clearTimeout(n);u.total_nodes++;u.resources.push(t);u.urls[e]=1;d=!0}else t.nodeType===Node.ELEMENT_NODE&&["IMAGE","IMG"].forEach(function(e){if((r=t.getElementsByTagName(e))&&r.length)for(i=0,o=r.length;i=t.timing.requestStart&&0!==e.responseEnd})){e=Math.floor(r+n.startTime);if((i=Math.floor(r+n.responseEnd))<=BOOMR.now()){t.timing.responseEnd=i;t.timing.loadEventEnd "+t,"rt")}return!0},refreshSession:function(e){if(e=e||BOOMR.plugins.RT.getCookie()){e.ss?BOOMR.session.start=e.ss:BOOMR.session.start=BOOMR.plugins.RT.navigationStart()||BOOMR.t_lstart||BOOMR.t_start;e.si&&e.si.match(/-/)&&(BOOMR.session.ID=e.si);e.sl&&(BOOMR.session.length=e.sl);e.tt&&(this.loadTime=e.tt);e.obo&&(this.oboError=e.obo);e.dm&&!BOOMR.session.domain&&(BOOMR.session.domain=e.dm);e.se&&(a.session_exp=e.se);e.bcn&&(this.beacon_url=e.bcn);e.rl&&"1"===e.rl&&(BOOMR.session.rate_limited=!0)}},maybeResetSession:function(e,t){var n=0;BOOMR.session.start&&BOOMR.session.length&&(n=(BOOMR.now()-BOOMR.session.start)/BOOMR.session.length);var r=1e3*a.session_exp;if(!BOOMR.session.start||t&&BOOMR.session.start>t||e-(a.lastActionTime||BOOMR.t_start)>r||rn.s&&(this.t_fb_approx=n.hd)}else this.t_start=this.t_fb_approx=undefined}n.s&&(this.lastActionTime=n.s);this.refreshSession(n);this.updateCookie({s:undefined,ul:undefined,cl:undefined,hd:undefined,ld:undefined,rl:undefined,r:undefined,nu:undefined,sh:undefined});this.maybeResetSession(BOOMR.now())}},incrementSessionDetails:function(){BOOMR.session.length++;!a.timers.t_done||isNaN(a.timers.t_done.delta)?a.oboError++:a.loadTime+=a.timers.t_done.delta},getBoomerangTimings:function(){var e,t,n,r,i;function o(e,t){e=Math.round(e||0),t=Math.round(t||0);return(e=0===e?0:e-t)||""}if(BOOMR.t_start){BOOMR.plugins.RT.startTimer("boomerang",BOOMR.t_start);BOOMR.plugins.RT.endTimer("boomerang",BOOMR.t_end);BOOMR.plugins.RT.endTimer("boomr_fb",BOOMR.t_start);if(BOOMR.t_lstart){BOOMR.plugins.RT.endTimer("boomr_ld",BOOMR.t_lstart);BOOMR.plugins.RT.setTimer("boomr_lat",BOOMR.t_start-BOOMR.t_lstart)}}try{if(window&&"performance"in window&&window.performance&&"function"==typeof window.performance.getEntriesByName){t={"rt.bmr":BOOMR.url};BOOMR.config_url&&(t["rt.cnf"]=BOOMR.config_url);for(n in t)if(t.hasOwnProperty(n)&&t[n]&&(e=window.performance.getEntriesByName(t[n]))&&0!==e.length&&e[0]){i=[r=o((e=e[0]).startTime,0),o(e.responseEnd,r),o(e.responseStart,r),o(e.requestStart,r),o(e.connectEnd,r),o(e.secureConnectionStart,r),o(e.connectStart,r),o(e.domainLookupEnd,r),o(e.domainLookupStart,r),o(e.redirectEnd,r),o(e.redirectStart,r)].join(",").replace(/,+$/,"");BOOMR.addVar(n,i,!0)}}}catch(a){a&&a.name&&a.name.hasOwnProperty("length")&&-1===a.name.indexOf("NS_ERROR_FAILURE")&&BOOMR.addError(a,"rt.getBoomerangTimings")}},checkPreRender:function(){if("prerender"!==BOOMR.visibilityState())return!1;BOOMR.plugins.RT.startTimer("t_load",this.navigationStart);BOOMR.plugins.RT.endTimer("t_load");BOOMR.plugins.RT.startTimer("t_prerender",this.navigationStart);BOOMR.plugins.RT.startTimer("t_postrender");return!0},initFromNavTiming:function(){var e,t;if(!this.navigationStart){(t=BOOMR.getPerformance())&&t.navigation&&(this.navigationType=t.navigation.type);if(t&&t.timing){e=t.timing;this.navigationStartSource="navigation"}else if(n.chrome&&n.chrome.csi&&n.chrome.csi().startE){e={navigationStart:n.chrome.csi().startE};this.navigationStartSource="csi"}else if(n.gtbExternal&&n.gtbExternal.startE()){e={navigationStart:n.gtbExternal.startE()};this.navigationStartSource="gtb"}if(e){this.navigationStart=e.navigationStart||e.fetchStart||undefined;this.fetchStart=e.fetchStart||undefined;this.responseStart=e.responseStart||undefined;navigator.userAgent.match(/Firefox\/[78]\./)&&(this.navigationStart=e.unloadEventStart||e.fetchStart||undefined)}else BOOMR.warn("This browser doesn't support the WebTiming API","rt")}},validateLoadTimestamp:function(e,t,n){return t&&t.timing&&t.timing.loadEventEnd?t.timing.loadEventEnd:"xhr"!==n||t&&BOOMR.utils.inArray(t.initiator,BOOMR.constants.BEACON_TYPE_SPAS)?(t=BOOMR.getPerformance())&&t.timing?t.timing.loadEventEnd||e:BOOMR.t_onload||BOOMR.t_lstart||BOOMR.t_start||e:e},setPageLoadTimers:function(e,t,n){var r,i;if(!("xhr"===e||"early"===e&&n&&BOOMR.utils.inArray(n.initiator,BOOMR.constants.BEACON_TYPE_SPAS))){a.initFromCookie();a.initFromNavTiming();BOOMR.addVar("rt.start",this.navigationStartSource);if(a.checkPreRender())return!1}if("xhr"===e)if(n.timers)for(var o in n.timers)n.timers.hasOwnProperty(o)&&BOOMR.plugins.RT.setTimer(o,n.timers[o]);else n&&n.timing&&(void 0===(i=n.timing.fetchStart)||n.timing.responseEnd>=i)&&(r=n.timing.responseEnd);else a.responseStart?a.responseStart>=a.navigationStart&&a.responseStart>=a.fetchStart&&(r=a.responseStart):a.timers.hasOwnProperty("t_page")?BOOMR.plugins.RT.endTimer("t_page"):a.t_fb_approx&&(r=a.t_fb_approx);if(r&&"early"!==e){i?BOOMR.plugins.RT.setTimer("t_resp",i,r):BOOMR.plugins.RT.endTimer("t_resp",r);"load"===e&&a.timers.t_load?BOOMR.plugins.RT.setTimer("t_page",a.timers.t_load.end-r):ta.oboError&&(a.oboError=e.RT.oboError);if(e.RT.loadTime&&!isNaN(e.RT.loadTime)&&e.RT.loadTime>a.loadTime){a.loadTime=e.RT.loadTime;a.timers.t_done&&!isNaN(a.timers.t_done.delta)&&(a.loadTime+=a.timers.t_done.delta)}}},domloaded:function(){BOOMR.plugins.RT&&BOOMR.plugins.RT.endTimer("t_domloaded")},clear:function(e){e&&"undefined"!=typeof e.early||BOOMR.removeVar("rt.start")},spaNavigation:function(){a.onloadfired=!0}};BOOMR.plugins.RT={init:function(e){n!==BOOMR.window&&(n=BOOMR.window);e&&e.CrossDomain&&e.CrossDomain.sending&&(a.crossdomain_sending=!0);if(n&&n.document){r=n.document;BOOMR.utils.pluginConfig(a,e,"RT",["cookie","cookie_exp","session_exp","strict_referrer"]);e&&"undefined"!=typeof e.autorun&&(a.autorun=e.autorun);if(e&&e.beacon_url){a.beacon_url&&!e.force_beacon_url||(a.beacon_url=e.beacon_url);a.next_beacon_url=e.beacon_url}void 0!==r&&(a.r=BOOMR.utils.hashQueryString(r.referrer,!0));a.initFromCookie();if(a.initialized)return this;a.complete=!1;a.timers={};a.check_visibility();BOOMR.subscribe("page_ready",a.page_ready,null,a);BOOMR.subscribe("visibility_changed",a.check_visibility,null,a);BOOMR.subscribe("prerender_to_visible",a.prerenderToVisible,null,a);BOOMR.subscribe("page_ready",this.done,"load",this);BOOMR.subscribe("xhr_load",this.done,"xhr",this);BOOMR.subscribe("before_early_beacon",this.done,"early",this);BOOMR.subscribe("dom_loaded",a.domloaded,null,a);BOOMR.subscribe("page_unload",a.page_unload,null,a);BOOMR.subscribe("click",a.onclick,null,a);BOOMR.subscribe("form_submit",a.onsubmit,null,a);BOOMR.subscribe("before_beacon",this.addTimersToBeacon,"beacon",this);BOOMR.subscribe("beacon",a.clear,null,a);BOOMR.subscribe("error",a.markComplete,null,a);BOOMR.subscribe("config",a.onconfig,null,a);BOOMR.subscribe("spa_navigation",a.spaNavigation,null,a);BOOMR.subscribe("interaction",a.markComplete,null,a);BOOMR.getBeaconURL=function(){return a.beacon_url};a.initialized=!0;return this}},startTimer:function(e,t){if(e){"t_page"===e&&this.endTimer("t_resp",t);a.timers[e]={start:"number"==typeof t?t:BOOMR.now()}}return this},endTimer:function(e,t){if(e){a.timers[e]=a.timers[e]||{};a.timers[e].end===undefined&&(a.timers[e].end="number"==typeof t?t:BOOMR.now())}return this},clearTimer:function(e){e&&delete a.timers[e];return this},setTimer:function(e,t,n){e&&(a.timers[e]=void 0!==n?{start:t,end:n,delta:n-t}:{delta:t});return this},addTimersToBeacon:function(e,t){var n,r,i=[];for(n in a.timers)if(a.timers.hasOwnProperty(n)){if("number"!=typeof(r=a.timers[n]).delta){"number"!=typeof r.start&&(r.start="xhr"===t?a.cached_xhr_start:a.cached_t_start);r.delta=r.end-r.start}isNaN(r.delta)||(a.basic_timers.hasOwnProperty(n)?BOOMR.addVar(n,r.delta,!0):i.push(n+"|"+r.delta))}i.length&&BOOMR.addVar("t_other",i.join(","),!0);if("beacon"===t&&(!e||"undefined"==typeof e.early)){a.timers={};a.complete=!1}},done:function(e,t){if(BOOMR.plugins.RT){var n,r=BOOMR.now(),i=!1;a.complete=!1;n=a.validateLoadTimestamp(r,e,t);if(("load"===t||"visible"===t||"xhr"===t||"early"===t)&&!a.setPageLoadTimers(t,n,e))return this;("load"===t||"visible"===t||"early"===t&&(!e||"undefined"==typeof e.initiator||"spa_hard"===e.initiator)||"xhr"===t&&e&&"spa_hard"===e.initiator)&&a.getBoomerangTimings();r=a.determineTStart(t,e);a.refreshSession();a.maybeResetSession(n,r);"early"!==t&&this.endTimer("t_done",n);e&&"xhr"===e.initiator&&this.setTimer("t_done",e.timing.requestStart,e.timing.loadEventEnd);a.setSupportingTimestamps(r,t);this.addTimersToBeacon(null,t);BOOMR.setReferrer(a.r);"xhr"===t&&e&&e&&e.data&&(e=e.data);if("xhr"===t&&e){i=e.subresource;e.url&&BOOMR.addVar("u",BOOMR.utils.cleanupURL(e.url.replace(/#.*/,"")),!0);e.status&&(e.status<-1||400<=e.status)&&BOOMR.addVar("http.errno",e.status,!0);e.method&&"GET"!==e.method&&BOOMR.addVar("http.method",e.method,!0);e.type&&"xhr"!==e.type&&BOOMR.addVar("http.type",e.type[0],!0);e.headers&&BOOMR.addVar("http.hdr",e.headers,!0);e.synchronous&&BOOMR.addVar("xhr.sync",1,!0);e.initiator&&BOOMR.addVar("http.initiator",e.initiator,!0);e.responseBodyNotUsed&&BOOMR.addVar("fetch.bnu",1,!0);e.responseUrl&&BOOMR.addVar("xhr.ru",BOOMR.utils.cleanupURL(e.responseUrl),!0)}i&&"passive"!==i&&BOOMR.addVar("rt.subres",1,!0);if("load"===t||"visible"===t||"xhr"===t&&!i||"unload"===t&&!a.onloadfired&&a.autorun&&!a.crossdomain_sending){a.incrementSessionDetails();a.updateCookie(null,"ld")}BOOMR.addVar({"rt.tt":a.loadTime,"rt.obo":a.oboError},undefined,!0);a.updateCookie();if("unload"===t){BOOMR.addVar("rt.quit","",!0);a.onloadfired||BOOMR.addVar("rt.abld","",!0);a.visiblefired||BOOMR.addVar("rt.ntvu","",!0)}"early"!==t&&(a.complete=!0);BOOMR.sendBeacon(a.beacon_url);return this}},is_complete:function(e){return a.complete||e&&"error"===e["http.initiator"]||e&&"undefined"!=typeof e.early},updateCookie:function(){a.updateCookie()},getCookie:function(){var e,t,n;if(!a.cookie)return!1;if(e=BOOMR.utils.getSubCookies(BOOMR.utils.getCookie(a.cookie))||{}){if(1&e.z){t=36;n=parseInt(e.ss||0,36)}else{t=10;n=0}e.ss=parseInt(e.ss||0,t);e.tt=parseInt(e.tt||0,t);e.obo=parseInt(e.obo||0,t);e.sl=parseInt(e.sl||0,t);e.se&&(e.se=parseInt(e.se,t)||1800);e.ld&&(e.ld=n+parseInt(e.ld,t));e.ul&&(e.ul=n+parseInt(e.ul,t));e.cl&&(e.cl=n+parseInt(e.cl,t));e.hd&&(e.hd=n+parseInt(e.hd,t))}return e},incrementSessionDetails:function(){a.incrementSessionDetails()},navigationStart:function(){a.navigationStart||a.initFromNavTiming();return a.navigationStart}}}}(window);!function(){var u,y;if(!BOOMR.plugins.BW){(y=[{name:"image-0.png",size:11773,timeout:1400},{name:"image-1.png",size:40836,timeout:1200},{name:"image-2.png",size:165544,timeout:1300},{name:"image-3.png",size:382946,timeout:1500},{name:"image-4.png",size:1236278,timeout:1200},{name:"image-5.png",size:4511798,timeout:1200},{name:"image-6.png",size:9092136,timeout:1200}]).end=y.length;y.start=0;y.l={name:"image-l.gif",size:35,timeout:1e3};u={base_url:"",timeout:15e3,nruns:5,latency_runs:10,user_ip:"",block_beacon:!1,test_https:!1,cookie_exp:604800,cookie:"BA",results:[],latencies:[],latency:null,runs_left:0,aborted:!1,complete:!0,running:!1,initialized:!1,ncmp:function(e,t){return e-t},iqr:function(e){var t,n=e.length-1,r=[],i=(e[Math.floor(.25*n)]+e[Math.ceil(.25*n)])/2,o=(e[Math.floor(.75*n)]+e[Math.ceil(.75*n)])/2,a=1.5*(o-i);if(0==a)return e;n++;for(t=0;ti-a&&r.push(e[t]);return r},calc_latency:function(){var e,t,n,r,i,o,a=0,s=0;this.latencies.shift();t=(o=this.iqr(this.latencies.sort(this.ncmp))).length;for(e=0;ethis.latency.mean){d=1e3*y[e].size/(t[e].t-this.latency.mean);p.push(d)}else M.push(e+"_"+t[e].t)}}if(3=y.end-1||this.results[this.nruns-n].r[e+1]!==undefined){n===this.nruns&&(y.start=e);BOOMR.setImmediate(this.iterate,null,null,this)}else this.load_img(e+1,n,this.img_loaded)}else this.results[this.nruns-n].r[e+1]={t:null,state:null,run:n}},finish:function(){this.latency||(this.latency=this.calc_latency());var e=this.calc_bw(),t={bw:e.median_corrected,bw_err:parseFloat(e.stderr_corrected,10),lat:this.latency.mean,lat_err:parseFloat(this.latency.stderr,10),bw_time:Math.round(BOOMR.now()/1e3)};BOOMR.addVar(t);0=s-this.cookie_exp&&0BOOMR.now()+864e5)&&BOOMR.addVar("nt_bad",1,!0);0t)break;if(void 0===n||"*"===n||!n.length||r.initiatorType&&BOOMR.utils.inArray(r.initiatorType,n)){!function l(n,e){(e||[]).forEach(function(e){var t=e.name||e.metric;"undefined"==typeof n[t]&&(n[t]={count:0,counts:{}});t=n[t];t.counts[e.description]=t.counts[e.description]||0;t.counts[e.description]++;t.count++})}(a,r.serverTiming);s.push(r)}}}var c=function d(r){return Object.keys(r).sort(function(e,t){return r[t].count-r[e].count}).reduce(function(e,n){var t=Object.keys(r[n].counts).sort(function(e,t){return r[n].counts[t]-r[n].counts[e]});e.push(1===t.length&&""===t[0]?n:[n].concat(t));return e},[])}(a);return{entries:s,serverTiming:{lookup:c,indexed:function f(e){return e.reduce(function(e,t,n){var r,i;if(Array.isArray(t)){r=t[0];i=t.slice(1).reduce(function(e,t,n){e[t]=n;return e},{})}else{r=t;i={"":0}}e[r]={index:n,descriptions:i};return e},{})}(c)}}}function r(e,t){var n,r={},i={},t=U(e,t,P.trackedResourceTypes),o=t.entries,a=t.serverTiming;if(!o||!o.length)return{restiming:{},servertiming:[]};for(n=0;n eval")?e.replace(/ line (\d+)(?: > eval line \d+)* > eval\:\d+\:\d+/g,":$1"):e).indexOf("@")&&-1===e.indexOf(":"))return{functionName:e};var t=e.split("@"),n=this.extractLocation(t.pop());return{functionName:t.join("@")||undefined,fileName:n[0],lineNumber:n[1],columnNumber:n[2],source:e}},this)},parseOpera:function l(e){return!e.stacktrace||-1e.stacktrace.split("\n").length?this.parseOpera9(e):e.stack?this.parseOpera11(e):this.parseOpera10(e)},parseOpera9:function d(e){for(var t=/Line (\d+).*script (?:in )?(\S+)/i,n=e.message.split("\n"),r=[],i=2,o=n.length;i/,"$2").replace(/\([^\)]*\)/g,"")||undefined,args:(t=n.match(/\(([^\)]*)\)/)?n.replace(/^[^\(]+\(([^\)]*)\)$/,"$1"):t)===undefined||"[arguments not available]"===t?undefined:t.split(","),fileName:r[0],lineNumber:r[1],columnNumber:r[2],source:e}},this)}}});!function(){if(!BOOMR.plugins.Errors){var O=["BOOMR_addError","createStackForSend","BOOMR.window.console.error","BOOMR.plugins.Errors.init","BOOMR.window.onerror","BOOMR_plugins_errors_"],p=["Object.send","b.send","wrap","Anonymous function"],m=["/boomerang"];g.prototype.equals=function(e){return"object"==typeof e&&(this.code===e.code&&(this.message===e.message&&(this.functionName===e.functionName&&(this.fileName===e.fileName&&(this.lineNumber===e.lineNumber&&(this.columnNumber===e.columnNumber&&(this.stack===e.stack&&(this.type===e.type&&this.source===e.source))))))))};g.fromError=function(e,t,n){var r,i,o,a,s,u,c,l,d=!1,f=BOOMR.now();if(!e)return null;if(e.stack){5e3=f.maxErrors)){n=g.fromError(e,t,n);i=f.mergeDuplicateErrors(f.errors,n,!1);BOOMR.fireEvent("error",i||n);f.mergeDuplicateErrors(f.q,n,!0);!BOOMR.hasSentPageLoadBeacon()&&f.autorun||-1!==f.sendIntervalId||i||(f.sendIntervalId=setTimeout(function(){f.sendIntervalId=-1;0!==f.q.length&&BOOMR.sendBeaconWhenReady({"rt.start":"manual","http.initiator":"error",api:1,"rt.tstart":o,"rt.end":o},function(){f.addErrorsToBeacon()},this)},f.isDuringLoad?f.sendIntervalDuringLoad:f.sendInterval))}}},findDuplicateError:function(e,t){if(!BOOMR.utils.isArray(e)||void 0===t)return undefined;for(var n=0;no.length)for(n=0;n=t.from}));t.to&&(e=e.filter(function(e){return e.startTime<=t.to}));return d.compressUserTiming(e,t)};r.compressForUri=function(e){if("object"!=typeof e)return"";var t,n=!1;for(t in e)if(e.hasOwnProperty(t)){if(isNaN(t)){n=!1;break}n=!0}if(n)return"1"+d.flattenMap(e);var r=d.convertToTrie(e),i=d.optimizeTrie(r,!0),o=d.jsUrl(i),a=d.flattenArray(e);if("string"!=typeof a||0===a.length)return"";r=encodeURIComponent(o),i=encodeURIComponent(a);return r.length=s.options.from}));var t=window.UserTimingCompression||BOOMR.window.UserTimingCompression;if(void 0===t){if(0===e.length)return null;for(var n={},r=0,i=e.length;r + +// Some default pre init +var Countly = Countly || {}; +Countly.q = Countly.q || []; +Countly.onload = Countly.onload || []; + +// Provide your app key that you retrieved from Countly dashboard +Countly.app_key = "YOUR_APP_KEY"; + +// Provide your server IP or name. Use try.count.ly or us-try.count.ly +// or asia-try.count.ly for EE trial server. +// If you use your own server, make sure you have https enabled if you use +// https below. +Countly.url = "https://yourdomain.com"; + +// Start pushing function calls to queue +// Track sessions automatically (recommended) +Countly.q.push(['track_sessions']); + +//track web page views automatically (recommended) +Countly.q.push(['track_pageview']); + +// Uncomment the following line to track web heatmaps (Enterprise Edition) +// Countly.q.push(['track_clicks']); + +// Uncomment the following line to track web scrollmaps (Enterprise Edition) +// Countly.q.push(['track_scrolls']); + +// Load Countly script asynchronously +(function() { + var cly = document.createElement('script'); cly.type = 'text/javascript'; + cly.async = true; + // Enter url of script here (see below for other option) + cly.src = 'https://cdn.jsdelivr.net/npm/countly-sdk-web@latest/lib/countly.min.js'; + cly.onload = function(){Countly.init()}; + var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(cly, s); +})(); + +``` + +### 2. Declare your plugin's script after integrating Countly + +```js + +``` + +### 3. Change your Google Analytics integration code like below + +```js + +``` + +That's all. Now you can track your app from your Countly instance too. You can check examples folder for an implementation example. diff --git a/plugin/ga_adapter/ga_adapter.js b/plugin/ga_adapter/ga_adapter.js new file mode 100644 index 0000000..c40868c --- /dev/null +++ b/plugin/ga_adapter/ga_adapter.js @@ -0,0 +1,455 @@ +"use strict"; + +/* global Countly */ +/* +Countly Adapter Library for Google Analytics +*/ +(function() { + // logs array for tests + window.cly_ga_test_logs = []; + Countly.onload = Countly.onload || []; + // adapter function + window.CountlyGAAdapter = function() { + // hold ga instance + var old_ga = window.ga; + // array for ga calls which called before ga initialized + var gaCalls = []; + // hold calls in array + window.ga = function() { + gaCalls.push(arguments); + return old_ga.apply(this, arguments); + }; + // ga overrided signature + window.ga._signature = 1; + + // hold ga_countly calls in array before countly initialized + var gaCountlyArray = []; + var ga_countly = function() { + gaCountlyArray.push(arguments); + }; + + Countly.onload.push(function(cly) { + // cart for ga:ecommerce plugin + var cart = cly._internals.store("cly_ecommerce:cart") || []; + // override ga_countly and map request to countly + ga_countly = function(c, o, u, n, t, l/* , y */) { + if (typeof c === "string") { + var customSegments; var i; var + count; + switch (c) { + case "send": + if (typeof o === "string") { + // ga('send', 'event', ..) + if (o === "event") { + customSegments = {}; + count = 1; + // ga('send', 'event', 'category', 'action') + if (arguments.length === 4) { + customSegments.category = u; + } + // ga('send', 'event', 'category', 'action', 'label') + else if (arguments.length === 5 && typeof arguments[4] === "string") { + customSegments.category = u; + customSegments.label = t; + } + // ga('send', 'event', 'category', 'action', {metric:value}) + else if (arguments.length === 5 && typeof arguments[4] === "object") { + customSegments.category = u; + for (i = 0; i < Object.keys(arguments[4]).length; i++) { + customSegments[Object.keys(arguments[4])[i]] = Object.values(arguments[4])[i]; + } + } + // ga('send', 'event', 'category', 'action', 'label', 1) + else if (arguments.length >= 6) { + customSegments.category = u; + customSegments.label = t; + count = l; + } + + // add event by configured values + Countly.q.push(["add_event", { + key: n, + count: count, + segmentation: customSegments + }]); + + if (window.cly_ga_test_mode) { + window.cly_ga_test_logs.push(["add_event", { + key: n, + count: count, + segmentation: customSegments + }]); + } + } + // ga('send', 'pageview') + else if (o === "pageview" && arguments.length === 2) { + if (cly._internals.store("cly_ga:page")) { + Countly.q.push(["track_pageview", cly._internals.store("cly_ga:page")]); + if (window.cly_ga_test_mode) { + window.cly_ga_test_logs.push(["track_pageview", cly._internals.store("cly_ga:page")]); + } + } + else { + Countly.q.push(["track_pageview"]); + if (window.cly_ga_test_mode) { + window.cly_ga_test_logs.push(["track_pageview"]); + } + } + } + // ga('send', 'pageview', 'page') + else if (o === "pageview" && arguments.length >= 3 && typeof arguments[2] === "string") { + Countly.q.push(["track_pageview", arguments[2]]); + if (window.cly_ga_test_mode) { + window.cly_ga_test_logs.push(["track_pageview", arguments[2]]); + } + } + // ga('send', 'pageview', {'customDimension':'customValue'}) + else if (o === "pageview" && arguments.length >= 3 && typeof arguments[2] === "object") { + // we are not supported tracking pageview with custom objects for now + Countly.q.push(["track_pageview"]); + if (window.cly_ga_test_mode) { + window.cly_ga_test_logs.push(["track_pageview"]); + } + } + // ga('send', 'social', 'network', 'action', 'target') + else if (o === "social") { + Countly.q.push(["add_event", { + key: n, + count: 1, + segmentation: { + category: o, + platform: u, + target: t + } + }]); + if (window.cly_ga_test_mode) { + window.cly_ga_test_logs.push(["add_event", { + key: n, + count: 1, + segmentation: { category: o, platform: u, target: t } + }]); + } + } + // ga('send', 'screenview', {..}) + else if (o === "screenview") { + customSegments = { appName: u.appName }; + + if (u.screenName) { + customSegments.screenName = u.screenName; + } + if (u.appVersion) { + customSegments.appVersion = u.appVersion; + } + if (u.appInstallerId) { + customSegments.appInstallerId = u.appInstallerId; + } + if (cly._internals.store("cly_ga:screenname")) { + customSegments.screenName = cly._internals.store("cly_ga:screenname"); + } + + Countly.q.push(["add_event", { + key: "Screen View", + count: 1, + segmentation: customSegments + }]); + + if (window.cly_ga_test_mode) { + window.cly_ga_test_logs.push(["add_event", { + key: "Screen View", + count: 1, + segmentation: customSegments + }]); + } + } + // ga('send', 'exception', {..}) + else if (o === "exception") { + cly.log_error(u.exDescription); + if (window.cly_ga_test_mode) { + window.cly_ga_test_logs.push(u.exDescription); + } + } + // ga('send', 'timing', 'timingCategory', 'timingVar', 'timingValue', 'timingLabel') + else if (o === "timing") { + customSegments = { category: u }; + if (l) { + customSegments.label = l; + } + Countly.q.push(["add_event", { + key: n, + count: 1, + dur: t, + segmentation: customSegments + }]); + if (window.cly_ga_test_mode) { + window.cly_ga_test_logs.push(["add_event", { + key: n, + count: 1, + dur: t, + segmentation: customSegments + }]); + } + } + } + // ga('send', {hitType:.., ...}) + else if (typeof o === "object") { + switch (o.hitType) { + case "event": + // ga('send', {'hitType':'event', ..}) + customSegments = { + category: o.eventCategory + }; + count = 1; + + if (o.eventLabel) { + customSegments.label = o.eventLabel; + } + if (o.eventValue) { + count = o.eventValue; + } + + Countly.q.push(["add_event", { + key: o.eventAction, + count: count, + segmentation: customSegments + }]); + + if (window.cly_ga_test_mode) { + window.cly_ga_test_logs.push(["add_event", { + key: o.eventAction, + count: count, + segmentation: customSegments + }]); + } + + break; + case "social": + // ga('send', {'hitType':'social', ..}) + Countly.q.push(["add_event", { + key: o.socialAction, + count: 1, + segmentation: { + category: o.hitType, + platform: o.socialNetwork, + target: o.socialTarget + } + }]); + + if (window.cly_ga_test_mode) { + window.cly_ga_test_logs.push(["add_event", { + key: o.socialAction, + count: 1, + segmentation: { + category: o.hitType, + platform: o.socialNetwork, + target: o.socialTarget + } + }]); + } + + break; + case "timing": + // ga('send', {'hitType':'timing', ..}) + Countly.q.push(["add_event", { + key: o.timingVar, + count: 1, + dur: o.timingValue, + segmentation: { + category: o.timingCategory + } + }]); + + if (window.cly_ga_test_mode) { + window.cly_ga_test_logs.push(["add_event", { + key: o.timingVar, + count: 1, + dur: o.timingValue, + segmentation: { category: o.timingCategory } + }]); + } + break; + case "pageview": + // ga('send', {'hitType':'pageview', 'page':'page'}) + Countly.q.push(["track_pageview", o.page]); + if (window.cly_ga_test_mode) { + window.cly_ga_test_logs.push(["track_pageview", o.page]); + } + break; + default: + Countly._internals.log("WARNING", "hitType is not recognized:[" + o.hitType + "]"); + } + } + break; + case "create": + // ga('create', '..') + // ga('create', .., 'auto', '..') + if (arguments.length === 4 && arguments[2] === "auto") { + cly._internals.store("cly_ga:id", o); + if (window.cly_ga_test_mode) { + window.cly_ga_test_logs.push({ + stored: cly._internals.store("cly_ga:id"), + value: o + }); + } + window.ga_adapter_integrated = true; + // ga('create', .., callback) + } + else if (arguments.length === 3) { + cly._internals.store("cly_ga:id", o); + if (window.cly_ga_test_mode) { + window.cly_ga_test_logs.push({ + stored: cly._internals.store("cly_ga:id"), + value: o + }); + } + window.ga_adapter_integrated = true; + } + break; + // ga('set', '..') + case "set": + // ga('set', 'page', '/login.html') + if (o === "page") { + cly._internals.store("cly_ga:page", u); + } + // ga('set', 'screenname', 'High scores') + else if (o === "screenname") { + cly._internals.store("cly_ga:screenname", u); + if (window.cly_ga_test_mode) { + window.cly_ga_test_logs.push({ + stored: cly._internals.store("cly_ga:screenname"), + value: u + }); + } + } + // ga('set', 'dimension', 'custom data') + else if (arguments.length === 3) { + Countly.q.push(["userData.set", o, u]); + if (window.cly_ga_test_mode) { + window.cly_ga_test_logs.push(["userData.set", o, u]); + } + } + // ga('set', {key:val, anotherKey: anotherVal}) + else if (arguments.length === 2 && typeof o === "object") { + Countly.q.push(["user_details", { custom: o }]); + if (window.cly_ga_test_mode) { + window.cly_ga_test_logs.push(["user_details", { custom: o }]); + } + } + break; + // ga('ecommerce:addTransaction', {..}) + case "ecommerce:addTransaction": + customSegments = { + id: o.id, + affiliation: o.affiliation, + shipping: o.shipping, + tax: o.tax + }; + + if (o.currency) { + customSegments.currency = o.currency; + } + + Countly.q.push(["add_event", { + key: c, + count: 1, + sum: o.revenue, + segmentation: customSegments + }]); + + if (window.cly_ga_test_mode) { + window.cly_ga_test_logs.push(["add_event", { + key: c, + count: 1, + sum: o.revenue, + segmentation: customSegments + }]); + } + + break; + // ga('ecommerce:addItem', {..}) + case "ecommerce:addItem": + customSegments = { + id: o.id, + name: o.name, + sku: o.sku, + category: o.category + }; + + if (o.currency) { + customSegments.currency = o.currency; + } + + cart.push(["add_event", { + key: c, + count: o.quantity, + sum: o.price, + segmentation: customSegments + }]); + + cly._internals.store("cly_ecommerce:cart", cart); + + if (window.cly_ga_test_mode) { + window.cly_ga_test_logs.push(["add_event", { + key: c, + count: o.quantity, + sum: o.price, + segmentation: customSegments + }]); + } + + break; + // ga('ecommerce:send') + case "ecommerce:send": + var firstLength; + if (window.cly_ga_test_mode) { + firstLength = cart.length; + } + for (i = 0; i < cart.length; i++) { + Countly.q.push(cart[i]); + } + cart = []; + cly._internals.store("cly_ecommerce:cart", cart); + + if (window.cly_ga_test_mode) { + window.cly_ga_test_logs.push({ first: firstLength, last: cart.length }); + } + break; + // ga('ecommerce:clear') + case "ecommerce:clear": + cart = []; + cly._internals.store("cly_ecommerce:cart", cart); + + if (window.cly_ga_test_mode) { + window.cly_ga_test_logs.push(cly._internals.store("cly_ecommerce:cart")); + } + break; + default: + break; + } + } + }; + // apply old countly calls to overrided function + while (gaCountlyArray.length) { + var args = gaCountlyArray.shift(); + ga_countly.apply(window, args); + } + }); + + // check variable for gaAdapter is loaded? + setTimeout(function check() { + if (window.ga._signature) { + return setTimeout(check, 125); + } + old_ga = window.ga; + + while (gaCalls.length) { + var args = gaCalls.shift(); + ga_countly.apply(window, args); + } + + window.ga = function() { + ga_countly.apply(window, arguments); + return old_ga.apply(this, arguments); + }; + }, 125); + }; +}()); \ No newline at end of file diff --git a/test_workers/worker.js b/test_workers/worker.js new file mode 100644 index 0000000..acfdfdb --- /dev/null +++ b/test_workers/worker.js @@ -0,0 +1,23 @@ +import Countly from "../Countly.js"; + +Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + debug: true +}); + +onmessage = function (e) { + console.log(`Worker: Message received from main script:[${JSON.stringify(e.data)}]`); + const data = e.data.data; const type = e.data.type; + if (type === "event") { + Countly.add_event(data); + } else if (type === "view") { + Countly.track_pageview(data); + } else if (type === "session") { + if (data === "begin_session") { + Countly.begin_session(); + return; + } + Countly.end_session(null, true); + } +} \ No newline at end of file diff --git a/test_workers/worker_for_test.js b/test_workers/worker_for_test.js new file mode 100644 index 0000000..8aa27d7 --- /dev/null +++ b/test_workers/worker_for_test.js @@ -0,0 +1,27 @@ +import Countly from "../Countly.js"; + +Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + debug: true, + test_mode: true +}); + +onmessage = function(e) { + console.log(`Worker: Message received from main script:[${JSON.stringify(e.data)}]`); + const data = e.data.data; const type = e.data.type; + if (type === "event") { + Countly.add_event(data); + } else if (type === "view") { + Countly.track_pageview(data); + } else if (type === "session") { + if (data === "begin_session") { + Countly.begin_session(); + return; + } + Countly.end_session(null, true); + } else if (type === "get") { + const queues = Countly._internals.getLocalQueues(); + postMessage(queues); + } +}; \ No newline at end of file