Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/matomo-org/matomo.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--core/Metrics.php7
-rw-r--r--core/Plugin/Manager.php1
-rw-r--r--core/Plugin/Report.php7
-rw-r--r--core/Plugin/ViewDataTable.php5
-rw-r--r--core/Tracker.php56
-rw-r--r--core/Tracker/Action.php8
-rw-r--r--core/Tracker/Request.php6
-rw-r--r--core/Tracker/TableLogAction.php3
-rw-r--r--js/piwik.js1772
-rw-r--r--libs/PiwikTracker/PiwikTracker.php96
-rw-r--r--misc/internal-docs/content-tracking.md578
-rw-r--r--plugins/Actions/Archiver.php2
-rw-r--r--plugins/Contents/API.php62
-rw-r--r--plugins/Contents/Actions/ActionContent.php55
-rw-r--r--plugins/Contents/Archiver.php312
-rw-r--r--plugins/Contents/Columns/ContentInteraction.php56
-rw-r--r--plugins/Contents/Columns/ContentName.php57
-rw-r--r--plugins/Contents/Columns/ContentPiece.php57
-rw-r--r--plugins/Contents/Columns/ContentTarget.php56
-rw-r--r--plugins/Contents/Contents.php30
-rw-r--r--plugins/Contents/Controller.php51
-rw-r--r--plugins/Contents/DataArray.php76
-rw-r--r--plugins/Contents/Dimensions.php33
-rw-r--r--plugins/Contents/Menu.php24
-rw-r--r--plugins/Contents/README.md18
-rw-r--r--plugins/Contents/Reports/Base.php53
-rw-r--r--plugins/Contents/Reports/GetContentNames.php38
-rw-r--r--plugins/Contents/Reports/GetContentPieces.php39
-rw-r--r--plugins/Contents/lang/en.json12
-rw-r--r--plugins/Contents/plugin.json13
-rw-r--r--plugins/Contents/screenshots/.gitkeep0
-rw-r--r--plugins/Events/Reports/Base.php3
-rw-r--r--tests/javascript/assets/qunit.css194
-rw-r--r--tests/javascript/assets/qunit.js4639
-rw-r--r--tests/javascript/content-fixtures/contentUtilities.html85
-rw-r--r--tests/javascript/content-fixtures/findContentBlockTest.html10
-rw-r--r--tests/javascript/content-fixtures/findContentNodesTest.html35
-rw-r--r--tests/javascript/content-fixtures/manyExamples.html80
-rw-r--r--tests/javascript/content-fixtures/trackerInternals.html80
-rw-r--r--tests/javascript/content-fixtures/trackingContent.html21
-rw-r--r--tests/javascript/content-fixtures/visibleNodes.html50
-rw-r--r--tests/javascript/index.php2023
-rw-r--r--tests/javascript/piwik.php58
-rw-r--r--tests/javascript/testrunner.js10
44 files changed, 8399 insertions, 2472 deletions
diff --git a/core/Metrics.php b/core/Metrics.php
index 85cc4da371..c88bb13294 100644
--- a/core/Metrics.php
+++ b/core/Metrics.php
@@ -82,6 +82,10 @@ class Metrics
const INDEX_NB_USERS = 39;
const INDEX_SUM_DAILY_NB_USERS = 40;
+ // Contents
+ const INDEX_CONTENT_NB_IMPRESSIONS = 41;
+ const INDEX_CONTENT_NB_INTERACTIONS = 42;
+
// Goal reports
const INDEX_GOAL_NB_CONVERSIONS = 1;
const INDEX_GOAL_REVENUE = 2;
@@ -141,6 +145,9 @@ class Metrics
Metrics::INDEX_EVENT_MAX_EVENT_VALUE => 'max_event_value',
Metrics::INDEX_EVENT_NB_HITS_WITH_VALUE => 'nb_events_with_value',
+ // Contents
+ Metrics::INDEX_CONTENT_NB_IMPRESSIONS => 'nb_impressions',
+ Metrics::INDEX_CONTENT_NB_INTERACTIONS => 'nb_interactions'
);
public static $mappingFromIdToNameGoal = array(
diff --git a/core/Plugin/Manager.php b/core/Plugin/Manager.php
index a777ef03e1..49b073c8d3 100644
--- a/core/Plugin/Manager.php
+++ b/core/Plugin/Manager.php
@@ -86,6 +86,7 @@ class Manager extends Singleton
'ExamplePluginTemplate',
'ExampleTracker',
'ExampleReport',
+ 'Contents'
);
// Themes bundled with core package, disabled by default
diff --git a/core/Plugin/Report.php b/core/Plugin/Report.php
index 71d655f6a7..f59e8a13c0 100644
--- a/core/Plugin/Report.php
+++ b/core/Plugin/Report.php
@@ -319,7 +319,7 @@ class Report
public function configureReportingMenu(MenuReporting $menu)
{
if ($this->menuTitle) {
- $action = 'menu' . ucfirst($this->action);
+ $action = $this->getMenuControllerAction();
$menu->add($this->category,
$this->menuTitle,
array('module' => $this->module, 'action' => $action),
@@ -662,4 +662,9 @@ class Report
return $metrics;
}
+
+ private function getMenuControllerAction()
+ {
+ return 'menu' . ucfirst($this->action);
+ }
}
diff --git a/core/Plugin/ViewDataTable.php b/core/Plugin/ViewDataTable.php
index 14b2f979f7..d7f72028cf 100644
--- a/core/Plugin/ViewDataTable.php
+++ b/core/Plugin/ViewDataTable.php
@@ -223,6 +223,11 @@ abstract class ViewDataTable implements ViewInterface
$this->config->addTranslations($metrics);
}
+ $processedMetrics = $report->getProcessedMetrics();
+ if (!empty($processedMetrics)) {
+ $this->config->addTranslations($processedMetrics);
+ }
+
$report->configureView($this);
}
diff --git a/core/Tracker.php b/core/Tracker.php
index 9ff3dec758..d9e8a04278 100644
--- a/core/Tracker.php
+++ b/core/Tracker.php
@@ -165,7 +165,7 @@ class Tracker
$requests = $jsonData['requests'];
}
- return array( $requests, $tokenAuth);
+ return array($requests, $tokenAuth);
}
private function isBulkTrackingRequireTokenAuth()
@@ -178,8 +178,8 @@ class Tracker
list($this->requests, $tokenAuth) = $this->getRequestsArrayFromBulkRequest($rawData);
$bulkTrackingRequireTokenAuth = $this->isBulkTrackingRequireTokenAuth();
- if($bulkTrackingRequireTokenAuth) {
- if(empty($tokenAuth)) {
+ if ($bulkTrackingRequireTokenAuth) {
+ if (empty($tokenAuth)) {
throw new Exception("token_auth must be specified when using Bulk Tracking Import. "
. " See <a href='http://developer.piwik.org/api-reference/tracking-api'>Tracking Doc</a>");
}
@@ -201,8 +201,9 @@ class Tracker
$requestObj = new Request($request, $tokenAuth);
$this->loadTrackerPlugins($requestObj);
- if($bulkTrackingRequireTokenAuth
- && !$requestObj->isAuthenticated()) {
+ if ($bulkTrackingRequireTokenAuth
+ && !$requestObj->isAuthenticated()
+ ) {
throw new Exception(sprintf("token_auth specified does not have Admin permission for idsite=%s", $requestObj->getIdSite()));
}
$request = $requestObj;
@@ -239,7 +240,7 @@ class Tracker
}
$this->runScheduledTasksIfAllowed($isAuthenticated);
$this->commitTransaction();
- } catch(DbException $e) {
+ } catch (DbException $e) {
Common::printDebug($e->getMessage());
$this->rollbackTransaction();
}
@@ -253,6 +254,8 @@ class Tracker
$this->end();
$this->flushOutputBuffer();
+
+ $this->performRedirectToUrlIfSet();
}
protected function initOutputBuffer()
@@ -273,7 +276,7 @@ class Tracker
protected function beginTransaction()
{
$this->transactionId = null;
- if(!$this->shouldUseTransactions()) {
+ if (!$this->shouldUseTransactions()) {
return;
}
$this->transactionId = self::getDatabase()->beginTransaction();
@@ -281,7 +284,7 @@ class Tracker
protected function commitTransaction()
{
- if(empty($this->transactionId)) {
+ if (empty($this->transactionId)) {
return;
}
self::getDatabase()->commit($this->transactionId);
@@ -289,7 +292,7 @@ class Tracker
protected function rollbackTransaction()
{
- if(empty($this->transactionId)) {
+ if (empty($this->transactionId)) {
return;
}
self::getDatabase()->rollback($this->transactionId);
@@ -309,7 +312,7 @@ class Tracker
*/
protected function isTransactionSupported()
{
- return (bool) Config::getInstance()->Tracker['bulk_requests_use_transaction'];
+ return (bool)Config::getInstance()->Tracker['bulk_requests_use_transaction'];
}
protected function shouldRunScheduledTasks()
@@ -426,13 +429,18 @@ class Tracker
*/
protected function exitWithException($e, $authenticated = false)
{
+ if ($this->hasRedirectUrl()) {
+ $this->performRedirectToUrlIfSet();
+ exit;
+ }
+
Common::sendHeader('HTTP/1.1 500 Internal Server Error');
error_log(sprintf("Error in Piwik (tracker): %s", str_replace("\n", " ", $this->getMessageFromException($e))));
if ($this->usingBulkTracking) {
// when doing bulk tracking we return JSON so the caller will know how many succeeded
$result = array(
- 'status' => 'error',
+ 'status' => 'error',
'tracked' => $this->countOfLoggedRequests
);
// send error when in debug mode or when authenticated (which happens when doing log importing,
@@ -495,7 +503,7 @@ class Tracker
{
if ($this->usingBulkTracking) {
$result = array(
- 'status' => 'success',
+ 'status' => 'success',
'tracked' => $this->countOfLoggedRequests
);
Common::sendHeader('Content-Type: application/json');
@@ -786,7 +794,8 @@ class Tracker
// Tests using window_look_back_for_visitor
if (Common::getRequestVar('forceLargeWindowLookBackForVisitor', false, null, $args) == 1
// also look for this in bulk requests (see fake_logs_replay.log)
- || strpos( json_encode($args, true), '"forceLargeWindowLookBackForVisitor":"1"' ) !== false) {
+ || strpos(json_encode($args, true), '"forceLargeWindowLookBackForVisitor":"1"') !== false
+ ) {
self::updateTrackerConfig('window_look_back_for_visitor', 2678400);
}
@@ -901,4 +910,25 @@ class Tracker
return file_get_contents("php://input");
}
+ private function getRedirectUrl()
+ {
+ // TODO only redirecti if domain is trusted in config?
+ return Common::getRequestVar('redirecturl', false, 'string');
+ }
+
+ private function hasRedirectUrl()
+ {
+ $redirectUrl = $this->getRedirectUrl();
+
+ return !empty($redirectUrl);
+ }
+
+ private function performRedirectToUrlIfSet()
+ {
+ if ($this->hasRedirectUrl()) {
+ $redirectUrl = $this->getRedirectUrl();
+ header('Location: ' . $redirectUrl);
+ }
+ }
+
}
diff --git a/core/Tracker/Action.php b/core/Tracker/Action.php
index b3ca851f61..149e9b59b9 100644
--- a/core/Tracker/Action.php
+++ b/core/Tracker/Action.php
@@ -36,10 +36,16 @@ abstract class Action
const TYPE_EVENT_ACTION = 11;
const TYPE_EVENT_NAME = 12;
+ const TYPE_CONTENT = 13; // Alias TYPE_CONTENT_PIECE
+ const TYPE_CONTENT_PIECE = 13;
+ const TYPE_CONTENT_NAME = 14;
+ const TYPE_CONTENT_TARGET = 15;
+ const TYPE_CONTENT_INTERACTION = 16;
+
const DB_COLUMN_CUSTOM_FLOAT = 'custom_float';
private static $factoryPriority = array(
- self::TYPE_PAGE_URL, self::TYPE_SITE_SEARCH, self::TYPE_EVENT, self::TYPE_OUTLINK, self::TYPE_DOWNLOAD
+ self::TYPE_PAGE_URL, self::TYPE_SITE_SEARCH, self::TYPE_EVENT, self::TYPE_CONTENT, self::TYPE_OUTLINK, self::TYPE_DOWNLOAD
);
/**
diff --git a/core/Tracker/Request.php b/core/Tracker/Request.php
index 239feb7ed7..659c5fac6a 100644
--- a/core/Tracker/Request.php
+++ b/core/Tracker/Request.php
@@ -286,6 +286,12 @@ class Request
'search_cat' => array(false, 'string'),
'search_count' => array(-1, 'int'),
'gt_ms' => array(-1, 'int'),
+
+ // Content
+ 'c_p' => array('', 'string'),
+ 'c_n' => array('', 'string'),
+ 'c_t' => array('', 'string'),
+ 'c_i' => array('', 'string'),
);
if (!isset($supportedParams[$name])) {
diff --git a/core/Tracker/TableLogAction.php b/core/Tracker/TableLogAction.php
index 6ebc6d60ff..40acd89f70 100644
--- a/core/Tracker/TableLogAction.php
+++ b/core/Tracker/TableLogAction.php
@@ -232,6 +232,9 @@ class TableLogAction
'eventAction' => Action::TYPE_EVENT_ACTION,
'eventCategory' => Action::TYPE_EVENT_CATEGORY,
'eventName' => Action::TYPE_EVENT_NAME,
+ 'contentPiece' => Action::TYPE_CONTENT_PIECE,
+ 'contentTarget' => Action::TYPE_CONTENT_TARGET,
+ 'contentName' => Action::TYPE_CONTENT_NAME,
);
if(!empty($exactMatch[$segmentName])) {
return $exactMatch[$segmentName];
diff --git a/js/piwik.js b/js/piwik.js
index 8c3a8c9d23..f9309c217f 100644
--- a/js/piwik.js
+++ b/js/piwik.js
@@ -29,7 +29,7 @@
* @version 2012-10-08
* @link http://www.JSON.org/js.html
************************************************************/
-/*jslint evil: true, regexp: false, bitwise: true*/
+/*jslint evil: true, regexp: false, bitwise: true, white: true */
/*global JSON2:true */
/*members "", "\b", "\t", "\n", "\f", "\r", "\"", "\\", apply,
call, charCodeAt, getUTCDate, getUTCFullYear, getUTCHours,
@@ -408,7 +408,7 @@ if (typeof JSON2 !== 'object') {
exec,
res, width, height, devicePixelRatio,
pdf, qt, realp, wma, dir, fla, java, gears, ag,
- hook, getHook, getVisitorId, getVisitorInfo, setUserId, setSiteId, setTrackerUrl, appendToTrackingUrl, getRequest, addPlugin,
+ hook, getHook, getVisitorId, getVisitorInfo, setSiteId, setTrackerUrl, appendToTrackingUrl, getRequest, addPlugin,
getAttributionInfo, getAttributionCampaignName, getAttributionCampaignKeyword,
getAttributionReferrerTimestamp, getAttributionReferrerUrl,
setCustomData, getCustomData,
@@ -429,7 +429,32 @@ if (typeof JSON2 !== 'object') {
setHeartBeatTimer, killFrame, redirectFile, setCountPreRendered,
trackGoal, trackLink, trackPageView, trackSiteSearch, trackEvent,
setEcommerceView, addEcommerceItem, trackEcommerceOrder, trackEcommerceCartUpdate,
- deleteCookies
+ deleteCookies, offsetTop, offsetLeft, offsetHeight, offsetWidth, nodeType, defaultView,
+ innerHTML, scrollLeft, scrollTop, currentStyle, getComputedStyle, querySelectorAll, splice,
+ getAttribute, hasAttribute, attributes, nodeName, findContentNodes, findContentNodes, findContentNodesWithinNode,
+ findPieceNode, findTargetNodeNoDefault, findTargetNode, findContentPiece, children, hasNodeCssClass,
+ getAttributeValueFromNode, hasNodeAttributeWithValue, hasNodeAttribute, findNodesByTagName, findMultiple,
+ makeNodesUnique, concat, find, htmlCollectionToArray, offsetParent, value, nodeValue, findNodesHavingAttribute,
+ findFirstNodeHavingAttribute, findFirstNodeHavingAttributeWithValue, getElementsByClassName,
+ findNodesHavingCssClass, findFirstNodeHavingClass, isLinkElement, findParentContentNode, removeDomainIfIsInLink,
+ findContentName, findMediaUrlInNode, toAbsoluteUrl, findContentTarget, getLocation, origin, host, isSameDomain,
+ search, trim, getBoundingClientRect, bottom, right, left, innerWidth, innerHeight, clientWidth, clientHeight,
+ isOrWasNodeInViewport, isNodeVisible, buildInteractionRequestParams, buildImpressionRequestParams,
+ shouldIgnoreInteraction, setHrefAttribute, setAttribute, buildContentBlock, collectContent, setLocation,
+ CONTENT_ATTR, CONTENT_CLASS, CONTENT_NAME_ATTR, CONTENT_PIECE_ATTR, CONTENT_PIECE_CLASS,
+ CONTENT_TARGET_ATTR, CONTENT_TARGET_CLASS, CONTENT_IGNOREINTERACTION_ATTR, CONTENT_IGNOREINTERACTION_CLASS,
+ trackCallbackOnLoad, trackCallbackOnReady, buildContentImpressionsRequests, wasContentImpressionAlreadyTracked,
+ getQuery, getContent, getContentImpressionsRequestsFromNodes, buildContentInteractionTrackingRedirectUrl,
+ buildContentInteractionRequestNode, buildContentInteractionRequest, buildContentImpressionRequest,
+ appendContentInteractionToRequestIfPossible, setupInteractionsTracking, trackContentImpressionClickInteraction,
+ internalIsNodeVisible, clearTrackedContentImpressions, getTrackerUrl, trackAllContentImpressions,
+ getTrackedContentImpressions, getCurrentlyVisibleContentImpressionsRequestsIfNotTrackedYet,
+ contentInteractionTrackingSetupDone, contains, match, pathname, piece, trackContentInteractionNode,
+ trackContentInteractionNode, trackContentImpressionsWithinNode, trackContentImpression,
+ enableTrackOnlyVisibleContent, trackContentInteraction, clearEnableTrackOnlyVisibleContent,
+ trackVisibleContentImpressions, isTrackOnlyVisibleContentEnabled, port, isUrlToCurrentDomain,
+ isNodeAuthorizedToTriggerInteraction, replaceHrefIfInternalLink, getConfigDownloadExtensions, disableLinkTracking,
+ substr, setAnyAttribute
*/
/*global _paq:true */
/*members push */
@@ -976,6 +1001,925 @@ if (typeof Piwik !== 'object') {
}
/************************************************************
+ * Element Visiblility
+ ************************************************************/
+
+ /**
+ * Author: Jason Farrell
+ * Author URI: http://useallfive.com/
+ *
+ * Description: Checks if a DOM element is truly visible.
+ * Package URL: https://github.com/UseAllFive/true-visibility
+ * License: MIT (https://github.com/UseAllFive/true-visibility/blob/master/LICENSE.txt)
+ */
+ function isVisible(node) {
+
+ if (!node) {
+ return false;
+ }
+
+ //-- Cross browser method to get style properties:
+ function _getStyle(el, property) {
+ if (windowAlias.getComputedStyle) {
+ return documentAlias.defaultView.getComputedStyle(el,null)[property];
+ }
+ if (el.currentStyle) {
+ return el.currentStyle[property];
+ }
+ }
+
+ function _elementInDocument(element) {
+ element = element.parentNode;
+
+ while (element) {
+ if (element === documentAlias) {
+ return true;
+ }
+ element = element.parentNode;
+ }
+ return false;
+ }
+
+ /**
+ * Checks if a DOM element is visible. Takes into
+ * consideration its parents and overflow.
+ *
+ * @param (el) the DOM element to check if is visible
+ *
+ * These params are optional that are sent in recursively,
+ * you typically won't use these:
+ *
+ * @param (t) Top corner position number
+ * @param (r) Right corner position number
+ * @param (b) Bottom corner position number
+ * @param (l) Left corner position number
+ * @param (w) Element width number
+ * @param (h) Element height number
+ */
+ function _isVisible(el, t, r, b, l, w, h) {
+ var p = el.parentNode,
+ VISIBLE_PADDING = 1; // has to be visible at least one px of the element
+
+ if (!_elementInDocument(el)) {
+ return false;
+ }
+
+ //-- Return true for document node
+ if (9 === p.nodeType) {
+ return true;
+ }
+
+ //-- Return false if our element is invisible
+ if (
+ '0' === _getStyle(el, 'opacity') ||
+ 'none' === _getStyle(el, 'display') ||
+ 'hidden' === _getStyle(el, 'visibility')
+ ) {
+ return false;
+ }
+
+ if (!isDefined(t) ||
+ !isDefined(r) ||
+ !isDefined(b) ||
+ !isDefined(l) ||
+ !isDefined(w) ||
+ !isDefined(h)) {
+ t = el.offsetTop;
+ l = el.offsetLeft;
+ b = t + el.offsetHeight;
+ r = l + el.offsetWidth;
+ w = el.offsetWidth;
+ h = el.offsetHeight;
+ }
+
+ if ((0 === h || 0 === w) && 'hidden' === _getStyle(el, 'overflow')) {
+ return false;
+ }
+
+ //-- If we have a parent, let's continue:
+ if (p) {
+ //-- Check if the parent can hide its children.
+ if (('hidden' === _getStyle(p, 'overflow') || 'scroll' === _getStyle(p, 'overflow'))) {
+ //-- Only check if the offset is different for the parent
+ if (
+ //-- If the target element is to the right of the parent elm
+ l + VISIBLE_PADDING > p.offsetWidth + p.scrollLeft ||
+ //-- If the target element is to the left of the parent elm
+ l + w - VISIBLE_PADDING < p.scrollLeft ||
+ //-- If the target element is under the parent elm
+ t + VISIBLE_PADDING > p.offsetHeight + p.scrollTop ||
+ //-- If the target element is above the parent elm
+ t + h - VISIBLE_PADDING < p.scrollTop
+ ) {
+ //-- Our target element is out of bounds:
+ return false;
+ }
+ }
+ //-- Add the offset parent's left/top coords to our element's offset:
+ if (el.offsetParent === p) {
+ l += p.offsetLeft;
+ t += p.offsetTop;
+ }
+ //-- Let's recursively check upwards:
+ return _isVisible(p, t, r, b, l, w, h);
+ }
+ return true;
+ }
+
+ return _isVisible(node);
+ }
+
+ /************************************************************
+ * Query
+ ************************************************************/
+
+ var query = {
+ htmlCollectionToArray: function (foundNodes)
+ {
+ var nodes = [], index;
+
+ if (!foundNodes || !foundNodes.length) {
+ return nodes;
+ }
+
+ for (index = 0; index < foundNodes.length; index++) {
+ nodes.push(foundNodes[index]);
+ }
+
+ return nodes;
+ },
+ find: function (selector)
+ {
+ // we use querySelectorAll only on document, not on nodes because of its unexpected behavior. See for
+ // instance http://stackoverflow.com/questions/11503534/jquery-vs-document-queryselectorall and
+ // http://jsfiddle.net/QdMc5/ and http://ejohn.org/blog/thoughts-on-queryselectorall
+ if (!document.querySelectorAll || !selector) {
+ return []; // we do not support all browsers
+ }
+
+ var foundNodes = document.querySelectorAll(selector);
+
+ return this.htmlCollectionToArray(foundNodes);
+ },
+ findMultiple: function (selectors)
+ {
+ if (!selectors || !selectors.length) {
+ return [];
+ }
+
+ var index, foundNodes;
+ var nodes = [];
+ for (index = 0; index < selectors.length; index++) {
+ foundNodes = this.find(selectors[index]);
+ nodes = nodes.concat(foundNodes);
+ }
+
+ nodes = this.makeNodesUnique(nodes);
+
+ return nodes;
+ },
+ findNodesByTagName: function (node, tagName)
+ {
+ if (!node || !tagName || !node.getElementsByTagName) {
+ return [];
+ }
+
+ var foundNodes = node.getElementsByTagName(tagName);
+
+ return this.htmlCollectionToArray(foundNodes);
+ },
+ makeNodesUnique: function (nodes)
+ {
+ nodes.sort(function(n1, n2){
+ if (n1 === n2) {
+ return 0;
+ }
+
+ return n1.innerHTML > n2.innerHTML ? 1 : -1;
+ });
+
+ if (nodes.length <= 1) {
+ return nodes;
+ }
+
+ var index = 0;
+ var numDuplicates = 0;
+ var duplicates = [];
+ var node;
+
+ node = nodes[index++];
+
+ while (node) {
+ if (node === nodes[index]) {
+ numDuplicates = duplicates.push(index);
+ }
+
+ node = nodes[index++] || null;
+ }
+
+ while (numDuplicates--) {
+ nodes.splice(duplicates[numDuplicates], 1);
+ }
+
+ return nodes;
+ },
+ getAttributeValueFromNode: function (node, attributeName)
+ {
+ if (!this.hasNodeAttribute(node, attributeName)) {
+ return;
+ }
+
+ if (node && node.getAttribute) {
+ return node.getAttribute(attributeName);
+ }
+
+ if (!node || !node.attributes) {
+ return;
+ }
+
+ var typeOfAttr = (typeof node.attributes[attributeName]);
+ if ('undefined' === typeOfAttr) {
+ return;
+ }
+
+ if (node.attributes[attributeName].value) {
+ return node.attributes[attributeName].value; // nodeValue is deprecated ie Chrome
+ }
+
+ if (node.attributes[attributeName].nodeValue) {
+ return node.attributes[attributeName].nodeValue;
+ }
+
+ var index;
+ var attrs = node.attributes;
+
+ if (!attrs) {
+ return;
+ }
+
+ for (index = 0; index < attrs.length; index++) {
+ if (attrs[index].nodeName === attributeName) {
+ return attrs[index].nodeValue;
+ }
+ }
+
+ return null;
+ },
+ hasNodeAttributeWithValue: function (node, attributeName)
+ {
+ var value = this.getAttributeValueFromNode(node, attributeName);
+
+ return !!value;
+ },
+ hasNodeAttribute: function (node, attributeName)
+ {
+ if (node && node.hasAttribute) {
+ return node.hasAttribute(attributeName);
+ }
+
+ if (node && node.attributes) {
+ var typeOfAttr = (typeof node.attributes[attributeName]);
+ return 'undefined' !== typeOfAttr;
+ }
+
+ return false;
+ },
+ hasNodeCssClass: function (node, className)
+ {
+ if (node && className && node.className) {
+ var classes = node.className.split(' ');
+ if (-1 !== classes.indexOf(className)) {
+ return true;
+ }
+ }
+
+ return false;
+ },
+ findNodesHavingAttribute: function (nodeToSearch, attributeName, nodes)
+ {
+ if (!nodes) {
+ nodes = [];
+ }
+
+ if (!nodeToSearch || !attributeName || !nodeToSearch.children) {
+ return nodes;
+ }
+
+ var index, child;
+ for (index = 0; index < nodeToSearch.children.length; index++) {
+ child = nodeToSearch.children[index];
+ if (this.hasNodeAttribute(child, attributeName)) {
+ nodes.push(child);
+ }
+
+ nodes = this.findNodesHavingAttribute(child, attributeName, nodes);
+ }
+
+ return nodes;
+ },
+ findFirstNodeHavingAttribute: function (node, attributeName)
+ {
+ if (!node || !attributeName) {
+ return;
+ }
+
+ if (this.hasNodeAttribute(node, attributeName)) {
+ return node;
+ }
+
+ var nodes = this.findNodesHavingAttribute(node, attributeName);
+
+ if (nodes && nodes.length) {
+ return nodes[0];
+ }
+ },
+ findFirstNodeHavingAttributeWithValue: function (node, attributeName)
+ {
+ if (!node || !attributeName) {
+ return;
+ }
+
+ if (this.hasNodeAttributeWithValue(node, attributeName)) {
+ return node;
+ }
+
+ var nodes = this.findNodesHavingAttribute(node, attributeName);
+
+ if (!nodes || !nodes.length) {
+ return;
+ }
+
+ var index;
+ for (index = 0; index < nodes.length; index++) {
+ if (this.getAttributeValueFromNode(nodes[index], attributeName)) {
+ return nodes[index];
+ }
+ }
+ },
+ findNodesHavingCssClass: function (nodeToSearch, className, nodes)
+ {
+ if (!nodes) {
+ nodes = [];
+ }
+
+ if (!nodeToSearch || !className) {
+ return nodes;
+ }
+
+ if (nodeToSearch.getElementsByClassName) {
+ var foundNodes = nodeToSearch.getElementsByClassName(className);
+ return this.htmlCollectionToArray(foundNodes);
+ }
+
+ if (!nodeToSearch.children) {
+ return;
+ }
+
+ var index, child;
+ for (index = 0; index < nodeToSearch.children.length; index++) {
+ child = nodeToSearch.children[index];
+ if (this.hasNodeCssClass(child, className)) {
+ nodes.push(child);
+ }
+
+ nodes = this.findNodesHavingCssClass(child, className, nodes);
+ }
+
+ return nodes;
+ },
+ findFirstNodeHavingClass: function (node, className)
+ {
+ if (!node || !className) {
+ return;
+ }
+
+ if (this.hasNodeCssClass(node, className)) {
+ return node;
+ }
+
+ var nodes = this.findNodesHavingCssClass(node, className);
+
+ if (nodes && nodes.length) {
+ return nodes[0];
+ }
+ },
+ isLinkElement: function (node)
+ {
+ if (!node) {
+ return false;
+ }
+
+ var elementName = String(node.nodeName).toLowerCase();
+ var linkElementNames = ['a', 'area'];
+ var pos = linkElementNames.indexOf(elementName);
+
+ return pos !== -1;
+ },
+ setAnyAttribute: function (node, attrName, attrValue)
+ {
+ if (!node || !attrName) {
+ return;
+ }
+
+ if (node.setAttribute) {
+ node.setAttribute(attrName, attrValue);
+ } else {
+ node[attrName] = attrValue;
+ }
+ }
+ };
+
+ /************************************************************
+ * Content Tracking
+ ************************************************************/
+
+ var content = {
+ CONTENT_ATTR: 'data-track-content',
+ CONTENT_CLASS: 'piwikTrackContent',
+ CONTENT_NAME_ATTR: 'data-content-name',
+ CONTENT_PIECE_ATTR: 'data-content-piece',
+ CONTENT_PIECE_CLASS: 'piwikContentPiece',
+ CONTENT_TARGET_ATTR: 'data-content-target',
+ CONTENT_TARGET_CLASS: 'piwikContentTarget',
+ CONTENT_IGNOREINTERACTION_ATTR: 'data-content-ignoreinteraction',
+ CONTENT_IGNOREINTERACTION_CLASS: 'piwikContentIgnoreInteraction',
+ location: undefined,
+
+ findContentNodes: function ()
+ {
+
+ var cssSelector = '.' + this.CONTENT_CLASS;
+ var attrSelector = '[' + this.CONTENT_ATTR + ']';
+ var contentNodes = query.findMultiple([cssSelector, attrSelector]);
+
+ return contentNodes;
+ },
+ findContentNodesWithinNode: function (node)
+ {
+ if (!node) {
+ return [];
+ }
+
+ // NOTE: we do not use query.findMultiple here as querySelectorAll would most likely not deliver the result we want
+
+ var nodes1 = query.findNodesHavingCssClass(node, this.CONTENT_CLASS);
+ var nodes2 = query.findNodesHavingAttribute(node, this.CONTENT_ATTR);
+
+ if (nodes2 && nodes2.length) {
+ var index;
+ for (index = 0; index < nodes2.length; index++) {
+ nodes1.push(nodes2[index]);
+ }
+ }
+
+ if (query.hasNodeAttribute(node, this.CONTENT_ATTR)) {
+ nodes1.push(node);
+ } else if (query.hasNodeCssClass(node, this.CONTENT_CLASS)) {
+ nodes1.push(node);
+ }
+
+ nodes1 = query.makeNodesUnique(nodes1);
+
+ return nodes1;
+ },
+ findParentContentNode: function (anyNode)
+ {
+ if (!anyNode) {
+ return;
+ }
+
+ var node = anyNode;
+ var counter = 0;
+
+ while (node && node !== documentAlias && node.parentNode) {
+ if (query.hasNodeAttribute(node, this.CONTENT_ATTR)) {
+ return node;
+ }
+ if (query.hasNodeCssClass(node, this.CONTENT_CLASS)) {
+ return node;
+ }
+
+ node = node.parentNode;
+
+ if (counter > 1000) {
+ break; // prevent loop, should not happen anyway but better we do this
+ }
+ counter++;
+ }
+ },
+ findPieceNode: function (node)
+ {
+ var contentPiece;
+
+ contentPiece = query.findFirstNodeHavingAttribute(node, this.CONTENT_PIECE_ATTR);
+
+ if (!contentPiece) {
+ contentPiece = query.findFirstNodeHavingClass(node, this.CONTENT_PIECE_CLASS);
+ }
+
+ if (contentPiece) {
+ return contentPiece;
+ }
+
+ return node;
+ },
+ findTargetNodeNoDefault: function (node)
+ {
+ if (!node) {
+ return;
+ }
+
+ var target = query.findFirstNodeHavingAttributeWithValue(node, this.CONTENT_TARGET_ATTR);
+ if (target) {
+ return target;
+ }
+
+ target = query.findFirstNodeHavingAttribute(node, this.CONTENT_TARGET_ATTR);
+ if (target) {
+ return target;
+ }
+
+ target = query.findFirstNodeHavingClass(node, this.CONTENT_TARGET_CLASS);
+ if (target) {
+ return target;
+ }
+ },
+ findTargetNode: function (node)
+ {
+ var target = this.findTargetNodeNoDefault(node);
+ if (target) {
+ return target;
+ }
+
+ return node;
+ },
+ findContentName: function (node)
+ {
+ if (!node) {
+ return;
+ }
+
+ var nameNode = query.findFirstNodeHavingAttributeWithValue(node, this.CONTENT_NAME_ATTR);
+
+ if (nameNode) {
+ return query.getAttributeValueFromNode(nameNode, this.CONTENT_NAME_ATTR);
+ }
+
+ var contentPiece = this.findContentPiece(node);
+ if (contentPiece) {
+ return this.removeDomainIfIsInLink(contentPiece);
+ }
+
+ if (query.hasNodeAttributeWithValue(node, 'title')) {
+ return query.getAttributeValueFromNode(node, 'title');
+ }
+
+ var clickUrlNode = this.findPieceNode(node);
+
+ if (query.hasNodeAttributeWithValue(clickUrlNode, 'title')) {
+ return query.getAttributeValueFromNode(clickUrlNode, 'title');
+ }
+
+ var targetNode = this.findTargetNode(node);
+
+ if (query.hasNodeAttributeWithValue(targetNode, 'title')) {
+ return query.getAttributeValueFromNode(targetNode, 'title');
+ }
+ },
+ findContentPiece: function (node)
+ {
+ if (!node) {
+ return;
+ }
+
+ var nameNode = query.findFirstNodeHavingAttributeWithValue(node, this.CONTENT_PIECE_ATTR);
+
+ if (nameNode) {
+ return query.getAttributeValueFromNode(nameNode, this.CONTENT_PIECE_ATTR);
+ }
+
+ var contentNode = this.findPieceNode(node);
+
+ var media = this.findMediaUrlInNode(contentNode);
+ if (media) {
+ return media;
+ }
+ },
+ findContentTarget: function (node)
+ {
+ if (!node) {
+ return;
+ }
+
+ var targetNode = this.findTargetNode(node);
+
+ if (query.hasNodeAttributeWithValue(targetNode, this.CONTENT_TARGET_ATTR)) {
+ return query.getAttributeValueFromNode(targetNode, this.CONTENT_TARGET_ATTR);
+ }
+
+ var href;
+ if (query.hasNodeAttributeWithValue(targetNode, 'href')) {
+ href = query.getAttributeValueFromNode(targetNode, 'href');
+ return this.toAbsoluteUrl(href);
+ }
+
+ var contentNode = this.findPieceNode(node);
+
+ if (query.hasNodeAttributeWithValue(contentNode, 'href')) {
+ href = query.getAttributeValueFromNode(contentNode, 'href');
+ return this.toAbsoluteUrl(href);
+ }
+ },
+ isSameDomain: function (url)
+ {
+ if (!url || !url.indexOf) {
+ return false;
+ }
+
+ if (0 === url.indexOf(this.getLocation().origin)) {
+ return true;
+ }
+
+ var posHost = url.indexOf(this.getLocation().host);
+ if (8 >= posHost && 0 <= posHost) {
+ return true;
+ }
+
+ return false;
+ },
+ removeDomainIfIsInLink: function (text)
+ {
+ // we will only remove if domain === location.origin meaning is not an outlink
+ var regexContainsProtocol = '^https?:\/\/[^\/]+';
+ var regexReplaceDomain = '^.*\/\/[^\/]+';
+
+ if (text &&
+ text.search &&
+ -1 !== text.search(new RegExp(regexContainsProtocol))
+ && this.isSameDomain(text)) {
+
+ text = text.replace(new RegExp(regexReplaceDomain), '');
+ if (!text) {
+ text = '/';
+ }
+ }
+
+ return text;
+ },
+ findMediaUrlInNode: function (node)
+ {
+ if (!node) {
+ return;
+ }
+
+ var mediaElements = ['img', 'embed', 'video', 'audio'];
+ var elementName = node.nodeName.toLowerCase();
+
+ if (-1 !== mediaElements.indexOf(elementName) &&
+ query.findFirstNodeHavingAttributeWithValue(node, 'src')) {
+
+ var sourceNode = query.findFirstNodeHavingAttributeWithValue(node, 'src');
+
+ return query.getAttributeValueFromNode(sourceNode, 'src');
+ }
+
+ if (elementName === 'object' &&
+ query.hasNodeAttributeWithValue(node, 'data')) {
+
+ return query.getAttributeValueFromNode(node, 'data');
+ }
+
+ if (elementName === 'object') {
+ var params = query.findNodesByTagName(node, 'param');
+ if (params && params.length) {
+ var index;
+ for (index = 0; index < params.length; index++) {
+ if ('movie' === query.getAttributeValueFromNode(params[index], 'name') &&
+ query.hasNodeAttributeWithValue(params[index], 'value')) {
+
+ return query.getAttributeValueFromNode(params[index], 'value');
+ }
+ }
+ }
+
+ var embed = query.findNodesByTagName(node, 'embed');
+ if (embed && embed.length) {
+ return this.findMediaUrlInNode(embed[0]);
+ }
+ }
+ },
+ trim: function (text)
+ {
+ if (text && String(text) === text) {
+ return text.replace(/^\s+|\s+$/g, '');
+ }
+
+ return text;
+ },
+ isOrWasNodeInViewport: function (node)
+ {
+ if (!node || !node.getBoundingClientRect || node.nodeType !== 1) {
+ return true;
+ }
+
+ var rect = node.getBoundingClientRect();
+ var html = documentAlias.documentElement || {};
+
+ var wasVisible = rect.top < 0;
+ if (wasVisible && node.offsetTop) {
+ wasVisible = (node.offsetTop + rect.height) > 0;
+ }
+
+ return (
+ (rect.bottom > 0 || wasVisible) &&
+ rect.right > 0 &&
+ rect.left < (windowAlias.innerWidth || html.clientWidth) &&
+ ((rect.top < (windowAlias.innerHeight || html.clientHeight)) || wasVisible) // rect.top < 0 we assume user has seen all the ones that are above the current viewport
+ );
+ },
+ isNodeVisible: function (node)
+ {
+ var isItVisible = isVisible(node);
+ var isInViewport = this.isOrWasNodeInViewport(node);
+ return isItVisible && isInViewport;
+ },
+ buildInteractionRequestParams: function (interaction, name, piece, target)
+ {
+ var params = '';
+
+ if (interaction) {
+ params += 'c_i='+ encodeWrapper(interaction);
+ }
+ if (name) {
+ if (params) {
+ params += '&';
+ }
+ params += 'c_n='+ encodeWrapper(name);
+ }
+ if (piece) {
+ if (params) {
+ params += '&';
+ }
+ params += 'c_p='+ encodeWrapper(piece);
+ }
+ if (target) {
+ if (params) {
+ params += '&';
+ }
+ params += 'c_t='+ encodeWrapper(target);
+ }
+
+ return params;
+ },
+ buildImpressionRequestParams: function (name, piece, target)
+ {
+ var params = 'c_n=' + encodeWrapper(name) +
+ '&c_p=' + encodeWrapper(piece);
+
+ if (target) {
+ params += '&c_t=' + encodeWrapper(target);
+ }
+
+ return params;
+ },
+ buildContentBlock: function (node)
+ {
+ if (!node) {
+ return;
+ }
+
+ var name = this.findContentName(node);
+ var piece = this.findContentPiece(node);
+ var target = this.findContentTarget(node);
+
+ name = this.trim(name);
+ piece = this.trim(piece);
+ target = this.trim(target);
+
+ return {
+ name: name || 'Unknown',
+ piece: piece || 'Unknown',
+ target: target || ''
+ };
+ },
+ collectContent: function (contentNodes)
+ {
+ if (!contentNodes || !contentNodes.length) {
+ return [];
+ }
+
+ var contents = [];
+
+ var index, contentBlock;
+ for (index = 0; index < contentNodes.length; index++) {
+ contentBlock = this.buildContentBlock(contentNodes[index]);
+ if (isDefined(contentBlock)) {
+ contents.push(contentBlock);
+ }
+ }
+
+ return contents;
+ },
+ setLocation: function (location)
+ {
+ this.location = location;
+ },
+ getLocation: function ()
+ {
+ var locationAlias = this.location || windowAlias.location;
+
+ if (!locationAlias.origin) {
+ locationAlias.origin = locationAlias.protocol + "//" + locationAlias.hostname + (locationAlias.port ? ':' + locationAlias.port: '');
+ }
+
+ return locationAlias;
+ },
+ toAbsoluteUrl: function (url)
+ {
+ if ((!url || String(url) !== url) && url !== '') {
+ // we only handle strings
+ return url;
+ }
+
+ if ('' === url) {
+ return this.getLocation().href;
+ }
+
+ // Eg //example.com/test.jpg
+ if (url.search(/^\/\//) !== -1) {
+ return this.getLocation().protocol + url;
+ }
+
+ // Eg http://example.com/test.jpg
+ if (url.search(/:\/\//) !== -1) {
+ return url;
+ }
+
+ // Eg #test.jpg
+ if (0 === url.indexOf('#')) {
+ return this.getLocation().origin + this.getLocation().pathname + url;
+ }
+
+ // Eg ?x=5
+ if (0 === url.indexOf('?')) {
+ return this.getLocation().origin + this.getLocation().pathname + url;
+ }
+
+ // Eg mailto:x@y.z tel:012345, ... market:... sms:..., javasript:... ecmascript: ... and many more
+ if (0 === url.search('^[a-zA-Z]{2,11}:')) {
+ return url;
+ }
+
+ // Eg /test.jpg
+ if (url.search(/^\//) !== -1) {
+ return this.getLocation().origin + url;
+ }
+
+ // Eg test.jpg
+ var regexMatchDir = '(.*\/)';
+ var base = this.getLocation().origin + this.getLocation().pathname.match(new RegExp(regexMatchDir))[0];
+ return base + url;
+ },
+ isUrlToCurrentDomain: function (url) {
+
+ var absoluteUrl = this.toAbsoluteUrl(url);
+
+ if (!absoluteUrl) {
+ return false;
+ }
+
+ var origin = this.getLocation().origin;
+ if (origin === absoluteUrl) {
+ return true;
+ }
+
+ if (0 === String(absoluteUrl).indexOf(origin)) {
+ if (':' === String(absoluteUrl).substr(origin.length, 1)) {
+ return false; // url has port whereas origin has not => different URL
+ }
+
+ return true;
+ }
+
+ return false;
+ },
+ setHrefAttribute: function (node, url)
+ {
+ if (!node || !url) {
+ return;
+ }
+
+ query.setAnyAttribute(node, 'href', url);
+ },
+ shouldIgnoreInteraction: function (targetNode)
+ {
+ var hasAttr = query.hasNodeAttribute(targetNode, this.CONTENT_IGNOREINTERACTION_ATTR);
+ var hasClass = query.hasNodeCssClass(targetNode, this.CONTENT_IGNOREINTERACTION_CLASS);
+ return hasAttr || hasClass;
+ }
+ };
+
+ /************************************************************
* Page Overlay
************************************************************/
@@ -1100,9 +2044,6 @@ if (typeof Piwik !== 'object') {
// Site ID
configTrackerSiteId = siteId || '',
- // User ID
- configUserId = '',
-
// Document URL
configCustomUrl,
@@ -1206,6 +2147,10 @@ if (typeof Piwik !== 'object') {
// Browser features via client-side data collection
browserFeatures = {},
+ // Keeps track of previously tracked content impressions
+ trackedContentImpressions = [],
+ isTrackOnlyVisibleContentEnabled = false,
+
// Guard against installing the link tracker more than once per Tracker instance
linkTrackingInstalled = false,
@@ -1400,7 +2345,7 @@ if (typeof Piwik !== 'object') {
function sendRequest(request, delay, callback) {
var now = new Date();
- if (!configDoNotTrack) {
+ if (!configDoNotTrack && request) {
if (configRequestMethod === 'POST') {
sendXmlHttpRequest(request, callback);
} else {
@@ -1412,6 +2357,29 @@ if (typeof Piwik !== 'object') {
}
/*
+ * Send requests using bulk
+ */
+ function sendBulkRequest(requests, delay) {
+
+ if (configDoNotTrack) {
+ return;
+ }
+
+ if (!requests || !requests.length) {
+ return;
+ }
+
+ // here we have to prevent 414 request uri too long error in case someone tracks like 1000
+
+ var now = new Date();
+ var bulk = '{"requests":["?' + requests.join('","?') + '"]}';
+
+ sendXmlHttpRequest(bulk);
+
+ expireDateTime = now.getTime() + delay;
+ }
+
+ /*
* Get cookie name with prefix and domain hash
*/
function getCookieName(baseName) {
@@ -1764,7 +2732,6 @@ if (typeof Piwik !== 'object') {
'&h=' + now.getHours() + '&m=' + now.getMinutes() + '&s=' + now.getSeconds() +
'&url=' + encodeWrapper(purify(currentUrl)) +
(configReferrerUrl.length ? '&urlref=' + encodeWrapper(purify(configReferrerUrl)) : '') +
- (configUserId.length ? '&uid=' + encodeWrapper(configUserId) : '') +
'&_id=' + uuid + '&_idts=' + createTs + '&_idvc=' + visitCount +
'&_idn=' + newVisitor + // currently unused
(campaignNameDetected.length ? '&_rcn=' + encodeWrapper(campaignNameDetected) : '') +
@@ -1982,18 +2949,478 @@ if (typeof Piwik !== 'object') {
}
/*
+ * Construct regular expression of classes
+ */
+ function getClassesRegExp(configClasses, defaultClass) {
+ var i,
+ classesRegExp = '(^| )(piwik[_-]' + defaultClass;
+
+ if (configClasses) {
+ for (i = 0; i < configClasses.length; i++) {
+ classesRegExp += '|' + configClasses[i];
+ }
+ }
+
+ classesRegExp += ')( |$)';
+
+ return new RegExp(classesRegExp);
+ }
+
+ /*
+ * Link or Download?
+ */
+ function getLinkType(className, href, isInLink) {
+ if (configTrackerUrl && href && 0 === String(href).indexOf(configTrackerUrl)) {
+ return 0;
+ }
+
+ // does class indicate whether it is an (explicit/forced) outlink or a download?
+ var downloadPattern = getClassesRegExp(configDownloadClasses, 'download'),
+ linkPattern = getClassesRegExp(configLinkClasses, 'link'),
+
+ // does file extension indicate that it is a download?
+ downloadExtensionsPattern = new RegExp('\\.(' + configDownloadExtensions + ')([?&#]|$)', 'i');
+
+ if (linkPattern.test(className)) {
+ return 'link';
+ }
+
+ if (downloadPattern.test(className) || downloadExtensionsPattern.test(href)) {
+ return 'download';
+ }
+
+ if (isInLink) {
+ return 0;
+ }
+
+ return 'link';
+ }
+
+ function getSourceElement(sourceElement)
+ {
+ var parentElement;
+
+ parentElement = sourceElement.parentNode;
+ while (parentElement !== null &&
+ /* buggy IE5.5 */
+ isDefined(parentElement)) {
+
+ if (query.isLinkElement(sourceElement)) {
+ break;
+ }
+ sourceElement = parentElement;
+ parentElement = sourceElement.parentNode;
+ }
+
+ return sourceElement;
+ }
+
+ function getLinkIfShouldBeProcessed(sourceElement)
+ {
+ sourceElement = getSourceElement(sourceElement);
+
+ if (!query.hasNodeAttribute(sourceElement, 'href')) {
+ return;
+ }
+
+ if (!isDefined(sourceElement.href)) {
+ return;
+ }
+
+ var href = query.getAttributeValueFromNode(sourceElement, 'href');
+
+ if (configTrackerUrl && href && 0 === String(href).indexOf(configTrackerUrl)) {
+ return;
+ }
+
+ // browsers, such as Safari, don't downcase hostname and href
+ var originalSourceHostName = sourceElement.hostname || getHostName(sourceElement.href);
+ var sourceHostName = originalSourceHostName.toLowerCase();
+ var sourceHref = sourceElement.href.replace(originalSourceHostName, sourceHostName);
+
+ // browsers, such as Safari, don't downcase hostname and href
+ var scriptProtocol = new RegExp('^(javascript|vbscript|jscript|mocha|livescript|ecmascript|mailto):', 'i');
+
+ if (!scriptProtocol.test(sourceHref)) {
+ // track outlinks and all downloads
+ var linkType = getLinkType(sourceElement.className, sourceHref, isSiteHostName(sourceHostName));
+
+ if (linkType) {
+ return {
+ type: linkType,
+ href: sourceHref
+ };
+ }
+ }
+ }
+
+ function buildContentInteractionRequest(interaction, name, piece, target)
+ {
+ var params = content.buildInteractionRequestParams(interaction, name, piece, target);
+
+ if (!params) {
+ return;
+ }
+
+ return getRequest(params, null, 'contentInteraction');
+ }
+
+ function buildContentInteractionTrackingRedirectUrl(url, contentInteraction, contentName, contentPiece, contentTarget)
+ {
+ if (!isDefined(url)) {
+ return;
+ }
+
+ if (url && configTrackerUrl && 0 === String(url).indexOf(configTrackerUrl)) {
+ return url;
+ }
+
+ var redirectUrl = content.toAbsoluteUrl(url);
+ var request = 'redirecturl=' + redirectUrl + '&';
+ request += buildContentInteractionRequest(contentInteraction, contentName, contentPiece, (contentTarget || url));
+
+ var separator = '&';
+ if (configTrackerUrl.indexOf('?') < 0) {
+ separator = '?';
+ }
+
+ return configTrackerUrl + separator + request;
+ }
+
+ function isNodeAuthorizedToTriggerInteraction(contentNode, interactedNode)
+ {
+ if (!contentNode || !interactedNode) {
+ return false;
+ }
+
+ var targetNode = content.findTargetNode(contentNode);
+
+ if (content.shouldIgnoreInteraction(targetNode)) {
+ // interaction should be ignored
+ return false;
+ }
+
+ targetNode = content.findTargetNodeNoDefault(contentNode);
+ if (targetNode && !targetNode.contains(interactedNode)) {
+ /**
+ * There is a target node defined but the clicked element is not within the target node. example:
+ * <div data-track-content><a href="Y" data-content-target>Y</a><img src=""/><a href="Z">Z</a></div>
+ *
+ * The user clicked in this case on link Z and not on target Y
+ */
+ return false;
+ }
+
+ return true;
+ }
+
+ function getContentInteractionToRequestIfPossible (anyNode, interaction, fallbackTarget)
+ {
+ if (!anyNode) {
+ return;
+ }
+
+ var contentNode = content.findParentContentNode(anyNode);
+
+ if (!contentNode) {
+ // we are not within a content block
+ return;
+ }
+
+ if (!isNodeAuthorizedToTriggerInteraction(contentNode, anyNode)) {
+ return;
+ }
+
+ var contentBlock = content.buildContentBlock(contentNode);
+
+ if (!contentBlock) {
+ return;
+ }
+
+ if (!contentBlock.target && fallbackTarget) {
+ contentBlock.target = fallbackTarget;
+ }
+
+ return content.buildInteractionRequestParams(interaction, contentBlock.name, contentBlock.piece, contentBlock.target);
+ }
+
+ function wasContentImpressionAlreadyTracked(contentBlock)
+ {
+ if (!trackedContentImpressions || !trackedContentImpressions.length) {
+ return false;
+ }
+
+ var index, trackedContent;
+
+ for (index = 0; index < trackedContentImpressions.length; index++) {
+ trackedContent = trackedContentImpressions[index];
+
+ if (trackedContent &&
+ trackedContent.name === contentBlock.name &&
+ trackedContent.piece === contentBlock.piece &&
+ trackedContent.target === contentBlock.target) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ function replaceHrefIfInternalLink(contentBlock)
+ {
+ if (!contentBlock) {
+ return false;
+ }
+
+ var targetNode = content.findTargetNode(contentBlock);
+
+ if (!targetNode || content.shouldIgnoreInteraction(targetNode)) {
+ return false;
+ }
+
+ var link = getLinkIfShouldBeProcessed(targetNode);
+ if (linkTrackingInstalled && link && link.type) {
+
+ return false; // it is an outlink or download.
+ }
+
+ if (query.isLinkElement(targetNode) &&
+ query.hasNodeAttributeWithValue(targetNode, 'href')) {
+ var url = String(query.getAttributeValueFromNode(targetNode, 'href'));
+
+ if (0 === url.indexOf('#')) {
+ return false;
+ }
+
+ if (configTrackerUrl && 0 === url.indexOf(configTrackerUrl)) {
+ return true;
+ }
+
+ if (!content.isUrlToCurrentDomain(url)) {
+ return false;
+ }
+
+ var contentName = content.findContentName(contentBlock);
+ var contentPiece = content.findContentPiece(contentBlock);
+ var contentTarget = content.findContentTarget(contentBlock);
+
+ var targetUrl = buildContentInteractionTrackingRedirectUrl(url, 'click', contentName, contentPiece, contentTarget);
+
+ // make sure we still track the correct content target when an interaction is happening
+ if (!query.hasNodeAttributeWithValue(targetNode, content.CONTENT_TARGET_ATTR)) {
+ query.setAnyAttribute(targetNode, content.CONTENT_TARGET_ATTR, content.toAbsoluteUrl(url));
+ }
+ // location.href does not respect target=_blank so we prefer to use this
+ content.setHrefAttribute(targetNode, targetUrl);
+
+ return true;
+ }
+
+ return false;
+ }
+
+ function replaceHrefsIfInternalLink(contentNodes)
+ {
+ if (!contentNodes || !contentNodes.length) {
+ return;
+ }
+
+ var index;
+ for (index = 0; index < contentNodes.length; index++) {
+ replaceHrefIfInternalLink(contentNodes[index]);
+ }
+ }
+
+ function trackContentImpressionClickInteraction (targetNode)
+ {
+ return function (event) {
+
+ if (!targetNode) {
+ return;
+ }
+
+ var contentBlock = content.findParentContentNode(targetNode);
+
+ var interactedElement;
+ if (event) {
+ interactedElement = event.target || event.srcElement;
+ }
+ if (!interactedElement) {
+ interactedElement = targetNode;
+ }
+
+ if (!isNodeAuthorizedToTriggerInteraction(contentBlock, interactedElement)) {
+ return;
+ }
+
+ var link = getLinkIfShouldBeProcessed(targetNode);
+
+ if (linkTrackingInstalled && link && link.type) {
+ // click ignore, will be tracked via processClick, we do not want to track it twice
+
+ return link.type;
+ }
+
+ if (replaceHrefIfInternalLink(contentBlock)) {
+ return 'href';
+ }
+
+ var contentName = content.findContentName(contentBlock);
+ var contentPiece = content.findContentPiece(contentBlock);
+ var contentTarget = content.findContentTarget(contentBlock);
+
+ // click on any non link element, or on a link element that has not an href attribute or on an anchor
+ var request = buildContentInteractionRequest('click', contentName, contentPiece, contentTarget);
+ sendRequest(request, configTrackerPause);
+
+ return request;
+ };
+
+ }
+
+ function setupInteractionsTracking(contentNodes)
+ {
+ if (!contentNodes || !contentNodes.length) {
+ return;
+ }
+
+ var index, targetNode;
+ for (index = 0; index < contentNodes.length; index++) {
+ targetNode = content.findTargetNode(contentNodes[index]);
+
+ if (targetNode && !targetNode.contentInteractionTrackingSetupDone) {
+ targetNode.contentInteractionTrackingSetupDone = true;
+
+ addEventListener(targetNode, 'click', trackContentImpressionClickInteraction(targetNode));
+ }
+ }
+ }
+
+ /*
+ * Log all content pieces
+ */
+ function buildContentImpressionsRequests(contents, contentNodes)
+ {
+ if (!contents || !contents.length) {
+ return [];
+ }
+
+ var index, request;
+
+ for (index = 0; index < contents.length; index++) {
+
+ if (wasContentImpressionAlreadyTracked(contents[index])) {
+ contents.splice(index, 1);
+ index--;
+ } else {
+ trackedContentImpressions.push(contents[index]);
+ }
+ }
+
+ if (!contents || !contents.length) {
+ return [];
+ }
+
+ replaceHrefsIfInternalLink(contentNodes);
+ setupInteractionsTracking(contentNodes);
+
+ var requests = [];
+
+ for (index = 0; index < contents.length; index++) {
+
+ request = getRequest(
+ content.buildImpressionRequestParams(contents[index].name, contents[index].piece, contents[index].target),
+ undefined,
+ 'contentImpressions'
+ );
+
+ requests.push(request);
+ }
+
+ return requests;
+ }
+
+ /*
+ * Log all content pieces
+ */
+ function getContentImpressionsRequestsFromNodes(contentNodes)
+ {
+ var contents = content.collectContent(contentNodes);
+
+ return buildContentImpressionsRequests(contents, contentNodes);
+ }
+
+ /*
+ * Log currently visible content pieces
+ */
+ function getCurrentlyVisibleContentImpressionsRequestsIfNotTrackedYet(contentNodes)
+ {
+ if (!contentNodes || !contentNodes.length) {
+ return [];
+ }
+
+ var index;
+
+ for (index = 0; index < contentNodes.length; index++) {
+ if (!content.isNodeVisible(contentNodes[index])) {
+ contentNodes.splice(index, 1);
+ index--;
+ }
+ }
+
+ if (!contentNodes || !contentNodes.length) {
+ return [];
+ }
+
+ return getContentImpressionsRequestsFromNodes(contentNodes);
+ }
+
+ function buildContentImpressionRequest(contentName, contentPiece, contentTarget)
+ {
+ var params = content.buildImpressionRequestParams(contentName, contentPiece, contentTarget);
+
+ return getRequest(params, null, 'contentImpression');
+ }
+
+ function buildContentInteractionRequestNode(node, contentInteraction)
+ {
+ if (!node) {
+ return;
+ }
+
+ var contentNode = content.findParentContentNode(node);
+ var contentBlock = content.buildContentBlock(contentNode);
+
+ if (!contentBlock) {
+ return;
+ }
+
+ if (!contentInteraction) {
+ contentInteraction = 'Unknown';
+ }
+
+ return buildContentInteractionRequest(contentInteraction, contentBlock.name, contentBlock.piece, contentBlock.target);
+ }
+
+ function buildEventRequest(category, action, name, value)
+ {
+ return 'e_c=' + encodeWrapper(category)
+ + '&e_a=' + encodeWrapper(action)
+ + (isDefined(name) ? '&e_n=' + encodeWrapper(name) : '')
+ + (isDefined(value) ? '&e_v=' + encodeWrapper(value) : '');
+ }
+
+ /*
* Log the event
*/
- function logEvent(category, action, name, value, customData) {
+ function logEvent(category, action, name, value, customData)
+ {
// Category and Action are required parameters
if (String(category).length === 0 || String(action).length === 0) {
return false;
}
var request = getRequest(
- 'e_c=' + encodeWrapper(category)
- + '&e_a=' + encodeWrapper(action)
- + (isDefined(name) ? '&e_n=' + encodeWrapper(name) : '')
- + (isDefined(value) ? '&e_v=' + encodeWrapper(value) : ''),
+ buildEventRequest(category, action, name, value),
customData,
'event'
);
@@ -2024,8 +3451,17 @@ if (typeof Piwik !== 'object') {
/*
* Log the link or click with the server
*/
- function logLink(url, linkType, customData, callback) {
- var request = getRequest(linkType + '=' + encodeWrapper(purify(url)), customData, 'link');
+ function logLink(url, linkType, customData, callback, sourceElement) {
+
+ var linkParams = linkType + '=' + encodeWrapper(purify(url));
+
+ var interaction = getContentInteractionToRequestIfPossible(sourceElement, 'click', url);
+
+ if (interaction) {
+ linkParams += '&' + interaction;
+ }
+
+ var request = getRequest(linkParams, customData, 'link');
sendRequest(request, (callback ? 0 : configTrackerPause), callback);
}
@@ -2083,77 +3519,46 @@ if (typeof Piwik !== 'object') {
callback();
}
- /*
- * Construct regular expression of classes
- */
- function getClassesRegExp(configClasses, defaultClass) {
- var i,
- classesRegExp = '(^| )(piwik[_-]' + defaultClass;
-
- if (configClasses) {
- for (i = 0; i < configClasses.length; i++) {
- classesRegExp += '|' + configClasses[i];
- }
+ function trackCallbackOnLoad(callback)
+ {
+ if (documentAlias.readyState === 'complete') {
+ callback();
+ } else if (windowAlias.addEventListener) {
+ windowAlias.addEventListener('load', callback);
+ } else if (windowAlias.attachEvent) {
+ windowAlias.attachEvent('onLoad', callback);
}
-
- classesRegExp += ')( |$)';
-
- return new RegExp(classesRegExp);
}
- /*
- * Link or Download?
- */
- function getLinkType(className, href, isInLink) {
- // does class indicate whether it is an (explicit/forced) outlink or a download?
- var downloadPattern = getClassesRegExp(configDownloadClasses, 'download'),
- linkPattern = getClassesRegExp(configLinkClasses, 'link'),
+ function trackCallbackOnReady(callback)
+ {
+ var loaded = false;
- // does file extension indicate that it is a download?
- downloadExtensionsPattern = new RegExp('\\.(' + configDownloadExtensions + ')([?&#]|$)', 'i');
+ if (documentAlias.attachEvent) {
+ loaded = documentAlias.readyState === "complete";
+ } else {
+ loaded = documentAlias.readyState !== "loading";
+ }
- // optimization of the if..elseif..else construct below
- return linkPattern.test(className) ? 'link' : (downloadPattern.test(className) || downloadExtensionsPattern.test(href) ? 'download' : (isInLink ? 0 : 'link'));
+ if (loaded) {
+ callback();
+ } else if (documentAlias.addEventListener) {
+ documentAlias.addEventListener('DOMContentLoaded', callback);
+ } else if (documentAlias.attachEvent) {
+ documentAlias.attachEvent('onreadystatechange', callback);
+ }
}
/*
* Process clicks
*/
function processClick(sourceElement) {
- var parentElement,
- tag,
- linkType;
+ var link = getLinkIfShouldBeProcessed(sourceElement);
- parentElement = sourceElement.parentNode;
- while (parentElement !== null &&
- /* buggy IE5.5 */
- isDefined(parentElement)) {
- tag = sourceElement.tagName.toUpperCase();
- if (tag === 'A' || tag === 'AREA') {
- break;
- }
- sourceElement = parentElement;
- parentElement = sourceElement.parentNode;
- }
-
- if (isDefined(sourceElement.href)) {
- // browsers, such as Safari, don't downcase hostname and href
- var originalSourceHostName = sourceElement.hostname || getHostName(sourceElement.href),
- sourceHostName = originalSourceHostName.toLowerCase(),
- sourceHref = sourceElement.href.replace(originalSourceHostName, sourceHostName),
- scriptProtocol = new RegExp('^(javascript|vbscript|jscript|mocha|livescript|ecmascript|mailto):', 'i');
-
- // ignore script pseudo-protocol links
- if (!scriptProtocol.test(sourceHref)) {
- // track outlinks and all downloads
- linkType = getLinkType(sourceElement.className, sourceHref, isSiteHostName(sourceHostName));
-
- if (linkType) {
- // urldecode %xx
- sourceHref = urldecode(sourceHref);
- logLink(sourceHref, linkType);
- }
- }
+ if (link && link.type) {
+ // urldecode %xx
+ link.href = urldecode(link.href);
+ logLink(link.href, link.type, undefined, null, sourceElement);
}
}
@@ -2223,6 +3628,72 @@ if (typeof Piwik !== 'object') {
}
}
}
+ function enableTrackOnlyVisibleContent (checkOnSroll, timeIntervalInMs, tracker) {
+
+ if (isTrackOnlyVisibleContentEnabled) {
+ // already enabled, do not register intervals again
+ return true;
+ }
+
+ isTrackOnlyVisibleContentEnabled = true;
+
+ var didScroll = false;
+ var events, index;
+
+ function setDidScroll() { didScroll = true; }
+
+ trackCallbackOnLoad(function () {
+
+ function checkContent(intervalInMs) {
+ setTimeout(function () {
+ if (!isTrackOnlyVisibleContentEnabled) {
+ return; // the tests stopped tracking only visible content
+ }
+ didScroll = false;
+ tracker.trackVisibleContentImpressions();
+ checkContent(intervalInMs);
+ }, intervalInMs);
+ }
+
+ function checkContentIfDidScroll(intervalInMs) {
+
+ setTimeout(function () {
+ if (!isTrackOnlyVisibleContentEnabled) {
+ return; // the tests stopped tracking only visible content
+ }
+
+ if (didScroll) {
+ didScroll = false;
+ tracker.trackVisibleContentImpressions();
+ }
+
+ checkContentIfDidScroll(intervalInMs);
+ }, intervalInMs);
+ }
+
+ if (checkOnSroll) {
+
+ // scroll event is executed after each pixel, so we make sure not to
+ // execute event too often. otherwise FPS goes down a lot!
+ events = ['scroll', 'resize'];
+ for (index = 0; index < events.length; index++) {
+ if (documentAlias.addEventListener) {
+ documentAlias.addEventListener(events[index], setDidScroll);
+ } else {
+ windowAlias.attachEvent('on' + events[index], setDidScroll);
+ }
+ }
+
+ checkContentIfDidScroll(100);
+ }
+
+ if (timeIntervalInMs && timeIntervalInMs > 0) {
+ timeIntervalInMs = parseInt(timeIntervalInMs, 10);
+ checkContent(timeIntervalInMs);
+ }
+
+ });
+ }
/*
* Browser features (plugins, resolution, cookies)
@@ -2338,6 +3809,50 @@ if (typeof Piwik !== 'object') {
getHook: function (hookName) {
return registeredHooks[hookName];
},
+ getQuery: function () {
+ return query;
+ },
+ getContent: function () {
+ return content;
+ },
+
+ buildContentImpressionRequest: buildContentImpressionRequest,
+ buildContentInteractionRequest: buildContentInteractionRequest,
+ buildContentInteractionRequestNode: buildContentInteractionRequestNode,
+ buildContentInteractionTrackingRedirectUrl: buildContentInteractionTrackingRedirectUrl,
+ getContentImpressionsRequestsFromNodes: getContentImpressionsRequestsFromNodes,
+ getCurrentlyVisibleContentImpressionsRequestsIfNotTrackedYet: getCurrentlyVisibleContentImpressionsRequestsIfNotTrackedYet,
+ trackCallbackOnLoad: trackCallbackOnLoad,
+ trackCallbackOnReady: trackCallbackOnReady,
+ buildContentImpressionsRequests: buildContentImpressionsRequests,
+ wasContentImpressionAlreadyTracked: wasContentImpressionAlreadyTracked,
+ appendContentInteractionToRequestIfPossible: getContentInteractionToRequestIfPossible,
+ setupInteractionsTracking: setupInteractionsTracking,
+ trackContentImpressionClickInteraction: trackContentImpressionClickInteraction,
+ internalIsNodeVisible: isVisible,
+ isNodeAuthorizedToTriggerInteraction: isNodeAuthorizedToTriggerInteraction,
+ replaceHrefIfInternalLink: replaceHrefIfInternalLink,
+ getConfigDownloadExtensions: function () {
+ return configDownloadExtensions;
+ },
+ enableTrackOnlyVisibleContent: function (checkOnScroll, timeIntervalInMs) {
+ return enableTrackOnlyVisibleContent(checkOnScroll, timeIntervalInMs, this);
+ },
+ clearTrackedContentImpressions: function () {
+ trackedContentImpressions = [];
+ },
+ getTrackedContentImpressions: function () {
+ return trackedContentImpressions;
+ },
+ getTrackerUrl: function () {
+ return configTrackerUrl;
+ },
+ clearEnableTrackOnlyVisibleContent: function () {
+ isTrackOnlyVisibleContentEnabled = false;
+ },
+ disableLinkTracking: function () {
+ linkTrackingInstalled = false;
+ },
/*</DEBUG>*/
/**
@@ -2429,15 +3944,6 @@ if (typeof Piwik !== 'object') {
},
/**
- * Sets a User ID to this user (such as an email address or a username)
- *
- * @param string User ID
- */
- setUserId: function (userId) {
- configUserId = userId;
- },
-
- /**
* Pass custom data to the server
*
* Examples:
@@ -2452,7 +3958,7 @@ if (typeof Piwik !== 'object') {
configCustomData = key_or_obj;
} else {
if (!configCustomData) {
- configCustomData = [];
+ configCustomData = {};
}
configCustomData[key_or_obj] = opt_value;
}
@@ -3045,6 +4551,8 @@ if (typeof Piwik !== 'object') {
* @param mixed customData
*/
trackPageView: function (customTitle, customData) {
+ trackedContentImpressions = [];
+
if (isOverlaySession(configTrackerSiteId)) {
trackCallback(function () {
injectOverlayScripts(configTrackerUrl, configApiUrl, configTrackerSiteId);
@@ -3056,6 +4564,101 @@ if (typeof Piwik !== 'object') {
}
},
+ trackAllContentImpressions: function () {
+ trackCallback(function () {
+ trackCallbackOnReady(function () {
+ // we have to wait till DOM ready
+ var contentNodes = content.findContentNodes();
+
+ var requests = getContentImpressionsRequestsFromNodes(contentNodes);
+ sendBulkRequest(requests, configTrackerPause);
+ });
+ });
+ },
+
+ trackVisibleContentImpressions: function (checkOnSroll, timeIntervalInMs) {
+
+ if (!isDefined(checkOnSroll)) {
+ checkOnSroll = true;
+ }
+
+ if (!isDefined(timeIntervalInMs)) {
+ timeIntervalInMs = 750;
+ }
+
+ enableTrackOnlyVisibleContent(checkOnSroll, timeIntervalInMs, this);
+
+ trackCallback(function () {
+ trackCallbackOnLoad(function () {
+ // we have to wait till CSS parsed and applied
+ var contentNodes = content.findContentNodes();
+
+ var requests = getCurrentlyVisibleContentImpressionsRequestsIfNotTrackedYet(contentNodes);
+ sendBulkRequest(requests, configTrackerPause);
+ });
+ });
+ },
+
+ // it must be a node that is set to .piwikTrackContent or [data-track-content] or one of its parents nodes
+ trackContentImpression: function (contentName, contentPiece, contentTarget) {
+ if (!contentName) {
+ return;
+ }
+
+ contentPiece = contentPiece || 'Unknown';
+
+ trackCallback(function () {
+ var request = buildContentImpressionRequest(contentName, contentPiece, contentTarget);
+ sendRequest(request, configTrackerPause);
+ });
+ },
+
+ // it must be a node that is set to .piwikTrackContent or [data-track-content] or one of its parents nodes
+ // we might remove this method again
+ trackContentImpressionsWithinNode: function (domNode) {
+ trackCallback(function () {
+ if (isTrackOnlyVisibleContentEnabled) {
+ trackCallbackOnLoad(function () {
+ // we have to wait till CSS parsed and applied
+ var contentNodes = content.findContentNodesWithinNode(domNode);
+
+ var requests = getCurrentlyVisibleContentImpressionsRequestsIfNotTrackedYet(contentNodes);
+ sendBulkRequest(requests, configTrackerPause);
+ });
+ } else {
+ trackCallbackOnReady(function () {
+ // we have to wait till DOM ready
+ var contentNodes = content.findContentNodesWithinNode(domNode);
+
+ var requests = getContentImpressionsRequestsFromNodes(contentNodes);
+ sendBulkRequest(requests, configTrackerPause);
+ });
+ }
+ });
+ },
+
+ // name and piece has to be same as previously used on an impression
+ trackContentInteraction: function (contentInteraction, contentName, contentPiece, contentTarget) {
+ if (!contentInteraction || !contentName) {
+ return;
+ }
+
+ contentPiece = contentPiece || 'Unknown';
+
+ trackCallback(function () {
+ var request = buildContentInteractionRequest(contentInteraction, contentName, contentPiece, contentTarget);
+ sendRequest(request, configTrackerPause);
+ });
+ },
+ // it must be a node that is set to .piwikTrackContent or [data-track-content] or one of its parents nodes
+ // we might remove this method again
+ trackContentInteractionNode: function (domNode, contentInteraction) {
+ trackCallback(function () {
+ var request = buildContentInteractionRequestNode(domNode, contentInteraction);
+ sendRequest(request, configTrackerPause);
+ });
+ },
+
/**
* Records an event
*
@@ -3083,7 +4686,6 @@ if (typeof Piwik !== 'object') {
});
},
-
/**
* Used to record that the current page view is an item (product) page view, or a Ecommerce Category page view.
* This must be called before trackPageView() on the product/category page.
diff --git a/libs/PiwikTracker/PiwikTracker.php b/libs/PiwikTracker/PiwikTracker.php
index aec4d011a4..af9a2d64cd 100644
--- a/libs/PiwikTracker/PiwikTracker.php
+++ b/libs/PiwikTracker/PiwikTracker.php
@@ -554,6 +554,36 @@ class PiwikTracker
}
/**
+ * Tracks a content impression
+ *
+ * @param string $contentName The name of the content. For instance 'Ad Foo Bar'
+ * @param string $contentPiece The actual content. For instance the path to an image, video, audio, any text
+ * @param string|false $contentTarget (optional) The target of the content. For instance the URL of a landing page.
+ * @return mixed Response string or true if using bulk requests.
+ */
+ public function doTrackContentImpression($contentName, $contentPiece = 'Unknown', $contentTarget = false)
+ {
+ $url = $this->getUrlTrackContentImpression($contentName, $contentPiece, $contentTarget);
+ return $this->sendRequest($url);
+ }
+
+ /**
+ * Tracks a content interaction. Make sure you have tracked a content impression using the same content name and
+ * content piece, otherwise it will not count. To do so you should call the method doTrackContentImpression();
+ *
+ * @param string $interaction The name of the interaction with the content. For instance a 'click'
+ * @param string $contentName The name of the content. For instance 'Ad Foo Bar'
+ * @param string $contentPiece The actual content. For instance the path to an image, video, audio, any text
+ * @param string|false $contentTarget (optional) The target the content leading to when an interaction occurs. For instance the URL of a landing page.
+ * @return mixed Response string or true if using bulk requests.
+ */
+ public function doTrackContentInteraction($interaction, $contentName, $contentPiece = 'Unknown', $contentTarget = false)
+ {
+ $url = $this->getUrlTrackContentInteraction($interaction, $contentName, $contentPiece, $contentTarget);
+ return $this->sendRequest($url);
+ }
+
+ /**
* Tracks an internal Site Search query, and optionally tracks the Search Category, and Search results Count.
* These are used to populate reports in Actions > Site Search.
*
@@ -852,6 +882,72 @@ class PiwikTracker
}
/**
+ * Builds URL to track a content impression.
+ *
+ * @see doTrackContentImpression()
+ * @param string $contentName The name of the content. For instance 'Ad Foo Bar'
+ * @param string $contentPiece The actual content. For instance the path to an image, video, audio, any text
+ * @param string|false $contentTarget (optional) The target of the content. For instance the URL of a landing page.
+ * @throws Exception In case $contentName is empty
+ * @return string URL to piwik.php with all parameters set to track the pageview
+ */
+ public function getUrlTrackContentImpression($contentName, $contentPiece, $contentTarget)
+ {
+ $url = $this->getRequest($this->idSite);
+
+ if (strlen($contentName) == 0) {
+ throw new Exception("You must specify a content name");
+ }
+
+ $url .= 'c_n=' . urlencode($contentName);
+
+ if (!empty($contentPiece) && strlen($contentPiece) > 0) {
+ $url .= '&c_p=' . urlencode($contentPiece);
+ }
+ if (!empty($contentTarget) && strlen($contentTarget) > 0) {
+ $url .= '&c_t=' . urlencode($contentTarget);
+ }
+
+ return $url;
+ }
+
+ /**
+ * Builds URL to track a content impression.
+ *
+ * @see doTrackContentInteraction()
+ * @param string $interaction The name of the interaction with the content. For instance a 'click'
+ * @param string $contentName The name of the content. For instance 'Ad Foo Bar'
+ * @param string $contentPiece The actual content. For instance the path to an image, video, audio, any text
+ * @param string|false $contentTarget (optional) The target the content leading to when an interaction occurs. For instance the URL of a landing page.
+ * @throws Exception In case $interaction or $contentName is empty
+ * @return string URL to piwik.php with all parameters set to track the pageview
+ */
+ public function getUrlTrackContentInteraction($interaction, $contentName, $contentPiece, $contentTarget)
+ {
+ $url = $this->getRequest($this->idSite);
+
+ if (strlen($interaction) == 0) {
+ throw new Exception("You must specify a name for the interaction");
+ }
+
+ if (strlen($contentName) == 0) {
+ throw new Exception("You must specify a content name");
+ }
+
+ $url .= 'c_i=' . urlencode($interaction);
+ $url .= 'c_n=' . urlencode($contentName);
+
+ if (!empty($contentPiece) && strlen($contentPiece) > 0) {
+ $url .= '&c_p=' . urlencode($contentPiece);
+ }
+ if (!empty($contentTarget) && strlen($contentTarget) > 0) {
+ $url .= '&c_t=' . urlencode($contentTarget);
+ }
+
+ return $url;
+ }
+
+ /**
* Builds URL to track a site search.
*
* @see doTrackSiteSearch()
diff --git a/misc/internal-docs/content-tracking.md b/misc/internal-docs/content-tracking.md
index 1f5ba0568f..b58af4e8ff 100644
--- a/misc/internal-docs/content-tracking.md
+++ b/misc/internal-docs/content-tracking.md
@@ -1,39 +1,575 @@
# Technical concept for implementing Content Tracking [#4996](#4996)
+See https://github.com/piwik/piwik/issues/4996 for explanation of the actual feature.
+
This is the technical concept for implementing content tracking. We won't plan anything to death but a little bit of thinking upfront makes sense :) Feel free to contribute and let us know if you have any objections! If your thoughts are not technical please comment on the actual issue [#4996](#4996).
-## Tagging of the content piece declarative
-In HTML...
+## Naming
+| Name | Purpose |
+| ------------- | ------------- |
+| Plugin name | Contents |
+| Content block | Is a container which consists of a content name, piece, target and an interaction. |
+| Content name | A name that represents a content block. The name will be visible in reports. One name can belong to differnt content pieces. |
+| Content piece | This is the actual content that was displayed, eg a path to a video/image/audio file, a text, ... |
+| Content target | For instance the URL of a landing page where the user was led to after interacting with the content block. |
+| Content impression | Any content block that was displayed on a page, such as a banner or an ad. Optionally you can tell Piwik to track only impressions for content blocks that were actually visible. |
+| Content interaction | Any content block that was interacted with by a user. This means usually a 'click' on a banner or ad happened, but it can be any interaction. |
+| Content interaction rate | The ratio of content impressions to interactions. For instance an ad was displayed a 100 times and there were 2 interactions results in a rate of 2%. |
+
+## Tracking the content declarative
+
+Generally said you can usually choose between HTML attributes and CSS classes to define the content you want to track. Attributes always take precedence over CSS classes. So if you define an attribute on one element and a CSS class on another element we will always pick the element having the attribute. If you set the same attribute or the same class on multiple elements within one block, the first element will always win.
+Nested content blocks are currently not supported.
+
+HTML attributes are the recommended way to go as it allows you to set a specific value that will be used when detecting the content impressions on your website.
+Imagine you do not have a value for an HTML attribute provided or if a CSS class is used, we will have to try to detect the content name, piece and target automatically based on a set of rules which are explained further below. For instance we are trying to read the content target from a `href` attribute of a link, the content piece from a `src` attribute of an image, and the name from a `title` attribute.
+If you let us automatically detect those values it can influence your tracking over time. For instance if you provide the same page in different languages, and we will detect the content automatically, we might end up in many different content blocks that represent actually all the same. Therefore it is recommended to use the HTML-attributes including values.
+
+The following attributes and their corresponding CSS classes are used which will be explained in detail below:
+* `[data-track-content] or .piwikTrackContent` == Defines a content block
+* `[data-content-name=""]` == Defines the name of the content block
+* `[data-content-piece=""] or .piwikContentPiece` == Defines the content piece
+* `[data-content-target=""] or .piwikContentTarget` == Defines the content target
+* `[data-content-ignoreinteraction] or .piwikContentIgnoreInteraction` == Tells Piwik to not automatically track the interaction
+
+### How to define a block of content?
+You can use either the attribute `data-track-content` or the CSS class `piwikTrackContent`. The attribute does not require any value.
+
+Examples:
+```
+<img src="img-en.jpg" data-track-content/>
+// content name = img-en.jpg
+// content piece = img-en.jpg
+// content target = ""
+
+<img src="img-en.jpg" class="piwikTrackContent"/>
+// content name = img-en.jpg
+// content piece = img-en.jpg
+// content target = ""
+```
+
+As you can see in these examples we do detect the content piece and name automatically based on the `src` attribute of the image. The content target cannot be detected since an image does not define a link.
+
+Note: In the future we may allow to define the name of the content using this attribute instead of `data-content-name` but I did not want this for two reasons: It could also define the actual content (the content piece) so it would not be intuitive, using `data-content-name` attribute allows to set the name also on nested attributes.
+
+### How do we detect the content piece element?
+The content piece element is used to detect the actual content of a content block.
+
+To find the content piece element we will try to find an element having the attribute `data-content-piece` or the CSS class `piwikContentPiece`. This attribute/class can be specified anywhere within a content block.
+If we do not find any specific content piece element, we will use the content block element.
+
+### How do we detect the content piece?
+
+* The simplest scenario is to provide an HTML attribute `data-content-piece="foo"` including a value anywhere within the content block or in the content block element itself.
+* If there is no such attribute we will check whether the content piece element is a media (audio, video, image) and we will try to detect the URL to the media automatically. For instance using the `src` attribute.
+ * In case of video and audio elements, when there are multiple sources defined, we will choose the URL of the first source
+* If we haven't found anything we will fall back to use the value "Unknown". In such a case you should set the attribute `data-content-piece` telling us explicitly what the content is.
+
+Examples:
+```
+<a href="http://www.example.com" data-track-content><img src="img-en.jpg" data-content-piece="img.jpg"/></a>
+// content name = img.jpg
+// content piece = img.jpg
+// content target = http://www.example.com
+```
+As you can see we can now define a specific value for the content piece which can be useful if your text or images are different in for each language.
+This time we can also automatically detect the content target since we have set the content block on an `a` element. More about this later. The `data-content-piece` attribute can be set on any element, also in the `a` element.
+
+```
+<a href="http://www.example.com" data-track-content><img src="img-en.jpg" data-content-piece/></a>
+<a href="http://www.example.com" data-track-content><img src="img-en.jpg" class="piwikContentPiece"/></a>
+// content name = img-en.jpg
+// content piece = img-en.jpg
+// content target = http://www.example.com
+```
+
+In this example we were able to detect the name and the piece of the content automatically based on the `src` attribute.
+
+```
+<a href="http://www.example.com" data-track-content><p data-content-piece>Lorem ipsum dolor sit amet</p></a>
+<a href="http://www.example.com" data-track-content><p class="piwikContentPiece">Lorem ipsum dolor sit amet</p></a>
+// content name = Unknown
+// content piece = Unknown
+// content target = http://www.example.com
+```
+
+As the content piece element is not an image, video or audio we cannot detect the content automatically. In such a case you have to define the `data-content-piece` attribute and set a value to it. We do not use the text of this element by default since the text might change often resulting in many content pieces, since it can be very long, since it can be translated and therefore results in many different content pieces although it is always the same, since it might contain user specific content and so on.
+
+Better:
+```
+<a href="http://www.example.com" data-track-content><p data-content-piece="My content">Lorem ipsum dolor sit amet...</p></a>
+// content name = My content
+// content piece = My content
+// content target = http://www.example.com
+```
+
+### How do we detect the content name?
+The content name represents a content block which will help you in the Piwik UI to easily identify a specific block.
+
+* The simplest scenario is that you provide us an HTML attribute `data-content-name` with a value anywhere within a content block or in a content block element itself.
+* If there is no such element we will use the value of the content piece in case there is one (if !== Unknown).
+ * A content piece will be usually detected automatically in case the content piece is an image, video or audio element.
+ * If content piece is a URL that is identical to the current domain of the website we will remove the domain from the URL
+* If we do not find a name we will look for a `title` attribute in the content block element.
+* If we do not find a name we will look for a `title` attribute in the content piece element.
+* If we do not find a name we will look for a `title` attribute in the content target element.
+* If we do not find a name we will fall back to "Unknown"
+
+Examples:
+```
+<img src="img-en.jpg" data-track-content data-content-name="Image1"/>
+// content name = Image1
+// content piece = img-en.jpg
+// content target = ""
+```
+
+This example would be the way to go by defining a `data-content-name` attribute anywhere we can easily detect the name of the content.
+
+```
+<img src="img-en.jpg" data-track-content/>
+// content name = img-en.jpg
+// content piece = img-en.jpg
+// content target = ""
+```
+
+If no content name is set, it will default to the content piece in case there is one.
+
+```
+<img src="http://www.example.com/path/img-en.jpg" data-track-content/>
+// content name = /path/img-en.jpg
+// content piece = http://www.example.com/path/img-en.jpg
+// content target = ""
+```
+
+If content piece contains a domain that is the same as the current website's domain we will remove it
+
+```
+<a href="http://www.example.com" data-track-content>Lorem ipsum dolor sit amet...</p></a>
+// content name = Unknown
+// content piece = Unknown
+// content target = http://www.example.com
+```
+
+In case there is no content name, no content piece and no title set anywhere it will default to "Unknown". To get a useful content name you should set either the `data-content-name` or a `title` attribute.
+
+```
+<a href="http://www.example.com" data-track-content title="Block Title"><span title="Inner Title" data-content-piece>Lorem ipsum dolor sit amet...</span></a>
+// content name = Block Title
+// content piece = Unknown
+// content target = http://www.example.com
+```
+
+In case there is no content name and no content piece we will fall back to the `title` attribute of the content block. The `title` attribute of the block element takes precendence over the piece element in this example.
+
+### How do we detect the content target element?
+The content target is the element that we will use to detect the URL of the landing page of the content block. The target element is usually a link or a button element. Generally said the target doesn't have to be a URL it can be anything but in most cases it will be a URL. A target could be for instance also a tab-container
+
+We detect the target element either by the attribute `data-content-target` or by the class `.piwikContentTarget`. If no such element can be found we will fall back to the content block element.
+
+### How do we detect the content target URL?
+
+* The simplest scenario is that you provide us an HTML attribute `data-content-target` with a value anywhere within a content block or in a content block element itself.
+* If there is no such element we will look for an `href` attribute in the target element
+* If there is no such attribute we will use an empty string ""
+
+Examples:
+```
+<a href="http://www.example.com" data-track-content>Click me</a>
+// content name = Unknown
+// content piece = Unknown
+// content target = "http://www.example.com"
+```
+
+As no specific target element is set, we will read the `href` attribute of the content block.
+
+```
+<a onclick="location.href='http://www.example.com'" data-content-target="http://www.example.com" data-track-content>Click me</a>
+// content name = Unknown
+// content piece = Unknown
+// content target = "http://www.example.com"
+```
+
+No `href` attribute is used as the link is executed via javascript. Therefore a `data-content-target` attribute with value has to be specified.
+
+
+```
+<div data-track-content><input type="submit"/></div>
+
+// content name = Unknown
+// content piece = Unknown
+// content target = ""
+```
+
+As there is neither a `data-content-target` attribute nor a `href` attribute we cannot detect the target.
+
+```
+<div data-track-content><input type="submit" data-content-target="http://www.example.com"/></div>
+
+// content name = Unknown
+// content piece = Unknown
+// content target = "http://www.example.com"
+```
+
+As the `data-content-target` attribute is specifically set with a value, we can detect the target URL based on this. Otherwise we could not.
+
+```
+<div data-track-content><a href="http://www.example.com" data-content-target>Click me</a></div>
+<div data-track-content><a href="http://www.example.com" class="piwikContentTarget">Click me</a></div>
+// content name = Unknown
+// content piece = Unknown
+// content target = "http://www.example.com"
+```
+
+As the target element has a `href` attribute we can detect the content target automatically.
+
+### How do we track an interaction automatically?
+
+Interactions can be detected declarative in case the detected target element is an `a` and `area` element with an `href` attribute. If not, you will have to track
+the interaction programmatically, see one of the next sections. We generally treat links to the same page differently than downloads or outlinks.
+
+We use `click` events do detect an interaction with a content. On mobile devices you might want to listen to `touch` events. In this case you may have to disable automatic content interaction tracking see below.
+
+#### Links to the same domain
+In case we detect a link to the same website we will replace the current `href` attribute with a link to the `piwik.php` tracker URL. Whenever a user clicks on such a link we will first send the user to the `piwik.php` of your Piwik installation and then redirect the user from there to the actual page. This click will be tracked as an event. Where the event category is the string `Content`, the event action is the value of the content interaction such as `click` and the event name will be the same as the content name.
+
+If the URL of the replaced `href` attribute changes meanwhile by your code we will respect the new `href` attribute and make sure to update the link with a `piwik.php` URL. Therefore we will add a `click` listener to the element.
+
+Note: The referrer information will get lost when redirecting from piwik.php to your page. If you depend on this you need to disable automatic tracking of interaction see below
+
+If you have added an `href` attribute after we scanned the DOM for content blocks we can not detect this and an interaction won't be tracked.
+
+#### Outlinks and downloads
+Outlinks and downloads are handled as before. If a user clicks on a download or outlink we will track this action using an XHR. Along with the information of this action we will send the information related to the content block. We will not track an additional event for this.
+
+#### Anchor links
+Anchor links will be tracked using an XHR.
+
+### How to prevent the automatic tracking of an interaction?
+
+Maybe you do not want us to track any interaction automatically as explained before.
+To do so you can either set the attribute `data-content-ignoreinteraction` or the CSS class `piwikContentIgnoreInteraction` on the content target element.
+
+Examples
+```
+<a href="http://outlink.example.com" class="piwikTrackContent piwikContentIgnoreInteraction">Add to shopping cart</a>
+<a href="http://outlink.example.com" data-track-content data-content-ignoreinteraction>Add to shopping cart</a>
+<div data-track-content><a href="http://outlink.example.com" data-content-target data-content-ignoreinteraction>Add to shopping cart</a></div>
+```
+
+In all examples we would track the impression automatically but not the interaction.
+
+Note: In single page application you will most likely always have to disable automatic tracking of an interaction as otherwise a page reload and a redirect will happen.
+
+### Putting it all together
+
+A few Examples:
+
+```
+<div data-track-content data-content-name="My Ad">
+ <img src="http://www.example.com/path/xyz.jpg" data-content-piece />
+ <a href="/anylink" data-content-target>Add to shopping cart</a>
+</div>
+// content name = My Ad
+// content piece = http://www.example.com/path/xyz.jpg
+// content target = /anylink
+```
+
+A typical example for a content block that displays an image - which is the content piece - and a call to action link - which is the content target - below.
+We would replace the `href=/anylink` with a link to piwik.php of your Piwik installation which will in turn redirect the user to the actual target to actually track the interaction.
+
+```
+<a href="http://ad.example.com" data-track-content>
+ <img src="http://www.example.com/path/xyz.jpg" data-content-piece />
+</a>
+// content name = /path/xyz.jpg
+// content piece = http://www.example.com/path/xyz.jpg
+// content target = http://ad.example.com
+```
+
+A typical example for a content block that displays a banner ad.
+
+```
+<a href="http://ad.example.com" data-track-content data-content-name="My Ad">
+ Lorem ipsum....
+</a>
+// content name = My Ad
+// content piece = Unknown
+// content target = http://ad.example.com
+```
+
+A typical example for a content block that displays a text ad.
+
+## Tracking the content programmatically
+
+There are several ways to track a content impression and/or interaction manually, semi-automatically and automatically. Please be aware that content impressions will be tracked using bulk tracking which will always send a `POST` request, even if `GET` is configured which is the default.
+
+Note: In case you have link tracking enabled you should call `enableLinkTracking()` before any of those functions.
+
+#### `trackAllContentImpressions()`
-## Tracking the impressions
-Impressions are logically not really events and I don't think it makes sense to use them here. It would also make it harder to analyze events when they are mixed with pieces of content.
+You can use this method to scan the entire DOM for content blocks.
+For each content block we will track a content impression immediately unless you have called `trackVisibleContentImpressions()` before see below.
+
+Note: We will not send an impression of the same content block twice if you call this method multiple times unless `trackPageView()` is called meanwhile. This is useful for single page applications. The "same" content blocks means if a content block has the identical name, piece and target as an already tracked one.
+Note: At this stage we do not exeute this method automatically along with a trackPageView(), we can do this later once we know it works
+
+#### `trackVisibleContentImpressions(checkOnSroll, timeIntervalInMs)`
+If you enable to track only visible content we will only track an impression if a content block is actually visible. With visible we mean the content block has been in the view port, it is actually in the DOM and is not hidden via CSS (opacity, visibility, display, ...).
+
+* Optionally you can tell us to rescan the DOM automatically after each scroll event by passing `checkOnSroll=true`. We will then check whether the previously hidden content blocks are visible now and if so track the impression.
+ * Parameter defaults to boolean `true` if not specified.
+ * As the scroll event is triggered after each pixel scrolling would be very slow when checking for new visible content blocks each time the event is triggered. Instead we are checking every 100ms whether a scroll event was triggered and if so we scan the DOM for new visible content blocks
+ * Note: If a content block is placed within a scrollable element (`overflow: scroll`), we do currently not attach an event in case the user scrolls within this element. This means we would not detect that such an element becomes visible.
+* Optionally you can tell us to rescan the entire DOM for new impressions every X milliseconds by passing `timeIntervalInMs=500` (rescan DOM every 500ms).
+ * If parameter is not set, a default interval sof 750ms will be used.
+ * Rescanning the entire DOM and detecting the visible state of content blocks can take a while depending on the browser and amount of content
+ * We do not really rescan every X milliseconds. We will schedule the next rescan after a previous scan has finished. So if it takes 20ms to scan the DOM and you tell us to rescan every 50ms it can actually take 70ms.
+ * In case your frames per second goes down you might want to increase this value
+* If you do want to only track visible content but not want us to perform any checks automatically you can either call `trackVisibleContentImpressions()` manually at any time to rescan the entire DOM or `trackContentImpressionsWithinNode()` to check only a specific part of the DOM for visible content blocks.
+ * Call `trackVisibleContentImpressions(false, 0)` to initially track only visible content impressions
+ * Call `trackVisibleContentImpressions()` at any time again to rescan the entire DOM for newly visible content blocks or
+ * Call `trackContentImpressionsWithinNode(node)` at any time to rescan only a part of the DOM for newly visible content blocks
+
+Note: You can not change the `checkOnScroll` or `timeIntervalInMs` after this method was called the first time.
+
+#### `(checkOnSroll, timeIntervalInMs)`
+
+Is a shorthand for calling `enableTrackOnlyVisibleContent()` and `trackContentImpressions()`.
+
+#### `trackContentImpressionsWithinNode(domNode, contentTarget)`
+
+You can use this method if you, for instance, dynamically add an element using JavaScript to your DOM after the we have tracked the initial impressions. Calling this method will make sure an impression will be tracked for all content blocks contained within this node.
+
+Example
+```
+var div = $('<div>...<div data-track-content>...</div>...<div data-track-content>...</div></div>');
+$('#id').append(div);
+
+_paq.push(['trackContentImpressionsWithinNode', div[0]]);
+```
+
+We would detect two new content blocks in this example.
+
+Please note: In case you have enabled to only track visible content blocks we will respect this. In case it contains a content block that was already tracked we will not track it again.
+
+#### trackContentInteractionNode(domNode, contentInteraction)
+
+By default we track interactions depending on a click and sometimes we cannot track interactions automatically add all. See "How do we track an interaction automatically?". In case you want to track an interaction manually for instance on a double click or on a form submit you can do this as following:
+
+Example
+```
+anyElement.addEventListener('dblclick', function () {
+ _paq.push(['trackContentInteractionNode', this]);
+});
+form.addEventListener('dblclick', function () {
+ _paq.push(['trackContentInteractionNode', this, 'submittedForm']);
+});
+```
+
+* The passed `domNode` can be any node within a content block or the content block element itself. Nothing will be tracked in case there is no content-block found.
+* The content name and piece will be detected based on the content block
+* Optionally you can set the name of the content interaction. If none is provided the `Unknown` will be used. Could be for instance `click` or `submit`.
+* The interaction will actually only have any effect if an impression was tracked for this content-block
+
+#### `trackContentImpression(contentName, contentPiece, contentTarget)` and `trackContentInteraction(contentName, contentPiece, contentInteraction)`
+You should use those methods only in conjunction together. It is not recommended to use `trackContentInteraction()` after an impression was tracked automatically using on of the other methods as an interaction would only count if you do set the same content name and piece that was used to track the related impression.
+
+Example
+```
+_paq.push(['trackContentImpression', 'Content Name', 'Content Piece', 'http://www.example.com']);
+
+div.addEventListener('click', function () {
+ _paq.push(['trackContentInteraction', 'Content Name', 'Content Piece', 'tabActivated']);
+});
+```
+
+Be aware that each call to one of those two methods will send one request to your Piwik tracker instance. Calling those methods too many times can cause performance problems.
+
+## Tracking Content Impressions API
+
+Content impressions are logically not really events and I don't think it makes sense to use them here. It would also make it harder to analyze events when they are mixed with pieces of content.
+
+* To track a content impression you will need to send the URL parameters `c_n`, `c_p` and `c_t` for name, piece and target along a tracking request.
+* `c_p` for content piece and `c_t` for content target is optional.
+* Multiple content impressions can be sent using bulk tracking for faster performance
+
+## Tracking content interactions API
+Contrary to impressions, clicks are actually events and it would be nice to use events here unless it is not an outlink or download to not lose such tracking data.
+
+* To track a content interaction you will need to send at least the URL parameters `c_n`, `c_p` and `c_i` for name and interaction
+
+We will link interactions to impressions at archiver time.
+
+## Database
+
+* New column `idaction_content_url` and `idaction_content_piece` in `log_link_visit_action`. For name `idaction_name` can be reused?
+
+Could we also reuse `idaction_url` instead of adding new column `idaction_content_url`?
+And we could also store the URL of the page showing the Content in `idaction_url_ref`. (reusing columns is good in this case)
-* New url parameter like `content` which contains serialized JSON like
- [{"c": "content", "n": "name", "u": "url"}, ...]
-* Saving in database?
- * New column `id_action_content_url` and `id_action_content_piece` in `log_link_visit_action`. For name `id_action_name` can be reused?
* Would we need a new column for each piece of content in action table to make archiver work? --> would result in many! columns
* or would we need a new table for each piece of content to make archiver work? --> would be basically a copy of the link_action table and therefore not really makes sense I reckon. Only a lot of work. Logically I am not sure if an impression is actually an "action" so it could make sense
* or would we store the pieces serialized as JSON in a `content` column? I don't know anything about the archiver but I think it wouldn't work at all
* or would we create an action entry for each piece of content? --> yes I think!
-* New Action class that handles type content
-Would a piece of content have maybe custom variables etc?
+Yes it seems most logical to create an action entry for each Content.
-## Tracking the clicks
-Contrary to impressions, clicks are actually events and it would be nice to use events here. Maybe we can link an event with a piece of content?
+## Thoughts on piwik.js
+* We need to find all dom nodes having css class or html attribute.
+ * Options for this is traversing over each node and checking for everything -> CSS selectors cannot be used on all browsers and it might be slow therefore -> maybe lot of work to make it cross browser compatible
+ * https://github.com/fabiomcosta/micro-selector --> tiny selector library but does not support attributes
+ * http://sizzlejs.com/ Used by jQuery & co but like 30kb (compressed + gzipped 4kb). Has way too many features we don't need
+ * https://github.com/ded/qwery Doesn't support IE8 and a few others, no support for attribute selector
+ * https://github.com/padolsey/satisfy 2.4KB and probably outdated
+ * https://github.com/digitarald/sly very tiny and many features but last commit 3 years old
+ * https://github.com/alpha123/Jaguar >10KB and last commit 2 years old
+ * As we don't need many features we could implement it ourselves but probably needs a lot of cross-browser testing which I wanted to avoid. We'd only start with `querySelectorAll()` maybe. Brings also incredible [performance benefits](http://jsperf.com/jquery-vs-native-selector-and-element-style/2) (2-10 faster than jQuery) but there might be problems see http://stackoverflow.com/questions/11503534/jquery-vs-document-queryselectorall, http://jsfiddle.net/QdMc5/ and http://ejohn.org/blog/thoughts-on-queryselectorall/
-## Piwik.js
+## Reports
+Nothing special here I think. We would probably automatically detect the type of content (image, video, text, sound, ...) depending on the content eg in case it ends with [.jpg, .png, .gif] it could be recognized as image content and show a banner in the report.
+## TODO
+* Redirect to URL in piwik.php only if trusted host
+* Would content impressions be tracked in overlay session?
+ * Overlay session should not trigger a content impression
+* Test scroll event in ie9, ie10, ie11, opera
+* Run JS tests in ff3, ie7, ie8, ie9, ie10, ie11, opera, android, iphone, ms phone, safari
+* Write PHP tests
+* Show images on hover in report
+* Write JS tests for interaction click event listener
+* Better position #contenttest in JS tests to make isNodeVisible work on all platforms
+* makeNodesUnique should return same result on all browsers. It does currently but different order which is no problem at all but makes test work in all browsers
+* When a user clicks on an interaction, we should check whether we have already tracked the impression as the content is visible now. If not tracked before, we should track the impression as well
+ * There can be a scroll or timer event that detects the same content became visible as well. This would not be a problem since we do not track same content block twice
+ * Maybe v2
+* We should reorder _paq links to make sure enableLinkTracking is called before any trackContent*() calls
-## Reports
-Nothing special here I think. We would probably automatically detect the type of content (image, video, text, sound, ...) depending on the content eg in case it ends with .jpg it could be recognized as image content and show a banner in the report.
+## V2:
+* "note: as a user, I see that piwik.php redirects is the default "click tracking" solution, but I want to be able to disable this piwik.php redirect and instead use the link tracking 500ms solution."
+ * BTW I could implement this with a few lines
+* When we listen to scroll events we currently do not detect if user scrolls the entire page, not if within a div
+ * We need to check all parent elements of a content block whether it is scrollable and if so connect an event to this
+* We could have in V2 or V3 an attribute data-content-interaction="submit" to tell Piwik to listen to the submit event and to use "submit" as an interaction
+
+
+## Open questions
+
+* We might need a trackContentImpressionsOfContentBlock() to make sure to track a content block in case we do not detect it correct that a node is visible now
+* Referrer gets lost when using piwik.php
+* Single page applications will always want to disable interactions as redirect would not fit into their concept!!!
+
+
+## Notes:
+* User can decide to manually setup the proper redirect URL via piwik.php?rec=1&idsite=1&clickurl={$URL_HERE}&....
+ * Currently, the user would also have to add event URL parameters and make sure to send the correct name and piece to match an impression.
+ * If the user does not use any data-content-* attributes this is very likely to fail since the auto detected content name and piece can easily change and tracking would be broken
+ * The only advantage I see would be that we even track clicks if we haven't added a click listener to replace the URL yet (for instance before DOM loaded)
+* and/or maybe we can replace the href="" directly within the DOM so right click, middle click, shift click are also tracked
+ * sounds ok to me, have implement it like this. Only problem is in case a replaced link changes later for instance based on a visitor form selection.
+ * To prevent this I added a click event on top of it and in case it does not start with configTrackerUrl I will build it again
+ * it might be bad for SEO
+ * FYI: outlinks/downloads will be still tracked as it is done currently for simplicity (500ms) so we are talking here only about internal links that are not anchor links (starting with "#"). Those would not be tracked
+ * http://outlink.example.org --> not replaced -> handled the old way
+ * #target --> not replaced -> handled the old way. In single page application users have to call trackWhatever again
+ * note to myself: They should be able to parse a node that we parse for all content as you maybe wanna parse only the replaced ajax content. maybe v2
+ * index.php, /foo/bar --> will be directly replaced by piwik.php in case clickNode (element having clickAttribute/Class) is an "A" element
+ * Need to think about possible XSS. If an attacker can set href attributes on that website and we replace attribute based on that but should be ok ...
+* FYI: Piwik Mobile displays currently only one metric, so people won't see impressions and number of interactions or ratio next to each other
+* If user wants to track only visible content we'll need to wait until the websites load (not DOMContentLoaded) event is triggered. Otherwise CSS might be not be applied yet and we cannot detect whether node is actually visible. Downside: Some websites might take > 10 seconds until this event is triggered. Depending on how good they are developed. During this time the user might be already no longer on that page or might have already scrolled to somewhere else.
+* If user wants to track all content impressions (not only the visible ones) we'd probably have to wait until at least DOMContentLoaded event is triggered
+* If the load event takes like 10 seconds later, the user has maybe already scrolled and seen some content blocks but we cannot detect... so considering viewport we need to assume all above the deepest scrollpoint was seen
+
+
+## Answered Questions
+1. Can the same content piece have different names / targets? Can the same content name have different targets/pieces?
+
+Maybe the unique ID of a Content can be the { Content name + Content piece }. Then we would recommend users to set the same Content target for a given tuple { Content name, Content piece }.
+
+I hope it makes sense to assume this tuple will have always same Content target by design?
+
+In this case I would modify questionas as follows:
+ * Can the same content piece have different names? Yes (eg. a banner image is used by different Content names),
+ * Can the same { content name, content piece } have different targets? Yes, but it's not recommended: Piwik will only aggregate one content target value. (eg. keep the latest content target value tracked for this { content name, content piece } tuple on a given day)
+
+
+2. Are we always assuming the "conversion" or "target URL" is caused by a click or can it be a hover or drag/drop, ...? For a general solution we might want to assume it can be anything?
+ * In this case we would also rename or need an additional attribute or whatever to [data-trackclick] etc.
+
+When drag and dropping there is a click needed by user, so maybe `data-trackclick` would still be OK in this case?
+if you have better naming idea feel free to suggest. Or maybe you have other use cases besides clicks and drag n drop?
+
+3. Would a piece of content - such as a banner - have maybe custom variables etc?
+
+It would be nice to be able to set custom variables to Contents.
+
+One possible use case is A/B testing. Maybe it would make sense to use Contents plugin for A/B testing. We could measure Content name = Experiment_TopMenu, Content piece = http://host/a.jpg. In a custom variable we would store "experiment => B". Then we would know that the given experiment is called Experiment_TopMenu and is defined by the image and that it's the variant B being served.
+
+4. How do we present the data in a report? Similar to events with second dimensions? Probably depends on 1)
+
+Second dimension would be really powerful to have (as per suggestion in 1)). It would let user see different banner images for a given banner name.
+
+There would be two reports:
+ * First dimension: Banner Names, Second dimension: Banner pieces
+ * First dimension: Banner pieces, Second dimension: Banner names
+
+(It's a bit simpler than Events because we don't need to switch the second dimension.)
+
+5. I assume there can be nested content in theory. A piece of content that contains another piece of content. In this case we have to be careful when automatically picking name, target, ...
+
+Nested content makes sense (users will do this). How would it work when several contents are nested?
+Note: we don't need to handle this case in MVP but maybe worth thinking about it.
+
+6. FYI: We would probably also need an attribute like data-target="$target" and/or the possiblity for data-trackclick="$target" since not all links might be defined via href but onclick javascript links. See next section
+
++1
+
+7. HTML Attributes always take precendence over css classes or the other way around (if both defined)? I think attributes should take precendence which I think is also defined in the spec
+
+attributes take precedence over CSS classes
+
+8. Do we need to support IE7 and older? Firefox 3 and older?
+
+Support modern browsers is enough (ie. last 2 years or so?).
+
+9. "Maybe we could automatically detect when such element becomes visible, and send the Impression event automatically"
+ * I think we can detect whether a specific content was visible at a specific time in most cases but not necessarily automatically. We would have to check the DOM for this every few ms (in case of Carousel) and we'd also have to attach to events like scrolling etc. This can make other peoples website slow, especially on mobile but even browser. Website owners usually want to achieve 60fps to have animations and scrolling smooth and they usually invest a lot of time to achieve this. So it has to an opt-in if at all
+
+in case user tags an element with `data-noautotrack` then it's already a kind of opt-in by user, so maybe in this case it's acceptable to check whether element tagged is visible, eg. every 500 ms ?
+
+ * Do I understand it right that we send an impression only if it is visible?
+
+Yes.
+
+ * We'd probably have to offer a mode to send all banners independend of visibility
+
+Sounds good: this would make Contents plugin more generic.
+
+
+ * We'd probably have to offer a mode to rescan all banners again at a certain time and only track those content pieces now that were not visibile before but are now
+
+In ticket I wrote `function trackContentPieces() that will let users re-scan the page for Content pieces when DOM has changed.` but maybe instead the function should be called `rescanPageForContents` ?
+
+ * We'd probably have to offer a method to pass a DOM node and track it independent of visibility (useful for instance in case of carousel when the website owner already knows a specific content piece is visible now but does not want to use expensive events for this)
+
+if I understand correctly it would make life of JS developers easier by providing nicer APIs to them?
+
+so +1
+
+ * We'd maybe have to offer a mode where we are trying to detect automatically when an impression becomes visible and send it
+
+I think that should be the default mode, ie. on page load we detect impressions, and then we also attach to events like scrolling to check ie. every 500ms whether a given Contents is visible. Would that be work?
+
+10. FYI: "you may add a CSS class or attribute to the link element to track" => It could be also a span, a div or something else
+11. FYI: There is way to much magic how content-name is found and it is neither predicatble nor understandable by users, I will simplify this and rather require users to set specific attributes! See next section
+OK
-## Order of implementation
-Of course everything goes kinda in parallel:
+12. FYI: We need to define how a content piece is defined in markup since it can be anything (was something like piwik-banner before) see next section
-* Make tracking of impressions work
-* Make a report work
-* Make tracking the clicks work
-* Piwik.js and tagging of the content of pieces \ No newline at end of file
+13. Why do we track an event for an interaction? Which is with the currently implementation done only on a click to an internal URL anyway... does it actually make sense? I mean there will be pageview -> content + event action -> same pageview after redirect. We would track same information 3 times
+It makes actually no sense and I will remove it again. It makes no sense because:
+* We would currently only track links to the same website as an event (as only there piwik.php is used), we could use it for other links as well but why...
+* A click to an internal page of the same website is simply no event per se. Also to an outlink or download... it is not an event
+* As it is possible that we would add many different EventNames (= ContentNames) and EventActions (=ContentInteraction) it would maybe make it harder for some users to analyze their event names/actions that they use for other things
+* The tracked content will be already displayed in the content report anyway, why displaying the same data in 2 reports (events and contents or actually even 3 reports as a pageview will be later tracked as well). There is no value in it
+* ... \ No newline at end of file
diff --git a/plugins/Actions/Archiver.php b/plugins/Actions/Archiver.php
index 14d4214744..407019061c 100644
--- a/plugins/Actions/Archiver.php
+++ b/plugins/Actions/Archiver.php
@@ -123,7 +123,7 @@ class Archiver extends \Piwik\Plugin\Archiver
*/
public static function getWhereClauseActionIsNotEvent()
{
- return " AND log_link_visit_action.idaction_event_category IS NULL";
+ return " AND log_link_visit_action.idaction_event_category IS NULL AND log_link_visit_action.idaction_content_name IS NULL";
}
/**
diff --git a/plugins/Contents/API.php b/plugins/Contents/API.php
new file mode 100644
index 0000000000..95dc283fe1
--- /dev/null
+++ b/plugins/Contents/API.php
@@ -0,0 +1,62 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ *
+ */
+namespace Piwik\Plugins\Contents;
+
+use Piwik\Archive;
+use Piwik\DataTable;
+use Piwik\DataTable\Row;
+use Piwik\Metrics;
+use Piwik\Piwik;
+
+/**
+ * API for plugin Contents
+ *
+ * @method static \Piwik\Plugins\Contents\API getInstance()
+ */
+class API extends \Piwik\Plugin\API
+{
+ public function getContentNames($idSite, $period, $date, $segment = false, $idSubtable = false)
+ {
+ return $this->getDataTable(__FUNCTION__, $idSite, $period, $date, $segment, false, $idSubtable);
+ }
+
+ public function getContentPieces($idSite, $period, $date, $segment = false, $idSubtable = false)
+ {
+ return $this->getDataTable(__FUNCTION__, $idSite, $period, $date, $segment, false, $idSubtable);
+ }
+
+ private function getDataTable($name, $idSite, $period, $date, $segment, $expanded, $idSubtable)
+ {
+ Piwik::checkUserHasViewAccess($idSite);
+ $recordName = Dimensions::getRecordNameForAction($name);
+ $dataTable = Archive::getDataTableFromArchive($recordName, $idSite, $period, $date, $segment, $expanded, $idSubtable);
+ $this->filterDataTable($dataTable);
+ return $dataTable;
+ }
+
+ /**
+ * @param DataTable $dataTable
+ */
+ private function filterDataTable($dataTable)
+ {
+ $dataTable->filter('Sort', array(Metrics::INDEX_NB_VISITS));
+
+ $dataTable->queueFilter('ReplaceColumnNames');
+ $dataTable->queueFilter('ReplaceSummaryRowLabel');
+ $dataTable->filter(function (DataTable $table) {
+ $row = $table->getRowFromLabel(Archiver::CONTENT_PIECE_NOT_SET);
+ if ($row) {
+ $row->setColumn('label', Piwik::translate('General_NotDefined', Piwik::translate('Contents_ContentPiece')));
+ }
+ });
+
+ // Content interaction rate = interactions / impressions
+ $dataTable->queueFilter('ColumnCallbackAddColumnPercentage', array('interaction_rate', 'nb_interactions', 'nb_impressions', $precision = 2));
+ }
+}
diff --git a/plugins/Contents/Actions/ActionContent.php b/plugins/Contents/Actions/ActionContent.php
new file mode 100644
index 0000000000..b9edc14a29
--- /dev/null
+++ b/plugins/Contents/Actions/ActionContent.php
@@ -0,0 +1,55 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ *
+ */
+
+namespace Piwik\Plugins\Contents\Actions;
+
+use Piwik\Tracker\Action;
+use Piwik\Tracker\Request;
+use Piwik\Tracker;
+
+/**
+ * A content is composed of a name, an actual piece of content, and optionally a target.
+ */
+class ActionContent extends Action
+{
+ public function __construct(Request $request)
+ {
+ parent::__construct(Action::TYPE_CONTENT, $request);
+
+ $url = $request->getParam('url');
+ $this->setActionUrl($url);
+ }
+
+ public static function shouldHandle(Request $request)
+ {
+ $name = $request->getParam('c_n');
+ $interaction = $request->getParam('c_i'); // if interaction is set we want it to be for instance an outlink, download, ...
+
+ return !empty($name) && empty($interaction);
+ }
+
+ protected function getActionsToLookup()
+ {
+ return array(
+ 'idaction_url' => $this->getUrlAndType()
+ );
+ }
+
+ // Do not track this Event URL as Entry/Exit Page URL (leave the existing entry/exit)
+ public function getIdActionUrlForEntryAndExitIds()
+ {
+ return false;
+ }
+
+ // Do not track this Event Name as Entry/Exit Page Title (leave the existing entry/exit)
+ public function getIdActionNameForEntryAndExitIds()
+ {
+ return false;
+ }
+}
diff --git a/plugins/Contents/Archiver.php b/plugins/Contents/Archiver.php
new file mode 100644
index 0000000000..55668b4c3a
--- /dev/null
+++ b/plugins/Contents/Archiver.php
@@ -0,0 +1,312 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ *
+ */
+namespace Piwik\Plugins\Contents;
+
+use Piwik\DataTable;
+use Piwik\Metrics;
+use Piwik\Plugins\Actions\ArchivingHelper;
+use Piwik\RankingQuery;
+
+/**
+ * Processing reports for Contents
+ */
+class Archiver extends \Piwik\Plugin\Archiver
+{
+ const CONTENTS_PIECE_NAME_RECORD_NAME = 'Contents_piece_name';
+ const CONTENTS_NAME_PIECE_RECORD_NAME = 'Contents_name_piece';
+ const CONTENT_TARGET_NOT_SET = 'Piwik_ContentTargetNotSet';
+ const CONTENT_PIECE_NOT_SET = 'Piwik_ContentPieceNotSet';
+
+ /**
+ * @var DataArray[]
+ */
+ protected $arrays = array();
+ protected $metadata = array();
+
+ public function __construct($processor)
+ {
+ parent::__construct($processor);
+ $this->columnToSortByBeforeTruncation = Metrics::INDEX_NB_VISITS;
+ $this->maximumRowsInDataTable = ArchivingHelper::$maximumRowsInDataTableLevelZero;
+ $this->maximumRowsInSubDataTable = ArchivingHelper::$maximumRowsInSubDataTable;
+ }
+
+ private function getRecordToDimensions()
+ {
+ return array(
+ self::CONTENTS_PIECE_NAME_RECORD_NAME => array('contentPiece', 'contentName'),
+ self::CONTENTS_NAME_PIECE_RECORD_NAME => array('contentName', 'contentPiece')
+ );
+ }
+
+ public function aggregateMultipleReports()
+ {
+ $dataTableToSum = $this->getRecordNames();
+ $this->getProcessor()->aggregateDataTableRecords($dataTableToSum, $this->maximumRowsInDataTable, $this->maximumRowsInSubDataTable, $this->columnToSortByBeforeTruncation);
+ }
+
+ private function getRecordNames()
+ {
+ $mapping = $this->getRecordToDimensions();
+ return array_keys($mapping);
+ }
+
+ public function aggregateDayReport()
+ {
+ $this->aggregateDayImpressions();
+ $this->aggregateDayInteractions();
+ $this->insertDayReports();
+ }
+
+ private function aggregateDayImpressions()
+ {
+ $select = "
+ log_action_content_piece.name as contentPiece,
+ log_action_content_target.name as contentTarget,
+ log_action_content_name.name as contentName,
+
+ count(distinct log_link_visit_action.idvisit) as `" . Metrics::INDEX_NB_VISITS . "`,
+ count(distinct log_link_visit_action.idvisitor) as `" . Metrics::INDEX_NB_UNIQ_VISITORS . "`,
+ count(*) as `" . Metrics::INDEX_CONTENT_NB_IMPRESSIONS . "`
+ ";
+
+ $from = array(
+ "log_link_visit_action",
+ array(
+ "table" => "log_action",
+ "tableAlias" => "log_action_content_piece",
+ "joinOn" => "log_link_visit_action.idaction_content_piece = log_action_content_piece.idaction"
+ ),
+ array(
+ "table" => "log_action",
+ "tableAlias" => "log_action_content_target",
+ "joinOn" => "log_link_visit_action.idaction_content_target = log_action_content_target.idaction"
+ ),
+ array(
+ "table" => "log_action",
+ "tableAlias" => "log_action_content_name",
+ "joinOn" => "log_link_visit_action.idaction_content_name = log_action_content_name.idaction"
+ )
+ );
+
+ $where = "log_link_visit_action.server_time >= ?
+ AND log_link_visit_action.server_time <= ?
+ AND log_link_visit_action.idsite = ?
+ AND log_link_visit_action.idaction_content_name IS NOT NULL
+ AND log_link_visit_action.idaction_content_interaction IS NULL";
+
+ $groupBy = "log_action_content_piece.idaction,
+ log_action_content_target.idaction,
+ log_action_content_name.idaction";
+
+ $orderBy = "`" . Metrics::INDEX_NB_VISITS . "` DESC";
+
+ $rankingQueryLimit = ArchivingHelper::getRankingQueryLimit();
+ $rankingQuery = null;
+ if ($rankingQueryLimit > 0) {
+ $rankingQuery = new RankingQuery($rankingQueryLimit);
+ $rankingQuery->setOthersLabel(DataTable::LABEL_SUMMARY_ROW);
+ $rankingQuery->addLabelColumn(array('contentPiece', 'contentTarget', 'contentName'));
+ $rankingQuery->addColumn(array(Metrics::INDEX_NB_UNIQ_VISITORS));
+ $rankingQuery->addColumn(array(Metrics::INDEX_CONTENT_NB_IMPRESSIONS, Metrics::INDEX_NB_VISITS), 'sum');
+ }
+
+ $resultSet = $this->archiveDayQueryProcess($select, $from, $where, $orderBy, $groupBy, $rankingQuery);
+
+ while ($row = $resultSet->fetch()) {
+ $this->aggregateImpressionRow($row);
+ }
+ }
+
+ private function aggregateDayInteractions()
+ {
+ $select = "
+ log_action_content_name.name as contentName,
+ log_action_content_interaction.name as contentInteraction,
+ log_action_content_piece.name as contentPiece,
+
+ count(*) as `" . Metrics::INDEX_CONTENT_NB_INTERACTIONS . "`
+ ";
+
+ $from = array(
+ "log_link_visit_action",
+ array(
+ "table" => "log_action",
+ "tableAlias" => "log_action_content_piece",
+ "joinOn" => "log_link_visit_action.idaction_content_piece = log_action_content_piece.idaction"
+ ),
+ array(
+ "table" => "log_action",
+ "tableAlias" => "log_action_content_interaction",
+ "joinOn" => "log_link_visit_action.idaction_content_interaction = log_action_content_interaction.idaction"
+ ),
+ array(
+ "table" => "log_action",
+ "tableAlias" => "log_action_content_name",
+ "joinOn" => "log_link_visit_action.idaction_content_name = log_action_content_name.idaction"
+ )
+ );
+
+ $where = "log_link_visit_action.server_time >= ?
+ AND log_link_visit_action.server_time <= ?
+ AND log_link_visit_action.idsite = ?
+ AND log_link_visit_action.idaction_content_name IS NOT NULL
+ AND log_link_visit_action.idaction_content_interaction IS NOT NULL";
+
+ $groupBy = "log_action_content_piece.idaction,
+ log_action_content_interaction.idaction,
+ log_action_content_name.idaction";
+
+ $orderBy = "`" . Metrics::INDEX_CONTENT_NB_INTERACTIONS . "` DESC";
+
+ $rankingQueryLimit = ArchivingHelper::getRankingQueryLimit();
+ $rankingQuery = null;
+ if ($rankingQueryLimit > 0) {
+ $rankingQuery = new RankingQuery($rankingQueryLimit);
+ $rankingQuery->setOthersLabel(DataTable::LABEL_SUMMARY_ROW);
+ $rankingQuery->addLabelColumn(array('contentPiece', 'contentInteraction', 'contentName'));
+ $rankingQuery->addColumn(array(Metrics::INDEX_CONTENT_NB_INTERACTIONS), 'sum');
+ }
+
+ $resultSet = $this->archiveDayQueryProcess($select, $from, $where, $orderBy, $groupBy, $rankingQuery);
+
+ while ($row = $resultSet->fetch()) {
+ $this->aggregateInteractionRow($row);
+ }
+ }
+
+ private function archiveDayQueryProcess($select, $from, $where, $orderBy, $groupBy, RankingQuery $rankingQuery)
+ {
+ // get query with segmentation
+ $query = $this->getLogAggregator()->generateQuery($select, $from, $where, $groupBy, $orderBy);
+
+ // apply ranking query
+ if ($rankingQuery) {
+ $query['sql'] = $rankingQuery->generateQuery($query['sql']);
+ }
+
+ // get result
+ $resultSet = $this->getLogAggregator()->getDb()->query($query['sql'], $query['bind']);
+
+ if ($resultSet === false) {
+ return;
+ }
+
+ return $resultSet;
+ }
+
+ /**
+ * Records the daily datatables
+ */
+ private function insertDayReports()
+ {
+ foreach ($this->arrays as $recordName => $dataArray) {
+
+ $dataTable = $dataArray->asDataTable();
+
+ foreach ($dataTable->getRows() as $row) {
+ $label = $row->getColumn('label');
+
+ if (!empty($this->metadata[$label])) {
+ foreach ($this->metadata[$label] as $name => $value) {
+ $row->addMetadata($name, $value);
+ }
+ }
+
+ }
+ $blob = $dataTable->getSerialized(
+ $this->maximumRowsInDataTable,
+ $this->maximumRowsInSubDataTable,
+ $this->columnToSortByBeforeTruncation);
+ $this->getProcessor()->insertBlobRecord($recordName, $blob);
+ }
+ }
+
+ /**
+ * @param string $name
+ * @return DataArray
+ */
+ private function getDataArray($name)
+ {
+ if (empty($this->arrays[$name])) {
+ $this->arrays[$name] = new DataArray();
+ }
+
+ return $this->arrays[$name];
+ }
+
+ private function aggregateImpressionRow($row)
+ {
+ foreach ($this->getRecordToDimensions() as $record => $dimensions) {
+ $dataArray = $this->getDataArray($record);
+
+ $mainDimension = $dimensions[0];
+ $mainLabel = $row[$mainDimension];
+
+ // content piece is optional
+ if ($mainDimension == 'contentPiece'
+ && empty($mainLabel)) {
+ $mainLabel = self::CONTENT_PIECE_NOT_SET;
+ }
+
+ $dataArray->sumMetricsImpressions($mainLabel, $row);
+ $this->rememberMetadataForRow($row, $mainLabel);
+
+ $subDimension = $dimensions[1];
+ $subLabel = $row[$subDimension];
+
+ if (empty($subLabel)) {
+ continue;
+ }
+
+ // content piece is optional
+ if ($subDimension == 'contentPiece'
+ && empty($subLabel)) {
+ $subLabel = self::CONTENT_PIECE_NOT_SET;
+ }
+
+ $dataArray->sumMetricsContentsImpressionPivot($mainLabel, $subLabel, $row);
+ }
+ }
+
+ private function aggregateInteractionRow($row)
+ {
+ foreach ($this->getRecordToDimensions() as $record => $dimensions) {
+ $dataArray = $this->getDataArray($record);
+
+ $mainDimension = $dimensions[0];
+ $mainLabel = $row[$mainDimension];
+
+ $dataArray->sumMetricsInteractions($mainLabel, $row);
+
+ $subDimension = $dimensions[1];
+ $subLabel = $row[$subDimension];
+
+ if (empty($subLabel)) {
+ continue;
+ }
+
+ $dataArray->sumMetricsContentsInteractionPivot($mainLabel, $subLabel, $row);
+ }
+ }
+
+ private function rememberMetadataForRow($row, $mainLabel)
+ {
+ $this->metadata[$mainLabel] = array();
+
+ $target = $row['contentTarget'];
+ if (empty($target)) {
+ $target = Archiver::CONTENT_TARGET_NOT_SET;
+ }
+
+ // there can be many different targets
+ $this->metadata[$mainLabel]['contentTarget'] = $target;
+ }
+
+}
diff --git a/plugins/Contents/Columns/ContentInteraction.php b/plugins/Contents/Columns/ContentInteraction.php
new file mode 100644
index 0000000000..c5e49fd0e7
--- /dev/null
+++ b/plugins/Contents/Columns/ContentInteraction.php
@@ -0,0 +1,56 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ *
+ */
+namespace Piwik\Plugins\Contents\Columns;
+
+use Piwik\Piwik;
+use Piwik\Plugin\Dimension\ActionDimension;
+use Piwik\Plugins\Actions\Segment;
+use Piwik\Tracker\Action;
+use Piwik\Tracker\Request;
+
+class ContentInteraction extends ActionDimension
+{
+ protected $columnName = 'idaction_content_interaction';
+ protected $columnType = 'INTEGER(10) UNSIGNED DEFAULT NULL';
+
+ protected function configureSegments()
+ {
+ $segment = new Segment();
+ $segment->setSegment('contentInteraction');
+ $segment->setName('Contents_Interaction');
+ $this->addSegment($segment);
+ }
+
+ public function getName()
+ {
+ return Piwik::translate('Contents_Interaction');
+ }
+
+ public function getActionId()
+ {
+ return Action::TYPE_CONTENT_INTERACTION;
+ }
+
+ public function onLookupAction(Request $request, Action $action)
+ {
+ $interaction = $request->getParam('c_i');
+
+ if (empty($interaction)) {
+ return false;
+ }
+
+ $interaction = trim($interaction);
+
+ if (strlen($interaction) > 0) {
+ return $interaction;
+ }
+
+ return false;
+ }
+} \ No newline at end of file
diff --git a/plugins/Contents/Columns/ContentName.php b/plugins/Contents/Columns/ContentName.php
new file mode 100644
index 0000000000..feac1092c2
--- /dev/null
+++ b/plugins/Contents/Columns/ContentName.php
@@ -0,0 +1,57 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ *
+ */
+namespace Piwik\Plugins\Contents\Columns;
+
+use Piwik\Piwik;
+use Piwik\Plugin\Dimension\ActionDimension;
+use Piwik\Plugins\Contents\Actions\ActionContent;
+use Piwik\Plugins\Actions\Segment;
+use Piwik\Tracker\Action;
+use Piwik\Tracker\Request;
+
+class ContentName extends ActionDimension
+{
+ protected $columnName = 'idaction_content_name';
+ protected $columnType = 'INTEGER(10) UNSIGNED DEFAULT NULL';
+
+ protected function configureSegments()
+ {
+ $segment = new Segment();
+ $segment->setSegment('contentName');
+ $segment->setName('Contents_ContentName');
+ $this->addSegment($segment);
+ }
+
+ public function getName()
+ {
+ return Piwik::translate('Contents_ContentName');
+ }
+
+ public function getActionId()
+ {
+ return Action::TYPE_CONTENT_NAME;
+ }
+
+ public function onLookupAction(Request $request, Action $action)
+ {
+ $contentName = $request->getParam('c_n');
+
+ if (empty($contentName)) {
+ return false;
+ }
+
+ $contentName = trim($contentName);
+
+ if (strlen($contentName) > 0) {
+ return $contentName;
+ }
+
+ return false;
+ }
+} \ No newline at end of file
diff --git a/plugins/Contents/Columns/ContentPiece.php b/plugins/Contents/Columns/ContentPiece.php
new file mode 100644
index 0000000000..bf0d64837e
--- /dev/null
+++ b/plugins/Contents/Columns/ContentPiece.php
@@ -0,0 +1,57 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ *
+ */
+namespace Piwik\Plugins\Contents\Columns;
+
+use Piwik\Piwik;
+use Piwik\Plugin\Dimension\ActionDimension;
+use Piwik\Plugins\Actions\Segment;
+use Piwik\Plugins\Contents\Actions\ActionContent;
+use Piwik\Tracker\Action;
+use Piwik\Tracker\Request;
+
+class ContentPiece extends ActionDimension
+{
+ protected $columnName = 'idaction_content_piece';
+ protected $columnType = 'INTEGER(10) UNSIGNED DEFAULT NULL';
+
+ protected function configureSegments()
+ {
+ $segment = new Segment();
+ $segment->setSegment('contentPiece');
+ $segment->setName('Contents_ContentPiece');
+ $this->addSegment($segment);
+ }
+
+ public function getName()
+ {
+ return Piwik::translate('Contents_ContentPiece');
+ }
+
+ public function getActionId()
+ {
+ return Action::TYPE_CONTENT_PIECE;
+ }
+
+ public function onLookupAction(Request $request, Action $action)
+ {
+ $contentPiece = $request->getParam('c_p');
+
+ if (empty($contentPiece)) {
+ return false;
+ }
+
+ $contentPiece = trim($contentPiece);
+
+ if (strlen($contentPiece) > 0) {
+ return $contentPiece;
+ }
+
+ return false;
+ }
+} \ No newline at end of file
diff --git a/plugins/Contents/Columns/ContentTarget.php b/plugins/Contents/Columns/ContentTarget.php
new file mode 100644
index 0000000000..17abb9f1a5
--- /dev/null
+++ b/plugins/Contents/Columns/ContentTarget.php
@@ -0,0 +1,56 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ *
+ */
+namespace Piwik\Plugins\Contents\Columns;
+
+use Piwik\Piwik;
+use Piwik\Plugin\Dimension\ActionDimension;
+use Piwik\Plugins\Actions\Segment;
+use Piwik\Plugins\Contents\Actions\ActionContent;
+use Piwik\Tracker\Action;
+use Piwik\Tracker\Request;
+
+class ContentTarget extends ActionDimension
+{
+ protected $columnName = 'idaction_content_target';
+ protected $columnType = 'INTEGER(10) UNSIGNED DEFAULT NULL';
+
+ protected function configureSegments()
+ {
+ $segment = new Segment();
+ $segment->setSegment('contentTarget');
+ $segment->setName('Contents_ContentTarget');
+ $this->addSegment($segment);
+ }
+
+ public function getName()
+ {
+ return Piwik::translate('Contents_ContentTarget');
+ }
+
+ public function getActionId()
+ {
+ return Action::TYPE_CONTENT_TARGET;
+ }
+
+ public function onLookupAction(Request $request, Action $action)
+ {
+ if (!($action instanceof ActionContent)) {
+ return false;
+ }
+
+ $contentTarget = $request->getParam('c_t');
+ $contentTarget = trim($contentTarget);
+
+ if (strlen($contentTarget) > 0) {
+ return $contentTarget;
+ }
+
+ return false;
+ }
+} \ No newline at end of file
diff --git a/plugins/Contents/Contents.php b/plugins/Contents/Contents.php
new file mode 100644
index 0000000000..0fd5993742
--- /dev/null
+++ b/plugins/Contents/Contents.php
@@ -0,0 +1,30 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ *
+ */
+namespace Piwik\Plugins\Contents;
+
+class Contents extends \Piwik\Plugin
+{
+ /**
+ * @see Piwik\Plugin::getListHooksRegistered
+ */
+ public function getListHooksRegistered()
+ {
+ return array(
+ 'Metrics.getDefaultMetricTranslations' => 'addMetricTranslations'
+ );
+ }
+
+ public function addMetricTranslations(&$translations)
+ {
+ $translations['nb_impressions'] = 'Contents_Impressions';
+ $translations['nb_interactions'] = 'Contents_Interactions';
+ $translations['interaction_rate'] = 'Contents_InteractionRate';
+ }
+
+}
diff --git a/plugins/Contents/Controller.php b/plugins/Contents/Controller.php
new file mode 100644
index 0000000000..daf319bb53
--- /dev/null
+++ b/plugins/Contents/Controller.php
@@ -0,0 +1,51 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ *
+ */
+namespace Piwik\Plugins\Contents;
+
+use Piwik\Plugin\Report;
+use Piwik\Plugins\Contents\Reports\GetContentNames;
+use Piwik\Plugins\Contents\Reports\GetContentPieces;
+use Piwik\View;
+
+class Controller extends \Piwik\Plugin\Controller
+{
+
+ public function index()
+ {
+ $reportsView = new View\ReportsByDimension('Contents');
+
+ /** @var \Piwik\Plugin\Report[] $reports */
+ $reports = array(new GetContentNames(), new GetContentPieces());
+
+ foreach($reports as $report) {
+ $reportsView->addReport(
+ $report->getCategory(),
+ $report->getName(),
+ 'Contents.menu' . ucfirst($report->getAction())
+ );
+ }
+
+ return $reportsView->render();
+ }
+
+ public function menuGetContentNames()
+ {
+ $report = new GetContentNames();
+
+ return View::singleReport($report->getName(), $report->render());
+ }
+
+ public function menuGetContentPieces()
+ {
+ $report = new GetContentPieces();
+
+ return View::singleReport($report->getName(), $report->render());
+ }
+
+}
diff --git a/plugins/Contents/DataArray.php b/plugins/Contents/DataArray.php
new file mode 100644
index 0000000000..98e7b0543c
--- /dev/null
+++ b/plugins/Contents/DataArray.php
@@ -0,0 +1,76 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ *
+ */
+namespace Piwik\Plugins\Contents;
+
+use Piwik\Metrics;
+
+/**
+ * The DataArray is a data structure used to aggregate datasets,
+ * ie. sum arrays made of rows made of columns,
+ * data from the logs is stored in a DataArray before being converted in a DataTable
+ *
+ */
+
+class DataArray extends \Piwik\DataArray
+{
+ public function sumMetricsImpressions($label, $row)
+ {
+ if (!isset($this->data[$label])) {
+ $this->data[$label] = self::makeEmptyContentsRow();
+ }
+ $this->doSumContentsImpressionMetrics($row, $this->data[$label]);
+ }
+
+ public function sumMetricsInteractions($label, $row)
+ {
+ if (!isset($this->data[$label])) {
+ return; // do igonre interactions that do not have an impression
+ }
+ $this->doSumContentsInteractionMetrics($row, $this->data[$label]);
+ }
+
+ protected static function makeEmptyContentsRow()
+ {
+ return array(
+ Metrics::INDEX_NB_UNIQ_VISITORS => 0,
+ Metrics::INDEX_NB_VISITS => 0,
+ Metrics::INDEX_CONTENT_NB_IMPRESSIONS => 0,
+ Metrics::INDEX_CONTENT_NB_INTERACTIONS => 0
+ );
+ }
+
+ protected function doSumContentsImpressionMetrics($newRowToAdd, &$oldRowToUpdate)
+ {
+ $oldRowToUpdate[Metrics::INDEX_NB_VISITS] += $newRowToAdd[Metrics::INDEX_NB_VISITS];
+ $oldRowToUpdate[Metrics::INDEX_NB_UNIQ_VISITORS] += $newRowToAdd[Metrics::INDEX_NB_UNIQ_VISITORS];
+ $oldRowToUpdate[Metrics::INDEX_CONTENT_NB_IMPRESSIONS] += $newRowToAdd[Metrics::INDEX_CONTENT_NB_IMPRESSIONS];
+ }
+
+ protected function doSumContentsInteractionMetrics($newRowToAdd, &$oldRowToUpdate)
+ {
+ $oldRowToUpdate[Metrics::INDEX_CONTENT_NB_INTERACTIONS] += $newRowToAdd[Metrics::INDEX_CONTENT_NB_INTERACTIONS];
+ }
+
+ public function sumMetricsContentsImpressionPivot($parentLabel, $label, $row)
+ {
+ if (!isset($this->dataTwoLevels[$parentLabel][$label])) {
+ $this->dataTwoLevels[$parentLabel][$label] = $this->makeEmptyContentsRow();
+ }
+ $this->doSumContentsImpressionMetrics($row, $this->dataTwoLevels[$parentLabel][$label]);
+ }
+
+ public function sumMetricsContentsInteractionPivot($parentLabel, $label, $row)
+ {
+ if (!isset($this->dataTwoLevels[$parentLabel][$label])) {
+ return; // do igonre interactions that do not have an impression
+ }
+ $this->doSumContentsInteractionMetrics($row, $this->dataTwoLevels[$parentLabel][$label]);
+ }
+
+}
diff --git a/plugins/Contents/Dimensions.php b/plugins/Contents/Dimensions.php
new file mode 100644
index 0000000000..ce74022924
--- /dev/null
+++ b/plugins/Contents/Dimensions.php
@@ -0,0 +1,33 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ *
+ */
+namespace Piwik\Plugins\Contents;
+
+class Dimensions
+{
+ public static function getRecordNameForAction($apiMethod)
+ {
+ $apiToRecord = array(
+ 'getContentNames' => Archiver::CONTENTS_NAME_PIECE_RECORD_NAME,
+ 'getContentPieces' => Archiver::CONTENTS_PIECE_NAME_RECORD_NAME
+ );
+
+ return $apiToRecord[$apiMethod];
+ }
+
+ public static function getSubtableLabelForApiMethod($apiMethod)
+ {
+ $labelToMethod = array(
+ 'getContentNames' => 'Contents_ContentPiece',
+ 'getContentPieces' => 'Contents_ContentName'
+ );
+
+ return $labelToMethod[$apiMethod];
+ }
+
+}
diff --git a/plugins/Contents/Menu.php b/plugins/Contents/Menu.php
new file mode 100644
index 0000000000..2e0d25bb7f
--- /dev/null
+++ b/plugins/Contents/Menu.php
@@ -0,0 +1,24 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ *
+ */
+namespace Piwik\Plugins\Contents;
+
+use Piwik\Menu\MenuReporting;
+
+/**
+ * This class allows you to add, remove or rename menu items.
+ * To configure a menu (such as Admin Menu, Reporting Menu, User Menu...) simply call the corresponding methods as
+ * described in the API-Reference http://developer.piwik.org/api-reference/Piwik/Menu/MenuAbstract
+ */
+class Menu extends \Piwik\Plugin\Menu
+{
+ public function configureReportingMenu(MenuReporting $menu)
+ {
+ $menu->addActionsItem('Contents_Contents', array('module' => 'Contents', 'action' => 'index'), $orderId = 40);
+ }
+}
diff --git a/plugins/Contents/README.md b/plugins/Contents/README.md
new file mode 100644
index 0000000000..dc2db01387
--- /dev/null
+++ b/plugins/Contents/README.md
@@ -0,0 +1,18 @@
+# Piwik Contents Plugin
+
+## Description
+
+Add your plugin description here.
+
+## FAQ
+
+__My question?__
+My answer
+
+## Changelog
+
+Here goes the changelog text.
+
+## Support
+
+Please direct any feedback to ... \ No newline at end of file
diff --git a/plugins/Contents/Reports/Base.php b/plugins/Contents/Reports/Base.php
new file mode 100644
index 0000000000..f73fe6f2ab
--- /dev/null
+++ b/plugins/Contents/Reports/Base.php
@@ -0,0 +1,53 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ *
+ */
+namespace Piwik\Plugins\Contents\Reports;
+
+use Piwik\Columns\Dimension;
+use Piwik\Common;
+use Piwik\Piwik;
+use Piwik\Plugin\Report;
+use Piwik\Plugin\ViewDataTable;
+use Piwik\Plugins\Contents\Dimensions;
+
+abstract class Base extends Report
+{
+ protected function init()
+ {
+ $this->category = 'General_Actions';
+ }
+
+ /**
+ * Here you can configure how your report should be displayed. For instance whether your report supports a search
+ * etc. You can also change the default request config. For instance change how many rows are displayed by default.
+ *
+ * @param ViewDataTable $view
+ */
+ public function configureView(ViewDataTable $view)
+ {
+ if (!empty($this->dimension)) {
+ $view->config->addTranslations(array('label' => $this->dimension->getName()));
+ }
+
+ $view->config->columns_to_display = array_merge(array('label'), $this->metrics, $this->processedMetrics);
+ $view->requestConfig->filter_sort_column = 'nb_impressions';
+
+ if ($this->hasSubtableId()) {
+ $apiMethod = $view->requestConfig->getApiMethodToRequest();
+ $label = Dimensions::getSubtableLabelForApiMethod($apiMethod);
+ $view->config->addTranslation('label', Piwik::translate($label));
+ }
+ }
+
+ private function hasSubtableId()
+ {
+ $subtable = Common::getRequestVar('idSubtable', false, 'integer');
+
+ return !empty($subtable);
+ }
+}
diff --git a/plugins/Contents/Reports/GetContentNames.php b/plugins/Contents/Reports/GetContentNames.php
new file mode 100644
index 0000000000..1a946f3a21
--- /dev/null
+++ b/plugins/Contents/Reports/GetContentNames.php
@@ -0,0 +1,38 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ *
+ */
+namespace Piwik\Plugins\Contents\Reports;
+
+use Piwik\Piwik;
+use Piwik\Plugin\Report;
+use Piwik\Plugins\Contents\Columns\ContentName;
+use Piwik\View;
+
+/**
+ * This class defines a new report.
+ *
+ * See {@link http://developer.piwik.org/api-reference/Piwik/Plugin/Report} for more information.
+ */
+class GetContentNames extends Base
+{
+ protected function init()
+ {
+ parent::init();
+
+ $this->name = Piwik::translate('Contents_ContentName');
+ $this->dimension = null;
+ // TODO $this->documentation = Piwik::translate('ContentsDocumentation');
+ $this->dimension = new ContentName();
+ $this->order = 35;
+ $this->actionToLoadSubTables = 'getContentNames';
+
+ $this->widgetTitle = 'Contents_ContentName';
+ $this->metrics = array('nb_impressions', 'nb_interactions');
+ $this->processedMetrics = array('interaction_rate');
+ }
+}
diff --git a/plugins/Contents/Reports/GetContentPieces.php b/plugins/Contents/Reports/GetContentPieces.php
new file mode 100644
index 0000000000..4601430ee1
--- /dev/null
+++ b/plugins/Contents/Reports/GetContentPieces.php
@@ -0,0 +1,39 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ *
+ */
+namespace Piwik\Plugins\Contents\Reports;
+
+use Piwik\Piwik;
+use Piwik\Plugin\Report;
+use Piwik\Plugins\Contents\Columns\ContentPiece;
+use Piwik\View;
+
+/**
+ * This class defines a new report.
+ *
+ * See {@link http://developer.piwik.org/api-reference/Piwik/Plugin/Report} for more information.
+ */
+class GetContentPieces extends Base
+{
+ protected function init()
+ {
+ parent::init();
+
+ $this->name = Piwik::translate('Contents_ContentPiece');
+ $this->dimension = null;
+ // TODO $this->documentation = Piwik::translate('ContentsDocumentation');
+ $this->dimension = new ContentPiece();
+ $this->order = 36;
+ $this->actionToLoadSubTables = 'getContentPieces';
+
+ $this->widgetTitle = 'Contents_ContentPiece';
+
+ $this->metrics = array('nb_impressions', 'nb_interactions');
+ $this->processedMetrics = array('interaction_rate');
+ }
+}
diff --git a/plugins/Contents/lang/en.json b/plugins/Contents/lang/en.json
new file mode 100644
index 0000000000..87b13eec1c
--- /dev/null
+++ b/plugins/Contents/lang/en.json
@@ -0,0 +1,12 @@
+{
+ "Contents":{
+ "Impressions":"Impressions",
+ "Interactions":"Interactions",
+ "Interaction":"Interaction",
+ "InteractionRate":"Interaction Rate",
+ "ContentName":"Content Name",
+ "ContentPiece":"Content Piece",
+ "ContentTarget":"Content Target",
+ "Contents":"Contents"
+ }
+} \ No newline at end of file
diff --git a/plugins/Contents/plugin.json b/plugins/Contents/plugin.json
new file mode 100644
index 0000000000..dbfd2b145f
--- /dev/null
+++ b/plugins/Contents/plugin.json
@@ -0,0 +1,13 @@
+{
+ "name": "Contents",
+ "version": "0.1.0",
+ "description": "Content and banner tracking",
+ "theme": false,
+ "authors": [
+ {
+ "name": "Piwik",
+ "email": "",
+ "homepage": ""
+ }
+ ]
+} \ No newline at end of file
diff --git a/plugins/Contents/screenshots/.gitkeep b/plugins/Contents/screenshots/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/plugins/Contents/screenshots/.gitkeep
diff --git a/plugins/Events/Reports/Base.php b/plugins/Events/Reports/Base.php
index c452120dc6..2caf9b6a75 100644
--- a/plugins/Events/Reports/Base.php
+++ b/plugins/Events/Reports/Base.php
@@ -8,10 +8,7 @@
*/
namespace Piwik\Plugins\Events\Reports;
-use Piwik\Piwik;
-use Piwik\Plugin\ViewDataTable;
use Piwik\Plugins\Events\API;
-use Piwik\Plugins\Events\Events;
abstract class Base extends \Piwik\Plugin\Report
{
diff --git a/tests/javascript/assets/qunit.css b/tests/javascript/assets/qunit.css
index 6229ea8323..8bb62f5dd4 100644
--- a/tests/javascript/assets/qunit.css
+++ b/tests/javascript/assets/qunit.css
@@ -1,241 +1,237 @@
-/**
- * QUnit v1.12.0 - A JavaScript Unit Testing Framework
+/*!
+ * QUnit 1.15.0
+ * http://qunitjs.com/
*
- * http://qunitjs.com
- *
- * Copyright 2012 jQuery Foundation and other contributors
- * Released under the MIT license.
+ * Copyright 2014 jQuery Foundation and other contributors
+ * Released under the MIT license
* http://jquery.org/license
+ *
+ * Date: 2014-08-08T16:00Z
*/
/** Font Family and Sizes */
#qunit-tests, #qunit-header, #qunit-banner, #qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult {
- font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial, sans-serif;
+ font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial, sans-serif;
}
#qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult, #qunit-tests li { font-size: small; }
#qunit-tests { font-size: smaller; }
+
/** Resets */
#qunit-tests, #qunit-header, #qunit-banner, #qunit-userAgent, #qunit-testresult, #qunit-modulefilter {
- margin: 0;
- padding: 0;
+ margin: 0;
+ padding: 0;
}
+
/** Header */
#qunit-header {
- padding: 0.5em 0 0.5em 1em;
+ padding: 0.5em 0 0.5em 1em;
- color: #8699a4;
- background-color: #0d3349;
+ color: #8699A4;
+ background-color: #0D3349;
- font-size: 1.5em;
- line-height: 1em;
- font-weight: normal;
+ font-size: 1.5em;
+ line-height: 1em;
+ font-weight: 400;
- border-radius: 5px 5px 0 0;
- -moz-border-radius: 5px 5px 0 0;
- -webkit-border-top-right-radius: 5px;
- -webkit-border-top-left-radius: 5px;
+ border-radius: 5px 5px 0 0;
}
#qunit-header a {
- text-decoration: none;
- color: #c2ccd1;
+ text-decoration: none;
+ color: #C2CCD1;
}
#qunit-header a:hover,
#qunit-header a:focus {
- color: #fff;
+ color: #FFF;
}
#qunit-testrunner-toolbar label {
- display: inline-block;
- padding: 0 .5em 0 .1em;
+ display: inline-block;
+ padding: 0 0.5em 0 0.1em;
}
#qunit-banner {
- height: 5px;
+ height: 5px;
}
#qunit-testrunner-toolbar {
- padding: 0.5em 0 0.5em 2em;
- color: #5E740B;
- background-color: #eee;
- overflow: hidden;
+ padding: 0.5em 1em 0.5em 1em;
+ color: #5E740B;
+ background-color: #EEE;
+ overflow: hidden;
}
#qunit-userAgent {
- padding: 0.5em 0 0.5em 2.5em;
- background-color: #2b81af;
- color: #fff;
- text-shadow: rgba(0, 0, 0, 0.5) 2px 2px 1px;
+ padding: 0.5em 1em 0.5em 1em;
+ background-color: #2B81AF;
+ color: #FFF;
+ text-shadow: rgba(0, 0, 0, 0.5) 2px 2px 1px;
}
#qunit-modulefilter-container {
- float: right;
+ float: right;
}
/** Tests: Pass/Fail */
#qunit-tests {
- list-style-position: inside;
+ list-style-position: inside;
}
#qunit-tests li {
- padding: 0.4em 0.5em 0.4em 2.5em;
- border-bottom: 1px solid #fff;
- list-style-position: inside;
+ padding: 0.4em 1em 0.4em 1em;
+ border-bottom: 1px solid #FFF;
+ list-style-position: inside;
}
#qunit-tests.hidepass li.pass, #qunit-tests.hidepass li.running {
- display: none;
+ display: none;
}
#qunit-tests li strong {
- cursor: pointer;
+ cursor: pointer;
}
#qunit-tests li a {
- padding: 0.5em;
- color: #c2ccd1;
- text-decoration: none;
+ padding: 0.5em;
+ color: #C2CCD1;
+ text-decoration: none;
}
#qunit-tests li a:hover,
#qunit-tests li a:focus {
- color: #000;
+ color: #000;
}
#qunit-tests li .runtime {
- float: right;
- font-size: smaller;
+ float: right;
+ font-size: smaller;
}
.qunit-assert-list {
- margin-top: 0.5em;
- padding: 0.5em;
+ margin-top: 0.5em;
+ padding: 0.5em;
- background-color: #fff;
+ background-color: #FFF;
- border-radius: 5px;
- -moz-border-radius: 5px;
- -webkit-border-radius: 5px;
+ border-radius: 5px;
}
.qunit-collapsed {
- display: none;
+ display: none;
}
#qunit-tests table {
- border-collapse: collapse;
- margin-top: .2em;
+ border-collapse: collapse;
+ margin-top: 0.2em;
}
#qunit-tests th {
- text-align: right;
- vertical-align: top;
- padding: 0 .5em 0 0;
+ text-align: right;
+ vertical-align: top;
+ padding: 0 0.5em 0 0;
}
#qunit-tests td {
- vertical-align: top;
+ vertical-align: top;
}
#qunit-tests pre {
- margin: 0;
- white-space: pre-wrap;
- word-wrap: break-word;
+ margin: 0;
+ white-space: pre-wrap;
+ word-wrap: break-word;
}
#qunit-tests del {
- background-color: #e0f2be;
- color: #374e0c;
- text-decoration: none;
+ background-color: #E0F2BE;
+ color: #374E0C;
+ text-decoration: none;
}
#qunit-tests ins {
- background-color: #ffcaca;
- color: #500;
- text-decoration: none;
+ background-color: #FFCACA;
+ color: #500;
+ text-decoration: none;
}
/*** Test Counts */
-#qunit-tests b.counts { color: black; }
+#qunit-tests b.counts { color: #000; }
#qunit-tests b.passed { color: #5E740B; }
#qunit-tests b.failed { color: #710909; }
#qunit-tests li li {
- padding: 5px;
- background-color: #fff;
- border-bottom: none;
- list-style-position: inside;
+ padding: 5px;
+ background-color: #FFF;
+ border-bottom: none;
+ list-style-position: inside;
}
/*** Passing Styles */
#qunit-tests li li.pass {
- color: #3c510c;
- background-color: #fff;
- border-left: 10px solid #C6E746;
+ color: #3C510C;
+ background-color: #FFF;
+ border-left: 10px solid #C6E746;
}
#qunit-tests .pass { color: #528CE0; background-color: #D2E0E6; }
#qunit-tests .pass .test-name { color: #366097; }
#qunit-tests .pass .test-actual,
-#qunit-tests .pass .test-expected { color: #999999; }
+#qunit-tests .pass .test-expected { color: #999; }
#qunit-banner.qunit-pass { background-color: #C6E746; }
/*** Failing Styles */
#qunit-tests li li.fail {
- color: #710909;
- background-color: #fff;
- border-left: 10px solid #EE5757;
- white-space: pre;
+ color: #710909;
+ background-color: #FFF;
+ border-left: 10px solid #EE5757;
+ white-space: pre;
}
#qunit-tests > li:last-child {
- border-radius: 0 0 5px 5px;
- -moz-border-radius: 0 0 5px 5px;
- -webkit-border-bottom-right-radius: 5px;
- -webkit-border-bottom-left-radius: 5px;
+ border-radius: 0 0 5px 5px;
}
-#qunit-tests .fail { color: #000000; background-color: #EE5757; }
+#qunit-tests .fail { color: #000; background-color: #EE5757; }
#qunit-tests .fail .test-name,
-#qunit-tests .fail .module-name { color: #000000; }
+#qunit-tests .fail .module-name { color: #000; }
#qunit-tests .fail .test-actual { color: #EE5757; }
-#qunit-tests .fail .test-expected { color: green; }
+#qunit-tests .fail .test-expected { color: #008000; }
#qunit-banner.qunit-fail { background-color: #EE5757; }
+
/** Result */
#qunit-testresult {
- padding: 0.5em 0.5em 0.5em 2.5em;
+ padding: 0.5em 1em 0.5em 1em;
- color: #2b81af;
- background-color: #D2E0E6;
+ color: #2B81AF;
+ background-color: #D2E0E6;
- border-bottom: 1px solid white;
+ border-bottom: 1px solid #FFF;
}
#qunit-testresult .module-name {
- font-weight: bold;
+ font-weight: 700;
}
/** Fixture */
#qunit-fixture {
- position: absolute;
- top: -10000px;
- left: -10000px;
- width: 1000px;
- height: 1000px;
-}
+ position: absolute;
+ top: -10000px;
+ left: -10000px;
+ width: 1000px;
+ height: 1000px;
+} \ No newline at end of file
diff --git a/tests/javascript/assets/qunit.js b/tests/javascript/assets/qunit.js
index bd7fa61f9b..06280f5985 100644
--- a/tests/javascript/assets/qunit.js
+++ b/tests/javascript/assets/qunit.js
@@ -1,2058 +1,1686 @@
-/**
- * QUnit v1.12.0 - A JavaScript Unit Testing Framework
+/*!
+ * QUnit 1.15.0
+ * http://qunitjs.com/
*
- * http://qunitjs.com
+ * Copyright 2014 jQuery Foundation and other contributors
+ * Released under the MIT license
+ * http://jquery.org/license
*
- * Copyright 2013 jQuery Foundation and other contributors
- * Released under the MIT license.
- * https://jquery.org/license/
+ * Date: 2014-08-08T16:00Z
*/
(function( window ) {
-var QUnit,
- assert,
- config,
- onErrorFnPrev,
- testId = 0,
- fileName = (sourceFromStacktrace( 0 ) || "" ).replace(/(:\d+)+\)?/, "").replace(/.+\//, ""),
- toString = Object.prototype.toString,
- hasOwn = Object.prototype.hasOwnProperty,
- // Keep a local reference to Date (GH-283)
- Date = window.Date,
- setTimeout = window.setTimeout,
- defined = {
- setTimeout: typeof window.setTimeout !== "undefined",
- sessionStorage: (function() {
- var x = "qunit-test-string";
- try {
- sessionStorage.setItem( x, x );
- sessionStorage.removeItem( x );
- return true;
- } catch( e ) {
- return false;
- }
- }())
- },
- /**
- * Provides a normalized error string, correcting an issue
- * with IE 7 (and prior) where Error.prototype.toString is
- * not properly implemented
- *
- * Based on http://es5.github.com/#x15.11.4.4
- *
- * @param {String|Error} error
- * @return {String} error message
- */
- errorString = function( error ) {
- var name, message,
- errorString = error.toString();
- if ( errorString.substring( 0, 7 ) === "[object" ) {
- name = error.name ? error.name.toString() : "Error";
- message = error.message ? error.message.toString() : "";
- if ( name && message ) {
- return name + ": " + message;
- } else if ( name ) {
- return name;
- } else if ( message ) {
- return message;
- } else {
- return "Error";
- }
- } else {
- return errorString;
- }
- },
- /**
- * Makes a clone of an object using only Array or Object as base,
- * and copies over the own enumerable properties.
- *
- * @param {Object} obj
- * @return {Object} New object with only the own properties (recursively).
- */
- objectValues = function( obj ) {
- // Grunt 0.3.x uses an older version of jshint that still has jshint/jshint#392.
- /*jshint newcap: false */
- var key, val,
- vals = QUnit.is( "array", obj ) ? [] : {};
- for ( key in obj ) {
- if ( hasOwn.call( obj, key ) ) {
- val = obj[key];
- vals[key] = val === Object(val) ? objectValues(val) : val;
- }
- }
- return vals;
- };
-
-function Test( settings ) {
- extend( this, settings );
- this.assertions = [];
- this.testNumber = ++Test.count;
-}
-
-Test.count = 0;
-
-Test.prototype = {
- init: function() {
- var a, b, li,
- tests = id( "qunit-tests" );
-
- if ( tests ) {
- b = document.createElement( "strong" );
- b.innerHTML = this.nameHtml;
-
- // `a` initialized at top of scope
- a = document.createElement( "a" );
- a.innerHTML = "Rerun";
- a.href = QUnit.url({ testNumber: this.testNumber });
-
- li = document.createElement( "li" );
- li.appendChild( b );
- li.appendChild( a );
- li.className = "running";
- li.id = this.id = "qunit-test-output" + testId++;
-
- tests.appendChild( li );
- }
- },
- setup: function() {
- if (
- // Emit moduleStart when we're switching from one module to another
- this.module !== config.previousModule ||
- // They could be equal (both undefined) but if the previousModule property doesn't
- // yet exist it means this is the first test in a suite that isn't wrapped in a
- // module, in which case we'll just emit a moduleStart event for 'undefined'.
- // Without this, reporters can get testStart before moduleStart which is a problem.
- !hasOwn.call( config, "previousModule" )
- ) {
- if ( hasOwn.call( config, "previousModule" ) ) {
- runLoggingCallbacks( "moduleDone", QUnit, {
- name: config.previousModule,
- failed: config.moduleStats.bad,
- passed: config.moduleStats.all - config.moduleStats.bad,
- total: config.moduleStats.all
- });
- }
- config.previousModule = this.module;
- config.moduleStats = { all: 0, bad: 0 };
- runLoggingCallbacks( "moduleStart", QUnit, {
- name: this.module
- });
- }
-
- config.current = this;
-
- this.testEnvironment = extend({
- setup: function() {},
- teardown: function() {}
- }, this.moduleTestEnvironment );
-
- this.started = +new Date();
- runLoggingCallbacks( "testStart", QUnit, {
- name: this.testName,
- module: this.module
- });
-
- /*jshint camelcase:false */
-
- /**
- * Expose the current test environment.
- *
- * @deprecated since 1.12.0: Use QUnit.config.current.testEnvironment instead.
- */
- QUnit.current_testEnvironment = this.testEnvironment;
-
- /*jshint camelcase:true */
-
- if ( !config.pollution ) {
- saveGlobal();
- }
- if ( config.notrycatch ) {
- this.testEnvironment.setup.call( this.testEnvironment, QUnit.assert );
- return;
- }
- try {
- this.testEnvironment.setup.call( this.testEnvironment, QUnit.assert );
- } catch( e ) {
- QUnit.pushFailure( "Setup failed on " + this.testName + ": " + ( e.message || e ), extractStacktrace( e, 1 ) );
- }
- },
- run: function() {
- config.current = this;
-
- var running = id( "qunit-testresult" );
-
- if ( running ) {
- running.innerHTML = "Running: <br/>" + this.nameHtml;
- }
-
- if ( this.async ) {
- QUnit.stop();
- }
-
- this.callbackStarted = +new Date();
-
- if ( config.notrycatch ) {
- this.callback.call( this.testEnvironment, QUnit.assert );
- this.callbackRuntime = +new Date() - this.callbackStarted;
- return;
- }
-
- try {
- this.callback.call( this.testEnvironment, QUnit.assert );
- this.callbackRuntime = +new Date() - this.callbackStarted;
- } catch( e ) {
- this.callbackRuntime = +new Date() - this.callbackStarted;
-
- QUnit.pushFailure( "Died on test #" + (this.assertions.length + 1) + " " + this.stack + ": " + ( e.message || e ), extractStacktrace( e, 0 ) );
- // else next test will carry the responsibility
- saveGlobal();
-
- // Restart the tests if they're blocking
- if ( config.blocking ) {
- QUnit.start();
- }
- }
- },
- teardown: function() {
- config.current = this;
- if ( config.notrycatch ) {
- if ( typeof this.callbackRuntime === "undefined" ) {
- this.callbackRuntime = +new Date() - this.callbackStarted;
- }
- this.testEnvironment.teardown.call( this.testEnvironment, QUnit.assert );
- return;
- } else {
- try {
- this.testEnvironment.teardown.call( this.testEnvironment, QUnit.assert );
- } catch( e ) {
- QUnit.pushFailure( "Teardown failed on " + this.testName + ": " + ( e.message || e ), extractStacktrace( e, 1 ) );
- }
- }
- checkPollution();
- },
- finish: function() {
- config.current = this;
- if ( config.requireExpects && this.expected === null ) {
- QUnit.pushFailure( "Expected number of assertions to be defined, but expect() was not called.", this.stack );
- } else if ( this.expected !== null && this.expected !== this.assertions.length ) {
- QUnit.pushFailure( "Expected " + this.expected + " assertions, but " + this.assertions.length + " were run", this.stack );
- } else if ( this.expected === null && !this.assertions.length ) {
- QUnit.pushFailure( "Expected at least one assertion, but none were run - call expect(0) to accept zero assertions.", this.stack );
- }
-
- var i, assertion, a, b, time, li, ol,
- test = this,
- good = 0,
- bad = 0,
- tests = id( "qunit-tests" );
-
- this.runtime = +new Date() - this.started;
- config.stats.all += this.assertions.length;
- config.moduleStats.all += this.assertions.length;
-
- if ( tests ) {
- ol = document.createElement( "ol" );
- ol.className = "qunit-assert-list";
-
- for ( i = 0; i < this.assertions.length; i++ ) {
- assertion = this.assertions[i];
-
- li = document.createElement( "li" );
- li.className = assertion.result ? "pass" : "fail";
- li.innerHTML = assertion.message || ( assertion.result ? "okay" : "failed" );
- ol.appendChild( li );
-
- if ( assertion.result ) {
- good++;
- } else {
- bad++;
- config.stats.bad++;
- config.moduleStats.bad++;
- }
- }
-
- // store result when possible
- if ( QUnit.config.reorder && defined.sessionStorage ) {
- if ( bad ) {
- sessionStorage.setItem( "qunit-test-" + this.module + "-" + this.testName, bad );
- } else {
- sessionStorage.removeItem( "qunit-test-" + this.module + "-" + this.testName );
- }
- }
-
- if ( bad === 0 ) {
- addClass( ol, "qunit-collapsed" );
- }
-
- // `b` initialized at top of scope
- b = document.createElement( "strong" );
- b.innerHTML = this.nameHtml + " <b class='counts'>(<b class='failed'>" + bad + "</b>, <b class='passed'>" + good + "</b>, " + this.assertions.length + ")</b>";
-
- addEvent(b, "click", function() {
- var next = b.parentNode.lastChild,
- collapsed = hasClass( next, "qunit-collapsed" );
- ( collapsed ? removeClass : addClass )( next, "qunit-collapsed" );
- });
-
- addEvent(b, "dblclick", function( e ) {
- var target = e && e.target ? e.target : window.event.srcElement;
- if ( target.nodeName.toLowerCase() === "span" || target.nodeName.toLowerCase() === "b" ) {
- target = target.parentNode;
- }
- if ( window.location && target.nodeName.toLowerCase() === "strong" ) {
- window.location = QUnit.url({ testNumber: test.testNumber });
- }
- });
-
- // `time` initialized at top of scope
- time = document.createElement( "span" );
- time.className = "runtime";
- time.innerHTML = this.runtime + " ms";
-
- // `li` initialized at top of scope
- li = id( this.id );
- li.className = bad ? "fail" : "pass";
- li.removeChild( li.firstChild );
- a = li.firstChild;
- li.appendChild( b );
- li.appendChild( a );
- li.appendChild( time );
- li.appendChild( ol );
-
- } else {
- for ( i = 0; i < this.assertions.length; i++ ) {
- if ( !this.assertions[i].result ) {
- bad++;
- config.stats.bad++;
- config.moduleStats.bad++;
- }
- }
- }
-
- runLoggingCallbacks( "testDone", QUnit, {
- name: this.testName,
- module: this.module,
- failed: bad,
- passed: this.assertions.length - bad,
- total: this.assertions.length,
- duration: this.runtime
- });
-
- QUnit.reset();
-
- config.current = undefined;
- },
-
- queue: function() {
- var bad,
- test = this;
-
- synchronize(function() {
- test.init();
- });
- function run() {
- // each of these can by async
- synchronize(function() {
- test.setup();
- });
- synchronize(function() {
- test.run();
- });
- synchronize(function() {
- test.teardown();
- });
- synchronize(function() {
- test.finish();
- });
- }
-
- // `bad` initialized at top of scope
- // defer when previous test run passed, if storage is available
- bad = QUnit.config.reorder && defined.sessionStorage &&
- +sessionStorage.getItem( "qunit-test-" + this.module + "-" + this.testName );
-
- if ( bad ) {
- run();
- } else {
- synchronize( run, true );
- }
- }
-};
+ var QUnit,
+ config,
+ onErrorFnPrev,
+ fileName = ( sourceFromStacktrace( 0 ) || "" ).replace( /(:\d+)+\)?/, "" ).replace( /.+\//, "" ),
+ toString = Object.prototype.toString,
+ hasOwn = Object.prototype.hasOwnProperty,
+ // Keep a local reference to Date (GH-283)
+ Date = window.Date,
+ now = Date.now || function() {
+ return new Date().getTime();
+ },
+ setTimeout = window.setTimeout,
+ clearTimeout = window.clearTimeout,
+ defined = {
+ document: typeof window.document !== "undefined",
+ setTimeout: typeof window.setTimeout !== "undefined",
+ sessionStorage: (function() {
+ var x = "qunit-test-string";
+ try {
+ sessionStorage.setItem( x, x );
+ sessionStorage.removeItem( x );
+ return true;
+ } catch ( e ) {
+ return false;
+ }
+ }())
+ },
+ /**
+ * Provides a normalized error string, correcting an issue
+ * with IE 7 (and prior) where Error.prototype.toString is
+ * not properly implemented
+ *
+ * Based on http://es5.github.com/#x15.11.4.4
+ *
+ * @param {String|Error} error
+ * @return {String} error message
+ */
+ errorString = function( error ) {
+ var name, message,
+ errorString = error.toString();
+ if ( errorString.substring( 0, 7 ) === "[object" ) {
+ name = error.name ? error.name.toString() : "Error";
+ message = error.message ? error.message.toString() : "";
+ if ( name && message ) {
+ return name + ": " + message;
+ } else if ( name ) {
+ return name;
+ } else if ( message ) {
+ return message;
+ } else {
+ return "Error";
+ }
+ } else {
+ return errorString;
+ }
+ },
+
+ isNode = function (obj) {
+ if (typeof Node === 'object') {
+ return obj instanceof Node
+ }
+
+ return obj && typeof obj === 'object' && typeof obj.nodeType === 'number' && typeof obj.nodeName=== 'string';
+ },
+
+ isElement = function (obj) {
+ if (typeof HTMLElement === 'object') {
+ return obj instanceof HTMLElement
+ }
+
+ return obj && typeof obj === 'object' && obj !== null && obj.nodeType === 1 && typeof obj.nodeName==='string';
+ },
+
+ /**
+ * Makes a clone of an object using only Array or Object as base,
+ * and copies over the own enumerable properties.
+ *
+ * @param {Object} obj
+ * @return {Object} New object with only the own properties (recursively).
+ */
+ objectValues = function( obj ) {
+ var key, val,
+ vals = QUnit.is( "array", obj ) ? [] : {};
+ for ( key in obj ) {
+ if ( hasOwn.call( obj, key ) ) {
+ val = obj[ key ];
+ vals[ key ] = val === Object( val ) && !isNode(val) && !isElement(val) ? objectValues( val ) : val;
+ }
+ }
+ return vals;
+ };
// Root QUnit object.
// `QUnit` initialized at top of scope
-QUnit = {
-
- // call on start of module test to prepend name to all tests
- module: function( name, testEnvironment ) {
- config.currentModule = name;
- config.currentModuleTestEnvironment = testEnvironment;
- config.modules[name] = true;
- },
-
- asyncTest: function( testName, expected, callback ) {
- if ( arguments.length === 2 ) {
- callback = expected;
- expected = null;
- }
-
- QUnit.test( testName, expected, callback, true );
- },
-
- test: function( testName, expected, callback, async ) {
- var test,
- nameHtml = "<span class='test-name'>" + escapeText( testName ) + "</span>";
-
- if ( arguments.length === 2 ) {
- callback = expected;
- expected = null;
- }
-
- if ( config.currentModule ) {
- nameHtml = "<span class='module-name'>" + escapeText( config.currentModule ) + "</span>: " + nameHtml;
- }
-
- test = new Test({
- nameHtml: nameHtml,
- testName: testName,
- expected: expected,
- async: async,
- callback: callback,
- module: config.currentModule,
- moduleTestEnvironment: config.currentModuleTestEnvironment,
- stack: sourceFromStacktrace( 2 )
- });
-
- if ( !validTest( test ) ) {
- return;
- }
-
- test.queue();
- },
-
- // Specify the number of expected assertions to guarantee that failed test (no assertions are run at all) don't slip through.
- expect: function( asserts ) {
- if (arguments.length === 1) {
- config.current.expected = asserts;
- } else {
- return config.current.expected;
- }
- },
-
- start: function( count ) {
- // QUnit hasn't been initialized yet.
- // Note: RequireJS (et al) may delay onLoad
- if ( config.semaphore === undefined ) {
- QUnit.begin(function() {
- // This is triggered at the top of QUnit.load, push start() to the event loop, to allow QUnit.load to finish first
- setTimeout(function() {
- QUnit.start( count );
- });
- });
- return;
- }
-
- config.semaphore -= count || 1;
- // don't start until equal number of stop-calls
- if ( config.semaphore > 0 ) {
- return;
- }
- // ignore if start is called more often then stop
- if ( config.semaphore < 0 ) {
- config.semaphore = 0;
- QUnit.pushFailure( "Called start() while already started (QUnit.config.semaphore was 0 already)", null, sourceFromStacktrace(2) );
- return;
- }
- // A slight delay, to avoid any current callbacks
- if ( defined.setTimeout ) {
- setTimeout(function() {
- if ( config.semaphore > 0 ) {
- return;
- }
- if ( config.timeout ) {
- clearTimeout( config.timeout );
- }
-
- config.blocking = false;
- process( true );
- }, 13);
- } else {
- config.blocking = false;
- process( true );
- }
- },
-
- stop: function( count ) {
- config.semaphore += count || 1;
- config.blocking = true;
-
- if ( config.testTimeout && defined.setTimeout ) {
- clearTimeout( config.timeout );
- config.timeout = setTimeout(function() {
- QUnit.ok( false, "Test timed out" );
- config.semaphore = 1;
- QUnit.start();
- }, config.testTimeout );
- }
- }
-};
-
-// `assert` initialized at top of scope
-// Assert helpers
-// All of these must either call QUnit.push() or manually do:
-// - runLoggingCallbacks( "log", .. );
-// - config.current.assertions.push({ .. });
-// We attach it to the QUnit object *after* we expose the public API,
-// otherwise `assert` will become a global variable in browsers (#341).
-assert = {
- /**
- * Asserts rough true-ish result.
- * @name ok
- * @function
- * @example ok( "asdfasdf".length > 5, "There must be at least 5 chars" );
- */
- ok: function( result, msg ) {
- if ( !config.current ) {
- throw new Error( "ok() assertion outside test context, was " + sourceFromStacktrace(2) );
- }
- result = !!result;
- msg = msg || (result ? "okay" : "failed" );
-
- var source,
- details = {
- module: config.current.module,
- name: config.current.testName,
- result: result,
- message: msg
- };
-
- msg = "<span class='test-message'>" + escapeText( msg ) + "</span>";
-
- if ( !result ) {
- source = sourceFromStacktrace( 2 );
- if ( source ) {
- details.source = source;
- msg += "<table><tr class='test-source'><th>Source: </th><td><pre>" + escapeText( source ) + "</pre></td></tr></table>";
- }
- }
- runLoggingCallbacks( "log", QUnit, details );
- config.current.assertions.push({
- result: result,
- message: msg
- });
- },
-
- /**
- * Assert that the first two arguments are equal, with an optional message.
- * Prints out both actual and expected values.
- * @name equal
- * @function
- * @example equal( format( "Received {0} bytes.", 2), "Received 2 bytes.", "format() replaces {0} with next argument" );
- */
- equal: function( actual, expected, message ) {
- /*jshint eqeqeq:false */
- QUnit.push( expected == actual, actual, expected, message );
- },
-
- /**
- * @name notEqual
- * @function
- */
- notEqual: function( actual, expected, message ) {
- /*jshint eqeqeq:false */
- QUnit.push( expected != actual, actual, expected, message );
- },
-
- /**
- * @name propEqual
- * @function
- */
- propEqual: function( actual, expected, message ) {
- actual = objectValues(actual);
- expected = objectValues(expected);
- QUnit.push( QUnit.equiv(actual, expected), actual, expected, message );
- },
-
- /**
- * @name notPropEqual
- * @function
- */
- notPropEqual: function( actual, expected, message ) {
- actual = objectValues(actual);
- expected = objectValues(expected);
- QUnit.push( !QUnit.equiv(actual, expected), actual, expected, message );
- },
-
- /**
- * @name deepEqual
- * @function
- */
- deepEqual: function( actual, expected, message ) {
- QUnit.push( QUnit.equiv(actual, expected), actual, expected, message );
- },
-
- /**
- * @name notDeepEqual
- * @function
- */
- notDeepEqual: function( actual, expected, message ) {
- QUnit.push( !QUnit.equiv(actual, expected), actual, expected, message );
- },
-
- /**
- * @name strictEqual
- * @function
- */
- strictEqual: function( actual, expected, message ) {
- QUnit.push( expected === actual, actual, expected, message );
- },
-
- /**
- * @name notStrictEqual
- * @function
- */
- notStrictEqual: function( actual, expected, message ) {
- QUnit.push( expected !== actual, actual, expected, message );
- },
-
- "throws": function( block, expected, message ) {
- var actual,
- expectedOutput = expected,
- ok = false;
-
- // 'expected' is optional
- if ( typeof expected === "string" ) {
- message = expected;
- expected = null;
- }
-
- config.current.ignoreGlobalErrors = true;
- try {
- block.call( config.current.testEnvironment );
- } catch (e) {
- actual = e;
- }
- config.current.ignoreGlobalErrors = false;
-
- if ( actual ) {
- // we don't want to validate thrown error
- if ( !expected ) {
- ok = true;
- expectedOutput = null;
- // expected is a regexp
- } else if ( QUnit.objectType( expected ) === "regexp" ) {
- ok = expected.test( errorString( actual ) );
- // expected is a constructor
- } else if ( actual instanceof expected ) {
- ok = true;
- // expected is a validation function which returns true is validation passed
- } else if ( expected.call( {}, actual ) === true ) {
- expectedOutput = null;
- ok = true;
- }
-
- QUnit.push( ok, actual, expectedOutput, message );
- } else {
- QUnit.pushFailure( message, null, "No exception was thrown." );
- }
- }
-};
-
-/**
- * @deprecated since 1.8.0
- * Kept assertion helpers in root for backwards compatibility.
- */
-extend( QUnit, assert );
-
-/**
- * @deprecated since 1.9.0
- * Kept root "raises()" for backwards compatibility.
- * (Note that we don't introduce assert.raises).
- */
-QUnit.raises = assert[ "throws" ];
-
-/**
- * @deprecated since 1.0.0, replaced with error pushes since 1.3.0
- * Kept to avoid TypeErrors for undefined methods.
- */
-QUnit.equals = function() {
- QUnit.push( false, false, false, "QUnit.equals has been deprecated since 2009 (e88049a0), use QUnit.equal instead" );
-};
-QUnit.same = function() {
- QUnit.push( false, false, false, "QUnit.same has been deprecated since 2009 (e88049a0), use QUnit.deepEqual instead" );
-};
-
-// We want access to the constructor's prototype
-(function() {
- function F() {}
- F.prototype = QUnit;
- QUnit = new F();
- // Make F QUnit's constructor so that we can add to the prototype later
- QUnit.constructor = F;
-}());
-
-/**
- * Config object: Maintain internal state
- * Later exposed as QUnit.config
- * `config` initialized at top of scope
- */
-config = {
- // The queue of tests to run
- queue: [],
-
- // block until document ready
- blocking: true,
-
- // when enabled, show only failing tests
- // gets persisted through sessionStorage and can be changed in UI via checkbox
- hidepassed: false,
-
- // by default, run previously failed tests first
- // very useful in combination with "Hide passed tests" checked
- reorder: true,
-
- // by default, modify document.title when suite is done
- altertitle: true,
-
- // when enabled, all tests must call expect()
- requireExpects: false,
-
- // add checkboxes that are persisted in the query-string
- // when enabled, the id is set to `true` as a `QUnit.config` property
- urlConfig: [
- {
- id: "noglobals",
- label: "Check for Globals",
- tooltip: "Enabling this will test if any test introduces new properties on the `window` object. Stored as query-strings."
- },
- {
- id: "notrycatch",
- label: "No try-catch",
- tooltip: "Enabling this will run tests outside of a try-catch block. Makes debugging exceptions in IE reasonable. Stored as query-strings."
- }
- ],
-
- // Set of all modules.
- modules: {},
-
- // logging callback queues
- begin: [],
- done: [],
- log: [],
- testStart: [],
- testDone: [],
- moduleStart: [],
- moduleDone: []
-};
-
-// Export global variables, unless an 'exports' object exists,
-// in that case we assume we're in CommonJS (dealt with on the bottom of the script)
-if ( typeof exports === "undefined" ) {
- extend( window, QUnit.constructor.prototype );
-
- // Expose QUnit object
- window.QUnit = QUnit;
-}
+ QUnit = {
+
+ // call on start of module test to prepend name to all tests
+ module: function( name, testEnvironment ) {
+ config.currentModule = name;
+ config.currentModuleTestEnvironment = testEnvironment;
+ config.modules[ name ] = true;
+ },
+
+ asyncTest: function( testName, expected, callback ) {
+ if ( arguments.length === 2 ) {
+ callback = expected;
+ expected = null;
+ }
+
+ QUnit.test( testName, expected, callback, true );
+ },
+
+ test: function( testName, expected, callback, async ) {
+ var test;
+
+ if ( arguments.length === 2 ) {
+ callback = expected;
+ expected = null;
+ }
+
+ test = new Test({
+ testName: testName,
+ expected: expected,
+ async: async,
+ callback: callback,
+ module: config.currentModule,
+ moduleTestEnvironment: config.currentModuleTestEnvironment,
+ stack: sourceFromStacktrace( 2 )
+ });
+
+ if ( !validTest( test ) ) {
+ return;
+ }
+
+ test.queue();
+ },
+
+ start: function( count ) {
+ var message;
+
+ // QUnit hasn't been initialized yet.
+ // Note: RequireJS (et al) may delay onLoad
+ if ( config.semaphore === undefined ) {
+ QUnit.begin(function() {
+ // This is triggered at the top of QUnit.load, push start() to the event loop, to allow QUnit.load to finish first
+ setTimeout(function() {
+ QUnit.start( count );
+ });
+ });
+ return;
+ }
+
+ config.semaphore -= count || 1;
+ // don't start until equal number of stop-calls
+ if ( config.semaphore > 0 ) {
+ return;
+ }
+
+ // Set the starting time when the first test is run
+ QUnit.config.started = QUnit.config.started || now();
+ // ignore if start is called more often then stop
+ if ( config.semaphore < 0 ) {
+ config.semaphore = 0;
+
+ message = "Called start() while already started (QUnit.config.semaphore was 0 already)";
+
+ if ( config.current ) {
+ QUnit.pushFailure( message, sourceFromStacktrace( 2 ) );
+ } else {
+ throw new Error( message );
+ }
+
+ return;
+ }
+ // A slight delay, to avoid any current callbacks
+ if ( defined.setTimeout ) {
+ setTimeout(function() {
+ if ( config.semaphore > 0 ) {
+ return;
+ }
+ if ( config.timeout ) {
+ clearTimeout( config.timeout );
+ }
+
+ config.blocking = false;
+ process( true );
+ }, 13 );
+ } else {
+ config.blocking = false;
+ process( true );
+ }
+ },
+
+ stop: function( count ) {
+ config.semaphore += count || 1;
+ config.blocking = true;
+
+ if ( config.testTimeout && defined.setTimeout ) {
+ clearTimeout( config.timeout );
+ config.timeout = setTimeout(function() {
+ QUnit.ok( false, "Test timed out" );
+ config.semaphore = 1;
+ QUnit.start();
+ }, config.testTimeout );
+ }
+ }
+ };
+
+// We use the prototype to distinguish between properties that should
+// be exposed as globals (and in exports) and those that shouldn't
+ (function() {
+ function F() {}
+ F.prototype = QUnit;
+ QUnit = new F();
+
+ // Make F QUnit's constructor so that we can add to the prototype later
+ QUnit.constructor = F;
+ }());
+
+ /**
+ * Config object: Maintain internal state
+ * Later exposed as QUnit.config
+ * `config` initialized at top of scope
+ */
+ config = {
+ // The queue of tests to run
+ queue: [],
+
+ // block until document ready
+ blocking: true,
+
+ // when enabled, show only failing tests
+ // gets persisted through sessionStorage and can be changed in UI via checkbox
+ hidepassed: false,
+
+ // by default, run previously failed tests first
+ // very useful in combination with "Hide passed tests" checked
+ reorder: true,
+
+ // by default, modify document.title when suite is done
+ altertitle: true,
+
+ // by default, scroll to top of the page when suite is done
+ scrolltop: true,
+
+ // when enabled, all tests must call expect()
+ requireExpects: false,
+
+ // add checkboxes that are persisted in the query-string
+ // when enabled, the id is set to `true` as a `QUnit.config` property
+ urlConfig: [
+ {
+ id: "noglobals",
+ label: "Check for Globals",
+ tooltip: "Enabling this will test if any test introduces new properties on the `window` object. Stored as query-strings."
+ },
+ {
+ id: "notrycatch",
+ label: "No try-catch",
+ tooltip: "Enabling this will run tests outside of a try-catch block. Makes debugging exceptions in IE reasonable. Stored as query-strings."
+ }
+ ],
+
+ // Set of all modules.
+ modules: {},
+
+ callbacks: {}
+ };
// Initialize more QUnit.config and QUnit.urlParams
-(function() {
- var i,
- location = window.location || { search: "", protocol: "file:" },
- params = location.search.slice( 1 ).split( "&" ),
- length = params.length,
- urlParams = {},
- current;
-
- if ( params[ 0 ] ) {
- for ( i = 0; i < length; i++ ) {
- current = params[ i ].split( "=" );
- current[ 0 ] = decodeURIComponent( current[ 0 ] );
- // allow just a key to turn on a flag, e.g., test.html?noglobals
- current[ 1 ] = current[ 1 ] ? decodeURIComponent( current[ 1 ] ) : true;
- urlParams[ current[ 0 ] ] = current[ 1 ];
- }
- }
-
- QUnit.urlParams = urlParams;
-
- // String search anywhere in moduleName+testName
- config.filter = urlParams.filter;
-
- // Exact match of the module name
- config.module = urlParams.module;
-
- config.testNumber = parseInt( urlParams.testNumber, 10 ) || null;
-
- // Figure out if we're running the tests from a server or not
- QUnit.isLocal = location.protocol === "file:";
-}());
-
-// Extend QUnit object,
-// these after set here because they should not be exposed as global functions
-extend( QUnit, {
- assert: assert,
-
- config: config,
-
- // Initialize the configuration options
- init: function() {
- extend( config, {
- stats: { all: 0, bad: 0 },
- moduleStats: { all: 0, bad: 0 },
- started: +new Date(),
- updateRate: 1000,
- blocking: false,
- autostart: true,
- autorun: false,
- filter: "",
- queue: [],
- semaphore: 1
- });
-
- var tests, banner, result,
- qunit = id( "qunit" );
-
- if ( qunit ) {
- qunit.innerHTML =
- "<h1 id='qunit-header'>" + escapeText( document.title ) + "</h1>" +
- "<h2 id='qunit-banner'></h2>" +
- "<div id='qunit-testrunner-toolbar'></div>" +
- "<h2 id='qunit-userAgent'></h2>" +
- "<ol id='qunit-tests'></ol>";
- }
-
- tests = id( "qunit-tests" );
- banner = id( "qunit-banner" );
- result = id( "qunit-testresult" );
-
- if ( tests ) {
- tests.innerHTML = "";
- }
-
- if ( banner ) {
- banner.className = "";
- }
-
- if ( result ) {
- result.parentNode.removeChild( result );
- }
-
- if ( tests ) {
- result = document.createElement( "p" );
- result.id = "qunit-testresult";
- result.className = "result";
- tests.parentNode.insertBefore( result, tests );
- result.innerHTML = "Running...<br/>&nbsp;";
- }
- },
-
- // Resets the test setup. Useful for tests that modify the DOM.
- /*
- DEPRECATED: Use multiple tests instead of resetting inside a test.
- Use testStart or testDone for custom cleanup.
- This method will throw an error in 2.0, and will be removed in 2.1
- */
- reset: function() {
- var fixture = id( "qunit-fixture" );
- if ( fixture ) {
- fixture.innerHTML = config.fixture;
- }
- },
-
- // Trigger an event on an element.
- // @example triggerEvent( document.body, "click" );
- triggerEvent: function( elem, type, event ) {
- if ( document.createEvent ) {
- event = document.createEvent( "MouseEvents" );
- event.initMouseEvent(type, true, true, elem.ownerDocument.defaultView,
- 0, 0, 0, 0, 0, false, false, false, false, 0, null);
-
- elem.dispatchEvent( event );
- } else if ( elem.fireEvent ) {
- elem.fireEvent( "on" + type );
- }
- },
-
- // Safe object type checking
- is: function( type, obj ) {
- return QUnit.objectType( obj ) === type;
- },
-
- objectType: function( obj ) {
- if ( typeof obj === "undefined" ) {
- return "undefined";
- // consider: typeof null === object
- }
- if ( obj === null ) {
- return "null";
- }
-
- var match = toString.call( obj ).match(/^\[object\s(.*)\]$/),
- type = match && match[1] || "";
-
- switch ( type ) {
- case "Number":
- if ( isNaN(obj) ) {
- return "nan";
- }
- return "number";
- case "String":
- case "Boolean":
- case "Array":
- case "Date":
- case "RegExp":
- case "Function":
- return type.toLowerCase();
- }
- if ( typeof obj === "object" ) {
- return "object";
- }
- return undefined;
- },
-
- push: function( result, actual, expected, message ) {
- if ( !config.current ) {
- throw new Error( "assertion outside test context, was " + sourceFromStacktrace() );
- }
-
- var output, source,
- details = {
- module: config.current.module,
- name: config.current.testName,
- result: result,
- message: message,
- actual: actual,
- expected: expected
- };
-
- message = escapeText( message ) || ( result ? "okay" : "failed" );
- message = "<span class='test-message'>" + message + "</span>";
- output = message;
-
- if ( !result ) {
- expected = escapeText( QUnit.jsDump.parse(expected) );
- actual = escapeText( QUnit.jsDump.parse(actual) );
- output += "<table><tr class='test-expected'><th>Expected: </th><td><pre>" + expected + "</pre></td></tr>";
-
- if ( actual !== expected ) {
- output += "<tr class='test-actual'><th>Result: </th><td><pre>" + actual + "</pre></td></tr>";
- output += "<tr class='test-diff'><th>Diff: </th><td><pre>" + QUnit.diff( expected, actual ) + "</pre></td></tr>";
- }
-
- source = sourceFromStacktrace();
-
- if ( source ) {
- details.source = source;
- output += "<tr class='test-source'><th>Source: </th><td><pre>" + escapeText( source ) + "</pre></td></tr>";
- }
-
- output += "</table>";
- }
-
- runLoggingCallbacks( "log", QUnit, details );
-
- config.current.assertions.push({
- result: !!result,
- message: output
- });
- },
-
- pushFailure: function( message, source, actual ) {
- if ( !config.current ) {
- throw new Error( "pushFailure() assertion outside test context, was " + sourceFromStacktrace(2) );
- }
-
- var output,
- details = {
- module: config.current.module,
- name: config.current.testName,
- result: false,
- message: message
- };
-
- message = escapeText( message ) || "error";
- message = "<span class='test-message'>" + message + "</span>";
- output = message;
-
- output += "<table>";
-
- if ( actual ) {
- output += "<tr class='test-actual'><th>Result: </th><td><pre>" + escapeText( actual ) + "</pre></td></tr>";
- }
-
- if ( source ) {
- details.source = source;
- output += "<tr class='test-source'><th>Source: </th><td><pre>" + escapeText( source ) + "</pre></td></tr>";
- }
-
- output += "</table>";
-
- runLoggingCallbacks( "log", QUnit, details );
-
- config.current.assertions.push({
- result: false,
- message: output
- });
- },
-
- url: function( params ) {
- params = extend( extend( {}, QUnit.urlParams ), params );
- var key,
- querystring = "?";
-
- for ( key in params ) {
- if ( hasOwn.call( params, key ) ) {
- querystring += encodeURIComponent( key ) + "=" +
- encodeURIComponent( params[ key ] ) + "&";
- }
- }
- return window.location.protocol + "//" + window.location.host +
- window.location.pathname + querystring.slice( 0, -1 );
- },
-
- extend: extend,
- id: id,
- addEvent: addEvent,
- addClass: addClass,
- hasClass: hasClass,
- removeClass: removeClass
- // load, equiv, jsDump, diff: Attached later
-});
-
-/**
- * @deprecated: Created for backwards compatibility with test runner that set the hook function
- * into QUnit.{hook}, instead of invoking it and passing the hook function.
- * QUnit.constructor is set to the empty F() above so that we can add to it's prototype here.
- * Doing this allows us to tell if the following methods have been overwritten on the actual
- * QUnit object.
- */
-extend( QUnit.constructor.prototype, {
-
- // Logging callbacks; all receive a single argument with the listed properties
- // run test/logs.html for any related changes
- begin: registerLoggingCallback( "begin" ),
-
- // done: { failed, passed, total, runtime }
- done: registerLoggingCallback( "done" ),
-
- // log: { result, actual, expected, message }
- log: registerLoggingCallback( "log" ),
-
- // testStart: { name }
- testStart: registerLoggingCallback( "testStart" ),
-
- // testDone: { name, failed, passed, total, duration }
- testDone: registerLoggingCallback( "testDone" ),
-
- // moduleStart: { name }
- moduleStart: registerLoggingCallback( "moduleStart" ),
-
- // moduleDone: { name, failed, passed, total }
- moduleDone: registerLoggingCallback( "moduleDone" )
-});
-
-if ( typeof document === "undefined" || document.readyState === "complete" ) {
- config.autorun = true;
-}
-
-QUnit.load = function() {
- runLoggingCallbacks( "begin", QUnit, {} );
-
- // Initialize the config, saving the execution queue
- var banner, filter, i, label, len, main, ol, toolbar, userAgent, val,
- urlConfigCheckboxesContainer, urlConfigCheckboxes, moduleFilter,
- numModules = 0,
- moduleNames = [],
- moduleFilterHtml = "",
- urlConfigHtml = "",
- oldconfig = extend( {}, config );
-
- QUnit.init();
- extend(config, oldconfig);
-
- config.blocking = false;
-
- len = config.urlConfig.length;
-
- for ( i = 0; i < len; i++ ) {
- val = config.urlConfig[i];
- if ( typeof val === "string" ) {
- val = {
- id: val,
- label: val,
- tooltip: "[no tooltip available]"
- };
- }
- config[ val.id ] = QUnit.urlParams[ val.id ];
- urlConfigHtml += "<input id='qunit-urlconfig-" + escapeText( val.id ) +
- "' name='" + escapeText( val.id ) +
- "' type='checkbox'" + ( config[ val.id ] ? " checked='checked'" : "" ) +
- " title='" + escapeText( val.tooltip ) +
- "'><label for='qunit-urlconfig-" + escapeText( val.id ) +
- "' title='" + escapeText( val.tooltip ) + "'>" + val.label + "</label>";
- }
- for ( i in config.modules ) {
- if ( config.modules.hasOwnProperty( i ) ) {
- moduleNames.push(i);
- }
- }
- numModules = moduleNames.length;
- moduleNames.sort( function( a, b ) {
- return a.localeCompare( b );
- });
- moduleFilterHtml += "<label for='qunit-modulefilter'>Module: </label><select id='qunit-modulefilter' name='modulefilter'><option value='' " +
- ( config.module === undefined ? "selected='selected'" : "" ) +
- ">< All Modules ></option>";
-
- for ( i = 0; i < numModules; i++) {
- moduleFilterHtml += "<option value='" + escapeText( encodeURIComponent(moduleNames[i]) ) + "' " +
- ( config.module === moduleNames[i] ? "selected='selected'" : "" ) +
- ">" + escapeText(moduleNames[i]) + "</option>";
- }
- moduleFilterHtml += "</select>";
-
- // `userAgent` initialized at top of scope
- userAgent = id( "qunit-userAgent" );
- if ( userAgent ) {
- userAgent.innerHTML = navigator.userAgent;
- }
-
- // `banner` initialized at top of scope
- banner = id( "qunit-header" );
- if ( banner ) {
- banner.innerHTML = "<a href='" + QUnit.url({ filter: undefined, module: undefined, testNumber: undefined }) + "'>" + banner.innerHTML + "</a> ";
- }
-
- // `toolbar` initialized at top of scope
- toolbar = id( "qunit-testrunner-toolbar" );
- if ( toolbar ) {
- // `filter` initialized at top of scope
- filter = document.createElement( "input" );
- filter.type = "checkbox";
- filter.id = "qunit-filter-pass";
-
- addEvent( filter, "click", function() {
- var tmp,
- ol = document.getElementById( "qunit-tests" );
-
- if ( filter.checked ) {
- ol.className = ol.className + " hidepass";
- } else {
- tmp = " " + ol.className.replace( /[\n\t\r]/g, " " ) + " ";
- ol.className = tmp.replace( / hidepass /, " " );
- }
- if ( defined.sessionStorage ) {
- if (filter.checked) {
- sessionStorage.setItem( "qunit-filter-passed-tests", "true" );
- } else {
- sessionStorage.removeItem( "qunit-filter-passed-tests" );
- }
- }
- });
-
- if ( config.hidepassed || defined.sessionStorage && sessionStorage.getItem( "qunit-filter-passed-tests" ) ) {
- filter.checked = true;
- // `ol` initialized at top of scope
- ol = document.getElementById( "qunit-tests" );
- ol.className = ol.className + " hidepass";
- }
- toolbar.appendChild( filter );
-
- // `label` initialized at top of scope
- label = document.createElement( "label" );
- label.setAttribute( "for", "qunit-filter-pass" );
- label.setAttribute( "title", "Only show tests and assertions that fail. Stored in sessionStorage." );
- label.innerHTML = "Hide passed tests";
- toolbar.appendChild( label );
-
- urlConfigCheckboxesContainer = document.createElement("span");
- urlConfigCheckboxesContainer.innerHTML = urlConfigHtml;
- urlConfigCheckboxes = urlConfigCheckboxesContainer.getElementsByTagName("input");
- // For oldIE support:
- // * Add handlers to the individual elements instead of the container
- // * Use "click" instead of "change"
- // * Fallback from event.target to event.srcElement
- addEvents( urlConfigCheckboxes, "click", function( event ) {
- var params = {},
- target = event.target || event.srcElement;
- params[ target.name ] = target.checked ? true : undefined;
- window.location = QUnit.url( params );
- });
- toolbar.appendChild( urlConfigCheckboxesContainer );
-
- if (numModules > 1) {
- moduleFilter = document.createElement( "span" );
- moduleFilter.setAttribute( "id", "qunit-modulefilter-container" );
- moduleFilter.innerHTML = moduleFilterHtml;
- addEvent( moduleFilter.lastChild, "change", function() {
- var selectBox = moduleFilter.getElementsByTagName("select")[0],
- selectedModule = decodeURIComponent(selectBox.options[selectBox.selectedIndex].value);
-
- window.location = QUnit.url({
- module: ( selectedModule === "" ) ? undefined : selectedModule,
- // Remove any existing filters
- filter: undefined,
- testNumber: undefined
- });
- });
- toolbar.appendChild(moduleFilter);
- }
- }
-
- // `main` initialized at top of scope
- main = id( "qunit-fixture" );
- if ( main ) {
- config.fixture = main.innerHTML;
- }
-
- if ( config.autostart ) {
- QUnit.start();
- }
-};
-
-addEvent( window, "load", QUnit.load );
+ (function() {
+ var i, current,
+ location = window.location || { search: "", protocol: "file:" },
+ params = location.search.slice( 1 ).split( "&" ),
+ length = params.length,
+ urlParams = {};
+
+ if ( params[ 0 ] ) {
+ for ( i = 0; i < length; i++ ) {
+ current = params[ i ].split( "=" );
+ current[ 0 ] = decodeURIComponent( current[ 0 ] );
+
+ // allow just a key to turn on a flag, e.g., test.html?noglobals
+ current[ 1 ] = current[ 1 ] ? decodeURIComponent( current[ 1 ] ) : true;
+ if ( urlParams[ current[ 0 ] ] ) {
+ urlParams[ current[ 0 ] ] = [].concat( urlParams[ current[ 0 ] ], current[ 1 ] );
+ } else {
+ urlParams[ current[ 0 ] ] = current[ 1 ];
+ }
+ }
+ }
+
+ QUnit.urlParams = urlParams;
+
+ // String search anywhere in moduleName+testName
+ config.filter = urlParams.filter;
+
+ // Exact match of the module name
+ config.module = urlParams.module;
+
+ config.testNumber = [];
+ if ( urlParams.testNumber ) {
+
+ // Ensure that urlParams.testNumber is an array
+ urlParams.testNumber = [].concat( urlParams.testNumber );
+ for ( i = 0; i < urlParams.testNumber.length; i++ ) {
+ current = urlParams.testNumber[ i ];
+ config.testNumber.push( parseInt( current, 10 ) );
+ }
+ }
+
+ // Figure out if we're running the tests from a server or not
+ QUnit.isLocal = location.protocol === "file:";
+ }());
+
+ extend( QUnit, {
+
+ config: config,
+
+ // Safe object type checking
+ is: function( type, obj ) {
+ return QUnit.objectType( obj ) === type;
+ },
+
+ objectType: function( obj ) {
+ if ( typeof obj === "undefined" ) {
+ return "undefined";
+ }
+
+ // Consider: typeof null === object
+ if ( obj === null ) {
+ return "null";
+ }
+
+ var match = toString.call( obj ).match( /^\[object\s(.*)\]$/ ),
+ type = match && match[ 1 ] || "";
+
+ switch ( type ) {
+ case "Number":
+ if ( isNaN( obj ) ) {
+ return "nan";
+ }
+ return "number";
+ case "String":
+ case "Boolean":
+ case "Array":
+ case "Date":
+ case "RegExp":
+ case "Function":
+ return type.toLowerCase();
+ }
+ if ( typeof obj === "object" ) {
+ return "object";
+ }
+ return undefined;
+ },
+
+ url: function( params ) {
+ params = extend( extend( {}, QUnit.urlParams ), params );
+ var key,
+ querystring = "?";
+
+ for ( key in params ) {
+ if ( hasOwn.call( params, key ) ) {
+ querystring += encodeURIComponent( key ) + "=" +
+ encodeURIComponent( params[ key ] ) + "&";
+ }
+ }
+ return window.location.protocol + "//" + window.location.host +
+ window.location.pathname + querystring.slice( 0, -1 );
+ },
+
+ extend: extend
+ });
+
+ /**
+ * @deprecated: Created for backwards compatibility with test runner that set the hook function
+ * into QUnit.{hook}, instead of invoking it and passing the hook function.
+ * QUnit.constructor is set to the empty F() above so that we can add to it's prototype here.
+ * Doing this allows us to tell if the following methods have been overwritten on the actual
+ * QUnit object.
+ */
+ extend( QUnit.constructor.prototype, {
+
+ // Logging callbacks; all receive a single argument with the listed properties
+ // run test/logs.html for any related changes
+ begin: registerLoggingCallback( "begin" ),
+
+ // done: { failed, passed, total, runtime }
+ done: registerLoggingCallback( "done" ),
+
+ // log: { result, actual, expected, message }
+ log: registerLoggingCallback( "log" ),
+
+ // testStart: { name }
+ testStart: registerLoggingCallback( "testStart" ),
+
+ // testDone: { name, failed, passed, total, runtime }
+ testDone: registerLoggingCallback( "testDone" ),
+
+ // moduleStart: { name }
+ moduleStart: registerLoggingCallback( "moduleStart" ),
+
+ // moduleDone: { name, failed, passed, total }
+ moduleDone: registerLoggingCallback( "moduleDone" )
+ });
+
+ QUnit.load = function() {
+ runLoggingCallbacks( "begin", {
+ totalTests: Test.count
+ });
+
+ // Initialize the configuration options
+ extend( config, {
+ stats: { all: 0, bad: 0 },
+ moduleStats: { all: 0, bad: 0 },
+ started: 0,
+ updateRate: 1000,
+ autostart: true,
+ filter: "",
+ semaphore: 1
+ }, true );
+
+ config.blocking = false;
+
+ if ( config.autostart ) {
+ QUnit.start();
+ }
+ };
// `onErrorFnPrev` initialized at top of scope
// Preserve other handlers
-onErrorFnPrev = window.onerror;
+ onErrorFnPrev = window.onerror;
// Cover uncaught exceptions
// Returning true will suppress the default browser handler,
// returning false will let it run.
-window.onerror = function ( error, filePath, linerNr ) {
- var ret = false;
- if ( onErrorFnPrev ) {
- ret = onErrorFnPrev( error, filePath, linerNr );
- }
-
- // Treat return value as window.onerror itself does,
- // Only do our handling if not suppressed.
- if ( ret !== true ) {
- if ( QUnit.config.current ) {
- if ( QUnit.config.current.ignoreGlobalErrors ) {
- return true;
- }
- QUnit.pushFailure( error, filePath + ":" + linerNr );
- } else {
- QUnit.test( "global failure", extend( function() {
- QUnit.pushFailure( error, filePath + ":" + linerNr );
- }, { validTest: validTest } ) );
- }
- return false;
- }
-
- return ret;
-};
-
-function done() {
- config.autorun = true;
-
- // Log the last module results
- if ( config.currentModule ) {
- runLoggingCallbacks( "moduleDone", QUnit, {
- name: config.currentModule,
- failed: config.moduleStats.bad,
- passed: config.moduleStats.all - config.moduleStats.bad,
- total: config.moduleStats.all
- });
- }
- delete config.previousModule;
-
- var i, key,
- banner = id( "qunit-banner" ),
- tests = id( "qunit-tests" ),
- runtime = +new Date() - config.started,
- passed = config.stats.all - config.stats.bad,
- html = [
- "Tests completed in ",
- runtime,
- " milliseconds.<br/>",
- "<span class='passed'>",
- passed,
- "</span> assertions of <span class='total'>",
- config.stats.all,
- "</span> passed, <span class='failed'>",
- config.stats.bad,
- "</span> failed."
- ].join( "" );
-
- if ( banner ) {
- banner.className = ( config.stats.bad ? "qunit-fail" : "qunit-pass" );
- }
-
- if ( tests ) {
- id( "qunit-testresult" ).innerHTML = html;
- }
-
- if ( config.altertitle && typeof document !== "undefined" && document.title ) {
- // show ✖ for good, ✔ for bad suite result in title
- // use escape sequences in case file gets loaded with non-utf-8-charset
- document.title = [
- ( config.stats.bad ? "\u2716" : "\u2714" ),
- //document.title.replace( /^[\u2714\u2716] /i, "" )
- ].join( " " );
- }
-
- // clear own sessionStorage items if all tests passed
- if ( config.reorder && defined.sessionStorage && config.stats.bad === 0 ) {
- // `key` & `i` initialized at top of scope
- for ( i = 0; i < sessionStorage.length; i++ ) {
- key = sessionStorage.key( i++ );
- if ( key.indexOf( "qunit-test-" ) === 0 ) {
- sessionStorage.removeItem( key );
- }
- }
- }
-
- // scroll back to top to show results
- if ( window.scrollTo ) {
- window.scrollTo(0, 0);
- }
-
- runLoggingCallbacks( "done", QUnit, {
- failed: config.stats.bad,
- passed: passed,
- total: config.stats.all,
- runtime: runtime
- });
-}
-
-/** @return Boolean: true if this test should be ran */
-function validTest( test ) {
- var include,
- filter = config.filter && config.filter.toLowerCase(),
- module = config.module && config.module.toLowerCase(),
- fullName = (test.module + ": " + test.testName).toLowerCase();
-
- // Internally-generated tests are always valid
- if ( test.callback && test.callback.validTest === validTest ) {
- delete test.callback.validTest;
- return true;
- }
-
- if ( config.testNumber ) {
- return test.testNumber === config.testNumber;
- }
-
- if ( module && ( !test.module || test.module.toLowerCase() !== module ) ) {
- return false;
- }
-
- if ( !filter ) {
- return true;
- }
-
- include = filter.charAt( 0 ) !== "!";
- if ( !include ) {
- filter = filter.slice( 1 );
- }
-
- // If the filter matches, we need to honour include
- if ( fullName.indexOf( filter ) !== -1 ) {
- return include;
- }
-
- // Otherwise, do the opposite
- return !include;
-}
-
-// so far supports only Firefox, Chrome and Opera (buggy), Safari (for real exceptions)
-// Later Safari and IE10 are supposed to support error.stack as well
+ window.onerror = function( error, filePath, linerNr ) {
+ var ret = false;
+ if ( onErrorFnPrev ) {
+ ret = onErrorFnPrev( error, filePath, linerNr );
+ }
+
+ // Treat return value as window.onerror itself does,
+ // Only do our handling if not suppressed.
+ if ( ret !== true ) {
+ if ( QUnit.config.current ) {
+ if ( QUnit.config.current.ignoreGlobalErrors ) {
+ return true;
+ }
+ QUnit.pushFailure( error, filePath + ":" + linerNr );
+ } else {
+ QUnit.test( "global failure", extend(function() {
+ QUnit.pushFailure( error, filePath + ":" + linerNr );
+ }, { validTest: validTest } ) );
+ }
+ return false;
+ }
+
+ return ret;
+ };
+
+ function done() {
+ config.autorun = true;
+
+ // Log the last module results
+ if ( config.previousModule ) {
+ runLoggingCallbacks( "moduleDone", {
+ name: config.previousModule,
+ failed: config.moduleStats.bad,
+ passed: config.moduleStats.all - config.moduleStats.bad,
+ total: config.moduleStats.all
+ });
+ }
+ delete config.previousModule;
+
+ var runtime = now() - config.started,
+ passed = config.stats.all - config.stats.bad;
+
+ runLoggingCallbacks( "done", {
+ failed: config.stats.bad,
+ passed: passed,
+ total: config.stats.all,
+ runtime: runtime
+ });
+ }
+
+ /** @return Boolean: true if this test should be ran */
+ function validTest( test ) {
+ var include,
+ filter = config.filter && config.filter.toLowerCase(),
+ module = config.module && config.module.toLowerCase(),
+ fullName = ( test.module + ": " + test.testName ).toLowerCase();
+
+ // Internally-generated tests are always valid
+ if ( test.callback && test.callback.validTest === validTest ) {
+ delete test.callback.validTest;
+ return true;
+ }
+
+ if ( config.testNumber.length > 0 ) {
+ if ( inArray( test.testNumber, config.testNumber ) < 0 ) {
+ return false;
+ }
+ }
+
+ if ( module && ( !test.module || test.module.toLowerCase() !== module ) ) {
+ return false;
+ }
+
+ if ( !filter ) {
+ return true;
+ }
+
+ include = filter.charAt( 0 ) !== "!";
+ if ( !include ) {
+ filter = filter.slice( 1 );
+ }
+
+ // If the filter matches, we need to honour include
+ if ( fullName.indexOf( filter ) !== -1 ) {
+ return include;
+ }
+
+ // Otherwise, do the opposite
+ return !include;
+ }
+
+// Doesn't support IE6 to IE9
// See also https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Error/Stack
-function extractStacktrace( e, offset ) {
- offset = offset === undefined ? 3 : offset;
-
- var stack, include, i;
-
- if ( e.stacktrace ) {
- // Opera
- return e.stacktrace.split( "\n" )[ offset + 3 ];
- } else if ( e.stack ) {
- // Firefox, Chrome
- stack = e.stack.split( "\n" );
- if (/^error$/i.test( stack[0] ) ) {
- stack.shift();
- }
- if ( fileName ) {
- include = [];
- for ( i = offset; i < stack.length; i++ ) {
- if ( stack[ i ].indexOf( fileName ) !== -1 ) {
- break;
- }
- include.push( stack[ i ] );
- }
- if ( include.length ) {
- return include.join( "\n" );
- }
- }
- return stack[ offset ];
- } else if ( e.sourceURL ) {
- // Safari, PhantomJS
- // hopefully one day Safari provides actual stacktraces
- // exclude useless self-reference for generated Error objects
- if ( /qunit.js$/.test( e.sourceURL ) ) {
- return;
- }
- // for actual exceptions, this is useful
- return e.sourceURL + ":" + e.line;
- }
-}
-function sourceFromStacktrace( offset ) {
- try {
- throw new Error();
- } catch ( e ) {
- return extractStacktrace( e, offset );
- }
-}
-
-/**
- * Escape text for attribute or text content.
- */
-function escapeText( s ) {
- if ( !s ) {
- return "";
- }
- s = s + "";
- // Both single quotes and double quotes (for attributes)
- return s.replace( /['"<>&]/g, function( s ) {
- switch( s ) {
- case "'":
- return "&#039;";
- case "\"":
- return "&quot;";
- case "<":
- return "&lt;";
- case ">":
- return "&gt;";
- case "&":
- return "&amp;";
- }
- });
-}
-
-function synchronize( callback, last ) {
- config.queue.push( callback );
-
- if ( config.autorun && !config.blocking ) {
- process( last );
- }
-}
-
-function process( last ) {
- function next() {
- process( last );
- }
- var start = new Date().getTime();
- config.depth = config.depth ? config.depth + 1 : 1;
-
- while ( config.queue.length && !config.blocking ) {
- if ( !defined.setTimeout || config.updateRate <= 0 || ( ( new Date().getTime() - start ) < config.updateRate ) ) {
- config.queue.shift()();
- } else {
- setTimeout( next, 13 );
- break;
- }
- }
- config.depth--;
- if ( last && !config.blocking && !config.queue.length && config.depth === 0 ) {
- done();
- }
-}
-
-function saveGlobal() {
- config.pollution = [];
-
- if ( config.noglobals ) {
- for ( var key in window ) {
- if ( hasOwn.call( window, key ) ) {
- // in Opera sometimes DOM element ids show up here, ignore them
- if ( /^qunit-test-output/.test( key ) ) {
- continue;
- }
- config.pollution.push( key );
- }
- }
- }
-}
-
-function checkPollution() {
- var newGlobals,
- deletedGlobals,
- old = config.pollution;
-
- saveGlobal();
-
- newGlobals = diff( config.pollution, old );
- if ( newGlobals.length > 0 ) {
- QUnit.pushFailure( "Introduced global variable(s): " + newGlobals.join(", ") );
- }
-
- deletedGlobals = diff( old, config.pollution );
- if ( deletedGlobals.length > 0 ) {
- QUnit.pushFailure( "Deleted global variable(s): " + deletedGlobals.join(", ") );
- }
-}
+ function extractStacktrace( e, offset ) {
+ offset = offset === undefined ? 4 : offset;
+
+ var stack, include, i;
+
+ if ( e.stacktrace ) {
+
+ // Opera 12.x
+ return e.stacktrace.split( "\n" )[ offset + 3 ];
+ } else if ( e.stack ) {
+
+ // Firefox, Chrome, Safari 6+, IE10+, PhantomJS and Node
+ stack = e.stack.split( "\n" );
+ if ( /^error$/i.test( stack[ 0 ] ) ) {
+ stack.shift();
+ }
+ if ( fileName ) {
+ include = [];
+ for ( i = offset; i < stack.length; i++ ) {
+ if ( stack[ i ].indexOf( fileName ) !== -1 ) {
+ break;
+ }
+ include.push( stack[ i ] );
+ }
+ if ( include.length ) {
+ return include.join( "\n" );
+ }
+ }
+ return stack[ offset ];
+ } else if ( e.sourceURL ) {
+
+ // Safari < 6
+ // exclude useless self-reference for generated Error objects
+ if ( /qunit.js$/.test( e.sourceURL ) ) {
+ return;
+ }
+
+ // for actual exceptions, this is useful
+ return e.sourceURL + ":" + e.line;
+ }
+ }
+ function sourceFromStacktrace( offset ) {
+ try {
+ throw new Error();
+ } catch ( e ) {
+ return extractStacktrace( e, offset );
+ }
+ }
+
+ function synchronize( callback, last ) {
+ config.queue.push( callback );
+
+ if ( config.autorun && !config.blocking ) {
+ process( last );
+ }
+ }
+
+ function process( last ) {
+ function next() {
+ process( last );
+ }
+ var start = now();
+ config.depth = config.depth ? config.depth + 1 : 1;
+
+ while ( config.queue.length && !config.blocking ) {
+ if ( !defined.setTimeout || config.updateRate <= 0 || ( ( now() - start ) < config.updateRate ) ) {
+ config.queue.shift()();
+ } else {
+ setTimeout( next, 13 );
+ break;
+ }
+ }
+ config.depth--;
+ if ( last && !config.blocking && !config.queue.length && config.depth === 0 ) {
+ done();
+ }
+ }
+
+ function saveGlobal() {
+ config.pollution = [];
+
+ if ( config.noglobals ) {
+ for ( var key in window ) {
+ if ( hasOwn.call( window, key ) ) {
+ // in Opera sometimes DOM element ids show up here, ignore them
+ if ( /^qunit-test-output/.test( key ) ) {
+ continue;
+ }
+ config.pollution.push( key );
+ }
+ }
+ }
+ }
+
+ function checkPollution() {
+ var newGlobals,
+ deletedGlobals,
+ old = config.pollution;
+
+ saveGlobal();
+
+ newGlobals = diff( config.pollution, old );
+ if ( newGlobals.length > 0 ) {
+ QUnit.pushFailure( "Introduced global variable(s): " + newGlobals.join( ", " ) );
+ }
+
+ deletedGlobals = diff( old, config.pollution );
+ if ( deletedGlobals.length > 0 ) {
+ QUnit.pushFailure( "Deleted global variable(s): " + deletedGlobals.join( ", " ) );
+ }
+ }
// returns a new Array with the elements that are in a but not in b
-function diff( a, b ) {
- var i, j,
- result = a.slice();
-
- for ( i = 0; i < result.length; i++ ) {
- for ( j = 0; j < b.length; j++ ) {
- if ( result[i] === b[j] ) {
- result.splice( i, 1 );
- i--;
- break;
- }
- }
- }
- return result;
-}
-
-function extend( a, b ) {
- for ( var prop in b ) {
- if ( hasOwn.call( b, prop ) ) {
- // Avoid "Member not found" error in IE8 caused by messing with window.constructor
- if ( !( prop === "constructor" && a === window ) ) {
- if ( b[ prop ] === undefined ) {
- delete a[ prop ];
- } else {
- a[ prop ] = b[ prop ];
- }
- }
- }
- }
-
- return a;
-}
-
-/**
- * @param {HTMLElement} elem
- * @param {string} type
- * @param {Function} fn
- */
-function addEvent( elem, type, fn ) {
- // Standards-based browsers
- if ( elem.addEventListener ) {
- elem.addEventListener( type, fn, false );
- // IE
- } else {
- elem.attachEvent( "on" + type, fn );
- }
-}
-
-/**
- * @param {Array|NodeList} elems
- * @param {string} type
- * @param {Function} fn
- */
-function addEvents( elems, type, fn ) {
- var i = elems.length;
- while ( i-- ) {
- addEvent( elems[i], type, fn );
- }
-}
-
-function hasClass( elem, name ) {
- return (" " + elem.className + " ").indexOf(" " + name + " ") > -1;
-}
-
-function addClass( elem, name ) {
- if ( !hasClass( elem, name ) ) {
- elem.className += (elem.className ? " " : "") + name;
- }
-}
-
-function removeClass( elem, name ) {
- var set = " " + elem.className + " ";
- // Class name may appear multiple times
- while ( set.indexOf(" " + name + " ") > -1 ) {
- set = set.replace(" " + name + " " , " ");
- }
- // If possible, trim it for prettiness, but not necessarily
- elem.className = typeof set.trim === "function" ? set.trim() : set.replace(/^\s+|\s+$/g, "");
-}
-
-function id( name ) {
- return !!( typeof document !== "undefined" && document && document.getElementById ) &&
- document.getElementById( name );
-}
-
-function registerLoggingCallback( key ) {
- return function( callback ) {
- config[key].push( callback );
- };
-}
-
-// Supports deprecated method of completely overwriting logging callbacks
-function runLoggingCallbacks( key, scope, args ) {
- var i, callbacks;
- if ( QUnit.hasOwnProperty( key ) ) {
- QUnit[ key ].call(scope, args );
- } else {
- callbacks = config[ key ];
- for ( i = 0; i < callbacks.length; i++ ) {
- callbacks[ i ].call( scope, args );
- }
- }
-}
-
-// Test for equality any JavaScript type.
-// Author: Philippe Rathé <prathe@gmail.com>
-QUnit.equiv = (function() {
-
- // Call the o related callback with the given arguments.
- function bindCallbacks( o, callbacks, args ) {
- var prop = QUnit.objectType( o );
- if ( prop ) {
- if ( QUnit.objectType( callbacks[ prop ] ) === "function" ) {
- return callbacks[ prop ].apply( callbacks, args );
- } else {
- return callbacks[ prop ]; // or undefined
- }
- }
- }
-
- // the real equiv function
- var innerEquiv,
- // stack to decide between skip/abort functions
- callers = [],
- // stack to avoiding loops from circular referencing
- parents = [],
- parentsB = [],
-
- getProto = Object.getPrototypeOf || function ( obj ) {
- /*jshint camelcase:false */
- return obj.__proto__;
- },
- callbacks = (function () {
-
- // for string, boolean, number and null
- function useStrictEquality( b, a ) {
- /*jshint eqeqeq:false */
- if ( b instanceof a.constructor || a instanceof b.constructor ) {
- // to catch short annotation VS 'new' annotation of a
- // declaration
- // e.g. var i = 1;
- // var j = new Number(1);
- return a == b;
- } else {
- return a === b;
- }
- }
-
- return {
- "string": useStrictEquality,
- "boolean": useStrictEquality,
- "number": useStrictEquality,
- "null": useStrictEquality,
- "undefined": useStrictEquality,
-
- "nan": function( b ) {
- return isNaN( b );
- },
-
- "date": function( b, a ) {
- return QUnit.objectType( b ) === "date" && a.valueOf() === b.valueOf();
- },
-
- "regexp": function( b, a ) {
- return QUnit.objectType( b ) === "regexp" &&
- // the regex itself
- a.source === b.source &&
- // and its modifiers
- a.global === b.global &&
- // (gmi) ...
- a.ignoreCase === b.ignoreCase &&
- a.multiline === b.multiline &&
- a.sticky === b.sticky;
- },
-
- // - skip when the property is a method of an instance (OOP)
- // - abort otherwise,
- // initial === would have catch identical references anyway
- "function": function() {
- var caller = callers[callers.length - 1];
- return caller !== Object && typeof caller !== "undefined";
- },
-
- "array": function( b, a ) {
- var i, j, len, loop, aCircular, bCircular;
-
- // b could be an object literal here
- if ( QUnit.objectType( b ) !== "array" ) {
- return false;
- }
-
- len = a.length;
- if ( len !== b.length ) {
- // safe and faster
- return false;
- }
-
- // track reference to avoid circular references
- parents.push( a );
- parentsB.push( b );
- for ( i = 0; i < len; i++ ) {
- loop = false;
- for ( j = 0; j < parents.length; j++ ) {
- aCircular = parents[j] === a[i];
- bCircular = parentsB[j] === b[i];
- if ( aCircular || bCircular ) {
- if ( a[i] === b[i] || aCircular && bCircular ) {
- loop = true;
- } else {
- parents.pop();
- parentsB.pop();
- return false;
- }
- }
- }
- if ( !loop && !innerEquiv(a[i], b[i]) ) {
- parents.pop();
- parentsB.pop();
- return false;
- }
- }
- parents.pop();
- parentsB.pop();
- return true;
- },
-
- "object": function( b, a ) {
- /*jshint forin:false */
- var i, j, loop, aCircular, bCircular,
- // Default to true
- eq = true,
- aProperties = [],
- bProperties = [];
-
- // comparing constructors is more strict than using
- // instanceof
- if ( a.constructor !== b.constructor ) {
- // Allow objects with no prototype to be equivalent to
- // objects with Object as their constructor.
- if ( !(( getProto(a) === null && getProto(b) === Object.prototype ) ||
- ( getProto(b) === null && getProto(a) === Object.prototype ) ) ) {
- return false;
- }
- }
-
- // stack constructor before traversing properties
- callers.push( a.constructor );
-
- // track reference to avoid circular references
- parents.push( a );
- parentsB.push( b );
-
- // be strict: don't ensure hasOwnProperty and go deep
- for ( i in a ) {
- loop = false;
- for ( j = 0; j < parents.length; j++ ) {
- aCircular = parents[j] === a[i];
- bCircular = parentsB[j] === b[i];
- if ( aCircular || bCircular ) {
- if ( a[i] === b[i] || aCircular && bCircular ) {
- loop = true;
- } else {
- eq = false;
- break;
- }
- }
- }
- aProperties.push(i);
- if ( !loop && !innerEquiv(a[i], b[i]) ) {
- eq = false;
- break;
- }
- }
-
- parents.pop();
- parentsB.pop();
- callers.pop(); // unstack, we are done
-
- for ( i in b ) {
- bProperties.push( i ); // collect b's properties
- }
-
- // Ensures identical properties name
- return eq && innerEquiv( aProperties.sort(), bProperties.sort() );
- }
- };
- }());
-
- innerEquiv = function() { // can take multiple arguments
- var args = [].slice.apply( arguments );
- if ( args.length < 2 ) {
- return true; // end transition
- }
-
- return (function( a, b ) {
- if ( a === b ) {
- return true; // catch the most you can
- } else if ( a === null || b === null || typeof a === "undefined" ||
- typeof b === "undefined" ||
- QUnit.objectType(a) !== QUnit.objectType(b) ) {
- return false; // don't lose time with error prone cases
- } else {
- return bindCallbacks(a, callbacks, [ b, a ]);
- }
-
- // apply transition with (1..n) arguments
- }( args[0], args[1] ) && innerEquiv.apply( this, args.splice(1, args.length - 1 )) );
- };
-
- return innerEquiv;
-}());
-
-/**
- * jsDump Copyright (c) 2008 Ariel Flesler - aflesler(at)gmail(dot)com |
- * http://flesler.blogspot.com Licensed under BSD
- * (http://www.opensource.org/licenses/bsd-license.php) Date: 5/15/2008
- *
- * @projectDescription Advanced and extensible data dumping for Javascript.
- * @version 1.0.0
- * @author Ariel Flesler
- * @link {http://flesler.blogspot.com/2008/05/jsdump-pretty-dump-of-any-javascript.html}
- */
-QUnit.jsDump = (function() {
- function quote( str ) {
- return "\"" + str.toString().replace( /"/g, "\\\"" ) + "\"";
- }
- function literal( o ) {
- return o + "";
- }
- function join( pre, arr, post ) {
- var s = jsDump.separator(),
- base = jsDump.indent(),
- inner = jsDump.indent(1);
- if ( arr.join ) {
- arr = arr.join( "," + s + inner );
- }
- if ( !arr ) {
- return pre + post;
- }
- return [ pre, inner + arr, base + post ].join(s);
- }
- function array( arr, stack ) {
- var i = arr.length, ret = new Array(i);
- this.up();
- while ( i-- ) {
- ret[i] = this.parse( arr[i] , undefined , stack);
- }
- this.down();
- return join( "[", ret, "]" );
- }
-
- var reName = /^function (\w+)/,
- jsDump = {
- // type is used mostly internally, you can fix a (custom)type in advance
- parse: function( obj, type, stack ) {
- stack = stack || [ ];
- var inStack, res,
- parser = this.parsers[ type || this.typeOf(obj) ];
-
- type = typeof parser;
- inStack = inArray( obj, stack );
-
- if ( inStack !== -1 ) {
- return "recursion(" + (inStack - stack.length) + ")";
- }
- if ( type === "function" ) {
- stack.push( obj );
- res = parser.call( this, obj, stack );
- stack.pop();
- return res;
- }
- return ( type === "string" ) ? parser : this.parsers.error;
- },
- typeOf: function( obj ) {
- var type;
- if ( obj === null ) {
- type = "null";
- } else if ( typeof obj === "undefined" ) {
- type = "undefined";
- } else if ( QUnit.is( "regexp", obj) ) {
- type = "regexp";
- } else if ( QUnit.is( "date", obj) ) {
- type = "date";
- } else if ( QUnit.is( "function", obj) ) {
- type = "function";
- } else if ( typeof obj.setInterval !== undefined && typeof obj.document !== "undefined" && typeof obj.nodeType === "undefined" ) {
- type = "window";
- } else if ( obj.nodeType === 9 ) {
- type = "document";
- } else if ( obj.nodeType ) {
- type = "node";
- } else if (
- // native arrays
- toString.call( obj ) === "[object Array]" ||
- // NodeList objects
- ( typeof obj.length === "number" && typeof obj.item !== "undefined" && ( obj.length ? obj.item(0) === obj[0] : ( obj.item( 0 ) === null && typeof obj[0] === "undefined" ) ) )
- ) {
- type = "array";
- } else if ( obj.constructor === Error.prototype.constructor ) {
- type = "error";
- } else {
- type = typeof obj;
- }
- return type;
- },
- separator: function() {
- return this.multiline ? this.HTML ? "<br />" : "\n" : this.HTML ? "&nbsp;" : " ";
- },
- // extra can be a number, shortcut for increasing-calling-decreasing
- indent: function( extra ) {
- if ( !this.multiline ) {
- return "";
- }
- var chr = this.indentChar;
- if ( this.HTML ) {
- chr = chr.replace( /\t/g, " " ).replace( / /g, "&nbsp;" );
- }
- return new Array( this.depth + ( extra || 0 ) ).join(chr);
- },
- up: function( a ) {
- this.depth += a || 1;
- },
- down: function( a ) {
- this.depth -= a || 1;
- },
- setParser: function( name, parser ) {
- this.parsers[name] = parser;
- },
- // The next 3 are exposed so you can use them
- quote: quote,
- literal: literal,
- join: join,
- //
- depth: 1,
- // This is the list of parsers, to modify them, use jsDump.setParser
- parsers: {
- window: "[Window]",
- document: "[Document]",
- error: function(error) {
- return "Error(\"" + error.message + "\")";
- },
- unknown: "[Unknown]",
- "null": "null",
- "undefined": "undefined",
- "function": function( fn ) {
- var ret = "function",
- // functions never have name in IE
- name = "name" in fn ? fn.name : (reName.exec(fn) || [])[1];
-
- if ( name ) {
- ret += " " + name;
- }
- ret += "( ";
-
- ret = [ ret, QUnit.jsDump.parse( fn, "functionArgs" ), "){" ].join( "" );
- return join( ret, QUnit.jsDump.parse(fn,"functionCode" ), "}" );
- },
- array: array,
- nodelist: array,
- "arguments": array,
- object: function( map, stack ) {
- /*jshint forin:false */
- var ret = [ ], keys, key, val, i;
- QUnit.jsDump.up();
- keys = [];
- for ( key in map ) {
- keys.push( key );
- }
- keys.sort();
- for ( i = 0; i < keys.length; i++ ) {
- key = keys[ i ];
- val = map[ key ];
- ret.push( QUnit.jsDump.parse( key, "key" ) + ": " + QUnit.jsDump.parse( val, undefined, stack ) );
- }
- QUnit.jsDump.down();
- return join( "{", ret, "}" );
- },
- node: function( node ) {
- var len, i, val,
- open = QUnit.jsDump.HTML ? "&lt;" : "<",
- close = QUnit.jsDump.HTML ? "&gt;" : ">",
- tag = node.nodeName.toLowerCase(),
- ret = open + tag,
- attrs = node.attributes;
-
- if ( attrs ) {
- for ( i = 0, len = attrs.length; i < len; i++ ) {
- val = attrs[i].nodeValue;
- // IE6 includes all attributes in .attributes, even ones not explicitly set.
- // Those have values like undefined, null, 0, false, "" or "inherit".
- if ( val && val !== "inherit" ) {
- ret += " " + attrs[i].nodeName + "=" + QUnit.jsDump.parse( val, "attribute" );
- }
- }
- }
- ret += close;
-
- // Show content of TextNode or CDATASection
- if ( node.nodeType === 3 || node.nodeType === 4 ) {
- ret += node.nodeValue;
- }
-
- return ret + open + "/" + tag + close;
- },
- // function calls it internally, it's the arguments part of the function
- functionArgs: function( fn ) {
- var args,
- l = fn.length;
-
- if ( !l ) {
- return "";
- }
-
- args = new Array(l);
- while ( l-- ) {
- // 97 is 'a'
- args[l] = String.fromCharCode(97+l);
- }
- return " " + args.join( ", " ) + " ";
- },
- // object calls it internally, the key part of an item in a map
- key: quote,
- // function calls it internally, it's the content of the function
- functionCode: "[code]",
- // node calls it internally, it's an html attribute value
- attribute: quote,
- string: quote,
- date: quote,
- regexp: literal,
- number: literal,
- "boolean": literal
- },
- // if true, entities are escaped ( <, >, \t, space and \n )
- HTML: false,
- // indentation unit
- indentChar: " ",
- // if true, items in a collection, are separated by a \n, else just a space.
- multiline: true
- };
-
- return jsDump;
-}());
+ function diff( a, b ) {
+ var i, j,
+ result = a.slice();
+
+ for ( i = 0; i < result.length; i++ ) {
+ for ( j = 0; j < b.length; j++ ) {
+ if ( result[ i ] === b[ j ] ) {
+ result.splice( i, 1 );
+ i--;
+ break;
+ }
+ }
+ }
+ return result;
+ }
+
+ function extend( a, b, undefOnly ) {
+ for ( var prop in b ) {
+ if ( hasOwn.call( b, prop ) ) {
+
+ // Avoid "Member not found" error in IE8 caused by messing with window.constructor
+ if ( !( prop === "constructor" && a === window ) ) {
+ if ( b[ prop ] === undefined ) {
+ delete a[ prop ];
+ } else if ( !( undefOnly && typeof a[ prop ] !== "undefined" ) ) {
+ a[ prop ] = b[ prop ];
+ }
+ }
+ }
+ }
+
+ return a;
+ }
+
+ function registerLoggingCallback( key ) {
+
+ // Initialize key collection of logging callback
+ if ( QUnit.objectType( config.callbacks[ key ] ) === "undefined" ) {
+ config.callbacks[ key ] = [];
+ }
+
+ return function( callback ) {
+ config.callbacks[ key ].push( callback );
+ };
+ }
+
+ function runLoggingCallbacks( key, args ) {
+ var i, l, callbacks;
+
+ callbacks = config.callbacks[ key ];
+ for ( i = 0, l = callbacks.length; i < l; i++ ) {
+ callbacks[ i ]( args );
+ }
+ }
// from jquery.js
-function inArray( elem, array ) {
- if ( array.indexOf ) {
- return array.indexOf( elem );
- }
+ function inArray( elem, array ) {
+ if ( array.indexOf ) {
+ return array.indexOf( elem );
+ }
+
+ for ( var i = 0, length = array.length; i < length; i++ ) {
+ if ( array[ i ] === elem ) {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ function Test( settings ) {
+ extend( this, settings );
+ this.assert = new Assert( this );
+ this.assertions = [];
+ this.testNumber = ++Test.count;
+ }
+
+ Test.count = 0;
+
+ Test.prototype = {
+ setup: function() {
+ if (
+
+ // Emit moduleStart when we're switching from one module to another
+ this.module !== config.previousModule ||
+
+ // They could be equal (both undefined) but if the previousModule property doesn't
+ // yet exist it means this is the first test in a suite that isn't wrapped in a
+ // module, in which case we'll just emit a moduleStart event for 'undefined'.
+ // Without this, reporters can get testStart before moduleStart which is a problem.
+ !hasOwn.call( config, "previousModule" )
+ ) {
+ if ( hasOwn.call( config, "previousModule" ) ) {
+ runLoggingCallbacks( "moduleDone", {
+ name: config.previousModule,
+ failed: config.moduleStats.bad,
+ passed: config.moduleStats.all - config.moduleStats.bad,
+ total: config.moduleStats.all
+ });
+ }
+ config.previousModule = this.module;
+ config.moduleStats = { all: 0, bad: 0 };
+ runLoggingCallbacks( "moduleStart", {
+ name: this.module
+ });
+ }
+
+ config.current = this;
+
+ this.testEnvironment = extend({
+ setup: function() {},
+ teardown: function() {}
+ }, this.moduleTestEnvironment );
+
+ this.started = now();
+ runLoggingCallbacks( "testStart", {
+ name: this.testName,
+ module: this.module,
+ testNumber: this.testNumber
+ });
+
+ if ( !config.pollution ) {
+ saveGlobal();
+ }
+ if ( config.notrycatch ) {
+ this.testEnvironment.setup.call( this.testEnvironment, this.assert );
+ return;
+ }
+ try {
+ this.testEnvironment.setup.call( this.testEnvironment, this.assert );
+ } catch ( e ) {
+ this.pushFailure( "Setup failed on " + this.testName + ": " + ( e.message || e ), extractStacktrace( e, 0 ) );
+ }
+ },
+ run: function() {
+ config.current = this;
+
+ if ( this.async ) {
+ QUnit.stop();
+ }
+
+ this.callbackStarted = now();
+
+ if ( config.notrycatch ) {
+ this.callback.call( this.testEnvironment, this.assert );
+ this.callbackRuntime = now() - this.callbackStarted;
+ return;
+ }
+
+ try {
+ this.callback.call( this.testEnvironment, this.assert );
+ this.callbackRuntime = now() - this.callbackStarted;
+ } catch ( e ) {
+ this.callbackRuntime = now() - this.callbackStarted;
+
+ this.pushFailure( "Died on test #" + ( this.assertions.length + 1 ) + " " + this.stack + ": " + ( e.message || e ), extractStacktrace( e, 0 ) );
+
+ // else next test will carry the responsibility
+ saveGlobal();
+
+ // Restart the tests if they're blocking
+ if ( config.blocking ) {
+ QUnit.start();
+ }
+ }
+ },
+ teardown: function() {
+ config.current = this;
+ if ( config.notrycatch ) {
+ if ( typeof this.callbackRuntime === "undefined" ) {
+ this.callbackRuntime = now() - this.callbackStarted;
+ }
+ this.testEnvironment.teardown.call( this.testEnvironment, this.assert );
+ return;
+ } else {
+ try {
+ this.testEnvironment.teardown.call( this.testEnvironment, this.assert );
+ } catch ( e ) {
+ this.pushFailure( "Teardown failed on " + this.testName + ": " + ( e.message || e ), extractStacktrace( e, 0 ) );
+ }
+ }
+ checkPollution();
+ },
+ finish: function() {
+ config.current = this;
+ if ( config.requireExpects && this.expected === null ) {
+ this.pushFailure( "Expected number of assertions to be defined, but expect() was not called.", this.stack );
+ } else if ( this.expected !== null && this.expected !== this.assertions.length ) {
+ this.pushFailure( "Expected " + this.expected + " assertions, but " + this.assertions.length + " were run", this.stack );
+ } else if ( this.expected === null && !this.assertions.length ) {
+ this.pushFailure( "Expected at least one assertion, but none were run - call expect(0) to accept zero assertions.", this.stack );
+ }
+
+ var i,
+ bad = 0;
+
+ this.runtime = now() - this.started;
+ config.stats.all += this.assertions.length;
+ config.moduleStats.all += this.assertions.length;
+
+ for ( i = 0; i < this.assertions.length; i++ ) {
+ if ( !this.assertions[ i ].result ) {
+ bad++;
+ config.stats.bad++;
+ config.moduleStats.bad++;
+ }
+ }
+
+ runLoggingCallbacks( "testDone", {
+ name: this.testName,
+ module: this.module,
+ failed: bad,
+ passed: this.assertions.length - bad,
+ total: this.assertions.length,
+ runtime: this.runtime,
+
+ // HTML Reporter use
+ assertions: this.assertions,
+ testNumber: this.testNumber,
+
+ // DEPRECATED: this property will be removed in 2.0.0, use runtime instead
+ duration: this.runtime
+ });
+
+ config.current = undefined;
+ },
+
+ queue: function() {
+ var bad,
+ test = this;
+
+ function run() {
+ // each of these can by async
+ synchronize(function() {
+ test.setup();
+ });
+ synchronize(function() {
+ test.run();
+ });
+ synchronize(function() {
+ test.teardown();
+ });
+ synchronize(function() {
+ test.finish();
+ });
+ }
+
+ // `bad` initialized at top of scope
+ // defer when previous test run passed, if storage is available
+ bad = QUnit.config.reorder && defined.sessionStorage &&
+ +sessionStorage.getItem( "qunit-test-" + this.module + "-" + this.testName );
+
+ if ( bad ) {
+ run();
+ } else {
+ synchronize( run, true );
+ }
+ },
+
+ push: function( result, actual, expected, message ) {
+ var source,
+ details = {
+ module: this.module,
+ name: this.testName,
+ result: result,
+ message: message,
+ actual: actual,
+ expected: expected,
+ testNumber: this.testNumber
+ };
+
+ if ( !result ) {
+ source = sourceFromStacktrace();
+
+ if ( source ) {
+ details.source = source;
+ }
+ }
+
+ runLoggingCallbacks( "log", details );
+
+ this.assertions.push({
+ result: !!result,
+ message: message
+ });
+ },
+
+ pushFailure: function( message, source, actual ) {
+ if ( !this instanceof Test ) {
+ throw new Error( "pushFailure() assertion outside test context, was " + sourceFromStacktrace( 2 ) );
+ }
+
+ var details = {
+ module: this.module,
+ name: this.testName,
+ result: false,
+ message: message || "error",
+ actual: actual || null,
+ testNumber: this.testNumber
+ };
+
+ if ( source ) {
+ details.source = source;
+ }
+
+ runLoggingCallbacks( "log", details );
+
+ this.assertions.push({
+ result: false,
+ message: message
+ });
+ }
+ };
+
+ QUnit.pushFailure = function() {
+ if ( !QUnit.config.current ) {
+ throw new Error( "pushFailure() assertion outside test context, in " + sourceFromStacktrace( 2 ) );
+ }
+
+ // Gets current test obj
+ var currentTest = QUnit.config.current.assert.test;
+
+ return currentTest.pushFailure.apply( currentTest, arguments );
+ };
+
+ function Assert( testContext ) {
+ this.test = testContext;
+ }
- for ( var i = 0, length = array.length; i < length; i++ ) {
- if ( array[ i ] === elem ) {
- return i;
- }
- }
-
- return -1;
-}
+// Assert helpers
+ QUnit.assert = Assert.prototype = {
+
+ // Specify the number of expected assertions to guarantee that failed test (no assertions are run at all) don't slip through.
+ expect: function( asserts ) {
+ if ( arguments.length === 1 ) {
+ this.test.expected = asserts;
+ } else {
+ return this.test.expected;
+ }
+ },
+
+ // Exports test.push() to the user API
+ push: function() {
+ var assert = this;
+
+ // Backwards compatibility fix.
+ // Allows the direct use of global exported assertions and QUnit.assert.*
+ // Although, it's use is not recommended as it can leak assertions
+ // to other tests from async tests, because we only get a reference to the current test,
+ // not exactly the test where assertion were intended to be called.
+ if ( !QUnit.config.current ) {
+ throw new Error( "assertion outside test context, in " + sourceFromStacktrace( 2 ) );
+ }
+ if ( !( assert instanceof Assert ) ) {
+ assert = QUnit.config.current.assert;
+ }
+ return assert.test.push.apply( assert.test, arguments );
+ },
+
+ /**
+ * Asserts rough true-ish result.
+ * @name ok
+ * @function
+ * @example ok( "asdfasdf".length > 5, "There must be at least 5 chars" );
+ */
+ ok: function( result, message ) {
+ message = message || ( result ? "okay" : "failed, expected argument to be truthy, was: " +
+ QUnit.dump.parse( result ) );
+ if ( !!result ) {
+ this.push( true, result, true, message );
+ } else {
+ this.test.pushFailure( message, null, result );
+ }
+ },
+
+ /**
+ * Assert that the first two arguments are equal, with an optional message.
+ * Prints out both actual and expected values.
+ * @name equal
+ * @function
+ * @example equal( format( "Received {0} bytes.", 2), "Received 2 bytes.", "format() replaces {0} with next argument" );
+ */
+ equal: function( actual, expected, message ) {
+ /*jshint eqeqeq:false */
+ this.push( expected == actual, actual, expected, message );
+ },
+
+ /**
+ * @name notEqual
+ * @function
+ */
+ notEqual: function( actual, expected, message ) {
+ /*jshint eqeqeq:false */
+ this.push( expected != actual, actual, expected, message );
+ },
+
+ /**
+ * @name propEqual
+ * @function
+ */
+ propEqual: function( actual, expected, message ) {
+ actual = objectValues( actual );
+ expected = objectValues( expected );
+ this.push( QUnit.equiv( actual, expected ), actual, expected, message );
+ },
+
+ /**
+ * @name notPropEqual
+ * @function
+ */
+ notPropEqual: function( actual, expected, message ) {
+ actual = objectValues( actual );
+ expected = objectValues( expected );
+ this.push( !QUnit.equiv( actual, expected ), actual, expected, message );
+ },
+
+ /**
+ * @name deepEqual
+ * @function
+ */
+ deepEqual: function( actual, expected, message ) {
+ this.push( QUnit.equiv( actual, expected ), actual, expected, message );
+ },
+
+ /**
+ * @name notDeepEqual
+ * @function
+ */
+ notDeepEqual: function( actual, expected, message ) {
+ this.push( !QUnit.equiv( actual, expected ), actual, expected, message );
+ },
+
+ /**
+ * @name strictEqual
+ * @function
+ */
+ strictEqual: function( actual, expected, message ) {
+ this.push( expected === actual, actual, expected, message );
+ },
+
+ /**
+ * @name notStrictEqual
+ * @function
+ */
+ notStrictEqual: function( actual, expected, message ) {
+ this.push( expected !== actual, actual, expected, message );
+ },
+
+ "throws": function( block, expected, message ) {
+ var actual, expectedType,
+ expectedOutput = expected,
+ ok = false;
+
+ // 'expected' is optional unless doing string comparison
+ if ( message == null && typeof expected === "string" ) {
+ message = expected;
+ expected = null;
+ }
+
+ this.test.ignoreGlobalErrors = true;
+ try {
+ block.call( this.test.testEnvironment );
+ } catch (e) {
+ actual = e;
+ }
+ this.test.ignoreGlobalErrors = false;
+
+ if ( actual ) {
+ expectedType = QUnit.objectType( expected );
+
+ // we don't want to validate thrown error
+ if ( !expected ) {
+ ok = true;
+ expectedOutput = null;
+
+ // expected is a regexp
+ } else if ( expectedType === "regexp" ) {
+ ok = expected.test( errorString( actual ) );
+
+ // expected is a string
+ } else if ( expectedType === "string" ) {
+ ok = expected === errorString( actual );
+
+ // expected is a constructor, maybe an Error constructor
+ } else if ( expectedType === "function" && actual instanceof expected ) {
+ ok = true;
+
+ // expected is an Error object
+ } else if ( expectedType === "object" ) {
+ ok = actual instanceof expected.constructor &&
+ actual.name === expected.name &&
+ actual.message === expected.message;
+
+ // expected is a validation function which returns true if validation passed
+ } else if ( expectedType === "function" && expected.call( {}, actual ) === true ) {
+ expectedOutput = null;
+ ok = true;
+ }
+
+ this.push( ok, actual, expectedOutput, message );
+ } else {
+ this.test.pushFailure( message, null, "No exception was thrown." );
+ }
+ }
+ };
+// Test for equality any JavaScript type.
+// Author: Philippe Rathé <prathe@gmail.com>
+ QUnit.equiv = (function() {
+
+ // Call the o related callback with the given arguments.
+ function bindCallbacks( o, callbacks, args ) {
+ var prop = QUnit.objectType( o );
+ if ( prop ) {
+ if ( QUnit.objectType( callbacks[ prop ] ) === "function" ) {
+ return callbacks[ prop ].apply( callbacks, args );
+ } else {
+ return callbacks[ prop ]; // or undefined
+ }
+ }
+ }
+
+ // the real equiv function
+ var innerEquiv,
+
+ // stack to decide between skip/abort functions
+ callers = [],
+
+ // stack to avoiding loops from circular referencing
+ parents = [],
+ parentsB = [],
+
+ getProto = Object.getPrototypeOf || function( obj ) {
+ /* jshint camelcase: false, proto: true */
+ return obj.__proto__;
+ },
+ callbacks = (function() {
+
+ // for string, boolean, number and null
+ function useStrictEquality( b, a ) {
+
+ /*jshint eqeqeq:false */
+ if ( b instanceof a.constructor || a instanceof b.constructor ) {
+
+ // to catch short annotation VS 'new' annotation of a
+ // declaration
+ // e.g. var i = 1;
+ // var j = new Number(1);
+ return a == b;
+ } else {
+ return a === b;
+ }
+ }
+
+ return {
+ "string": useStrictEquality,
+ "boolean": useStrictEquality,
+ "number": useStrictEquality,
+ "null": useStrictEquality,
+ "undefined": useStrictEquality,
+
+ "nan": function( b ) {
+ return isNaN( b );
+ },
+
+ "date": function( b, a ) {
+ return QUnit.objectType( b ) === "date" && a.valueOf() === b.valueOf();
+ },
+
+ "regexp": function( b, a ) {
+ return QUnit.objectType( b ) === "regexp" &&
+
+ // the regex itself
+ a.source === b.source &&
+
+ // and its modifiers
+ a.global === b.global &&
+
+ // (gmi) ...
+ a.ignoreCase === b.ignoreCase &&
+ a.multiline === b.multiline &&
+ a.sticky === b.sticky;
+ },
+
+ // - skip when the property is a method of an instance (OOP)
+ // - abort otherwise,
+ // initial === would have catch identical references anyway
+ "function": function() {
+ var caller = callers[ callers.length - 1 ];
+ return caller !== Object && typeof caller !== "undefined";
+ },
+
+ "array": function( b, a ) {
+ var i, j, len, loop, aCircular, bCircular;
+
+ // b could be an object literal here
+ if ( QUnit.objectType( b ) !== "array" ) {
+ return false;
+ }
+
+ len = a.length;
+ if ( len !== b.length ) {
+ // safe and faster
+ return false;
+ }
+
+ // track reference to avoid circular references
+ parents.push( a );
+ parentsB.push( b );
+ for ( i = 0; i < len; i++ ) {
+ loop = false;
+ for ( j = 0; j < parents.length; j++ ) {
+ aCircular = parents[ j ] === a[ i ];
+ bCircular = parentsB[ j ] === b[ i ];
+ if ( aCircular || bCircular ) {
+ if ( a[ i ] === b[ i ] || aCircular && bCircular ) {
+ loop = true;
+ } else {
+ parents.pop();
+ parentsB.pop();
+ return false;
+ }
+ }
+ }
+ if ( !loop && !innerEquiv( a[ i ], b[ i ] ) ) {
+ parents.pop();
+ parentsB.pop();
+ return false;
+ }
+ }
+ parents.pop();
+ parentsB.pop();
+ return true;
+ },
+
+ "object": function( b, a ) {
+
+ /*jshint forin:false */
+ var i, j, loop, aCircular, bCircular,
+ // Default to true
+ eq = true,
+ aProperties = [],
+ bProperties = [];
+
+ // comparing constructors is more strict than using
+ // instanceof
+ if ( a.constructor !== b.constructor ) {
+
+ // Allow objects with no prototype to be equivalent to
+ // objects with Object as their constructor.
+ if ( !( ( getProto( a ) === null && getProto( b ) === Object.prototype ) ||
+ ( getProto( b ) === null && getProto( a ) === Object.prototype ) ) ) {
+ return false;
+ }
+ }
+
+ // stack constructor before traversing properties
+ callers.push( a.constructor );
+
+ // track reference to avoid circular references
+ parents.push( a );
+ parentsB.push( b );
+
+ // be strict: don't ensure hasOwnProperty and go deep
+ for ( i in a ) {
+ loop = false;
+ for ( j = 0; j < parents.length; j++ ) {
+ aCircular = parents[ j ] === a[ i ];
+ bCircular = parentsB[ j ] === b[ i ];
+ if ( aCircular || bCircular ) {
+ if ( a[ i ] === b[ i ] || aCircular && bCircular ) {
+ loop = true;
+ } else {
+ eq = false;
+ break;
+ }
+ }
+ }
+ aProperties.push( i );
+ if ( !loop && !innerEquiv( a[ i ], b[ i ] ) ) {
+ eq = false;
+ break;
+ }
+ }
+
+ parents.pop();
+ parentsB.pop();
+ callers.pop(); // unstack, we are done
+
+ for ( i in b ) {
+ bProperties.push( i ); // collect b's properties
+ }
+
+ // Ensures identical properties name
+ return eq && innerEquiv( aProperties.sort(), bProperties.sort() );
+ }
+ };
+ }());
+
+ innerEquiv = function() { // can take multiple arguments
+ var args = [].slice.apply( arguments );
+ if ( args.length < 2 ) {
+ return true; // end transition
+ }
+
+ return ( (function( a, b ) {
+ if ( a === b ) {
+ return true; // catch the most you can
+ } else if ( a === null || b === null || typeof a === "undefined" ||
+ typeof b === "undefined" ||
+ QUnit.objectType( a ) !== QUnit.objectType( b ) ) {
+
+ // don't lose time with error prone cases
+ return false;
+ } else {
+ return bindCallbacks( a, callbacks, [ b, a ] );
+ }
+
+ // apply transition with (1..n) arguments
+ }( args[ 0 ], args[ 1 ] ) ) && innerEquiv.apply( this, args.splice( 1, args.length - 1 ) ) );
+ };
+
+ return innerEquiv;
+ }());
+
+// Based on jsDump by Ariel Flesler
+// http://flesler.blogspot.com/2008/05/jsdump-pretty-dump-of-any-javascript.html
+ QUnit.dump = (function() {
+ function quote( str ) {
+ return "\"" + str.toString().replace( /"/g, "\\\"" ) + "\"";
+ }
+ function literal( o ) {
+ return o + "";
+ }
+ function join( pre, arr, post ) {
+ var s = dump.separator(),
+ base = dump.indent(),
+ inner = dump.indent( 1 );
+ if ( arr.join ) {
+ arr = arr.join( "," + s + inner );
+ }
+ if ( !arr ) {
+ return pre + post;
+ }
+ return [ pre, inner + arr, base + post ].join( s );
+ }
+ function array( arr, stack ) {
+ var i = arr.length,
+ ret = new Array( i );
+ this.up();
+ while ( i-- ) {
+ ret[ i ] = this.parse( arr[ i ], undefined, stack );
+ }
+ this.down();
+ return join( "[", ret, "]" );
+ }
+
+ var reName = /^function (\w+)/,
+ dump = {
+ // type is used mostly internally, you can fix a (custom)type in advance
+ parse: function( obj, type, stack ) {
+ stack = stack || [];
+ var inStack, res,
+ parser = this.parsers[ type || this.typeOf( obj ) ];
+
+ type = typeof parser;
+ inStack = inArray( obj, stack );
+
+ if ( inStack !== -1 ) {
+ return "recursion(" + ( inStack - stack.length ) + ")";
+ }
+ if ( type === "function" ) {
+ stack.push( obj );
+ res = parser.call( this, obj, stack );
+ stack.pop();
+ return res;
+ }
+ return ( type === "string" ) ? parser : this.parsers.error;
+ },
+ typeOf: function( obj ) {
+ var type;
+ if ( obj === null ) {
+ type = "null";
+ } else if ( typeof obj === "undefined" ) {
+ type = "undefined";
+ } else if ( QUnit.is( "regexp", obj ) ) {
+ type = "regexp";
+ } else if ( QUnit.is( "date", obj ) ) {
+ type = "date";
+ } else if ( QUnit.is( "function", obj ) ) {
+ type = "function";
+ } else if ( typeof obj.setInterval !== undefined && typeof obj.document !== "undefined" && typeof obj.nodeType === "undefined" ) {
+ type = "window";
+ } else if ( obj.nodeType === 9 ) {
+ type = "document";
+ } else if ( obj.nodeType ) {
+ type = "node";
+ } else if (
+
+ // native arrays
+ toString.call( obj ) === "[object Array]" ||
+
+ // NodeList objects
+ ( typeof obj.length === "number" && typeof obj.item !== "undefined" && ( obj.length ? obj.item( 0 ) === obj[ 0 ] : ( obj.item( 0 ) === null && typeof obj[ 0 ] === "undefined" ) ) )
+ ) {
+ type = "array";
+ } else if ( obj.constructor === Error.prototype.constructor ) {
+ type = "error";
+ } else {
+ type = typeof obj;
+ }
+ return type;
+ },
+ separator: function() {
+ return this.multiline ? this.HTML ? "<br />" : "\n" : this.HTML ? "&nbsp;" : " ";
+ },
+ // extra can be a number, shortcut for increasing-calling-decreasing
+ indent: function( extra ) {
+ if ( !this.multiline ) {
+ return "";
+ }
+ var chr = this.indentChar;
+ if ( this.HTML ) {
+ chr = chr.replace( /\t/g, " " ).replace( / /g, "&nbsp;" );
+ }
+ return new Array( this.depth + ( extra || 0 ) ).join( chr );
+ },
+ up: function( a ) {
+ this.depth += a || 1;
+ },
+ down: function( a ) {
+ this.depth -= a || 1;
+ },
+ setParser: function( name, parser ) {
+ this.parsers[ name ] = parser;
+ },
+ // The next 3 are exposed so you can use them
+ quote: quote,
+ literal: literal,
+ join: join,
+ //
+ depth: 1,
+ // This is the list of parsers, to modify them, use dump.setParser
+ parsers: {
+ window: "[Window]",
+ document: "[Document]",
+ error: function( error ) {
+ return "Error(\"" + error.message + "\")";
+ },
+ unknown: "[Unknown]",
+ "null": "null",
+ "undefined": "undefined",
+ "function": function( fn ) {
+ var ret = "function",
+ // functions never have name in IE
+ name = "name" in fn ? fn.name : ( reName.exec( fn ) || [] )[ 1 ];
+
+ if ( name ) {
+ ret += " " + name;
+ }
+ ret += "( ";
+
+ ret = [ ret, dump.parse( fn, "functionArgs" ), "){" ].join( "" );
+ return join( ret, dump.parse( fn, "functionCode" ), "}" );
+ },
+ array: array,
+ nodelist: array,
+ "arguments": array,
+ object: function( map, stack ) {
+ /*jshint forin:false */
+ var ret = [], keys, key, val, i, nonEnumerableProperties;
+ dump.up();
+ keys = [];
+ for ( key in map ) {
+ keys.push( key );
+ }
+
+ // Some properties are not always enumerable on Error objects.
+ nonEnumerableProperties = [ "message", "name" ];
+ for ( i in nonEnumerableProperties ) {
+ key = nonEnumerableProperties[ i ];
+ if ( key in map && !( key in keys ) ) {
+ keys.push( key );
+ }
+ }
+ keys.sort();
+ for ( i = 0; i < keys.length; i++ ) {
+ key = keys[ i ];
+ val = map[ key ];
+ ret.push( dump.parse( key, "key" ) + ": " + dump.parse( val, undefined, stack ) );
+ }
+ dump.down();
+ return join( "{", ret, "}" );
+ },
+ node: function( node ) {
+ var len, i, val,
+ open = dump.HTML ? "&lt;" : "<",
+ close = dump.HTML ? "&gt;" : ">",
+ tag = node.nodeName.toLowerCase(),
+ ret = open + tag,
+ attrs = node.attributes;
+
+ if ( attrs ) {
+ for ( i = 0, len = attrs.length; i < len; i++ ) {
+ val = attrs[ i ].nodeValue;
+
+ // IE6 includes all attributes in .attributes, even ones not explicitly set.
+ // Those have values like undefined, null, 0, false, "" or "inherit".
+ if ( val && val !== "inherit" ) {
+ ret += " " + attrs[ i ].nodeName + "=" + dump.parse( val, "attribute" );
+ }
+ }
+ }
+ ret += close;
+
+ // Show content of TextNode or CDATASection
+ if ( node.nodeType === 3 || node.nodeType === 4 ) {
+ ret += node.nodeValue;
+ }
+
+ return ret + open + "/" + tag + close;
+ },
+
+ // function calls it internally, it's the arguments part of the function
+ functionArgs: function( fn ) {
+ var args,
+ l = fn.length;
+
+ if ( !l ) {
+ return "";
+ }
+
+ args = new Array( l );
+ while ( l-- ) {
+
+ // 97 is 'a'
+ args[ l ] = String.fromCharCode( 97 + l );
+ }
+ return " " + args.join( ", " ) + " ";
+ },
+ // object calls it internally, the key part of an item in a map
+ key: quote,
+ // function calls it internally, it's the content of the function
+ functionCode: "[code]",
+ // node calls it internally, it's an html attribute value
+ attribute: quote,
+ string: quote,
+ date: quote,
+ regexp: literal,
+ number: literal,
+ "boolean": literal
+ },
+ // if true, entities are escaped ( <, >, \t, space and \n )
+ HTML: false,
+ // indentation unit
+ indentChar: " ",
+ // if true, items in a collection, are separated by a \n, else just a space.
+ multiline: true
+ };
+
+ return dump;
+ }());
+
+// back compat
+ QUnit.jsDump = QUnit.dump;
+
+// For browser, export only select globals
+ if ( typeof window !== "undefined" ) {
+
+ // Deprecated
+ // Extend assert methods to QUnit and Global scope through Backwards compatibility
+ (function() {
+ var i,
+ assertions = Assert.prototype;
+
+ function applyCurrent( current ) {
+ return function() {
+ var assert = new Assert( QUnit.config.current );
+ current.apply( assert, arguments );
+ };
+ }
+
+ for ( i in assertions ) {
+ QUnit[ i ] = applyCurrent( assertions[ i ] );
+ }
+ })();
+
+ (function() {
+ var i, l,
+ keys = [
+ "test",
+ "module",
+ "expect",
+ "asyncTest",
+ "start",
+ "stop",
+ "ok",
+ "equal",
+ "notEqual",
+ "propEqual",
+ "notPropEqual",
+ "deepEqual",
+ "notDeepEqual",
+ "strictEqual",
+ "notStrictEqual",
+ "throws"
+ ];
+
+ for ( i = 0, l = keys.length; i < l; i++ ) {
+ window[ keys[ i ] ] = QUnit[ keys[ i ] ];
+ }
+ })();
+
+ window.QUnit = QUnit;
+ }
+
+// For CommonJS environments, export everything
+ if ( typeof module !== "undefined" && module.exports ) {
+ module.exports = QUnit;
+ }
+
+// Get a reference to the global object, like window in browsers
+}( (function() {
+ return this;
+})() ));
+
+/*istanbul ignore next */
/*
* Javascript Diff Algorithm
* By John Resig (http://ejohn.org/)
@@ -2068,143 +1696,820 @@ function inArray( elem, array ) {
* QUnit.diff( "the quick brown fox jumped over", "the quick fox jumps over" ) == "the quick <del>brown </del> fox <del>jumped </del><ins>jumps </ins> over"
*/
QUnit.diff = (function() {
- /*jshint eqeqeq:false, eqnull:true */
- function diff( o, n ) {
- var i,
- ns = {},
- os = {};
-
- for ( i = 0; i < n.length; i++ ) {
- if ( !hasOwn.call( ns, n[i] ) ) {
- ns[ n[i] ] = {
- rows: [],
- o: null
- };
- }
- ns[ n[i] ].rows.push( i );
- }
-
- for ( i = 0; i < o.length; i++ ) {
- if ( !hasOwn.call( os, o[i] ) ) {
- os[ o[i] ] = {
- rows: [],
- n: null
- };
- }
- os[ o[i] ].rows.push( i );
- }
-
- for ( i in ns ) {
- if ( hasOwn.call( ns, i ) ) {
- if ( ns[i].rows.length === 1 && hasOwn.call( os, i ) && os[i].rows.length === 1 ) {
- n[ ns[i].rows[0] ] = {
- text: n[ ns[i].rows[0] ],
- row: os[i].rows[0]
- };
- o[ os[i].rows[0] ] = {
- text: o[ os[i].rows[0] ],
- row: ns[i].rows[0]
- };
- }
- }
- }
-
- for ( i = 0; i < n.length - 1; i++ ) {
- if ( n[i].text != null && n[ i + 1 ].text == null && n[i].row + 1 < o.length && o[ n[i].row + 1 ].text == null &&
- n[ i + 1 ] == o[ n[i].row + 1 ] ) {
-
- n[ i + 1 ] = {
- text: n[ i + 1 ],
- row: n[i].row + 1
- };
- o[ n[i].row + 1 ] = {
- text: o[ n[i].row + 1 ],
- row: i + 1
- };
- }
- }
-
- for ( i = n.length - 1; i > 0; i-- ) {
- if ( n[i].text != null && n[ i - 1 ].text == null && n[i].row > 0 && o[ n[i].row - 1 ].text == null &&
- n[ i - 1 ] == o[ n[i].row - 1 ]) {
-
- n[ i - 1 ] = {
- text: n[ i - 1 ],
- row: n[i].row - 1
- };
- o[ n[i].row - 1 ] = {
- text: o[ n[i].row - 1 ],
- row: i - 1
- };
- }
- }
-
- return {
- o: o,
- n: n
- };
- }
-
- return function( o, n ) {
- o = o.replace( /\s+$/, "" );
- n = n.replace( /\s+$/, "" );
-
- var i, pre,
- str = "",
- out = diff( o === "" ? [] : o.split(/\s+/), n === "" ? [] : n.split(/\s+/) ),
- oSpace = o.match(/\s+/g),
- nSpace = n.match(/\s+/g);
-
- if ( oSpace == null ) {
- oSpace = [ " " ];
- }
- else {
- oSpace.push( " " );
- }
-
- if ( nSpace == null ) {
- nSpace = [ " " ];
- }
- else {
- nSpace.push( " " );
- }
-
- if ( out.n.length === 0 ) {
- for ( i = 0; i < out.o.length; i++ ) {
- str += "<del>" + out.o[i] + oSpace[i] + "</del>";
- }
- }
- else {
- if ( out.n[0].text == null ) {
- for ( n = 0; n < out.o.length && out.o[n].text == null; n++ ) {
- str += "<del>" + out.o[n] + oSpace[n] + "</del>";
- }
- }
-
- for ( i = 0; i < out.n.length; i++ ) {
- if (out.n[i].text == null) {
- str += "<ins>" + out.n[i] + nSpace[i] + "</ins>";
- }
- else {
- // `pre` initialized at top of scope
- pre = "";
-
- for ( n = out.n[i].row + 1; n < out.o.length && out.o[n].text == null; n++ ) {
- pre += "<del>" + out.o[n] + oSpace[n] + "</del>";
- }
- str += " " + out.n[i].text + nSpace[i] + pre;
- }
- }
- }
-
- return str;
- };
+ var hasOwn = Object.prototype.hasOwnProperty;
+
+ /*jshint eqeqeq:false, eqnull:true */
+ function diff( o, n ) {
+ var i,
+ ns = {},
+ os = {};
+
+ for ( i = 0; i < n.length; i++ ) {
+ if ( !hasOwn.call( ns, n[ i ] ) ) {
+ ns[ n[ i ] ] = {
+ rows: [],
+ o: null
+ };
+ }
+ ns[ n[ i ] ].rows.push( i );
+ }
+
+ for ( i = 0; i < o.length; i++ ) {
+ if ( !hasOwn.call( os, o[ i ] ) ) {
+ os[ o[ i ] ] = {
+ rows: [],
+ n: null
+ };
+ }
+ os[ o[ i ] ].rows.push( i );
+ }
+
+ for ( i in ns ) {
+ if ( hasOwn.call( ns, i ) ) {
+ if ( ns[ i ].rows.length === 1 && hasOwn.call( os, i ) && os[ i ].rows.length === 1 ) {
+ n[ ns[ i ].rows[ 0 ] ] = {
+ text: n[ ns[ i ].rows[ 0 ] ],
+ row: os[ i ].rows[ 0 ]
+ };
+ o[ os[ i ].rows[ 0 ] ] = {
+ text: o[ os[ i ].rows[ 0 ] ],
+ row: ns[ i ].rows[ 0 ]
+ };
+ }
+ }
+ }
+
+ for ( i = 0; i < n.length - 1; i++ ) {
+ if ( n[ i ].text != null && n[ i + 1 ].text == null && n[ i ].row + 1 < o.length && o[ n[ i ].row + 1 ].text == null &&
+ n[ i + 1 ] == o[ n[ i ].row + 1 ] ) {
+
+ n[ i + 1 ] = {
+ text: n[ i + 1 ],
+ row: n[ i ].row + 1
+ };
+ o[ n[ i ].row + 1 ] = {
+ text: o[ n[ i ].row + 1 ],
+ row: i + 1
+ };
+ }
+ }
+
+ for ( i = n.length - 1; i > 0; i-- ) {
+ if ( n[ i ].text != null && n[ i - 1 ].text == null && n[ i ].row > 0 && o[ n[ i ].row - 1 ].text == null &&
+ n[ i - 1 ] == o[ n[ i ].row - 1 ] ) {
+
+ n[ i - 1 ] = {
+ text: n[ i - 1 ],
+ row: n[ i ].row - 1
+ };
+ o[ n[ i ].row - 1 ] = {
+ text: o[ n[ i ].row - 1 ],
+ row: i - 1
+ };
+ }
+ }
+
+ return {
+ o: o,
+ n: n
+ };
+ }
+
+ return function( o, n ) {
+ o = o.replace( /\s+$/, "" );
+ n = n.replace( /\s+$/, "" );
+
+ var i, pre,
+ str = "",
+ out = diff( o === "" ? [] : o.split( /\s+/ ), n === "" ? [] : n.split( /\s+/ ) ),
+ oSpace = o.match( /\s+/g ),
+ nSpace = n.match( /\s+/g );
+
+ if ( oSpace == null ) {
+ oSpace = [ " " ];
+ } else {
+ oSpace.push( " " );
+ }
+
+ if ( nSpace == null ) {
+ nSpace = [ " " ];
+ } else {
+ nSpace.push( " " );
+ }
+
+ if ( out.n.length === 0 ) {
+ for ( i = 0; i < out.o.length; i++ ) {
+ str += "<del>" + out.o[ i ] + oSpace[ i ] + "</del>";
+ }
+ } else {
+ if ( out.n[ 0 ].text == null ) {
+ for ( n = 0; n < out.o.length && out.o[ n ].text == null; n++ ) {
+ str += "<del>" + out.o[ n ] + oSpace[ n ] + "</del>";
+ }
+ }
+
+ for ( i = 0; i < out.n.length; i++ ) {
+ if ( out.n[ i ].text == null ) {
+ str += "<ins>" + out.n[ i ] + nSpace[ i ] + "</ins>";
+ } else {
+
+ // `pre` initialized at top of scope
+ pre = "";
+
+ for ( n = out.n[ i ].row + 1; n < out.o.length && out.o[ n ].text == null; n++ ) {
+ pre += "<del>" + out.o[ n ] + oSpace[ n ] + "</del>";
+ }
+ str += " " + out.n[ i ].text + nSpace[ i ] + pre;
+ }
+ }
+ }
+
+ return str;
+ };
}());
-// for CommonJS environments, export everything
-if ( typeof exports !== "undefined" ) {
- extend( exports, QUnit.constructor.prototype );
-}
+(function() {
-// get at whatever the global object is, like window in browsers
-}( (function() {return this;}.call()) ));
+// Deprecated QUnit.init - Ref #530
+// Re-initialize the configuration options
+ QUnit.init = function() {
+ var tests, banner, result, qunit,
+ config = QUnit.config;
+
+ config.stats = { all: 0, bad: 0 };
+ config.moduleStats = { all: 0, bad: 0 };
+ config.started = 0;
+ config.updateRate = 1000;
+ config.blocking = false;
+ config.autostart = true;
+ config.autorun = false;
+ config.filter = "";
+ config.queue = [];
+ config.semaphore = 1;
+
+ // Return on non-browser environments
+ // This is necessary to not break on node tests
+ if ( typeof window === "undefined" ) {
+ return;
+ }
+
+ qunit = id( "qunit" );
+ if ( qunit ) {
+ qunit.innerHTML =
+ "<h1 id='qunit-header'>" + escapeText( document.title ) + "</h1>" +
+ "<h2 id='qunit-banner'></h2>" +
+ "<div id='qunit-testrunner-toolbar'></div>" +
+ "<h2 id='qunit-userAgent'></h2>" +
+ "<ol id='qunit-tests'></ol>";
+ }
+
+ tests = id( "qunit-tests" );
+ banner = id( "qunit-banner" );
+ result = id( "qunit-testresult" );
+
+ if ( tests ) {
+ tests.innerHTML = "";
+ }
+
+ if ( banner ) {
+ banner.className = "";
+ }
+
+ if ( result ) {
+ result.parentNode.removeChild( result );
+ }
+
+ if ( tests ) {
+ result = document.createElement( "p" );
+ result.id = "qunit-testresult";
+ result.className = "result";
+ tests.parentNode.insertBefore( result, tests );
+ result.innerHTML = "Running...<br/>&nbsp;";
+ }
+ };
+
+// Resets the test setup. Useful for tests that modify the DOM.
+ /*
+ DEPRECATED: Use multiple tests instead of resetting inside a test.
+ Use testStart or testDone for custom cleanup.
+ This method will throw an error in 2.0, and will be removed in 2.1
+ */
+ QUnit.reset = function() {
+
+ // Return on non-browser environments
+ // This is necessary to not break on node tests
+ if ( typeof window === "undefined" ) {
+ return;
+ }
+
+ var fixture = id( "qunit-fixture" );
+ if ( fixture ) {
+ fixture.innerHTML = config.fixture;
+ }
+ };
+
+// Don't load the HTML Reporter on non-Browser environments
+ if ( typeof window === "undefined" ) {
+ return;
+ }
+
+ var config = QUnit.config,
+ hasOwn = Object.prototype.hasOwnProperty,
+ defined = {
+ document: typeof window.document !== "undefined",
+ sessionStorage: (function() {
+ var x = "qunit-test-string";
+ try {
+ sessionStorage.setItem( x, x );
+ sessionStorage.removeItem( x );
+ return true;
+ } catch ( e ) {
+ return false;
+ }
+ }())
+ };
+
+ /**
+ * Escape text for attribute or text content.
+ */
+ function escapeText( s ) {
+ if ( !s ) {
+ return "";
+ }
+ s = s + "";
+
+ // Both single quotes and double quotes (for attributes)
+ return s.replace( /['"<>&]/g, function( s ) {
+ switch ( s ) {
+ case "'":
+ return "&#039;";
+ case "\"":
+ return "&quot;";
+ case "<":
+ return "&lt;";
+ case ">":
+ return "&gt;";
+ case "&":
+ return "&amp;";
+ }
+ });
+ }
+
+ /**
+ * @param {HTMLElement} elem
+ * @param {string} type
+ * @param {Function} fn
+ */
+ function addEvent( elem, type, fn ) {
+ if ( elem.addEventListener ) {
+
+ // Standards-based browsers
+ elem.addEventListener( type, fn, false );
+ } else if ( elem.attachEvent ) {
+
+ // support: IE <9
+ elem.attachEvent( "on" + type, fn );
+ }
+ }
+
+ /**
+ * @param {Array|NodeList} elems
+ * @param {string} type
+ * @param {Function} fn
+ */
+ function addEvents( elems, type, fn ) {
+ var i = elems.length;
+ while ( i-- ) {
+ addEvent( elems[ i ], type, fn );
+ }
+ }
+
+ function hasClass( elem, name ) {
+ return ( " " + elem.className + " " ).indexOf( " " + name + " " ) >= 0;
+ }
+
+ function addClass( elem, name ) {
+ if ( !hasClass( elem, name ) ) {
+ elem.className += ( elem.className ? " " : "" ) + name;
+ }
+ }
+
+ function toggleClass( elem, name ) {
+ if ( hasClass( elem, name ) ) {
+ removeClass( elem, name );
+ } else {
+ addClass( elem, name );
+ }
+ }
+
+ function removeClass( elem, name ) {
+ var set = " " + elem.className + " ";
+
+ // Class name may appear multiple times
+ while ( set.indexOf( " " + name + " " ) >= 0 ) {
+ set = set.replace( " " + name + " ", " " );
+ }
+
+ // trim for prettiness
+ elem.className = typeof set.trim === "function" ? set.trim() : set.replace( /^\s+|\s+$/g, "" );
+ }
+
+ function id( name ) {
+ return defined.document && document.getElementById && document.getElementById( name );
+ }
+
+ function getUrlConfigHtml() {
+ var i, j, val,
+ escaped, escapedTooltip,
+ selection = false,
+ len = config.urlConfig.length,
+ urlConfigHtml = "";
+
+ for ( i = 0; i < len; i++ ) {
+ val = config.urlConfig[ i ];
+ if ( typeof val === "string" ) {
+ val = {
+ id: val,
+ label: val
+ };
+ }
+
+ escaped = escapeText( val.id );
+ escapedTooltip = escapeText( val.tooltip );
+
+ config[ val.id ] = QUnit.urlParams[ val.id ];
+ if ( !val.value || typeof val.value === "string" ) {
+ urlConfigHtml += "<input id='qunit-urlconfig-" + escaped +
+ "' name='" + escaped + "' type='checkbox'" +
+ ( val.value ? " value='" + escapeText( val.value ) + "'" : "" ) +
+ ( config[ val.id ] ? " checked='checked'" : "" ) +
+ " title='" + escapedTooltip + "'><label for='qunit-urlconfig-" + escaped +
+ "' title='" + escapedTooltip + "'>" + val.label + "</label>";
+ } else {
+ urlConfigHtml += "<label for='qunit-urlconfig-" + escaped +
+ "' title='" + escapedTooltip + "'>" + val.label +
+ ": </label><select id='qunit-urlconfig-" + escaped +
+ "' name='" + escaped + "' title='" + escapedTooltip + "'><option></option>";
+
+ if ( QUnit.is( "array", val.value ) ) {
+ for ( j = 0; j < val.value.length; j++ ) {
+ escaped = escapeText( val.value[ j ] );
+ urlConfigHtml += "<option value='" + escaped + "'" +
+ ( config[ val.id ] === val.value[ j ] ?
+ ( selection = true ) && " selected='selected'" : "" ) +
+ ">" + escaped + "</option>";
+ }
+ } else {
+ for ( j in val.value ) {
+ if ( hasOwn.call( val.value, j ) ) {
+ urlConfigHtml += "<option value='" + escapeText( j ) + "'" +
+ ( config[ val.id ] === j ?
+ ( selection = true ) && " selected='selected'" : "" ) +
+ ">" + escapeText( val.value[ j ] ) + "</option>";
+ }
+ }
+ }
+ if ( config[ val.id ] && !selection ) {
+ escaped = escapeText( config[ val.id ] );
+ urlConfigHtml += "<option value='" + escaped +
+ "' selected='selected' disabled='disabled'>" + escaped + "</option>";
+ }
+ urlConfigHtml += "</select>";
+ }
+ }
+
+ return urlConfigHtml;
+ }
+
+ function toolbarUrlConfigContainer() {
+ var urlConfigContainer = document.createElement( "span" );
+
+ urlConfigContainer.innerHTML = getUrlConfigHtml();
+
+ // For oldIE support:
+ // * Add handlers to the individual elements instead of the container
+ // * Use "click" instead of "change" for checkboxes
+ // * Fallback from event.target to event.srcElement
+ addEvents( urlConfigContainer.getElementsByTagName( "input" ), "click", function( event ) {
+ var params = {},
+ target = event.target || event.srcElement;
+ params[ target.name ] = target.checked ?
+ target.defaultValue || true :
+ undefined;
+ window.location = QUnit.url( params );
+ });
+ addEvents( urlConfigContainer.getElementsByTagName( "select" ), "change", function( event ) {
+ var params = {},
+ target = event.target || event.srcElement;
+ params[ target.name ] = target.options[ target.selectedIndex ].value || undefined;
+ window.location = QUnit.url( params );
+ });
+
+ return urlConfigContainer;
+ }
+
+ function getModuleNames() {
+ var i,
+ moduleNames = [];
+
+ for ( i in config.modules ) {
+ if ( config.modules.hasOwnProperty( i ) ) {
+ moduleNames.push( i );
+ }
+ }
+
+ moduleNames.sort(function( a, b ) {
+ return a.localeCompare( b );
+ });
+
+ return moduleNames;
+ }
+
+ function toolbarModuleFilterHtml() {
+ var i,
+ moduleFilterHtml = "",
+ moduleNames = getModuleNames();
+
+ if ( moduleNames.length <= 1 ) {
+ return false;
+ }
+
+ moduleFilterHtml += "<label for='qunit-modulefilter'>Module: </label>" +
+ "<select id='qunit-modulefilter' name='modulefilter'><option value='' " +
+ ( config.module === undefined ? "selected='selected'" : "" ) +
+ ">< All Modules ></option>";
+
+ for ( i = 0; i < moduleNames.length; i++ ) {
+ moduleFilterHtml += "<option value='" +
+ escapeText( encodeURIComponent( moduleNames[ i ] ) ) + "' " +
+ ( config.module === moduleNames[ i ] ? "selected='selected'" : "" ) +
+ ">" + escapeText( moduleNames[ i ] ) + "</option>";
+ }
+ moduleFilterHtml += "</select>";
+
+ return moduleFilterHtml;
+ }
+
+ function toolbarModuleFilter() {
+ var moduleFilter = document.createElement( "span" ),
+ moduleFilterHtml = toolbarModuleFilterHtml();
+
+ if ( !moduleFilterHtml ) {
+ return false;
+ }
+
+ moduleFilter.setAttribute( "id", "qunit-modulefilter-container" );
+ moduleFilter.innerHTML = moduleFilterHtml;
+
+ addEvent( moduleFilter.lastChild, "change", function() {
+ var selectBox = moduleFilter.getElementsByTagName( "select" )[ 0 ],
+ selectedModule = decodeURIComponent( selectBox.options[ selectBox.selectedIndex ].value );
+
+ window.location = QUnit.url({
+ module: ( selectedModule === "" ) ? undefined : selectedModule,
+
+ // Remove any existing filters
+ filter: undefined,
+ testNumber: undefined
+ });
+ });
+
+ return moduleFilter;
+ }
+
+ function toolbarFilter() {
+ var testList = id( "qunit-tests" ),
+ filter = document.createElement( "input" );
+
+ filter.type = "checkbox";
+ filter.id = "qunit-filter-pass";
+
+ addEvent( filter, "click", function() {
+ if ( filter.checked ) {
+ addClass( testList, "hidepass" );
+ if ( defined.sessionStorage ) {
+ sessionStorage.setItem( "qunit-filter-passed-tests", "true" );
+ }
+ } else {
+ removeClass( testList, "hidepass" );
+ if ( defined.sessionStorage ) {
+ sessionStorage.removeItem( "qunit-filter-passed-tests" );
+ }
+ }
+ });
+
+ if ( config.hidepassed || defined.sessionStorage &&
+ sessionStorage.getItem( "qunit-filter-passed-tests" ) ) {
+ filter.checked = true;
+
+ addClass( testList, "hidepass" );
+ }
+
+ return filter;
+ }
+
+ function toolbarLabel() {
+ var label = document.createElement( "label" );
+ label.setAttribute( "for", "qunit-filter-pass" );
+ label.setAttribute( "title", "Only show tests and assertions that fail. Stored in sessionStorage." );
+ label.innerHTML = "Hide passed tests";
+
+ return label;
+ }
+
+ function appendToolbar() {
+ var moduleFilter,
+ toolbar = id( "qunit-testrunner-toolbar" );
+
+ if ( toolbar ) {
+ toolbar.appendChild( toolbarFilter() );
+ toolbar.appendChild( toolbarLabel() );
+ toolbar.appendChild( toolbarUrlConfigContainer() );
+
+ moduleFilter = toolbarModuleFilter();
+ if ( moduleFilter ) {
+ toolbar.appendChild( moduleFilter );
+ }
+ }
+ }
+
+ function appendBanner() {
+ var banner = id( "qunit-banner" );
+
+ if ( banner ) {
+ banner.className = "";
+ banner.innerHTML = "<a href='" +
+ QUnit.url({ filter: undefined, module: undefined, testNumber: undefined }) +
+ "'>" + banner.innerHTML + "</a> ";
+ }
+ }
+
+ function appendTestResults() {
+ var tests = id( "qunit-tests" ),
+ result = id( "qunit-testresult" );
+
+ if ( result ) {
+ result.parentNode.removeChild( result );
+ }
+
+ if ( tests ) {
+ tests.innerHTML = "";
+ result = document.createElement( "p" );
+ result.id = "qunit-testresult";
+ result.className = "result";
+ tests.parentNode.insertBefore( result, tests );
+ result.innerHTML = "Running...<br>&nbsp;";
+ }
+ }
+
+ function storeFixture() {
+ var fixture = id( "qunit-fixture" );
+ if ( fixture ) {
+ config.fixture = fixture.innerHTML;
+ }
+ }
+
+ function appendUserAgent() {
+ var userAgent = id( "qunit-userAgent" );
+ if ( userAgent ) {
+ userAgent.innerHTML = navigator.userAgent;
+ }
+ }
+
+// HTML Reporter initialization and load
+ QUnit.begin(function() {
+ var qunit = id( "qunit" );
+
+ if ( qunit ) {
+ qunit.innerHTML =
+ "<h1 id='qunit-header'>" + escapeText( document.title ) + "</h1>" +
+ "<h2 id='qunit-banner'></h2>" +
+ "<div id='qunit-testrunner-toolbar'></div>" +
+ "<h2 id='qunit-userAgent'></h2>" +
+ "<ol id='qunit-tests'></ol>";
+ }
+
+ appendBanner();
+ appendTestResults();
+ appendUserAgent();
+ appendToolbar();
+ storeFixture();
+ });
+
+ QUnit.done(function( details ) {
+ var i, key,
+ banner = id( "qunit-banner" ),
+ tests = id( "qunit-tests" ),
+ html = [
+ "Tests completed in ",
+ details.runtime,
+ " milliseconds.<br>",
+ "<span class='passed'>",
+ details.passed,
+ "</span> assertions of <span class='total'>",
+ details.total,
+ "</span> passed, <span class='failed'>",
+ details.failed,
+ "</span> failed."
+ ].join( "" );
+
+ if ( banner ) {
+ banner.className = details.failed ? "qunit-fail" : "qunit-pass";
+ }
+
+ if ( tests ) {
+ id( "qunit-testresult" ).innerHTML = html;
+ }
+
+ if ( config.altertitle && defined.document && document.title ) {
+
+ if (document.title === (document.title + '')) {
+ // show ✖ for good, ✔ for bad suite result in title
+ // use escape sequences in case file gets loaded with non-utf-8-charset
+ document.title = [
+ ( details.failed ? "\u2716" : "\u2714" ),
+ document.title.replace( /^[\u2714\u2716] /i, "" )
+ ].join( " " );
+ }
+
+ }
+
+ // clear own sessionStorage items if all tests passed
+ if ( config.reorder && defined.sessionStorage && details.failed === 0 ) {
+ for ( i = 0; i < sessionStorage.length; i++ ) {
+ key = sessionStorage.key( i++ );
+ if ( key.indexOf( "qunit-test-" ) === 0 ) {
+ sessionStorage.removeItem( key );
+ }
+ }
+ }
+
+ // scroll back to top to show results
+ if ( config.scrolltop && window.scrollTo ) {
+ window.scrollTo( 0, 0 );
+ }
+ });
+
+ function getNameHtml( name, module ) {
+ var nameHtml = "";
+
+ if ( module ) {
+ nameHtml = "<span class='module-name'>" + escapeText( module ) + "</span>: ";
+ }
+
+ nameHtml += "<span class='test-name'>" + escapeText( name ) + "</span>";
+
+ return nameHtml;
+ }
+
+ QUnit.testStart(function( details ) {
+ var a, b, li, running, assertList,
+ name = getNameHtml( details.name, details.module ),
+ tests = id( "qunit-tests" );
+
+ if ( tests ) {
+ b = document.createElement( "strong" );
+ b.innerHTML = name;
+
+ a = document.createElement( "a" );
+ a.innerHTML = "Rerun";
+ a.href = QUnit.url({ testNumber: details.testNumber });
+
+ li = document.createElement( "li" );
+ li.appendChild( b );
+ li.appendChild( a );
+ li.className = "running";
+ li.id = "qunit-test-output" + details.testNumber;
+
+ assertList = document.createElement( "ol" );
+ assertList.className = "qunit-assert-list";
+
+ li.appendChild( assertList );
+
+ tests.appendChild( li );
+ }
+
+ running = id( "qunit-testresult" );
+ if ( running ) {
+ running.innerHTML = "Running: <br>" + name;
+ }
+
+ });
+
+ QUnit.log(function( details ) {
+ var assertList, assertLi,
+ message, expected, actual,
+ testItem = id( "qunit-test-output" + details.testNumber );
+
+ if ( !testItem ) {
+ return;
+ }
+
+ message = escapeText( details.message ) || ( details.result ? "okay" : "failed" );
+ message = "<span class='test-message'>" + message + "</span>";
+
+ // pushFailure doesn't provide details.expected
+ // when it calls, it's implicit to also not show expected and diff stuff
+ // Also, we need to check details.expected existence, as it can exist and be undefined
+ if ( !details.result && hasOwn.call( details, "expected" ) ) {
+ expected = escapeText( QUnit.dump.parse( details.expected ) );
+ actual = escapeText( QUnit.dump.parse( details.actual ) );
+ message += "<table><tr class='test-expected'><th>Expected: </th><td><pre>" +
+ expected +
+ "</pre></td></tr>";
+
+ if ( actual !== expected ) {
+ message += "<tr class='test-actual'><th>Result: </th><td><pre>" +
+ actual + "</pre></td></tr>" +
+ "<tr class='test-diff'><th>Diff: </th><td><pre>" +
+ QUnit.diff( expected, actual ) + "</pre></td></tr>";
+ }
+
+ if ( details.source ) {
+ message += "<tr class='test-source'><th>Source: </th><td><pre>" +
+ escapeText( details.source ) + "</pre></td></tr>";
+ }
+
+ message += "</table>";
+
+ // this occours when pushFailure is set and we have an extracted stack trace
+ } else if ( !details.result && details.source ) {
+ message += "<table>" +
+ "<tr class='test-source'><th>Source: </th><td><pre>" +
+ escapeText( details.source ) + "</pre></td></tr>" +
+ "</table>";
+ }
+
+ assertList = testItem.getElementsByTagName( "ol" )[ 0 ];
+
+ assertLi = document.createElement( "li" );
+ assertLi.className = details.result ? "pass" : "fail";
+ assertLi.innerHTML = message;
+ assertList.appendChild( assertLi );
+ });
+
+ QUnit.testDone(function( details ) {
+ var testTitle, time, testItem, assertList,
+ good, bad, testCounts,
+ tests = id( "qunit-tests" );
+
+ // QUnit.reset() is deprecated and will be replaced for a new
+ // fixture reset function on QUnit 2.0/2.1.
+ // It's still called here for backwards compatibility handling
+ QUnit.reset();
+
+ if ( !tests ) {
+ return;
+ }
+
+ testItem = id( "qunit-test-output" + details.testNumber );
+ assertList = testItem.getElementsByTagName( "ol" )[ 0 ];
+
+ good = details.passed;
+ bad = details.failed;
+
+ // store result when possible
+ if ( config.reorder && defined.sessionStorage ) {
+ if ( bad ) {
+ sessionStorage.setItem( "qunit-test-" + details.module + "-" + details.name, bad );
+ } else {
+ sessionStorage.removeItem( "qunit-test-" + details.module + "-" + details.name );
+ }
+ }
+
+ if ( bad === 0 ) {
+ addClass( assertList, "qunit-collapsed" );
+ }
+
+ // testItem.firstChild is the test name
+ testTitle = testItem.firstChild;
+
+ testCounts = bad ?
+ "<b class='failed'>" + bad + "</b>, " + "<b class='passed'>" + good + "</b>, " :
+ "";
+
+ testTitle.innerHTML += " <b class='counts'>(" + testCounts +
+ details.assertions.length + ")</b>";
+
+ addEvent( testTitle, "click", function() {
+ toggleClass( assertList, "qunit-collapsed" );
+ });
+
+ time = document.createElement( "span" );
+ time.className = "runtime";
+ time.innerHTML = details.runtime + " ms";
+
+ testItem.className = bad ? "fail" : "pass";
+
+ testItem.insertBefore( time, assertList );
+ });
+
+ if ( !defined.document || document.readyState === "complete" ) {
+ config.autorun = true;
+ }
+
+ if ( defined.document ) {
+ addEvent( window, "load", QUnit.load );
+ }
+
+})(); \ No newline at end of file
diff --git a/tests/javascript/content-fixtures/contentUtilities.html b/tests/javascript/content-fixtures/contentUtilities.html
new file mode 100644
index 0000000000..4db210e5ae
--- /dev/null
+++ b/tests/javascript/content-fixtures/contentUtilities.html
@@ -0,0 +1,85 @@
+<div id="ignoreInteraction1" data-track-content>
+ <a href="http://www.example.com" class="piwikContentTarget piwikContentIgnoreInteraction">Link</a>
+</div>
+<div id="ignoreInteraction2" data-track-content>
+ <a href="http://www.example.com" class="piwikContentTarget" data-content-ignoreinteraction>Link</a>
+</div>
+<!-- targetNode in this example is the content block node -->
+<div id="ignoreInteraction3" data-track-content data-content-ignoreinteraction>
+ <a href="http://www.example.com">Link</a>
+</div>
+<!-- targetNode in this example is the content block node -->
+<div id="ignoreInteraction4" data-track-content class="piwikContentIgnoreInteraction">
+ <a href="http://www.example.com">Link</a>
+</div>
+
+<div id="notIgnoreInteraction1" data-track-content>
+ <a href="http://www.example.com" class="piwikContentTarget">Link</a>
+</div>
+<!-- Will not be ignored since set on wrong element, has to be set on content target node -->
+<div id="notIgnoreInteraction2" data-track-content data-content-ignoreinteraction class="piwikContentIgnoreInteraction">
+ <a href="http://www.example.com" class="piwikContentTarget">Link</a>
+</div>
+
+
+<!-- test to make sure only clicked elements within target node are authorized -->
+<!-- authorized because content block is target and a link is within -->
+<div id="authorized1" data-track-content>
+ <a id="authorized1_1" href="http://www.example.com">Link</a>
+</div>
+<!-- authorized because span is within target -->
+<div id="authorized2" data-track-content>
+ <a href="http://www.example.com" data-content-target id="authorized2_1">Link<span id="authorized2_2"></span></a>
+</div>
+<!-- not authorized because span is not in target -->
+<div id="authorized3" data-track-content>
+ <span id="authorized3_1"></span>
+ <a href="http://www.example.com" data-content-target id="authorized3_2">Link</a>
+</div>
+
+
+
+<a href="http://www.example.com" id="aLinkToBeChanged">Link</a>
+
+<div class="media">
+ <div id="mediaDiv" src="test/img.jpg"/>
+ <img id="mediaImg" src="test/img.jpg"/>
+
+ <video id="mediaVideo" width="320" height="240" controls>
+ <source src="movie.mp4" type="video/mp4">
+ <source src="movie.ogg" type="video/ogg">
+ Your browser does not support the video tag.
+ </video>
+
+ <audio id="mediaAudio" controls>
+ <source src="audio.ogg" type="audio/ogg">
+ <source src="audio.mp3" type="audio/mpeg">
+ Your browser does not support the audio element.
+ </audio>
+
+ <embed id="mediaEmbed" src="embed.swf">
+
+ <object id="mediaObjectSimple" width="400" height="400" data="objectSimple.swf"></object>
+
+ <object id="mediaObjectParam" classid="clsid:d27cdb6e-ae6d-11cf-96b8-444553540000" width="550"
+ height="400" id="movie_name" align="middle">
+ <param name="anything" value="anyvalue"/>
+ <param name="movie" value="movie_param1.swf"/>
+ <!--[if !IE]>-->
+ <object type="application/x-shockwave-flash" data="movie_inner.swf" width="550" height="400">
+ <param name="movie" value="movie_param2.swf"/>
+ <!<![endif]--> <a href="http://www.adobe.com/go/getflash"> <img
+ src="http://www.adobe.com/de/images/shared/download_buttons/get_flash_player.gif"
+ alt="Get Adobe Flash player"/> </a> <!--[if !IE]>--> </object>
+ <!<![endif]-->
+ </object>
+
+ <object id="mediaObjectPdf" data="document.pdf" type="application/pdf">
+ <embed src="document2.pdf" type="application/pdf" />
+ </object>
+
+ <!-- should fall back to embed as no data specified -->
+ <object id="mediaObjectEmbed" data="" type="application/pdf">
+ <embed src="document2.pdf" type="application/pdf" />
+ </object>
+</div> \ No newline at end of file
diff --git a/tests/javascript/content-fixtures/findContentBlockTest.html b/tests/javascript/content-fixtures/findContentBlockTest.html
new file mode 100644
index 0000000000..0ef162c834
--- /dev/null
+++ b/tests/javascript/content-fixtures/findContentBlockTest.html
@@ -0,0 +1,10 @@
+<img src="img-en.jpg" id="isOneWithAttribute" data-track-content/>
+<img src="img-en.jpg" id="isOneWithClass" class="piwikTrackContent"/>
+<!-- next content block tests it will find block only once although attribute and class is used -->
+<div><a href="http://www.example.com" data-track-content class="piwikTrackContent"><img src="img-en.jpg" data-content-piece/><div id="innerNode"></div></a></div>
+<div></div>
+<div id="containsOneWithAttribute">
+ <div>
+ <div data-track-content><input type="submit"/></div>
+ </div>
+</div> \ No newline at end of file
diff --git a/tests/javascript/content-fixtures/findContentNodesTest.html b/tests/javascript/content-fixtures/findContentNodesTest.html
new file mode 100644
index 0000000000..94a1fdc99e
--- /dev/null
+++ b/tests/javascript/content-fixtures/findContentNodesTest.html
@@ -0,0 +1,35 @@
+<div>
+ <img id="ex1" src="img-en.jpg" data-track-content/>
+ <img id="ex2" src="img-en.jpg" class="piwikTrackContent"/>
+ <!-- used for testing detection of content elements via attribute -->
+ <div id="ex3" data-track-content>
+ <img src="http://www.example.com/path/xyz.jpg" data-content-piece />
+ <a href="/anylink" data-content-target>Add to shopping cart</a>
+ </div>
+ <!-- used for testing detection of content elements via class -->
+ <div id="ex4" data-track-content>
+ <img src="http://www.example.com/path/xyz.jpg" class="piwikContentPiece" />
+ <a href="/anylink" class="piwikContentTarget">Add to shopping cart</a>
+ </div>
+ <!-- used for testing detection of attribute takes precendency over CSS-->
+ <div id="ex5" data-track-content>
+ <span src="http://www.example.com/path/xyz.jpg" class="piwikContentPiece">Piece with class</span>
+ <span src="http://www.example.com/path/xyz.jpg" data-content-piece>Piece with attribute</span>
+ <a href="/anylink" class="piwikContentTarget">Target with class</a>
+ <a href="/anylink" data-content-target>Target with attribute</a>
+ </div>
+ <!-- used for testing always the first matching one will be picked if many have same class -->
+ <div id="ex6" data-track-content>
+ <span src="http://www.example.com/path/xyz.jpg" class="piwikContentPiece">Piece with class1</span>
+ <span src="http://www.example.com/path/xyz.jpg" class="piwikContentPiece">Piece with class2</span>
+ <a href="/anylink" class="piwikContentTarget">Target with class1</a>
+ <a href="/anylink" class="piwikContentTarget">Target with class2</a>
+ </div>
+ <!-- used for testing always the first matching one will be picked if many have same attribute -->
+ <div id="ex7" data-track-content>
+ <span src="http://www.example.com/path/xyz.jpg" data-content-piece>Piece with attribute1</span>
+ <span src="http://www.example.com/path/xyz.jpg" data-content-piece>Piece with attribute2</span>
+ <a href="/anylink" data-content-target>Target with attribute1</a>
+ <a href="/anylink" data-content-target>Target with attribute2</a>
+ </div>
+</div> \ No newline at end of file
diff --git a/tests/javascript/content-fixtures/manyExamples.html b/tests/javascript/content-fixtures/manyExamples.html
new file mode 100644
index 0000000000..876d2a6cbd
--- /dev/null
+++ b/tests/javascript/content-fixtures/manyExamples.html
@@ -0,0 +1,80 @@
+<div>
+ <img id="ex1" src="img-en.jpg" data-track-content/>
+ <img id="ex2" src="img-en.jpg" class="piwikTrackContent"/>
+ <a id="ex3" href="http://www.example.com" data-track-content><img src="img-en.jpg" data-content-piece="img.jpg"/></a>
+ <a id="ex4" href="http://www.example.com" data-track-content><img src="img-en.jpg" data-content-piece/></a>
+ <a id="ex5" href="http://www.example.com" data-track-content><img src="img-en.jpg" class="piwikContentPiece"/></a>
+ <a id="ex6" href="http://www.example.com" data-track-content><p data-content-piece>Lorem ipsum dolor sit amet</p></a>
+ <a id="ex7" href="http://www.example.com" data-track-content><p class="piwikContentPiece">Lorem ipsum dolor sit amet</p></a>
+ <a id="ex8" href="http://www.example.com" data-track-content><p data-content-piece="My content">Lorem ipsum dolor sit amet...</p></a>
+ <img id="ex9" src="img-en.jpg" data-track-content data-content-name="Image1"/>
+ <!-- make sure we will not remove domain from src for content name as different domain -->
+ <img id="ex10" src="http://www.example.com/path/img-en.jpg" data-track-content/>
+ <a id="ex11" href="http://www.example.com" data-track-content>Lorem ipsum dolor sit amet...</p></a>
+ <!-- test fallback to title attribute, content block title should win over other titles -->
+ <a id="ex12" href="http://www.example.com" data-track-content title="Block Title"><span title="Inner Title" data-content-piece>Lorem ipsum dolor sit amet...</span></a>
+ <a id="ex13" onclick="location.href='http://www.example.com'" data-content-target="http://manual.example.com" data-track-content>Click me</a>
+ <div id="ex14" data-track-content><input type="submit"/></div>
+ <div id="ex15" data-track-content><input type="submit" data-content-target="http://attr.example.com"/></div>
+ <div id="ex16" data-track-content><a href="http://www.example.com" data-content-target>Click me</a></div>
+ <div id="ex17" data-track-content><a href="http://www.example.com" class="piwikContentTarget">Click me</a></div>
+ <div id="ex18" data-track-content data-content-name="My Ad">
+ <img src="http://www.example.com/path/xyz.jpg" data-content-piece />
+ <a href="/anylink" data-content-target>Add to shopping cart</a>
+ <!-- we should automatically add domain to content target in this example -->
+ </div>
+ <a id="ex19" href="http://ad.example.com" data-track-content>
+ <img src="http://www.example.com/path/xyz.jpg" data-content-piece />
+ </a>
+ <a id="ex20" href="http://ad.example.com" data-track-content data-content-name="My Ad">
+ Lorem ipsum....
+ </a>
+ <!-- test fallback to title attribute of content block -->
+ <a id="ex21" href="http://www.example.com" data-track-content title="Block Title"><span data-content-piece>Lorem ipsum dolor sit amet...</span></a>
+ <!-- test fallback to title attribute of content piece -->
+ <a id="ex22" href="http://www.example.com" data-track-content><span title="Piece Title" data-content-piece>Lorem ipsum dolor sit amet...</span></a>
+ <!-- test fallback to title attribute of content target -->
+ <a id="ex23" href="http://www.example.com" data-track-content><span title="Target Title" data-content-target>Lorem ipsum dolor sit amet...</span></a>
+ <!-- test fallback title... content piece title should win over content target title -->
+ <div id="ex24" data-track-content>
+ <a title="Target Title" data-content-target="http://target.example.com">Lorem ipsum dolor sit amet...</a>
+ <span title="Piece Title" data-content-piece>Lorem ipsum dolor sit amet...</span>
+ </div>
+ <!-- Mix of attributes and classes -->
+ <div id="ex25" data-track-content data-content-name="My Ad">
+ <img src="http://www.example.com/path/xyz.jpg" class="piwikContentPiece" />
+ <a href="/anylink" class="piwikContentTarget">Add to shopping cart</a>
+ </div>
+ <!-- If no href attribute in content target detected, we try to find one in the content piece, different domain -->
+ <div id="ex26" data-track-content data-content-name="My Ad">
+ <a href="http://fallback.example.com" class="piwikContentPiece" >Test</a>
+ </div>
+ <!-- If no href attribute in content target detected, we try to find one in the content piece, absolute url -->
+ <div id="ex27" data-track-content data-content-name="My Ad">
+ <a href="/test" class="piwikContentPiece">Test</a>
+ </div>
+ <!-- If no href attribute in content target detected, we try to find one in the content piece, relative url -->
+ <div id="ex28" data-track-content data-content-name="My Ad">
+ <a href="test" class="piwikContentPiece">Test</a>
+ </div>
+ <div id="ex29" data-track-content data-content-name="My Video">
+ <video width="320" height="240" controls data-content-piece data-content-target="videoplayer">
+ <source src="movie.mp4" type="video/mp4">
+ <source src="movie.ogg" type="video/ogg">
+ Your browser does not support the video tag.
+ </video>
+ </div>
+ <div id="ex30" data-track-content>
+ <audio data-content-target="audioplayer" class="piwikContentPiece" controls>
+ <source src="audio.ogg" type="audio/ogg">
+ <source src="audio.mp3" type="audio/mpeg">
+ Your browser does not support the audio element.
+ </audio>
+ </div>
+ <!-- example with whitespace -->
+ <div id="ex31" data-track-content
+ data-content-piece=" pie ce "
+ data-content-target=" targ et "
+ data-content-name=" name ">
+ </div>
+</div> \ No newline at end of file
diff --git a/tests/javascript/content-fixtures/trackerInternals.html b/tests/javascript/content-fixtures/trackerInternals.html
new file mode 100644
index 0000000000..ee4494c65d
--- /dev/null
+++ b/tests/javascript/content-fixtures/trackerInternals.html
@@ -0,0 +1,80 @@
+<div id="ex101" data-track-content>
+ <a id="ignoreInteraction1" href="http://www.example.com"
+ class="piwikContentTarget piwikContentIgnoreInteraction">Link</a>
+</div>
+<div id="ex102" data-track-content data-content-ignoreinteraction>
+ <a id="ignoreInteraction2" href="http://www.example.com">Link</a>
+</div>
+<div id="ex103" data-track-content>
+ <a title="Target Title" href="http://target.example.com" data-content-target>Lorem ipsum dolor sit amet...</a>
+ <span id="notClickedTargetNode" title="Piece Title">Lorem ipsum dolor sit amet...</span>
+</div>
+<div id="ex104" data-track-content>
+ <img src="img.jpg" data-content-piece/>
+ <a id="ex104_inner" href="http://www.example.com">Link</a>
+</div>
+<div id="ex105" data-track-content>
+ <img src="img.jpg" data-content-piece/>
+ <a id="ex105_target" data-content-target="" href="http://www.example.com">
+ <span id="ex105_withinTarget">test</span>Link</a>
+</div>
+<!-- click event should be added to target node -->
+<div id="ex106" data-track-content data-content-name="My Ad">
+ <a id="ex106_target" href="/anylink" data-content-target>Add to shopping cart</a>
+</div>
+<!-- click event should be added to content block node which is target node -->
+<a id="ex107" data-track-content data-content-name="My Ad">
+ <img src="img.jpg" data-content-piece/>
+</a>
+<!-- trackContentImpressionClickInteraction, should be tracked as outlink -->
+<a id="ex108" href="http://ad.example.com" data-track-content>
+ <img src="http://www.example.com/path/xyz.jpg" data-content-piece />
+</a>
+<!-- trackContentImpressionClickInteraction, should be tracked as download -->
+<a id="ex109" href="/file.pdf" data-track-content>
+ <img src="http://www.example.com/path/xyz.jpg" data-content-piece />
+</a>
+<!-- trackContentImpressionClickInteraction, should be tracked using redirect as internal page link -->
+<a id="ex110" href="/example" data-track-content data-content-name="MyName">
+ <img src="http://www.example.com/path/xyz.jpg" data-content-piece="img.jpg" />
+</a>
+<!-- trackContentImpressionClickInteraction, if a link to tracker is already set we do not alter this link even if link is wrong -->
+<a id="ex111" href="piwik.php?xyz=makesnosense" data-track-content data-content-name="MyName">
+ <img src="http://www.example.com/path/xyz.jpg" data-content-piece="img.jpg" />
+</a>
+<!-- trackContentImpressionClickInteraction, should be tracked as XHR as link within same page -->
+<a id="ex112" href="#example" data-track-content>
+ <img src="http://www.example.com/path/xyz.jpg" data-content-piece="img.jpg" />
+</a>
+<!-- trackContentImpressionClickInteraction, target link is not an A or AREA element -->
+<div id="ex113" data-track-content>
+ <img id="ex113_target" src="http://www.example.com/path/xyz.jpg" data-content-target data-content-piece="img.jpg" />
+</div>
+<!-- trackContentImpressionClickInteraction, target link is link element but does not have href, we track it via xhr -->
+<a id="ex114" onclick="void 0" data-track-content data-content-target="/test">
+ <img src="http://www.example.com/path/xyz.jpg" data-content-piece="imgnohref.jpg" />
+</a>
+
+<!-- some nodes are visible, some not -->
+<a id="ex115" href="http://www.example.com" data-track-content class="assertSize">
+ <img src="img115.jpg" data-content-piece />
+</a>
+<a id="ex116_hidden" href="http://www.example.com" data-track-content class="assertSize" style="display:none">
+ <img src="img116.jpg" data-content-piece />
+</a>
+
+<div id="ex117" data-track-content>
+ <a id="ignoreInternalLink" href="/internallink" class="piwikContentIgnoreInteraction" data-content-target>Link</a>
+</div>
+
+
+<a id="ex118" href="piwik.php?test=5" data-track-content>Link</a>
+<a id="ex119" href="#test" data-track-content>Link</a>
+<a id="ex120" href="http://www.example.com" data-track-content>Link</a>
+<a id="ex121" href="/download.pdf" class="download" data-track-content>Link</a>
+<div id="ex122" data-track-content>
+ <a id="replacedLinkWithTarget" href="/internallink" data-content-target="/test">Link</a>
+</div>
+<div id="ex123" data-track-content>
+ <a id="replacedLinkWithTarget" data-content-target="/test">Link</a>
+</div>
diff --git a/tests/javascript/content-fixtures/trackingContent.html b/tests/javascript/content-fixtures/trackingContent.html
new file mode 100644
index 0000000000..8ff0918927
--- /dev/null
+++ b/tests/javascript/content-fixtures/trackingContent.html
@@ -0,0 +1,21 @@
+<div>
+ <img src="img1-en.jpg" data-track-content/>
+ <img src="img1-en.jpg" class="piwikTrackContent"/> <!-- same as before should be ignored -->
+ <div id="block1" style="display: none;">
+ <a href="http://img2.example.com" data-track-content id="isOutlink"><img id="isWithinOutlink" src="img2-en.jpg" data-content-piece="img.jpg"/></a>
+ <a href="http://img3.example.com" data-track-content><img src="img3-en.jpg" data-content-piece/></a>
+ <a href="http://img4.example.com" data-track-content><p data-content-piece="My content 4">Lorem ipsum</p></a>
+ </div>
+ <div id="block2">
+ <div id="ex5" data-track-content data-content-name="My Ad 5">
+ <img src="http://img5.example.com/path/xyz.jpg" data-content-piece id="notWithinTarget" />
+ <a href="/anylink5" data-content-target id="internalLink">Lorem ipsum</a>
+ </div>
+ <a href="http://img6.example.com" data-track-content>
+ <img src="http://www.example.com/path/xyz.jpg" data-content-piece />
+ </a>
+ <a href="http://img7.example.com" data-track-content data-content-name="My Ad 7" style="visibility:hidden;">
+ Lorem ipsum
+ </a>
+ </div>
+</div> \ No newline at end of file
diff --git a/tests/javascript/content-fixtures/visibleNodes.html b/tests/javascript/content-fixtures/visibleNodes.html
new file mode 100644
index 0000000000..66b49badaa
--- /dev/null
+++ b/tests/javascript/content-fixtures/visibleNodes.html
@@ -0,0 +1,50 @@
+<div class="assertSize" id="ex1"></div> <!-- visible -->
+<div class="assertSize" id="ex2" style="opacity: 0"></div>
+<div class="assertSize" id="ex3" style="visibility: hidden"></div>
+<div class="assertSize" id="ex4" style="display: none"></div>
+<div class="assertSize" id="ex5" style="width: 0px;"></div>
+<div class="assertSize" id="ex6" style="height: 0px;"></div>
+<div class="assertSize" id="ex7" style="width: 0px;overflow: hidden;"></div>
+<div class="assertSize" id="ex8" style="height: 0px;overflow: hidden;"></div>
+
+<div id="ex9" style="margin-left: -110px;width: 50px;">Test</div>
+<div id="ex10" style="margin-left: 1000000px; width: 50px;">Test</div>
+
+<!-- The elements itself are visible but hidden by a parent -->
+<div class="assertSize" style="opacity: 0"><div class="assertSize" id="ex13"></div></div>
+<div class="assertSize" style="visibility: hidden"><div class="assertSize" id="ex14"></div></div>
+<div class="assertSize" style="display: none"><div class="assertSize" id="ex15"></div></div>
+<div class="assertSize" style="width: 0px;overflow: hidden;"><div class="assertSize" id="ex16"></div></div>
+<div class="assertSize" style="height: 0px;overflow: hidden;"><div class="assertSize" id="ex17"></div></div>
+
+<!-- at least one pixel has to be visible of the element -->
+<div id="ex18" style="margin-left: -118px;width: 110px;">Test</div>
+<div id="ex19" style="margin-left: -118px; width: 111px;">Test</div>
+
+<!-- positioned absolute -->
+<div id="ex20" style="height: 20px;width: 20px;position: absolute;left: 1px;top: -19px;"></div>
+<div id="ex21" style="height: 20px;width: 20px;position: absolute;left: -19px;top: 0px;"></div>
+<div id="ex22" style="height: 20px;width: 20px;position: absolute;right: -19px;top: 0px;"></div>
+<div id="ex23" style="height: 20px;width: 20px;position: absolute;left: 1px;bottom: -19px;"></div>
+
+<div id="ex24" style="height: 20px;width: 20px;position: absolute;left: 1px;top: -20px;"></div>
+<div id="ex25" style="height: 20px;width: 20px;position: absolute;left: -20px;top: 0px;"></div>
+<div id="ex26" style="height: 20px;width: 20px;position: absolute;right: -20px;top: 0px;"></div>
+<div id="ex27" style="height: 20px;width: 20px;position: absolute;left: 1px;bottom: -20px;"></div>
+
+<!-- positioned fixed -->
+<div id="ex28" style="height: 20px;width: 20px;position: fixed;left: 1px;top: -19px;"></div>
+<div id="ex29" style="height: 20px;width: 20px;position: fixed;left: -19px;top: 0px;"></div>
+<div id="ex30" style="height: 20px;width: 20px;position: fixed;right: -19px;top: 0px;"></div>
+<div id="ex31" style="height: 20px;width: 20px;position: fixed;left: 1px;bottom: -19px;"></div>
+
+<div id="ex32" style="height: 20px;width: 20px;position: fixed;left: 1px;top: -20px;"></div>
+<div id="ex33" style="height: 20px;width: 20px;position: fixed;left: -20px;top: 0px;"></div>
+<div id="ex34" style="height: 20px;width: 20px;position: fixed;right: -20px;top: 0px;"></div>
+<div id="ex35" style="height: 20px;width: 20px;position: fixed;left: 1px;bottom: -20px;"></div>
+
+<!-- nodes whose parent is scrollable -->
+<div class="assertSize" id="ex36" style="overflow: scroll;position: fixed;top:0px;left:0px;width:20px;height: 20px;">
+ <div class="assertSize" id="ex37" style="height:30px;"></div>
+ <div class="assertSize" id="ex38" style="height:5px;"></div>
+</div>
diff --git a/tests/javascript/index.php b/tests/javascript/index.php
index ca7f887ac1..a1ff474547 100644
--- a/tests/javascript/index.php
+++ b/tests/javascript/index.php
@@ -15,6 +15,9 @@ if(file_exists("stub.tpl")) {
function getToken() {
return "<?php $token = md5(uniqid(mt_rand(), true)); echo $token; ?>";
}
+function getContentToken() {
+ return "<?php $token = md5(uniqid(mt_rand(), true)); echo $token; ?>";
+}
<?php
$sqlite = false;
if (file_exists("enable_sqlite")) {
@@ -53,9 +56,25 @@ testTrackPageViewAsync();
<script src="piwiktest.js" type="text/javascript"></script>
<link rel="stylesheet" href="assets/qunit.css" type="text/css" media="screen" />
<link rel="stylesheet" href="jash/Jash.css" type="text/css" media="screen" />
+<style>
+ .assertSize {
+ height: 1px;
+ width: 1px;
+ }
+ .hideY {
+ overflow-x: hidden !important;
+ }
+ .ie #contenttest {
+ position: relative;
+ margin-left: 8px;
+ margin-right: 8px;
+ }
+</style>
+ <script src="../../libs/jquery/jquery.js" type="text/javascript"></script>
<script src="assets/qunit.js" type="text/javascript"></script>
<script src="jslint/jslint.js" type="text/javascript"></script>
<script type="text/javascript">
+ QUnit.config.reorder = false;
function _e(id){
if (document.getElementById)
return document.getElementById(id);
@@ -64,6 +83,27 @@ function _e(id){
if (document.all)
return document.all[id];
}
+ function isIE () {
+ var myNav = navigator.userAgent.toLowerCase();
+ return (myNav.indexOf('msie') != -1) ? parseInt(myNav.split('msie')[1]) : false;
+ }
+
+function _s(selector) { // select node within content test scope
+ $nodes = $('#contenttest ' + selector);
+ if ($nodes.length) {
+ return $nodes[0];
+ } else {
+ ok(false, 'selector not found but should: #contenttest ' + selector);
+ }
+}
+
+ function getOrigin()
+ {
+ if (window.location.origin) {
+ return window.location.origin;
+ }
+ return window.location.protocol + "//" + window.location.hostname + (window.location.port ? ':' + window.location.port: '');
+ }
function loadJash() {
var jashDiv = _e('jashDiv');
@@ -72,7 +112,45 @@ function loadJash() {
document.body.appendChild(document.createElement('script')).src='jash/Jash.js';
}
-function dropCookie(cookieName, path, domain) {
+ function scrollToTop()
+ {
+ window.scroll(0, 0);
+ }
+
+function triggerEvent(element, type) {
+ if ( document.createEvent ) {
+ var event = document.createEvent( "MouseEvents" );
+ event.initMouseEvent(type, true, true, element.ownerDocument.defaultView,
+ 0, 0, 0, 0, 0, false, false, false, false, 0, null);
+ element.dispatchEvent( event );
+ } else if ( element.fireEvent ) {
+ element.fireEvent( "on" + type );
+ }
+}
+
+ function wait(msecs)
+ {
+ var start = new Date().getTime();
+ var cur = start
+ while(cur - start < msecs)
+ {
+ cur = new Date().getTime();
+ }
+ }
+
+ function fetchTrackedRequests(token)
+ {
+ var xhr = window.XMLHttpRequest ? new window.XMLHttpRequest() :
+ window.ActiveXObject ? new ActiveXObject("Microsoft.XMLHTTP") :
+ null;
+
+ xhr.open("GET", "piwik.php?requests=" + token, false);
+ xhr.send(null);
+
+ return xhr.responseText;
+ }
+
+ function dropCookie(cookieName, path, domain) {
var expiryDate = new Date();
expiryDate.setTime(expiryDate.getTime() - 3600);
@@ -148,10 +226,41 @@ function deleteCookies() {
}
}
}
+
+var contentTestHtml = {};
+
+ function removeContentTrackingFixture()
+ {
+ $('#contenttest').remove();
+ }
+
+function setupContentTrackingFixture(name, targetNode) {
+ var url = 'content-fixtures/' + name + '.html'
+
+ if (!contentTestHtml[name]) {
+ $.ajax({
+ url: url,
+ success: function( content ) { contentTestHtml[name] = content; },
+ dataType: 'html',
+ async: false
+ });
+ }
+
+ var newNode = $('<div id="contenttest">' + contentTestHtml[name] + '</div>');
+
+ removeContentTrackingFixture();
+
+ if (targetNode) {
+ $(targetNode).prepend(newNode);
+ } else {
+ $('#other').append(newNode);
+ }
+}
+
</script>
</head>
<body>
-<div style="display:none;"><a href="http://piwik.org/qa">First anchor link</a></div>
+<div style="display:none;"><a id="firstLink" href="http://piwik.org/qa">First anchor link</a></div>
<h1 id="qunit-header">piwik.js: Unit Tests</h1>
<h2 id="qunit-banner"></h2>
@@ -166,6 +275,8 @@ function deleteCookies() {
<iframe name="iframe5"></iframe>
<iframe name="iframe6"></iframe>
<iframe name="iframe7"></iframe>
+ <img id="image1" src=""/> <!-- Test require this empty source attribute before image2!! -->
+ <img id="image2" data-content-piece src="img.jpg"/>
<ul>
<li><a id="click1" href="javascript:_e('div1').innerHTML='&lt;iframe src=&quot;http://click.example.com&quot;&gt;&lt;/iframe&gt;';void(0)" class="clicktest">ignore: implicit (JavaScript href)</a></li>
<li><a id="click2" href="http://example.org" target="iframe2" class="piwik_ignore clicktest">ignore: explicit</a></li>
@@ -178,12 +289,29 @@ function deleteCookies() {
</ul>
<div id="clickDiv"></div>
</div>
+ <map name="map">
+ <area id="area1" shape="rect" coords="0,0,10,10" href="img.jpg" alt="Piwik">
+ <area shape="circle" coords="10,10,10,20" href="img2.jpg" alt="Piwik2">
+ </map>
<ol id="qunit-tests"></ol>
<div id="main" style="display:none;"></div>
<script>
+
+ if (isIE()) {
+ (function () {
+ // otherwise because of position:absolute some nodes will be visible but should not... it will show scroll bars in IE
+ function fixWidthNode(tagName){
+ var node = document.getElementsByTagName(tagName)[0];
+ node.className = node.className + ' hideY ie';
+ }
+ fixWidthNode('html');
+ fixWidthNode('body');
+ })();
+ }
+
var hasLoaded = false;
function PiwikTest() {
hasLoaded = true;
@@ -196,7 +324,9 @@ function PiwikTest() {
$src = file_get_contents('../../js/piwik.js');
$src = strtr($src, array('\\'=>'\\\\',"'"=>"\\'",'"'=>'\\"',"\r"=>'\\r',"\n"=>'\\n','</'=>'<\/'));
echo "$src"; ?>';
- ok( JSLINT(src), "JSLint" );
+
+ var result = JSLINT(src);
+ ok( result, "JSLint" );
// alert(JSLINT.report(true));
});
@@ -297,7 +427,1351 @@ function PiwikTest() {
[ {'domains' : ['example.com', 'example.ca']}, {'names' : ['Sean', 'Cathy'] } ], 'Nested members' );
});
- module("core");
+ module("core", {
+ setup: function () {
+ Piwik.getTracker().clearTrackedContentImpressions();
+ },
+ teardown: function () {
+ $('#other #content').remove();
+ }
+ });
+
+ test("Query", function() {
+ var tracker = Piwik.getTracker();
+ var query = tracker.getQuery();
+ var actual;
+
+ actual = query.findFirstNodeHavingClass();
+ strictEqual(actual, undefined, "findFirstNodeHavingClass, no node set");
+
+ actual = query.findFirstNodeHavingClass(document.body);
+ strictEqual(actual, undefined, "findFirstNodeHavingClass, no classname set");
+
+ actual = query.findFirstNodeHavingClass(document.body, 'notExistingClass');
+ strictEqual(actual, undefined, "findFirstNodeHavingClass, no such classname exists");
+
+ actual = query.findFirstNodeHavingClass(document.body, 'piwik_ignore');
+ strictEqual(actual, _e('click2'), "findFirstNodeHavingClass, find matching within body");
+
+ actual = query.findFirstNodeHavingClass(_e('other'), 'clicktest');
+ strictEqual(actual, _e('click1'), "findFirstNodeHavingClass, find matching within node");
+
+ actual = query.findFirstNodeHavingClass(_e('click1'), 'clicktest');
+ strictEqual(actual, _e('click1'), "findFirstNodeHavingClass, passed node has class itself");
+
+
+ actual = query.findNodesHavingCssClass();
+ propEqual(actual, [], "findNodesHavingCssClass, no node set");
+
+ actual = query.findNodesHavingCssClass(document.body);
+ propEqual(actual, [], "findNodesHavingCssClass, no classname set");
+
+ actual = query.findNodesHavingCssClass(document.body, 'piwik_ignore');
+ propEqual(actual, [_e('click2')], "findNodesHavingCssClass, find matching within body");
+
+ actual = query.findNodesHavingCssClass(_e('other'), 'piwik_ignore');
+ propEqual(actual, [_e('click2')], "findNodesHavingCssClass, ffind matching within div");
+
+ actual = query.findNodesHavingCssClass(_e('other'), 'piwik_download');
+ propEqual(actual, [_e('click7')], "findNodesHavingCssClass, find matching within div different class");
+
+ actual = query.findNodesHavingCssClass(_e('other'), 'clicktest');
+ propEqual(actual, [_e('click1'), _e('click2'), _e('click3'), _e('click4'), _e('click5'), _e('click6'), _e('click7'), _e('click8')], "findNodesHavingCssClass, find many matching within div");
+
+ actual = query.findNodesHavingCssClass(_e('click7'), 'piwik_download');
+ propEqual(actual, [], "findNodesHavingCssClass, should not find if passed node has class itself");
+
+ actual = query.findNodesHavingCssClass(_e('clickDiv'), 'clicktest');
+ ok(_e('clickDiv').children.length === 0, "clickDiv should not have any children");
+ propEqual(actual, [], "findNodesHavingCssClass, should not find anything");
+
+
+
+ actual = query.hasNodeCssClass();
+ strictEqual(actual, false, "hasNodeCssClass, no element set");
+
+ actual = query.hasNodeCssClass(_e('clickDiv'));
+ strictEqual(actual, false, "hasNodeCssClass, no classname set");
+
+ actual = query.hasNodeCssClass(_e('clickDiv'), 'anyClass');
+ strictEqual(actual, false, "hasNodeCssClass, element has no class at all");
+
+ actual = query.hasNodeCssClass(_e('click3'), 'anyClass');
+ strictEqual(actual, false, "hasNodeCssClass, element has one classes and it does not match");
+
+ actual = query.hasNodeCssClass(_e('click3'), 'clicktest');
+ strictEqual(actual, true, "hasNodeCssClass, element has one classes and it matches");
+
+ actual = query.hasNodeCssClass(_e('click7'), 'anyClass');
+ strictEqual(actual, false, "hasNodeCssClass, element has many classes but not this one");
+
+ actual = query.hasNodeCssClass(_e('click7'), 'piwik_download');
+ strictEqual(actual, true, "hasNodeCssClass, element has many classes and it matches");
+
+
+
+ actual = query.hasNodeAttribute();
+ strictEqual(actual, false, "hasNodeAttribute, no element set");
+
+ actual = query.hasNodeAttribute(_e('clickDiv'));
+ strictEqual(actual, false, "hasNodeAttribute, no attribute set");
+
+ actual = query.hasNodeAttribute(document.body, 'anyAttribute');
+ strictEqual(actual, false, "hasNodeAttribute, element has no attribute at all");
+
+ actual = query.hasNodeAttribute(_e('click2'), 'anyAttribute');
+ strictEqual(actual, false, "hasNodeAttribute, element has attributes and it does not match");
+
+ actual = query.hasNodeAttribute(_e('click2'), 'href');
+ strictEqual(actual, true, "hasNodeAttribute, element has attributes and it does match");
+
+ actual = query.hasNodeAttribute(_e('image1'), 'src');
+ strictEqual(actual, true, "hasNodeAttribute, element has attributes and it does match other attribute");
+
+ actual = query.hasNodeAttribute(_e('image2'), 'data-content-piece');
+ strictEqual(actual, true, "hasNodeAttribute, element has attribute and no value");
+
+
+
+ actual = query.hasNodeAttributeWithValue();
+ strictEqual(actual, false, "hasNodeAttributeWithValue, no element set");
+
+ actual = query.hasNodeAttributeWithValue(_e('clickDiv'));
+ strictEqual(actual, false, "hasNodeAttributeWithValue, no attribute set");
+
+ actual = query.hasNodeAttributeWithValue(document.body, 'anyAttribute');
+ strictEqual(actual, false, "hasNodeAttributeWithValue, element has no attribute at all");
+
+ actual = query.hasNodeAttributeWithValue(_e('click2'), 'anyAttribute');
+ strictEqual(actual, false, "hasNodeAttributeWithValue, element has attributes but not this one");
+
+ actual = query.hasNodeAttributeWithValue(_e('click2'), 'href');
+ strictEqual(actual, true, "hasNodeAttributeWithValue, element has attribute and value");
+
+ actual = query.hasNodeAttributeWithValue(_e('image1'), 'src');
+ strictEqual(actual, false, "hasNodeAttributeWithValue, element has attribute but no value");
+
+ actual = query.hasNodeAttributeWithValue(_e('image2'), 'data-content-piece');
+ strictEqual(actual, false, "hasNodeAttributeWithValue, element has attribute but no value");
+
+
+ actual = query.getAttributeValueFromNode();
+ strictEqual(actual, undefined, "getAttributeValueFromNode, no element set");
+
+ actual = query.getAttributeValueFromNode(_e('clickDiv'));
+ strictEqual(actual, undefined, "getAttributeValueFromNode, no attribute set");
+
+ actual = query.getAttributeValueFromNode(document.body, 'anyAttribute');
+ strictEqual(actual, undefined, "getAttributeValueFromNode, element has no attribute at all");
+
+ actual = query.getAttributeValueFromNode(_e('click2'), 'anyAttribute');
+ strictEqual(actual, undefined, "getAttributeValueFromNode, element has attributes but not this one");
+
+ actual = query.getAttributeValueFromNode(_e('click2'), 'href');
+ strictEqual(actual, 'http://example.org', "getAttributeValueFromNode, element has attribute and value");
+
+ actual = query.getAttributeValueFromNode(_e('image1'), 'src');
+ strictEqual(actual, '', "getAttributeValueFromNode, element has attribute but no value");
+
+ actual = query.getAttributeValueFromNode(_e('image2'), 'data-content-piece');
+ strictEqual(actual, '', "getAttributeValueFromNode, element has attribute but no value");
+
+ actual = query.getAttributeValueFromNode(_e('click2'), 'class');
+ strictEqual(actual, 'piwik_ignore clicktest', "getAttributeValueFromNode, element has attribute class and value");
+
+
+
+ actual = query.findNodesHavingAttribute();
+ propEqual(actual, [], "findNodesHavingAttribute, no node set");
+
+ actual = query.findNodesHavingAttribute(document.body);
+ propEqual(actual, [], "findNodesHavingAttribute, no attribute set");
+
+ actual = query.findNodesHavingAttribute(document.body, 'anyAttribute');
+ propEqual(actual, [], "findNodesHavingAttribute, should not find any such attribute within body");
+
+ actual = query.findNodesHavingAttribute(document.body, 'style');
+ strictEqual(actual.length, 3, "findNodesHavingAttribute, should find a few");
+
+ actual = query.findNodesHavingAttribute(_e('click1'), 'href');
+ propEqual(actual, [], "findNodesHavingAttribute, should not find itself if the passed element has the attribute");
+
+ actual = query.findNodesHavingAttribute(_e('clickDiv'), 'id');
+ ok(_e('clickDiv').children.length === 0, "clickDiv should not have any children");
+ propEqual(actual, [], "findNodesHavingAttribute, this element does not have children");
+
+ actual = query.findNodesHavingAttribute(document.body, 'href');
+ ok(actual.length > 11, "findNodesHavingAttribute, should find many elements within body");
+
+ actual = query.findNodesHavingAttribute(_e('other'), 'href');
+ propEqual(actual, [_e('click1'), _e('click2'), _e('click3'), _e('click4'), _e('click5'), _e('click6'), _e('click7'), _e('click8')], "findNodesHavingAttribute, should find many elements within node");
+
+ actual = query.findNodesHavingAttribute(_e('other'), 'anyAttribute');
+ propEqual(actual, [], "findNodesHavingAttribute, should not find any such attribute within div");
+
+
+// TODO it is a bit confusing that findNodesHavingAttribute/CssClass does not include the passed node in the search but findFirstNodeHavingAttribute/CssClass does
+ actual = query.findFirstNodeHavingAttribute();
+ strictEqual(actual, undefined, "findFirstNodeHavingAttribute, no node set");
+
+ actual = query.findFirstNodeHavingAttribute(document.body);
+ strictEqual(actual, undefined, "findFirstNodeHavingAttribute, no attribute set");
+
+ actual = query.findFirstNodeHavingAttribute(document.body, 'anyAttribute');
+ strictEqual(actual, undefined, "findFirstNodeHavingAttribute, should not find any such attribute within body");
+
+ actual = query.findFirstNodeHavingAttribute(_e('click1'), 'href');
+ strictEqual(actual, _e('click1'), "findFirstNodeHavingAttribute, element has the attribute itself and not a children");
+
+ actual = query.findFirstNodeHavingAttribute(_e('clickDiv'), 'anyAttribute');
+ strictEqual(actual, undefined, "findFirstNodeHavingAttribute, this element does not have children");
+
+ actual = query.findFirstNodeHavingAttribute(document.body, 'href');
+ strictEqual(actual, _e('firstLink'), "findFirstNodeHavingAttribute, should find first link within body");
+
+ actual = query.findFirstNodeHavingAttribute(_e('other'), 'href');
+ strictEqual(actual, _e('click1'), "findFirstNodeHavingAttribute, should find fist link within node");
+
+
+
+ actual = query.findFirstNodeHavingAttributeWithValue();
+ strictEqual(actual, undefined, "findFirstNodeHavingAttributeWithValue, no node set");
+
+ actual = query.findFirstNodeHavingAttributeWithValue(document.body);
+ strictEqual(actual, undefined, "findFirstNodeHavingAttributeWithValue, no attribute set");
+
+ actual = query.findFirstNodeHavingAttributeWithValue(document.body, 'anyAttribute');
+ strictEqual(actual, undefined, "findFirstNodeHavingAttributeWithValue, should not find any such attribute within body");
+
+ actual = query.findFirstNodeHavingAttributeWithValue(_e('click2'), 'href');
+ strictEqual(actual, _e('click2'), "findFirstNodeHavingAttributeWithValue, element has the attribute itself and not a children");
+
+ actual = query.findFirstNodeHavingAttributeWithValue(_e('clickDiv'), 'anyAttribute');
+ strictEqual(actual, undefined, "findFirstNodeHavingAttributeWithValue, this element does not have children");
+
+ actual = query.findFirstNodeHavingAttributeWithValue(document.body, 'href');
+ strictEqual(actual, _e('firstLink'), "findFirstNodeHavingAttributeWithValue, should find first link within body");
+
+ actual = query.findFirstNodeHavingAttributeWithValue(document.body, 'src');
+ strictEqual(actual, _e('image2'), "findFirstNodeHavingAttributeWithValue, should not return first image which has empty src attribute");
+
+
+
+ actual = query.htmlCollectionToArray();
+ propEqual(actual, [], "htmlCollectionToArray, should always return an array even if nothing given");
+
+ actual = query.htmlCollectionToArray(5);
+ propEqual(actual, [], "htmlCollectionToArray, should always return an array even if interger given"); // would still parse string to an array but we can live with that
+
+ var htmlCollection = document.getElementsByTagName('a');
+ ok((htmlCollection instanceof HTMLCollection) || (htmlCollection instanceof NodeList), 'htmlCollectionToArray, we need to make sure we handle an html collection in order to make test really useful')
+ actual = query.htmlCollectionToArray(htmlCollection);
+ ok($.isArray(actual), 'htmlCollectionToArray, should convert to array');
+ ok(actual.length === htmlCollection.length, 'htmlCollectionToArray should have same amount of elements as before');
+ ok(actual.length > 10, 'htmlCollectionToArray, just make sure there are many a elements found. otherwise test is useless');
+ ok(-1 !== actual.indexOf(_e('click1')), 'htmlCollectionToArray, random check to make sure it contains a link');
+
+
+ actual = query.isLinkElement();
+ strictEqual(actual, false, "isLinkElement, no element set");
+
+ actual = query.isLinkElement(_e('div1'));
+ strictEqual(actual, false, "isLinkElement, a div is not a link element");
+
+ actual = query.isLinkElement(document.createTextNode('ff'));
+ strictEqual(actual, false, "isLinkElement, a text node is not a link element");
+
+ actual = query.isLinkElement(document.createComment('tt'));
+ strictEqual(actual, false, "isLinkElement, a comment is not a link element");
+
+ actual = query.isLinkElement(_e('area1'));
+ strictEqual(actual, true, "isLinkElement, an area element is a link element");
+
+ actual = query.isLinkElement(_e('click1'));
+ strictEqual(actual, true, "isLinkElement, an a element is a link element");
+
+
+ actual = query.find();
+ propEqual(actual, [], "find, no selector passed should return an empty array");
+
+ actual = query.find('[data-content-piece]');
+ propEqual(actual, [_e('image2')], "find, should find elements by attribute");
+
+ actual = query.find('.piwik_link');
+ propEqual(actual, [_e('click5')], "find, should find elements by class");
+
+ actual = query.find('#image1');
+ propEqual(actual, [_e('image1')], "find, should find elements by id");
+
+ actual = query.find('[href]');
+ ok(actual.length > 10, "find, should find many elements by attribute");
+ ok(-1 !== actual.indexOf(_e('click1')), 'find, random check to make sure it contains a link');
+
+ actual = query.find('.clicktest');
+ ok(actual.length === 8, "find, should find many elements by class");
+ ok(-1 !== actual.indexOf(_e('click1')), 'find, random check to make sure it contains a link');
+
+
+
+ actual = query.findMultiple();
+ propEqual(actual, [], "findMultiple, no selectors passed should return an empty array");
+
+ actual = query.findMultiple([]);
+ propEqual(actual, [], "findMultiple, empty selectors passed should return an empty array");
+
+ actual = query.findMultiple(['.piwik_link']);
+ propEqual(actual, [_e('click5')], "findMultiple, only one selector passed");
+
+ actual = query.findMultiple(['.piwik_link', '[data-content-piece]']);
+ propEqual(actual, [_e('image2'), _e('click5')], "findMultiple, two selectors passed");
+
+ actual = query.findMultiple(['.piwik_link', '[data-content-piece]', '#image2', '#div1']);
+ propEqual(actual, [_e('image2'), _e('div1'), _e('click5')], "findMultiple, should make nodes unique in case we select the same multiple times");
+
+
+ actual = query.findNodesByTagName();
+ propEqual(actual, [], "findNodesByTagName, no element and no tag name set");
+
+ actual = query.findNodesByTagName(document.body);
+ propEqual(actual, [], "findNodesByTagName, no tag name set");
+
+ actual = query.findNodesByTagName(document.body, 'notExistingOne');
+ propEqual(actual, [], "findNodesByTagName, should not find any such element");
+
+ actual = query.findNodesByTagName(document.body, 'a');
+ ok($.isArray(actual), "findNodesByTagName, should always return an array");
+
+ actual = query.findNodesByTagName(document.body, 'h1');
+ propEqual(actual, [_e('qunit-header')], "findNodesByTagName, find exactly one");
+
+ actual = query.findNodesByTagName(document.body, 'a');
+ ok(actual.length > 10, "findNodesByTagName, find many, even nested ones");
+ ok(actual.indexOf(_e('click1')), "findNodesByTagName, just a random test to make sure it actually contains a link");
+ });
+
+ test("contentFindContentBlock", function() {
+
+ var tracker = Piwik.getTracker();
+ var content = tracker.getContent();
+ var actual, expected;
+
+ actual = content.findContentNodes();
+ propEqual(actual, [], "findContentNodes, should not find any content node when there is none");
+
+ actual = content.findContentNodesWithinNode();
+ propEqual(actual, [], "findContentNodesWithinNode, should not find any content node when no node passed");
+
+ actual = content.findContentNodesWithinNode(_e('other'));
+ ok(_e('other'), "if we do not get an element here test is not useful");
+ propEqual(actual, [], "findContentNodesWithinNode, should not find any content node when there is none");
+
+ actual = content.findParentContentNode(_e('click1'));
+ ok(_e('click1'), "if we do not get an element here test is not useful");
+ strictEqual(actual, undefined, "findParentContentNode, should not find any content node when there is none");
+
+
+
+ setupContentTrackingFixture('findContentBlockTest');
+
+ expected = [_s('#isOneWithClass'), _s('#isOneWithAttribute'), _s('[href="http://www.example.com"]'), _s('#containsOneWithAttribute [data-track-content]')];
+ actual = content.findContentNodes();
+ propEqual(actual, expected, "findContentNodes, should find all content blocks within the DOM");
+
+ actual = content.findContentNodesWithinNode(_s(''));
+ propEqual(actual, expected, "findContentNodesWithinNode, should find all content blocks within the DOM");
+
+ actual = content.findContentNodesWithinNode(_s('#containsOneWithAttribute'));
+ propEqual(actual, [expected[3]], "findContentNodesWithinNode, should find content blocks within a node");
+
+ actual = content.findContentNodesWithinNode(expected[0]);
+ propEqual(actual, [expected[0]], "findContentNodesWithinNode, should find one content block in the node itself");
+
+ actual = content.findParentContentNode(_s('#isOneWithClass'));
+ strictEqual(actual, expected[0], "findParentContentNode, should find itself in case the passed node is a content block with class");
+
+ actual = content.findParentContentNode(_s('#isOneWithAttribute'));
+ strictEqual(actual, expected[1], "findParentContentNode, should find itself in case the passed node is a content block with attribute");
+
+ actual = content.findParentContentNode(_s('#innerNode'));
+ strictEqual(actual, expected[2], "findParentContentNode, should find parent content block");
+ });
+
+ test("contentFindContentNodes", function() {
+ function ex(testNumber) { // select node within content test scope
+ $nodes = $('#contenttest #ex' + testNumber);
+ if ($nodes.length) {
+ return $nodes[0];
+ } else {
+ ok(false, 'selector was not found but should be "#contenttest #ex' + selector + '"')
+ }
+ }
+
+ var tracker = Piwik.getTracker();
+ var content = tracker.getContent();
+ var actual;
+
+ var unrelatedNode = _e('other');
+ ok(unrelatedNode, 'Make sure this element exists');
+
+ actual = content.findTargetNodeNoDefault();
+ strictEqual(actual, undefined, "findTargetNodeNoDefault, should not find anything if no node set");
+
+ actual = content.findTargetNode();
+ strictEqual(actual, undefined, "findTargetNode, should not find anything if no node set");
+
+ actual = content.findPieceNode();
+ strictEqual(actual, undefined, "findPieceNode, should not find anything if no node set");
+
+
+
+ setupContentTrackingFixture('findContentNodesTest');
+
+ var example1 = ex(1);
+ ok(example1, 'Make sure this element exists to verify setup');
+
+ ok("test fall back to content block node");
+
+ actual = content.findTargetNodeNoDefault(example1);
+ strictEqual(actual, undefined, "findTargetNodeNoDefault, should return nothing as no target set");
+
+ actual = content.findTargetNode(example1);
+ strictEqual(actual, example1, "findTargetNode, should fall back to content block node as no target set");
+
+ actual = content.findPieceNode(example1);
+ strictEqual(actual, example1, "findPieceNode, should not find anything if no node set");
+
+
+
+ ok("test actually detects the attributes within a content block");
+
+ actual = content.findTargetNodeNoDefault(ex(3));
+ ok(undefined !== $(actual).attr(content.CONTENT_TARGET_ATTR), "findTargetNodeNoDefault, should have the attribute");
+ strictEqual(actual, ex('3 a'), "findTargetNodeNoDefault, should find actual target node via attribute");
+
+ actual = content.findTargetNode(ex(3));
+ ok(undefined !== $(actual).attr(content.CONTENT_TARGET_ATTR), "findTargetNode, should have the attribute");
+ strictEqual(actual, ex('3 a'), "findTargetNode, should find actual target node via attribute");
+
+ actual = content.findPieceNode(ex(3));
+ ok(undefined !== $(actual).attr(content.CONTENT_PIECE_ATTR), "findPieceNode, should have the attribute");
+ strictEqual(actual, ex('3 img'), "findPieceNode, should find actual target piece via attribute");
+
+
+
+ ok("test actually detects the CSS class within a content block");
+
+ actual = content.findTargetNodeNoDefault(ex(4));
+ ok($(actual).hasClass(content.CONTENT_TARGET_CLASS), "findTargetNodeNoDefault, should have the CSS class");
+ strictEqual(actual, ex('4 a'), "findTargetNodeNoDefault, should find actual target node via class");
+
+ actual = content.findTargetNode(ex(4));
+ ok($(actual).hasClass(content.CONTENT_TARGET_CLASS), "findTargetNode, should have the CSS class");
+ strictEqual(actual, ex('4 a'), "findTargetNode, should find actual target node via class");
+
+ actual = content.findPieceNode(ex(4));
+ ok($(actual).hasClass(content.CONTENT_PIECE_CLASS), "findPieceNode, should have the CSS class");
+ strictEqual(actual, ex('4 img'), "findPieceNode, should find actual target piece via class");
+
+
+
+ ok("test actually attributes takes precendence over class");
+
+ actual = content.findTargetNodeNoDefault(ex(5));
+ ok(undefined !== $(actual).attr(content.CONTENT_TARGET_ATTR), "findTargetNodeNoDefault, should have the attribute");
+ strictEqual(actual.textContent, 'Target with attribute', "findTargetNodeNoDefault, should igonre node with class and pick attribute node");
+
+ actual = content.findTargetNode(ex(5));
+ ok(undefined !== $(actual).attr(content.CONTENT_TARGET_ATTR), "findTargetNode, should have the attribute");
+ strictEqual(actual.textContent, 'Target with attribute', "findTargetNode, should igonre node with class and pick attribute node");
+
+ actual = content.findPieceNode(ex(5));
+ ok(undefined !== $(actual).attr(content.CONTENT_PIECE_ATTR), "findPieceNode, should have the attribute");
+ strictEqual(actual.textContent, 'Piece with attribute', "findPieceNode, should igonre node with class and pick attribute node");
+
+
+
+ ok("make sure it picks always the first one with multiple nodes have same class or same attribute");
+
+ actual = content.findTargetNode(ex(6));
+ ok($(actual).hasClass(content.CONTENT_TARGET_CLASS), "findTargetNode, should have the CSS class");
+ strictEqual(actual.textContent, 'Target with class1', "findTargetNode, should igonre node with class and pick attribute node");
+
+ actual = content.findPieceNode(ex(6));
+ ok($(actual).hasClass(content.CONTENT_PIECE_CLASS), "findPieceNode, should have the CSS class");
+ strictEqual(actual.textContent, 'Piece with class1', "findPieceNode, should igonre node with class and pick attribute node");
+
+ actual = content.findTargetNode(ex(7));
+ ok(undefined !== $(actual).attr(content.CONTENT_TARGET_ATTR), "findTargetNode, should have the attribute");
+ strictEqual(actual.textContent, 'Target with attribute1', "findTargetNode, should igonre node with class and pick attribute node");
+
+ actual = content.findPieceNode(ex(7));
+ ok(undefined !== $(actual).attr(content.CONTENT_PIECE_ATTR), "findPieceNode, should have the attribute");
+ strictEqual(actual.textContent, 'Piece with attribute1', "findPieceNode, should igonre node with class and pick attribute node");
+ });
+
+ test("contentUtilities", function() {
+
+ var tracker = Piwik.getTracker();
+ var content = tracker.getContent();
+ var query = tracker.getQuery();
+ content.setLocation(); // clear possible previous location
+ var actual, expected;
+
+ function assertTrimmed(value, expected, message)
+ {
+ strictEqual(content.trim(value), expected, message);
+ }
+
+ function assertRemoveDomainKeepsValueUntouched(value, message)
+ {
+ strictEqual(content.removeDomainIfIsInLink(value), value, message);
+ }
+
+ function assertIsSameDomain(url, message)
+ {
+ strictEqual(content.isSameDomain(url), true, message);
+ }
+
+ function assertIsNotSameDomain(url, message)
+ {
+ strictEqual(content.isSameDomain(url), false, 'isSameDomain, ' + message);
+ }
+
+ function assertDomainWillBeRemoved(url, expected, message)
+ {
+ strictEqual(content.removeDomainIfIsInLink(url), expected, message);
+ }
+
+ function assertBuildsAbsoluteUrl(url, expected, message)
+ {
+ strictEqual(content.toAbsoluteUrl(url), expected, message);
+ }
+
+ function assertImpressionRequestParams(name, piece, target, expected, message) {
+ strictEqual(content.buildImpressionRequestParams(name, piece, target), expected, message);
+ }
+
+ function assertInteractionRequestParams(interaction, name, piece, target, expected, message) {
+ strictEqual(content.buildInteractionRequestParams(interaction, name, piece, target), expected, message);
+ }
+
+ function assertShouldIgnoreInteraction(id, message) {
+ var node = content.findTargetNode(_e(id));
+ strictEqual(content.shouldIgnoreInteraction(node), true, message);
+ ok($(node).hasClass(content.CONTENT_IGNOREINTERACTION_CLASS) || undefined !== $(node).attr(content.CONTENT_IGNOREINTERACTION_ATTR), "needs to have either attribute or class");
+ }
+
+ function assertShouldNotIgnoreInteraction(id, message) {
+ var node = content.findTargetNode(_e(id));
+ strictEqual(content.shouldIgnoreInteraction(node), false, message);
+ }
+
+ function assertNodeAuthorizedToTriggerInteraction(contentNode, interactedNode, message) {
+ strictEqual(tracker.isNodeAuthorizedToTriggerInteraction(_s(contentNode), _s(interactedNode)), true, message);
+ }
+
+ function assertNodeNotAuthorizedToTriggerInteraction(contentNode, interactedNode, message) {
+ strictEqual(tracker.isNodeAuthorizedToTriggerInteraction(_s(contentNode), _s(interactedNode)), false, message);
+ }
+
+ function assertFoundMediaUrl(id, expected, message) {
+ var node = content.findPieceNode(_e(id));
+ strictEqual(content.findMediaUrlInNode(node), expected, message);
+ }
+
+ function assertIsUrlToCurrentDomain(url, message) {
+ strictEqual(content.isUrlToCurrentDomain(url), true, message);
+ }
+
+ function assertNotUrlToCurrentDomain(url, message) {
+ strictEqual(content.isUrlToCurrentDomain(url), false, message);
+ }
+
+ var locationAlias = $.extend({}, window.location);
+ var origin = getOrigin();
+ var host = locationAlias.host;
+
+ ok("test trim(text)");
+
+ strictEqual(undefined, content.trim(), 'should not fail if nothing set / is undefined');
+ assertTrimmed(null, null, 'should not trim if null');
+ assertTrimmed(5, 5, 'should not trim a number');
+ assertTrimmed('', '', 'should not change an empty string');
+ assertTrimmed(' ', '', 'should remove all whitespace');
+ assertTrimmed(' xxxx', 'xxxx', 'should remove left whitespace');
+ assertTrimmed(' xxxx ', 'xxxx', 'should remove left and right whitespace');
+ assertTrimmed(" \t xxxx \t", 'xxxx', 'should remove tabs and whitespace');
+ assertTrimmed(' xx xx ', 'xx xx', 'should keep whitespace between text untouched');
+
+ ok("test isSameDomain(url)");
+ assertIsNotSameDomain(undefined, 'no url given');
+ assertIsNotSameDomain(5, 'a number, not a url');
+ assertIsNotSameDomain('foo bar', 'not a url');
+ assertIsNotSameDomain('http://example.com', 'not same domain');
+ assertIsNotSameDomain('https://www.example.com', 'not same domain and different protocol');
+ assertIsNotSameDomain('http://www.example.com:8080', 'not same domain and different port');
+ assertIsNotSameDomain('http://www.example.com/path/img.jpg', 'not same domain with path');
+
+ assertIsSameDomain(origin, 'same protocol and same domain');
+ assertIsSameDomain(origin + '/path/img.jpg', 'same protocol and same domain with path');
+ assertIsSameDomain('https://' + host + '/path/img.jpg', 'different protocol is still same domain');
+ assertIsSameDomain('http://' + host + ':8080/path/img.jpg', 'different port is still same domain');
+
+ ok("test removeDomainIfIsInLink(url)");
+
+ strictEqual(content.removeDomainIfIsInLink(), undefined, 'should not fail if nothing set / is undefined');
+ assertRemoveDomainKeepsValueUntouched(null, 'should keep null untouched');
+ assertRemoveDomainKeepsValueUntouched(5, 'should keep number untouched');
+ assertRemoveDomainKeepsValueUntouched('', 'should keep empty string untouched');
+ assertRemoveDomainKeepsValueUntouched('Any Text', 'should keep string untouched that is not a url');
+ assertRemoveDomainKeepsValueUntouched('/path/img.jpg', 'should keep string untouched that looks like a path');
+ assertRemoveDomainKeepsValueUntouched('ftp://path/img.jpg', 'should keep string untouched that looks like a path');
+ assertRemoveDomainKeepsValueUntouched('http://www.example.com', 'should keep string untouched as it is different domain');
+ assertRemoveDomainKeepsValueUntouched('http://www.example.com/', 'should keep string untouched as it is different domain');
+ assertRemoveDomainKeepsValueUntouched('https://www.example.com/', 'should keep string untouched as it is different domain');
+ assertRemoveDomainKeepsValueUntouched('http://www.example.com/path/img.jpg', 'should keep string untouched as it is different domain, this time with path');
+ assertRemoveDomainKeepsValueUntouched('http://www.example.com:8080/path/img.jpg', 'should keep string untouched as it is different domain, this time with port');
+
+ assertDomainWillBeRemoved(origin + '/path/img.jpg?x=y', '/path/img.jpg?x=y', 'should trim http domain with path that is the same as the current');
+ assertDomainWillBeRemoved('https://' + host + '/path/img.jpg?x=y', '/path/img.jpg?x=y', 'should trim https domain with path that is the same as the current');
+ assertDomainWillBeRemoved(origin, '/', 'should trim http domain without path that is the same as the current');
+ assertDomainWillBeRemoved('https://' + host, '/', 'should trim https domain without path that is the same as the current');
+ assertDomainWillBeRemoved('https://' + host + ':8080', '/', 'should trim https domain with port that is the same as the current');
+
+ ok("test isUrlToCurrentDomain(url)");
+
+ strictEqual(content.removeDomainIfIsInLink(), undefined, 'should not fail if nothing set / is undefined');
+ assertNotUrlToCurrentDomain(null, ' null is not a urls');
+ assertNotUrlToCurrentDomain(5, '5 is not a url');
+ assertIsUrlToCurrentDomain('', 'empty string is same as current url so same domain');
+ assertIsUrlToCurrentDomain('Any Text', 'relative url, same domain');
+ assertIsUrlToCurrentDomain('/path/img.jpg', 'absolute url same domain');
+ assertNotUrlToCurrentDomain('ftp://path/img.jpg', 'different protocol');
+ assertNotUrlToCurrentDomain('http://www.example.com', 'different domain');
+ assertNotUrlToCurrentDomain('http://www.example.com/', 'different domain with root path');
+ assertNotUrlToCurrentDomain('https://www.example.com/', 'different domain and protocol');
+ assertNotUrlToCurrentDomain('http://www.example.com/path/img.jpg', 'different domain, this time with path');
+ assertNotUrlToCurrentDomain('http://www.example.com:8080/path/img.jpg', 'different domain, this time with port');
+
+ assertIsUrlToCurrentDomain(origin + '/path/img.jpg?x=y', 'same domain with path');
+ assertIsUrlToCurrentDomain(origin + '?x=y', 'same domain with question mark');
+ assertNotUrlToCurrentDomain('https://' + host + '/path/img.jpg?x=y', 'different protocol and path is different url');
+ assertIsUrlToCurrentDomain(origin, '/', 'same domain with root path');
+ assertNotUrlToCurrentDomain('https://' + host, 'same domain but different protocol');
+ assertNotUrlToCurrentDomain('https://' + host + ':5959', 'different protocol and port');
+ assertNotUrlToCurrentDomain('http://' + host + ':5959', 'different protocol and port');
+
+ ok("test toAbsoluteUrl(url) we need a lot of tests for this method as this will generate the redirect url");
+
+ strictEqual(undefined, content.toAbsoluteUrl(), 'should not fail if nothing set / is undefined');
+ assertBuildsAbsoluteUrl(null, null, 'null should be untouched');
+ assertBuildsAbsoluteUrl(5, 5, 'number should be untouched');
+ assertBuildsAbsoluteUrl('', locationAlias.href, 'an empty string should generate the same URL as it is currently');
+ assertBuildsAbsoluteUrl('/', origin + '/', 'root path');
+ assertBuildsAbsoluteUrl('/test', origin + '/test', 'absolute url');
+ assertBuildsAbsoluteUrl('/test/', origin + '/test/', 'absolute url');
+ assertBuildsAbsoluteUrl('?x=5', origin + '/tests/javascript/?x=5', 'absolute url');
+ assertBuildsAbsoluteUrl('path', origin + '/tests/javascript/path', 'relative path');
+ assertBuildsAbsoluteUrl('path/x?p=5', origin + '/tests/javascript/path/x?p=5', 'relative path');
+ assertBuildsAbsoluteUrl('#test', origin + '/tests/javascript/#test', 'anchor url');
+ assertBuildsAbsoluteUrl('//' + locationAlias.host + '/test/img.jpg', origin + '/test/img.jpg', 'inherit protocol url');
+ assertBuildsAbsoluteUrl('mailto:test@example.com', 'mailto:test@example.com', 'mailto pseudo-protocol url');
+ assertBuildsAbsoluteUrl('javascript:void 0', 'javascript:void 0', 'javascript pseudo-protocol url');
+ assertBuildsAbsoluteUrl('tel:0123456789', 'tel:0123456789', 'tel pseudo-protocol url');
+ assertBuildsAbsoluteUrl('anythinggggggggg:test', origin + '/tests/javascript/anythinggggggggg:test', 'we do not treat this one as pseudo-protocol url as there are too many characters before colon');
+ assertBuildsAbsoluteUrl('k1dm:test', origin + '/tests/javascript/k1dm:test', 'we do not treat this one as pseudo-protocol url as it contains a number');
+
+ locationAlias.pathname = '/test/';
+ content.setLocation(locationAlias);
+ assertBuildsAbsoluteUrl('?x=5', origin + '/test/?x=5', 'should add query param');
+ assertBuildsAbsoluteUrl('link2', origin + '/test/link2', 'relative url in existing path');
+
+ locationAlias.pathname = '/test';
+ content.setLocation(locationAlias);
+ assertBuildsAbsoluteUrl('?x=5', origin + '/test?x=5', 'should add query param');
+ assertBuildsAbsoluteUrl('link2', origin + '/link2', 'relative url replaces other relative url');
+
+ ok("test buildImpressionRequestParams(name, piece, target)");
+ assertImpressionRequestParams('name', 'piece', 'target', 'c_n=name&c_p=piece&c_t=target', "all parameters set");
+ assertImpressionRequestParams('name', 'piece', null, 'c_n=name&c_p=piece', "no target set");
+ assertImpressionRequestParams('http://example.com.com', '/?x=1', '&target=1', 'c_n=http%3A%2F%2Fexample.com.com&c_p=%2F%3Fx%3D1&c_t=%26target%3D1', "should encode values");
+
+ ok("test buildInteractionRequestParams(interaction, name, piece, target)");
+ assertInteractionRequestParams(null, null, null, null, '', "nothing set");
+ assertInteractionRequestParams('interaction', null, null, null, 'c_i=interaction', "only interaction set");
+ assertInteractionRequestParams('interaction', 'name', null, null, 'c_i=interaction&c_n=name', "no piece and no target set");
+ assertInteractionRequestParams('interaction', 'name', 'piece', null, 'c_i=interaction&c_n=name&c_p=piece', "no target set");
+ assertInteractionRequestParams('interaction', 'name', 'piece', 'target', 'c_i=interaction&c_n=name&c_p=piece&c_t=target', "all parameters set");
+ assertInteractionRequestParams(null, 'name', 'piece', null, 'c_n=name&c_p=piece', "only name and piece set");
+ assertInteractionRequestParams('http://', 'http://example.com.com', '/?x=1', '&target=1', 'c_i=http%3A%2F%2F&c_n=http%3A%2F%2Fexample.com.com&c_p=%2F%3Fx%3D1&c_t=%26target%3D1', "should encode values");
+
+ setupContentTrackingFixture('contentUtilities');
+
+ ok("test shouldIgnoreInteraction(targetNode)");
+ assertShouldIgnoreInteraction('ignoreInteraction1', 'should be ignored because of CSS class');
+ assertShouldIgnoreInteraction('ignoreInteraction2', 'should be ignored because of Attribute');
+ assertShouldIgnoreInteraction('ignoreInteraction3', 'should be ignored because of CSS class');
+ assertShouldIgnoreInteraction('ignoreInteraction4', 'should be ignored because of Attribute');
+ assertShouldNotIgnoreInteraction('notIgnoreInteraction1', 'should not be ignored');
+ assertShouldNotIgnoreInteraction('notIgnoreInteraction2', 'should not be ignored as set in wrong element');
+
+
+ ok("test isNodeAuthorizedToTriggerInteraction(targetNode)");
+ strictEqual(tracker.isNodeAuthorizedToTriggerInteraction(), false, 'nothing set');
+ strictEqual(tracker.isNodeAuthorizedToTriggerInteraction('#ignoreInteraction2'), false, 'no interacted node set');
+
+ var notAuthIgnoreNode = '#ignoreInteraction2 a';
+ assertNodeNotAuthorizedToTriggerInteraction(notAuthIgnoreNode, notAuthIgnoreNode, 'node has to be ignored');
+ $(_s(notAuthIgnoreNode)).attr('data-content-ignoreinteraction', null);
+ // node no longer ignored and it should be authorized!
+ assertNodeAuthorizedToTriggerInteraction(notAuthIgnoreNode, notAuthIgnoreNode, 'node no longer has to be ignored');
+ $(_s(notAuthIgnoreNode)).attr('data-content-ignoreinteraction', ''); // reset changed attribute
+
+ assertNodeAuthorizedToTriggerInteraction('#authorized1', '#authorized1', 'interacted with target node which is content block');
+ assertNodeAuthorizedToTriggerInteraction('#authorized1', '#authorized1_1', 'interacted with child of target node which is content block');
+ assertNodeAuthorizedToTriggerInteraction('#authorized2', '#authorized2_1', 'interacted with target node');
+ assertNodeAuthorizedToTriggerInteraction('#authorized2', '#authorized2_2', 'interacted with children of target node');
+ assertNodeNotAuthorizedToTriggerInteraction('#authorized3', '#authorized3', 'interacted with content block but it is not target node');
+ assertNodeNotAuthorizedToTriggerInteraction('#authorized3', '#authorized3_1', 'interacted with children of content block but not children of target node');
+ assertNodeAuthorizedToTriggerInteraction('#authorized3', '#authorized3_2', 'interacted with target node to make sure auth3 is not ignored');
+
+
+ ok("test setHrefAttribute(node, url)");
+ var aElement = _e('aLinkToBeChanged');
+ content.setHrefAttribute(); // should not fail if no arguments
+ strictEqual(query.getAttributeValueFromNode(aElement, 'href'), 'http://www.example.com', 'setHrefAttribute, check initial link value');
+ content.setHrefAttribute(aElement);
+ content.setHrefAttribute(aElement, '');
+ strictEqual(query.getAttributeValueFromNode(aElement, 'href'), 'http://www.example.com', 'setHrefAttribute, an empty URL should not be set');
+ content.setHrefAttribute(aElement, '/test');
+ strictEqual(query.getAttributeValueFromNode(aElement, 'href'), '/test', 'setHrefAttribute, link should be changed now');
+
+ strictEqual(content.findMediaUrlInNode(), undefined, 'should not fail if no node passed');
+ ok(_e('click1') && _e('mediaDiv'), 'make sure both nodes exist otherwise following two assertions to not test what we want');
+ assertFoundMediaUrl('click1', undefined, 'should not find anything in a link as it is not a media');
+ assertFoundMediaUrl('mediaDiv', undefined, 'should not find anything in a non media element even if it defines a src attribute');
+ assertFoundMediaUrl('mediaImg', 'test/img.jpg', 'should find url of image');
+ assertFoundMediaUrl('mediaVideo', 'movie.mp4', 'should find url of video, first one should be used');
+ assertFoundMediaUrl('mediaAudio', 'audio.ogg', 'should find url of audio, first one should be used');
+ assertFoundMediaUrl('mediaEmbed', 'embed.swf', 'should find url of embed element');
+ assertFoundMediaUrl('mediaObjectSimple', 'objectSimple.swf', 'should find url of a simple object element');
+ assertFoundMediaUrl('mediaObjectParam', 'movie_param1.swf', 'should find url of a simple object element');
+ assertFoundMediaUrl('mediaObjectPdf', 'document.pdf', 'should find url of an object that contains non flash resources such as pdf');
+ assertFoundMediaUrl('mediaObjectEmbed', 'document2.pdf', 'should fallback to an embed in an object');
+ });
+
+ test("contentVisibleNodeTests", function() {
+
+ var tracker = Piwik.getTracker();
+ var content = tracker.getContent();
+ var actual;
+
+ function _ex(testnumber) { // select node within content test scope
+ return _s('#ex' + testnumber);
+ }
+
+ function assertContentNodeVisible(node, message)
+ {
+ scrollToTop(); // make sure content nodes are actually in view port
+
+ if (!message) {
+ message = '';
+ }
+ strictEqual(content.isNodeVisible(node), true, 'isNodeVisible, ' + message);
+ }
+
+ function assertContentNodeNotVisible(node, message)
+ {
+ scrollToTop(); // make sure content nodes are actually in view port
+
+ if (!message) {
+ message = '';
+ }
+ strictEqual(content.isNodeVisible(node), false, 'isNodeVisible, ' + message);
+ }
+
+ function assertInternalNodeVisible(node, message)
+ {
+ scrollToTop(); // make sure content nodes are actually in view port
+
+ if (!message) {
+ message = '';
+ }
+ strictEqual(tracker.internalIsNodeVisible(node), true, 'internalIsNodeVisible, ' + message);
+ }
+
+ function assertInternalNodeNotVisible(node, message)
+ {
+ scrollToTop(); // make sure content nodes are actually in view port
+
+ if (!message) {
+ message = '';
+ }
+ strictEqual(tracker.internalIsNodeVisible(node), false, 'internalIsNodeVisible, ' + message);
+ }
+
+ function assertNodeNotInViewport(node, message)
+ {
+ scrollToTop(); // make sure content nodes are actually in view port
+
+ if (!message) {
+ message = '';
+ }
+ strictEqual(content.isOrWasNodeInViewport(node), false, 'internalIsNodeVisible, ' + message);
+ }
+
+ function assertNodeIsInViewport(node, message)
+ {
+ scrollToTop(); // make sure content nodes are actually in view port
+
+ if (!message) {
+ message = '';
+ }
+ strictEqual(content.isOrWasNodeInViewport(node), true, 'internalIsNodeVisible, ' + message);
+
+ window.scroll(0,200); // if we scroll done it was visible
+
+ strictEqual(content.isOrWasNodeInViewport(node), true, 'internalIsNodeVisible, ' + message);
+ }
+
+ setupContentTrackingFixture('visibleNodes', document.body); // #contenttest is placed by default in #other but #other is not visible so all tests would return false.
+
+ ok('test internalIsNodeVisible()');
+ assertInternalNodeNotVisible(undefined, 'no node set, cannot be visible');
+ assertInternalNodeNotVisible(_e('click1'), 'parent other is hidden');
+ assertInternalNodeNotVisible(document.createElement('div'), 'element is not in DOM');
+ assertInternalNodeVisible(_ex(1), 'node exists and should be visible');
+ assertInternalNodeNotVisible(_ex(2), 'hidden via opacity');
+ assertInternalNodeNotVisible(_ex(3), 'hidden via visibility');
+ assertInternalNodeNotVisible(_ex(4), 'hidden via display');
+ assertInternalNodeVisible(_ex(5), 'width is 0 but overflow can make it visible again?!?');
+ assertInternalNodeVisible(_ex(6), 'height is 0 but overflow can make it visible again?!?');
+ assertInternalNodeNotVisible(_ex(7), 'hidden via width:0, overflow is hidden');
+ assertInternalNodeNotVisible(_ex(8), 'hidden via height:0, overflow is hidden');
+ assertInternalNodeNotVisible(_ex(13), 'parent is hidden via opacity');
+ assertInternalNodeNotVisible(_ex(14), 'parent is hidden via visibility');
+ assertInternalNodeNotVisible(_ex(15), 'parent is hidden via display');
+ assertInternalNodeNotVisible(_ex(16), 'parent is hidden via width:0, overflow is hidden');
+ assertInternalNodeNotVisible(_ex(17), 'parent is hidden via height:0, overflow is hidden');
+
+ assertInternalNodeVisible(_ex(18), 'element is visible by 0px');
+ assertNodeNotInViewport(_ex(18), 'element is not visible, ends directly at left:0px');
+
+ assertInternalNodeVisible(_ex(19), 'element is visible by one px');
+ assertNodeIsInViewport(_ex(19), 'element is visible by one px');
+
+ assertNodeIsInViewport(_ex(20), 'element is position absolute and partially visible top');
+ assertNodeIsInViewport(_ex(21), 'element is position absolute and partially visible left');
+ assertNodeIsInViewport(_ex(22), 'element is position absolute and partially visible right');
+ assertNodeIsInViewport(_ex(23), 'element is position absolute and partially visible bottom');
+ assertNodeNotInViewport(_ex(24), 'element is position absolute and position too far top');
+ assertNodeNotInViewport(_ex(25), 'element is position absolute and position too far left');
+ assertNodeNotInViewport(_ex(26), 'element is position absolute and position too far right');
+ assertNodeNotInViewport(_ex(27), 'element is position absolute and position too far bottom');
+
+ assertNodeIsInViewport(_ex(28), 'element is position fixed and partially visible top');
+ assertNodeIsInViewport(_ex(29), 'element is position fixed and partially visible left');
+ assertNodeIsInViewport(_ex(30), 'element is position fixed and partially visible right');
+ assertNodeIsInViewport(_ex(31), 'element is position fixed and partially visible bottom');
+ assertNodeNotInViewport(_ex(32), 'element is position fixed and position too far top');
+ assertNodeNotInViewport(_ex(33), 'element is position fixed and position too far left');
+ assertNodeNotInViewport(_ex(34), 'element is position fixed and position too far right');
+ assertNodeNotInViewport(_ex(35), 'element is position fixed and position too far bottom');
+
+
+ assertInternalNodeVisible(_ex(37), 'element is within overflow scroll and it is visible');
+ assertInternalNodeNotVisible(_ex(38), 'element is within overflow scroll but not visible');
+ _ex(36).scrollTop = 35;_ex(36).scrolltop = 35; // scroll within div
+ assertInternalNodeVisible(_ex(38), 'element is within overflow scroll but not visible');
+
+ var nodesThatShouldBeInViewPort = [1,2,3,5,6,7,8,13,14,16,17];
+ var index;
+ for (index = 1; index < nodesThatShouldBeInViewPort.length; index++) {
+ if (4 === index) {
+ continue; // display:none will not be in view port
+ }
+ var exampleId = nodesThatShouldBeInViewPort[index];
+ assertNodeIsInViewport(_ex(exampleId), 'example ' + exampleId + ' the nodes have to be in view port otherwise we might test something else than expected');
+ }
+
+ assertNodeNotInViewport(_ex(9), 'margin left position is so far left it cannot be visible');
+ assertNodeNotInViewport(_ex(10), 'margin left position is so far right it cannot be visible');
+
+
+ assertContentNodeNotVisible(undefined, 'no node set');
+ assertContentNodeNotVisible(_ex(3), 'element is not visible but in viewport');
+ assertContentNodeNotVisible(_ex(18), 'element is visible but not viewport');
+ assertContentNodeNotVisible(_ex(4), 'element is neither visible nor in viewport');
+ assertContentNodeVisible(_ex(19), 'element is visible and in viewport');
+ });
+
+ test("contentFindContentValues", function() {
+
+ function _st(id) {
+ return id && (''+id) === id ? _s('#' + id) : id;
+ }
+
+ function assertFoundContent(id, expectedName, expectedPiece, expectedTarget, message) {
+ var node = _st(id)
+ if (!message) {
+ message = 'Id: ' + id;
+ }
+
+ strictEqual(content.findContentTarget(node), expectedTarget, 'findContentTarget, ' + message + ', expected ' + expectedTarget);
+ strictEqual(content.findContentPiece(node), expectedPiece, 'findContentPiece, ' + message + ', expected ' + expectedPiece);
+ strictEqual(content.findContentName(node), expectedName, 'findContentName, ' + message + ', expected ' + expectedName);
+ }
+
+ function buildContentStruct(name, piece, target) {
+ return {
+ name: name,
+ piece: piece,
+ target: target
+ };
+ }
+
+ function assertBuiltContent(id, expectedName, expectedPiece, expectedTarget, message) {
+ var node = _st(id);
+ if (!message) {
+ message = 'Id: ' + id;
+ }
+
+ var expected = buildContentStruct(expectedName, expectedPiece, expectedTarget);
+
+ propEqual(content.buildContentBlock(node), expected, 'buildContentBlock, ' + message);
+ }
+
+ function assertCollectedContent(ids, expected, message) {
+ var nodes = [];
+ var index;
+ for (index = 0; index < ids.length; index++) {
+ nodes.push(_st(ids[index]));
+ }
+
+ if (!message) {
+ message = 'Id: ' + id;
+ }
+
+ propEqual(content.collectContent(nodes), expected, 'collectContent , ' + message);
+ }
+
+ var tracker = Piwik.getTracker();
+ var content = tracker.getContent();
+ content.setLocation();
+ var actual;
+
+ setupContentTrackingFixture('manyExamples');
+
+ var origin = getOrigin();
+ var host = location.host;
+
+ assertFoundContent(undefined, undefined, undefined, undefined, 'No node set');
+ assertFoundContent('ex1', 'img-en.jpg', 'img-en.jpg', undefined);
+ assertFoundContent('ex2', 'img-en.jpg', 'img-en.jpg', undefined);
+ assertFoundContent('ex3', 'img.jpg', 'img.jpg', 'http://www.example.com');
+ assertFoundContent('ex4', 'img-en.jpg', 'img-en.jpg', 'http://www.example.com');
+ assertFoundContent('ex5', 'img-en.jpg', 'img-en.jpg', 'http://www.example.com');
+ assertFoundContent('ex6', undefined, undefined, 'http://www.example.com');
+ assertFoundContent('ex7', undefined, undefined, 'http://www.example.com');
+ assertFoundContent('ex8', 'My content', 'My content', 'http://www.example.com');
+ assertFoundContent('ex9', 'Image1', 'img-en.jpg', undefined);
+ assertFoundContent('ex10', 'http://www.example.com/path/img-en.jpg', 'http://www.example.com/path/img-en.jpg', undefined);
+ assertFoundContent('ex11', undefined, undefined, 'http://www.example.com');
+ assertFoundContent('ex12', 'Block Title', undefined, 'http://www.example.com');
+ assertFoundContent('ex13', undefined, undefined, 'http://manual.example.com');
+ assertFoundContent('ex14', undefined, undefined, undefined);
+ assertFoundContent('ex15', undefined, undefined, 'http://attr.example.com');
+ assertFoundContent('ex16', undefined, undefined, 'http://www.example.com');
+ assertFoundContent('ex17', undefined, undefined, 'http://www.example.com');
+ assertFoundContent('ex18', 'My Ad', 'http://www.example.com/path/xyz.jpg', origin + '/anylink');
+ assertFoundContent('ex19', 'http://www.example.com/path/xyz.jpg', 'http://www.example.com/path/xyz.jpg', 'http://ad.example.com');
+
+ // test removal of domain if url === current domain
+ var newUrl = origin + '/path/xyz.jpg';
+ $(_s('#ex19 img')).attr('src', newUrl);
+ assertFoundContent('ex19', '/path/xyz.jpg', newUrl, 'http://ad.example.com', 'Should remove domain if the same as current');
+
+ newUrl = 'http://' + host + '/path/xyz.jpg';
+ $(_s('#ex19 img')).attr('src', newUrl);
+ assertFoundContent('ex19', '/path/xyz.jpg', newUrl, 'http://ad.example.com', 'Should remove domain if the same as current');
+
+ newUrl = 'https://' + host + '/path/xyz.jpg';
+ $(_s('#ex19 img')).attr('src', newUrl);
+ assertFoundContent('ex19', '/path/xyz.jpg', newUrl, 'http://ad.example.com', 'Should remove domain if the same as current');
+
+ assertFoundContent('ex20', 'My Ad', undefined, 'http://ad.example.com');
+ assertFoundContent('ex21', 'Block Title', undefined, 'http://www.example.com');
+ assertFoundContent('ex22', 'Piece Title', undefined, 'http://www.example.com');
+ assertFoundContent('ex23', 'Target Title', undefined, 'http://www.example.com');
+ assertFoundContent('ex24', 'Piece Title', undefined, 'http://target.example.com');
+ assertFoundContent('ex25', 'My Ad', 'http://www.example.com/path/xyz.jpg', origin + '/anylink');
+ assertFoundContent('ex26', 'My Ad', undefined, 'http://fallback.example.com');
+ assertFoundContent('ex27', 'My Ad', undefined, origin + '/test');
+ assertFoundContent('ex28', 'My Ad', undefined, origin + '/tests/javascript/test');
+ assertFoundContent('ex29', 'My Video', 'movie.mp4', 'videoplayer');
+ assertFoundContent('ex30', 'audio.ogg', 'audio.ogg', 'audioplayer');
+ assertFoundContent('ex31', ' name ', ' pie ce ', ' targ et ', 'Should not trim');
+
+
+ ok('test buildContentBlock(node)');
+ strictEqual(content.buildContentBlock(), undefined, 'no node set');
+ assertBuiltContent('ex31', 'name', 'pie ce', 'targ et', 'Should trim values');
+ assertBuiltContent('ex30', 'audio.ogg', 'audio.ogg', 'audioplayer', 'All values set');
+ assertBuiltContent(_e('div1'), 'Unknown', 'Unknown', '', 'It is not a content block, so should use defaults');
+ assertBuiltContent('ex1', 'img-en.jpg', 'img-en.jpg', '', 'Should use default for target');
+ assertBuiltContent('ex12', 'Block Title', 'Unknown', 'http://www.example.com', 'Should use default for piece');
+ assertBuiltContent('ex15', 'Unknown', 'Unknown', 'http://attr.example.com', 'Should use default for name and piece');
+
+
+ ok('test collectContent(node)');
+ propEqual(content.collectContent(), [], 'no node set should still return array');
+
+ var expected = [
+ buildContentStruct('name', 'pie ce', 'targ et'),
+ buildContentStruct('audio.ogg', 'audio.ogg', 'audioplayer'),
+ buildContentStruct('Unknown', 'Unknown', ''),
+ buildContentStruct('img-en.jpg', 'img-en.jpg', ''),
+ buildContentStruct('Block Title', 'Unknown', 'http://www.example.com'),
+ buildContentStruct('Unknown', 'Unknown', 'http://attr.example.com'),
+ ];
+ var ids = ['ex31', 'ex30', _e('div1'), 'ex1', 'ex12', 'ex15'];
+ assertCollectedContent(ids, expected, 'should collect all content, make sure it trims values and it uses default values');
+ });
+
+ test("ContentTrackerInternals", function() {
+ var tracker = Piwik.getTracker();
+ var actual, expected, trackerUrl;
+
+ var impression = {name: 'name', piece: '5', target: 'target'};
+
+ var origin = getOrigin();
+ var originEncoded = window.encodeURIComponent(origin);
+
+ function assertTrackingRequest(actual, expectedStartsWith, message)
+ {
+ if (!message) {
+ message = '';
+ } else {
+ message += ', ';
+ }
+
+ strictEqual(actual.indexOf(expectedStartsWith), 0, message + actual + ' should start with ' + expectedStartsWith);
+ strictEqual(actual.indexOf('&idsite=&rec=1'), expectedStartsWith.length);
+ // make sure it contains all those other tracking stuff directly afterwards so we can assume it did append
+ // the other request stuff and we also make sure to compare the whole custom string as we check from
+ // expectedStartsWith.length
+ }
+
+ ok('test buildContentImpressionRequest()');
+ actual = tracker.buildContentImpressionRequest();
+ assertTrackingRequest(actual, 'c_n=undefined&c_p=undefined', 'nothing set');
+ actual = tracker.buildContentImpressionRequest('name', 'piece');
+ assertTrackingRequest(actual, 'c_n=name&c_p=piece', 'only name and piece');
+ actual = tracker.buildContentImpressionRequest('name', 'piece', 'target');
+ assertTrackingRequest(actual, 'c_n=name&c_p=piece&c_t=target');
+ actual = tracker.buildContentImpressionRequest('name://', 'x=5', '?x=5');
+ assertTrackingRequest(actual, 'c_n=name%3A%2F%2F&c_p=x%3D5&c_t=%3Fx%3D5', 'should encode values');
+
+ ok('test buildContentInteractionRequest()');
+ actual = tracker.buildContentInteractionRequest();
+ strictEqual(actual, undefined, 'nothing set should not build request');
+ actual = tracker.buildContentInteractionRequest('interaction');
+ assertTrackingRequest(actual, 'c_i=interaction');
+ actual = tracker.buildContentInteractionRequest('interaction', 'name', 'piece');
+ assertTrackingRequest(actual, 'c_i=interaction&c_n=name&c_p=piece');
+ actual = tracker.buildContentInteractionRequest('interaction', 'name', 'piece', 'target');
+ assertTrackingRequest(actual, 'c_i=interaction&c_n=name&c_p=piece&c_t=target', 'all params');
+ actual = tracker.buildContentInteractionRequest('interaction://', 'name://', 'p?=iece', 'tar=get');
+ assertTrackingRequest(actual, 'c_i=interaction%3A%2F%2F&c_n=name%3A%2F%2F&c_p=p%3F%3Diece&c_t=tar%3Dget', 'should encode');
+
+
+ setupContentTrackingFixture('manyExamples');
+
+
+ ok('test buildContentInteractionRequestNode()');
+ actual = tracker.buildContentInteractionRequestNode();
+ strictEqual(actual, undefined, 'nothing set should not build request');
+
+ actual = tracker.buildContentInteractionRequestNode(_e('div1'));
+ strictEqual(actual, undefined, 'does not contain a content block, should not build anything');
+
+ actual = tracker.buildContentInteractionRequestNode(_e('ex18'));
+ expected = 'c_i=Unknown&c_n=My%20Ad&c_p=http%3A%2F%2Fwww.example.com%2Fpath%2Fxyz.jpg&c_t=' + originEncoded + '%2Fanylink';
+ assertTrackingRequest(actual, expected, 'no interaction set should default to unknown and recognize all other values');
+
+ actual = tracker.buildContentInteractionRequestNode(_e('ex18'), 'CustomInteraction://');
+ expected = 'c_i=CustomInteraction%3A%2F%2F&c_n=My%20Ad&c_p=http%3A%2F%2Fwww.example.com%2Fpath%2Fxyz.jpg&c_t=' + originEncoded + '%2Fanylink';
+ assertTrackingRequest(actual, expected, 'custom interaction');
+
+ actual = tracker.buildContentInteractionRequestNode($('#ex18 a')[0], 'CustomInteraction://');
+ expected = 'c_i=CustomInteraction%3A%2F%2F&c_n=My%20Ad&c_p=http%3A%2F%2Fwww.example.com%2Fpath%2Fxyz.jpg&c_t=' + originEncoded + '%2Fanylink';
+ assertTrackingRequest(actual, expected, 'should automatically find parent and search for content from there');
+
+
+ actual = tracker.buildContentInteractionTrackingRedirectUrl();
+ strictEqual(actual, undefined, 'nothing set');
+
+ actual = tracker.buildContentInteractionTrackingRedirectUrl('/path?a=b');
+ assertTrackingRequest(actual, '?redirecturl=' + origin + '/path?a=b&c_t=%2Fpath%3Fa%3Db', 'should build redirect url including domain when absolute path. Target should also fallback to passed url if not set');
+
+ actual = tracker.buildContentInteractionTrackingRedirectUrl('path?a=b');
+ assertTrackingRequest(actual, '?redirecturl=' + origin + '/tests/javascript/path?a=b&c_t=path%3Fa%3Db', 'should build redirect url including domain when relative path. Target should also fallback to passed url if not set');
+
+ actual = tracker.buildContentInteractionTrackingRedirectUrl('#test', 'click', 'name', 'piece', 'target');
+ assertTrackingRequest(actual, '?redirecturl=' + origin + '/tests/javascript/#test&c_i=click&c_n=name&c_p=piece&c_t=target', 'all params set');
+
+ trackerUrl = tracker.getTrackerUrl();
+ tracker.setTrackerUrl('piwik.php?test=1');
+
+ actual = tracker.buildContentInteractionTrackingRedirectUrl('#test', 'click', 'name', 'piece', 'target');
+ assertTrackingRequest(actual, 'piwik.php?test=1&redirecturl=' + origin + '/tests/javascript/#test&c_i=click&c_n=name&c_p=piece&c_t=target', 'should use & if tracker url already contains question mark');
+
+ tracker.setTrackerUrl('piwik.php');
+ actual = tracker.buildContentInteractionTrackingRedirectUrl('piwik.php?redirecturl=http://www.example.com', 'click', 'name', 'piece', 'target');
+ strictEqual(actual, 'piwik.php?redirecturl=http://www.example.com', 'should return unmodified url if it is already a tracker url so users can set piwik.php link in href');
+
+ actual = tracker.buildContentInteractionTrackingRedirectUrl('http://www.example.com', 'click', 'name');
+ assertTrackingRequest(actual, 'piwik.php?redirecturl=http://www.example.com&c_i=click&c_n=name&c_t=http%3A%2F%2Fwww.example.com', 'should not change url if absolute');
+
+ actual = tracker.buildContentInteractionTrackingRedirectUrl(origin, 'something', 'name', undefined, 'target');
+ assertTrackingRequest(actual, 'piwik.php?redirecturl=' + origin + '&c_i=something&c_n=name&c_t=target', 'should not change url if same domain');
+
+ tracker.setTrackerUrl(trackerUrl);
+
+ ok('test wasContentImpressionAlreadyTracked()');
+ actual = tracker.wasContentImpressionAlreadyTracked(impression);
+ strictEqual(actual, false, 'wasContentImpressionAlreadyTracked, content impression was not tracked before');
+ tracker.buildContentImpressionsRequests([impression], []);
+ actual = tracker.wasContentImpressionAlreadyTracked(impression);
+ strictEqual(actual, true, 'wasContentImpressionAlreadyTracked, should be marked as already tracked now');
+ actual = tracker.wasContentImpressionAlreadyTracked({name: 'name', piece: 5, target: 'target'});
+ strictEqual(actual, false, 'wasContentImpressionAlreadyTracked, should compare with === equal parameter');
+ tracker.trackPageView();
+ actual = tracker.wasContentImpressionAlreadyTracked(impression);
+ strictEqual(actual, false, 'wasContentImpressionAlreadyTracked, trackPageView should reset tracked impressions');
+
+ setupContentTrackingFixture('trackerInternals');
+
+ ok('test appendContentInteractionToRequestIfPossible()');
+ ok(_e('notClickedTargetNode') && _e('ignoreInteraction2') && _e('ignoreInteraction1') && _e('click1') && _s('#ex103') && _s('#ex104'),
+ 'Make sure the nodes we are using for testing actually exist. Otherwise tests would be useless');
+ actual = tracker.appendContentInteractionToRequestIfPossible();
+ strictEqual(actual, undefined, 'appendContentInteractionToRequestIfPossible, nothing set');
+ actual = tracker.appendContentInteractionToRequestIfPossible(_e('click1'));
+ strictEqual(actual, undefined, 'appendContentInteractionToRequestIfPossible, no content block');
+ actual = tracker.appendContentInteractionToRequestIfPossible(_e('ignoreInteraction1'));
+ strictEqual(actual, undefined, 'appendContentInteractionToRequestIfPossible, contains block but should be ignored in target node');
+ actual = tracker.appendContentInteractionToRequestIfPossible(_e('ignoreInteraction2'));
+ strictEqual(actual, undefined, 'appendContentInteractionToRequestIfPossible, contains block but should be ignored in block node as no target node');
+ actual = tracker.appendContentInteractionToRequestIfPossible(_e('notClickedTargetNode'));
+ strictEqual(actual, undefined, 'appendContentInteractionToRequestIfPossible, not a node within target node was clicked');
+ actual = tracker.appendContentInteractionToRequestIfPossible(_s('#ex103'));
+ strictEqual(actual, undefined, 'appendContentInteractionToRequestIfPossible, the content block node was clicked but it is not the target');
+
+ actual = tracker.appendContentInteractionToRequestIfPossible(_s('#ex104'));
+ strictEqual(actual, 'c_n=img.jpg&c_p=img.jpg', 'appendContentInteractionToRequestIfPossible, the actual target node was clicked');
+
+ actual = tracker.appendContentInteractionToRequestIfPossible(_s('#ex104'), 'clicki');
+ strictEqual(actual, 'c_i=clicki&c_n=img.jpg&c_p=img.jpg', 'appendContentInteractionToRequestIfPossible, with interaction');
+
+ actual = tracker.appendContentInteractionToRequestIfPossible(_s('#ex104_inner'));
+ strictEqual(actual, 'c_n=img.jpg&c_p=img.jpg', 'appendContentInteractionToRequestIfPossible, block node is target node and any node within it was clicked which is good, we build a request');
+
+ actual = tracker.appendContentInteractionToRequestIfPossible(_s('#ex104_inner'));
+ strictEqual(actual, 'c_n=img.jpg&c_p=img.jpg', 'appendContentInteractionToRequestIfPossible, a node within a target node was clicked which is googd');
+
+ actual = tracker.appendContentInteractionToRequestIfPossible(_s('#ex105_target'));
+ strictEqual(actual, 'c_n=img.jpg&c_p=img.jpg&c_t=http%3A%2F%2Fwww.example.com', 'appendContentInteractionToRequestIfPossible, target node was clicked which is good');
+
+ actual = tracker.appendContentInteractionToRequestIfPossible(_s('#ex105_withinTarget'));
+ strictEqual(actual, 'c_n=img.jpg&c_p=img.jpg&c_t=http%3A%2F%2Fwww.example.com', 'appendContentInteractionToRequestIfPossible, a node within target node was clicked which is googd');
+
+ actual = tracker.appendContentInteractionToRequestIfPossible(_s('#ex104_inner'), 'click', 'fallbacktarget');
+ strictEqual(actual, 'c_i=click&c_n=img.jpg&c_p=img.jpg&c_t=fallbacktarget', 'appendContentInteractionToRequestIfPossible, if no target found we can specify a default target');
+
+
+
+ ok('test setupInteractionsTracking()');
+ actual = tracker.setupInteractionsTracking();
+ strictEqual(actual, undefined, 'setupInteractionsTracking, no nodes set');
+ actual = tracker.setupInteractionsTracking([_s('#ex106'), _s('#ex107')]);
+ strictEqual(_s('#ex106_target').contentInteractionTrackingSetupDone, true, 'setupInteractionsTracking, should add event to target node');
+ strictEqual(_s('#ex107').contentInteractionTrackingSetupDone, true, 'setupInteractionsTracking, should add event to block node if no target node specified');
+
+
+ ok('test trackContentImpressionClickInteraction()');
+
+ trackerUrl = tracker.getTrackerUrl();
+ tracker.setTrackerUrl('piwik.php');
+ tracker.disableLinkTracking();
+
+ ok(_s('#ignoreInteraction1') && _s('#ex108') && _s('#ex109'), 'make sure node exists otherwise test is useless');
+ actual = (tracker.trackContentImpressionClickInteraction())();
+ strictEqual(actual, undefined, 'trackContentImpressionClickInteraction, no target node set');
+ actual = (tracker.trackContentImpressionClickInteraction(_s('#ignoreInteraction1')))({target: _s('#ignoreInteraction1')});
+ strictEqual(actual, undefined, 'trackContentImpressionClickInteraction, no target node set');
+
+ actual = (tracker.trackContentImpressionClickInteraction(_s('#ex108')))({target: _s('#ex108')});
+ assertTrackingRequest(actual, 'c_i=click&c_n=http%3A%2F%2Fwww.example.com%2Fpath%2Fxyz.jpg&c_p=http%3A%2F%2Fwww.example.com%2Fpath%2Fxyz.jpg&c_t=http%3A%2F%2Fad.example.com', 'trackContentImpressionClickInteraction, is outlink but should use xhr as link tracking not enabled');
+ actual = (tracker.trackContentImpressionClickInteraction(_s('#ex109')))({target: _s('#ex109')});
+ strictEqual(actual, 'href', 'trackContentImpressionClickInteraction, is internal download but should use href as link tracking not enabled');
+ assertTrackingRequest($(_s('#ex109')).attr('href'), 'piwik.php?redirecturl=http://apache.piwik/file.pdf&c_i=click&c_n=http%3A%2F%2Fwww.example.com%2Fpath%2Fxyz.jpg&c_p=http%3A%2F%2Fwww.example.com%2Fpath%2Fxyz.jpg&c_t=http%3A%2F%2Fapache.piwik%2Ffile.pdf', 'trackContentImpressionClickInteraction, the href download link should be replaced with a redirect link to tracker');
+
+ actual = (tracker.trackContentImpressionClickInteraction(_s('#ex110')))({target: _s('#ex110')});
+ strictEqual(actual, 'href', 'trackContentImpressionClickInteraction, should be tracked using redirect');
+ assertTrackingRequest($(_s('#ex110')).attr('href'), 'piwik.php?redirecturl=' + origin + '/example&c_i=click&c_n=MyName&c_p=img.jpg&c_t=' + originEncoded + '%2Fexample', 'trackContentImpressionClickInteraction, the href link should be replaced with a redirect link to tracker');
+
+ actual = (tracker.trackContentImpressionClickInteraction(_s('#ex111')))({target: _s('#ex111')});
+ strictEqual(actual, 'href', 'trackContentImpressionClickInteraction, should detect it is a link to same page');
+ strictEqual($(_s('#ex111')).attr('href'), 'piwik.php?xyz=makesnosense', 'trackContentImpressionClickInteraction, a tracking link should not be changed');
+
+ actual = (tracker.trackContentImpressionClickInteraction(_s('#ex112')))({target: _s('#ex112')});
+ assertTrackingRequest(actual, 'c_i=click&c_n=img.jpg&c_p=img.jpg&c_t=' + originEncoded + '%2Ftests%2Fjavascript%2F%23example', 'trackContentImpressionClickInteraction, a link that is an anchor should be tracked as XHR and no redirect');
+
+ actual = (tracker.trackContentImpressionClickInteraction(_s('#ex113_target')))({target: _s('#ex113_target')});
+ assertTrackingRequest(actual, 'c_i=click&c_n=img.jpg&c_p=img.jpg', 'trackContentImpressionClickInteraction, if element is not A or AREA it should always use xhr');
+
+ actual = (tracker.trackContentImpressionClickInteraction(_s('#ex114')))({target: _s('#ex114')});
+ assertTrackingRequest(actual, 'c_i=click&c_n=imgnohref.jpg&c_p=imgnohref.jpg&c_t=%2Ftest', 'trackContentImpressionClickInteraction, if element is an A or AREA element but has no href attribute it should always use xhr');
+
+ tracker.enableLinkTracking();
+
+ actual = (tracker.trackContentImpressionClickInteraction(_s('#ex108')))({target: _s('#ex108')});
+ strictEqual(actual, 'link', 'trackContentImpressionClickInteraction, should not track as is an outlink and link tracking enabled');
+ $(_s('#ex109')).attr('href', '/file.pdf'); // reset download link as was replaced with piwik.php
+ actual = (tracker.trackContentImpressionClickInteraction(_s('#ex109')))({target: _s('#ex109')});
+ strictEqual(actual, 'download', 'trackContentImpressionClickInteraction, should not track as is a download and link tracking enabled');
+
+ tracker.disableLinkTracking();
+ tracker.setTrackerUrl(trackerUrl);
+
+
+ ok('test buildContentImpressionsRequests()');
+ ok(impression, 'we should have an impression');
+ tracker.clearTrackedContentImpressions();
+
+ actual = tracker.buildContentImpressionsRequests();
+ propEqual(actual, [], 'buildContentImpressionsRequests, nothing set');
+ strictEqual(tracker.getTrackedContentImpressions().length, 0, 'buildContentImpressionsRequests, tracked impressions should be empty');
+
+ actual = tracker.buildContentImpressionsRequests([impression]);
+ propEqual(tracker.getTrackedContentImpressions(), [impression], 'buildContentImpressionsRequests, should have marked content as tracked');
+
+ actual = tracker.buildContentImpressionsRequests([impression]);
+ propEqual(actual, [], 'buildContentImpressionsRequests, nothing tracked as supposed to be ignored');
+ propEqual(tracker.getTrackedContentImpressions(), [impression], 'buildContentImpressionsRequests, impression should be ignored as it was already tracked before');
+
+ tracker.clearTrackedContentImpressions();
+ delete _s('#ignoreInteraction1').contentInteractionTrackingSetupDone;
+ tracker.buildContentImpressionsRequests([impression], [_s('#ex101')]);
+ strictEqual(_s('#ignoreInteraction1').contentInteractionTrackingSetupDone, true, 'buildContentImpressionsRequests, should trigger setup of interaction tracking');
+
+ tracker.clearTrackedContentImpressions();
+ actual = tracker.buildContentImpressionsRequests([impression], [_s('#ex101')]);
+ strictEqual(actual.length, 1, 'buildContentImpressionsRequests, should generate a request for one request');
+ assertTrackingRequest(actual[0], 'c_n=name&c_p=5&c_t=target');
+
+ tracker.clearTrackedContentImpressions();
+ var impression2 = {name: 'name2', piece: 'piece2', target: 'http://www.example.com'};
+ var impression3 = {name: 'name3', piece: 'piece3', target: 'Anything'};
+
+ actual = tracker.buildContentImpressionsRequests([impression, impression, impression2, impression, impression3], [_s('#ex101')]);
+ strictEqual(actual.length, 3, 'buildContentImpressionsRequests, should be only 3 requests as one impression was there twice and should be ignored once');
+ assertTrackingRequest(actual[0], 'c_n=name&c_p=5&c_t=target');
+ assertTrackingRequest(actual[1], 'c_n=name2&c_p=piece2&c_t=http%3A%2F%2Fwww.example.com');
+ assertTrackingRequest(actual[2], 'c_n=name3&c_p=piece3&c_t=Anything');
+
+
+ setupContentTrackingFixture('manyExamples');
+
+
+ ok('test getContentImpressionsRequestsFromNodes()');
+ actual = tracker.getContentImpressionsRequestsFromNodes();
+ propEqual(actual, [], 'getContentImpressionsRequestsFromNodes, no nodes set');
+
+ tracker.clearTrackedContentImpressions();
+ actual = tracker.getContentImpressionsRequestsFromNodes([undefined, null]);
+ propEqual(actual, [], 'getContentImpressionsRequestsFromNodes, no nodes set that are actually content nodes');
+
+ tracker.clearTrackedContentImpressions();
+ actual = tracker.getContentImpressionsRequestsFromNodes([_s('#ex1'), _s('#ex2')]);
+ strictEqual(actual.length, 1, 'getContentImpressionsRequestsFromNodes, should ignore a duplicated node that has same content');
+ assertTrackingRequest(actual[0], 'c_n=img-en.jpg&c_p=img-en.jpg');
+
+ tracker.clearTrackedContentImpressions();
+ actual = tracker.getContentImpressionsRequestsFromNodes([_s('#ex1'), undefined, _s('#ex2'), _s('#ex8'), _s('#ex19')]);
+ strictEqual(actual.length, 3, 'getContentImpressionsRequestsFromNodes, should only build requests for nodes that are content nodes');
+ assertTrackingRequest(actual[0], 'c_n=img-en.jpg&c_p=img-en.jpg');
+ assertTrackingRequest(actual[1], 'c_n=My%20content&c_p=My%20content&c_t=http%3A%2F%2Fwww.example.com');
+ assertTrackingRequest(actual[2], 'c_n=http%3A%2F%2Fwww.example.com%2Fpath%2Fxyz.jpg&c_p=http%3A%2F%2Fwww.example.com%2Fpath%2Fxyz.jpg&c_t=http%3A%2F%2Fad.example.com');
+
+ setupContentTrackingFixture('trackerInternals', document.body);
+
+ ok('test getCurrentlyVisibleContentImpressionsRequestsIfNotTrackedYet()');
+
+ actual = tracker.getCurrentlyVisibleContentImpressionsRequestsIfNotTrackedYet();
+ propEqual(actual, [], 'getVisibleImpressions, no nodes set');
+
+ _s('#ex115').scrollIntoView(true);
+ tracker.clearTrackedContentImpressions();
+ actual = tracker.getCurrentlyVisibleContentImpressionsRequestsIfNotTrackedYet([_s('#ex116_hidden')]);
+ propEqual(actual, [], 'getVisibleImpressions, if all are hidden should not return anything');
+
+ _s('#ex115').scrollIntoView(true);
+ tracker.clearTrackedContentImpressions();
+ actual = tracker.getCurrentlyVisibleContentImpressionsRequestsIfNotTrackedYet([_s('#ex115'),_s('#ex115'), _s('#ex116_hidden')]);
+ strictEqual(actual.length, 1, 'getVisibleImpressions, should not ignore the found requests but the visible ones, should not add the same one twice');
+ assertTrackingRequest(actual[0], 'c_n=img115.jpg&c_p=img115.jpg&c_t=http%3A%2F%2Fwww.example.com');
+
+ _s('#ex115').scrollIntoView(true);
+ tracker.clearTrackedContentImpressions();
+ actual = tracker.getCurrentlyVisibleContentImpressionsRequestsIfNotTrackedYet([_s('#ex116_hidden'), _s('#ex116_hidden'), _s('#ex115'),_s('#ex115')]);
+ strictEqual(actual.length, 1, 'getVisibleImpressions, two hidden ones before a visible ones to make sure removing hidden content block from array works and does not ignore one');
+ assertTrackingRequest(actual[0], 'c_n=img115.jpg&c_p=img115.jpg&c_t=http%3A%2F%2Fwww.example.com');
+
+
+ ok('test replaceHrefIfInternalLink()')
+
+ var trackerUrl = tracker.getTrackerUrl();
+ tracker.setTrackerUrl('piwik.php');
+
+ strictEqual(tracker.replaceHrefIfInternalLink(), false, 'no content node set');
+ strictEqual(tracker.replaceHrefIfInternalLink(_s('#ex117')), false, 'should be ignored');
+ $(_s('#ignoreInternalLink')).removeClass('piwikContentIgnoreInteraction'); // now it should be no longer ignored and as it is an intenral link replaced
+ strictEqual(tracker.replaceHrefIfInternalLink(_s('#ex117')), true, 'should be replaced as is internal link');
+ assertTrackingRequest($(_s('#ignoreInternalLink')).attr('href'), 'piwik.php?redirecturl=http://apache.piwik/internallink&c_i=click&c_t=http%3A%2F%2Fapache.piwik%2Finternallink', 'internal link should be replaced');
+ strictEqual($(_s('#ignoreInternalLink')).attr('data-content-target'), origin + '/internallink', 'we need to set data-content-target when link is set otherwise a replace would not be found');
+
+ strictEqual(tracker.replaceHrefIfInternalLink(_s('#ex122')), true, 'should be replaced');
+ strictEqual($(_s('#replacedLinkWithTarget')).attr('data-content-target'), '/test', 'should replace href but not a data-content-target if already exists');
+
+ strictEqual(tracker.replaceHrefIfInternalLink(_s('#ex118')), true, 'should not replace already replaced link');
+ strictEqual($(_s('#ex118')).attr('href'), 'piwik.php?test=5', 'link should not be replaced');
+
+ strictEqual(tracker.replaceHrefIfInternalLink(_s('#ex119')), false, 'anchor link should not be replaced');
+ strictEqual($(_s('#ex119')).attr('href'), '#test', 'link should not replace anchor link');
+
+ strictEqual(tracker.replaceHrefIfInternalLink(_s('#ex120')), false, 'external link should not be replaced');
+ strictEqual($(_s('#ex120')).attr('href'), 'http://www.example.com', 'should not replace external link');
+
+ strictEqual(tracker.replaceHrefIfInternalLink(_s('#ex121')), true, 'should replace download link if link tracking not enabled');
+ assertTrackingRequest($(_s('#ex121')).attr('href'), 'piwik.php?redirecturl=http://apache.piwik/download.pdf&c_i=click&c_t=http%3A%2F%2Fapache.piwik%2Fdownload.pdf', 'should replace download link as link tracking disabled');
+
+ $(_s('#ex121')).attr('href', '/download.pdf'); // reset link
+ tracker.enableLinkTracking();
+
+ strictEqual(tracker.replaceHrefIfInternalLink(_s('#ex121')), false, 'should not replace download link');
+ strictEqual($(_s('#ex121')).attr('href'), '/download.pdf', 'should not replace download link');
+
+ strictEqual(tracker.replaceHrefIfInternalLink(_s('#ex123')), false, 'should not replace a link that has no href');
+ strictEqual($(_s('#ex123')).attr('href'), undefined, 'should still not have a href attribute');
+
+
+
+ tracker.setTrackerUrl(trackerUrl);
+
+ removeContentTrackingFixture();
+ });
test("Basic requirements", function() {
expect(3);
@@ -322,7 +1796,7 @@ function PiwikTest() {
});
test("API methods", function() {
- expect(58);
+ expect(63);
equal( typeof Piwik.addPlugin, 'function', 'addPlugin' );
equal( typeof Piwik.getTracker, 'function', 'getTracker' );
@@ -347,7 +1821,6 @@ function PiwikTest() {
equal( typeof tracker.getRequest, 'function', 'getRequest' );
equal( typeof tracker.addPlugin, 'function', 'addPlugin' );
equal( typeof tracker.setSiteId, 'function', 'setSiteId' );
- equal( typeof tracker.setUserId, 'function', 'setUserId' );
equal( typeof tracker.setCustomData, 'function', 'setCustomData' );
equal( typeof tracker.getCustomData, 'function', 'getCustomData' );
equal( typeof tracker.setCustomRequestProcessing, 'function', 'setCustomRequestProcessing' );
@@ -385,6 +1858,13 @@ function PiwikTest() {
equal( typeof tracker.trackGoal, 'function', 'trackGoal' );
equal( typeof tracker.trackLink, 'function', 'trackLink' );
equal( typeof tracker.trackPageView, 'function', 'trackPageView' );
+ // content
+ equal( typeof tracker.trackAllContentImpressions, 'function', 'trackAllContentImpressions' );
+ equal( typeof tracker.trackVisibleContentImpressions, 'function', 'trackVisibleContentImpressions' );
+ equal( typeof tracker.trackContentImpression, 'function', 'trackContentImpression' );
+ equal( typeof tracker.trackContentImpressionsWithinNode, 'function', 'trackContentImpressionsWithinNode' );
+ equal( typeof tracker.trackContentInteraction, 'function', 'trackContentInteraction' );
+ equal( typeof tracker.trackContentInteractionNode, 'function', 'trackContentInteractionNode' );
// ecommerce
equal( typeof tracker.setEcommerceView, 'function', 'setEcommerceView' );
equal( typeof tracker.addEcommerceItem, 'function', 'addEcommerceItem' );
@@ -679,46 +2159,72 @@ function PiwikTest() {
});
test("Tracker setDownloadExtensions(), addDownloadExtensions(), setDownloadClasses(), setLinkClasses(), and getLinkType()", function() {
- expect(25);
+ expect(54);
var tracker = Piwik.getTracker();
- equal( typeof tracker.hook.test._getLinkType, 'function', 'getLinkType' );
-
- equal( tracker.hook.test._getLinkType('something', 'goofy.html', false), 'link', 'implicit link' );
- equal( tracker.hook.test._getLinkType('something', 'goofy.pdf', false), 'download', 'external PDF files are downloads' );
- equal( tracker.hook.test._getLinkType('something', 'goofy.pdf', true), 'download', 'local PDF are downloads' );
- equal( tracker.hook.test._getLinkType('something', 'goofy-with-dash.pdf', true), 'download', 'local PDF are downloads' );
-
- equal( tracker.hook.test._getLinkType('piwik_download', 'piwiktest.ext', true), 'download', 'piwik_download' );
- equal( tracker.hook.test._getLinkType('abc piwik_download xyz', 'piwiktest.ext', true), 'download', 'abc piwik_download xyz' );
- equal( tracker.hook.test._getLinkType('piwik_link', 'piwiktest.asp', true), 'link', 'piwik_link' );
- equal( tracker.hook.test._getLinkType('abc piwik_link xyz', 'piwiktest.asp', true), 'link', 'abc piwik_link xyz' );
- equal( tracker.hook.test._getLinkType('something', 'piwiktest.txt', true), 'download', 'download extension' );
- equal( tracker.hook.test._getLinkType('something', 'piwiktest.ext', true), 0, '[1] link (default)' );
-
- equal( tracker.hook.test._getLinkType('something', 'file.zip', true), 'download', 'download file.zip' );
- equal( tracker.hook.test._getLinkType('something', 'index.php?name=file.zip#anchor', true), 'download', 'download file.zip (anchor)' );
- equal( tracker.hook.test._getLinkType('something', 'index.php?name=file.zip&redirect=yes', true), 'download', 'download file.zip (is param)' );
- equal( tracker.hook.test._getLinkType('something', 'file.zip?mirror=true', true), 'download', 'download file.zip (with param)' );
-
- tracker.setDownloadExtensions('pk');
- equal( tracker.hook.test._getLinkType('something', 'piwiktest.pk', true), 'download', '[1] .pk == download extension' );
- equal( tracker.hook.test._getLinkType('something', 'piwiktest.txt', true), 0, '.txt =! download extension' );
-
- tracker.addDownloadExtensions('xyz');
- equal( tracker.hook.test._getLinkType('something', 'piwiktest.pk', true), 'download', '[2] .pk == download extension' );
- equal( tracker.hook.test._getLinkType('something', 'piwiktest.xyz', true), 'download', '.xyz == download extension' );
-
- tracker.setDownloadClasses(['a', 'b']);
- equal( tracker.hook.test._getLinkType('abc piwik_download', 'piwiktest.ext', true), 'download', 'download (default)' );
- equal( tracker.hook.test._getLinkType('abc a', 'piwiktest.ext', true), 'download', 'download (a)' );
- equal( tracker.hook.test._getLinkType('b abc', 'piwiktest.ext', true), 'download', 'download (b)' );
-
- tracker.setLinkClasses(['c', 'd']);
- equal( tracker.hook.test._getLinkType('abc piwik_link', 'piwiktest.ext', true), 'link', 'link (default)' );
- equal( tracker.hook.test._getLinkType('abc c', 'piwiktest.ext', true), 'link', 'link (c)' );
- equal( tracker.hook.test._getLinkType('d abc', 'piwiktest.ext', true), 'link', 'link (d)' );
+ function runTests(messagePrefix) {
+
+ equal( typeof tracker.hook.test._getLinkType, 'function', 'getLinkType' );
+
+ equal( tracker.hook.test._getLinkType('something', 'goofy.html', false), 'link', messagePrefix + 'implicit link' );
+ equal( tracker.hook.test._getLinkType('something', 'goofy.pdf', false), 'download', messagePrefix + 'external PDF files are downloads' );
+ equal( tracker.hook.test._getLinkType('something', 'goofy.pdf', true), 'download', messagePrefix + 'local PDF are downloads' );
+ equal( tracker.hook.test._getLinkType('something', 'goofy-with-dash.pdf', true), 'download', messagePrefix + 'local PDF are downloads' );
+
+ equal( tracker.hook.test._getLinkType('piwik_download', 'piwiktest.ext', true), 'download', messagePrefix + 'piwik_download' );
+ equal( tracker.hook.test._getLinkType('abc piwik_download xyz', 'piwiktest.ext', true), 'download', messagePrefix + 'abc piwik_download xyz' );
+ equal( tracker.hook.test._getLinkType('piwik_link', 'piwiktest.asp', true), 'link', messagePrefix+ 'piwik_link' );
+ equal( tracker.hook.test._getLinkType('abc piwik_link xyz', 'piwiktest.asp', true), 'link', messagePrefix + 'abc piwik_link xyz' );
+ equal( tracker.hook.test._getLinkType('something', 'piwiktest.txt', true), 'download', messagePrefix + 'download extension' );
+ equal( tracker.hook.test._getLinkType('something', 'piwiktest.ext', true), 0, messagePrefix + '[1] link (default)' );
+
+ equal( tracker.hook.test._getLinkType('something', 'file.zip', true), 'download', messagePrefix + 'download file.zip' );
+ equal( tracker.hook.test._getLinkType('something', 'index.php?name=file.zip#anchor', true), 'download', messagePrefix + 'download file.zip (anchor)' );
+ equal( tracker.hook.test._getLinkType('something', 'index.php?name=file.zip&redirect=yes', true), 'download', messagePrefix + 'download file.zip (is param)' );
+ equal( tracker.hook.test._getLinkType('something', 'file.zip?mirror=true', true), 'download', messagePrefix + 'download file.zip (with param)' );
+
+ tracker.setDownloadExtensions('pk');
+ equal( tracker.hook.test._getLinkType('something', 'piwiktest.pk', true), 'download', messagePrefix + '[1] .pk == download extension' );
+ equal( tracker.hook.test._getLinkType('something', 'piwiktest.txt', true), 0, messagePrefix + '.txt =! download extension' );
+
+ tracker.addDownloadExtensions('xyz');
+ equal( tracker.hook.test._getLinkType('something', 'piwiktest.pk', true), 'download', messagePrefix + '[2] .pk == download extension' );
+ equal( tracker.hook.test._getLinkType('something', 'piwiktest.xyz', true), 'download', messagePrefix + '.xyz == download extension' );
+
+ tracker.setDownloadClasses(['a', 'b']);
+ equal( tracker.hook.test._getLinkType('abc piwik_download', 'piwiktest.ext', true), 'download', messagePrefix + 'download (default)' );
+ equal( tracker.hook.test._getLinkType('abc a', 'piwiktest.ext', true), 'download', messagePrefix + 'download (a)' );
+ equal( tracker.hook.test._getLinkType('b abc', 'piwiktest.ext', true), 'download', messagePrefix + 'download (b)' );
+
+ tracker.setLinkClasses(['c', 'd']);
+ equal( tracker.hook.test._getLinkType('abc piwik_link', 'piwiktest.ext', true), 'link', messagePrefix + 'link (default)' );
+ equal( tracker.hook.test._getLinkType('abc c', 'piwiktest.ext', true), 'link', messagePrefix + 'link (c)' );
+ equal( tracker.hook.test._getLinkType('d abc', 'piwiktest.ext', true), 'link', messagePrefix + 'link (d)' );
+ }
+
+ var trackerUrl = tracker.getTrackerUrl();
+ var downloadExtensions = tracker.getConfigDownloadExtensions();
+ tracker.setTrackerUrl('');
+ tracker.setDownloadClasses([]);
+ tracker.setLinkClasses([]);
+
+ equal( tracker.hook.test._getLinkType('something', 'piwik.php', false), 'link', 'an empty tracker url should not match configtrackerurl' );
+
+ runTests('without tracker url, ');
+
+ tracker.setTrackerUrl('piwik.php');
+ tracker.setDownloadClasses([]);
+ tracker.setLinkClasses([]);
+ tracker.setDownloadExtensions(downloadExtensions);
+
+ runTests('with tracker url, ');
+
+ equal( tracker.hook.test._getLinkType('something', 'piwik.php', true), 0, 'matches tracker url and should never return any tracker Url' );
+ equal( tracker.hook.test._getLinkType('something', 'piwik.php?redirecturl=http://example.com/test.pdf', true), 0, 'should not match download as is config tracker url' );
+ equal( tracker.hook.test._getLinkType('something', 'piwik.php?redirecturl=http://example.com/', true), 0, 'should not match link as is config tracker url' );
+
+ tracker.setTrackerUrl(trackerUrl);
});
test("utf8_encode(), sha1()", function() {
@@ -948,7 +2454,7 @@ if ($sqlite) {
});
test("tracking", function() {
- expect(99);
+ expect(98);
/*
* Prevent Opera and HtmlUnit from performing the default action (i.e., load the href URL)
@@ -971,16 +2477,6 @@ if ($sqlite) {
tracker.setTrackerUrl("piwik.php");
tracker.setSiteId(1);
- function wait(msecs)
- {
- var start = new Date().getTime();
- var cur = start
- while(cur - start < msecs)
- {
- cur = new Date().getTime();
- }
- }
-
var visitorIdStart = tracker.getVisitorId();
// need to wait at least 1 second so that the cookie would be different, if it wasnt persisted
wait(2000);
@@ -1044,7 +2540,7 @@ if ($sqlite) {
piwik_log("CompatibilityLayer", 1, "piwik.php", { "token" : getToken() });
tracker.hook.test._addEventListener(_e("click8"), "click", stopEvent);
- QUnit.triggerEvent( _e("click8"), "click" );
+ triggerEvent(_e("click8"), 'click');
tracker.enableLinkTracking();
@@ -1052,7 +2548,7 @@ if ($sqlite) {
var buttons = new Array("click1", "click2", "click3", "click4", "click5", "click6", "click7");
for (var i=0; i < buttons.length; i++) {
tracker.hook.test._addEventListener(_e(buttons[i]), "click", stopEvent);
- QUnit.triggerEvent( _e(buttons[i]), "click" );
+ triggerEvent(_e(buttons[i]), 'click');
}
var xhr = window.XMLHttpRequest ? new window.XMLHttpRequest() :
@@ -1068,7 +2564,7 @@ if ($sqlite) {
clickDiv.appendChild(anchor);
tracker.addListener(anchor);
tracker.hook.test._addEventListener(anchor, "click", stopEvent);
- QUnit.triggerEvent( _e("click9"), "click" );
+ triggerEvent(_e('click9'), 'click');
var visitorId1, visitorId2;
@@ -1189,9 +2685,6 @@ if ($sqlite) {
// do not track
tracker3.setDoNotTrack(false);
- // User ID
- tracker3.setUserId('userid@mydomain.org');
-
// Append tracking url parameter
tracker3.appendToTrackingUrl("appended=1&appended2=value");
@@ -1317,9 +2810,6 @@ if ($sqlite) {
// Testing the Tracking URL append
ok( /&appended=1&appended2=value/.test( results ), "appendToTrackingUrl(query) function");
- // Testing the User ID setter
- ok( /&uid=userid%40mydomain.org/.test( results ), "setUserId(userId) function");
-
// Testing the JavaScript Error Tracking
ok( /e_c=JavaScript%20Errors&e_a=http%3A%2F%2Fpiwik.org%2Fpath%2Fto%2Ffile.js%3Fcb%3D34343%3A44%3A12&e_n=Uncaught%20Error%3A%20The%20message&idsite=1/.test( results ), "enableJSErrorTracking() function with predefined onerror event");
ok( /e_c=JavaScript%20Errors&e_a=http%3A%2F%2Fpiwik.org%2Fpath%2Fto%2Ffile.js%3Fcb%3D3kfkf%3A45&e_n=Second%20Error%3A%20With%20less%20data&idsite=1/.test( results ), "enableJSErrorTracking() function without predefined onerror event and less parameters");
@@ -1327,6 +2817,413 @@ if ($sqlite) {
start();
}, 5000);
});
+
+ test("trackingContent", function() {
+ expect(77);
+
+ function assertTrackingRequest(actual, expectedStartsWith, message)
+ {
+ if (!message) {
+ message = '';
+ } else {
+ message += ', ';
+ }
+
+ expectedStartsWith = '<span>/tests/javascript/piwik.php?' + expectedStartsWith;
+
+ strictEqual(actual.indexOf(expectedStartsWith), 0, message + actual + ' should start with ' + expectedStartsWith);
+ strictEqual(actual.indexOf('&idsite=1&rec=1'), expectedStartsWith.length);
+ }
+
+ function resetTracker(track, token, replace)
+ {
+ tracker.clearTrackedContentImpressions();
+ tracker.clearEnableTrackOnlyVisibleContent();
+ tracker.setCustomData('token', token);
+ scrollToTop();
+ }
+
+ var token = getContentToken();
+
+ var tracker = Piwik.getTracker();
+ tracker.setTrackerUrl("piwik.php");
+ tracker.setSiteId(1);
+ resetTracker(tracker, token);
+
+ var visitorIdStart = tracker.getVisitorId();
+ // need to wait at least 1 second so that the cookie would be different, if it wasnt persisted
+ wait(2000);
+
+ var actual, expected, trackerUrl;
+
+ tracker.trackAllContentImpressions();
+ strictEqual(tracker.getTrackedContentImpressions().length, 0, 'getTrackedContentImpressions, there is no content block to track');
+ tracker.trackContentImpressionsWithinNode(_e('other'));
+ strictEqual(tracker.getTrackedContentImpressions().length, 0, 'getTrackedContentImpressionsWithinNode, there is no content block to track');
+ tracker.trackContentInteractionNode();
+ strictEqual(tracker.getTrackedContentImpressions().length, 0, 'trackContentInteractionNode, no node given should not track anything');
+
+ setupContentTrackingFixture('trackingContent', document.body);
+
+ tracker.trackAllContentImpressions();
+ strictEqual(tracker.getTrackedContentImpressions().length, 7, 'should mark 7 content blocks as tracked');
+
+ wait(500);
+
+ var token2 = '2' + token;
+ resetTracker(tracker, token2);
+ tracker.trackContentImpressionsWithinNode(_s('#block1'));
+ strictEqual(tracker.getTrackedContentImpressions().length, 3, 'should mark 3 content blocks as tracked');
+
+ tracker.clearTrackedContentImpressions();
+ tracker.trackContentImpressionsWithinNode(_e('click1'));
+ strictEqual(tracker.getTrackedContentImpressions().length, 0, 'should not track anything as does not contain content block');
+
+ wait(500);
+
+ var token3 = '3' + token;
+ resetTracker(tracker, token3);
+ tracker.trackContentImpression(); // should not track anything as name is required
+ tracker.trackContentImpression('MyName'); // piece should default to Unknown
+ wait(500);
+ tracker.trackContentImpression('Any://Name', 'AnyPiece?', 'http://www.example.com');
+ strictEqual(tracker.getTrackedContentImpressions().length, 0, 'manual impression call should not be marked as already tracked');
+
+ wait(500);
+
+ var token4 = '4' + token;
+ resetTracker(tracker, token4);
+ tracker.trackContentInteraction(); // should not track anything as interaction and name is required
+ tracker.trackContentInteraction('Clicki'); // should not track anything as interaction and name is required
+ tracker.trackContentInteraction('Clicke', 'IntName'); // should use default for piece and ignore target as it is not set
+ wait(500);
+ tracker.trackContentInteraction('Clicki', 'IntN:/ame', 'IntPiece?', 'http://int.example.com');
+
+ wait(500);
+
+ setupContentTrackingFixture('trackingContent', document.body);
+
+ var token5 = '5' + token;
+ resetTracker(tracker, token5);
+ tracker.trackContentInteractionNode(_s('#ex5'), 'Clicki?iii');
+
+ wait(500);
+
+ var token6 = '6' + token;
+ resetTracker(tracker, token6);
+ tracker.enableTrackOnlyVisibleContent(false, 0);
+ tracker.trackAllContentImpressions();
+ strictEqual(tracker.getTrackedContentImpressions().length, 7, 'should still track all impressions even if visible enabled');
+
+ var token7 = '7' + token;
+ resetTracker(tracker, token7);
+ tracker.enableTrackOnlyVisibleContent(false, 0);
+ tracker.trackContentImpressionsWithinNode();
+ strictEqual(tracker.getTrackedContentImpressions().length, 0, 'should not track anything, no node provided');
+ tracker.trackContentImpressionsWithinNode(_s('#block1'));
+ strictEqual(tracker.getTrackedContentImpressions().length, 0, 'should not track any block since all not visible');
+ tracker.trackContentImpressionsWithinNode(_s('#block2'));
+ strictEqual(tracker.getTrackedContentImpressions().length, 2, 'should track the two visible ones');
+
+ wait(500);
+
+ var token8 = '8' + token;
+ resetTracker(tracker, token8);
+ tracker.trackVisibleContentImpressions(false, 0, tracker);
+ strictEqual(tracker.getTrackedContentImpressions().length, 3, 'should only track all visible impressions');
+
+
+ wait(500);
+
+ // test detection of content via interval
+ var token9 = '9' + token;
+ var token10 = '10' + token;
+ resetTracker(tracker, token9);
+ tracker.trackVisibleContentImpressions(false, 500);
+ strictEqual(tracker.getTrackedContentImpressions().length, 3, 'should only track all visible impressions, timeInterval');
+ _s('#block1').style.display = 'block';
+ scrollToTop();
+
+ setTimeout(function () {
+ strictEqual(tracker.getTrackedContentImpressions().length, 6, 'should now have tracked 6 impressions via time interval');
+ tracker.clearEnableTrackOnlyVisibleContent(); // stop visible content time interval check
+
+ // test detection of content via scroll
+ setTimeout(function () {
+ _s('#block1').style.display = 'none';
+ resetTracker(tracker, token10);
+ tracker.trackVisibleContentImpressions(true, 0);
+ strictEqual(tracker.getTrackedContentImpressions().length, 3, 'should trak 3 initial visible impressions, scroll');
+ _s('#block1').style.display = 'block';
+ window.scrollTo(0, 200); // should trigger scroll event
+ setTimeout(function () {
+ strictEqual(tracker.getTrackedContentImpressions().length, 6, 'should detect 3 more afer scroll');
+ tracker.clearEnableTrackOnlyVisibleContent(); // stop visible content scroll interval check
+ }, 500);
+
+ }, 250); // wait for time interval to stop.
+
+ }, 1500);
+
+ stop();
+ setTimeout(function() {
+ removeContentTrackingFixture();
+
+ // trackAllContentImpressions()
+ var results = fetchTrackedRequests(token);
+ equal( (/<span\>([0-9]+)\<\/span\>/.exec(results))[1], "7", "count trackAllContentImpressions requests. all content blocks should be tracked" );
+
+ var requests = results.match(/<span\>(.*?)\<\/span\>/g);
+ requests.shift();
+
+ assertTrackingRequest(requests[0], 'c_n=img1-en.jpg&c_p=img1-en.jpg');
+ assertTrackingRequest(requests[1], 'c_n=My%20Ad%205&c_p=http%3A%2F%2Fimg5.example.com%2Fpath%2Fxyz.jpg&c_t=http%3A%2F%2Fapache.piwik%2Fanylink5');
+ assertTrackingRequest(requests[2], 'c_n=http%3A%2F%2Fwww.example.com%2Fpath%2Fxyz.jpg&c_p=http%3A%2F%2Fwww.example.com%2Fpath%2Fxyz.jpg&c_t=http%3A%2F%2Fimg6.example.com');
+ assertTrackingRequest(requests[3], 'c_n=My%20Ad%207&c_p=Unknown&c_t=http%3A%2F%2Fimg7.example.com');
+ assertTrackingRequest(requests[4], 'c_n=img.jpg&c_p=img.jpg&c_t=http%3A%2F%2Fimg2.example.com');
+ assertTrackingRequest(requests[5], 'c_n=img3-en.jpg&c_p=img3-en.jpg&c_t=http%3A%2F%2Fimg3.example.com');
+ assertTrackingRequest(requests[6], 'c_n=My%20content%204&c_p=My%20content%204&c_t=http%3A%2F%2Fimg4.example.com');
+
+
+ // trackContentImpressionsWithinNode()
+ results = fetchTrackedRequests(token2);
+ equal( (/<span\>([0-9]+)\<\/span\>/.exec(results))[1], "3", "count trackContentImpressionsWithinNode requests. should track only content blocks within node" );
+
+ requests = results.match(/<span\>(.*?)\<\/span\>/g);
+ requests.shift();
+
+ assertTrackingRequest(requests[0], 'c_n=img.jpg&c_p=img.jpg&c_t=http%3A%2F%2Fimg2.example.com');
+
+ // trackContentImpression()
+ results = fetchTrackedRequests(token3);
+ equal( (/<span\>([0-9]+)\<\/span\>/.exec(results))[1], "2", "count trackContentImpression requests. " );
+
+ requests = results.match(/<span\>(.*?)\<\/span\>/g);
+ requests.shift();
+
+ var firstRequest = 0;
+ var secondRequest = 1;
+ if (-1 === requests[0].indexOf('MyName')) {
+ firstRequest = 1;
+ secondRequest = 0;
+ }
+
+ assertTrackingRequest(requests[firstRequest], 'c_n=MyName&c_p=Unknown');
+ assertTrackingRequest(requests[secondRequest], 'c_n=Any%3A%2F%2FName&c_p=AnyPiece%3F&c_t=http%3A%2F%2Fwww.example.com');
+
+
+ // trackContentInteraction()
+ results = fetchTrackedRequests(token4);
+ equal( (/<span\>([0-9]+)\<\/span\>/.exec(results))[1], "2", "count trackContentInteraction requests." );
+
+ requests = results.match(/<span\>(.*?)\<\/span\>/g);
+ requests.shift();
+
+ firstRequest = 0;
+ secondRequest = 1;
+ if (-1 === requests[0].indexOf('IntName')) {
+ firstRequest = 1;
+ secondRequest = 0;
+ }
+
+ assertTrackingRequest(requests[firstRequest], 'c_i=Clicke&c_n=IntName&c_p=Unknown');
+ assertTrackingRequest(requests[secondRequest], 'c_i=Clicki&c_n=IntN%3A%2Fame&c_p=IntPiece%3F&c_t=http%3A%2F%2Fint.example.com');
+
+
+ // trackContentInteractionNode()
+ results = fetchTrackedRequests(token5);
+ equal( (/<span\>([0-9]+)\<\/span\>/.exec(results))[1], "1", "count trackContentInteractionNode requests." );
+
+ requests = results.match(/<span\>(.*?)\<\/span\>/g);
+ requests.shift();
+
+ assertTrackingRequest(requests[0], 'c_i=Clicki%3Fiii&c_n=My%20Ad%205&c_p=http%3A%2F%2Fimg5.example.com%2Fpath%2Fxyz.jpg&c_t=http%3A%2F%2Fapache.piwik%2Fanylink5');
+
+
+ // enableTrackOnlyVisibleContent() && trackAllContentImpressions()
+ results = fetchTrackedRequests(token6);
+ equal( (/<span\>([0-9]+)\<\/span\>/.exec(results))[1], "7", "count enabledVisibleContentImpressions requests." );
+
+
+ // enableTrackOnlyVisibleContent() && trackContentImpressionsWithinNode()
+ results = fetchTrackedRequests(token7);
+ equal( (/<span\>([0-9]+)\<\/span\>/.exec(results))[1], "2", "count enabledVisibleContentImpressionsWithinNode requests." );
+
+ requests = results.match(/<span\>(.*?)\<\/span\>/g);
+ requests.shift();
+
+ assertTrackingRequest(requests[0], 'c_n=My%20Ad%205&c_p=http%3A%2F%2Fimg5.example.com%2Fpath%2Fxyz.jpg&c_t=http%3A%2F%2Fapache.piwik%2Fanylink5');
+ assertTrackingRequest(requests[1], 'c_n=http%3A%2F%2Fwww.example.com%2Fpath%2Fxyz.jpg&c_p=http%3A%2F%2Fwww.example.com%2Fpath%2Fxyz.jpg&c_t=http%3A%2F%2Fimg6.example.com');
+
+
+ // trackVisibleContentImpressions()
+ results = fetchTrackedRequests(token8);
+ equal( (/<span\>([0-9]+)\<\/span\>/.exec(results))[1], "3", "count enabledVisibleContentImpressions requests." );
+
+ requests = results.match(/<span\>(.*?)\<\/span\>/g);
+ requests.shift();
+
+ assertTrackingRequest(requests[0], 'c_n=img1-en.jpg&c_p=img1-en.jpg');
+ assertTrackingRequest(requests[1], 'c_n=My%20Ad%205&c_p=http%3A%2F%2Fimg5.example.com%2Fpath%2Fxyz.jpg&c_t=http%3A%2F%2Fapache.piwik%2Fanylink5');
+ assertTrackingRequest(requests[2], 'c_n=http%3A%2F%2Fwww.example.com%2Fpath%2Fxyz.jpg&c_p=http%3A%2F%2Fwww.example.com%2Fpath%2Fxyz.jpg&c_t=http%3A%2F%2Fimg6.example.com');
+
+
+ // enableTrackOnlyVisibleContent(false, 500)
+ results = fetchTrackedRequests(token9);
+ equal( (/<span\>([0-9]+)\<\/span\>/.exec(results))[1], "6", "count automatically tracked requests via time interval. " );
+
+ var requests = results.match(/<span\>(.*?)\<\/span\>/g);
+ requests.shift();
+
+ assertTrackingRequest(requests[0], 'c_n=img1-en.jpg&c_p=img1-en.jpg');
+ assertTrackingRequest(requests[1], 'c_n=My%20Ad%205&c_p=http%3A%2F%2Fimg5.example.com%2Fpath%2Fxyz.jpg&c_t=http%3A%2F%2Fapache.piwik%2Fanylink5');
+ assertTrackingRequest(requests[2], 'c_n=http%3A%2F%2Fwww.example.com%2Fpath%2Fxyz.jpg&c_p=http%3A%2F%2Fwww.example.com%2Fpath%2Fxyz.jpg&c_t=http%3A%2F%2Fimg6.example.com');
+ assertTrackingRequest(requests[3], 'c_n=img.jpg&c_p=img.jpg&c_t=http%3A%2F%2Fimg2.example.com');
+ assertTrackingRequest(requests[4], 'c_n=img3-en.jpg&c_p=img3-en.jpg&c_t=http%3A%2F%2Fimg3.example.com');
+ assertTrackingRequest(requests[5], 'c_n=My%20content%204&c_p=My%20content%204&c_t=http%3A%2F%2Fimg4.example.com');
+
+
+ // enableTrackOnlyVisibleContent(true, 0)
+ results = fetchTrackedRequests(token10);
+ equal( (/<span\>([0-9]+)\<\/span\>/.exec(results))[1], "6", "count automatically tracked requests, via scroll. " );
+
+ start();
+ }, 6000);
+
+ });
+
+ test("trackingContentInteractionInteractive", function() {
+ expect(17);
+
+ function assertTrackingRequest(actual, expectedStartsWith, message)
+ {
+ if (!message) {
+ message = '';
+ } else {
+ message += ', ';
+ }
+
+ expectedStartsWith = '<span>/tests/javascript/piwik.php?' + expectedStartsWith;
+
+ strictEqual(actual.indexOf(expectedStartsWith), 0, message + actual + ' should start with ' + expectedStartsWith);
+ strictEqual(actual.indexOf('&idsite=1&rec=1'), expectedStartsWith.length);
+ }
+
+ function resetTracker(track, token)
+ {
+ tracker.clearTrackedContentImpressions();
+ tracker.clearEnableTrackOnlyVisibleContent();
+ tracker.setCustomData('token', token);
+ scrollToTop();
+ }
+
+ function preventClickDefault(selector)
+ {
+ $(_s(selector)).on('click', function (event) { event.preventDefault(); })
+ }
+
+ var token = getContentToken();
+ var origin = getOrigin();
+ var originEncoded = window.encodeURIComponent(origin);
+ var actual, expected, trackerUrl;
+
+ var tracker = Piwik.getTracker();
+ tracker.setTrackerUrl("piwik.php");
+ tracker.setSiteId(1);
+ resetTracker(tracker, token);
+
+ var visitorIdStart = tracker.getVisitorId();
+ // need to wait at least 1 second so that the cookie would be different, if it wasnt persisted
+ wait(2000);
+
+
+ setupContentTrackingFixture('trackingContent', document.body);
+
+ tracker.trackAllContentImpressions();
+ strictEqual(tracker.getTrackedContentImpressions().length, 7, 'should mark 7 content blocks as tracked');
+
+
+ var token1 = '1' + token;
+ resetTracker(tracker, token1);
+ preventClickDefault('#isWithinOutlink');
+ triggerEvent(_s('#isWithinOutlink'), 'click'); // should only track interaction and no outlink as link tracking not enabled
+
+ tracker.enableLinkTracking();
+
+ var token2 = '2' + token;
+ resetTracker(tracker, token2);
+ preventClickDefault('#isWithinOutlink');
+ triggerEvent(_s('#isWithinOutlink'), 'click'); // click on an element within a link
+
+
+ var token3 = '3' + token;
+ resetTracker(tracker, token3);
+ preventClickDefault('#isOutlink');
+ triggerEvent(_s('#isOutlink'), 'click'); // click on the link element itself
+
+
+ var token4 = '4' + token;
+ resetTracker(tracker, token4);
+ preventClickDefault('#notWithinTarget');
+ triggerEvent(_s('#notWithinTarget'), 'click'); // this element is in a content block, there is a content target, but this element is not child of content target
+
+
+ var token5 = '5' + token;
+ resetTracker(tracker, token5);
+ preventClickDefault('#internalLink');
+ var expectedLink = origin + '/tests/javascript/piwik.php?redirecturl=' + origin + '/anylink5&c_i=click&c_n=My%20Ad%205&c_p=http%3A%2F%2Fimg5.example.com%2Fpath%2Fxyz.jpg&c_t=' + originEncoded +'%2Fanylink5&idsite=1&rec=1';
+ var newHref = _s('#internalLink').href;
+ strictEqual(0, newHref.indexOf(expectedLink), 'replaced href is replaced: ' + newHref); // make sure was already replace by trackContentImpressions()
+ // now we are going to change the link to see whether it will be replaced again
+ tracker.getContent().setHrefAttribute(_s('#internalLink'), '/newlink');
+
+ triggerEvent(_s('#internalLink'), 'click'); // should replace href php
+ newHref = _s('#internalLink').href;
+ expectedLink = origin + '/tests/javascript/piwik.php?redirecturl=' + origin + '/newlink&c_i=click&c_n=My%20Ad%205&c_p=http%3A%2F%2Fimg5.example.com%2Fpath%2Fxyz.jpg&c_t=' + originEncoded +'%2Fnewlink&idsite=1&rec=1';
+ strictEqual(0, newHref.indexOf(expectedLink), 'replaced href2 is replaced: ' + newHref); // make sure was already replace by trackContentImpressions()
+
+ stop();
+ setTimeout(function() {
+ removeContentTrackingFixture();
+
+ var results = fetchTrackedRequests(token1);
+ equal( (/<span\>([0-9]+)\<\/span\>/.exec(results))[1], "1", "count #isWithinOutlink requests as interaction. " );
+
+ var requests = results.match(/<span\>(.*?)\<\/span\>/g);
+ requests.shift();
+ assertTrackingRequest(requests[0], 'c_i=click&c_n=img.jpg&c_p=img.jpg&c_t=http%3A%2F%2Fimg2.example.com');
+
+
+ results = fetchTrackedRequests(token2);
+ equal( (/<span\>([0-9]+)\<\/span\>/.exec(results))[1], "1", "count #isWithinOutlink requests as outlink + interaction. " );
+
+ requests = results.match(/<span\>(.*?)\<\/span\>/g);
+ requests.shift();
+
+ assertTrackingRequest(requests[0], 'link=http%3A%2F%2Fimg2.example.com%2F&c_i=click&c_n=img.jpg&c_p=img.jpg&c_t=http%3A%2F%2Fimg2.example.com');
+
+
+ results = fetchTrackedRequests(token3);
+ equal( (/<span\>([0-9]+)\<\/span\>/.exec(results))[1], "1", "count #isOutlink requests as outlink + interaction. " );
+
+ requests = results.match(/<span\>(.*?)\<\/span\>/g);
+ requests.shift();
+
+ assertTrackingRequest(requests[0], 'link=http%3A%2F%2Fimg2.example.com%2F&c_i=click&c_n=img.jpg&c_p=img.jpg&c_t=http%3A%2F%2Fimg2.example.com');
+
+
+ results = fetchTrackedRequests(token4);
+ equal( (/<span\>([0-9]+)\<\/span\>/.exec(results))[1], "0", "count #notWithinTarget requests." );
+
+
+ results = fetchTrackedRequests(token5);
+ equal( (/<span\>([0-9]+)\<\/span\>/.exec(results))[1], "0", "count #internalLink requests. (would be tracked via redirect which we do not want to perform in test and it is tested above)" );
+
+ start();
+ }, 4000);
+
+ });
+
<?php
}
?>
diff --git a/tests/javascript/piwik.php b/tests/javascript/piwik.php
index 856193e330..c1c0f5ae20 100644
--- a/tests/javascript/piwik.php
+++ b/tests/javascript/piwik.php
@@ -9,6 +9,11 @@ function sendWebBug() {
print(base64_decode($trans_gif_64));
}
+function isPost()
+{
+ return $_SERVER['REQUEST_METHOD'] == 'POST';
+}
+
if (!file_exists("enable_sqlite")) {
sendWebBug();
exit;
@@ -35,6 +40,22 @@ if (filesize(dirname(__FILE__).'/unittest.dbf') == 0)
}
}
+function logRequest($sqlite, $uri, $data) {
+ $ip = $_SERVER['REMOTE_ADDR'];
+ $ts = $_SERVER['REQUEST_TIME'];
+
+// $uri = htmlspecialchars($uri);
+
+ $referrer = isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : '';
+ $ua = $_SERVER['HTTP_USER_AGENT'];
+
+ $token = isset($data['token']) ? $data['token'] : '';
+
+ $query = $sqlite->exec("INSERT INTO requests (token, ip, ts, uri, referer, ua) VALUES (\"$token\", \"$ip\", \"$ts\", \"$uri\", \"$referrer\", \"$ua\")");
+
+ return $query;
+}
+
if (isset($_GET['requests'])) {
$token = get_magic_quotes_gpc() ? stripslashes($_GET['requests']) : $_GET['requests'];
$ua = $_SERVER['HTTP_USER_AGENT'];
@@ -56,25 +77,36 @@ if (isset($_GET['requests'])) {
echo "</body></html>\n";
} else {
+
if (!isset($_REQUEST['data'])) {
- header("HTTP/1.0 400 Bad Request");
+ header("HTTP/1.0 400 Bad Request");
} else {
- $ip = $_SERVER['REMOTE_ADDR'];
- $ts = $_SERVER['REQUEST_TIME'];
- $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '';
- if($_SERVER['REQUEST_METHOD'] == 'POST') {
- $uri .= '?' . file_get_contents('php://input');
- }
-// $uri = htmlspecialchars($uri);
+ $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '';
+
+ $input = file_get_contents("php://input");
+ $requests = @json_decode($input, true);
+ $data = json_decode(get_magic_quotes_gpc() ? stripslashes($_REQUEST['data']) : $_REQUEST['data'], true);
+
+ if (!empty($requests) && isPost()) {
+ $query = true;
+ foreach ($requests['requests'] as $request) {
+ if (empty($data) && preg_match('/data=%7B%22token%22%3A%22([a-z0-9A-Z]*?)%22%7D/', $request, $matches)) {
+ // safari and opera
+ $data = array('token' => $matches[1]);
+ }
+
+ $query = $query && logRequest($sqlite, $uri . $request, $data);
+ }
+ } else {
- $referrer = isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : '';
- $ua = $_SERVER['HTTP_USER_AGENT'];
+ if (isPost()) {
+ $uri .= '?' . file_get_contents('php://input');
+ }
- $data = json_decode(get_magic_quotes_gpc() ? stripslashes($_REQUEST['data']) : $_REQUEST['data'], true);
- $token = isset($data['token']) ? $data['token'] : '';
+ $query = logRequest($sqlite, $uri, $data);
+ }
- $query = $sqlite->exec("INSERT INTO requests (token, ip, ts, uri, referer, ua) VALUES (\"$token\", \"$ip\", \"$ts\", \"$uri\", \"$referrer\", \"$ua\")");
if (!$query) {
header("HTTP/1.0 500 Internal Server Error");
} else {
diff --git a/tests/javascript/testrunner.js b/tests/javascript/testrunner.js
index 61ca183f97..5b333a8a29 100644
--- a/tests/javascript/testrunner.js
+++ b/tests/javascript/testrunner.js
@@ -22,10 +22,10 @@
// IN THE SOFTWARE
var fs = require("fs");
-var url = 'http://localhost/tests/javascript';
+var url = 'http://localhost/tests/javascript/';
function printError(message) {
- fs.write("/dev/stderr", message + "\n", "w");
+ fs.write("/dev/stderr", message + "\n", "w");
}
var page = require("webpage").create();
@@ -38,7 +38,7 @@ page.onResourceReceived = function() {
page.evaluate(function() {
if (!window.QUnit || window.phantomAttached) return;
- QUnit.config.done.push(function(obj) {
+ QUnit.done(function(obj) {
console.log("Tests passed: " + obj.passed);
console.log("Tests failed: " + obj.failed);
console.log("Total tests: " + obj.total);
@@ -47,6 +47,8 @@ page.onResourceReceived = function() {
window.phantomResults = obj;
});
+ window.phantomAttached = true;
+
QUnit.log(function(obj) {
if (!obj.result) {
var errorMessage = "Test failed in module " + obj.module + ": '" + obj.name + "' \nError: " + obj.message;
@@ -64,8 +66,6 @@ page.onResourceReceived = function() {
console.log(errorMessage);
}
});
-
- window.phantomAttached = true;
});
}