@owlmeans/consent 0.1.18-rc.5 → 0.1.18-rc.6
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/build/consts.d.ts +10 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +10 -0
- package/build/consts.js.map +1 -1
- package/build/gtm.d.ts +33 -0
- package/build/gtm.d.ts.map +1 -1
- package/build/gtm.js +61 -1
- package/build/gtm.js.map +1 -1
- package/package.json +1 -1
- package/src/consts.ts +11 -0
- package/src/gtm.ts +72 -1
- package/tests/consent.spec.ts +33 -3
- package/tests/tracking.spec.ts +191 -0
package/build/consts.d.ts
CHANGED
|
@@ -17,6 +17,16 @@ export declare const CONSENT_COOKIE_DAYS = 365;
|
|
|
17
17
|
export declare const CONSENT_SCHEMA_VERSION = 2;
|
|
18
18
|
/** Idempotence flag for the Consent Mode defaults. Fixed: a page may carry two bundles. */
|
|
19
19
|
export declare const CONSENT_SETUP_FLAG = "cookieConsentSetup";
|
|
20
|
+
/**
|
|
21
|
+
* The DOM event `applyConsent` dispatches on `window` after it finishes writing globals and
|
|
22
|
+
* pushing the Consent Mode update.
|
|
23
|
+
*
|
|
24
|
+
* A loader that already ran (a gate script that decided, on first paint, not to load yet) has no
|
|
25
|
+
* other way to hear a LATER grant — Consent Mode itself speaks only on `window.dataLayer`, which a
|
|
26
|
+
* tag that has not loaded is not listening to. `event.detail.record` carries the record that was
|
|
27
|
+
* just applied.
|
|
28
|
+
*/
|
|
29
|
+
export declare const CONSENT_EVENT = "owlmeans:consent";
|
|
20
30
|
export declare const CONSENT_ESSENTIAL = "essential";
|
|
21
31
|
export declare const CONSENT_ANALYTICS = "analytics";
|
|
22
32
|
export declare const CONSENT_MARKETING = "marketing";
|
package/build/consts.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAEjD;;;;;GAKG;AACH,eAAO,MAAM,WAAW,wBAAwB,CAAA;AAEhD,eAAO,MAAM,mBAAmB,MAAM,CAAA;AAEtC;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,IAAI,CAAA;AAEvC,2FAA2F;AAC3F,eAAO,MAAM,kBAAkB,uBAAuB,CAAA;AAEtD,eAAO,MAAM,iBAAiB,cAAc,CAAA;AAC5C,eAAO,MAAM,iBAAiB,cAAc,CAAA;AAC5C,eAAO,MAAM,iBAAiB,cAAc,CAAA;AAE5C,eAAO,MAAM,eAAe,YAAI,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAU,CAAA;AAExF,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAA;AAE5D;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,EAAE,eAAe,EAmBvD,CAAA;AAED;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,GAAG,QAAQ,CAQxE,CAAA"}
|
|
1
|
+
{"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAEjD;;;;;GAKG;AACH,eAAO,MAAM,WAAW,wBAAwB,CAAA;AAEhD,eAAO,MAAM,mBAAmB,MAAM,CAAA;AAEtC;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,IAAI,CAAA;AAEvC,2FAA2F;AAC3F,eAAO,MAAM,kBAAkB,uBAAuB,CAAA;AAEtD;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa,qBAAqB,CAAA;AAE/C,eAAO,MAAM,iBAAiB,cAAc,CAAA;AAC5C,eAAO,MAAM,iBAAiB,cAAc,CAAA;AAC5C,eAAO,MAAM,iBAAiB,cAAc,CAAA;AAE5C,eAAO,MAAM,eAAe,YAAI,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAU,CAAA;AAExF,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAA;AAE5D;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,EAAE,eAAe,EAmBvD,CAAA;AAED;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,GAAG,QAAQ,CAQxE,CAAA"}
|
package/build/consts.js
CHANGED
|
@@ -16,6 +16,16 @@ export const CONSENT_COOKIE_DAYS = 365;
|
|
|
16
16
|
export const CONSENT_SCHEMA_VERSION = 2;
|
|
17
17
|
/** Idempotence flag for the Consent Mode defaults. Fixed: a page may carry two bundles. */
|
|
18
18
|
export const CONSENT_SETUP_FLAG = 'cookieConsentSetup';
|
|
19
|
+
/**
|
|
20
|
+
* The DOM event `applyConsent` dispatches on `window` after it finishes writing globals and
|
|
21
|
+
* pushing the Consent Mode update.
|
|
22
|
+
*
|
|
23
|
+
* A loader that already ran (a gate script that decided, on first paint, not to load yet) has no
|
|
24
|
+
* other way to hear a LATER grant — Consent Mode itself speaks only on `window.dataLayer`, which a
|
|
25
|
+
* tag that has not loaded is not listening to. `event.detail.record` carries the record that was
|
|
26
|
+
* just applied.
|
|
27
|
+
*/
|
|
28
|
+
export const CONSENT_EVENT = 'owlmeans:consent';
|
|
19
29
|
export const CONSENT_ESSENTIAL = 'essential';
|
|
20
30
|
export const CONSENT_ANALYTICS = 'analytics';
|
|
21
31
|
export const CONSENT_MARKETING = 'marketing';
|
package/build/consts.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,qBAAqB,CAAA;AAEhD,MAAM,CAAC,MAAM,mBAAmB,GAAG,GAAG,CAAA;AAEtC;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC,CAAA;AAEvC,2FAA2F;AAC3F,MAAM,CAAC,MAAM,kBAAkB,GAAG,oBAAoB,CAAA;AAEtD,MAAM,CAAC,MAAM,iBAAiB,GAAG,WAAW,CAAA;AAC5C,MAAM,CAAC,MAAM,iBAAiB,GAAG,WAAW,CAAA;AAC5C,MAAM,CAAC,MAAM,iBAAiB,GAAG,WAAW,CAAA;AAE5C,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAU,CAAA;AAIxF;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAsB;IAC3D;QACE,GAAG,EAAE,iBAAiB,EAAE,QAAQ,EAAE,IAAI;QACtC,QAAQ,EAAE,WAAW,EAAE,cAAc,EAAE,eAAe;QACtD,SAAS,EAAE,qBAAqB;QAChC,OAAO,EAAE,CAAC,kBAAkB,EAAE,uBAAuB,CAAC;KACvD;IACD;QACE,GAAG,EAAE,iBAAiB;QACtB,QAAQ,EAAE,WAAW,EAAE,cAAc,EAAE,eAAe;QACtD,SAAS,EAAE,qBAAqB;QAChC,OAAO,EAAE,CAAC,mBAAmB,CAAC;KAC/B;IACD;QACE,GAAG,EAAE,iBAAiB;QACtB,QAAQ,EAAE,WAAW,EAAE,cAAc,EAAE,eAAe;QACtD,SAAS,EAAE,qBAAqB;QAChC,OAAO,EAAE,CAAC,YAAY,EAAE,cAAc,EAAE,oBAAoB,CAAC;KAC9D;CACF,CAAA;AAED;;;;;GAKG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAyC;IAC3E,UAAU,EAAE,QAAQ;IACpB,YAAY,EAAE,QAAQ;IACtB,kBAAkB,EAAE,QAAQ;IAC5B,iBAAiB,EAAE,QAAQ;IAC3B,qBAAqB,EAAE,QAAQ;IAC/B,uBAAuB,EAAE,QAAQ;IACjC,gBAAgB,EAAE,SAAS;CAC5B,CAAA"}
|
|
1
|
+
{"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,qBAAqB,CAAA;AAEhD,MAAM,CAAC,MAAM,mBAAmB,GAAG,GAAG,CAAA;AAEtC;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC,CAAA;AAEvC,2FAA2F;AAC3F,MAAM,CAAC,MAAM,kBAAkB,GAAG,oBAAoB,CAAA;AAEtD;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,kBAAkB,CAAA;AAE/C,MAAM,CAAC,MAAM,iBAAiB,GAAG,WAAW,CAAA;AAC5C,MAAM,CAAC,MAAM,iBAAiB,GAAG,WAAW,CAAA;AAC5C,MAAM,CAAC,MAAM,iBAAiB,GAAG,WAAW,CAAA;AAE5C,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAU,CAAA;AAIxF;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAsB;IAC3D;QACE,GAAG,EAAE,iBAAiB,EAAE,QAAQ,EAAE,IAAI;QACtC,QAAQ,EAAE,WAAW,EAAE,cAAc,EAAE,eAAe;QACtD,SAAS,EAAE,qBAAqB;QAChC,OAAO,EAAE,CAAC,kBAAkB,EAAE,uBAAuB,CAAC;KACvD;IACD;QACE,GAAG,EAAE,iBAAiB;QACtB,QAAQ,EAAE,WAAW,EAAE,cAAc,EAAE,eAAe;QACtD,SAAS,EAAE,qBAAqB;QAChC,OAAO,EAAE,CAAC,mBAAmB,CAAC;KAC/B;IACD;QACE,GAAG,EAAE,iBAAiB;QACtB,QAAQ,EAAE,WAAW,EAAE,cAAc,EAAE,eAAe;QACtD,SAAS,EAAE,qBAAqB;QAChC,OAAO,EAAE,CAAC,YAAY,EAAE,cAAc,EAAE,oBAAoB,CAAC;KAC9D;CACF,CAAA;AAED;;;;;GAKG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAyC;IAC3E,UAAU,EAAE,QAAQ;IACpB,YAAY,EAAE,QAAQ;IACtB,kBAAkB,EAAE,QAAQ;IAC5B,iBAAiB,EAAE,QAAQ;IAC3B,qBAAqB,EAAE,QAAQ;IAC/B,uBAAuB,EAAE,QAAQ;IACjC,gBAAgB,EAAE,SAAS;CAC5B,CAAA"}
|
package/build/gtm.d.ts
CHANGED
|
@@ -26,6 +26,16 @@ export declare const pushConsentDefaults: (opts?: ConsentOptions) => void;
|
|
|
26
26
|
* turn, so writing them afterwards would let the first firing see the previous answer.
|
|
27
27
|
*/
|
|
28
28
|
export declare const applyConsent: (record: ConsentRecord, opts?: ConsentOptions) => void;
|
|
29
|
+
/**
|
|
30
|
+
* Whether a stored decision grants tracking at all — a signal-bearing category, not merely the
|
|
31
|
+
* required essential one.
|
|
32
|
+
*
|
|
33
|
+
* "Signal-bearing" is what a required category can never be: it exists to gate whether a LOADER
|
|
34
|
+
* runs, and a required category is disclosure, not a question — everyone gets it. Only a category
|
|
35
|
+
* that both is optional and drives at least one Consent Mode signal counts, which is exactly what
|
|
36
|
+
* `googleTagHeadScript`'s `'basic'` mode gates a Google tag behind.
|
|
37
|
+
*/
|
|
38
|
+
export declare const trackingGranted: (record: ConsentRecord | null, categories?: ConsentCategory[]) => boolean;
|
|
29
39
|
/**
|
|
30
40
|
* The inline script a document stamps ABOVE its tag-manager snippet.
|
|
31
41
|
*
|
|
@@ -36,4 +46,27 @@ export declare const applyConsent: (record: ConsentRecord, opts?: ConsentOptions
|
|
|
36
46
|
* denied for the first paint of every page.
|
|
37
47
|
*/
|
|
38
48
|
export declare const consentBootstrapScript: (opts?: ConsentOptions) => string;
|
|
49
|
+
/**
|
|
50
|
+
* Withhold a loader until a stored or later decision grants tracking.
|
|
51
|
+
*
|
|
52
|
+
* The counterpart to `consentBootstrapScript` for the `'basic'` gated loading mode
|
|
53
|
+
* (`@owlmeans/web-gtm`'s `GOOGLE_TAG_DEFAULT_MODE`): where the bootstrap always declares Consent
|
|
54
|
+
* Mode's defaults so a tag may load cookieless, this withholds the tag itself — the loader string
|
|
55
|
+
* is only ever reached once `trackingGranted` is true, either because a returning visitor's stored
|
|
56
|
+
* record already says so, or because `applyConsent` later dispatches {@link CONSENT_EVENT} with
|
|
57
|
+
* one that does. The listener removes itself on the first passing event, so the loader — which
|
|
58
|
+
* marks its own element and refuses to run twice — is asked to run at most once from here.
|
|
59
|
+
*
|
|
60
|
+
* `loaderExpr` is a complete statement or expression that runs the loader when reached — the
|
|
61
|
+
* self-invoking IIFE string `googleTagHeadScript`'s `gtagScript`/`gtmContainerScript` already
|
|
62
|
+
* produce, embedded here verbatim rather than called. It is placed inline exactly as
|
|
63
|
+
* `consentBootstrapScript`'s own output is: dynamic values (the storage key, the category shape,
|
|
64
|
+
* the event name) are JSON-encoded so nothing configured can break out of a string literal, and
|
|
65
|
+
* escaping the RESULT for HTML (`</script`, `<!--`) is left to whoever composes the final inline
|
|
66
|
+
* script — the same discipline `consentBootstrapScript` follows, and `googleTagHeadScript` already
|
|
67
|
+
* applies to the whole script it stamps.
|
|
68
|
+
*/
|
|
69
|
+
export declare const consentGateScript: (loaderExpr: string, opts?: ConsentOptions & {
|
|
70
|
+
categories?: ConsentCategory[];
|
|
71
|
+
}) => string;
|
|
39
72
|
//# sourceMappingURL=gtm.d.ts.map
|
package/build/gtm.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gtm.d.ts","sourceRoot":"","sources":["../src/gtm.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"gtm.d.ts","sourceRoot":"","sources":["../src/gtm.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAUhF;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,IAAI,CAUrD;AAKD,8EAA8E;AAC9E,eAAO,MAAM,eAAe,gBACb,eAAe,EAAE,KAC7B,MAAM,CAAC,MAAM,EAAE,SAAS,GAAG,QAAQ,CAMrC,CAAA;AAED,sDAAsD;AACtD,eAAO,MAAM,aAAa,WAChB,aAAa,eAAe,eAAe,EAAE,KACpD,MAAM,CAAC,MAAM,EAAE,SAAS,GAAG,QAAQ,CAWrC,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,UAAW,cAAc,KAAG,IAO3D,CAAA;AAED;;;;;GAKG;AACH,eAAO,MAAM,YAAY,WAAY,aAAa,SAAS,cAAc,KAAG,IA6B3E,CAAA;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe,WAClB,aAAa,GAAG,IAAI,eAAc,eAAe,EAAE,KAC1D,OAEF,CAAA;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,UAAW,cAAc,KAAG,MAwB9D,CAAA;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,iBAAiB,eAChB,MAAM,SAAS,cAAc,GAAG;IAAE,UAAU,CAAC,EAAE,eAAe,EAAE,CAAA;CAAE,KAC7E,MAuBF,CAAA"}
|
package/build/gtm.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CONSENT_KEY, CONSENT_SETUP_FLAG, CONSENT_SIGNAL_DEFAULTS, DEFAULT_CONSENT_CATEGORIES, } from './consts.js';
|
|
1
|
+
import { CONSENT_EVENT, CONSENT_KEY, CONSENT_SETUP_FLAG, CONSENT_SIGNAL_DEFAULTS, DEFAULT_CONSENT_CATEGORIES, } from './consts.js';
|
|
2
2
|
const global = () => typeof window !== 'undefined' ? window : null;
|
|
3
3
|
/**
|
|
4
4
|
* Push onto the tag-manager queue in the shape Google's own snippet uses.
|
|
@@ -77,7 +77,24 @@ export const applyConsent = (record, opts) => {
|
|
|
77
77
|
win.dataLayer.push({ event: category.event });
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
|
+
// A gate script (`consentGateScript`) that already decided NOT to load, because nothing was
|
|
81
|
+
// granted at the time it ran, has no other way to hear this. Consent Mode itself speaks only on
|
|
82
|
+
// `dataLayer`, which a tag that never loaded is not listening to.
|
|
83
|
+
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function'
|
|
84
|
+
&& typeof CustomEvent === 'function') {
|
|
85
|
+
window.dispatchEvent(new CustomEvent(CONSENT_EVENT, { detail: { record } }));
|
|
86
|
+
}
|
|
80
87
|
};
|
|
88
|
+
/**
|
|
89
|
+
* Whether a stored decision grants tracking at all — a signal-bearing category, not merely the
|
|
90
|
+
* required essential one.
|
|
91
|
+
*
|
|
92
|
+
* "Signal-bearing" is what a required category can never be: it exists to gate whether a LOADER
|
|
93
|
+
* runs, and a required category is disclosure, not a question — everyone gets it. Only a category
|
|
94
|
+
* that both is optional and drives at least one Consent Mode signal counts, which is exactly what
|
|
95
|
+
* `googleTagHeadScript`'s `'basic'` mode gates a Google tag behind.
|
|
96
|
+
*/
|
|
97
|
+
export const trackingGranted = (record, categories = DEFAULT_CONSENT_CATEGORIES) => record != null && categories.some(category => !category.required && (category.signals?.length ?? 0) > 0 && record[category.key] === true);
|
|
81
98
|
/**
|
|
82
99
|
* The inline script a document stamps ABOVE its tag-manager snippet.
|
|
83
100
|
*
|
|
@@ -111,4 +128,47 @@ export const consentBootstrapScript = (opts) => {
|
|
|
111
128
|
`u[s]=ok&&u[s]!=='denied'?'granted':'denied'}}` +
|
|
112
129
|
`g('consent','update',u)})(window,document)`;
|
|
113
130
|
};
|
|
131
|
+
/**
|
|
132
|
+
* Withhold a loader until a stored or later decision grants tracking.
|
|
133
|
+
*
|
|
134
|
+
* The counterpart to `consentBootstrapScript` for the `'basic'` gated loading mode
|
|
135
|
+
* (`@owlmeans/web-gtm`'s `GOOGLE_TAG_DEFAULT_MODE`): where the bootstrap always declares Consent
|
|
136
|
+
* Mode's defaults so a tag may load cookieless, this withholds the tag itself — the loader string
|
|
137
|
+
* is only ever reached once `trackingGranted` is true, either because a returning visitor's stored
|
|
138
|
+
* record already says so, or because `applyConsent` later dispatches {@link CONSENT_EVENT} with
|
|
139
|
+
* one that does. The listener removes itself on the first passing event, so the loader — which
|
|
140
|
+
* marks its own element and refuses to run twice — is asked to run at most once from here.
|
|
141
|
+
*
|
|
142
|
+
* `loaderExpr` is a complete statement or expression that runs the loader when reached — the
|
|
143
|
+
* self-invoking IIFE string `googleTagHeadScript`'s `gtagScript`/`gtmContainerScript` already
|
|
144
|
+
* produce, embedded here verbatim rather than called. It is placed inline exactly as
|
|
145
|
+
* `consentBootstrapScript`'s own output is: dynamic values (the storage key, the category shape,
|
|
146
|
+
* the event name) are JSON-encoded so nothing configured can break out of a string literal, and
|
|
147
|
+
* escaping the RESULT for HTML (`</script`, `<!--`) is left to whoever composes the final inline
|
|
148
|
+
* script — the same discipline `consentBootstrapScript` follows, and `googleTagHeadScript` already
|
|
149
|
+
* applies to the whole script it stamps.
|
|
150
|
+
*/
|
|
151
|
+
export const consentGateScript = (loaderExpr, opts) => {
|
|
152
|
+
const gate = JSON.stringify(categoriesOf(opts).map(category => ({
|
|
153
|
+
key: category.key,
|
|
154
|
+
required: category.required === true,
|
|
155
|
+
hasSignal: (category.signals ?? []).length > 0,
|
|
156
|
+
})));
|
|
157
|
+
const storageKey = JSON.stringify(opts?.storageKey ?? CONSENT_KEY);
|
|
158
|
+
const event = JSON.stringify(CONSENT_EVENT);
|
|
159
|
+
return `(function(w,d){` +
|
|
160
|
+
`function ok(r){if(!r)return false;var cs=${gate};` +
|
|
161
|
+
`for(var i=0;i<cs.length;i++){var c=cs[i];` +
|
|
162
|
+
`if(!c.required&&c.hasSignal&&r[c.key]===true)return true}return false}` +
|
|
163
|
+
`function load(){${loaderExpr}}` +
|
|
164
|
+
`var raw=null;try{raw=w.localStorage.getItem(${storageKey})}catch(e){}` +
|
|
165
|
+
`if(!raw){var p=('; '+d.cookie).split('; '+${storageKey}+'=');` +
|
|
166
|
+
`if(p.length===2){raw=p.pop().split(';').shift()}}` +
|
|
167
|
+
`var r=null;if(raw){try{r=JSON.parse(raw)}catch(e){r=null}}` +
|
|
168
|
+
`if(ok(r)){load();return}` +
|
|
169
|
+
`function h(ev){if(ok(ev&&ev.detail&&ev.detail.record)){` +
|
|
170
|
+
`w.removeEventListener(${event},h);load()}}` +
|
|
171
|
+
`w.addEventListener(${event},h)` +
|
|
172
|
+
`})(window,document)`;
|
|
173
|
+
};
|
|
114
174
|
//# sourceMappingURL=gtm.js.map
|
package/build/gtm.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gtm.js","sourceRoot":"","sources":["../src/gtm.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,WAAW,EAAE,kBAAkB,EAAE,uBAAuB,
|
|
1
|
+
{"version":3,"file":"gtm.js","sourceRoot":"","sources":["../src/gtm.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/src/consts.ts
CHANGED
|
@@ -22,6 +22,17 @@ export const CONSENT_SCHEMA_VERSION = 2
|
|
|
22
22
|
/** Idempotence flag for the Consent Mode defaults. Fixed: a page may carry two bundles. */
|
|
23
23
|
export const CONSENT_SETUP_FLAG = 'cookieConsentSetup'
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The DOM event `applyConsent` dispatches on `window` after it finishes writing globals and
|
|
27
|
+
* pushing the Consent Mode update.
|
|
28
|
+
*
|
|
29
|
+
* A loader that already ran (a gate script that decided, on first paint, not to load yet) has no
|
|
30
|
+
* other way to hear a LATER grant — Consent Mode itself speaks only on `window.dataLayer`, which a
|
|
31
|
+
* tag that has not loaded is not listening to. `event.detail.record` carries the record that was
|
|
32
|
+
* just applied.
|
|
33
|
+
*/
|
|
34
|
+
export const CONSENT_EVENT = 'owlmeans:consent'
|
|
35
|
+
|
|
25
36
|
export const CONSENT_ESSENTIAL = 'essential'
|
|
26
37
|
export const CONSENT_ANALYTICS = 'analytics'
|
|
27
38
|
export const CONSENT_MARKETING = 'marketing'
|
package/src/gtm.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
-
CONSENT_KEY, CONSENT_SETUP_FLAG, CONSENT_SIGNAL_DEFAULTS,
|
|
2
|
+
CONSENT_EVENT, CONSENT_KEY, CONSENT_SETUP_FLAG, CONSENT_SIGNAL_DEFAULTS,
|
|
3
|
+
DEFAULT_CONSENT_CATEGORIES,
|
|
3
4
|
} from './consts.js'
|
|
4
5
|
import type { ConsentCategory, ConsentOptions, ConsentRecord } from './types.js'
|
|
5
6
|
|
|
@@ -103,8 +104,31 @@ export const applyConsent = (record: ConsentRecord, opts?: ConsentOptions): void
|
|
|
103
104
|
win.dataLayer.push({ event: category.event })
|
|
104
105
|
}
|
|
105
106
|
}
|
|
107
|
+
|
|
108
|
+
// A gate script (`consentGateScript`) that already decided NOT to load, because nothing was
|
|
109
|
+
// granted at the time it ran, has no other way to hear this. Consent Mode itself speaks only on
|
|
110
|
+
// `dataLayer`, which a tag that never loaded is not listening to.
|
|
111
|
+
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function'
|
|
112
|
+
&& typeof CustomEvent === 'function') {
|
|
113
|
+
window.dispatchEvent(new CustomEvent(CONSENT_EVENT, { detail: { record } }))
|
|
114
|
+
}
|
|
106
115
|
}
|
|
107
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Whether a stored decision grants tracking at all — a signal-bearing category, not merely the
|
|
119
|
+
* required essential one.
|
|
120
|
+
*
|
|
121
|
+
* "Signal-bearing" is what a required category can never be: it exists to gate whether a LOADER
|
|
122
|
+
* runs, and a required category is disclosure, not a question — everyone gets it. Only a category
|
|
123
|
+
* that both is optional and drives at least one Consent Mode signal counts, which is exactly what
|
|
124
|
+
* `googleTagHeadScript`'s `'basic'` mode gates a Google tag behind.
|
|
125
|
+
*/
|
|
126
|
+
export const trackingGranted = (
|
|
127
|
+
record: ConsentRecord | null, categories: ConsentCategory[] = DEFAULT_CONSENT_CATEGORIES
|
|
128
|
+
): boolean => record != null && categories.some(
|
|
129
|
+
category => !category.required && (category.signals?.length ?? 0) > 0 && record[category.key] === true
|
|
130
|
+
)
|
|
131
|
+
|
|
108
132
|
/**
|
|
109
133
|
* The inline script a document stamps ABOVE its tag-manager snippet.
|
|
110
134
|
*
|
|
@@ -139,3 +163,50 @@ export const consentBootstrapScript = (opts?: ConsentOptions): string => {
|
|
|
139
163
|
`u[s]=ok&&u[s]!=='denied'?'granted':'denied'}}` +
|
|
140
164
|
`g('consent','update',u)})(window,document)`
|
|
141
165
|
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Withhold a loader until a stored or later decision grants tracking.
|
|
169
|
+
*
|
|
170
|
+
* The counterpart to `consentBootstrapScript` for the `'basic'` gated loading mode
|
|
171
|
+
* (`@owlmeans/web-gtm`'s `GOOGLE_TAG_DEFAULT_MODE`): where the bootstrap always declares Consent
|
|
172
|
+
* Mode's defaults so a tag may load cookieless, this withholds the tag itself — the loader string
|
|
173
|
+
* is only ever reached once `trackingGranted` is true, either because a returning visitor's stored
|
|
174
|
+
* record already says so, or because `applyConsent` later dispatches {@link CONSENT_EVENT} with
|
|
175
|
+
* one that does. The listener removes itself on the first passing event, so the loader — which
|
|
176
|
+
* marks its own element and refuses to run twice — is asked to run at most once from here.
|
|
177
|
+
*
|
|
178
|
+
* `loaderExpr` is a complete statement or expression that runs the loader when reached — the
|
|
179
|
+
* self-invoking IIFE string `googleTagHeadScript`'s `gtagScript`/`gtmContainerScript` already
|
|
180
|
+
* produce, embedded here verbatim rather than called. It is placed inline exactly as
|
|
181
|
+
* `consentBootstrapScript`'s own output is: dynamic values (the storage key, the category shape,
|
|
182
|
+
* the event name) are JSON-encoded so nothing configured can break out of a string literal, and
|
|
183
|
+
* escaping the RESULT for HTML (`</script`, `<!--`) is left to whoever composes the final inline
|
|
184
|
+
* script — the same discipline `consentBootstrapScript` follows, and `googleTagHeadScript` already
|
|
185
|
+
* applies to the whole script it stamps.
|
|
186
|
+
*/
|
|
187
|
+
export const consentGateScript = (
|
|
188
|
+
loaderExpr: string, opts?: ConsentOptions & { categories?: ConsentCategory[] }
|
|
189
|
+
): string => {
|
|
190
|
+
const gate = JSON.stringify(categoriesOf(opts).map(category => ({
|
|
191
|
+
key: category.key,
|
|
192
|
+
required: category.required === true,
|
|
193
|
+
hasSignal: (category.signals ?? []).length > 0,
|
|
194
|
+
})))
|
|
195
|
+
const storageKey = JSON.stringify(opts?.storageKey ?? CONSENT_KEY)
|
|
196
|
+
const event = JSON.stringify(CONSENT_EVENT)
|
|
197
|
+
|
|
198
|
+
return `(function(w,d){` +
|
|
199
|
+
`function ok(r){if(!r)return false;var cs=${gate};` +
|
|
200
|
+
`for(var i=0;i<cs.length;i++){var c=cs[i];` +
|
|
201
|
+
`if(!c.required&&c.hasSignal&&r[c.key]===true)return true}return false}` +
|
|
202
|
+
`function load(){${loaderExpr}}` +
|
|
203
|
+
`var raw=null;try{raw=w.localStorage.getItem(${storageKey})}catch(e){}` +
|
|
204
|
+
`if(!raw){var p=('; '+d.cookie).split('; '+${storageKey}+'=');` +
|
|
205
|
+
`if(p.length===2){raw=p.pop().split(';').shift()}}` +
|
|
206
|
+
`var r=null;if(raw){try{r=JSON.parse(raw)}catch(e){r=null}}` +
|
|
207
|
+
`if(ok(r)){load();return}` +
|
|
208
|
+
`function h(ev){if(ok(ev&&ev.detail&&ev.detail.record)){` +
|
|
209
|
+
`w.removeEventListener(${event},h);load()}}` +
|
|
210
|
+
`w.addEventListener(${event},h)` +
|
|
211
|
+
`})(window,document)`
|
|
212
|
+
}
|
package/tests/consent.spec.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { describe, test, expect, beforeEach } from 'bun:test'
|
|
2
2
|
import {
|
|
3
|
-
CONSENT_ANALYTICS, CONSENT_ESSENTIAL, CONSENT_KEY, CONSENT_LOCALES,
|
|
4
|
-
CONSENT_SCHEMA_VERSION, DEFAULT_CONSENT_CATEGORIES,
|
|
3
|
+
CONSENT_ANALYTICS, CONSENT_ESSENTIAL, CONSENT_EVENT, CONSENT_KEY, CONSENT_LOCALES,
|
|
4
|
+
CONSENT_MARKETING, CONSENT_SCHEMA_VERSION, DEFAULT_CONSENT_CATEGORIES,
|
|
5
5
|
} from '../src/consts.js'
|
|
6
6
|
import { migrateConsent, readConsent, writeConsent, clearConsent } from '../src/storage.js'
|
|
7
|
-
import { consentDefaults, consentUpdate, consentBootstrapScript } from '../src/gtm.js'
|
|
7
|
+
import { applyConsent, consentDefaults, consentUpdate, consentBootstrapScript } from '../src/gtm.js'
|
|
8
8
|
import { makeConsentStore } from '../src/store.js'
|
|
9
9
|
import {
|
|
10
10
|
DEFAULT_CONSENT_MESSAGES, defaultConsentTranslate, interpolate, normalizeLocale,
|
|
@@ -117,6 +117,36 @@ describe('consent mode signals', () => {
|
|
|
117
117
|
})
|
|
118
118
|
})
|
|
119
119
|
|
|
120
|
+
describe('applyConsent dispatches a DOM event', () => {
|
|
121
|
+
test('fires CONSENT_EVENT with the record after writing globals and pushing the update', () => {
|
|
122
|
+
const seen: unknown[] = []
|
|
123
|
+
const listener = (event: Event) => { seen.push((event as CustomEvent).detail) }
|
|
124
|
+
;(globalThis as any).addEventListener(CONSENT_EVENT, listener)
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
applyConsent({ [CONSENT_ANALYTICS]: true })
|
|
128
|
+
|
|
129
|
+
expect(seen).toEqual([{ record: { [CONSENT_ANALYTICS]: true } }])
|
|
130
|
+
} finally {
|
|
131
|
+
;(globalThis as any).removeEventListener(CONSENT_EVENT, listener)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('silent mode dispatches nothing', () => {
|
|
136
|
+
const seen: unknown[] = []
|
|
137
|
+
const listener = (event: Event) => { seen.push((event as CustomEvent).detail) }
|
|
138
|
+
;(globalThis as any).addEventListener(CONSENT_EVENT, listener)
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
applyConsent({ [CONSENT_ANALYTICS]: true }, { silent: true })
|
|
142
|
+
|
|
143
|
+
expect(seen).toEqual([])
|
|
144
|
+
} finally {
|
|
145
|
+
;(globalThis as any).removeEventListener(CONSENT_EVENT, listener)
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
120
150
|
describe('the bootstrap script', () => {
|
|
121
151
|
const script = consentBootstrapScript()
|
|
122
152
|
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { CONSENT_ANALYTICS, CONSENT_ESSENTIAL, CONSENT_EVENT, CONSENT_KEY, CONSENT_MARKETING } from '../src/consts.js'
|
|
3
|
+
import { consentGateScript, trackingGranted } from '../src/gtm.js'
|
|
4
|
+
import type { ConsentCategory, ConsentRecord } from '../src/types.js'
|
|
5
|
+
|
|
6
|
+
describe('trackingGranted', () => {
|
|
7
|
+
test('no record at all is not granted', () => {
|
|
8
|
+
expect(trackingGranted(null)).toBe(false)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('an essential-only record is not granted — required categories never count', () => {
|
|
12
|
+
const record: ConsentRecord = {
|
|
13
|
+
[CONSENT_ESSENTIAL]: true, [CONSENT_ANALYTICS]: false, [CONSENT_MARKETING]: false,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
expect(trackingGranted(record)).toBe(false)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('analytics granted is tracking-granted', () => {
|
|
20
|
+
const record: ConsentRecord = { [CONSENT_ESSENTIAL]: true, [CONSENT_ANALYTICS]: true }
|
|
21
|
+
|
|
22
|
+
expect(trackingGranted(record)).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('marketing granted is tracking-granted too', () => {
|
|
26
|
+
const record: ConsentRecord = { [CONSENT_ESSENTIAL]: true, [CONSENT_MARKETING]: true }
|
|
27
|
+
|
|
28
|
+
expect(trackingGranted(record)).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('a required category is ignored even when the record marks it granted', () => {
|
|
32
|
+
// A category can be BOTH required and signal-bearing (`essential` drives security_storage /
|
|
33
|
+
// functionality_storage by default) — required must still veto it, because everyone gets it
|
|
34
|
+
// regardless of what the record says.
|
|
35
|
+
const categories: ConsentCategory[] = [
|
|
36
|
+
{
|
|
37
|
+
key: 'essential', required: true, labelKey: 'a', descriptionKey: 'b',
|
|
38
|
+
signals: ['security_storage'],
|
|
39
|
+
},
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
expect(trackingGranted({ essential: true }, categories)).toBe(false)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('a category with no signals is ignored even when granted', () => {
|
|
46
|
+
const categories: ConsentCategory[] = [
|
|
47
|
+
{ key: 'preferences', labelKey: 'a', descriptionKey: 'b' },
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
expect(trackingGranted({ preferences: true }, categories)).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('consentGateScript — the two reachable paths', () => {
|
|
55
|
+
/** A minimal window/document the generated script can run against, with an event bus. */
|
|
56
|
+
const makeBrowser = (stored?: { key?: string, record: Record<string, unknown> }) => {
|
|
57
|
+
const storage = new Map<string, string>()
|
|
58
|
+
if (stored != null) {
|
|
59
|
+
storage.set(stored.key ?? CONSENT_KEY, JSON.stringify(stored.record))
|
|
60
|
+
}
|
|
61
|
+
const listeners = new Map<string, ((event: unknown) => void)[]>()
|
|
62
|
+
const win: Record<string, unknown> & {
|
|
63
|
+
addEventListener: (type: string, cb: (event: unknown) => void) => void
|
|
64
|
+
removeEventListener: (type: string, cb: (event: unknown) => void) => void
|
|
65
|
+
} = {
|
|
66
|
+
localStorage: { getItem: (k: string) => storage.get(k) ?? null },
|
|
67
|
+
addEventListener: (type, cb) => {
|
|
68
|
+
listeners.set(type, [...(listeners.get(type) ?? []), cb])
|
|
69
|
+
},
|
|
70
|
+
removeEventListener: (type, cb) => {
|
|
71
|
+
listeners.set(type, (listeners.get(type) ?? []).filter(listener => listener !== cb))
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
const doc = { cookie: '' }
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
win,
|
|
78
|
+
run: (script: string) => {
|
|
79
|
+
// eslint-disable-next-line no-new-func
|
|
80
|
+
new Function('window', 'document', script)(win, doc)
|
|
81
|
+
},
|
|
82
|
+
dispatch: (detail: unknown) => {
|
|
83
|
+
for (const cb of listeners.get(CONSENT_EVENT) ?? []) {
|
|
84
|
+
cb({ detail })
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
listenerCount: () => listeners.get(CONSENT_EVENT)?.length ?? 0,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
test('a returning visitor with a stored grant runs the loader immediately, synchronously', () => {
|
|
92
|
+
const browser = makeBrowser({ record: { essential: true, analytics: true, v: 2 } })
|
|
93
|
+
browser.win.__loaded = 0
|
|
94
|
+
browser.run(consentGateScript('window.__loaded = (window.__loaded || 0) + 1'))
|
|
95
|
+
|
|
96
|
+
expect(browser.win.__loaded).toBe(1)
|
|
97
|
+
// Nothing left listening — the immediate branch never subscribes.
|
|
98
|
+
expect(browser.listenerCount()).toBe(0)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('a stored record that grants nothing does not run the loader immediately', () => {
|
|
102
|
+
const browser = makeBrowser({ record: { essential: true, analytics: false, v: 2 } })
|
|
103
|
+
browser.win.__loaded = 0
|
|
104
|
+
browser.run(consentGateScript('window.__loaded = (window.__loaded || 0) + 1'))
|
|
105
|
+
|
|
106
|
+
expect(browser.win.__loaded).toBe(0)
|
|
107
|
+
expect(browser.listenerCount()).toBe(1)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('no stored record at all falls to the event-listener branch and waits', () => {
|
|
111
|
+
const browser = makeBrowser()
|
|
112
|
+
browser.win.__loaded = 0
|
|
113
|
+
browser.run(consentGateScript('window.__loaded = (window.__loaded || 0) + 1'))
|
|
114
|
+
|
|
115
|
+
expect(browser.win.__loaded).toBe(0)
|
|
116
|
+
|
|
117
|
+
// A denying event changes nothing.
|
|
118
|
+
browser.dispatch({ record: { essential: true, analytics: false } })
|
|
119
|
+
expect(browser.win.__loaded).toBe(0)
|
|
120
|
+
|
|
121
|
+
// The first granting event runs the loader and removes the listener.
|
|
122
|
+
browser.dispatch({ record: { essential: true, analytics: true } })
|
|
123
|
+
expect(browser.win.__loaded).toBe(1)
|
|
124
|
+
expect(browser.listenerCount()).toBe(0)
|
|
125
|
+
|
|
126
|
+
// A later event does nothing further — the listener already removed itself.
|
|
127
|
+
browser.dispatch({ record: { essential: true, analytics: true } })
|
|
128
|
+
expect(browser.win.__loaded).toBe(1)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('the cookie answers when localStorage has nothing stored', () => {
|
|
132
|
+
// Same fallback order `consentBootstrapScript`/`readConsent` use: localStorage first, the
|
|
133
|
+
// cookie second. Simulated here by handing the script a `document` whose cookie carries the
|
|
134
|
+
// record and a `window` whose localStorage is empty.
|
|
135
|
+
const record = JSON.stringify({ essential: true, analytics: true, v: 2 })
|
|
136
|
+
const win: Record<string, unknown> & {
|
|
137
|
+
addEventListener: (type: string, cb: (event: unknown) => void) => void
|
|
138
|
+
removeEventListener: (type: string, cb: (event: unknown) => void) => void
|
|
139
|
+
} = {
|
|
140
|
+
localStorage: { getItem: () => null },
|
|
141
|
+
addEventListener: () => {},
|
|
142
|
+
removeEventListener: () => {},
|
|
143
|
+
__loaded: 0,
|
|
144
|
+
}
|
|
145
|
+
const doc = { cookie: `${CONSENT_KEY}=${record}` }
|
|
146
|
+
// eslint-disable-next-line no-new-func
|
|
147
|
+
new Function(
|
|
148
|
+
'window', 'document', consentGateScript('window.__loaded = (window.__loaded || 0) + 1')
|
|
149
|
+
)(win, doc)
|
|
150
|
+
|
|
151
|
+
expect(win.__loaded).toBe(1)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('only a signal-bearing, non-required category can satisfy the gate', () => {
|
|
155
|
+
const categories: ConsentCategory[] = [
|
|
156
|
+
{ key: 'essential', required: true, labelKey: 'a', descriptionKey: 'b', signals: ['security_storage'] },
|
|
157
|
+
{ key: 'analytics', labelKey: 'c', descriptionKey: 'd', signals: ['analytics_storage'] },
|
|
158
|
+
]
|
|
159
|
+
const browser = makeBrowser({ record: { essential: true, analytics: false, v: 2 } })
|
|
160
|
+
browser.win.__loaded = 0
|
|
161
|
+
browser.run(consentGateScript('window.__loaded = (window.__loaded || 0) + 1', { categories }))
|
|
162
|
+
|
|
163
|
+
expect(browser.win.__loaded).toBe(0)
|
|
164
|
+
|
|
165
|
+
browser.dispatch({ record: { essential: true, analytics: true } })
|
|
166
|
+
expect(browser.win.__loaded).toBe(1)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('the generated script carries both reachable code paths in its text', () => {
|
|
170
|
+
const script = consentGateScript('window.__loaded = (window.__loaded || 0) + 1')
|
|
171
|
+
|
|
172
|
+
// The immediate branch: `load()` called right after the stored-record check, before any
|
|
173
|
+
// listener is attached.
|
|
174
|
+
expect(script.indexOf('load()')).toBeLessThan(script.indexOf('addEventListener'))
|
|
175
|
+
// The event-listener branch: the same loader, reachable a second time from inside the handler
|
|
176
|
+
// — plus the `function load(){...}` declaration itself, three occurrences of `load()` in all.
|
|
177
|
+
expect(script.match(/load\(\)/g)?.length).toBe(3)
|
|
178
|
+
expect(script).toContain(JSON.stringify(CONSENT_EVENT))
|
|
179
|
+
expect(script).toContain(JSON.stringify(CONSENT_KEY))
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('a custom storage key is honoured, matching the bootstrap script\'s own lookup', () => {
|
|
183
|
+
const browser = makeBrowser({ key: 'custom_key', record: { essential: true, analytics: true, v: 2 } })
|
|
184
|
+
browser.win.__loaded = 0
|
|
185
|
+
browser.run(consentGateScript(
|
|
186
|
+
'window.__loaded = (window.__loaded || 0) + 1', { storageKey: 'custom_key' }
|
|
187
|
+
))
|
|
188
|
+
|
|
189
|
+
expect(browser.win.__loaded).toBe(1)
|
|
190
|
+
})
|
|
191
|
+
})
|