diff --git a/docs/content/docs/1.guides/4.global.md b/docs/content/docs/1.guides/4.global.md index 37db8202..07a350a1 100644 --- a/docs/content/docs/1.guides/4.global.md +++ b/docs/content/docs/1.guides/4.global.md @@ -74,6 +74,38 @@ export default defineNuxtConfig({ }) ``` +### Overriding the script per deployment + +Globals also support runtime overrides via `NUXT_PUBLIC_SCRIPTS_GLOBALS_*` env vars. This lets a single build serve multiple deployments with different third-party IDs (e.g. Trusted Shops, Awin, GTM) without rebuilding. + +The env var path mirrors the global's key in `SCREAMING_SNAKE_CASE` (camelCase boundaries become underscores): + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + globals: { + trustedShops: { + src: 'https://widgets.trustedshops.com/build-default.js', + }, + }, + }, +}) +``` + +```bash [.env per deployment] +# Override the src for this deployment only: +NUXT_PUBLIC_SCRIPTS_GLOBALS_TRUSTED_SHOPS_SRC=https://widgets.trustedshops.com/X1234.js +``` + +Any input field (e.g. `src`, `integrity`, `crossorigin`, or your own `data-*` attributes) can be overridden this way. The env value replaces the build-time default at runtime via `runtimeConfig.public.scriptsGlobals`. + +**Not overridable at runtime:** + +- `scriptOptions` (the second tuple slot, e.g. `trigger`, `mode`) and object-form triggers stay baked in at build. +- Asset bundling: the bundle transformer can't statically read `src` through the runtime-config wrapper, so it skips globals that are env-overridable. They load directly from their CDN at runtime. If you need bundling, set `src` statically and skip the env override for that script. + +Typos in env var keys are surfaced as dev warnings with suggestions, e.g. `NUXT_PUBLIC_SCRIPTS_GLOBALS_TRUSTED_SHOP_SRC` → suggest `trustedShops`. + ### Accessing a global script All Nuxt Scripts register on the `$scripts` Nuxt App property. diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index 1290f7b9..62fc14f0 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -563,6 +563,7 @@ export default defineNuxtModule({ scripts, new Set(Object.keys(config.registry || {}).filter(k => (config.registry as any)?.[k] !== false)), logger, + Object.keys(config.globals || {}), ) // Setup runtimeConfig for proxies and devtools. @@ -720,6 +721,35 @@ export default defineNuxtModule({ } } + // Expose globals input via runtimeConfig so it can be overridden per + // deployment via NUXT_PUBLIC_SCRIPTS_GLOBALS__ env vars + // without rebuilding. The codegen reads + Object.assigns these on plugin setup. + // Must run in the main setup body — runtimeConfig is locked in before modules:done. + if (Object.keys(config.globals || {}).length) { + const globalsRuntime: Record> = {} + for (const [k, c] of Object.entries(config.globals || {})) { + let input: Record + if (typeof c === 'string') + input = { src: c } + else if (Array.isArray(c) && c.length === 2) + input = typeof c[0] === 'string' ? { src: c[0] } : { ...c[0] } + else if (typeof c === 'object' && c !== null) + input = { ...(c as Record) } + else + continue + // scriptOptions / object-triggers are build-time only — they can't + // round-trip through env vars and stay baked into the generated plugin. + delete input.trigger + globalsRuntime[k] = input + } + // Top-level `scriptsGlobals` (camelCase, no hyphen) so Nuxt's standard + // env-var override resolves cleanly: NUXT_PUBLIC_SCRIPTS_GLOBALS__. + nuxt.options.runtimeConfig.public.scriptsGlobals = defu( + globalsRuntime, + nuxt.options.runtimeConfig.public.scriptsGlobals as any, + ) as any + } + nuxt.hooks.hook('modules:done', async () => { const registryScripts = [...scripts] diff --git a/packages/script/src/templates.ts b/packages/script/src/templates.ts index 85e8119f..50065b90 100644 --- a/packages/script/src/templates.ts +++ b/packages/script/src/templates.ts @@ -17,17 +17,27 @@ export function registerTypeTemplates({ config, newScripts }: TypeTemplateContex addTypeTemplate({ filename: 'types/nuxt-scripts-augments.d.ts', getContents: () => { + const globalsKeys = Object.keys(config.globals || {}) let augments = `// Generated by @nuxt/scripts declare module '#app' { interface NuxtApp { - $scripts: Record<${[...[...Object.keys(config.globals || {}), ...Object.keys(config.registry || {})].map(k => `'${k}'`), ...['string']].join(' | ')}, import('#nuxt-scripts/types').UseScriptContext | undefined> + $scripts: Record<${[...[...globalsKeys, ...Object.keys(config.registry || {})].map(k => `'${k}'`), ...['string']].join(' | ')}, import('#nuxt-scripts/types').UseScriptContext | undefined> _scripts: Record } interface RuntimeNuxtHooks { 'scripts:updated': (ctx: { scripts: Record }) => void | Promise } } +${globalsKeys.length + ? `declare module '@nuxt/schema' { + interface PublicRuntimeConfig { + scriptsGlobals?: { +${globalsKeys.map(k => ` ${JSON.stringify(k)}?: Record`).join('\n')} + } + } +} ` + : ''}` if (newScripts.length) { augments += ` @@ -142,45 +152,66 @@ export function templatePlugin(config: Partial, registry: Require inits.push(`const ${k} = ${importDefinition.import.name}(${argsJson})`) } } + // Globals input is merged at runtime so `runtimeConfig.public['nuxt-scripts'].globals[]` + // (set via env vars like NUXT_PUBLIC_NUXT_SCRIPTS_GLOBALS__) wins over + // the build-time defaults. scriptOptions/object-triggers stay compile-time. + const hasGlobals = Object.keys(config.globals || {}).length > 0 for (const [k, c] of Object.entries(config.globals || {})) { + let buildInput: Record + let extraOptions: Record | undefined if (typeof c === 'string') { - inits.push(`const ${k} = useScript(${JSON.stringify({ src: c, key: k })}, { use: () => ({ ${k}: window.${k} }) })`) + buildInput = { src: c } } else if (Array.isArray(c) && c.length === 2) { - const options = c[1] - const triggerResolved = resolveTriggerForTemplate(options?.trigger) - if (triggerResolved) { - if (triggerResolved.includes('useScriptTriggerIdleTimeout')) - needsIdleTimeoutImport = true - if (triggerResolved.includes('useScriptTriggerInteraction')) - needsInteractionImport = true - if (triggerResolved.includes('useScriptTriggerServiceWorker')) - needsServiceWorkerImport = true - const resolvedOptions = { ...options, trigger: '__TRIGGER_PLACEHOLDER__' } as any - const optionsJson = JSON.stringify(resolvedOptions).replace(TRIGGER_PLACEHOLDER_RE, triggerResolved) - inits.push(`const ${k} = useScript(${JSON.stringify({ key: k, ...(typeof c[0] === 'string' ? { src: c[0] } : c[0]) })}, { ...${optionsJson}, use: () => ({ ${k}: window.${k} }) })`) - } - else { - inits.push(`const ${k} = useScript(${JSON.stringify({ key: k, ...(typeof c[0] === 'string' ? { src: c[0] } : c[0]) })}, { ...${JSON.stringify(c[1])}, use: () => ({ ${k}: window.${k} }) })`) - } + buildInput = typeof c[0] === 'string' ? { src: c[0] } : { ...(c[0] as any) } + extraOptions = c[1] as any } else if (typeof c === 'object' && c !== null) { - const triggerResolved = resolveTriggerForTemplate((c as any).trigger) - if (triggerResolved) { - if (triggerResolved.includes('useScriptTriggerIdleTimeout')) + buildInput = { ...(c as any) } + } + else { + continue + } + // Object-form triggers in the input bag need a placeholder substitution after JSON.stringify. + const inputTrigger = buildInput.trigger + const inputTriggerResolved = resolveTriggerForTemplate(inputTrigger) + if (inputTriggerResolved) + buildInput.trigger = '__TRIGGER_PLACEHOLDER__' + let buildInputJson = JSON.stringify(buildInput) + if (inputTriggerResolved) + buildInputJson = buildInputJson.replace(TRIGGER_PLACEHOLDER_RE, inputTriggerResolved) + const inputExpr = `Object.assign({ key: ${JSON.stringify(k)} }, ${buildInputJson}, __scriptsGlobals[${JSON.stringify(k)}] || {})` + + // scriptOptions trigger (array form, second slot) — same dance, separate JSON. + let optionsJson = '' + if (extraOptions && Object.keys(extraOptions).length > 0) { + const optsCopy: Record = { ...extraOptions } + const optsTriggerResolved = resolveTriggerForTemplate(optsCopy.trigger) + if (optsTriggerResolved) + optsCopy.trigger = '__TRIGGER_PLACEHOLDER__' + optionsJson = JSON.stringify(optsCopy) + if (optsTriggerResolved) + optionsJson = optionsJson.replace(TRIGGER_PLACEHOLDER_RE, optsTriggerResolved) + if (optsTriggerResolved) { + if (optsTriggerResolved.includes('useScriptTriggerIdleTimeout')) needsIdleTimeoutImport = true - if (triggerResolved.includes('useScriptTriggerInteraction')) + if (optsTriggerResolved.includes('useScriptTriggerInteraction')) needsInteractionImport = true - if (triggerResolved.includes('useScriptTriggerServiceWorker')) + if (optsTriggerResolved.includes('useScriptTriggerServiceWorker')) needsServiceWorkerImport = true - const resolvedOptions = { ...c, trigger: '__TRIGGER_PLACEHOLDER__' } as any - const argsJson = JSON.stringify({ key: k, ...resolvedOptions }).replace(TRIGGER_PLACEHOLDER_RE, triggerResolved) - inits.push(`const ${k} = useScript(${argsJson}, { use: () => ({ ${k}: window.${k} }) })`) - } - else { - inits.push(`const ${k} = useScript(${JSON.stringify({ key: k, ...c })}, { use: () => ({ ${k}: window.${k} }) })`) } } + if (inputTriggerResolved) { + if (inputTriggerResolved.includes('useScriptTriggerIdleTimeout')) + needsIdleTimeoutImport = true + if (inputTriggerResolved.includes('useScriptTriggerInteraction')) + needsInteractionImport = true + if (inputTriggerResolved.includes('useScriptTriggerServiceWorker')) + needsServiceWorkerImport = true + } + const useFn = `use: () => ({ ${k}: window.${k} })` + const optionsArg = optionsJson ? `{ ...${optionsJson}, ${useFn} }` : `{ ${useFn} }` + inits.push(`const ${k} = useScript(${inputExpr}, ${optionsArg})`) } // Add conditional imports for trigger composables const triggerImports = [] @@ -194,9 +225,14 @@ export function templatePlugin(config: Partial, registry: Require triggerImports.push(`import { useScriptTriggerServiceWorker } from '#nuxt-scripts/composables/useScriptTriggerServiceWorker'`) } + const setupBody: string[] = [] + if (hasGlobals) + setupBody.push(` const __scriptsGlobals = useRuntimeConfig().public.scriptsGlobals || {}`) + setupBody.push(...inits.map(i => ` ${i}`)) + setupBody.push(` return { provide: { scripts: { ${[...Object.keys(config.globals || {}), ...resolvedRegistryKeys].join(', ')} } } }`) return [ `import { useScript } from '#nuxt-scripts/composables/useScript'`, - `import { defineNuxtPlugin } from 'nuxt/app'`, + `import { defineNuxtPlugin${hasGlobals ? ', useRuntimeConfig' : ''} } from 'nuxt/app'`, ...triggerImports, ...imports, '', @@ -205,8 +241,7 @@ export function templatePlugin(config: Partial, registry: Require ` env: { islands: false },`, ` parallel: true,`, ` setup() {`, - ...inits.map(i => ` ${i}`), - ` return { provide: { scripts: { ${[...Object.keys(config.globals || {}), ...resolvedRegistryKeys].join(', ')} } } }`, + ...setupBody, ` }`, `})`, ].join('\n') diff --git a/packages/script/src/validate-env.ts b/packages/script/src/validate-env.ts index 798d8427..b42595e0 100644 --- a/packages/script/src/validate-env.ts +++ b/packages/script/src/validate-env.ts @@ -5,6 +5,7 @@ const UPPER_RE = /([A-Z])/g const toScreamingSnake = (s: string) => s.replace(UPPER_RE, '_$1').toUpperCase() const ENV_PREFIX = 'NUXT_PUBLIC_SCRIPTS_' +const GLOBALS_ENV_PREFIX = 'NUXT_PUBLIC_SCRIPTS_GLOBALS_' function levenshtein(a: string, b: string): number { if (a === b) @@ -39,7 +40,14 @@ export function validateScriptsEnvVars( scripts: RegistryScript[], enabledRegistryKeys: Set, logger: ConsolaInstance, + globalsKeys: string[] = [], ): void { + // Configured `scripts.globals` keys — env vars NUXT_PUBLIC_SCRIPTS_GLOBALS__* + // are validated against these (typo detection, suggestions). Globals are + // schemaless so we can't validate the trailing field name. + const validGlobalsByScreaming = new Map() + for (const k of globalsKeys) + validGlobalsByScreaming.set(toScreamingSnake(k), k) // Build a map from screaming-snake registry key to its envDefaults fields const validByKey = new Map }>() for (const s of scripts) { @@ -50,7 +58,7 @@ export function validateScriptsEnvVars( validByKey.set(screaming, { camel: s.registryKey, fields }) } - if (!validByKey.size) + if (!validByKey.size && !validGlobalsByScreaming.size) return const allValidEnvKeys: string[] = [] @@ -62,6 +70,41 @@ export function validateScriptsEnvVars( for (const envKey of Object.keys(process.env)) { if (!envKey.startsWith(ENV_PREFIX)) continue + // Globals env vars (NUXT_PUBLIC_SCRIPTS_GLOBALS_*) target user-defined keys in + // `scripts.globals`. Validate against the configured globals keys with typo + // suggestions; fields can't be checked (globals are schemaless). + if (envKey.startsWith(GLOBALS_ENV_PREFIX)) { + if (!validGlobalsByScreaming.size) + continue + const segment = envKey.slice(GLOBALS_ENV_PREFIX.length) + const segmentParts = segment.split('_') + let matched = false + for (const [screaming] of validGlobalsByScreaming) { + const keyParts = screaming.split('_') + if (segmentParts.length > keyParts.length + && keyParts.every((p, i) => segmentParts[i] === p)) { + matched = true + break + } + } + if (matched) + continue + // No exact prefix match — suggest the closest configured globals key. + let best: { screaming: string, camel: string, dist: number } | undefined + for (const [screaming, camel] of validGlobalsByScreaming) { + const head = segmentParts.slice(0, screaming.split('_').length).join('_') + const d = levenshtein(head, screaming) + if (!best || d < best.dist) + best = { screaming, camel, dist: d } + } + const suggestion = best && best.dist <= Math.max(2, Math.floor(best.screaming.length / 2)) + ? ` Did you mean globals key \`${best.camel}\` (\`${GLOBALS_ENV_PREFIX}${best.screaming}_*\`)?` + : ` Configured globals: ${[...validGlobalsByScreaming.values()].map(k => `\`${k}\``).join(', ')}.` + logger.warn( + `[scripts] env var \`${envKey}\` does not map to any configured \`scripts.globals\` key.${suggestion}`, + ) + continue + } if (allValidEnvKeys.includes(envKey)) continue diff --git a/test/e2e/issue-759-globals-env-override.test.ts b/test/e2e/issue-759-globals-env-override.test.ts new file mode 100644 index 00000000..0016228d --- /dev/null +++ b/test/e2e/issue-759-globals-env-override.test.ts @@ -0,0 +1,25 @@ +import { createResolver } from '@nuxt/kit' +import { $fetch, setup } from '@nuxt/test-utils/e2e' +import { describe, expect, it } from 'vitest' + +const { resolve } = createResolver(import.meta.url) + +// Set env vars BEFORE setup() so Nitro picks them up when it builds the server. +// This proves the single-build / multi-deploy contract for issue #759: +// the same build produces different rendered src values depending on env. +process.env.NUXT_PUBLIC_SCRIPTS_GLOBALS_TRUSTED_SHOPS_SRC = 'https://widgets.trustedshops.com/from-env.js' + +await setup({ + rootDir: resolve('../fixtures/issue-759'), + dev: true, + browser: false, +}) + +describe('issue-759 globals env override', () => { + it('runtimeConfig.public.scriptsGlobals picks up the env-var override', async () => { + const html = await $fetch('/') + // The fixture serializes rc.public.scriptsGlobals into #globals-runtime. + expect(html).toContain('https://widgets.trustedshops.com/from-env.js') + expect(html).not.toContain('build-default.js') + }) +}) diff --git a/test/fixtures/issue-759/app.vue b/test/fixtures/issue-759/app.vue new file mode 100644 index 00000000..3e493cf2 --- /dev/null +++ b/test/fixtures/issue-759/app.vue @@ -0,0 +1,11 @@ + + + diff --git a/test/fixtures/issue-759/nuxt.config.ts b/test/fixtures/issue-759/nuxt.config.ts new file mode 100644 index 00000000..8e487994 --- /dev/null +++ b/test/fixtures/issue-759/nuxt.config.ts @@ -0,0 +1,17 @@ +import { defineNuxtConfig } from 'nuxt/config' + +// Single build, multi-deployment: src is overridable via +// NUXT_PUBLIC_SCRIPTS_GLOBALS_TRUSTED_SHOPS_SRC at server start. +// https://github.com/nuxt/scripts/issues/759 +export default defineNuxtConfig({ + modules: ['@nuxt/scripts'], + scripts: { + globals: { + trustedShops: [ + { src: 'https://widgets.trustedshops.com/build-default.js' }, + { trigger: 'onNuxtReady' }, + ], + }, + }, + compatibilityDate: '2024-07-05', +}) diff --git a/test/fixtures/issue-759/package.json b/test/fixtures/issue-759/package.json new file mode 100644 index 00000000..352055cd --- /dev/null +++ b/test/fixtures/issue-759/package.json @@ -0,0 +1,3 @@ +{ + "private": true +} diff --git a/test/unit/templates.test.ts b/test/unit/templates.test.ts index bf95e7ca..c06a3275 100644 --- a/test/unit/templates.test.ts +++ b/test/unit/templates.test.ts @@ -36,7 +36,7 @@ describe('template plugin file', () => { stripe: 'https://js.stripe.com/v3/', }, }, []) - expect(res).toContain('const stripe = useScript({"src":"https://js.stripe.com/v3/","key":"stripe"}, { use: () => ({ stripe: window.stripe }) })') + expect(res).toContain('const stripe = useScript(Object.assign({ key: "stripe" }, {"src":"https://js.stripe.com/v3/"}, __scriptsGlobals["stripe"] || {}), { use: () => ({ stripe: window.stripe }) })') }) it('object global', async () => { const res = templatePlugin({ @@ -50,7 +50,7 @@ describe('template plugin file', () => { }, }, }, []) - expect(res).toContain('const stripe = useScript({"key":"stripe","async":true,"src":"https://js.stripe.com/v3/","defer":true,"referrerpolicy":"no-referrer"}, { use: () => ({ stripe: window.stripe }) })') + expect(res).toContain('const stripe = useScript(Object.assign({ key: "stripe" }, {"async":true,"src":"https://js.stripe.com/v3/","key":"stripe","defer":true,"referrerpolicy":"no-referrer"}, __scriptsGlobals["stripe"] || {}), { use: () => ({ stripe: window.stripe }) })') }) it('array global', async () => { const res = templatePlugin({ @@ -70,7 +70,7 @@ describe('template plugin file', () => { ], }, }, []) - expect(res).toContain(' const stripe = useScript({"key":"stripe","async":true,"src":"https://js.stripe.com/v3/","defer":true,"referrerpolicy":"no-referrer"}, { ...{"trigger":"onNuxtReady","mode":"client"}, use: () => ({ stripe: window.stripe }) })') + expect(res).toContain('const stripe = useScript(Object.assign({ key: "stripe" }, {"async":true,"src":"https://js.stripe.com/v3/","key":"stripe","defer":true,"referrerpolicy":"no-referrer"}, __scriptsGlobals["stripe"] || {}), { ...{"trigger":"onNuxtReady","mode":"client"}, use: () => ({ stripe: window.stripe }) })') }) it('mixing global', async () => { const res = templatePlugin({ @@ -94,16 +94,17 @@ describe('template plugin file', () => { }, []) expect(res).toMatchInlineSnapshot(` "import { useScript } from '#nuxt-scripts/composables/useScript' - import { defineNuxtPlugin } from 'nuxt/app' + import { defineNuxtPlugin, useRuntimeConfig } from 'nuxt/app' export default defineNuxtPlugin({ name: "scripts:init", env: { islands: false }, parallel: true, setup() { - const stripe1 = useScript({"src":"https://js.stripe.com/v3/","key":"stripe1"}, { use: () => ({ stripe1: window.stripe1 }) }) - const stripe2 = useScript({"key":"stripe","async":true,"src":"https://js.stripe.com/v3/","defer":true,"referrerpolicy":"no-referrer"}, { use: () => ({ stripe2: window.stripe2 }) }) - const stripe3 = useScript({"key":"stripe3","src":"https://js.stripe.com/v3/"}, { ...{"trigger":"onNuxtReady","mode":"client"}, use: () => ({ stripe3: window.stripe3 }) }) + const __scriptsGlobals = useRuntimeConfig().public.scriptsGlobals || {} + const stripe1 = useScript(Object.assign({ key: "stripe1" }, {"src":"https://js.stripe.com/v3/"}, __scriptsGlobals["stripe1"] || {}), { use: () => ({ stripe1: window.stripe1 }) }) + const stripe2 = useScript(Object.assign({ key: "stripe2" }, {"async":true,"src":"https://js.stripe.com/v3/","key":"stripe","defer":true,"referrerpolicy":"no-referrer"}, __scriptsGlobals["stripe2"] || {}), { use: () => ({ stripe2: window.stripe2 }) }) + const stripe3 = useScript(Object.assign({ key: "stripe3" }, {"src":"https://js.stripe.com/v3/"}, __scriptsGlobals["stripe3"] || {}), { ...{"trigger":"onNuxtReady","mode":"client"}, use: () => ({ stripe3: window.stripe3 }) }) return { provide: { scripts: { stripe1, stripe2, stripe3 } } } } })" @@ -269,6 +270,25 @@ describe('template plugin file', () => { expect(res).toContain('import { useScriptTriggerInteraction }') }) + // Env-override merge: runtimeConfig globals[key] is the last arg to Object.assign, + // so any field set via NUXT_PUBLIC_NUXT_SCRIPTS_GLOBALS__ wins over + // the build-time default. This is what makes single-build / multi-deploy work + // (see https://github.com/nuxt/scripts/issues/759). + it('global input is wrapped so runtimeConfig override wins', async () => { + const res = templatePlugin({ + globals: { + trustedShops: { + src: 'https://widgets.trustedshops.com/build-time.js', + }, + }, + }, []) + // useRuntimeConfig is imported and resolved once. + expect(res).toContain('import { defineNuxtPlugin, useRuntimeConfig } from \'nuxt/app\'') + expect(res).toContain('const __scriptsGlobals = useRuntimeConfig().public.scriptsGlobals || {}') + // Override slot is last → wins over the build-time JSON. + expect(res).toContain('Object.assign({ key: "trustedShops" }, {"src":"https://widgets.trustedshops.com/build-time.js"}, __scriptsGlobals["trustedShops"] || {})') + }) + // Test serviceWorker trigger in globals it('global with serviceWorker trigger', async () => { const res = templatePlugin({ diff --git a/test/unit/validate-env.test.ts b/test/unit/validate-env.test.ts index 8f1f6e8a..46adf320 100644 --- a/test/unit/validate-env.test.ts +++ b/test/unit/validate-env.test.ts @@ -85,4 +85,55 @@ describe('validateScriptsEnvVars', () => { validateScriptsEnvVars(scripts, new Set(['matomoAnalytics']), logger) expect(logger.warn).not.toHaveBeenCalled() }) + + describe('globals env vars', () => { + it('does not warn for valid globals env var on configured key', () => { + process.env.NUXT_PUBLIC_SCRIPTS_GLOBALS_TRUSTED_SHOPS_SRC = 'https://x' + removeKeys.push('NUXT_PUBLIC_SCRIPTS_GLOBALS_TRUSTED_SHOPS_SRC') + const logger = makeLogger() + validateScriptsEnvVars(scripts, new Set(), logger, ['trustedShops']) + expect(logger.warn).not.toHaveBeenCalled() + }) + + it('warns and suggests when globals key is typoed', () => { + process.env.NUXT_PUBLIC_SCRIPTS_GLOBALS_TRUSTED_SHOP_SRC = 'https://x' + removeKeys.push('NUXT_PUBLIC_SCRIPTS_GLOBALS_TRUSTED_SHOP_SRC') + const logger = makeLogger() + validateScriptsEnvVars(scripts, new Set(), logger, ['trustedShops']) + expect(logger.warn).toHaveBeenCalledTimes(1) + const msg = (logger.warn as any).mock.calls[0][0] as string + expect(msg).toContain('NUXT_PUBLIC_SCRIPTS_GLOBALS_TRUSTED_SHOP_SRC') + expect(msg).toContain('trustedShops') + }) + + it('lists configured globals when env var is unrecognisable', () => { + process.env.NUXT_PUBLIC_SCRIPTS_GLOBALS_TOTALLY_UNRELATED_SRC = 'https://x' + removeKeys.push('NUXT_PUBLIC_SCRIPTS_GLOBALS_TOTALLY_UNRELATED_SRC') + const logger = makeLogger() + validateScriptsEnvVars(scripts, new Set(), logger, ['awin']) + expect(logger.warn).toHaveBeenCalledTimes(1) + const msg = (logger.warn as any).mock.calls[0][0] as string + expect(msg).toContain('Configured globals') + expect(msg).toContain('awin') + }) + + it('does nothing when no globals are configured', () => { + process.env.NUXT_PUBLIC_SCRIPTS_GLOBALS_ANYTHING_SRC = 'x' + removeKeys.push('NUXT_PUBLIC_SCRIPTS_GLOBALS_ANYTHING_SRC') + const logger = makeLogger() + validateScriptsEnvVars(scripts, new Set(), logger, []) + expect(logger.warn).not.toHaveBeenCalled() + }) + + it('validates globals env vars even when no registry envDefaults exist', () => { + // Regression: early-return on empty `validByKey` must not skip globals validation. + process.env.NUXT_PUBLIC_SCRIPTS_GLOBALS_TYPO_SRC = 'x' + removeKeys.push('NUXT_PUBLIC_SCRIPTS_GLOBALS_TYPO_SRC') + const logger = makeLogger() + validateScriptsEnvVars([], new Set(), logger, ['trustedShops']) + expect(logger.warn).toHaveBeenCalledTimes(1) + const msg = (logger.warn as any).mock.calls[0][0] as string + expect(msg).toContain('NUXT_PUBLIC_SCRIPTS_GLOBALS_TYPO_SRC') + }) + }) })