From ccf2bfe8ed9c0f650d634856dfc8573fb96fc063 Mon Sep 17 00:00:00 2001 From: turtledreams Date: Fri, 19 Jan 2024 16:39:41 +0900 Subject: [PATCH 01/21] Added a way to explicity process the async queue --- CHANGELOG.md | 4 + cypress/e2e/async_queue.cy.js | 376 ++++++++++++++++++++++++++++++++++ modules/Constants.js | 2 +- modules/CountlyClass.js | 86 +++++--- package.json | 2 +- 5 files changed, 435 insertions(+), 35 deletions(-) create mode 100644 cypress/e2e/async_queue.cy.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ffc053f..6d2627d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 23.12.5 + +* Mitigated an issue where the SDK was not emptying the async queue explicity when closing a browser + ## 23.12.4 * Enhanced userAgentData detection for bot filtering diff --git a/cypress/e2e/async_queue.cy.js b/cypress/e2e/async_queue.cy.js new file mode 100644 index 0000000..b5d5af1 --- /dev/null +++ b/cypress/e2e/async_queue.cy.js @@ -0,0 +1,376 @@ +/* eslint-disable require-jsdoc */ +var Countly = require("../../Countly.js"); +var hp = require("../support/helper.js"); + +function initMain(clear) { + Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://your.domain.count.ly", + debug: true, + test_mode: true, + clear_stored_id: clear + }); +} + +function event(number) { + return { + key: `event_${number}`, + segmentation: { + id: number + } + }; +}; + + +// All the tests below checks if the functions are working correctly +// Currently tests for 'beforeunload' and 'unload' events has to be done manually by using the throttling option of the browser +describe("Test Countly.q related methods and processes", () => { + // For this tests we disable the internal heatbeat and use processAsyncQueue and sendEventsForced + // So we are able to test if those functions work as intented: + // processAsyncQueue should send events from .q to event queue + // sendEventsForced should send events from event queue to request queue (it also calls processAsyncQueue) + it("Check processAsyncQueue and sendEventsForced works as expected", () => { + hp.haltAndClearStorage(() => { + // Disable heartbeat and init the SDK + Countly.noHeartBeat = true; + initMain(); + cy.wait(1000); + + // Check that the .q is empty + expect(Countly.q.length).to.equal(0); + + // Add 4 events to the .q + Countly.q.push(['add_event', event(1)]); + Countly.q.push(['add_event', event(2)]); + Countly.q.push(['add_event', event(3)]); + Countly.q.push(['add_event', event(4)]); + // Check that the .q has 4 events + expect(Countly.q.length).to.equal(4); + + cy.fetch_local_event_queue().then((rq) => { + // Check that events are still in .q + expect(Countly.q.length).to.equal(4); + + // Check that the event queue is empty + expect(rq.length).to.equal(0); + + // Process the .q (should send things to the event queue) + Countly._internals.processAsyncQueue(); + + // Check that the .q is empty + expect(Countly.q.length).to.equal(0); + + cy.fetch_local_request_queue().then((rq) => { + // Check that nothing sent to request queue + expect(rq.length).to.equal(0); + cy.fetch_local_event_queue().then((eq) => { + // Check that events are now in event queue + expect(eq.length).to.equal(4); + + // Send events from event queue to request queue + Countly._internals.sendEventsForced(); + cy.fetch_local_event_queue().then((eq) => { + // Check that event queue is empty + expect(eq.length).to.equal(0); + cy.fetch_local_request_queue().then((rq) => { + // Check that events are now in request queue + expect(rq.length).to.equal(1); + const eventsArray = JSON.parse(rq[0].events); + expect(eventsArray[0].key).to.equal("event_1"); + expect(eventsArray[1].key).to.equal("event_2"); + expect(eventsArray[2].key).to.equal("event_3"); + expect(eventsArray[3].key).to.equal("event_4"); + }); + }); + }); + }); + }); + }); + }); + // This test is same with the one above but this time we only use sendEventsForced which alredy includes processAsyncQueue inside + it('Check sendEventsForced works as expected', () => { + hp.haltAndClearStorage(() => { + // Disable heartbeat and init the SDK + Countly.noHeartBeat = true; + Countly.q = []; + initMain(); + cy.wait(1000); + + // Check that the .q is empty + expect(Countly.q.length).to.equal(0); + + // Add 4 events to the .q + Countly.q.push(['add_event', event(1)]); + Countly.q.push(['add_event', event(2)]); + Countly.q.push(['add_event', event(3)]); + Countly.q.push(['add_event', event(4)]); + // Check that the .q has 4 events + expect(Countly.q.length).to.equal(4); + + cy.fetch_local_event_queue().then((rq) => { + // Check that the event queue is empty + expect(rq.length).to.equal(0); + + // Check that events are still in .q + expect(Countly.q.length).to.equal(4); + + // Send events from .q to event queue and then to request queue + Countly._internals.sendEventsForced(); + + // Check that the .q is empty + expect(Countly.q.length).to.equal(0); + cy.fetch_local_event_queue().then((eq) => { + // Check that event queue is empty + expect(eq.length).to.equal(0); + cy.fetch_local_request_queue().then((rq) => { + // Check that events are now in request queue + expect(rq.length).to.equal(1); + const eventsArray = JSON.parse(rq[0].events); + expect(eventsArray[0].key).to.equal("event_1"); + expect(eventsArray[1].key).to.equal("event_2"); + expect(eventsArray[2].key).to.equal("event_3"); + expect(eventsArray[3].key).to.equal("event_4"); + }); + }); + }); + }); + }); + //This test is same with the ones above but this time we use change_id to trigger sendEventsForced + it('Check changing device ID without merge empties the .q', () => { + hp.haltAndClearStorage(() => { + // Disable heartbeat and init the SDK + Countly.noHeartBeat = true; + Countly.q = []; + initMain(); + cy.wait(1000); + + // Check that the .q is empty + expect(Countly.q.length).to.equal(0); + + // Add 4 events to the .q + Countly.q.push(['add_event', event(1)]); + Countly.q.push(['add_event', event(2)]); + Countly.q.push(['add_event', event(3)]); + Countly.q.push(['add_event', event(4)]); + // Check that the .q has 4 events + expect(Countly.q.length).to.equal(4); + + cy.fetch_local_event_queue().then((rq) => { + // Check that the event queue is empty + expect(rq.length).to.equal(0); + + // Check that events are still in .q + expect(Countly.q.length).to.equal(4); + + // Trigger sendEventsForced by changing device ID without merge + Countly.change_id("new_user_id", false); + + // Check that the .q is empty + expect(Countly.q.length).to.equal(0); + cy.fetch_local_event_queue().then((eq) => { + // Check that event queue has new device ID's orientation event + expect(eq.length).to.equal(1); + expect(eq[0].key).to.equal("[CLY]_orientation"); + cy.fetch_local_request_queue().then((rq) => { + // Check that events are now in request queue (second request is begin session for new device ID) + expect(rq.length).to.equal(2); + const eventsArray = JSON.parse(rq[0].events); + expect(eventsArray[0].key).to.equal("event_1"); + expect(eventsArray[1].key).to.equal("event_2"); + expect(eventsArray[2].key).to.equal("event_3"); + expect(eventsArray[3].key).to.equal("event_4"); + // check begin session + expect(rq[1].begin_session).to.equal(1); + }); + }); + }); + }); + }); + // This test checks if clear_stored_id set to true during init we call sendEventsForced (it sends events from .q to event queue and then to request queue) + it('Check clear_stored_id set to true empties the .q', () => { + hp.haltAndClearStorage(() => { + // Disable heartbeat + Countly.noHeartBeat = true; + Countly.q = []; + localStorage.setItem("YOUR_APP_KEY/cly_id", "old_user_id"); // Set old device ID for clear_stored_id to work + + // Add 4 events to the .q + Countly.q.push(['add_event', event(1)]); + Countly.q.push(['add_event', event(2)]); + Countly.q.push(['add_event', event(3)]); + Countly.q.push(['add_event', event(4)]); + + // Check that the .q has 4 events + expect(Countly.q.length).to.equal(4); + + // Init the SDK with clear_stored_id set to true + initMain(true); + cy.wait(1000); + + // Check that the .q is empty + expect(Countly.q.length).to.equal(0); + + cy.fetch_local_event_queue().then((rq) => { + // Check that the event queue is empty because sendEventsForced sends events from .q to event queue and then to request queue + expect(rq.length).to.equal(0); + + cy.fetch_local_request_queue().then((rq) => { + // Check that events are now in request queue + expect(rq.length).to.equal(1); + const eventsArray = JSON.parse(rq[0].events); + expect(eventsArray[0].key).to.equal("event_1"); + expect(eventsArray[1].key).to.equal("event_2"); + expect(eventsArray[2].key).to.equal("event_3"); + expect(eventsArray[3].key).to.equal("event_4"); + }); + }); + }); + }); + // This test checks if calling user_details triggers sendEventsForced (it sends events from .q to event queue and then to request queue) + it('Check sending user details empties .q', () => { + hp.haltAndClearStorage(() => { + // Disable heartbeat and init the SDK + Countly.noHeartBeat = true; + Countly.q = []; + initMain(); + cy.wait(1000); + + // Check that the .q is empty + expect(Countly.q.length).to.equal(0); + + // Add 4 events to the .q + Countly.q.push(['add_event', event(1)]); + Countly.q.push(['add_event', event(2)]); + Countly.q.push(['add_event', event(3)]); + Countly.q.push(['add_event', event(4)]); + // Check that the .q has 4 events + expect(Countly.q.length).to.equal(4); + + cy.fetch_local_event_queue().then((rq) => { + // Check that the event queue is empty + expect(rq.length).to.equal(0); + + // Check that events are still in .q + expect(Countly.q.length).to.equal(4); + + // Trigger sendEventsForced by adding user details + Countly.user_details({name: "test_user"}); + + // Check that the .q is empty + expect(Countly.q.length).to.equal(0); + cy.fetch_local_event_queue().then((eq) => { + // Check that event queue is empty + expect(eq.length).to.equal(0); + cy.fetch_local_request_queue().then((rq) => { + // Check that events are now in request queue (second request is user details) + expect(rq.length).to.equal(2); + const eventsArray = JSON.parse(rq[0].events); + expect(eventsArray[0].key).to.equal("event_1"); + expect(eventsArray[1].key).to.equal("event_2"); + expect(eventsArray[2].key).to.equal("event_3"); + expect(eventsArray[3].key).to.equal("event_4"); + // check user details + const user_details = JSON.parse(rq[1].user_details); + expect(user_details.name).to.equal("test_user"); + }); + }); + }); + }); + }); + // This Test checks if calling userData.save triggers sendEventsForced (it sends events from .q to event queue and then to request queue) + it('Check sending custom user info empties .q', () => { + hp.haltAndClearStorage(() => { + // Disable heartbeat and init the SDK + Countly.noHeartBeat = true; + Countly.q = []; + initMain(); + cy.wait(1000); + + // Check that the .q is empty + expect(Countly.q.length).to.equal(0); + + // Add 4 events to the .q + Countly.q.push(['add_event', event(1)]); + Countly.q.push(['add_event', event(2)]); + Countly.q.push(['add_event', event(3)]); + Countly.q.push(['add_event', event(4)]); + // Check that the .q has 4 events + expect(Countly.q.length).to.equal(4); + + cy.fetch_local_event_queue().then((rq) => { + // Check that the event queue is empty + expect(rq.length).to.equal(0); + + // Check that events are still in .q + expect(Countly.q.length).to.equal(4); + + // Trigger sendEventsForced by saving UserData + Countly.userData.set("name", "test_user"); + Countly.userData.save(); + + // Check that the .q is empty + expect(Countly.q.length).to.equal(0); + cy.fetch_local_event_queue().then((eq) => { + // Check that event queue is empty + expect(eq.length).to.equal(0); + cy.fetch_local_request_queue().then((rq) => { + // Check that events are now in request queue (second request is user details) + expect(rq.length).to.equal(2); + const eventsArray = JSON.parse(rq[0].events); + expect(eventsArray[0].key).to.equal("event_1"); + expect(eventsArray[1].key).to.equal("event_2"); + expect(eventsArray[2].key).to.equal("event_3"); + expect(eventsArray[3].key).to.equal("event_4"); + // check user data + const user_details = JSON.parse(rq[1].user_details); + expect(user_details.custom.name).to.equal("test_user"); + }); + }); + }); + }); + }); + // This test check if the heartbeat is processing the .q (executes processAsyncQueue) + it('Check if heatbeat is processing .q', () => { + hp.haltAndClearStorage(() => { + // init the SDK + Countly.q = []; + initMain(); + + // Check that the .q is empty + expect(Countly.q.length).to.equal(0); + cy.fetch_local_event_queue().then((eq) => { + // Check that the event queue is empty + expect(eq.length).to.equal(0); + cy.fetch_local_request_queue().then((rq) => { + // Check that the request queue is empty + expect(rq.length).to.equal(0); + // Add 4 events to the .q + Countly.q.push(['add_event', event(1)]); + Countly.q.push(['add_event', event(2)]); + Countly.q.push(['add_event', event(3)]); + Countly.q.push(['add_event', event(4)]); + // Check that the .q has 4 events + expect(Countly.q.length).to.equal(4); + // Wait for heartBeat to process the .q + cy.wait(1500).then(() => { + // Check that the .q is empty + expect(Countly.q.length).to.equal(0); + cy.fetch_local_event_queue().then((eq) => { + // Check that event queue is empty as all must be in request queue + expect(eq.length).to.equal(0); + cy.fetch_local_request_queue().then((rq) => { + // Check that events are now in request queue + expect(rq.length).to.equal(1); + const eventsArray = JSON.parse(rq[0].events); + expect(eventsArray[0].key).to.equal("event_1"); + expect(eventsArray[1].key).to.equal("event_2"); + expect(eventsArray[2].key).to.equal("event_3"); + expect(eventsArray[3].key).to.equal("event_4"); + }); + }); + }); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/modules/Constants.js b/modules/Constants.js index 08cb5e4..d9dd2f3 100644 --- a/modules/Constants.js +++ b/modules/Constants.js @@ -104,7 +104,7 @@ var healthCheckCounterEnum = Object.freeze({ errorMessage: "cly_hc_error_message", }); -var SDK_VERSION = "23.12.4"; +var SDK_VERSION = "23.12.5"; 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) diff --git a/modules/CountlyClass.js b/modules/CountlyClass.js index 2d56f9d..5820a88 100644 --- a/modules/CountlyClass.js +++ b/modules/CountlyClass.js @@ -511,7 +511,12 @@ class CountlyClass { notifyLoaders(); setTimeout(function () { - heartBeat(); + if (!Countly.noHeartBeat) { + heartBeat(); + } else { + log(logLevelEnums.WARNING, "initialize, Heartbeat disabled. This is for testing purposes only!"); + } + if (self.remote_config) { self.fetch_remote_config(self.remote_config); } @@ -533,6 +538,8 @@ class CountlyClass { this.halt = function () { log(logLevelEnums.WARNING, "halt, Resetting Countly"); Countly.i = undefined; + Countly.q = []; + Countly.noHeartBeat = undefined; global = !Countly.i; sessionStarted = false; apiPath = "/i"; @@ -3445,6 +3452,7 @@ class CountlyClass { * Check and send the events to request queue if there are any, empty the event queue */ function sendEventsForced() { + processAsyncQueue(); // process async queue before sending events if (eventQueue.length > 0) { log(logLevelEnums.DEBUG, "Flushing events"); toRequestQueue({ events: JSON.stringify(eventQueue) }); @@ -3693,40 +3701,9 @@ class CountlyClass { } 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)); - } - } - } + processAsyncQueue(); } // extend session if needed @@ -3785,6 +3762,48 @@ class CountlyClass { setTimeout(heartBeat, beatInterval); } + /** + * Process queued calls + * @memberof Countly._internals + */ + function processAsyncQueue() { + const q = Countly.q; + Countly.q = []; + for (let i = 0; i < q.length; i++) { + let req = q[i]; + log(logLevelEnums.DEBUG, "Processing queued calls:" + 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 + try { + if (Countly.i[req[arg]]) { + inst = Countly.i[req[arg]]; + arg++; + } + } catch (error) { + // possibly first init and no other instance + log(logLevelEnums.DEBUG, "No instance found for the provided key while processing async queue"); + } + 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)); + } + } + } + } + /** * Get device ID, stored one, or generate new one * @memberof Countly._internals @@ -4631,6 +4650,7 @@ class CountlyClass { getRequestQueue: getRequestQueue, getEventQueue: getEventQueue, sendFetchRequest: sendFetchRequest, + processAsyncQueue: processAsyncQueue, makeNetworkRequest: makeNetworkRequest, /** * Clear queued data diff --git a/package.json b/package.json index 0105d8c..6e86b99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "countly-sdk-js", - "version": "23.12.4", + "version": "23.12.5", "description": "Countly JavaScript SDK", "type": "module", "main": "Countly.js", From dc07a42fbc001f9015dc8600c096fcd41fe660ef Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 25 Jan 2024 17:03:10 +0300 Subject: [PATCH 02/21] feat: warn for defaults --- examples/Angular/main.ts | 11 +++++-- examples/React/src/index.js | 12 ++++++-- examples/Symbolication/src/main.js | 11 +++++-- examples/example_async.html | 6 +++- examples/example_fb.html | 12 ++++++-- examples/example_formdata.html | 14 ++++++--- examples/example_gdpr.html | 11 +++++-- examples/example_helpers.html | 11 +++++-- examples/example_multiple_instances.html | 38 ++++++++++++++++++------ examples/example_opt_out.html | 11 +++++-- examples/example_rating_widgets.html | 11 +++++-- examples/example_remote_config.html | 11 +++++-- examples/example_sync.html | 11 +++++-- examples/examples_feedback_widgets.html | 11 +++++-- examples/worker.js | 12 ++++++-- 15 files changed, 154 insertions(+), 39 deletions(-) diff --git a/examples/Angular/main.ts b/examples/Angular/main.ts index 85da81a..37f2f0c 100644 --- a/examples/Angular/main.ts +++ b/examples/Angular/main.ts @@ -8,9 +8,16 @@ import Countly from 'countly-sdk-web'; window.Countly = Countly; +const COUNTLY_SERVER_KEY = "https://your.server.ly"; +const COUNTLY_APP_KEY = "YOUR_APP_KEY"; + +if(COUNTLY_APP_KEY === "YOUR_APP_KEY" || COUNTLY_SERVER_KEY === "https://your.server.ly"){ + throw new Error("Please do not use default set of app key and server url") +} +// initializing countly with params Countly.init({ - app_key: "YOUR_APP_KEY", - url: "https://your.domain.count.ly", + app_key: COUNTLY_APP_KEY, + url: COUNTLY_SERVER_KEY, //your server goes here debug: true }); Countly.track_sessions(); diff --git a/examples/React/src/index.js b/examples/React/src/index.js index d5461ab..34f04c9 100644 --- a/examples/React/src/index.js +++ b/examples/React/src/index.js @@ -8,9 +8,17 @@ import Countly from 'countly-sdk-web'; //Exposing Countly to the DOM as a global variable //Usecase - Heatmaps window.Countly = Countly; + +const COUNTLY_SERVER_KEY = "https://your.server.ly"; +const COUNTLY_APP_KEY = "YOUR_APP_KEY"; + +if(COUNTLY_APP_KEY === "YOUR_APP_KEY" || COUNTLY_SERVER_KEY === "https://your.server.ly"){ + throw new Error("Please do not use default set of app key and server url") +} +// initializing countly with params Countly.init({ - app_key: 'YOUR_APP_KEY', - url: 'YOUR_SERVER_URL', + app_key: COUNTLY_APP_KEY, + url: COUNTLY_SERVER_KEY, //your server goes here debug: true }); diff --git a/examples/Symbolication/src/main.js b/examples/Symbolication/src/main.js index 39d8e79..3c71d56 100644 --- a/examples/Symbolication/src/main.js +++ b/examples/Symbolication/src/main.js @@ -1,9 +1,16 @@ import Countly from "countly-sdk-web"; +const COUNTLY_SERVER_KEY = "https://your.server.ly"; +const COUNTLY_APP_KEY = "YOUR_APP_KEY"; + +if(COUNTLY_APP_KEY === "YOUR_APP_KEY" || COUNTLY_SERVER_KEY === "https://your.server.ly"){ + throw new Error("Please do not use default set of app key and server url") +} +// initializing countly with params Countly.init({ - app_key: "YOUR_APP_KEY", + app_key: COUNTLY_APP_KEY, + url: COUNTLY_SERVER_KEY, //your server goes here app_version: "1.0", - url: "https://your.domain.count.ly", debug: true }); diff --git a/examples/example_async.html b/examples/example_async.html index ac68a00..f60db3f 100644 --- a/examples/example_async.html +++ b/examples/example_async.html @@ -12,7 +12,11 @@ //provide countly initialization parameters Countly.app_key = "YOUR_APP_KEY"; - Countly.url = "https://your.domain.count.ly"; //your server goes here + Countly.url = "https://your.server.ly"; //your server goes here + + if(Countly.app_key === "YOUR_APP_KEY" || Countly.url === "https://your.server.ly"){ + throw new Error("Please do not use default set of app key and server url") + } Countly.debug = true; //start pushing function calls to queue diff --git a/examples/example_fb.html b/examples/example_fb.html index 9edf033..452ed64 100644 --- a/examples/example_fb.html +++ b/examples/example_fb.html @@ -11,10 +11,16 @@ diff --git a/examples/example_opt_out.html b/examples/example_opt_out.html index 71ad8b2..70e4179 100644 --- a/examples/example_opt_out.html +++ b/examples/example_opt_out.html @@ -9,9 +9,16 @@ import Countly from '../Countly.js'; //initializing countly with params + const COUNTLY_SERVER_KEY = "https://your.server.ly"; + const COUNTLY_APP_KEY = "YOUR_APP_KEY"; + + if(COUNTLY_APP_KEY === "YOUR_APP_KEY" || COUNTLY_SERVER_KEY === "https://your.server.ly"){ + throw new Error("Please do not use default set of app key and server url") + } + // initializing countly with params Countly.init({ - app_key: "YOUR_APP_KEY", - url: "https://your.domain.count.ly", //your server goes here + app_key: COUNTLY_APP_KEY, + url: COUNTLY_SERVER_KEY, //your server goes here debug: true }) //track sessions automatically diff --git a/examples/example_rating_widgets.html b/examples/example_rating_widgets.html index 955f64b..0a17a61 100644 --- a/examples/example_rating_widgets.html +++ b/examples/example_rating_widgets.html @@ -8,9 +8,16 @@