@gemx-dev/clarity-js 0.8.46 → 0.8.47
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/package.json +2 -7
- package/src/clarity.ts +26 -31
- package/src/core/report.ts +3 -10
- package/src/core/version.ts +1 -1
- package/src/data/consent.ts +4 -3
- package/src/data/cookie.ts +90 -0
- package/src/data/index.ts +2 -1
- package/src/data/metadata.ts +23 -110
- package/src/data/util.ts +18 -0
- package/src/index.ts +1 -1
- package/src/insight/snapshot.ts +7 -7
- package/test/hash.test.ts +68 -0
- package/test/time.test.ts +42 -0
- package/tsconfig.json +3 -12
- package/types/data.d.ts +38 -29
- package/types/index.d.ts +3 -3
- package/test/core.test.ts +0 -139
- package/test/helper.ts +0 -167
- package/test/html/core.html +0 -34
- package/test/stub.test.ts +0 -7
- package/test/tsconfig.test.json +0 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gemx-dev/clarity-js",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.47",
|
|
4
4
|
"description": "Clarity js",
|
|
5
5
|
"author": "Microsoft Corp.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -20,16 +20,11 @@
|
|
|
20
20
|
"@rollup/plugin-node-resolve": "^15.0.0",
|
|
21
21
|
"@rollup/plugin-terser": "^0.4.0",
|
|
22
22
|
"@rollup/plugin-typescript": "^11.0.0",
|
|
23
|
-
"@types/chai": "^4.3.0",
|
|
24
23
|
"@types/resize-observer-browser": "^0.1.6",
|
|
25
|
-
"chai": "^4.3.0",
|
|
26
24
|
"del-cli": "^5.0.0",
|
|
27
25
|
"husky": "^8.0.0",
|
|
28
26
|
"lint-staged": "^13.1.0",
|
|
29
|
-
"playwright": "^1.6.2",
|
|
30
27
|
"rollup": "^3.0.0",
|
|
31
|
-
"ts-mocha": "^11.1.0",
|
|
32
|
-
"tslib": "^2.3.0",
|
|
33
28
|
"tslint": "^6.1.3",
|
|
34
29
|
"typescript": "^4.3.5"
|
|
35
30
|
},
|
|
@@ -37,7 +32,7 @@
|
|
|
37
32
|
"build": "yarn build:clean && yarn build:main",
|
|
38
33
|
"build:main": "rollup -c rollup.config.ts --configPlugin @rollup/plugin-typescript",
|
|
39
34
|
"build:clean": "del-cli build/*",
|
|
40
|
-
"test": "
|
|
35
|
+
"test": "playwright test --config ../../playwright.config.ts --project=clarity-js",
|
|
41
36
|
"tslint": "tslint --project ./",
|
|
42
37
|
"tslint:fix": "tslint --fix --project ./ --force"
|
|
43
38
|
},
|
package/src/clarity.ts
CHANGED
|
@@ -22,19 +22,17 @@ export { measure };
|
|
|
22
22
|
const modules: Module[] = [diagnostic, layout, interaction, performance, dynamic];
|
|
23
23
|
|
|
24
24
|
export function start(config: Config = null): void {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
// Check that browser supports required APIs and we do not attempt to start Clarity multiple times
|
|
26
|
+
if (core.check()) {
|
|
27
|
+
core.config(config);
|
|
28
|
+
core.start();
|
|
29
|
+
data.start();
|
|
30
|
+
modules.forEach(x => measure(x.start)());
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
}
|
|
32
|
+
// If it's an internal call to start, without explicit configuration,
|
|
33
|
+
// re-process any newly accumulated items in the queue
|
|
34
|
+
if (config === null) { queue.process(); }
|
|
35
|
+
}
|
|
38
36
|
}
|
|
39
37
|
|
|
40
38
|
// By default Clarity is asynchronous and will yield by looking for requestIdleCallback.
|
|
@@ -43,29 +41,26 @@ export function start(config: Config = null): void {
|
|
|
43
41
|
// we do allow external clients to manually pause Clarity for that short burst of time and minimize
|
|
44
42
|
// performance impact even further. For reference, we are talking single digit milliseconds optimization here, not seconds.
|
|
45
43
|
export function pause(): void {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
44
|
+
if (core.active()) {
|
|
45
|
+
data.event(Constant.Clarity, Constant.Pause);
|
|
46
|
+
task.pause();
|
|
47
|
+
}
|
|
50
48
|
}
|
|
51
49
|
|
|
52
50
|
// This is how external clients can get out of pause state, and resume Clarity to continue monitoring the page
|
|
53
51
|
export function resume(): void {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
if (core.active()) {
|
|
53
|
+
task.resume();
|
|
54
|
+
data.event(Constant.Clarity, Constant.Resume);
|
|
55
|
+
}
|
|
58
56
|
}
|
|
59
57
|
|
|
60
58
|
export function stop(): void {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
queue.setup();
|
|
70
|
-
}
|
|
71
|
-
}
|
|
59
|
+
if (core.active()) {
|
|
60
|
+
// Stop modules in the reverse order of their initialization and start queuing up items again
|
|
61
|
+
modules.slice().reverse().forEach(x => measure(x.stop)());
|
|
62
|
+
data.stop();
|
|
63
|
+
core.stop();
|
|
64
|
+
queue.setup();
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/core/report.ts
CHANGED
|
@@ -9,23 +9,16 @@ export function reset(): void {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export function report(e: Error): Error {
|
|
12
|
-
console.log(`🚀 🐥 ~ report ~ e:`, e);
|
|
13
12
|
// Do not report the same message twice for the same page
|
|
14
13
|
if (history && history.indexOf(e.message) === -1) {
|
|
15
14
|
const url = config.report;
|
|
16
|
-
console.log(`🚀 🐥 ~ report ~ url:`, url);
|
|
17
15
|
if (url && url.length > 0 && data) {
|
|
18
|
-
let payload: Report = {
|
|
19
|
-
if (e.message) {
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
if (e.stack) {
|
|
23
|
-
payload.e = e.stack;
|
|
24
|
-
}
|
|
16
|
+
let payload: Report = {v: data.version, p: data.projectId, u: data.userId, s: data.sessionId, n: data.pageNum};
|
|
17
|
+
if (e.message) { payload.m = e.message; }
|
|
18
|
+
if (e.stack) { payload.e = e.stack; }
|
|
25
19
|
// Using POST request instead of a GET request (img-src) to not violate existing CSP rules
|
|
26
20
|
// Since, Clarity already uses XHR to upload data, we stick with similar POST mechanism for reporting too
|
|
27
21
|
let xhr = new XMLHttpRequest();
|
|
28
|
-
console.log(`🚀 🐥 ~ report ~ xhr:`, xhr);
|
|
29
22
|
xhr.open("POST", url, true);
|
|
30
23
|
xhr.send(JSON.stringify(payload));
|
|
31
24
|
history.push(e.message);
|
package/src/core/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
let version = "0.8.
|
|
1
|
+
let version = "0.8.47";
|
|
2
2
|
export default version;
|
package/src/data/consent.ts
CHANGED
|
@@ -30,12 +30,13 @@ function processConsent(): void {
|
|
|
30
30
|
|
|
31
31
|
const analytics_storage = ics.getConsentState(Constant.AnalyticsStorage);
|
|
32
32
|
const ad_storage = ics.getConsentState(Constant.AdStorage);
|
|
33
|
-
const consentState =
|
|
34
|
-
consentv2(consentState
|
|
33
|
+
const consentState = getGcmConsentState({ ad_Storage: ad_storage, analytics_Storage: analytics_storage });
|
|
34
|
+
consentv2(consentState);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function
|
|
37
|
+
function getGcmConsentState(googleConsent: GCMConsentState): ConsentState {
|
|
38
38
|
const consentState: ConsentState = {
|
|
39
|
+
source: ConsentSource.GCM,
|
|
39
40
|
ad_Storage: googleConsent.ad_Storage === GCMConsent.Granted ? Constant.Granted : Constant.Denied,
|
|
40
41
|
analytics_Storage: googleConsent.analytics_Storage === GCMConsent.Granted ? Constant.Granted : Constant.Denied,
|
|
41
42
|
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Constant } from "@clarity-types/data";
|
|
2
|
+
import config from "@src/core/config";
|
|
3
|
+
import { decodeCookieValue, encodeCookieValue, supported } from "@src/data/util";
|
|
4
|
+
|
|
5
|
+
let rootDomain = null;
|
|
6
|
+
export const COOKIE_SEP = Constant.Caret;
|
|
7
|
+
|
|
8
|
+
export function start() {
|
|
9
|
+
rootDomain = null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function stop() {
|
|
13
|
+
rootDomain = null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getCookie(key: string, limit = false): string {
|
|
17
|
+
if (supported(document, Constant.Cookie)) {
|
|
18
|
+
let cookies: string[] = document.cookie.split(Constant.Semicolon);
|
|
19
|
+
if (cookies) {
|
|
20
|
+
for (let i = 0; i < cookies.length; i++) {
|
|
21
|
+
let pair: string[] = cookies[i].split(Constant.Equals);
|
|
22
|
+
if (pair.length > 1 && pair[0] && pair[0].trim() === key) {
|
|
23
|
+
// Some browsers automatically url encode cookie values if they are not url encoded.
|
|
24
|
+
// We therefore encode and decode cookie values ourselves.
|
|
25
|
+
// For backwards compatability we need to consider 3 cases:
|
|
26
|
+
// * Cookie was previously not encoded by Clarity and browser did not encode it
|
|
27
|
+
// * Cookie was previously not encoded by Clarity and browser encoded it once or more
|
|
28
|
+
// * Cookie was previously encoded by Clarity and browser did not encode it
|
|
29
|
+
let [isEncoded, decodedValue] = decodeCookieValue(pair[1]);
|
|
30
|
+
|
|
31
|
+
while (isEncoded) {
|
|
32
|
+
[isEncoded, decodedValue] = decodeCookieValue(decodedValue);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// If we are limiting cookies, check if the cookie value is limited
|
|
36
|
+
if (limit) {
|
|
37
|
+
return decodedValue.endsWith(`${Constant.Tilde}1`) ? decodedValue.substring(0, decodedValue.length - 2) : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return decodedValue;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function setCookie(key: string, value: string, time: number): void {
|
|
49
|
+
// only write cookies if we are currently in a cookie writing mode (and they are supported)
|
|
50
|
+
// OR if we are trying to write an empty cookie (i.e. clear the cookie value out)
|
|
51
|
+
if ((config.track || value == Constant.Empty) && ((navigator && navigator.cookieEnabled) || supported(document, Constant.Cookie))) {
|
|
52
|
+
// Some browsers automatically url encode cookie values if they are not url encoded.
|
|
53
|
+
// We therefore encode and decode cookie values ourselves.
|
|
54
|
+
let encodedValue = encodeCookieValue(value);
|
|
55
|
+
|
|
56
|
+
let expiry = new Date();
|
|
57
|
+
expiry.setDate(expiry.getDate() + time);
|
|
58
|
+
let expires = expiry ? Constant.Expires + expiry.toUTCString() : Constant.Empty;
|
|
59
|
+
let cookie = `${key}=${encodedValue}${Constant.Semicolon}${expires}${Constant.Path}`;
|
|
60
|
+
try {
|
|
61
|
+
// Attempt to get the root domain only once and fall back to writing cookie on the current domain.
|
|
62
|
+
if (rootDomain === null) {
|
|
63
|
+
let hostname = location.hostname ? location.hostname.split(Constant.Dot) : [];
|
|
64
|
+
// Walk backwards on a domain and attempt to set a cookie, until successful
|
|
65
|
+
for (let i = hostname.length - 1; i >= 0; i--) {
|
|
66
|
+
rootDomain = `.${hostname[i]}${rootDomain ? rootDomain : Constant.Empty}`;
|
|
67
|
+
// We do not wish to attempt writing a cookie on the absolute last part of the domain, e.g. .com or .net.
|
|
68
|
+
// So we start attempting after second-last part, e.g. .domain.com (PASS) or .co.uk (FAIL)
|
|
69
|
+
if (i < hostname.length - 1) {
|
|
70
|
+
// Write the cookie on the current computed top level domain
|
|
71
|
+
document.cookie = `${cookie}${Constant.Semicolon}${Constant.Domain}${rootDomain}`;
|
|
72
|
+
// Once written, check if the cookie exists and its value matches exactly with what we intended to set
|
|
73
|
+
// Checking for exact value match helps us eliminate a corner case where the cookie may already be present with a different value
|
|
74
|
+
// If the check is successful, no more action is required and we can return from the function since rootDomain cookie is already set
|
|
75
|
+
// If the check fails, continue with the for loop until we can successfully set and verify the cookie
|
|
76
|
+
if (getCookie(key) === value) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Finally, if we were not successful and gone through all the options, play it safe and reset rootDomain to be empty
|
|
82
|
+
// This forces our code to fall back to always writing cookie to the current domain
|
|
83
|
+
rootDomain = Constant.Empty;
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
rootDomain = Constant.Empty;
|
|
87
|
+
}
|
|
88
|
+
document.cookie = rootDomain ? `${cookie}${Constant.Semicolon}${Constant.Domain}${rootDomain}` : cookie;
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/data/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import * as upgrade from "@src/data/upgrade";
|
|
|
13
13
|
import * as upload from "@src/data/upload";
|
|
14
14
|
import * as variable from "@src/data/variable";
|
|
15
15
|
import * as extract from "@src/data/extract";
|
|
16
|
+
import * as cookie from "@src/data/cookie";
|
|
16
17
|
export { event } from "@src/data/custom";
|
|
17
18
|
export { consent, consentv2, metadata } from "@src/data/metadata";
|
|
18
19
|
export { upgrade } from "@src/data/upgrade";
|
|
@@ -21,7 +22,7 @@ export { signal } from "@src/data/signal";
|
|
|
21
22
|
export { max as maxMetric } from "@src/data/metric";
|
|
22
23
|
export { log as dlog } from "@src/data/dimension";
|
|
23
24
|
|
|
24
|
-
const modules: Module[] = [baseline, dimension, variable, limit, summary, consent, metadata, envelope, upload, ping, upgrade, extract];
|
|
25
|
+
const modules: Module[] = [baseline, dimension, variable, limit, summary, cookie, consent, metadata, envelope, upload, ping, upgrade, extract];
|
|
25
26
|
|
|
26
27
|
export function start(): void {
|
|
27
28
|
// Metric needs to be initialized before we can start measuring. so metric is not wrapped in measure
|
package/src/data/metadata.ts
CHANGED
|
@@ -1,25 +1,24 @@
|
|
|
1
|
-
import { Time } from "@clarity-types/core";
|
|
2
|
-
import { BooleanFlag, Constant, Dimension, Metadata, MetadataCallback, MetadataCallbackOptions, Metric, Session,
|
|
1
|
+
import { Constant as CoreConstant, Time } from "@clarity-types/core";
|
|
2
|
+
import { BooleanFlag, ConsentData, ConsentSource, ConsentState, Constant, Dimension, Metadata, MetadataCallback, MetadataCallbackOptions, Metric, Session, Setting, User } from "@clarity-types/data";
|
|
3
3
|
import * as clarity from "@src/clarity";
|
|
4
4
|
import * as core from "@src/core";
|
|
5
5
|
import config from "@src/core/config";
|
|
6
6
|
import hash from "@src/core/hash";
|
|
7
7
|
import * as scrub from "@src/core/scrub";
|
|
8
|
+
import * as trackConsent from "@src/data/consent";
|
|
9
|
+
import { COOKIE_SEP, getCookie, setCookie } from "@src/data/cookie";
|
|
8
10
|
import * as dimension from "@src/data/dimension";
|
|
9
11
|
import * as metric from "@src/data/metric";
|
|
12
|
+
import { supported } from "@src/data/util";
|
|
10
13
|
import { set } from "@src/data/variable";
|
|
11
|
-
import * as trackConsent from "@src/data/consent";
|
|
12
|
-
import { Constant as CoreConstant } from "@clarity-types/core";
|
|
13
14
|
|
|
14
15
|
export let data: Metadata = null;
|
|
15
16
|
export let callbacks: MetadataCallbackOptions[] = [];
|
|
16
17
|
export let electron = BooleanFlag.False;
|
|
17
|
-
let rootDomain = null;
|
|
18
18
|
let consentStatus: ConsentState = null;
|
|
19
|
-
let defaultStatus: ConsentState = { source: ConsentSource.
|
|
19
|
+
let defaultStatus: ConsentState = { source: ConsentSource.Default, ad_Storage: Constant.Denied, analytics_Storage: Constant.Denied };
|
|
20
20
|
|
|
21
21
|
export function start(): void {
|
|
22
|
-
rootDomain = null;
|
|
23
22
|
const ua = navigator && "userAgent" in navigator ? navigator.userAgent : Constant.Empty;
|
|
24
23
|
const timezone = (typeof Intl !== 'undefined' && Intl?.DateTimeFormat()?.resolvedOptions()?.timeZone) ?? '';
|
|
25
24
|
const timezoneOffset = new Date().getTimezoneOffset().toString();
|
|
@@ -89,7 +88,7 @@ export function start(): void {
|
|
|
89
88
|
// If consent status is not already set, initialize it based on project configuration. Otherwise, use the existing consent status.
|
|
90
89
|
if (consentStatus === null) {
|
|
91
90
|
consentStatus = {
|
|
92
|
-
source: ConsentSource.Implicit,
|
|
91
|
+
source: u.consent ? ConsentSource.Cookie : ConsentSource.Implicit,
|
|
93
92
|
ad_Storage: config.track ? Constant.Granted : Constant.Denied,
|
|
94
93
|
analytics_Storage: config.track ? Constant.Granted : Constant.Denied,
|
|
95
94
|
};
|
|
@@ -114,7 +113,6 @@ function userAgentData(): void {
|
|
|
114
113
|
}
|
|
115
114
|
|
|
116
115
|
export function stop(): void {
|
|
117
|
-
rootDomain = null;
|
|
118
116
|
data = null;
|
|
119
117
|
callbacks.forEach(cb => { cb.called = false; });
|
|
120
118
|
}
|
|
@@ -142,15 +140,15 @@ export function id(): string {
|
|
|
142
140
|
//TODO: Remove this function once consentv2 is fully released
|
|
143
141
|
export function consent(status = true): void {
|
|
144
142
|
if (!status) {
|
|
145
|
-
consentv2();
|
|
143
|
+
consentv2({ source: ConsentSource.APIv1, ad_Storage: Constant.Denied, analytics_Storage: Constant.Denied });
|
|
146
144
|
return;
|
|
147
145
|
}
|
|
148
146
|
|
|
149
|
-
consentv2({ ad_Storage: Constant.Granted, analytics_Storage: Constant.Granted });
|
|
147
|
+
consentv2({ source: ConsentSource.APIv1, ad_Storage: Constant.Granted, analytics_Storage: Constant.Granted });
|
|
150
148
|
trackConsent.consent();
|
|
151
149
|
}
|
|
152
150
|
|
|
153
|
-
export function consentv2(consentState: ConsentState = defaultStatus, source: number = ConsentSource.
|
|
151
|
+
export function consentv2(consentState: ConsentState = defaultStatus, source: number = ConsentSource.APIv2): void {
|
|
154
152
|
const updatedStatus = {
|
|
155
153
|
source: consentState.source ?? source,
|
|
156
154
|
ad_Storage: normalizeConsent(consentState.ad_Storage, consentStatus?.ad_Storage),
|
|
@@ -162,6 +160,9 @@ export function consentv2(consentState: ConsentState = defaultStatus, source: nu
|
|
|
162
160
|
updatedStatus.ad_Storage === consentStatus.ad_Storage &&
|
|
163
161
|
updatedStatus.analytics_Storage === consentStatus.analytics_Storage
|
|
164
162
|
) {
|
|
163
|
+
consentStatus.source = updatedStatus.source;
|
|
164
|
+
trackConsent.trackConsentv2(getConsentData(consentStatus));
|
|
165
|
+
trackConsent.consent();
|
|
165
166
|
return;
|
|
166
167
|
}
|
|
167
168
|
|
|
@@ -171,8 +172,7 @@ export function consentv2(consentState: ConsentState = defaultStatus, source: nu
|
|
|
171
172
|
|
|
172
173
|
if (!consentData.analytics_Storage && config.track) {
|
|
173
174
|
config.track = false;
|
|
174
|
-
|
|
175
|
-
setCookie(Constant.CookieKey, Constant.Empty, -Number.MAX_VALUE);
|
|
175
|
+
clear(true);
|
|
176
176
|
clarity.stop();
|
|
177
177
|
window.setTimeout(clarity.start, Setting.RestartDelay);
|
|
178
178
|
return;
|
|
@@ -202,9 +202,14 @@ function normalizeConsent(value: unknown, fallback: string = Constant.Denied): s
|
|
|
202
202
|
return typeof value === 'string' ? value.toLowerCase() : fallback;
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
-
export function clear(): void {
|
|
205
|
+
export function clear(all: boolean = false): void {
|
|
206
206
|
// Clear any stored information in the cookie that tracks session information so we can restart fresh the next time
|
|
207
|
-
setCookie(Constant.SessionKey, Constant.Empty,
|
|
207
|
+
setCookie(Constant.SessionKey, Constant.Empty, -Number.MAX_VALUE);
|
|
208
|
+
|
|
209
|
+
// Clear user cookie as well if all flag is set
|
|
210
|
+
if (all) {
|
|
211
|
+
setCookie(Constant.CookieKey, Constant.Empty, -Number.MAX_VALUE);
|
|
212
|
+
}
|
|
208
213
|
}
|
|
209
214
|
|
|
210
215
|
function tab(): string {
|
|
@@ -227,7 +232,7 @@ export function save(): void {
|
|
|
227
232
|
let ts = Math.round(Date.now());
|
|
228
233
|
let upload = config.upload && typeof config.upload === Constant.String ? (config.upload as string).replace(Constant.HTTPS, Constant.Empty) : Constant.Empty;
|
|
229
234
|
let upgrade = config.lean ? BooleanFlag.False : BooleanFlag.True;
|
|
230
|
-
setCookie(Constant.SessionKey, [data.sessionId, ts, data.pageNum, upgrade, upload].join(
|
|
235
|
+
setCookie(Constant.SessionKey, [data.sessionId, ts, data.pageNum, upgrade, upload].join(COOKIE_SEP), Setting.SessionExpire);
|
|
231
236
|
}
|
|
232
237
|
|
|
233
238
|
function processCallback(upgrade: BooleanFlag, consentUpdate: boolean = false): void {
|
|
@@ -250,10 +255,6 @@ function processCallback(upgrade: BooleanFlag, consentUpdate: boolean = false):
|
|
|
250
255
|
}
|
|
251
256
|
}
|
|
252
257
|
|
|
253
|
-
function supported(target: Window | Document, api: string): boolean {
|
|
254
|
-
try { return !!target[api]; } catch { return false; }
|
|
255
|
-
}
|
|
256
|
-
|
|
257
258
|
function track(u: User, consent: BooleanFlag = null): void {
|
|
258
259
|
// If consent is not explicitly specified, infer it from the user object
|
|
259
260
|
consent = consent === null ? u.consent : consent;
|
|
@@ -266,7 +267,7 @@ function track(u: User, consent: BooleanFlag = null): void {
|
|
|
266
267
|
// To avoid cookie churn, write user id cookie only once every day
|
|
267
268
|
if (u.expiry === null || Math.abs(end - u.expiry) >= Setting.CookieInterval || u.consent !== consent || u.dob !== dob) {
|
|
268
269
|
let cookieParts = [data.userId, Setting.CookieVersion, end.toString(36), consent, dob];
|
|
269
|
-
setCookie(Constant.CookieKey, cookieParts.join(
|
|
270
|
+
setCookie(Constant.CookieKey, cookieParts.join(COOKIE_SEP), Setting.Expire);
|
|
270
271
|
}
|
|
271
272
|
}
|
|
272
273
|
|
|
@@ -321,91 +322,3 @@ function user(): User {
|
|
|
321
322
|
return output;
|
|
322
323
|
}
|
|
323
324
|
|
|
324
|
-
function getCookie(key: string, limit = false): string {
|
|
325
|
-
if (supported(document, Constant.Cookie)) {
|
|
326
|
-
let cookies: string[] = document.cookie.split(Constant.Semicolon);
|
|
327
|
-
if (cookies) {
|
|
328
|
-
for (let i = 0; i < cookies.length; i++) {
|
|
329
|
-
let pair: string[] = cookies[i].split(Constant.Equals);
|
|
330
|
-
if (pair.length > 1 && pair[0] && pair[0].trim() === key) {
|
|
331
|
-
// Some browsers automatically url encode cookie values if they are not url encoded.
|
|
332
|
-
// We therefore encode and decode cookie values ourselves.
|
|
333
|
-
// For backwards compatability we need to consider 3 cases:
|
|
334
|
-
// * Cookie was previously not encoded by Clarity and browser did not encode it
|
|
335
|
-
// * Cookie was previously not encoded by Clarity and browser encoded it once or more
|
|
336
|
-
// * Cookie was previously encoded by Clarity and browser did not encode it
|
|
337
|
-
let [isEncoded, decodedValue] = decodeCookieValue(pair[1]);
|
|
338
|
-
|
|
339
|
-
while (isEncoded) {
|
|
340
|
-
[isEncoded, decodedValue] = decodeCookieValue(decodedValue);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// If we are limiting cookies, check if the cookie value is limited
|
|
344
|
-
if (limit) {
|
|
345
|
-
return decodedValue.endsWith(`${Constant.Tilde}1`)
|
|
346
|
-
? decodedValue.substring(0, decodedValue.length - 2)
|
|
347
|
-
: null;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
return decodedValue;
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
return null;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function decodeCookieValue(value: string): [boolean, string] {
|
|
359
|
-
try {
|
|
360
|
-
let decodedValue = decodeURIComponent(value);
|
|
361
|
-
return [decodedValue != value, decodedValue];
|
|
362
|
-
}
|
|
363
|
-
catch {
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
return [false, value];
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
function encodeCookieValue(value: string): string {
|
|
370
|
-
return encodeURIComponent(value);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
function setCookie(key: string, value: string, time: number): void {
|
|
374
|
-
// only write cookies if we are currently in a cookie writing mode (and they are supported)
|
|
375
|
-
// OR if we are trying to write an empty cookie (i.e. clear the cookie value out)
|
|
376
|
-
if ((config.track || value == Constant.Empty) && ((navigator && navigator.cookieEnabled) || supported(document, Constant.Cookie))) {
|
|
377
|
-
// Some browsers automatically url encode cookie values if they are not url encoded.
|
|
378
|
-
// We therefore encode and decode cookie values ourselves.
|
|
379
|
-
let encodedValue = encodeCookieValue(value);
|
|
380
|
-
|
|
381
|
-
let expiry = new Date();
|
|
382
|
-
expiry.setDate(expiry.getDate() + time);
|
|
383
|
-
let expires = expiry ? Constant.Expires + expiry.toUTCString() : Constant.Empty;
|
|
384
|
-
let cookie = `${key}=${encodedValue}${Constant.Semicolon}${expires}${Constant.Path}`;
|
|
385
|
-
try {
|
|
386
|
-
// Attempt to get the root domain only once and fall back to writing cookie on the current domain.
|
|
387
|
-
if (rootDomain === null) {
|
|
388
|
-
let hostname = location.hostname ? location.hostname.split(Constant.Dot) : [];
|
|
389
|
-
// Walk backwards on a domain and attempt to set a cookie, until successful
|
|
390
|
-
for (let i = hostname.length - 1; i >= 0; i--) {
|
|
391
|
-
rootDomain = `.${hostname[i]}${rootDomain ? rootDomain : Constant.Empty}`;
|
|
392
|
-
// We do not wish to attempt writing a cookie on the absolute last part of the domain, e.g. .com or .net.
|
|
393
|
-
// So we start attempting after second-last part, e.g. .domain.com (PASS) or .co.uk (FAIL)
|
|
394
|
-
if (i < hostname.length - 1) {
|
|
395
|
-
// Write the cookie on the current computed top level domain
|
|
396
|
-
document.cookie = `${cookie}${Constant.Semicolon}${Constant.Domain}${rootDomain}`;
|
|
397
|
-
// Once written, check if the cookie exists and its value matches exactly with what we intended to set
|
|
398
|
-
// Checking for exact value match helps us eliminate a corner case where the cookie may already be present with a different value
|
|
399
|
-
// If the check is successful, no more action is required and we can return from the function since rootDomain cookie is already set
|
|
400
|
-
// If the check fails, continue with the for loop until we can successfully set and verify the cookie
|
|
401
|
-
if (getCookie(key) === value) { return; }
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
// Finally, if we were not successful and gone through all the options, play it safe and reset rootDomain to be empty
|
|
405
|
-
// This forces our code to fall back to always writing cookie to the current domain
|
|
406
|
-
rootDomain = Constant.Empty;
|
|
407
|
-
}
|
|
408
|
-
} catch { rootDomain = Constant.Empty; }
|
|
409
|
-
document.cookie = rootDomain ? `${cookie}${Constant.Semicolon}${Constant.Domain}${rootDomain}` : cookie;
|
|
410
|
-
}
|
|
411
|
-
}
|
package/src/data/util.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function supported(target: Window | Document, api: string): boolean {
|
|
2
|
+
try { return !!target[api]; } catch { return false; }
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function encodeCookieValue(value: string): string {
|
|
6
|
+
return encodeURIComponent(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function decodeCookieValue(value: string): [boolean, string] {
|
|
10
|
+
try {
|
|
11
|
+
let decodedValue = decodeURIComponent(value);
|
|
12
|
+
return [decodedValue != value, decodedValue];
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return [false, value];
|
|
18
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import hash from "./core/hash";
|
|
|
3
3
|
import * as selector from "./layout/selector";
|
|
4
4
|
import { get, getNode, lookup } from "./layout/dom";
|
|
5
5
|
|
|
6
|
-
const helper = { hash, selector, get, getNode, lookup }
|
|
6
|
+
const helper = { hash, selector, get, getNode, lookup }
|
|
7
7
|
const version = clarity.version;
|
|
8
8
|
|
|
9
9
|
export { clarity, version, helper };
|
package/src/insight/snapshot.ts
CHANGED
|
@@ -38,7 +38,7 @@ export function metadata(node: Node): TargetMetadata {
|
|
|
38
38
|
return output;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export function snapshot(): void {
|
|
41
|
+
export function snapshot(): void {
|
|
42
42
|
values = [];
|
|
43
43
|
traverse(document);
|
|
44
44
|
encode(Event.Snapshot);
|
|
@@ -54,13 +54,13 @@ function traverse(root: Node): void {
|
|
|
54
54
|
let attributes = null, tag = null, value = null;
|
|
55
55
|
let node = queue.shift();
|
|
56
56
|
let next = node.firstChild;
|
|
57
|
-
let parent = node.parentElement ? node.parentElement : (node.parentNode ?
|
|
57
|
+
let parent = node.parentElement ? node.parentElement : (node.parentNode ? node.parentNode : null);
|
|
58
58
|
|
|
59
59
|
while (next) {
|
|
60
60
|
queue.push(next);
|
|
61
61
|
next = next.nextSibling;
|
|
62
62
|
}
|
|
63
|
-
|
|
63
|
+
|
|
64
64
|
// Process the node
|
|
65
65
|
let type = node.nodeType;
|
|
66
66
|
switch (type) {
|
|
@@ -69,7 +69,7 @@ function traverse(root: Node): void {
|
|
|
69
69
|
tag = Constant.DocumentTag;
|
|
70
70
|
attributes = { name: doctype.name, publicId: doctype.publicId, systemId: doctype.systemId }
|
|
71
71
|
break;
|
|
72
|
-
case Node.TEXT_NODE:
|
|
72
|
+
case Node.TEXT_NODE:
|
|
73
73
|
value = node.nodeValue;
|
|
74
74
|
tag = idMap.get(parent) ? Constant.TextTag : tag;
|
|
75
75
|
break;
|
|
@@ -79,7 +79,7 @@ function traverse(root: Node): void {
|
|
|
79
79
|
tag = ["NOSCRIPT", "SCRIPT", "STYLE"].indexOf(element.tagName) < 0 ? element.tagName : tag;
|
|
80
80
|
break;
|
|
81
81
|
}
|
|
82
|
-
add(node, parent, { tag, attributes, value });
|
|
82
|
+
add(node, parent, { tag, attributes, value });
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
@@ -88,7 +88,7 @@ function getAttributes(element: HTMLElement): { [key: string]: string } {
|
|
|
88
88
|
let output = {};
|
|
89
89
|
let attributes = element.attributes;
|
|
90
90
|
if (attributes && attributes.length > 0) {
|
|
91
|
-
for (let i = 0; i < attributes.length; i++) {
|
|
91
|
+
for (let i = 0; i < attributes.length; i++) {
|
|
92
92
|
output[attributes[i].name] = attributes[i].value;
|
|
93
93
|
}
|
|
94
94
|
}
|
|
@@ -112,4 +112,4 @@ function add(node: Node, parent: Node, data: NodeInfo): void {
|
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
export function get(_node: Node): NodeValue {
|
|
115
|
+
export function get(_node: Node): NodeValue {return null;}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import hash from "@src/core/hash";
|
|
3
|
+
|
|
4
|
+
test.describe("Core Utilities - Hash", () => {
|
|
5
|
+
test("hash function should generate consistent hash for same input", () => {
|
|
6
|
+
const input = "test-string";
|
|
7
|
+
const hash1 = hash(input);
|
|
8
|
+
const hash2 = hash(input);
|
|
9
|
+
|
|
10
|
+
expect(hash1).toBe(hash2);
|
|
11
|
+
expect(hash1).toBeTruthy();
|
|
12
|
+
expect(typeof hash1).toBe("string");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("hash function should generate different hashes for different inputs", () => {
|
|
16
|
+
const hash1 = hash("input1");
|
|
17
|
+
const hash2 = hash("input2");
|
|
18
|
+
const hash3 = hash("completely-different-input");
|
|
19
|
+
|
|
20
|
+
expect(hash1).not.toBe(hash2);
|
|
21
|
+
expect(hash2).not.toBe(hash3);
|
|
22
|
+
expect(hash1).not.toBe(hash3);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("hash function should respect precision parameter", () => {
|
|
26
|
+
const input = "test-precision";
|
|
27
|
+
const hashNoPrecision = hash(input);
|
|
28
|
+
const hash16 = hash(input, 16);
|
|
29
|
+
const hash8 = hash(input, 8);
|
|
30
|
+
|
|
31
|
+
expect(hashNoPrecision).toBeTruthy();
|
|
32
|
+
expect(hash16).toBeTruthy();
|
|
33
|
+
expect(hash8).toBeTruthy();
|
|
34
|
+
|
|
35
|
+
// Hash with precision should be different from no precision
|
|
36
|
+
expect(hashNoPrecision).not.toBe(hash16);
|
|
37
|
+
|
|
38
|
+
// Verify precision limits: 2^16 = 65536, 2^8 = 256
|
|
39
|
+
const hash16Num = parseInt(hash16, 36);
|
|
40
|
+
const hash8Num = parseInt(hash8, 36);
|
|
41
|
+
expect(hash16Num).toBeLessThan(65536);
|
|
42
|
+
expect(hash8Num).toBeLessThan(256);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("hash function should handle empty strings", () => {
|
|
46
|
+
const result = hash("");
|
|
47
|
+
expect(result).toBeTruthy();
|
|
48
|
+
expect(typeof result).toBe("string");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("hash function should handle special characters", () => {
|
|
52
|
+
const hash1 = hash("test@example.com");
|
|
53
|
+
const hash2 = hash("test#$%^&*()");
|
|
54
|
+
const hash3 = hash("🎉🎊✨");
|
|
55
|
+
|
|
56
|
+
expect(hash1).toBeTruthy();
|
|
57
|
+
expect(hash2).toBeTruthy();
|
|
58
|
+
expect(hash3).toBeTruthy();
|
|
59
|
+
expect(hash1).not.toBe(hash2);
|
|
60
|
+
expect(hash2).not.toBe(hash3);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("hash function should produce base36 output", () => {
|
|
64
|
+
const result = hash("test-base36");
|
|
65
|
+
// Base36 should only contain 0-9 and a-z
|
|
66
|
+
expect(/^[0-9a-z]+$/.test(result)).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { start, stop, time } from "@src/core/time";
|
|
3
|
+
|
|
4
|
+
test.describe("Time Utilities", () => {
|
|
5
|
+
test("time module should start and stop", () => {
|
|
6
|
+
start();
|
|
7
|
+
const time1 = time();
|
|
8
|
+
|
|
9
|
+
expect(time1).toBeGreaterThanOrEqual(0);
|
|
10
|
+
|
|
11
|
+
stop();
|
|
12
|
+
const time2 = time();
|
|
13
|
+
|
|
14
|
+
// After stop, time should still work but use different baseline
|
|
15
|
+
expect(time2).toBeGreaterThanOrEqual(0);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("time function should track elapsed time", async () => {
|
|
19
|
+
start();
|
|
20
|
+
const time1 = time();
|
|
21
|
+
|
|
22
|
+
// Wait a bit
|
|
23
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
24
|
+
|
|
25
|
+
const time2 = time();
|
|
26
|
+
|
|
27
|
+
expect(time2).toBeGreaterThan(time1);
|
|
28
|
+
expect(time2 - time1).toBeGreaterThanOrEqual(50);
|
|
29
|
+
|
|
30
|
+
stop();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("time function should handle null event parameter", () => {
|
|
34
|
+
start();
|
|
35
|
+
const result = time(null);
|
|
36
|
+
|
|
37
|
+
expect(result).toBeGreaterThanOrEqual(0);
|
|
38
|
+
expect(typeof result).toBe("number");
|
|
39
|
+
|
|
40
|
+
stop();
|
|
41
|
+
});
|
|
42
|
+
});
|
package/tsconfig.json
CHANGED
|
@@ -1,21 +1,12 @@
|
|
|
1
1
|
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
2
3
|
"compilerOptions": {
|
|
3
|
-
"module": "esnext",
|
|
4
|
-
"target": "es5",
|
|
5
|
-
"lib": ["es6", "dom", "es2016", "es2017"],
|
|
6
|
-
"moduleResolution": "node",
|
|
7
|
-
"forceConsistentCasingInFileNames": true,
|
|
8
|
-
"noImplicitReturns": true,
|
|
9
|
-
"noUnusedLocals": true,
|
|
10
|
-
"noUnusedParameters": true,
|
|
11
|
-
"resolveJsonModule": true,
|
|
12
|
-
"esModuleInterop": true,
|
|
13
4
|
"baseUrl": ".",
|
|
14
5
|
"paths": {
|
|
15
6
|
"@src/*": ["src/*"],
|
|
16
7
|
"@clarity-types/*": ["types/*"]
|
|
17
8
|
}
|
|
18
9
|
},
|
|
19
|
-
"include":["src/**/*.ts","types/**/*.d.ts", "rollup.config.ts"],
|
|
20
|
-
"exclude": ["
|
|
10
|
+
"include":["src/**/*.ts", "types/**/*.d.ts", "test/**/*.ts", "rollup.config.ts"],
|
|
11
|
+
"exclude": ["node_modules", "build"]
|
|
21
12
|
}
|
package/types/data.d.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { Time } from "@clarity-types/core";
|
|
2
|
-
export type Target = number | Node;
|
|
3
|
-
export type Token = string | number | number[] | string[] | (string | number)[];
|
|
4
|
-
export type DecodedToken = any | any[];
|
|
2
|
+
export type Target = (number | Node);
|
|
3
|
+
export type Token = (string | number | number[] | string[] | (string | number)[]);
|
|
4
|
+
export type DecodedToken = (any | any[]);
|
|
5
5
|
|
|
6
6
|
export type MetadataCallback = (data: Metadata, playback: boolean, consentStatus?: ConsentState) => void;
|
|
7
7
|
export interface MetadataCallbackOptions {
|
|
8
|
-
callback: MetadataCallback
|
|
9
|
-
wait: boolean
|
|
10
|
-
recall: boolean
|
|
11
|
-
called: boolean
|
|
12
|
-
consentInfo: boolean
|
|
8
|
+
callback: MetadataCallback,
|
|
9
|
+
wait: boolean,
|
|
10
|
+
recall: boolean,
|
|
11
|
+
called: boolean,
|
|
12
|
+
consentInfo: boolean
|
|
13
13
|
}
|
|
14
|
-
export type SignalCallback = (data: ClaritySignal) => void
|
|
14
|
+
export type SignalCallback = (data: ClaritySignal) => void
|
|
15
15
|
|
|
16
16
|
/* Enum */
|
|
17
17
|
export const enum Event {
|
|
@@ -87,7 +87,7 @@ export const enum Event {
|
|
|
87
87
|
Keystrokes = 104,
|
|
88
88
|
BackGesture = 105,
|
|
89
89
|
WebViewStatus = 106,
|
|
90
|
-
AppInstallReferrer = 107
|
|
90
|
+
AppInstallReferrer = 107
|
|
91
91
|
// 200-300 reserved for internal use
|
|
92
92
|
}
|
|
93
93
|
|
|
@@ -182,7 +182,7 @@ export const enum Dimension {
|
|
|
182
182
|
Timezone = 34,
|
|
183
183
|
TimezoneOffset = 35,
|
|
184
184
|
Consent = 36,
|
|
185
|
-
InteractionNextPaint = 37
|
|
185
|
+
InteractionNextPaint = 37
|
|
186
186
|
// 200-300 reserved for internal use
|
|
187
187
|
}
|
|
188
188
|
|
|
@@ -194,7 +194,7 @@ export const enum Check {
|
|
|
194
194
|
Bytes = 4,
|
|
195
195
|
Collection = 5,
|
|
196
196
|
Server = 6,
|
|
197
|
-
Page = 7
|
|
197
|
+
Page = 7
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
export const enum Code {
|
|
@@ -218,29 +218,29 @@ export const enum Severity {
|
|
|
218
218
|
Info = 0,
|
|
219
219
|
Warning = 1,
|
|
220
220
|
Error = 2,
|
|
221
|
-
Fatal = 3
|
|
221
|
+
Fatal = 3
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
export const enum Upload {
|
|
225
225
|
Async = 0,
|
|
226
|
-
Beacon = 1
|
|
226
|
+
Beacon = 1
|
|
227
227
|
}
|
|
228
228
|
|
|
229
229
|
export const enum BooleanFlag {
|
|
230
230
|
False = 0,
|
|
231
|
-
True = 1
|
|
231
|
+
True = 1
|
|
232
232
|
}
|
|
233
233
|
|
|
234
234
|
export const enum GCMConsent {
|
|
235
235
|
Unknown = 0,
|
|
236
236
|
Granted = 1,
|
|
237
|
-
Denied = 2
|
|
237
|
+
Denied = 2
|
|
238
238
|
}
|
|
239
239
|
|
|
240
240
|
export const enum IframeStatus {
|
|
241
241
|
Unknown = 0,
|
|
242
242
|
TopFrame = 1,
|
|
243
|
-
Iframe = 2
|
|
243
|
+
Iframe = 2
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
export const enum Setting {
|
|
@@ -291,11 +291,11 @@ export const enum Character {
|
|
|
291
291
|
Blank = 32,
|
|
292
292
|
Tab = 9,
|
|
293
293
|
NewLine = 10,
|
|
294
|
-
Return = 13
|
|
294
|
+
Return = 13
|
|
295
295
|
}
|
|
296
296
|
|
|
297
297
|
export const enum ApplicationPlatform {
|
|
298
|
-
WebApp = 0
|
|
298
|
+
WebApp = 0
|
|
299
299
|
}
|
|
300
300
|
|
|
301
301
|
export const enum Constant {
|
|
@@ -380,27 +380,36 @@ export const enum XMLReadyState {
|
|
|
380
380
|
Opened = 1,
|
|
381
381
|
Headers_Recieved = 2,
|
|
382
382
|
Loading = 3,
|
|
383
|
-
Done = 4
|
|
383
|
+
Done = 4
|
|
384
384
|
}
|
|
385
385
|
|
|
386
386
|
export const enum ConsentSource {
|
|
387
387
|
Implicit = 0,
|
|
388
388
|
API = 1,
|
|
389
389
|
GCM = 2,
|
|
390
|
+
TCF = 3,
|
|
391
|
+
APIv1 = 4,
|
|
392
|
+
APIv2 = 5,
|
|
393
|
+
Cookie = 6,
|
|
394
|
+
Default = 7,
|
|
395
|
+
// 100-255 Reserved for CMP integration, both internal and external
|
|
396
|
+
ClarityShopifyPixel = 100,
|
|
397
|
+
ClarityShopifyApp = 101,
|
|
398
|
+
UET = 102
|
|
390
399
|
}
|
|
391
400
|
|
|
392
401
|
/* Helper Interfaces */
|
|
393
402
|
|
|
394
403
|
export interface Payload {
|
|
395
|
-
e: Token[] /* Envelope
|
|
396
|
-
a: Token[][] /* Events that are used for data analysis
|
|
397
|
-
p: Token[][] /* Events that are primarily used for session playback
|
|
404
|
+
e: Token[]; /* Envelope */
|
|
405
|
+
a: Token[][]; /* Events that are used for data analysis */
|
|
406
|
+
p: Token[][]; /* Events that are primarily used for session playback */
|
|
398
407
|
}
|
|
399
408
|
|
|
400
409
|
export interface EncodedPayload {
|
|
401
|
-
e: string /* Envelope
|
|
402
|
-
a: string /* Analytics Payload
|
|
403
|
-
p: string /* Playback Payload
|
|
410
|
+
e: string; /* Envelope */
|
|
411
|
+
a: string; /* Analytics Payload */
|
|
412
|
+
p: string; /* Playback Payload */
|
|
404
413
|
}
|
|
405
414
|
|
|
406
415
|
export interface Metadata {
|
|
@@ -534,8 +543,8 @@ export interface UploadData {
|
|
|
534
543
|
}
|
|
535
544
|
|
|
536
545
|
export interface ClaritySignal {
|
|
537
|
-
type: string
|
|
538
|
-
value?: number
|
|
546
|
+
type: string
|
|
547
|
+
value?: number
|
|
539
548
|
}
|
|
540
549
|
|
|
541
550
|
export interface PerformanceEventTiming extends PerformanceEntry {
|
|
@@ -557,7 +566,7 @@ export interface ConsentState {
|
|
|
557
566
|
export const enum ConsentType {
|
|
558
567
|
None = 0,
|
|
559
568
|
Implicit = 1,
|
|
560
|
-
General = 2
|
|
569
|
+
General = 2
|
|
561
570
|
}
|
|
562
571
|
|
|
563
572
|
export interface ConsentData {
|
package/types/index.d.ts
CHANGED
|
@@ -11,12 +11,12 @@ interface Clarity {
|
|
|
11
11
|
pause: () => void;
|
|
12
12
|
resume: () => void;
|
|
13
13
|
upgrade: (key: string) => void;
|
|
14
|
-
consent: () => void;
|
|
15
|
-
consentv2: () => void;
|
|
14
|
+
consent: (status?: boolean) => void;
|
|
15
|
+
consentv2: (consentState?: Data.ConsentState, source?: number) => void;
|
|
16
16
|
event: (name: string, value: string) => void;
|
|
17
17
|
set: (variable: string, value: string | string[]) => void;
|
|
18
18
|
identify: (userId: string, sessionId?: string, pageId?: string, userHint?: string) => void;
|
|
19
|
-
metadata: (callback: Data.MetadataCallback, wait?: boolean) => void;
|
|
19
|
+
metadata: (callback: Data.MetadataCallback, wait?: boolean, recall?: boolean, consentInfo?: boolean) => void;
|
|
20
20
|
signal: (callback: Data.SignalCallback) => void;
|
|
21
21
|
}
|
|
22
22
|
|
package/test/core.test.ts
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
import { assert } from "chai";
|
|
2
|
-
import { Browser, Page } from "playwright";
|
|
3
|
-
import { changes, clicks, inputs, launch, markup, node, text } from "./helper";
|
|
4
|
-
import { decode } from "@gemx-dev/clarity-decode";
|
|
5
|
-
|
|
6
|
-
let browser: Browser;
|
|
7
|
-
let page: Page;
|
|
8
|
-
|
|
9
|
-
describe("Core Tests", () => {
|
|
10
|
-
before(async () => {
|
|
11
|
-
browser = await launch();
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
beforeEach(async () => {
|
|
15
|
-
page = await browser.newPage();
|
|
16
|
-
await page.goto("about:blank");
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
afterEach(async () => {
|
|
20
|
-
await page.close();
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
after(async () => {
|
|
24
|
-
await browser.close();
|
|
25
|
-
browser = null;
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("should mask sensitive content by default", async () => {
|
|
29
|
-
let encoded: string[] = await markup(page, "core.html");
|
|
30
|
-
let decoded = encoded.map((x) => decode(x));
|
|
31
|
-
let heading = text(decoded, "one");
|
|
32
|
-
let address = text(decoded, "two");
|
|
33
|
-
let email = node(decoded, "attributes.id", "eml");
|
|
34
|
-
let password = node(decoded, "attributes.id", "pwd");
|
|
35
|
-
let search = node(decoded, "attributes.id", "search");
|
|
36
|
-
let card = node(decoded, "attributes.id", "cardnum");
|
|
37
|
-
let option = text(decoded, "option1");
|
|
38
|
-
let textarea = text(decoded, "textarea");
|
|
39
|
-
let click = clicks(decoded)[0];
|
|
40
|
-
let input = inputs(decoded)[0];
|
|
41
|
-
let group = changes(decoded);
|
|
42
|
-
|
|
43
|
-
// Non-sensitive fields continue to pass through with sensitive bits masked off
|
|
44
|
-
assert.equal(heading, "Thanks for your order #▫▪▪▫▫▫▪▪");
|
|
45
|
-
|
|
46
|
-
// Sensitive fields, including input fields, are randomized and masked
|
|
47
|
-
assert.equal(address, "•••••• ••••• ••••• ••••• ••••• •••••");
|
|
48
|
-
assert.equal(email.attributes.value, "••••• •••• •••• ••••");
|
|
49
|
-
assert.equal(password.attributes.value, "••••");
|
|
50
|
-
assert.equal(search.attributes.value, "••••• •••• ••••");
|
|
51
|
-
assert.equal(card.attributes.value, "•••••");
|
|
52
|
-
assert.equal(textarea, "••••• •••••");
|
|
53
|
-
assert.equal(option, "• •••••");
|
|
54
|
-
|
|
55
|
-
// Clicked text and input value should be consistent with uber masking configuration
|
|
56
|
-
assert.equal(click.data.text, "Hello ▪▪▪▫▪");
|
|
57
|
-
assert.equal(input.data.value, "••••• •••• •••• ••••");
|
|
58
|
-
assert.equal(group.length, 2);
|
|
59
|
-
// Search change - we should captured mangled input and hash
|
|
60
|
-
assert.equal(group[0].data.type, "search");
|
|
61
|
-
assert.equal(group[0].data.value, "••••• •••• •••• ••••");
|
|
62
|
-
assert.equal(group[0].data.checksum, "4y7m6");
|
|
63
|
-
// Password change - we should capture placholder value and empty hash
|
|
64
|
-
assert.equal(group[1].data.type, "password");
|
|
65
|
-
assert.equal(group[1].data.value, "••••");
|
|
66
|
-
assert.equal(group[1].data.checksum, "");
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("should mask all text in strict mode", async () => {
|
|
70
|
-
let encoded: string[] = await markup(page, "core.html", { content: false });
|
|
71
|
-
let decoded = encoded.map((x) => decode(x));
|
|
72
|
-
let heading = text(decoded, "one");
|
|
73
|
-
let address = text(decoded, "two");
|
|
74
|
-
let email = node(decoded, "attributes.id", "eml");
|
|
75
|
-
let password = node(decoded, "attributes.id", "pwd");
|
|
76
|
-
let search = node(decoded, "attributes.id", "search");
|
|
77
|
-
let card = node(decoded, "attributes.id", "cardnum");
|
|
78
|
-
let click = clicks(decoded)[0];
|
|
79
|
-
let input = inputs(decoded)[0];
|
|
80
|
-
let option = text(decoded, "option1");
|
|
81
|
-
|
|
82
|
-
// All fields are randomized and masked
|
|
83
|
-
assert.equal(heading, "• ••••• ••••• ••••• ••••• •••••");
|
|
84
|
-
assert.equal(address, "•••••• ••••• ••••• ••••• ••••• •••••");
|
|
85
|
-
assert.equal(email.attributes.value, "••••• •••• •••• ••••");
|
|
86
|
-
assert.equal(password.attributes.value, "••••");
|
|
87
|
-
assert.equal(search.attributes.value, "••••• •••• ••••");
|
|
88
|
-
assert.equal(card.attributes.value, "•••••");
|
|
89
|
-
assert.equal(option, "• •••••");
|
|
90
|
-
|
|
91
|
-
// Clicked text and input value should also be masked in strict mode
|
|
92
|
-
assert.equal(click.data.text, "••••• •••• ••••");
|
|
93
|
-
assert.equal(input.data.value, "••••• •••• •••• ••••");
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("should unmask non-sensitive text in relaxed mode", async () => {
|
|
97
|
-
let encoded: string[] = await markup(page, "core.html", { unmask: ["body"] });
|
|
98
|
-
let decoded = encoded.map((x) => decode(x));
|
|
99
|
-
let heading = text(decoded, "one");
|
|
100
|
-
let address = text(decoded, "two");
|
|
101
|
-
let email = node(decoded, "attributes.id", "eml");
|
|
102
|
-
let password = node(decoded, "attributes.id", "pwd");
|
|
103
|
-
let search = node(decoded, "attributes.id", "search");
|
|
104
|
-
let card = node(decoded, "attributes.id", "cardnum");
|
|
105
|
-
let click = clicks(decoded)[0];
|
|
106
|
-
let input = inputs(decoded)[0];
|
|
107
|
-
let option = text(decoded, "option1");
|
|
108
|
-
|
|
109
|
-
// Text flows through unmasked for non-sensitive fields, with exception of input fields
|
|
110
|
-
assert.equal(heading, "Thanks for your order #2AB700GH");
|
|
111
|
-
assert.equal(address, "1 Microsoft Way, Redmond, WA - 98052");
|
|
112
|
-
assert.equal(search.attributes.value, "••••• •••• ••••");
|
|
113
|
-
assert.equal(option, "• •••••");
|
|
114
|
-
|
|
115
|
-
// Sensitive fields are still masked
|
|
116
|
-
assert.equal(email.attributes.value, "••••• •••• •••• ••••");
|
|
117
|
-
assert.equal(password.attributes.value, "••••");
|
|
118
|
-
assert.equal(card.attributes.value, "•••••");
|
|
119
|
-
|
|
120
|
-
// Clicked text comes through unmasked in relaxed mode but input is still masked
|
|
121
|
-
assert.equal(click.data.text, "Hello Wor1d");
|
|
122
|
-
assert.equal(input.data.value, "••••• •••• •••• ••••");
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it("should respect mask config even in relaxed mode", async () => {
|
|
126
|
-
let encoded: string[] = await markup(page, "core.html", { mask: ["#mask"], unmask: ["body"] });
|
|
127
|
-
let decoded = encoded.map((x) => decode(x));
|
|
128
|
-
let subtree = text(decoded, "child");
|
|
129
|
-
let click = clicks(decoded)[0];
|
|
130
|
-
let input = inputs(decoded)[0];
|
|
131
|
-
|
|
132
|
-
// Masked sub-trees continue to stay masked
|
|
133
|
-
assert.equal(subtree, "••••• •••••");
|
|
134
|
-
|
|
135
|
-
// Clicked text is masked due to masked configuration and input value is also masked
|
|
136
|
-
assert.equal(click.data.text, "••••• •••• ••••");
|
|
137
|
-
assert.equal(input.data.value, "••••• •••• •••• ••••");
|
|
138
|
-
});
|
|
139
|
-
});
|
package/test/helper.ts
DELETED
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
import { Core, Data, decode, Interaction, Layout } from "@gemx-dev/clarity-decode";
|
|
2
|
-
import * as fs from "fs";
|
|
3
|
-
import * as url from "url";
|
|
4
|
-
import * as path from "path";
|
|
5
|
-
import { Browser, Page, chromium } from "playwright";
|
|
6
|
-
|
|
7
|
-
export async function launch(): Promise<Browser> {
|
|
8
|
-
return chromium.launch({ headless: false, args: ["--no-sandbox"] });
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export async function markup(page: Page, file: string, override: Core.Config = null): Promise<string[]> {
|
|
12
|
-
const htmlPath = path.resolve(__dirname, `./html/${file}`);
|
|
13
|
-
const htmlFileUrl = url.pathToFileURL(htmlPath).toString();
|
|
14
|
-
const html = fs.readFileSync(htmlPath, "utf8");
|
|
15
|
-
await Promise.all([page.goto(htmlFileUrl), page.waitForNavigation()]);
|
|
16
|
-
await page.setContent(
|
|
17
|
-
html.replace(
|
|
18
|
-
"</body>",
|
|
19
|
-
`
|
|
20
|
-
<script>
|
|
21
|
-
window.payloads = [];
|
|
22
|
-
${fs.readFileSync(path.resolve(__dirname, `../build/clarity.min.js`), "utf8")};
|
|
23
|
-
clarity("start", ${config(override)});
|
|
24
|
-
</script>
|
|
25
|
-
</body>
|
|
26
|
-
`
|
|
27
|
-
)
|
|
28
|
-
);
|
|
29
|
-
await page.hover("#two");
|
|
30
|
-
await page.click("#child");
|
|
31
|
-
await page.locator("#search").fill("");
|
|
32
|
-
await page.locator("#search").type("query with numb3rs");
|
|
33
|
-
await page.locator("#pwd").type("p1ssw0rd");
|
|
34
|
-
await page.locator("#eml").fill("");
|
|
35
|
-
await page.locator("#eml").type("hello@world.com");
|
|
36
|
-
await page.waitForFunction("payloads && payloads.length > 2");
|
|
37
|
-
return await page.evaluate("payloads");
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function clicks(decoded: Data.DecodedPayload[]): Interaction.ClickEvent[] {
|
|
41
|
-
let output: Interaction.ClickEvent[] = [];
|
|
42
|
-
for (let i = decoded.length - 1; i >= 0; i--) {
|
|
43
|
-
if (decoded[i].click) {
|
|
44
|
-
for (let j = 0; j < decoded[i].click.length; j++) {
|
|
45
|
-
output.push(decoded[i].click[j]);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
return output;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function inputs(decoded: Data.DecodedPayload[]): Interaction.InputEvent[] {
|
|
53
|
-
let output: Interaction.InputEvent[] = [];
|
|
54
|
-
for (let i = decoded.length - 1; i >= 0; i--) {
|
|
55
|
-
if (decoded[i].input) {
|
|
56
|
-
for (let j = 0; j < decoded[i].input.length; j++) {
|
|
57
|
-
output.push(decoded[i].input[j]);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
return output;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function changes(decoded: Data.DecodedPayload[]): Interaction.ChangeEvent[] {
|
|
65
|
-
let output: Interaction.ChangeEvent[] = [];
|
|
66
|
-
for (let i = decoded.length - 1; i >= 0; i--) {
|
|
67
|
-
if (decoded[i].change) {
|
|
68
|
-
for (let j = 0; j < decoded[i].change.length; j++) {
|
|
69
|
-
output.push(decoded[i].change[j]);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
return output;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function node(
|
|
77
|
-
decoded: Data.DecodedPayload[],
|
|
78
|
-
key: string,
|
|
79
|
-
value: string | number,
|
|
80
|
-
tag: string = null
|
|
81
|
-
): Layout.DomData {
|
|
82
|
-
let sub = null;
|
|
83
|
-
|
|
84
|
-
// Exploding nested keys into key and sub key
|
|
85
|
-
if (key.indexOf(".") > 0) {
|
|
86
|
-
const parts = key.split(".");
|
|
87
|
-
if (parts.length === 2) {
|
|
88
|
-
key = parts[0];
|
|
89
|
-
sub = parts[1];
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Walking over the decoded payload to find the right match
|
|
94
|
-
for (let i = decoded.length - 1; i >= 0; i--) {
|
|
95
|
-
if (decoded[i].dom) {
|
|
96
|
-
for (let j = 0; j < decoded[i].dom.length; j++) {
|
|
97
|
-
if (decoded[i].dom[j].data) {
|
|
98
|
-
for (let k = 0; k < decoded[i].dom[j].data.length; k++) {
|
|
99
|
-
let d = decoded[i].dom[j].data[k];
|
|
100
|
-
if ((sub && d[key] && d[key][sub] === value) || (d[key] && d[key] === value)) {
|
|
101
|
-
if ((tag && d.tag === tag) || tag === null) {
|
|
102
|
-
return d;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function text(decoded: Data.DecodedPayload[], id: string): string {
|
|
114
|
-
let parent = node(decoded, "attributes.id", id);
|
|
115
|
-
if (parent) {
|
|
116
|
-
let child = node(decoded, "parent", parent.id, "*T");
|
|
117
|
-
if (child && child.value) {
|
|
118
|
-
return child.value;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function config(override: Core.Config): string {
|
|
125
|
-
const settings = {
|
|
126
|
-
delay: 100,
|
|
127
|
-
content: true,
|
|
128
|
-
fraud: [],
|
|
129
|
-
regions: [],
|
|
130
|
-
mask: [],
|
|
131
|
-
unmask: [],
|
|
132
|
-
upload: (payload) => {
|
|
133
|
-
window["payloads"].push(payload);
|
|
134
|
-
window["clarity"]("upgrade", "test");
|
|
135
|
-
},
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
// Process overrides
|
|
139
|
-
if (override) {
|
|
140
|
-
for (let key of Object.keys(override)) {
|
|
141
|
-
settings[key] = override[key];
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Serialize configuration
|
|
146
|
-
let output = "";
|
|
147
|
-
for (let key of Object.keys(settings)) {
|
|
148
|
-
switch (key) {
|
|
149
|
-
case "upload":
|
|
150
|
-
output += `${JSON.stringify(key)}: ${settings[key].toString()},`;
|
|
151
|
-
break;
|
|
152
|
-
case "projectId":
|
|
153
|
-
case "mask":
|
|
154
|
-
case "unmask":
|
|
155
|
-
case "regions":
|
|
156
|
-
case "cookies":
|
|
157
|
-
case "fraud":
|
|
158
|
-
output += `${JSON.stringify(key)}: ${JSON.stringify(settings[key])},`;
|
|
159
|
-
break;
|
|
160
|
-
default:
|
|
161
|
-
output += `${JSON.stringify(key)}: ${settings[key]},`;
|
|
162
|
-
break;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
output += `"projectId": "test"`;
|
|
166
|
-
return "{" + output + "}";
|
|
167
|
-
}
|
package/test/html/core.html
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html>
|
|
3
|
-
<head>
|
|
4
|
-
<title>Core Tests</title>
|
|
5
|
-
<style>
|
|
6
|
-
input,
|
|
7
|
-
textarea,
|
|
8
|
-
select {
|
|
9
|
-
margin: 10px;
|
|
10
|
-
display: block;
|
|
11
|
-
}
|
|
12
|
-
</style>
|
|
13
|
-
</head>
|
|
14
|
-
<body>
|
|
15
|
-
<div>
|
|
16
|
-
<h1 id="one">Thanks for your order #2AB700GH</h1>
|
|
17
|
-
<p id="two" class="address-details">1 Microsoft Way, Redmond, WA - 98052</p>
|
|
18
|
-
</div>
|
|
19
|
-
<div id="mask">
|
|
20
|
-
<span id="child">Hello Wor1d</span>
|
|
21
|
-
</div>
|
|
22
|
-
<form name="login">
|
|
23
|
-
<input type="email" id="eml" title="Email" value="random@email.test" />
|
|
24
|
-
<input type="password" id="pwd" title="Password" maxlength="16" value="passw0rd" />
|
|
25
|
-
<input type="search" id="search" title="Search" value="hello w0rld" />
|
|
26
|
-
<input type="text" id="cardnum" title="CC" value="1234" />
|
|
27
|
-
<textarea id="textarea" autocapitalize="off" role="combobox" rows="5" placeholder="" spellcheck="false">Hell0 World</textarea>
|
|
28
|
-
<select id="select" ng-options="origin.code" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
|
|
29
|
-
<option id="option1" label="Halifax" value="string:YHZ">Halifax</option>
|
|
30
|
-
<option id="option2" label="Montréal" value="string:YUL" selected="selected">Montréal</option>
|
|
31
|
-
</select>
|
|
32
|
-
</form>
|
|
33
|
-
</body>
|
|
34
|
-
</html>
|
package/test/stub.test.ts
DELETED