@owlmeans/web-consent 0.1.18-rc.3 → 0.1.18-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/web-consent",
3
- "version": "0.1.18-rc.3",
3
+ "version": "0.1.18-rc.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -33,13 +33,19 @@
33
33
  },
34
34
  "devDependencies": {
35
35
  "@owlmeans/dep-config": "workspace:*",
36
+ "@owlmeans/test-ui": "^0.1.18-rc.11",
37
+ "@tailwindcss/vite": "^4.0.0",
36
38
  "@types/bun": "^1.4.0",
37
39
  "@types/react": "^19.2.17",
40
+ "@types/react-dom": "^19.2.3",
41
+ "@vitejs/plugin-react": "^6.0.3",
38
42
  "clsx": "^2.1.1",
39
43
  "lucide-react": "^0.550.0",
40
44
  "nodemon": "^3.1.14",
45
+ "react-dom": "^19.2.0",
41
46
  "tailwind-merge": "^3.4.1",
42
- "typescript": "^7.0.2"
47
+ "typescript": "^7.0.2",
48
+ "vite": "^8.1.2"
43
49
  },
44
50
  "publishConfig": {
45
51
  "access": "public"
@@ -0,0 +1,244 @@
1
+ import { afterAll, describe, expect, test } from 'bun:test'
2
+ import { closeBrowser, mountComponent } from '@owlmeans/test-ui'
3
+ import {
4
+ CONSENT_KEY, CONSENT_LOCALES, CONSENT_SCHEMA_VERSION, defaultConsentTranslate,
5
+ } from '@owlmeans/consent'
6
+ import { HARNESS_URL } from './context.js'
7
+
8
+ // Browser work does not fit the 5s default: a cold harness compiles the app on first request.
9
+ const TIMEOUT = 60_000
10
+
11
+ afterAll(async () => {
12
+ await closeBrowser()
13
+ })
14
+
15
+ const base = HARNESS_URL.replace(/\/$/, '')
16
+
17
+ /**
18
+ * A page booted with `record` already in storage, the way a returning visitor arrives.
19
+ *
20
+ * The seed lands BEFORE the document under test loads, because both things being observed — the
21
+ * inline bootstrap's `consent/update` and the dialog's decision not to open — read storage during
22
+ * the first paint. Seeding after load would test neither.
23
+ */
24
+ const seeded = async (record: unknown | null, query = '') => {
25
+ const mounted = await mountComponent({ url: `${base}/`, waitUntil: 'commit' })
26
+ // Seed on the real origin, then reload so the document boots with the record in place.
27
+ await mounted.page.evaluate(
28
+ ([key, value]) => {
29
+ if (value == null) window.localStorage.removeItem(key as string)
30
+ else window.localStorage.setItem(key as string, value as string)
31
+ },
32
+ [CONSENT_KEY, record == null ? null : JSON.stringify(record)] as [string, string | null]
33
+ )
34
+ await mounted.page.goto(`${base}/${query}`, { waitUntil: 'domcontentloaded' })
35
+
36
+ return mounted
37
+ }
38
+
39
+ describe('@owlmeans/web-consent — the dialog', () => {
40
+ test('a first-time visitor is asked, and the defaults were declared before anything could read them', async () => {
41
+ const { page, close } = await mountComponent({ url: `${base}/` })
42
+ try {
43
+ await page.waitForSelector('[data-consent-dialog]')
44
+
45
+ // The ORDER is the whole point of the inline bootstrap: whatever a tag reads when it loads
46
+ // is what it obeys, so `consent/default` has to be the FIRST thing on the queue — not merely
47
+ // present somewhere on it.
48
+ const layer = await page.evaluate(() => (
49
+ (window as never as { dataLayer?: unknown[] }).dataLayer ?? []
50
+ ).map(item => Array.from(item as ArrayLike<unknown>).slice(0, 2).join(':')))
51
+
52
+ expect(layer[0]).toBe('consent:default')
53
+
54
+ // No decision exists yet, so nothing may have been granted on this page.
55
+ expect(layer.filter(entry => entry === 'consent:update')).toHaveLength(0)
56
+ } finally {
57
+ await close()
58
+ }
59
+ }, TIMEOUT)
60
+
61
+ test('an existing owlmeans.com visitor is NOT asked again', async () => {
62
+ // THE migration case. The previous widget stored two booleans and no version; a reader that
63
+ // treated those as unusable would re-prompt every visitor the site has ever had — the most
64
+ // expensive possible regression, and completely silent.
65
+ const { page, close } = await seeded({ analytics: true, marketing: false })
66
+ try {
67
+ await page.waitForSelector('[data-consent-reopen]')
68
+
69
+ expect(await page.locator('[data-consent-dialog]').count()).toBe(0)
70
+
71
+ // The migration is applied on READ, in memory — a read must never write — so the assertion
72
+ // is on the EFFECTIVE decision rather than on the stored bytes, which legitimately stay in
73
+ // the old shape until the visitor next saves. Essential is granted without ever having been
74
+ // offered as a choice, and the two answers the visitor did give are carried across intact.
75
+ const applied = await page.evaluate(() => {
76
+ const w = window as never as Record<string, unknown>
77
+
78
+ return {
79
+ essential: w.owlConsentEssential ?? null,
80
+ analytics: w.owlConsentAnalytics ?? null,
81
+ marketing: w.owlConsentMarketing ?? null,
82
+ }
83
+ })
84
+ expect(applied).toEqual({ essential: true, analytics: true, marketing: false })
85
+
86
+ // And the record still on disk is the legacy one, untouched.
87
+ const stored = await page.evaluate(
88
+ key => JSON.parse(window.localStorage.getItem(key) ?? 'null'), CONSENT_KEY
89
+ ) as Record<string, unknown>
90
+ expect(stored.v).toBeUndefined()
91
+ } finally {
92
+ await close()
93
+ }
94
+ }, TIMEOUT)
95
+
96
+ test('a stored decision reaches the tag from the INLINE script, before React runs', async () => {
97
+ // The returning visitor's case, and the reason the bootstrap reads storage at all: a tag that
98
+ // loads on this page must see the previous answer immediately, or every returning visitor is
99
+ // treated as denied for the first paint of every page.
100
+ //
101
+ // Asserted against the DEFAULT categories on purpose — the inline snippet is stamped by the
102
+ // HTML emitter, so it carries whatever set the host gave `consentBootstrapScript()`, and a
103
+ // host with custom categories must pass them there too. Mismatching the two is a real
104
+ // misconfiguration, not something the component can paper over.
105
+ const { page, close } = await seeded(
106
+ { essential: true, analytics: true, marketing: false, v: CONSENT_SCHEMA_VERSION }
107
+ )
108
+ try {
109
+ await page.waitForSelector('[data-consent-reopen]')
110
+
111
+ const seen = await page.evaluate(() => {
112
+ const layer = ((window as never as { dataLayer?: unknown[] }).dataLayer ?? [])
113
+ .map(item => Array.from(item as ArrayLike<unknown>))
114
+
115
+ return {
116
+ kinds: layer.map(entry => `${String(entry[0])}:${String(entry[1])}`),
117
+ update: layer.find(entry => entry[1] === 'update')?.[2] ?? null,
118
+ analytics: (window as never as Record<string, unknown>).owlConsentAnalytics ?? null,
119
+ marketing: (window as never as Record<string, unknown>).owlConsentMarketing ?? null,
120
+ }
121
+ })
122
+
123
+ expect(seen.kinds[0]).toBe('consent:default')
124
+ expect(seen.kinds).toContain('consent:update')
125
+ expect(seen.update).toMatchObject({
126
+ analytics_storage: 'granted', ad_storage: 'denied', security_storage: 'granted',
127
+ })
128
+ // `globalVar` is the seam for a snippet that cannot subscribe — a GTM custom-HTML tag, a
129
+ // hand-placed pixel — so it has to be right by the time the update lands, which is the same
130
+ // inline script and therefore the same tick.
131
+ expect(seen.analytics).toBe(true)
132
+ expect(seen.marketing).toBe(false)
133
+ } finally {
134
+ await close()
135
+ }
136
+ }, TIMEOUT)
137
+
138
+ test('refusing a category denies its signal, and the decision survives a remount', async () => {
139
+ const { page, close } = await mountComponent({ url: `${base}/?categories=custom` })
140
+ try {
141
+ await page.waitForSelector('[data-consent-dialog]')
142
+ // Save without granting the optional category: the essential row is locked on, the other is
143
+ // off by default, so saving as-is IS the refusal.
144
+ await page.locator('[data-consent-save]').click()
145
+ await page.waitForSelector('[data-consent-reopen]')
146
+
147
+ const denied = await page.evaluate(() => {
148
+ const layer = ((window as never as { dataLayer?: unknown[] }).dataLayer ?? [])
149
+ .map(item => Array.from(item as ArrayLike<unknown>))
150
+
151
+ return {
152
+ update: layer.filter(entry => entry[1] === 'update').pop()?.[2] ?? null,
153
+ globalVar: (window as never as Record<string, unknown>).harnessTelemetry ?? null,
154
+ }
155
+ })
156
+ expect(denied.update).toMatchObject({ analytics_storage: 'denied' })
157
+ expect(denied.globalVar).toBe(false)
158
+
159
+ // A decision that does not survive a remount would re-prompt on every page of an SPA.
160
+ await page.locator('#remount').click()
161
+ await page.waitForSelector('[data-consent-reopen]')
162
+ expect(await page.locator('[data-consent-dialog]').count()).toBe(0)
163
+ } finally {
164
+ await close()
165
+ }
166
+ }, TIMEOUT)
167
+
168
+ test('a custom category set is what renders — no silent fallback to the defaults', async () => {
169
+ const { page, close } = await mountComponent({ url: `${base}/?categories=custom` })
170
+ try {
171
+ const dialog = page.locator('[data-consent-dialog]')
172
+ await dialog.waitFor()
173
+ const text = await dialog.innerText()
174
+
175
+ expect(text).toContain('Telemetry')
176
+ // The default set's optional categories must NOT appear: a component that ignored the prop
177
+ // would still render a plausible dialog, and only their absence proves it did not.
178
+ expect(text).not.toContain('Marketing')
179
+ } finally {
180
+ await close()
181
+ }
182
+ }, TIMEOUT)
183
+
184
+ test('the re-open button brings the dialog back with a reason', async () => {
185
+ const { page, close } = await seeded({ essential: true, analytics: false, marketing: false, v: CONSENT_SCHEMA_VERSION })
186
+ try {
187
+ await page.locator('[data-consent-reopen]').click()
188
+ await page.waitForSelector('[data-consent-dialog]')
189
+ // The reason is what lets a host explain WHY it reopened — a login gate reads differently
190
+ // from a footer link, and a dialog that cannot say which is not answerable.
191
+ expect(await page.locator('[data-consent-reason]').count()).toBeGreaterThanOrEqual(0)
192
+ } finally {
193
+ await close()
194
+ }
195
+ }, TIMEOUT)
196
+
197
+ test('every supported locale renders wording, never a bare key', async () => {
198
+ // Seven languages are a shipped contract. A missing bundle does not throw — it renders the
199
+ // key — so the visible string is the only thing worth asserting.
200
+ for (const locale of CONSENT_LOCALES) {
201
+ const { page, close } = await mountComponent({ url: `${base}/?locale=${locale}` })
202
+ try {
203
+ const dialog = page.locator('[data-consent-dialog]')
204
+ await dialog.waitFor()
205
+ const text = await dialog.innerText()
206
+
207
+ expect(text).not.toContain('consent.')
208
+ expect(text.length).toBeGreaterThan(40)
209
+ // And it is genuinely THAT language's wording, not English shown seven times.
210
+ expect(text).toContain(defaultConsentTranslate(locale)('consent.title', ''))
211
+ } finally {
212
+ await close()
213
+ }
214
+ }
215
+ }, TIMEOUT * 3)
216
+ })
217
+
218
+ describe('@owlmeans/web-consent — the policy page', () => {
219
+ test('it names what is actually stored', async () => {
220
+ // A policy that does not name the key, the retention and the categories in force is
221
+ // decoration. This is the page a regulator and a user both read.
222
+ const { page, close } = await mountComponent({ url: `${base}/?view=policy` })
223
+ try {
224
+ const policy = page.locator('[data-cookie-policy]')
225
+ await policy.waitFor()
226
+ const text = await policy.innerText()
227
+
228
+ expect(text).toContain(CONSENT_KEY)
229
+ expect(text).toContain('Acme')
230
+ } finally {
231
+ await close()
232
+ }
233
+ }, TIMEOUT)
234
+
235
+ test('it offers a way back to the decision', async () => {
236
+ const { page, close } = await mountComponent({ url: `${base}/?view=policy` })
237
+ try {
238
+ await page.locator('[data-cookie-policy-manage]').click()
239
+ await page.waitForSelector('[data-consent-dialog]')
240
+ } finally {
241
+ await close()
242
+ }
243
+ }, TIMEOUT)
244
+ })
@@ -0,0 +1,51 @@
1
+ import { createServer } from 'vite'
2
+ import type { Plugin } from 'vite'
3
+ import react from '@vitejs/plugin-react'
4
+ import tailwindcss from '@tailwindcss/vite'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { dirname, resolve } from 'node:path'
7
+ import { consentBootstrapScript } from '@owlmeans/consent'
8
+
9
+ const here = dirname(fileURLToPath(import.meta.url))
10
+
11
+ /**
12
+ * Stamp the real bootstrap snippet into `<head>`, the way every consumer does.
13
+ *
14
+ * A test that hand-wrote the snippet would pass while the shipped emitter was broken, and one that
15
+ * loaded it as a module would defer it — which is precisely the defect the ordering exists to
16
+ * prevent. Injecting the emitter's own output, inline and classic, is what makes the ordering
17
+ * assertions in `bootstrap.spec.ts` mean anything.
18
+ */
19
+ const consentBootstrap = (): Plugin => ({
20
+ name: 'owlmeans-consent-bootstrap',
21
+ transformIndexHtml: html =>
22
+ html.replace('<!--owlmeans:consent-->', `<script>${consentBootstrapScript()}</script>`),
23
+ })
24
+
25
+ let url: string | null = null
26
+
27
+ /**
28
+ * Boot a Vite dev server over `tests/harness/` — a real HTTP origin, because everything under
29
+ * test reads and writes `localStorage` and a document cookie, and neither exists on a `data:` URL.
30
+ *
31
+ * React is deduped so hooks cross the workspace links.
32
+ */
33
+ export const getHarnessUrl = async (): Promise<string> => {
34
+ if (url != null) return url
35
+ const server = await createServer({
36
+ configFile: false,
37
+ root: resolve(here, './harness'),
38
+ plugins: [react(), tailwindcss(), consentBootstrap()],
39
+ resolve: { dedupe: ['react', 'react-dom'] },
40
+ server: { port: 0 },
41
+ logLevel: 'warn'
42
+ })
43
+ await server.listen()
44
+ const local = server.resolvedUrls?.local?.[0]
45
+ if (local == null) throw new Error('vite did not expose a local URL')
46
+ url = local
47
+
48
+ return url
49
+ }
50
+
51
+ export const HARNESS_URL = await getHarnessUrl()
@@ -0,0 +1,22 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>@owlmeans/web-consent</title>
6
+ <!--
7
+ The bootstrap script is injected HERE, above the module below, because that is the only
8
+ placement that makes Consent Mode mean anything: a tag reads the state present when it loads,
9
+ so a decision pushed from a React bundle arrives after everything on the page has acted.
10
+
11
+ It is injected by the harness server's `transformIndexHtml`, exactly as `manager-web`'s vite
12
+ plugin, the template's rollup config and the Astro layout do — so the string under test is
13
+ the one `consentBootstrapScript()` emits, inline and classic, not a copy and not a deferred
14
+ module.
15
+ -->
16
+ <!--owlmeans:consent-->
17
+ </head>
18
+ <body>
19
+ <div id="root"></div>
20
+ <script type="module" src="/mount.tsx"></script>
21
+ </body>
22
+ </html>
@@ -0,0 +1,77 @@
1
+ import type { FC } from 'react'
2
+ import { useState } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { CookieConsent, CookiePolicy, DEFAULT_CONSENT_CATEGORIES } from '../../src/index.js'
5
+ import type { ConsentCategory } from '../../src/index.js'
6
+
7
+ /**
8
+ * The harness renders whatever the query string asks for, so a spec chooses its case by URL and
9
+ * every case runs against one real mount of the shipped components.
10
+ *
11
+ * - `?locale=<lng>` — render the dialog in that language.
12
+ * - `?categories=custom` — a category set that is NOT the default, with its own global var.
13
+ * - `?view=policy` — the cookie-policy page instead of the dialog.
14
+ */
15
+ const params = new URLSearchParams(window.location.search)
16
+
17
+ /**
18
+ * A deliberately non-default set: a different key, a different global var, and a signal list that
19
+ * does not match the built-in one. A component that quietly fell back to the defaults would still
20
+ * render three plausible rows, so the assertions key on THESE names.
21
+ */
22
+ const CUSTOM: ConsentCategory[] = [
23
+ {
24
+ key: 'essential', required: true,
25
+ labelKey: 'consent.essential.label', descriptionKey: 'consent.essential.description',
26
+ },
27
+ {
28
+ key: 'telemetry',
29
+ labelKey: 'consent.telemetry.label', descriptionKey: 'consent.telemetry.description',
30
+ globalVar: 'harnessTelemetry',
31
+ signals: ['analytics_storage'],
32
+ },
33
+ ]
34
+
35
+ const categories = params.get('categories') === 'custom' ? CUSTOM : DEFAULT_CONSENT_CATEGORIES
36
+
37
+ const translate = params.get('categories') === 'custom'
38
+ // Only the custom keys need wording; everything else falls through to the packaged bundle,
39
+ // which is exactly how a consumer with one extra category is expected to wire it.
40
+ ? (key: string, defaultValue: string): string => key === 'consent.telemetry.label'
41
+ ? 'Telemetry'
42
+ : key === 'consent.telemetry.description' ? 'Product telemetry.' : defaultValue
43
+ : undefined
44
+
45
+ const App: FC = () => {
46
+ // Re-mounting on demand proves a decision SURVIVES a mount rather than merely a render — the
47
+ // migration case is meaningless otherwise.
48
+ const [generation, setGeneration] = useState(0)
49
+
50
+ return <div>
51
+ <button id="remount" onClick={() => setGeneration(g => g + 1)}>remount</button>
52
+ <span id="generation">{generation}</span>
53
+ {params.get('view') === 'policy' && <CookiePolicy
54
+ key={`policy-${generation}`}
55
+ locale={params.get('locale') ?? undefined}
56
+ categories={categories}
57
+ operator="Acme"
58
+ privacyHref="https://example.test/privacy"
59
+ termsHref="https://example.test/terms"
60
+ />}
61
+ {/*
62
+ Mounted in EVERY view, including alongside the policy page — that is how an application
63
+ wires it (once, at the root) and it is what makes the policy's "manage preferences" button
64
+ mean anything: the button asks the store to open, and something has to be listening.
65
+ */}
66
+ <CookieConsent
67
+ key={`consent-${generation}`}
68
+ locale={params.get('locale') ?? undefined}
69
+ categories={categories}
70
+ translate={translate}
71
+ policyHref="/cookies"
72
+ links={[{ href: 'https://example.test/privacy', labelKey: 'consent.privacy', defaultLabel: 'Privacy Policy' }]}
73
+ />
74
+ </div>
75
+ }
76
+
77
+ createRoot(document.getElementById('root')!).render(<App />)