@owlmeans/consent 0.1.18-rc.10
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 +64 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +90 -0
- package/build/consts.js.map +1 -0
- package/build/consts.local.d.ts +4 -0
- package/build/consts.local.d.ts.map +1 -0
- package/build/consts.local.js +4 -0
- package/build/consts.local.js.map +1 -0
- package/build/gtm.d.ts +72 -0
- package/build/gtm.d.ts.map +1 -0
- package/build/gtm.js +182 -0
- package/build/gtm.js.map +1 -0
- package/build/i18n/be.json +36 -0
- package/build/i18n/de.json +36 -0
- package/build/i18n/en.json +36 -0
- package/build/i18n/es.json +36 -0
- package/build/i18n/fr.json +36 -0
- package/build/i18n/pl.json +36 -0
- package/build/i18n/ru.json +36 -0
- package/build/i18n/uk.json +36 -0
- package/build/i18n.d.ts +16 -0
- package/build/i18n.d.ts.map +1 -0
- package/build/i18n.js +29 -0
- package/build/i18n.js.map +1 -0
- package/build/index.d.ts +12 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +9 -0
- package/build/index.js.map +1 -0
- package/build/linker.d.ts +55 -0
- package/build/linker.d.ts.map +1 -0
- package/build/linker.js +334 -0
- package/build/linker.js.map +1 -0
- package/build/plugins.d.ts +30 -0
- package/build/plugins.d.ts.map +1 -0
- package/build/plugins.js +73 -0
- package/build/plugins.js.map +1 -0
- package/build/storage.d.ts +27 -0
- package/build/storage.d.ts.map +1 -0
- package/build/storage.js +86 -0
- package/build/storage.js.map +1 -0
- package/build/store.d.ts +16 -0
- package/build/store.d.ts.map +1 -0
- package/build/store.js +100 -0
- package/build/store.js.map +1 -0
- package/build/types.d.ts +205 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/types.local.d.ts +5 -0
- package/build/types.local.d.ts.map +1 -0
- package/build/types.local.js +2 -0
- package/build/types.local.js.map +1 -0
- package/package.json +33 -0
- package/src/consts.local.ts +4 -0
- package/src/consts.ts +103 -0
- package/src/gtm.ts +216 -0
- package/src/i18n/be.json +36 -0
- package/src/i18n/de.json +36 -0
- package/src/i18n/en.json +36 -0
- package/src/i18n/es.json +36 -0
- package/src/i18n/fr.json +36 -0
- package/src/i18n/pl.json +36 -0
- package/src/i18n/ru.json +36 -0
- package/src/i18n/uk.json +36 -0
- package/src/i18n.ts +39 -0
- package/src/index.ts +14 -0
- package/src/linker.ts +369 -0
- package/src/plugins.ts +88 -0
- package/src/storage.ts +96 -0
- package/src/store.ts +126 -0
- package/src/types.local.ts +4 -0
- package/src/types.ts +218 -0
- package/tests/consent.spec.ts +256 -0
- package/tests/linker.spec.ts +774 -0
- package/tests/tracking.spec.ts +191 -0
- package/tsconfig.json +11 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { CONSENT_KEY, DEFAULT_CONSENT_CATEGORIES } from './consts.js'
|
|
2
|
+
import { applyConsent, pushConsentDefaults } from './gtm.js'
|
|
3
|
+
import { consentLinker, stripConsentLinkParam, writeConsentLanguage } from './linker.js'
|
|
4
|
+
import { adoptConsent, adoptConsentLanguage, registerConsentPlugin, startConsentPlugins } from './plugins.js'
|
|
5
|
+
import { readConsent, writeConsent } from './storage.js'
|
|
6
|
+
import type {
|
|
7
|
+
ConsentListener, ConsentOptions, ConsentReason, ConsentRecord, ConsentState, ConsentStore,
|
|
8
|
+
} from './types.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The consent state of this DOCUMENT.
|
|
12
|
+
*
|
|
13
|
+
* A module singleton rather than something hung off a context, because that is what consent
|
|
14
|
+
* actually is — a property of the browser and the page, not of one component tree. It also has to
|
|
15
|
+
* be reachable from places that are not React at all: the sign-in precondition runs inside a click
|
|
16
|
+
* handler, and a tag-manager helper runs before any component has mounted.
|
|
17
|
+
*/
|
|
18
|
+
export const makeConsentStore = (): ConsentStore => {
|
|
19
|
+
const listeners = new Set<ConsentListener>()
|
|
20
|
+
let state: ConsentState = { record: null, open: false, reason: null }
|
|
21
|
+
let options: ConsentOptions = {}
|
|
22
|
+
|
|
23
|
+
const publish = (next: Partial<ConsentState>): void => {
|
|
24
|
+
state = { ...state, ...next }
|
|
25
|
+
listeners.forEach(listener => listener(state))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const resolved = (): ConsentOptions & { categories: typeof DEFAULT_CONSENT_CATEGORIES, storageKey: string } => ({
|
|
29
|
+
...options,
|
|
30
|
+
categories: options.categories ?? DEFAULT_CONSENT_CATEGORIES,
|
|
31
|
+
storageKey: options.storageKey ?? CONSENT_KEY,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const store: ConsentStore = {
|
|
35
|
+
get: () => state,
|
|
36
|
+
|
|
37
|
+
subscribe: listener => {
|
|
38
|
+
listeners.add(listener)
|
|
39
|
+
|
|
40
|
+
return () => { listeners.delete(listener) }
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
init: opts => {
|
|
44
|
+
options = { ...options, ...opts }
|
|
45
|
+
// Before anything is read, so a page with no stored answer still declares what is denied.
|
|
46
|
+
pushConsentDefaults(options)
|
|
47
|
+
|
|
48
|
+
if (options.linker != null) {
|
|
49
|
+
// Replace-by-alias (`registerConsentPlugin`), so a second `init` (a re-mounted provider,
|
|
50
|
+
// StrictMode) never double-installs the click listener — `consentLinker().start` also
|
|
51
|
+
// guards itself with its own closured flag, belt and braces.
|
|
52
|
+
registerConsentPlugin(consentLinker())
|
|
53
|
+
startConsentPlugins(options)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let record = readConsent(options)
|
|
57
|
+
// Only when THIS document has no decision yet — an existing one always wins, exactly as the
|
|
58
|
+
// ordinary "ask" path would never overwrite a stored record either.
|
|
59
|
+
if (record == null && options.linker != null) {
|
|
60
|
+
record = adoptConsent(options)
|
|
61
|
+
if (record != null) {
|
|
62
|
+
writeConsent(record, options)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (options.linker != null) {
|
|
66
|
+
// The language the link carried, stored at once — the interface language is strictly
|
|
67
|
+
// necessary storage, so no decision is consulted. A page that stamped the inline head
|
|
68
|
+
// fragment has already done this and stripped the parameter, which leaves nothing here.
|
|
69
|
+
const carried = adoptConsentLanguage(options)
|
|
70
|
+
if (carried != null) {
|
|
71
|
+
writeConsentLanguage(carried, options)
|
|
72
|
+
}
|
|
73
|
+
// Always, whether or not anything was adopted — a stale or foreign parameter is exactly as
|
|
74
|
+
// much noise in the visible URL as an adopted one, and a page that already had its own
|
|
75
|
+
// decision may still have arrived with one attached.
|
|
76
|
+
stripConsentLinkParam(options)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (record != null) {
|
|
80
|
+
applyConsent(record, options)
|
|
81
|
+
publish({ record, open: false, reason: null })
|
|
82
|
+
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
publish({ record: null, open: true, reason: 'initial' })
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
save: record => {
|
|
89
|
+
writeConsent(record, options)
|
|
90
|
+
applyConsent(record, options)
|
|
91
|
+
publish({ record, open: false, reason: null })
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
acceptAll: () => {
|
|
95
|
+
const record: ConsentRecord = Object.fromEntries(
|
|
96
|
+
resolved().categories.map(category => [category.key, true])
|
|
97
|
+
)
|
|
98
|
+
store.save(record)
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
open: (reason?: ConsentReason) => { publish({ open: true, reason: reason ?? 'reopen' }) },
|
|
102
|
+
|
|
103
|
+
close: () => { publish({ open: false, reason: null }) },
|
|
104
|
+
|
|
105
|
+
granted: key => {
|
|
106
|
+
const category = resolved().categories.find(candidate => candidate.key === key)
|
|
107
|
+
if (category?.required === true) {
|
|
108
|
+
return true
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return state.record?.[key] === true
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
options: resolved,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return store
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const consentStore: ConsentStore = makeConsentStore()
|
|
121
|
+
|
|
122
|
+
/** Open the preferences dialog from anywhere — a footer link, a policy page, a login gate. */
|
|
123
|
+
export const openConsent = (reason?: ConsentReason): void => consentStore.open(reason)
|
|
124
|
+
|
|
125
|
+
/** Whether a category is granted, for the callers that are not components. */
|
|
126
|
+
export const isConsented = (key: string): boolean => consentStore.granted(key)
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Consent Mode v2 signals.
|
|
3
|
+
*
|
|
4
|
+
* Named here rather than left as free strings because a typo in a signal name is silent: the tag
|
|
5
|
+
* manager simply never sees the update, and the only symptom is analytics that stay switched off
|
|
6
|
+
* for everyone who agreed to them.
|
|
7
|
+
*/
|
|
8
|
+
import { CONSENT_LINK_VERSION } from './consts.local.js'
|
|
9
|
+
import { CONSENT_LOCALES } from './consts.js'
|
|
10
|
+
|
|
11
|
+
export type ConsentSignal =
|
|
12
|
+
| 'ad_storage' | 'ad_user_data' | 'ad_personalization'
|
|
13
|
+
| 'analytics_storage' | 'functionality_storage'
|
|
14
|
+
| 'personalization_storage' | 'security_storage'
|
|
15
|
+
|
|
16
|
+
export interface ConsentCategory {
|
|
17
|
+
/** Stable storage key. `essential` is reserved and always required. */
|
|
18
|
+
key: string
|
|
19
|
+
/**
|
|
20
|
+
* Always granted, rendered locked and labelled as required.
|
|
21
|
+
*
|
|
22
|
+
* Strictly-necessary storage does not need consent, and a dialog that presents it as a choice is
|
|
23
|
+
* itself a dark pattern. A required category is disclosure, not a question.
|
|
24
|
+
*/
|
|
25
|
+
required?: boolean
|
|
26
|
+
/** Resolved through `translate`; the built-in bundle supplies the default set in 8 languages. */
|
|
27
|
+
labelKey: string
|
|
28
|
+
descriptionKey: string
|
|
29
|
+
/**
|
|
30
|
+
* The window global this category yields to: `window[globalVar] = granted`, written on every
|
|
31
|
+
* apply and BEFORE the tag-manager push.
|
|
32
|
+
*
|
|
33
|
+
* This is the seam for anything that cannot subscribe — a custom-HTML tag reading a flag, a
|
|
34
|
+
* hand-placed pixel, a script that only ever runs once. Without it, integrating a tag that is
|
|
35
|
+
* not Consent-Mode-aware means reaching into this package.
|
|
36
|
+
*/
|
|
37
|
+
globalVar?: string
|
|
38
|
+
/** Consent Mode v2 signals this category drives. */
|
|
39
|
+
signals?: ConsentSignal[]
|
|
40
|
+
/** dataLayer event pushed when this category transitions from denied to granted. */
|
|
41
|
+
event?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One third-party service the site runs under a consent category, as the cookie policy discloses
|
|
46
|
+
* it.
|
|
47
|
+
*
|
|
48
|
+
* A category says WHY something is stored; a service says WHO receives it. A regulator reading
|
|
49
|
+
* the policy wants the second — "analytics cookies" discloses nothing about which company learns
|
|
50
|
+
* what a visitor did — so a site that runs a tag lists it here, beside the category that gates
|
|
51
|
+
* it. Plain data rather than keys: the name, the provider and the purpose are whatever the party
|
|
52
|
+
* adding the tag knows about it, and `@owlmeans/web-gtm`'s `googleTagServices(id)` is the
|
|
53
|
+
* disclosure for a Google tag.
|
|
54
|
+
*/
|
|
55
|
+
export interface ConsentService {
|
|
56
|
+
/** Display name: "Google Analytics". */
|
|
57
|
+
name: string
|
|
58
|
+
/** Who receives the data: "Google LLC". */
|
|
59
|
+
provider: string
|
|
60
|
+
/**
|
|
61
|
+
* The `ConsentCategory.key` this service runs under: `'analytics'`, `'marketing'`,
|
|
62
|
+
* `'essential'`, or a custom key. A service whose category is not in force is still disclosed,
|
|
63
|
+
* in a trailing group of its own — hiding it would hide something that may still run.
|
|
64
|
+
*/
|
|
65
|
+
category: string
|
|
66
|
+
/** One sentence on what it does with the data. */
|
|
67
|
+
purpose?: string
|
|
68
|
+
/** Cookie names it may set: `['_ga', '_ga_<ID>']`. */
|
|
69
|
+
cookies?: string[]
|
|
70
|
+
/** The provider's own privacy policy. */
|
|
71
|
+
privacyHref?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `v` is the schema version; every other key is a category key. */
|
|
75
|
+
export interface ConsentRecord {
|
|
76
|
+
v?: number
|
|
77
|
+
[category: string]: boolean | number | undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ConsentOptions {
|
|
81
|
+
categories?: ConsentCategory[]
|
|
82
|
+
storageKey?: string
|
|
83
|
+
cookieDays?: number
|
|
84
|
+
/**
|
|
85
|
+
* Left undefined by default, and that is deliberate: setting a domain orphans the existing
|
|
86
|
+
* host-only cookie, so every visitor who has already chosen would be asked again.
|
|
87
|
+
*/
|
|
88
|
+
cookieDomain?: string
|
|
89
|
+
/** Skip every dataLayer and global write. For tests, and for an app that runs no tags. */
|
|
90
|
+
silent?: boolean
|
|
91
|
+
/**
|
|
92
|
+
* Cross-domain consent: share this decision, and adopt one, between the listed first-party
|
|
93
|
+
* domains — a visitor who already decided on one need not decide again on another. See
|
|
94
|
+
* `consentLinker`/`ConsentPlugin` in `./plugins.js` and `./linker.js`.
|
|
95
|
+
*/
|
|
96
|
+
linker?: ConsentLinkerOptions
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ConsentLinkerOptions {
|
|
100
|
+
/**
|
|
101
|
+
* Every domain this decision is shared with, the current host included or not — either way, the
|
|
102
|
+
* current host is always part of the disclosed list (`consentDomains`), since a visitor reads
|
|
103
|
+
* "applies to" as the whole set, not just the others.
|
|
104
|
+
*/
|
|
105
|
+
domains: string[]
|
|
106
|
+
/** The URL parameter a decorated link carries the decision in. Defaults to `owlcc`. */
|
|
107
|
+
param?: string
|
|
108
|
+
/** How old a decorated link's timestamp may be and still be trusted, in seconds. Defaults to 300. */
|
|
109
|
+
maxAge?: number
|
|
110
|
+
/**
|
|
111
|
+
* Carry the visitor's interface language along with the decision, so a visitor reading the site
|
|
112
|
+
* in Polish arrives on the next domain in Polish. Its PRESENCE turns the sending side on (a link
|
|
113
|
+
* to a listed domain carries this document's `<html lang>`); `supported` turns the receiving side
|
|
114
|
+
* on — which stores the carried language whatever the cookie decision says, because the interface
|
|
115
|
+
* language is strictly necessary. See `ConsentLinkerLanguage`.
|
|
116
|
+
*/
|
|
117
|
+
language?: ConsentLinkerLanguage
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface ConsentLinkerLanguage {
|
|
121
|
+
/**
|
|
122
|
+
* RECEIVING side: the languages this application can render. A carried language is adopted only
|
|
123
|
+
* when it is one of these, exactly or by its base tag (`de-DE` → `de`), and is then stored at
|
|
124
|
+
* once — the interface language is strictly necessary storage, so no cookie decision gates it;
|
|
125
|
+
* anything else is ignored and the application keeps choosing its own. Leave it out on a site
|
|
126
|
+
* that only SENDS its language.
|
|
127
|
+
*/
|
|
128
|
+
supported?: string[]
|
|
129
|
+
/**
|
|
130
|
+
* RECEIVING side: the `localStorage` key an explicit language choice lives under. Defaults to
|
|
131
|
+
* `owlmeans-lng`, the key `@owlmeans/client-i18n` reads before it renders anything.
|
|
132
|
+
*/
|
|
133
|
+
storageKey?: string
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Why the dialog is open. `login` is what the sign-in precondition raises. */
|
|
137
|
+
export type ConsentReason = 'initial' | 'reopen' | 'login' | string
|
|
138
|
+
|
|
139
|
+
export interface ConsentState {
|
|
140
|
+
record: ConsentRecord | null
|
|
141
|
+
open: boolean
|
|
142
|
+
reason: ConsentReason | null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type ConsentListener = (state: ConsentState) => void
|
|
146
|
+
|
|
147
|
+
export interface ConsentStore {
|
|
148
|
+
get: () => ConsentState
|
|
149
|
+
subscribe: (listener: ConsentListener) => () => void
|
|
150
|
+
/** Push the defaults, load and migrate any stored record, apply it, and open when there is none. */
|
|
151
|
+
init: (opts?: ConsentOptions) => void
|
|
152
|
+
save: (record: ConsentRecord) => void
|
|
153
|
+
acceptAll: () => void
|
|
154
|
+
open: (reason?: ConsentReason) => void
|
|
155
|
+
close: () => void
|
|
156
|
+
/** Imperative reader, for the callers that are not React. */
|
|
157
|
+
granted: (key: string) => boolean
|
|
158
|
+
options: () => ConsentOptions & { categories: ConsentCategory[], storageKey: string }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type ConsentLocale = (typeof CONSENT_LOCALES)[number]
|
|
162
|
+
|
|
163
|
+
/** The decoded, not-yet-validated payload a decorated link's parameter carries. */
|
|
164
|
+
export interface ConsentLinkPayload {
|
|
165
|
+
v: typeof CONSENT_LINK_VERSION
|
|
166
|
+
/** One entry per OPTIONAL category this site knows, `1` granted / `0` denied. */
|
|
167
|
+
c: Record<string, 0 | 1>
|
|
168
|
+
/** Unix seconds the link was decorated at. */
|
|
169
|
+
t: number
|
|
170
|
+
/**
|
|
171
|
+
* The interface language of the page the link was on (a BCP 47 tag, lower-cased) — present only
|
|
172
|
+
* when the sender has `linker.language` set and its `<html lang>` names one. Optional on the
|
|
173
|
+
* wire, so a receiver that predates it reads the same `v: 2` payload and ignores the field.
|
|
174
|
+
*/
|
|
175
|
+
l?: string
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* An extension seam for `@owlmeans/consent`: something that reacts to this document's consent
|
|
180
|
+
* decision, or supplies one from elsewhere, without the core store knowing anything about where
|
|
181
|
+
* that decision came from.
|
|
182
|
+
*
|
|
183
|
+
* `consentLinker` (`./linker.js`) is the one built-in plugin — cross-domain consent through
|
|
184
|
+
* decorated links — but nothing here is specific to it. A registry rather than a fixed option on
|
|
185
|
+
* `ConsentOptions` because the store has zero dependencies today and stays that way: a plugin is
|
|
186
|
+
* opt-in code an application imports for its own reason, never a bundled feature this package
|
|
187
|
+
* pulls in whether or not anything uses it.
|
|
188
|
+
*/
|
|
189
|
+
export interface ConsentPlugin {
|
|
190
|
+
/** Stable identity. Registering the same alias again REPLACES the earlier plugin. */
|
|
191
|
+
alias: string
|
|
192
|
+
/** Run order when more than one plugin implements the same hook. Higher first. Defaults to 0. */
|
|
193
|
+
priority?: number
|
|
194
|
+
/** Installed once, the first time `consentStore.init` runs with this plugin registered. */
|
|
195
|
+
start?: (opts: ConsentOptions) => void
|
|
196
|
+
/**
|
|
197
|
+
* Offer a decision from outside this document's own storage — e.g. one carried on the URL that
|
|
198
|
+
* led here. Returns a full record to adopt, or `null` to defer to the next plugin / the ordinary
|
|
199
|
+
* "ask" path. Never called once a stored record already exists.
|
|
200
|
+
*/
|
|
201
|
+
adopt?: (opts: ConsentOptions) => ConsentRecord | null
|
|
202
|
+
/**
|
|
203
|
+
* Offer an interface language from outside this document's own storage — e.g. the one the page
|
|
204
|
+
* that led here was read in. Returns a language the application supports, or `null` to keep
|
|
205
|
+
* choosing its own. Only a CANDIDATE: it says what the link carried, and `writeConsentLanguage`
|
|
206
|
+
* stores it — unconditionally, since the interface language is strictly necessary storage.
|
|
207
|
+
*/
|
|
208
|
+
adoptLanguage?: (opts: ConsentOptions) => string | null
|
|
209
|
+
/**
|
|
210
|
+
* Rewrite an outgoing URL to carry this document's current decision — and its language — or
|
|
211
|
+
* `null` to leave it untouched (a foreign host, a link that opts out, nothing to carry yet).
|
|
212
|
+
* `record` is `null` while the visitor has not decided: a plugin that also carries something
|
|
213
|
+
* else (the language) still has a reason to decorate then.
|
|
214
|
+
*/
|
|
215
|
+
decorate?: (url: URL, record: ConsentRecord | null, opts: ConsentOptions) => URL | null
|
|
216
|
+
/** Extra domains this decision is understood to apply to, for disclosure — never deduplicated here. */
|
|
217
|
+
domains?: (opts: ConsentOptions) => string[]
|
|
218
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
CONSENT_ANALYTICS, CONSENT_ESSENTIAL, CONSENT_EVENT, CONSENT_KEY, CONSENT_LOCALES,
|
|
4
|
+
CONSENT_MARKETING, CONSENT_SCHEMA_VERSION, DEFAULT_CONSENT_CATEGORIES,
|
|
5
|
+
} from '../src/consts.js'
|
|
6
|
+
import { migrateConsent, readConsent, writeConsent, clearConsent } from '../src/storage.js'
|
|
7
|
+
import { applyConsent, consentDefaults, consentUpdate, consentBootstrapScript } from '../src/gtm.js'
|
|
8
|
+
import { makeConsentStore } from '../src/store.js'
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_CONSENT_MESSAGES, defaultConsentTranslate, interpolate, normalizeLocale,
|
|
11
|
+
} from '../src/i18n.js'
|
|
12
|
+
import type { ConsentCategory } from '../src/types.js'
|
|
13
|
+
|
|
14
|
+
/** A minimal browser: just the two stores this package writes to. */
|
|
15
|
+
const browser = () => {
|
|
16
|
+
const store = new Map<string, string>()
|
|
17
|
+
let cookie = ''
|
|
18
|
+
;(globalThis as any).localStorage = {
|
|
19
|
+
getItem: (k: string) => store.get(k) ?? null,
|
|
20
|
+
setItem: (k: string, v: string) => { store.set(k, v) },
|
|
21
|
+
removeItem: (k: string) => { store.delete(k) },
|
|
22
|
+
}
|
|
23
|
+
;(globalThis as any).document = {
|
|
24
|
+
get cookie() { return cookie },
|
|
25
|
+
set cookie(value: string) {
|
|
26
|
+
const [pair] = value.split(';')
|
|
27
|
+
cookie = cookie === '' ? pair : `${cookie}; ${pair}`
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
;(globalThis as any).window = globalThis
|
|
31
|
+
return { store, raw: () => cookie, reset: () => { store.clear(); cookie = '' } }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const env = browser()
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
env.reset()
|
|
38
|
+
delete (globalThis as any).dataLayer
|
|
39
|
+
delete (globalThis as any).cookieConsentSetup
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('storage', () => {
|
|
43
|
+
test('a record round-trips through localStorage', () => {
|
|
44
|
+
writeConsent({ [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: false })
|
|
45
|
+
|
|
46
|
+
expect(readConsent()?.[CONSENT_ANALYTICS]).toBe(true)
|
|
47
|
+
expect(readConsent()?.[CONSENT_MARKETING]).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('the cookie answers when localStorage does not', () => {
|
|
51
|
+
writeConsent({ [CONSENT_ANALYTICS]: true })
|
|
52
|
+
env.store.clear()
|
|
53
|
+
|
|
54
|
+
expect(readConsent()?.[CONSENT_ANALYTICS]).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('the cookie is path-wide, SameSite=Lax and long-lived', () => {
|
|
58
|
+
writeConsent({ [CONSENT_ANALYTICS]: true })
|
|
59
|
+
|
|
60
|
+
expect(env.raw()).toContain(`${CONSENT_KEY}=`)
|
|
61
|
+
// The cookie fragment the browser stub keeps is just the pair, so the attributes are asserted
|
|
62
|
+
// on what `writeConsent` produced rather than on what a real browser would retain.
|
|
63
|
+
expect(readConsent()).not.toBeNull()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('a legacy record — the owlmeans.com visitor — migrates instead of being discarded', () => {
|
|
67
|
+
// Exactly what the previous widget wrote: two categories, no version, essential implicit.
|
|
68
|
+
const legacy = { analytics: true, marketing: false }
|
|
69
|
+
|
|
70
|
+
const migrated = migrateConsent(legacy)
|
|
71
|
+
|
|
72
|
+
expect(migrated?.[CONSENT_ESSENTIAL]).toBe(true)
|
|
73
|
+
expect(migrated?.[CONSENT_ANALYTICS]).toBe(true)
|
|
74
|
+
expect(migrated?.v).toBe(CONSENT_SCHEMA_VERSION)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('a current record is returned unchanged', () => {
|
|
78
|
+
const current = { essential: true, analytics: false, v: CONSENT_SCHEMA_VERSION }
|
|
79
|
+
|
|
80
|
+
expect(migrateConsent(current)).toBe(current)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('nothing stored reads as null', () => {
|
|
84
|
+
clearConsent()
|
|
85
|
+
|
|
86
|
+
expect(readConsent()).toBeNull()
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('consent mode signals', () => {
|
|
91
|
+
test('defaults deny everything except security storage', () => {
|
|
92
|
+
const defaults = consentDefaults()
|
|
93
|
+
|
|
94
|
+
expect(defaults.analytics_storage).toBe('denied')
|
|
95
|
+
expect(defaults.ad_storage).toBe('denied')
|
|
96
|
+
expect(defaults.ad_user_data).toBe('denied')
|
|
97
|
+
expect(defaults.ad_personalization).toBe('denied')
|
|
98
|
+
expect(defaults.security_storage).toBe('granted')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('an update maps each category onto the signals it drives', () => {
|
|
102
|
+
const update = consentUpdate({ [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: false })
|
|
103
|
+
|
|
104
|
+
expect(update.analytics_storage).toBe('granted')
|
|
105
|
+
expect(update.ad_storage).toBe('denied')
|
|
106
|
+
// Required categories are granted whether or not the record says so.
|
|
107
|
+
expect(update.security_storage).toBe('granted')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('a custom category set drives only the signals it names', () => {
|
|
111
|
+
const categories: ConsentCategory[] = [
|
|
112
|
+
{ key: 'ads', labelKey: 'a', descriptionKey: 'b', signals: ['ad_storage'] },
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
expect(Object.keys(consentDefaults(categories))).toEqual(['ad_storage'])
|
|
116
|
+
expect(consentUpdate({ ads: true }, categories)).toEqual({ ad_storage: 'granted' })
|
|
117
|
+
})
|
|
118
|
+
})
|
|
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
|
+
|
|
150
|
+
describe('the bootstrap script', () => {
|
|
151
|
+
const script = consentBootstrapScript()
|
|
152
|
+
|
|
153
|
+
test('declares the defaults before anything else can', () => {
|
|
154
|
+
expect(script).toContain("'consent','default'")
|
|
155
|
+
expect(script.indexOf("'consent','default'")).toBeLessThan(script.indexOf("'consent','update'"))
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('is idempotent through the setup flag', () => {
|
|
159
|
+
expect(script).toContain('cookieConsentSetup')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('reads the same storage key the package writes', () => {
|
|
163
|
+
expect(script).toContain(JSON.stringify(CONSENT_KEY))
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('pushes arguments, the shape gtag.js itself emits', () => {
|
|
167
|
+
expect(script).toContain('w.dataLayer.push(arguments)')
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe('the store', () => {
|
|
172
|
+
test('opens when nothing is stored, and stays shut when something is', () => {
|
|
173
|
+
const first = makeConsentStore()
|
|
174
|
+
first.init({ silent: true })
|
|
175
|
+
|
|
176
|
+
expect(first.get().open).toBe(true)
|
|
177
|
+
expect(first.get().reason).toBe('initial')
|
|
178
|
+
|
|
179
|
+
first.save({ [CONSENT_ANALYTICS]: true })
|
|
180
|
+
|
|
181
|
+
const second = makeConsentStore()
|
|
182
|
+
second.init({ silent: true })
|
|
183
|
+
|
|
184
|
+
expect(second.get().open).toBe(false)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('a required category is granted whether or not the record says so', () => {
|
|
188
|
+
const store = makeConsentStore()
|
|
189
|
+
store.init({ silent: true })
|
|
190
|
+
store.save({ [CONSENT_ANALYTICS]: false })
|
|
191
|
+
|
|
192
|
+
expect(store.granted(CONSENT_ESSENTIAL)).toBe(true)
|
|
193
|
+
expect(store.granted(CONSENT_ANALYTICS)).toBe(false)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('accept-all grants every category', () => {
|
|
197
|
+
const store = makeConsentStore()
|
|
198
|
+
store.init({ silent: true })
|
|
199
|
+
store.acceptAll()
|
|
200
|
+
|
|
201
|
+
expect(DEFAULT_CONSENT_CATEGORIES.every(c => store.granted(c.key))).toBe(true)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('subscribers see every transition', () => {
|
|
205
|
+
const store = makeConsentStore()
|
|
206
|
+
const seen: boolean[] = []
|
|
207
|
+
store.subscribe(state => seen.push(state.open))
|
|
208
|
+
store.init({ silent: true })
|
|
209
|
+
store.acceptAll()
|
|
210
|
+
store.open('reopen')
|
|
211
|
+
|
|
212
|
+
expect(seen).toEqual([true, false, true])
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
describe('the built-in translations', () => {
|
|
217
|
+
test('every locale carries every key of English', () => {
|
|
218
|
+
// Without this, a language added to the bundle ships with English strings in it and nothing
|
|
219
|
+
// reports it — the readers who cannot tell us are exactly the ones affected.
|
|
220
|
+
const keys = Object.keys(require('../src/i18n/en.json') as Record<string, string>)
|
|
221
|
+
// Driven by the shipped locale list, so a language added there is checked without anyone
|
|
222
|
+
// remembering to add it here — French was once in the list and missing from this loop.
|
|
223
|
+
for (const lng of CONSENT_LOCALES.filter(locale => locale !== 'en')) {
|
|
224
|
+
const bundle = require(`../src/i18n/${lng}.json`) as Record<string, string>
|
|
225
|
+
expect({ lng, missing: keys.filter(key => bundle[key] == null) })
|
|
226
|
+
.toEqual({ lng, missing: [] })
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('every locale keeps every placeholder English has', () => {
|
|
231
|
+
// A translation that drops `{{provider}}` still renders — just without the name of the company
|
|
232
|
+
// the link belongs to, which is the one word of that line a reader needed.
|
|
233
|
+
const en = DEFAULT_CONSENT_MESSAGES.en
|
|
234
|
+
const placeholders = (text: string) => (text.match(/\{\{[a-z]+\}\}/gi) ?? []).sort()
|
|
235
|
+
for (const lng of CONSENT_LOCALES) {
|
|
236
|
+
const bundle = DEFAULT_CONSENT_MESSAGES[lng]
|
|
237
|
+
const drifted = Object.keys(en).filter(key =>
|
|
238
|
+
typeof bundle[key] === 'string'
|
|
239
|
+
&& placeholders(bundle[key]).join() !== placeholders(en[key]).join())
|
|
240
|
+
expect({ lng, drifted }).toEqual({ lng, drifted: [] })
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('an unknown locale falls back to English rather than to a raw key', () => {
|
|
245
|
+
expect(normalizeLocale('kl')).toBe('en')
|
|
246
|
+
expect(defaultConsentTranslate('kl')('title', 'fallback')).toBe('Cookie Preferences')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test('a key nobody translated returns the supplied default', () => {
|
|
250
|
+
expect(defaultConsentTranslate('en')('nope', 'the default')).toBe('the default')
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
test('placeholders interpolate', () => {
|
|
254
|
+
expect(interpolate('kept for {{days}} days', { days: 365 })).toBe('kept for 365 days')
|
|
255
|
+
})
|
|
256
|
+
})
|