diff --git a/docs/content/docs/1.guides/3.consent.md b/docs/content/docs/1.guides/3.consent.md index abbde6964..acfef2c92 100644 --- a/docs/content/docs/1.guides/3.consent.md +++ b/docs/content/docs/1.guides/3.consent.md @@ -89,7 +89,7 @@ const consent = useScriptTriggerConsent({ ## Per-script consent API -Every consent-aware `useScriptX()`{lang="ts"} returns a `consent` object typed to the vendor's native API. Combine it with `defaultConsent` for the initial policy (applied in `clientInit` before the vendor fires its first call) and call `consent.*` from your cookie banner to update. +Every consent-aware `useScriptX()`{lang="ts"} returns a `consent` object typed to the vendor's native API. Combine it with `defaultConsent` for the initial policy (applied in `clientInit` before the vendor fires its first call) and call `consent.*` from your cookie banner to update. For GCMv2 scripts (Google Analytics, Google Tag Manager), `consent.default(state)`{lang="ts"} is also available for runtime-derived defaults; both methods validate input against the canonical GCMv2 schema and warn via `consola` on unknown keys or non-`granted`/`denied` values. ```ts const { consent } = useScriptGoogleAnalytics({ @@ -111,8 +111,8 @@ function onAcceptAll() { | Script | `defaultConsent` | Runtime `consent.*` | |---|---|---| -| Google Analytics | `Partial`{lang="html"} (GCMv2) | `consent.update(state)`{lang="ts"} | -| Google Tag Manager | `Partial`{lang="html"} (GCMv2) | `consent.update(state)`{lang="ts"} | +| Google Analytics | `Partial`{lang="html"} (GCMv2) | `consent.default(state)`{lang="ts"} / `consent.update(state)`{lang="ts"} | +| Google Tag Manager | `Partial`{lang="html"} (GCMv2) | `consent.default(state)`{lang="ts"} / `consent.update(state)`{lang="ts"} | | Bing UET | `{ ad_storage }` | `consent.update({ ad_storage })`{lang="ts"} | | Meta Pixel | `'granted' \| 'denied'` | `consent.grant()`{lang="ts"} / `consent.revoke()`{lang="ts"} | | TikTok Pixel | `'granted' \| 'denied' \| 'hold'` | `consent.grant()`{lang="ts"} / `consent.revoke()`{lang="ts"} / `consent.hold()`{lang="ts"} | diff --git a/docs/content/scripts/google-analytics.md b/docs/content/scripts/google-analytics.md index 4568a5d2b..0f89e88dd 100644 --- a/docs/content/scripts/google-analytics.md +++ b/docs/content/scripts/google-analytics.md @@ -32,7 +32,7 @@ The proxy exposes the `gtag` and `dataLayer` properties, and you should use them ## Consent Mode -Google Analytics natively consumes [GCMv2 consent state](https://developers.google.com/tag-platform/security/guides/consent). Set the default with `defaultConsent` (fires `gtag('consent', 'default', state)`{lang="ts"} before `gtag('js', ...)`{lang="ts"}) and call `consent.update()`{lang="ts"} at runtime to flip categories. +Google Analytics natively consumes [GCMv2 consent state](https://developers.google.com/tag-platform/security/guides/consent). Set the default with `defaultConsent` (fires `gtag('consent', 'default', state)`{lang="ts"} before `gtag('js', ...)`{lang="ts"}) and call `consent.update()`{lang="ts"} at runtime to flip categories. For runtime-derived defaults (waiting for region/CMS to resolve before firing), call `consent.default()`{lang="ts"} from the client. ::callout{icon="i-heroicons-play" to="https://stackblitz.com/github/nuxt/scripts/tree/main/examples/regional-consent" target="_blank"} Try the live [Regional Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/regional-consent) on [StackBlitz](https://stackblitz.com). @@ -70,7 +70,7 @@ function savePreferences(choices: { analytics: boolean, marketing: boolean }) { ``` -`consent.update()`{lang="ts"} accepts any `Partial`{lang="ts"}; missing categories stay at their current value. For pre-`gtag('js')`{lang="ts"} setup beyond consent defaults, `onBeforeGtagStart` remains available as a general escape hatch. +`consent.update()`{lang="ts"} and `consent.default()`{lang="ts"} both accept any `Partial`{lang="ts"}; missing categories stay at their current value. Both methods validate input against the canonical GCMv2 schema and warn via `consola` on unknown keys or non-`granted`/`denied` values. For pre-`gtag('js')`{lang="ts"} setup beyond consent defaults, `onBeforeGtagStart` remains available as a general escape hatch. ### Per-region defaults diff --git a/docs/content/scripts/google-tag-manager.md b/docs/content/scripts/google-tag-manager.md index 7e86a1901..4cd706392 100644 --- a/docs/content/scripts/google-tag-manager.md +++ b/docs/content/scripts/google-tag-manager.md @@ -48,7 +48,7 @@ useScriptEventPage(({ title, path }) => { ## Consent Mode -Google Tag Manager natively consumes [GCMv2 consent state](https://developers.google.com/tag-platform/security/guides/consent?consentmode=basic). Set the default with `defaultConsent` (pushes `['consent','default', state]` onto the dataLayer before the `gtm.js` event) and call `consent.update()`{lang="ts"} at runtime. Pass an **array** to `defaultConsent` to fire multiple defaults, for example [region-specific defaults](https://developers.google.com/tag-platform/security/guides/consent?consentmode=advanced#region-specific-behavior) where each entry targets different countries via `region`. +Google Tag Manager natively consumes [GCMv2 consent state](https://developers.google.com/tag-platform/security/guides/consent?consentmode=basic). Set the default with `defaultConsent` (pushes `['consent','default', state]` onto the dataLayer before the `gtm.js` event) and call `consent.update()`{lang="ts"} at runtime. Pass an **array** to `defaultConsent` to fire multiple defaults, for example [region-specific defaults](https://developers.google.com/tag-platform/security/guides/consent?consentmode=advanced#region-specific-behavior) where each entry targets different countries via `region`. For runtime-derived defaults (waiting for region/CMS to resolve before queueing), call `consent.default()`{lang="ts"} from the client. ::callout{icon="i-heroicons-play" to="https://stackblitz.com/github/nuxt/scripts/tree/main/examples/cookie-consent" target="_blank"} Try the live [Cookie Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/cookie-consent), [Granular Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/granular-consent), or [Regional Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/regional-consent) on [StackBlitz](https://stackblitz.com). @@ -133,7 +133,7 @@ useScriptGoogleTagManager({ The module forwards each entry verbatim, in input order. Precedence between region-scoped and unscoped defaults is enforced by gtag at runtime, not by ordering. -`consent.update()`{lang="ts"} accepts any `Partial`{lang="ts"}; missing categories stay at their current value. `onBeforeGtmStart` remains available as a general escape hatch for any other pre-`gtm.start` setup (only when the GTM ID is passed directly to the composable, not via `nuxt.config`). +`consent.update()`{lang="ts"} and `consent.default()`{lang="ts"} both accept any `Partial`{lang="ts"}; missing categories stay at their current value. Both methods validate input against the canonical GCMv2 schema and warn via `consola` on unknown keys or non-`granted`/`denied` values. `onBeforeGtmStart` remains available as a general escape hatch for any other pre-`gtm.start` setup (only when the GTM ID is passed directly to the composable, not via `nuxt.config`). ::script-types :: diff --git a/packages/script/src/runtime/registry/_gcm-consent.ts b/packages/script/src/runtime/registry/_gcm-consent.ts new file mode 100644 index 000000000..15c74b539 --- /dev/null +++ b/packages/script/src/runtime/registry/_gcm-consent.ts @@ -0,0 +1,47 @@ +import type { ConsentState, GcmConsentApi, UseScriptContext } from '../types' +import { safeParse, strictObject } from 'valibot' +import { logger } from '../logger' +import { gcmConsentState } from './schemas' + +export type { GcmConsentApi } + +// Strict variant rebuilt from the lenient schema's entries — same shape, but +// unknown keys produce issues so we can warn on typos without breaking the +// lenient `defaultConsent` schema parse used at build time. +const gcmConsentStateStrict = strictObject(gcmConsentState.entries) + +/** + * GCMv2 consent contract returned by registry scripts (GA, GTM, future Google Ads, …). + * `useRegistryScript` wires the `consent.default/update` API when present. + */ +export interface GcmConsentContract { + /** Forward a `consent`,``, `` call to the script's transport (dataLayer or gtag). */ + push: (proxy: any, action: 'default' | 'update', state: ConsentState) => void +} + +/** Validate a partial GCMv2 consent state. Logs each issue via the registry-scoped logger. */ +export function validateConsentState(log: typeof logger, state: ConsentState, source: string) { + const result = safeParse(gcmConsentStateStrict, state) + if (result.success) + return + for (const issue of result.issues) + log.warn(`${source}: ${issue.message} (path: ${issue.path?.map(p => p.key).join('.') || ''})`) +} + +export function attachGcmConsent( + instance: UseScriptContext, + contract: GcmConsentContract, + registryKey: string, +) { + if (instance.consent) + return + const log = logger.withTag(registryKey) + const push = (action: 'default' | 'update', state: ConsentState) => { + validateConsentState(log, state, `consent.${action}()`) + contract.push(instance.proxy, action, state) + } + instance.consent = { + default: (state: ConsentState) => push('default', state), + update: (state: ConsentState) => push('update', state), + } +} diff --git a/packages/script/src/runtime/registry/google-analytics.ts b/packages/script/src/runtime/registry/google-analytics.ts index c27c707c1..cb62abbfe 100644 --- a/packages/script/src/runtime/registry/google-analytics.ts +++ b/packages/script/src/runtime/registry/google-analytics.ts @@ -1,4 +1,5 @@ import type { ConsentState, RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types' +import type { GcmConsentApi } from './_gcm-consent' import { withQuery } from 'ufo' import { useRegistryScript } from '#nuxt-scripts/utils' import { GoogleAnalyticsOptions } from './schemas' @@ -111,13 +112,11 @@ export { GoogleAnalyticsOptions } export type GoogleAnalyticsInput = RegistryScriptInput -export interface GoogleAnalyticsConsent { - /** Send `gtag('consent','update', state)` with GCMv2 partial state. */ - update: (state: ConsentState) => void -} +/** @deprecated Use {@link GcmConsentApi} from `#nuxt-scripts/types` instead. */ +export type GoogleAnalyticsConsent = GcmConsentApi -export function useScriptGoogleAnalytics(_options?: GoogleAnalyticsInput & { onBeforeGtagStart?: (gtag: GTag) => void }): UseScriptContext { - const instance = useRegistryScript(_options?.key || 'googleAnalytics', (options) => { +export function useScriptGoogleAnalytics(_options?: GoogleAnalyticsInput & { onBeforeGtagStart?: (gtag: GTag) => void }): UseScriptContext { + return useRegistryScript(_options?.key || 'googleAnalytics', (options) => { const dataLayerName = options?.l ?? 'dataLayer' const w = import.meta.client ? window as any : {} return { @@ -133,6 +132,9 @@ export function useScriptGoogleAnalytics(_options? } }, }, + gcmConsent: { + push: (proxy: any, action: 'default' | 'update', state: ConsentState) => (proxy as GoogleAnalyticsApi).gtag('consent', action, state as ConsentOptions), + }, clientInit: import.meta.server ? undefined : () => { @@ -157,14 +159,5 @@ export function useScriptGoogleAnalytics(_options? } }, } - }, _options) as UseScriptContext - - if (import.meta.client && !instance.consent) { - instance.consent = { - update: (state: ConsentState) => { - ;(instance.proxy as unknown as GoogleAnalyticsApi).gtag('consent', 'update', state as ConsentOptions) - }, - } - } - return instance + }, _options) as UseScriptContext } diff --git a/packages/script/src/runtime/registry/google-tag-manager.ts b/packages/script/src/runtime/registry/google-tag-manager.ts index 0d0af16d8..da7c20497 100644 --- a/packages/script/src/runtime/registry/google-tag-manager.ts +++ b/packages/script/src/runtime/registry/google-tag-manager.ts @@ -1,4 +1,5 @@ import type { ConsentState, NuxtUseScriptOptions, RegistryScriptInput, UseFunctionType, UseScriptContext } from '#nuxt-scripts/types' +import type { GcmConsentApi } from './_gcm-consent' import type { GTag } from './google-analytics' import { withQuery } from 'ufo' import { useRegistryScript } from '#nuxt-scripts/utils' @@ -80,10 +81,8 @@ export { GoogleTagManagerOptions } export type GoogleTagManagerInput = RegistryScriptInput -export interface GoogleTagManagerConsent { - /** Send `gtag('consent','update', state)` so the dataLayer receives consent command (GCMv2 partial state). */ - update: (state: ConsentState) => void -} +/** @deprecated Use {@link GcmConsentApi} from `#nuxt-scripts/types` instead. */ +export type GoogleTagManagerConsent = GcmConsentApi /** * Hook to use Google Tag Manager in Nuxt applications @@ -96,10 +95,8 @@ export function useScriptGoogleTagManager( */ onBeforeGtmStart?: (gtag: DataLayerPush) => void }, -): UseScriptContext, T>, GoogleTagManagerConsent> { - const consentDataLayerName = options?.l ?? options?.dataLayer ?? 'dataLayer' - - const instance = useRegistryScript( +): UseScriptContext, T>, GcmConsentApi> { + return useRegistryScript( options?.key || 'googleTagManager', (opts) => { const dataLayerName = opts?.l ?? opts?.dataLayer ?? 'dataLayer' @@ -128,6 +125,17 @@ export function useScriptGoogleTagManager( } }, }, + gcmConsent: { + // Match the gtag.js contract: enqueue an `Arguments` object, not a plain Array, + // so GTM/Tag Assistant/Analytics Debugger recognise the consent command. See #770/#771. + push: (_proxy: any, action: 'default' | 'update', state: ConsentState) => { + const dl = (window as any)[dataLayerName] = (window as any)[dataLayerName] || [] + ;(function (..._args: any[]) { + // eslint-disable-next-line prefer-rest-params + dl.push(arguments) + })('consent', action, state) + }, + }, clientInit: import.meta.server ? undefined : () => { @@ -168,30 +176,5 @@ export function useScriptGoogleTagManager( } }, options, - ) - - // Handle callback for cached/pre-initialized scripts (e.g., when ID is in nuxt.config) - if (import.meta.client && options?.onBeforeGtmStart) { - const gtag = (window as any).gtag - if (gtag) - options.onBeforeGtmStart(gtag) - } - - const typed = instance as UseScriptContext, T>, GoogleTagManagerConsent> - if (import.meta.client && !typed.consent) { - typed.consent = { - update: (state: ConsentState) => { - const dl = (window as any)[consentDataLayerName] = (window as any)[consentDataLayerName] || [] - // Must push the real `arguments` object — not a - // spread array — so GTM processes consent and - // other commands like the official snippet - ;(function (..._args: any[]) { - // Rest params satisfy TypeScript call sites; gtm expects `arguments` on the queue. - // eslint-disable-next-line prefer-rest-params - dl.push(arguments) - })('consent', 'update', state) - }, - } - } - return typed + ) as UseScriptContext, T>, GcmConsentApi> } diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index 65378d7e0..64b7a5c48 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -3,8 +3,11 @@ import { any, array, boolean, custom, literal, minLength, number, object, option // Shared GCMv2 consent category value. const consentCategoryValue = union([literal('granted'), literal('denied')]) -// Shared GCMv2 consent state (+ GA-only control fields). -const gcmConsentState = object({ +// Shared GCMv2 consent state (+ GA-only control fields). Lenient at schema-parse +// time (extra keys pass through); the runtime `consent.*` API rebuilds a strict +// variant from these entries so typos surface as a `consola` warning without +// breaking the dev schema check. +export const gcmConsentState = object({ ad_storage: optional(consentCategoryValue), ad_user_data: optional(consentCategoryValue), ad_personalization: optional(consentCategoryValue), diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 147e750f0..bbbaf1af6 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -80,6 +80,17 @@ export interface ConsentState { wait_for_update?: number } +/** + * Auto-attached `consent` API on scripts that adhere to the GCMv2 Consent Mode + * contract (Google Analytics, Google Tag Manager, …). + */ +export interface GcmConsentApi { + /** Push `['consent','default', state]` (or equivalent gtag call) with GCMv2 partial state. */ + default: (state: ConsentState) => void + /** Push `['consent','update', state]` (or equivalent gtag call) with GCMv2 partial state. */ + update: (state: ConsentState) => void +} + export type UseScriptContext, C = unknown> = VueScriptInstance & { /** * Remove and reload the script. Useful for scripts that need to re-execute diff --git a/packages/script/src/runtime/utils.ts b/packages/script/src/runtime/utils.ts index 95ed48565..c2073c3ba 100644 --- a/packages/script/src/runtime/utils.ts +++ b/packages/script/src/runtime/utils.ts @@ -9,12 +9,14 @@ import type { UseFunctionType, UseScriptContext, } from '#nuxt-scripts/types' +import type { GcmConsentContract } from './registry/_gcm-consent' import { defu } from 'defu' import { createError, useRuntimeConfig } from 'nuxt/app' import { parseQuery, parseURL, withQuery } from 'ufo' import { parse } from 'valibot' import { useScript } from './composables/useScript' import { createNpmScriptStub } from './npm-script-stub' +import { attachGcmConsent } from './registry/_gcm-consent' // Dev-only: stack trace parsing for component location detection (only referenced inside import.meta.dev) const URL_MATCH_RE = /https?:\/\/[^/]+\/_nuxt\/(.+\.vue)(?:\?[^)]*)?:(\d+):(\d+)/ @@ -42,6 +44,11 @@ type OptionsFn = (options: InferIfSchema, ctx: { scriptInput?: UseScriptIn schema?: O extends ObjectSchema | UnionSchema ? O : undefined clientInit?: () => void | Promise scriptMode?: 'external' | 'npm' // NEW: external = CDN script (default), npm = NPM package only + /** + * Opt-in: this script consumes GCMv2 Consent Mode. `useRegistryScript` auto-attaches + * a `consent: { default, update }` API + dev validation against the canonical schema. + */ + gcmConsent?: GcmConsentContract }) export function scriptRuntimeConfig(key: T) { @@ -173,5 +180,10 @@ export function useRegistryScript, O = Em options.clientInit?.() } } - return useScript(scriptInput, scriptOptions as NuxtUseScriptOptions) + const instance = useScript(scriptInput, scriptOptions as NuxtUseScriptOptions) + + if (import.meta.client && options.gcmConsent) + attachGcmConsent(instance as any, options.gcmConsent, String(registryKey)) + + return instance } diff --git a/test/nuxt-runtime/consent-default.nuxt.test.ts b/test/nuxt-runtime/consent-default.nuxt.test.ts index 77ca414cc..8f2c7916a 100644 --- a/test/nuxt-runtime/consent-default.nuxt.test.ts +++ b/test/nuxt-runtime/consent-default.nuxt.test.ts @@ -18,30 +18,27 @@ vi.mock('posthog-js', () => ({ // Force useRegistryScript to a pass-through so we can call `clientInit` directly // instead of going through the real beforeInit wrapping (which requires the full // Nuxt + useScript plumbing). -vi.mock('#nuxt-scripts/utils', () => ({ - useRegistryScript: (_key: string, optionsFn: any, userOptions?: any) => { +async function buildMockUseRegistryScript() { + const { attachGcmConsent } = await import('../../packages/script/src/runtime/registry/_gcm-consent') + return (key: string, optionsFn: any, userOptions?: any) => { const opts = optionsFn(userOptions || {}, { scriptInput: userOptions?.scriptInput }) const instance: any = { _opts: opts } - // Lazy proxy so `use()` runs AFTER clientInit populates window.fbq/ttq/etc. Object.defineProperty(instance, 'proxy', { configurable: true, get() { return opts.scriptOptions?.use?.() }, }) + if (opts.gcmConsent) + attachGcmConsent(instance, opts.gcmConsent, key) return instance - }, + } +} + +vi.mock('#nuxt-scripts/utils', async () => ({ + useRegistryScript: await buildMockUseRegistryScript(), })) -vi.mock('../../packages/script/src/runtime/utils', () => ({ - useRegistryScript: (_key: string, optionsFn: any, userOptions?: any) => { - const opts = optionsFn(userOptions || {}, { scriptInput: userOptions?.scriptInput }) - const instance: any = { _opts: opts } - // Lazy proxy so `use()` runs AFTER clientInit populates window.fbq/ttq/etc. - Object.defineProperty(instance, 'proxy', { - configurable: true, - get() { return opts.scriptOptions?.use?.() }, - }) - return instance - }, +vi.mock('../../packages/script/src/runtime/utils', async () => ({ + useRegistryScript: await buildMockUseRegistryScript(), scriptRuntimeConfig: () => ({}), scriptsPrefix: () => '/_scripts', requireRegistryEndpoint: () => {}, @@ -63,6 +60,19 @@ function gtmConsentCommandParts(e: any): [string, string, any?] | null { return [row[0] as string, row[1] as string, row[2]] } +// Stub the runtime logger so consent debug/warn calls land on plain spies +// (consola's `withTag` returns a new instance, so we keep `withTag` returning self). +vi.mock('../../packages/script/src/runtime/logger', () => { + const log: any = { + warn: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + error: vi.fn(), + withTag: () => log, + } + return { logger: log } +}) + describe('consent defaults — clientInit ordering', () => { beforeEach(() => { delete (window as any).dataLayer @@ -392,6 +402,66 @@ describe('per-script consent object', () => { expect(gtmConsentCommandParts(entry)).toEqual(['consent', 'update', { analytics_storage: 'granted' }]) }) + it('gtm: consent.default() queues Arguments(consent, default, state) to dataLayer', async () => { + ;(window as any).dataLayer = [] + const { useScriptGoogleTagManager } = await import('../../packages/script/src/runtime/registry/google-tag-manager') + const result: any = useScriptGoogleTagManager({ id: 'GTM-XXXX' }) + result._opts.clientInit() + result.consent.default({ analytics_storage: 'denied' }) + const dl = (window as any).dataLayer as any[] + const entry = dl.find(e => gtmConsentCommandParts(e)?.[1] === 'default') + expect(entry).toBeDefined() + expect(Array.isArray(entry)).toBe(false) + expect(gtmConsentCommandParts(entry)).toEqual(['consent', 'default', { analytics_storage: 'denied' }]) + }) + + it('ga: consent.default() pushes gtag consent default via dataLayer', async () => { + ;(window as any).dataLayer = [] + const { useScriptGoogleAnalytics } = await import('../../packages/script/src/runtime/registry/google-analytics') + const result: any = useScriptGoogleAnalytics({ id: 'G-XXXX' }) + result._opts.clientInit() + result.consent.default({ ad_storage: 'denied' }) + const dl = (window as any).dataLayer as any[] + const defaultArgs = dl.find(e => e[0] === 'consent' && e[1] === 'default' && e[2]?.ad_storage) + expect(defaultArgs?.[2]).toEqual({ ad_storage: 'denied' }) + }) + + it('gtm: validateConsentState warns on unknown GCMv2 keys in consent.default()', async () => { + ;(window as any).dataLayer = [] + const { logger } = await import('../../packages/script/src/runtime/logger') as any + logger.warn.mockClear() + const { useScriptGoogleTagManager } = await import('../../packages/script/src/runtime/registry/google-tag-manager') + const result: any = useScriptGoogleTagManager({ id: 'GTM-XXXX' }) + result._opts.clientInit() + result.consent.default({ analytics_storages: 'denied' } as any) + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('analytics_storages')) + }) + + it('gtm: validateConsentState warns on unknown GCMv2 keys in consent.update()', async () => { + ;(window as any).dataLayer = [] + const { logger } = await import('../../packages/script/src/runtime/logger') as any + logger.warn.mockClear() + const { useScriptGoogleTagManager } = await import('../../packages/script/src/runtime/registry/google-tag-manager') + const result: any = useScriptGoogleTagManager({ + id: 'GTM-XXXX', + defaultConsent: { analytics_storage: 'denied' }, + }) + result._opts.clientInit() + result.consent.update({ ad_storag: 'granted' } as any) + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('ad_storag')) + }) + + it('gtm: validateConsentState warns on bad consent values (e.g. "allow") in consent.update()', async () => { + ;(window as any).dataLayer = [] + const { logger } = await import('../../packages/script/src/runtime/logger') as any + logger.warn.mockClear() + const { useScriptGoogleTagManager } = await import('../../packages/script/src/runtime/registry/google-tag-manager') + const result: any = useScriptGoogleTagManager({ id: 'GTM-XXXX' }) + result._opts.clientInit() + result.consent.update({ ad_storage: 'allow' } as any) + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('ad_storage')) + }) + it('meta: consent.grant()/revoke() queue fbq(\'consent\', ...) calls', async () => { const { useScriptMetaPixel } = await import('../../packages/script/src/runtime/registry/meta-pixel') const result: any = useScriptMetaPixel({ id: '123' })