@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.
Files changed (76) hide show
  1. package/build/consts.d.ts +64 -0
  2. package/build/consts.d.ts.map +1 -0
  3. package/build/consts.js +90 -0
  4. package/build/consts.js.map +1 -0
  5. package/build/consts.local.d.ts +4 -0
  6. package/build/consts.local.d.ts.map +1 -0
  7. package/build/consts.local.js +4 -0
  8. package/build/consts.local.js.map +1 -0
  9. package/build/gtm.d.ts +72 -0
  10. package/build/gtm.d.ts.map +1 -0
  11. package/build/gtm.js +182 -0
  12. package/build/gtm.js.map +1 -0
  13. package/build/i18n/be.json +36 -0
  14. package/build/i18n/de.json +36 -0
  15. package/build/i18n/en.json +36 -0
  16. package/build/i18n/es.json +36 -0
  17. package/build/i18n/fr.json +36 -0
  18. package/build/i18n/pl.json +36 -0
  19. package/build/i18n/ru.json +36 -0
  20. package/build/i18n/uk.json +36 -0
  21. package/build/i18n.d.ts +16 -0
  22. package/build/i18n.d.ts.map +1 -0
  23. package/build/i18n.js +29 -0
  24. package/build/i18n.js.map +1 -0
  25. package/build/index.d.ts +12 -0
  26. package/build/index.d.ts.map +1 -0
  27. package/build/index.js +9 -0
  28. package/build/index.js.map +1 -0
  29. package/build/linker.d.ts +55 -0
  30. package/build/linker.d.ts.map +1 -0
  31. package/build/linker.js +334 -0
  32. package/build/linker.js.map +1 -0
  33. package/build/plugins.d.ts +30 -0
  34. package/build/plugins.d.ts.map +1 -0
  35. package/build/plugins.js +73 -0
  36. package/build/plugins.js.map +1 -0
  37. package/build/storage.d.ts +27 -0
  38. package/build/storage.d.ts.map +1 -0
  39. package/build/storage.js +86 -0
  40. package/build/storage.js.map +1 -0
  41. package/build/store.d.ts +16 -0
  42. package/build/store.d.ts.map +1 -0
  43. package/build/store.js +100 -0
  44. package/build/store.js.map +1 -0
  45. package/build/types.d.ts +205 -0
  46. package/build/types.d.ts.map +1 -0
  47. package/build/types.js +2 -0
  48. package/build/types.js.map +1 -0
  49. package/build/types.local.d.ts +5 -0
  50. package/build/types.local.d.ts.map +1 -0
  51. package/build/types.local.js +2 -0
  52. package/build/types.local.js.map +1 -0
  53. package/package.json +33 -0
  54. package/src/consts.local.ts +4 -0
  55. package/src/consts.ts +103 -0
  56. package/src/gtm.ts +216 -0
  57. package/src/i18n/be.json +36 -0
  58. package/src/i18n/de.json +36 -0
  59. package/src/i18n/en.json +36 -0
  60. package/src/i18n/es.json +36 -0
  61. package/src/i18n/fr.json +36 -0
  62. package/src/i18n/pl.json +36 -0
  63. package/src/i18n/ru.json +36 -0
  64. package/src/i18n/uk.json +36 -0
  65. package/src/i18n.ts +39 -0
  66. package/src/index.ts +14 -0
  67. package/src/linker.ts +369 -0
  68. package/src/plugins.ts +88 -0
  69. package/src/storage.ts +96 -0
  70. package/src/store.ts +126 -0
  71. package/src/types.local.ts +4 -0
  72. package/src/types.ts +218 -0
  73. package/tests/consent.spec.ts +256 -0
  74. package/tests/linker.spec.ts +774 -0
  75. package/tests/tracking.spec.ts +191 -0
  76. 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)
@@ -0,0 +1,4 @@
1
+ export interface ConsentWindow {
2
+ dataLayer?: unknown[]
3
+ [flag: string]: unknown
4
+ }
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
+ })