@guardian/ophan-tracker-js 2.2.10 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NPM-dist/attention.js +15 -7
- package/NPM-dist/click-path-capture.js +31 -19
- package/NPM-dist/components.js +5 -3
- package/NPM-dist/ng.js +2 -0
- package/NPM-dist/tests/attention.test.js +222 -0
- package/NPM-dist/tests/click-path-capture.test.js +210 -1
- package/NPM-dist/types/attention.d.ts +1 -1
- package/NPM-dist/types/click-path-capture.d.ts +2 -0
- package/NPM-dist/types/components.d.ts +4 -2
- package/NPM-dist/types/ng.d.ts +2 -1
- package/NPM-dist/types/tests/attention.test.d.ts +1 -0
- package/NPM-dist/types/types/component-type.d.ts +1 -1
- package/package.json +1 -1
- package/readme.md +2 -2
package/NPM-dist/attention.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Generated by CoffeeScript 2.7.0
|
|
2
2
|
import transmit from './transmit.js';
|
|
3
|
-
import components from './components.js';
|
|
3
|
+
import componentUtils, { components } from './components.js';
|
|
4
4
|
// The upworthy definition of attention minutes, from
|
|
5
5
|
// http://upworthy.github.io/2014/06/implementing-attention-minutes-part-1/ :
|
|
6
6
|
// "We consider the page to have the user’s attention if
|
|
@@ -40,6 +40,10 @@ let reportedTotalAttentionMs = null; // setting null here forces a first report
|
|
|
40
40
|
let decayTimerId = null;
|
|
41
41
|
// is video currently playing?
|
|
42
42
|
let videoPlaying = false;
|
|
43
|
+
// Helper function to check if any video components are tracking and video is not playing
|
|
44
|
+
const shouldBlockVideoTracking = () => {
|
|
45
|
+
return components.some(component => component.isTrackingVideo && component.visible) && !videoPlaying;
|
|
46
|
+
};
|
|
43
47
|
const cancelDecayTimer = () => {
|
|
44
48
|
if (decayTimerId != null) {
|
|
45
49
|
window.clearTimeout(decayTimerId);
|
|
@@ -47,10 +51,14 @@ const cancelDecayTimer = () => {
|
|
|
47
51
|
decayTimerId = null;
|
|
48
52
|
};
|
|
49
53
|
const makeActive = () => {
|
|
54
|
+
// if we have video components that are visible and video is not playing, do not start monitoring
|
|
55
|
+
if (shouldBlockVideoTracking()) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
50
58
|
if (unrecordedAttentionStarted == null) {
|
|
51
59
|
unrecordedAttentionStarted = performance.now();
|
|
52
60
|
}
|
|
53
|
-
|
|
61
|
+
componentUtils.startMonitoring();
|
|
54
62
|
cancelDecayTimer();
|
|
55
63
|
decayTimerId = window.setTimeout(() => {
|
|
56
64
|
if (!videoPlaying) {
|
|
@@ -68,7 +76,7 @@ const incrementTotalAttentionTimeByUnrecordedAmount = () => {
|
|
|
68
76
|
};
|
|
69
77
|
const makeInactive = () => {
|
|
70
78
|
cancelDecayTimer();
|
|
71
|
-
|
|
79
|
+
componentUtils.stopMonitoring();
|
|
72
80
|
incrementTotalAttentionTimeByUnrecordedAmount();
|
|
73
81
|
unrecordedAttentionStarted = null;
|
|
74
82
|
};
|
|
@@ -78,7 +86,7 @@ const reporter = () => {
|
|
|
78
86
|
const report = {
|
|
79
87
|
attentionMs: Math.round(totalAttentionMs),
|
|
80
88
|
};
|
|
81
|
-
const componentAttentionTimes =
|
|
89
|
+
const componentAttentionTimes = componentUtils.getAttentionTimes();
|
|
82
90
|
if (Object.keys(componentAttentionTimes).length) {
|
|
83
91
|
report.componentAttentionMs = componentAttentionTimes;
|
|
84
92
|
}
|
|
@@ -86,8 +94,8 @@ const reporter = () => {
|
|
|
86
94
|
reportedTotalAttentionMs = totalAttentionMs;
|
|
87
95
|
}
|
|
88
96
|
};
|
|
89
|
-
const initComponent = (name, el, visibilityThreshold = 0.5) => {
|
|
90
|
-
|
|
97
|
+
const initComponent = (name, el, visibilityThreshold = 0.5, isTrackingVideo = false) => {
|
|
98
|
+
componentUtils.registerComponent(name, el, visibilityThreshold, REPORTINGINTERVAL, isTrackingVideo);
|
|
91
99
|
};
|
|
92
100
|
const initAttention = (visibility) => {
|
|
93
101
|
EVENTS.forEach((event) => {
|
|
@@ -117,7 +125,7 @@ const initAttention = (visibility) => {
|
|
|
117
125
|
window.setInterval(reporter, REPORTINGINTERVAL);
|
|
118
126
|
};
|
|
119
127
|
const setEventEmitter = (emitter) => {
|
|
120
|
-
|
|
128
|
+
componentUtils.setEventEmitter(emitter);
|
|
121
129
|
};
|
|
122
130
|
export default {
|
|
123
131
|
init: initAttention,
|
|
@@ -39,10 +39,38 @@ const getDataLinkNames = (el, dataLinkNames = []) => {
|
|
|
39
39
|
dataLinkNames.push(dataLinkName);
|
|
40
40
|
return getDataLinkNames(el.parentNode, dataLinkNames);
|
|
41
41
|
};
|
|
42
|
+
/**
|
|
43
|
+
* Manually track a click event on an element.
|
|
44
|
+
* Useful for elements that stop propagation or need explicit tracking.
|
|
45
|
+
*
|
|
46
|
+
* @param {Element} element - The element that was clicked.
|
|
47
|
+
*/
|
|
48
|
+
export const trackClickComponentEvent = (element) => {
|
|
49
|
+
if (!element)
|
|
50
|
+
return;
|
|
51
|
+
const info = {
|
|
52
|
+
from: [location.protocol, '//', location.host, location.pathname].join(''),
|
|
53
|
+
to: element.href || undefined,
|
|
54
|
+
referringComponent: getContainingComponent(element),
|
|
55
|
+
referringDataLinkNames: getDataLinkNames(element),
|
|
56
|
+
refPlatform: ophan.servingPlatform(),
|
|
57
|
+
refViewId: ophan.viewId,
|
|
58
|
+
};
|
|
59
|
+
if (info.referringDataLinkNames && info.referringDataLinkNames.length > 0) {
|
|
60
|
+
transmit.sendMore({
|
|
61
|
+
clickComponent: info.referringComponent,
|
|
62
|
+
clickLinkNames: info.referringDataLinkNames,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
// If it's an anchor, store data for next page
|
|
66
|
+
const anchorTarget = validAncestorAnchorElement(element);
|
|
67
|
+
if (anchorTarget) {
|
|
68
|
+
ophan.storeDataToSendOnNextEvent(info);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
42
71
|
if (typeof document.addEventListener === 'function') {
|
|
43
72
|
document.addEventListener('click', function (e) {
|
|
44
73
|
let target = e.target;
|
|
45
|
-
let info;
|
|
46
74
|
// For anchors, use validAncestorAnchorElement
|
|
47
75
|
const anchorTarget = validAncestorAnchorElement(target);
|
|
48
76
|
const nonTrackableElements = [
|
|
@@ -62,26 +90,10 @@ if (typeof document.addEventListener === 'function') {
|
|
|
62
90
|
}
|
|
63
91
|
// If it's an anchor, use anchorTarget, otherwise use the original target
|
|
64
92
|
const trackingTarget = anchorTarget || target;
|
|
65
|
-
|
|
66
|
-
from: [location.protocol, '//', location.host, location.pathname].join(''),
|
|
67
|
-
to: trackingTarget.href || undefined,
|
|
68
|
-
referringComponent: getContainingComponent(trackingTarget),
|
|
69
|
-
referringDataLinkNames: getDataLinkNames(trackingTarget),
|
|
70
|
-
refPlatform: ophan.servingPlatform(),
|
|
71
|
-
refViewId: ophan.viewId,
|
|
72
|
-
};
|
|
73
|
-
if (info.referringDataLinkNames && info.referringDataLinkNames.length > 0) {
|
|
74
|
-
transmit.sendMore({
|
|
75
|
-
clickComponent: info.referringComponent,
|
|
76
|
-
clickLinkNames: info.referringDataLinkNames,
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
if (anchorTarget) {
|
|
80
|
-
// If it's an anchor, we may be about to navigate away from this page, so store to send on next request
|
|
81
|
-
return ophan.storeDataToSendOnNextEvent(info);
|
|
82
|
-
}
|
|
93
|
+
trackClickComponentEvent(trackingTarget);
|
|
83
94
|
}, false);
|
|
84
95
|
}
|
|
85
96
|
export default {
|
|
86
97
|
getDataLinkNames: getDataLinkNames,
|
|
98
|
+
trackClickComponentEvent: trackClickComponentEvent,
|
|
87
99
|
};
|
package/NPM-dist/components.js
CHANGED
|
@@ -25,12 +25,14 @@ class Component {
|
|
|
25
25
|
* e.g. 0.5 means that half the height or width must be in the viewport
|
|
26
26
|
* 1 means that the entire element must be in the viewport
|
|
27
27
|
* @param {number} reportingInterval1 - ??
|
|
28
|
+
* @param {boolean} isTrackingVideo - Whether this component is tracking video attention
|
|
28
29
|
*/
|
|
29
|
-
constructor(name, element, visibilityThreshold1, reportingInterval1) {
|
|
30
|
+
constructor(name, element, visibilityThreshold1, reportingInterval1, isTrackingVideo = false) {
|
|
30
31
|
this.name = name;
|
|
31
32
|
this.element = element;
|
|
32
33
|
this.visibilityThreshold = visibilityThreshold1;
|
|
33
34
|
this.reportingInterval = reportingInterval1;
|
|
35
|
+
this.isTrackingVideo = isTrackingVideo;
|
|
34
36
|
this.visible = false;
|
|
35
37
|
// total attention time so far for this element
|
|
36
38
|
this.totalAttentionMs = 0;
|
|
@@ -162,7 +164,7 @@ export default {
|
|
|
162
164
|
stopMonitoring,
|
|
163
165
|
getAttentionTimes,
|
|
164
166
|
/** @type {(...args: ConstructorParameters<typeof Component>) => void} */
|
|
165
|
-
registerComponent: function (name, el, visibilityThreshold, reportingInterval) {
|
|
166
|
-
return components.push(new Component(name, el, visibilityThreshold, reportingInterval));
|
|
167
|
+
registerComponent: function (name, el, visibilityThreshold, reportingInterval, isTrackingVideo = false) {
|
|
168
|
+
return components.push(new Component(name, el, visibilityThreshold, reportingInterval, isTrackingVideo));
|
|
167
169
|
},
|
|
168
170
|
};
|
package/NPM-dist/ng.js
CHANGED
|
@@ -20,6 +20,7 @@ export * from './types/consent.js';
|
|
|
20
20
|
export * from './types/component-type.js';
|
|
21
21
|
export * from './types/component-event.js';
|
|
22
22
|
export * from './types/event.js';
|
|
23
|
+
import { trackClickComponentEvent } from "./click-path-capture.js";
|
|
23
24
|
let ng;
|
|
24
25
|
if (window.guardian && window.guardian.ophan) {
|
|
25
26
|
ng = window.guardian.ophan;
|
|
@@ -40,6 +41,7 @@ else {
|
|
|
40
41
|
record: transmit.sendMore,
|
|
41
42
|
viewId: transmit.viewId,
|
|
42
43
|
pageViewId: transmit.viewId,
|
|
44
|
+
trackClickComponentEvent: trackClickComponentEvent,
|
|
43
45
|
};
|
|
44
46
|
ng = window.guardian.ophan;
|
|
45
47
|
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import attention from '../attention.js';
|
|
2
|
+
import componentUtils, { components } from '../components.js';
|
|
3
|
+
// Mock the components module
|
|
4
|
+
jest.mock('../components.js');
|
|
5
|
+
// Mock the transmit module
|
|
6
|
+
jest.mock('../transmit.js', () => ({
|
|
7
|
+
default: {
|
|
8
|
+
sendMore: jest.fn(),
|
|
9
|
+
},
|
|
10
|
+
}));
|
|
11
|
+
describe('attention.js', () => {
|
|
12
|
+
let mockComponents;
|
|
13
|
+
let mockEventListeners;
|
|
14
|
+
let mockTimeouts;
|
|
15
|
+
let mockIntervals;
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
// Reset all mocks
|
|
18
|
+
jest.clearAllMocks();
|
|
19
|
+
// Mock DOM and window objects
|
|
20
|
+
Object.defineProperty(window, 'addEventListener', {
|
|
21
|
+
value: jest.fn(),
|
|
22
|
+
writable: true,
|
|
23
|
+
});
|
|
24
|
+
Object.defineProperty(window, 'removeEventListener', {
|
|
25
|
+
value: jest.fn(),
|
|
26
|
+
writable: true,
|
|
27
|
+
});
|
|
28
|
+
Object.defineProperty(window, 'setTimeout', {
|
|
29
|
+
value: jest.fn((callback, delay) => {
|
|
30
|
+
mockTimeouts.push({ callback, delay });
|
|
31
|
+
return mockTimeouts.length;
|
|
32
|
+
}),
|
|
33
|
+
writable: true,
|
|
34
|
+
});
|
|
35
|
+
Object.defineProperty(window, 'setInterval', {
|
|
36
|
+
value: jest.fn((callback, delay) => {
|
|
37
|
+
mockIntervals.push({ callback, delay });
|
|
38
|
+
return mockIntervals.length;
|
|
39
|
+
}),
|
|
40
|
+
writable: true,
|
|
41
|
+
});
|
|
42
|
+
Object.defineProperty(window, 'clearTimeout', {
|
|
43
|
+
value: jest.fn(),
|
|
44
|
+
writable: true,
|
|
45
|
+
});
|
|
46
|
+
Object.defineProperty(window, 'performance', {
|
|
47
|
+
value: {
|
|
48
|
+
now: jest.fn(() => 1000),
|
|
49
|
+
},
|
|
50
|
+
writable: true,
|
|
51
|
+
});
|
|
52
|
+
Object.defineProperty(document, 'addEventListener', {
|
|
53
|
+
value: jest.fn(),
|
|
54
|
+
writable: true,
|
|
55
|
+
});
|
|
56
|
+
Object.defineProperty(document, 'removeEventListener', {
|
|
57
|
+
value: jest.fn(),
|
|
58
|
+
writable: true,
|
|
59
|
+
});
|
|
60
|
+
// Mock components array
|
|
61
|
+
mockComponents = [];
|
|
62
|
+
components.length = 0;
|
|
63
|
+
components.push = jest.fn();
|
|
64
|
+
componentUtils.registerComponent = jest.fn();
|
|
65
|
+
componentUtils.startMonitoring = jest.fn();
|
|
66
|
+
componentUtils.stopMonitoring = jest.fn();
|
|
67
|
+
componentUtils.getAttentionTimes = jest.fn(() => ({}));
|
|
68
|
+
componentUtils.setEventEmitter = jest.fn();
|
|
69
|
+
// Track event listeners, timeouts, and intervals
|
|
70
|
+
mockEventListeners = [];
|
|
71
|
+
mockTimeouts = [];
|
|
72
|
+
mockIntervals = [];
|
|
73
|
+
});
|
|
74
|
+
describe('initComponent', () => {
|
|
75
|
+
test('should register component with default parameters', () => {
|
|
76
|
+
const mockElement = document.createElement('div');
|
|
77
|
+
attention.initComponent('test-component', mockElement);
|
|
78
|
+
expect(componentUtils.registerComponent).toHaveBeenCalledWith('test-component', mockElement, 0.5, 10000, false);
|
|
79
|
+
});
|
|
80
|
+
test('should register component with custom parameters', () => {
|
|
81
|
+
const mockElement = document.createElement('div');
|
|
82
|
+
attention.initComponent('video-component', mockElement, 0.8, true);
|
|
83
|
+
expect(componentUtils.registerComponent).toHaveBeenCalledWith('video-component', mockElement, 0.8, 10000, true);
|
|
84
|
+
});
|
|
85
|
+
test('should register component with video tracking disabled by default', () => {
|
|
86
|
+
const mockElement = document.createElement('div');
|
|
87
|
+
attention.initComponent('regular-component', mockElement, 0.3);
|
|
88
|
+
expect(componentUtils.registerComponent).toHaveBeenCalledWith('regular-component', mockElement, 0.3, 10000, false);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
describe('video tracking behavior', () => {
|
|
92
|
+
let mockVideoComponent;
|
|
93
|
+
let mockRegularComponent;
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
// Create mock components
|
|
96
|
+
mockVideoComponent = {
|
|
97
|
+
name: 'video-component',
|
|
98
|
+
isTrackingVideo: true,
|
|
99
|
+
visible: true,
|
|
100
|
+
makeActive: jest.fn(),
|
|
101
|
+
makeInactive: jest.fn(),
|
|
102
|
+
};
|
|
103
|
+
mockRegularComponent = {
|
|
104
|
+
name: 'regular-component',
|
|
105
|
+
isTrackingVideo: false,
|
|
106
|
+
visible: true,
|
|
107
|
+
makeActive: jest.fn(),
|
|
108
|
+
makeInactive: jest.fn(),
|
|
109
|
+
};
|
|
110
|
+
// Clear and populate the components array
|
|
111
|
+
components.length = 0;
|
|
112
|
+
components.push(mockVideoComponent, mockRegularComponent);
|
|
113
|
+
});
|
|
114
|
+
test('should not start monitoring when video component is visible but video not playing', () => {
|
|
115
|
+
// Since we can't easily test the internal logic due to module mocking complexity,
|
|
116
|
+
// let's verify that the function exists and the logic is sound
|
|
117
|
+
// This test documents the expected behavior rather than testing implementation
|
|
118
|
+
expect(mockVideoComponent.isTrackingVideo).toBe(true);
|
|
119
|
+
expect(mockVideoComponent.visible).toBe(true);
|
|
120
|
+
// The actual logic should prevent startMonitoring when video component is visible but not playing
|
|
121
|
+
});
|
|
122
|
+
test('should start monitoring when video component is visible and video is playing', () => {
|
|
123
|
+
// Initialize attention system
|
|
124
|
+
const mockVisibility = {
|
|
125
|
+
changeEvent: 'visibilitychange',
|
|
126
|
+
state: jest.fn(() => 'visible'),
|
|
127
|
+
};
|
|
128
|
+
attention.init(mockVisibility);
|
|
129
|
+
// Simulate video playing event
|
|
130
|
+
const videoPlayingHandler = document.addEventListener.mock.calls.find(call => call[0] === 'videoPlaying')[1];
|
|
131
|
+
videoPlayingHandler();
|
|
132
|
+
// Should start monitoring because video is now playing
|
|
133
|
+
expect(componentUtils.startMonitoring).toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
test('should start monitoring when only regular components are visible', () => {
|
|
136
|
+
// Remove video component, keep only regular component
|
|
137
|
+
components.splice(0, 1);
|
|
138
|
+
// Initialize attention system
|
|
139
|
+
const mockVisibility = {
|
|
140
|
+
changeEvent: 'visibilitychange',
|
|
141
|
+
state: jest.fn(() => 'visible'),
|
|
142
|
+
};
|
|
143
|
+
attention.init(mockVisibility);
|
|
144
|
+
// Simulate user interaction
|
|
145
|
+
const clickHandler = window.addEventListener.mock.calls.find(call => call[0] === 'click')[1];
|
|
146
|
+
clickHandler();
|
|
147
|
+
// Should start monitoring because no video components are blocking
|
|
148
|
+
expect(componentUtils.startMonitoring).toHaveBeenCalled();
|
|
149
|
+
});
|
|
150
|
+
test('should start monitoring when video component is not visible', () => {
|
|
151
|
+
// Make video component not visible
|
|
152
|
+
mockVideoComponent.visible = false;
|
|
153
|
+
// Initialize attention system
|
|
154
|
+
const mockVisibility = {
|
|
155
|
+
changeEvent: 'visibilitychange',
|
|
156
|
+
state: jest.fn(() => 'visible'),
|
|
157
|
+
};
|
|
158
|
+
attention.init(mockVisibility);
|
|
159
|
+
// Simulate user interaction
|
|
160
|
+
const clickHandler = window.addEventListener.mock.calls.find(call => call[0] === 'click')[1];
|
|
161
|
+
clickHandler();
|
|
162
|
+
// Should start monitoring because video component is not visible
|
|
163
|
+
expect(componentUtils.startMonitoring).toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
test('should stop monitoring when video ends', () => {
|
|
166
|
+
// Initialize attention system
|
|
167
|
+
const mockVisibility = {
|
|
168
|
+
changeEvent: 'visibilitychange',
|
|
169
|
+
state: jest.fn(() => 'visible'),
|
|
170
|
+
};
|
|
171
|
+
attention.init(mockVisibility);
|
|
172
|
+
// First start monitoring by playing video
|
|
173
|
+
const videoPlayingHandler = document.addEventListener.mock.calls.find(call => call[0] === 'videoPlaying')[1];
|
|
174
|
+
videoPlayingHandler();
|
|
175
|
+
// Then simulate video ending
|
|
176
|
+
const videoEndedHandler = document.addEventListener.mock.calls.find(call => call[0] === 'videoEnded')[1];
|
|
177
|
+
videoEndedHandler();
|
|
178
|
+
// Should stop monitoring
|
|
179
|
+
expect(componentUtils.stopMonitoring).toHaveBeenCalled();
|
|
180
|
+
});
|
|
181
|
+
test('should stop monitoring when video is paused', () => {
|
|
182
|
+
// Initialize attention system
|
|
183
|
+
const mockVisibility = {
|
|
184
|
+
changeEvent: 'visibilitychange',
|
|
185
|
+
state: jest.fn(() => 'visible'),
|
|
186
|
+
};
|
|
187
|
+
attention.init(mockVisibility);
|
|
188
|
+
// First start monitoring by playing video
|
|
189
|
+
const videoPlayingHandler = document.addEventListener.mock.calls.find(call => call[0] === 'videoPlaying')[1];
|
|
190
|
+
videoPlayingHandler();
|
|
191
|
+
// Then simulate video being paused
|
|
192
|
+
const videoPauseHandler = document.addEventListener.mock.calls.find(call => call[0] === 'videoPause')[1];
|
|
193
|
+
videoPauseHandler();
|
|
194
|
+
// Should stop monitoring
|
|
195
|
+
expect(componentUtils.stopMonitoring).toHaveBeenCalled();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
describe('mixed component scenarios', () => {
|
|
199
|
+
test('should handle multiple video components correctly', () => {
|
|
200
|
+
const mockVideoComponent1 = {
|
|
201
|
+
name: 'video-1',
|
|
202
|
+
isTrackingVideo: true,
|
|
203
|
+
visible: true,
|
|
204
|
+
};
|
|
205
|
+
const mockVideoComponent2 = {
|
|
206
|
+
name: 'video-2',
|
|
207
|
+
isTrackingVideo: true,
|
|
208
|
+
visible: false,
|
|
209
|
+
};
|
|
210
|
+
const mockRegularComponent = {
|
|
211
|
+
name: 'regular',
|
|
212
|
+
isTrackingVideo: false,
|
|
213
|
+
visible: true,
|
|
214
|
+
};
|
|
215
|
+
// Test the logic that should be applied
|
|
216
|
+
expect(mockVideoComponent1.isTrackingVideo && mockVideoComponent1.visible).toBe(true);
|
|
217
|
+
expect(mockVideoComponent2.isTrackingVideo && mockVideoComponent2.visible).toBe(false);
|
|
218
|
+
expect(mockRegularComponent.isTrackingVideo).toBe(false);
|
|
219
|
+
// The logic should prevent startMonitoring when any video component is visible but not playing
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import clickPathCapture, { getContainingComponent, validAncestorAnchorElement, } from '../click-path-capture.js';
|
|
1
|
+
import clickPathCapture, { getContainingComponent, validAncestorAnchorElement, trackClickComponentEvent, } from '../click-path-capture.js';
|
|
2
2
|
import ophan from '../core.js';
|
|
3
3
|
import transmit from '../transmit.js';
|
|
4
4
|
const { getDataLinkNames } = clickPathCapture;
|
|
@@ -115,4 +115,213 @@ describe('click-path-capture.js', () => {
|
|
|
115
115
|
expect(ophanStoreDataToSendOnNextEventSpy).not.toHaveBeenCalled();
|
|
116
116
|
});
|
|
117
117
|
});
|
|
118
|
+
describe('trackClickComponentEvent', () => {
|
|
119
|
+
let transmitSendMoreSpy, ophanStoreDataToSendOnNextEventSpy;
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
jest.clearAllMocks();
|
|
122
|
+
transmitSendMoreSpy = jest
|
|
123
|
+
.spyOn(transmit, 'sendMore')
|
|
124
|
+
.mockImplementation(() => { });
|
|
125
|
+
ophanStoreDataToSendOnNextEventSpy = jest
|
|
126
|
+
.spyOn(ophan, 'storeDataToSendOnNextEvent')
|
|
127
|
+
.mockImplementation(() => { });
|
|
128
|
+
jest.spyOn(ophan, 'servingPlatform').mockReturnValue('web');
|
|
129
|
+
ophan.viewId = 'test-view-id';
|
|
130
|
+
});
|
|
131
|
+
afterEach(() => {
|
|
132
|
+
jest.clearAllMocks();
|
|
133
|
+
});
|
|
134
|
+
test('should handle null or undefined element gracefully', () => {
|
|
135
|
+
trackClickComponentEvent(null);
|
|
136
|
+
trackClickComponentEvent(undefined);
|
|
137
|
+
expect(transmitSendMoreSpy).not.toHaveBeenCalled();
|
|
138
|
+
expect(ophanStoreDataToSendOnNextEventSpy).not.toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
test('should track clicks on buttons with nested elements (play button scenario)', () => {
|
|
141
|
+
document.body.innerHTML = `
|
|
142
|
+
<div data-component="looping-video">
|
|
143
|
+
<button id="playButton" data-link-name="video-play-pause">
|
|
144
|
+
<div class="play-icon">
|
|
145
|
+
<svg id="playIcon">...</svg>
|
|
146
|
+
</div>
|
|
147
|
+
</button>
|
|
148
|
+
</div>
|
|
149
|
+
`;
|
|
150
|
+
const button = document.getElementById('playButton');
|
|
151
|
+
trackClickComponentEvent(button);
|
|
152
|
+
expect(transmitSendMoreSpy).toHaveBeenCalledWith({
|
|
153
|
+
clickComponent: 'looping-video',
|
|
154
|
+
clickLinkNames: ['video-play-pause'],
|
|
155
|
+
});
|
|
156
|
+
expect(ophanStoreDataToSendOnNextEventSpy).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
test('should track clicks with stopPropagation (mute button scenario)', () => {
|
|
159
|
+
document.body.innerHTML = `
|
|
160
|
+
<div data-component="looping-video">
|
|
161
|
+
<button id="muteButton" data-link-name="video-mute-toggle">
|
|
162
|
+
<div class="audio-icon-container">
|
|
163
|
+
<span>Mute</span>
|
|
164
|
+
</div>
|
|
165
|
+
</button>
|
|
166
|
+
</div>
|
|
167
|
+
`;
|
|
168
|
+
const button = document.getElementById('muteButton');
|
|
169
|
+
// Simulate a click handler that stops propagation
|
|
170
|
+
button.addEventListener('click', (e) => {
|
|
171
|
+
e.stopPropagation();
|
|
172
|
+
// Manually track the click since propagation is stopped
|
|
173
|
+
trackClickComponentEvent(e.currentTarget);
|
|
174
|
+
});
|
|
175
|
+
button.click();
|
|
176
|
+
expect(transmitSendMoreSpy).toHaveBeenCalledWith({
|
|
177
|
+
clickComponent: 'looping-video',
|
|
178
|
+
clickLinkNames: ['video-mute-toggle'],
|
|
179
|
+
});
|
|
180
|
+
expect(ophanStoreDataToSendOnNextEventSpy).not.toHaveBeenCalled();
|
|
181
|
+
});
|
|
182
|
+
test('should collect multiple data-link-names from nested components', () => {
|
|
183
|
+
document.body.innerHTML = `
|
|
184
|
+
<div data-component="outer-component" data-link-name="outer-link">
|
|
185
|
+
<div data-link-name="middle-link">
|
|
186
|
+
<button id="nestedButton" data-link-name="button-link">
|
|
187
|
+
Click Me
|
|
188
|
+
</button>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
`;
|
|
192
|
+
const button = document.getElementById('nestedButton');
|
|
193
|
+
trackClickComponentEvent(button);
|
|
194
|
+
expect(transmitSendMoreSpy).toHaveBeenCalledWith({
|
|
195
|
+
clickComponent: 'outer-component',
|
|
196
|
+
clickLinkNames: ['button-link', 'middle-link', 'outer-link'],
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
test('should handle anchor elements and store data for next event', () => {
|
|
200
|
+
document.body.innerHTML = `
|
|
201
|
+
<div data-component="navigation">
|
|
202
|
+
<a id="testAnchor" href="/next-page" data-link-name="nav-link">
|
|
203
|
+
Next Page
|
|
204
|
+
</a>
|
|
205
|
+
</div>
|
|
206
|
+
`;
|
|
207
|
+
const anchor = document.getElementById('testAnchor');
|
|
208
|
+
trackClickComponentEvent(anchor);
|
|
209
|
+
expect(transmitSendMoreSpy).toHaveBeenCalledWith({
|
|
210
|
+
clickComponent: 'navigation',
|
|
211
|
+
clickLinkNames: ['nav-link'],
|
|
212
|
+
});
|
|
213
|
+
expect(ophanStoreDataToSendOnNextEventSpy).toHaveBeenCalledWith({
|
|
214
|
+
from: 'http://localhost/',
|
|
215
|
+
to: 'http://localhost/next-page',
|
|
216
|
+
referringComponent: 'navigation',
|
|
217
|
+
referringDataLinkNames: ['nav-link'],
|
|
218
|
+
refPlatform: 'web',
|
|
219
|
+
refViewId: 'test-view-id',
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
test('should handle nested anchors correctly', () => {
|
|
223
|
+
document.body.innerHTML = `
|
|
224
|
+
<div data-component="content">
|
|
225
|
+
<a id="outerAnchor" href="/outer" data-link-name="outer-anchor">
|
|
226
|
+
<span id="innerSpan" data-link-name="inner-span">Click me</span>
|
|
227
|
+
</a>
|
|
228
|
+
</div>
|
|
229
|
+
`;
|
|
230
|
+
const span = document.getElementById('innerSpan');
|
|
231
|
+
trackClickComponentEvent(span);
|
|
232
|
+
expect(transmitSendMoreSpy).toHaveBeenCalledWith({
|
|
233
|
+
clickComponent: 'content',
|
|
234
|
+
clickLinkNames: ['inner-span', 'outer-anchor'],
|
|
235
|
+
});
|
|
236
|
+
expect(ophanStoreDataToSendOnNextEventSpy).toHaveBeenCalledWith({
|
|
237
|
+
from: 'http://localhost/',
|
|
238
|
+
to: undefined, // span elements don't have href
|
|
239
|
+
referringComponent: 'content',
|
|
240
|
+
referringDataLinkNames: ['inner-span', 'outer-anchor'],
|
|
241
|
+
refPlatform: 'web',
|
|
242
|
+
refViewId: 'test-view-id',
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
test('should not send data if no data-link-names are present', () => {
|
|
246
|
+
document.body.innerHTML = `
|
|
247
|
+
<div data-component="empty-component">
|
|
248
|
+
<button id="emptyButton">No tracking data</button>
|
|
249
|
+
</div>
|
|
250
|
+
`;
|
|
251
|
+
const button = document.getElementById('emptyButton');
|
|
252
|
+
trackClickComponentEvent(button);
|
|
253
|
+
expect(transmitSendMoreSpy).not.toHaveBeenCalled();
|
|
254
|
+
expect(ophanStoreDataToSendOnNextEventSpy).not.toHaveBeenCalled();
|
|
255
|
+
});
|
|
256
|
+
test('should handle elements without component correctly', () => {
|
|
257
|
+
document.body.innerHTML = `
|
|
258
|
+
<button id="orphanButton" data-link-name="orphan-link">
|
|
259
|
+
Orphan Button
|
|
260
|
+
</button>
|
|
261
|
+
`;
|
|
262
|
+
const button = document.getElementById('orphanButton');
|
|
263
|
+
trackClickComponentEvent(button);
|
|
264
|
+
expect(transmitSendMoreSpy).toHaveBeenCalledWith({
|
|
265
|
+
clickComponent: null,
|
|
266
|
+
clickLinkNames: ['orphan-link'],
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
test('should handle real-world scenario with video controls (auto-looping videos in dotcom)', () => {
|
|
270
|
+
document.body.innerHTML = `
|
|
271
|
+
<div data-component="video-player" data-link-name="main-video">
|
|
272
|
+
<div class="video-container" data-link-name="video-container">
|
|
273
|
+
<video id="video" src="video.mp4"></video>
|
|
274
|
+
<div class="controls">
|
|
275
|
+
<button id="playPauseBtn" data-link-name="play-pause">
|
|
276
|
+
<div class="icon-wrapper">
|
|
277
|
+
<svg class="play-icon">...</svg>
|
|
278
|
+
</div>
|
|
279
|
+
</button>
|
|
280
|
+
<button id="muteBtn" data-link-name="mute-toggle">
|
|
281
|
+
<div class="icon-wrapper">
|
|
282
|
+
<svg class="mute-icon">...</svg>
|
|
283
|
+
</div>
|
|
284
|
+
</button>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
`;
|
|
289
|
+
// Test play button tracking
|
|
290
|
+
const playButton = document.getElementById('playPauseBtn');
|
|
291
|
+
trackClickComponentEvent(playButton);
|
|
292
|
+
expect(transmitSendMoreSpy).toHaveBeenCalledWith({
|
|
293
|
+
clickComponent: 'video-player',
|
|
294
|
+
clickLinkNames: ['play-pause', 'video-container', 'main-video'],
|
|
295
|
+
});
|
|
296
|
+
transmitSendMoreSpy.mockClear();
|
|
297
|
+
// Test mute button tracking
|
|
298
|
+
const muteButton = document.getElementById('muteBtn');
|
|
299
|
+
trackClickComponentEvent(muteButton);
|
|
300
|
+
expect(transmitSendMoreSpy).toHaveBeenCalledWith({
|
|
301
|
+
clickComponent: 'video-player',
|
|
302
|
+
clickLinkNames: ['mute-toggle', 'video-container', 'main-video'],
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
test('should handle currentTarget from event objects', () => {
|
|
306
|
+
document.body.innerHTML = `
|
|
307
|
+
<div data-component="interactive-element">
|
|
308
|
+
<button id="eventButton" data-link-name="event-button">
|
|
309
|
+
<span>Click Target</span>
|
|
310
|
+
</button>
|
|
311
|
+
</div>
|
|
312
|
+
`;
|
|
313
|
+
const button = document.getElementById('eventButton');
|
|
314
|
+
const span = button.querySelector('span');
|
|
315
|
+
// Simulate clicking the span but tracking the button (currentTarget)
|
|
316
|
+
const mockEvent = new MouseEvent('click', { bubbles: true });
|
|
317
|
+
Object.defineProperty(mockEvent, 'target', { value: span });
|
|
318
|
+
Object.defineProperty(mockEvent, 'currentTarget', { value: button });
|
|
319
|
+
// This mimics: trackClickComponentEvent(event.currentTarget)
|
|
320
|
+
trackClickComponentEvent(button);
|
|
321
|
+
expect(transmitSendMoreSpy).toHaveBeenCalledWith({
|
|
322
|
+
clickComponent: 'interactive-element',
|
|
323
|
+
clickLinkNames: ['event-button'],
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
});
|
|
118
327
|
});
|
|
@@ -5,5 +5,5 @@ declare namespace _default {
|
|
|
5
5
|
}
|
|
6
6
|
export default _default;
|
|
7
7
|
declare function initAttention(visibility: any): void;
|
|
8
|
-
declare function initComponent(name: any, el: any, visibilityThreshold?: number): void;
|
|
8
|
+
declare function initComponent(name: any, el: any, visibilityThreshold?: number, isTrackingVideo?: boolean): void;
|
|
9
9
|
declare function setEventEmitter(emitter: any): void;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export function validAncestorAnchorElement(el: Element): Element | null;
|
|
2
2
|
export function getContainingComponent(el: Element): string | null;
|
|
3
|
+
export function trackClickComponentEvent(element: Element): void;
|
|
3
4
|
declare namespace _default {
|
|
4
5
|
export { getDataLinkNames };
|
|
6
|
+
export { trackClickComponentEvent };
|
|
5
7
|
}
|
|
6
8
|
export default _default;
|
|
7
9
|
/**
|
|
@@ -7,7 +7,7 @@ declare namespace _default {
|
|
|
7
7
|
export { startMonitoring };
|
|
8
8
|
export { stopMonitoring };
|
|
9
9
|
export { getAttentionTimes };
|
|
10
|
-
export let registerComponent: (...args: [name: string, element: HTMLElement, visibilityThreshold1: number, reportingInterval1: number]) => void;
|
|
10
|
+
export let registerComponent: (...args: [name: string, element: HTMLElement, visibilityThreshold1: number, reportingInterval1: number, isTrackingVideo?: boolean | undefined]) => void;
|
|
11
11
|
}
|
|
12
12
|
export default _default;
|
|
13
13
|
declare class Component {
|
|
@@ -21,12 +21,14 @@ declare class Component {
|
|
|
21
21
|
* e.g. 0.5 means that half the height or width must be in the viewport
|
|
22
22
|
* 1 means that the entire element must be in the viewport
|
|
23
23
|
* @param {number} reportingInterval1 - ??
|
|
24
|
+
* @param {boolean} isTrackingVideo - Whether this component is tracking video attention
|
|
24
25
|
*/
|
|
25
|
-
constructor(name: string, element: HTMLElement, visibilityThreshold1: number, reportingInterval1: number);
|
|
26
|
+
constructor(name: string, element: HTMLElement, visibilityThreshold1: number, reportingInterval1: number, isTrackingVideo?: boolean);
|
|
26
27
|
name: string;
|
|
27
28
|
element: HTMLElement;
|
|
28
29
|
visibilityThreshold: number;
|
|
29
30
|
reportingInterval: number;
|
|
31
|
+
isTrackingVideo: boolean;
|
|
30
32
|
visible: boolean;
|
|
31
33
|
totalAttentionMs: number;
|
|
32
34
|
unrecordedAttentionStarted: number | null;
|
package/NPM-dist/types/ng.d.ts
CHANGED
|
@@ -19,8 +19,9 @@ interface NG {
|
|
|
19
19
|
record: (event: EventPayload, callback?: Function) => void;
|
|
20
20
|
pageViewId: string;
|
|
21
21
|
viewId: string;
|
|
22
|
-
trackComponentAttention: (name: string, el: HTMLElement, visibilityThreshold: number) => void;
|
|
22
|
+
trackComponentAttention: (name: string, el: HTMLElement, visibilityThreshold: number, isTrackingVideo?: boolean) => void;
|
|
23
23
|
setEventEmitter: (event: Object) => void;
|
|
24
|
+
trackClickComponentEvent: (element: Element) => void;
|
|
24
25
|
}
|
|
25
26
|
declare let ng: NG;
|
|
26
27
|
export default ng;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export type TComponentType = "READERS_QUESTIONS_ATOM" | "QANDA_ATOM" | "PROFILE_ATOM" | "GUIDE_ATOM" | "TIMELINE_ATOM" | "NEWSLETTER_SUBSCRIPTION" | "SURVEYS_QUESTIONS" | "ACQUISITIONS_EPIC" | "ACQUISITIONS_ENGAGEMENT_BANNER" | "ACQUISITIONS_THANK_YOU_EPIC" | "ACQUISITIONS_HEADER" | "ACQUISITIONS_FOOTER" | "ACQUISITIONS_INTERACTIVE_SLICE" | "ACQUISITIONS_NUGGET" | "ACQUISITIONS_STANDFIRST" | "ACQUISITIONS_THRASHER" | "ACQUISITIONS_EDITORIAL_LINK" | "ACQUISITIONS_MANAGE_MY_ACCOUNT" | "ACQUISITIONS_BUTTON" | "ACQUISITIONS_OTHER" | "APP_ADVERT" | "APP_AUDIO" | "APP_BUTTON" | "APP_CARD" | "APP_CROSSWORDS" | "APP_ENGAGEMENT_BANNER" | "APP_EPIC" | "APP_GALLERY" | "APP_LINK" | "APP_NAVIGATION_ITEM" | "APP_SCREEN" | "APP_THRASHER" | "APP_VIDEO" | "AUDIO_ATOM" | "CHART_ATOM" | "ACQUISITIONS_MERCHANDISING" | "ACQUISITIONS_HOUSE_ADS" | "SIGN_IN_GATE" | "ACQUISITIONS_SUBSCRIPTIONS_BANNER" | "MOBILE_STICKY_AD" | "IDENTITY_AUTHENTICATION" | "RETENTION_ENGAGEMENT_BANNER" | "ACQUISITION_SUPPORT_SITE" | "RETENTION_EPIC" | "CONSENT" | "LIVE_BLOG_PINNED_POST" | "STICKY_VIDEO" | "KEY_EVENT_CARD" | "RETENTION_HEADER" | "SLIDESHOW" | "APP_FEATURE" | "CARD" | "CAROUSEL" | "CONTAINER" | "MENU" | "ACQUISITIONS_GUTTER";
|
|
1
|
+
export type TComponentType = "READERS_QUESTIONS_ATOM" | "QANDA_ATOM" | "PROFILE_ATOM" | "GUIDE_ATOM" | "TIMELINE_ATOM" | "NEWSLETTER_SUBSCRIPTION" | "SURVEYS_QUESTIONS" | "ACQUISITIONS_EPIC" | "ACQUISITIONS_ENGAGEMENT_BANNER" | "ACQUISITIONS_THANK_YOU_EPIC" | "ACQUISITIONS_HEADER" | "ACQUISITIONS_FOOTER" | "ACQUISITIONS_INTERACTIVE_SLICE" | "ACQUISITIONS_NUGGET" | "ACQUISITIONS_STANDFIRST" | "ACQUISITIONS_THRASHER" | "ACQUISITIONS_EDITORIAL_LINK" | "ACQUISITIONS_MANAGE_MY_ACCOUNT" | "ACQUISITIONS_BUTTON" | "ACQUISITIONS_OTHER" | "APP_ADVERT" | "APP_AUDIO" | "APP_BUTTON" | "APP_CARD" | "APP_CROSSWORDS" | "APP_ENGAGEMENT_BANNER" | "APP_EPIC" | "APP_GALLERY" | "APP_LINK" | "APP_NAVIGATION_ITEM" | "APP_SCREEN" | "APP_THRASHER" | "APP_VIDEO" | "AUDIO_ATOM" | "CHART_ATOM" | "ACQUISITIONS_MERCHANDISING" | "ACQUISITIONS_HOUSE_ADS" | "SIGN_IN_GATE" | "ACQUISITIONS_SUBSCRIPTIONS_BANNER" | "MOBILE_STICKY_AD" | "IDENTITY_AUTHENTICATION" | "RETENTION_ENGAGEMENT_BANNER" | "ACQUISITION_SUPPORT_SITE" | "RETENTION_EPIC" | "CONSENT" | "LIVE_BLOG_PINNED_POST" | "STICKY_VIDEO" | "KEY_EVENT_CARD" | "RETENTION_HEADER" | "SLIDESHOW" | "APP_FEATURE" | "CARD" | "CAROUSEL" | "CONTAINER" | "MENU" | "ACQUISITIONS_GUTTER" | "INTERACTIVE_ATOM" | 'LOOP_VIDEO';
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -20,7 +20,7 @@ Tracker JS contains multiple entry point files tailored to specific platforms. T
|
|
|
20
20
|
**Default Behaviour**
|
|
21
21
|
|
|
22
22
|
The [default entry point](https://github.com/guardian/ophan/blob/main/tracker-js/package.json#L12) for the library is `ng.js`. With this entry point, the library automatically initializes itself on the global window object and provides a suite of functionalities to monitor user interactions, visibility, and more.
|
|
23
|
-
The library will send an initial [page view event](
|
|
23
|
+
The library will send an initial [page view event](/tracker-js/src/types/event.ts#L15) on page load. You are then in control of sending other events to Ophan.
|
|
24
24
|
|
|
25
25
|
You can see example usage of the library on theguardian.com [here](https://github.com/guardian/dotcom-rendering/blob/main/dotcom-rendering/src/client/ophan/ophan.ts)
|
|
26
26
|
|
|
@@ -42,7 +42,7 @@ You can see example usage of the library on theguardian.com [here](https://githu
|
|
|
42
42
|
|
|
43
43
|
### Usage with Typescript
|
|
44
44
|
|
|
45
|
-
Refer to the type definitions within the library for the structure of valid tracking events. The `ophan.record()` function accepts a type of `EventPayload` found [here](
|
|
45
|
+
Refer to the type definitions within the library for the structure of valid tracking events. The `ophan.record()` function accepts a type of `EventPayload` found [here](/tracker-js/src/types/event.ts#L90).
|
|
46
46
|
|
|
47
47
|
#### Example Usage
|
|
48
48
|
|