From 1fb629a7eba683833554eeb166a36c1a3757ac31 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 30 Sep 2026 19:49:19 -0500 Subject: [PATCH 1/2] Fix shared context and logging across browser bundles --- CHANGELOG.md | 1 + crates/trusted-server-js/lib/build-all.mjs | 27 +-- .../lib/build-module-options.mjs | 32 ++++ .../trusted-server-js/lib/src/core/context.ts | 14 +- crates/trusted-server-js/lib/src/core/log.ts | 23 ++- .../lib/src/integrations/creative/click.ts | 3 - .../lib/test/core/config.test.ts | 15 +- .../lib/test/core/context.test.ts | 27 ++- .../lib/test/core/log.test.ts | 57 ++++++ .../iife-state-artifact-integration.test.mjs | 173 ++++++++++++++++++ .../test/prebid-artifact-integration.test.mjs | 29 +-- 11 files changed, 334 insertions(+), 67 deletions(-) create mode 100644 crates/trusted-server-js/lib/build-module-options.mjs create mode 100644 crates/trusted-server-js/lib/test/core/log.test.ts create mode 100644 crates/trusted-server-js/lib/test/iife-state-artifact-integration.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index bcb3d7acb..5ffa2faae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Browser integration bundles now share context providers and the configured log level with TSJS core (#1196). With Permutive enabled and cohorts available, `tsjs.requestAds()` now sends `config.permutive_segments` to `/auction`; the server retains that key only when `auction.allowed_context_keys` includes `"permutive_segments"`. This restores a previously inactive data flow, not a consent gate: review publisher consent handling before relying on it. Integration logs now follow `tsjs.setConfig` and `tsjs.log.setLevel`, and creative no longer raises the default `warn` level to `info`; explicit `tsdebug` behavior remains available. - TSJS-generated envelopes now send `trustedServer.params.storedRequest: false`, preventing accidental PBS stored lookups without suppressing eligible non-PBS demand. PBS filters unusable impressions after overrides; explicit `true` and omission in valid envelopes retain inline-first stored fallback. A malformed envelope disables stored fallback for the entire slot, including independent direct demand left unusable after overrides. Publisher intent survives repeated and refresh auctions. Deploy compatible server admission everywhere before serving the new JS, and retain it during rollback while cached clients remain. See the Prebid deployment guide. - Protocol-relative creative URLs now honor `rewrite.exclude_domains`, so excluded creative assets stay direct and excluded absolute or protocol-relative URLs submitted to `/first-party/sign` are rejected. - Server-side ad template bids now always carry `hb_adid` in `window.tsjs.bids`. Bidders that return neither a Prebid Cache UUID nor an `adid` previously produced no `hb_adid` at all, so no `hb_adid` GPT targeting key was set and the Universal Creative render bridge had nothing to match — the winning creative never rendered. The OpenRTB bid `id`, which is mandatory per spec, is now the last-resort source; `cache_id` and `adid` still take priority where present. Blank `cacheId`/`adid` values no longer win that precedence and emit an unusable empty `hb_adid`, and `hb_cache_host`/`hb_cache_path` are now emitted only alongside a real Prebid Cache UUID — without one they pointed the Universal Creative at a guaranteed cache miss instead of letting it fall through to the inline creative. diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index 2bfee01b1..f973e5737 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -19,6 +19,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { build } from 'vite'; +import { moduleBuildOptions } from './build-module-options.mjs'; + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const srcDir = path.resolve(__dirname, 'src'); const distDir = path.resolve(__dirname, '..', 'dist'); @@ -48,30 +50,7 @@ async function buildModule(name, entryPath) { const outFile = `tsjs-${name}.js`; console.log(`[build-all] Building ${outFile} from ${path.relative(__dirname, entryPath)}`); - await build({ - configFile: false, - root: __dirname, - build: { - emptyOutDir: false, - outDir: distDir, - assetsDir: '.', - sourcemap: false, - minify: 'esbuild', - rollupOptions: { - input: entryPath, - output: { - format: 'iife', - dir: distDir, - entryFileNames: outFile, - inlineDynamicImports: true, - extend: false, - // Use a unique IIFE name per module to avoid conflicts - name: name === 'core' ? 'tsjs' : `tsjs_${name}`, - }, - }, - }, - logLevel: 'warn', - }); + await build(moduleBuildOptions({ name, entryPath, outDir: distDir })); console.log(`[build-all] Built ${outFile}`); } diff --git a/crates/trusted-server-js/lib/build-module-options.mjs b/crates/trusted-server-js/lib/build-module-options.mjs new file mode 100644 index 000000000..64b78a3fb --- /dev/null +++ b/crates/trusted-server-js/lib/build-module-options.mjs @@ -0,0 +1,32 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const libDir = path.dirname(fileURLToPath(import.meta.url)); + +/** Production Vite options for one independently bundled tsjs IIFE. */ +export function moduleBuildOptions({ name, entryPath, outDir }) { + return { + configFile: false, + root: libDir, + build: { + emptyOutDir: false, + outDir, + assetsDir: '.', + sourcemap: false, + minify: 'esbuild', + rollupOptions: { + input: entryPath, + output: { + format: 'iife', + dir: outDir, + entryFileNames: `tsjs-${name}.js`, + inlineDynamicImports: true, + extend: false, + // Use a unique IIFE name per module to avoid conflicts. + name: name === 'core' ? 'tsjs' : `tsjs_${name}`, + }, + }, + }, + logLevel: 'warn', + }; +} diff --git a/crates/trusted-server-js/lib/src/core/context.ts b/crates/trusted-server-js/lib/src/core/context.ts index 9ee4ddff5..23ee0895d 100644 --- a/crates/trusted-server-js/lib/src/core/context.ts +++ b/crates/trusted-server-js/lib/src/core/context.ts @@ -8,7 +8,16 @@ import { log } from './log'; */ export type ContextProvider = () => Record | undefined; -const providers = new Map(); +// Independently built IIFEs must register and collect from the same map. +// Resolve it on each call rather than capturing a bundle-local registry. +const CONTEXT_PROVIDERS_KEY = Symbol.for('trusted-server.contextProviders'); + +function getProviders(): Map { + const sharedGlobal = globalThis as typeof globalThis & { + [CONTEXT_PROVIDERS_KEY]?: Map; + }; + return (sharedGlobal[CONTEXT_PROVIDERS_KEY] ??= new Map()); +} /** * Register a context provider that will be called before every auction request. @@ -19,6 +28,7 @@ const providers = new Map(); * duplicate accumulation in SPA environments. */ export function registerContextProvider(id: string, provider: ContextProvider): void { + const providers = getProviders(); providers.set(id, provider); log.debug('context: registered provider', { id, total: providers.size }); } @@ -32,7 +42,7 @@ export function registerContextProvider(id: string, provider: ContextProvider): */ export function collectContext(): Record { const context: Record = {}; - for (const provider of providers.values()) { + for (const provider of getProviders().values()) { try { const data = provider(); if (data) Object.assign(context, data); diff --git a/crates/trusted-server-js/lib/src/core/log.ts b/crates/trusted-server-js/lib/src/core/log.ts index b750430c6..336cdf571 100644 --- a/crates/trusted-server-js/lib/src/core/log.ts +++ b/crates/trusted-server-js/lib/src/core/log.ts @@ -2,7 +2,14 @@ export type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug'; const LEVELS: Record = { silent: -1, error: 0, warn: 1, info: 2, debug: 3 }; -let currentLevel: LogLevel = 'warn'; +// Each IIFE carries its own logger code, but all copies share one level. +const LOG_LEVEL_KEY = Symbol.for('trusted-server.logLevel'); + +type LogGlobal = typeof globalThis & { [LOG_LEVEL_KEY]?: LogLevel }; + +function getLevel(): LogLevel { + return ((globalThis as LogGlobal)[LOG_LEVEL_KEY] ??= 'warn'); +} function levelNum(l: LogLevel) { return LEVELS[l] ?? 1; @@ -50,21 +57,19 @@ function print(method: 'log' | 'info' | 'warn' | 'error', ...args: unknown[]) { // Thin wrapper around console that keeps timestamped output and honours a runtime log level. export const log = { setLevel(l: LogLevel) { - currentLevel = l; - }, - getLevel(): LogLevel { - return currentLevel; + (globalThis as LogGlobal)[LOG_LEVEL_KEY] = l; }, + getLevel, info: (...a: unknown[]) => { - if (levelNum(currentLevel) >= LEVELS.info) print('info', ...a); + if (levelNum(getLevel()) >= LEVELS.info) print('info', ...a); }, warn: (...a: unknown[]) => { - if (levelNum(currentLevel) >= LEVELS.warn) print('warn', ...a); + if (levelNum(getLevel()) >= LEVELS.warn) print('warn', ...a); }, error: (...a: unknown[]) => { - if (levelNum(currentLevel) >= LEVELS.error) print('error', ...a); + if (levelNum(getLevel()) >= LEVELS.error) print('error', ...a); }, debug: (...a: unknown[]) => { - if (levelNum(currentLevel) >= LEVELS.debug) print('log', ...a); + if (levelNum(getLevel()) >= LEVELS.debug) print('log', ...a); }, }; diff --git a/crates/trusted-server-js/lib/src/integrations/creative/click.ts b/crates/trusted-server-js/lib/src/integrations/creative/click.ts index 38d6b982e..8a9cbee0f 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/click.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/click.ts @@ -489,9 +489,6 @@ function monitorAnchorMutations(): void { // Wire up capture-phase click handlers + mutation observers to protect clicks. export function installClickGuard(): void { - if (log.getLevel && log.getLevel() === 'warn') { - log.setLevel('info'); - } enableDebugFromEnv(); log.info('tsjs-creative:click: installing click guard'); diff --git a/crates/trusted-server-js/lib/test/core/config.test.ts b/crates/trusted-server-js/lib/test/core/config.test.ts index 6abacdf54..3d303ac2f 100644 --- a/crates/trusted-server-js/lib/test/core/config.test.ts +++ b/crates/trusted-server-js/lib/test/core/config.test.ts @@ -1,9 +1,16 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const LOG_LEVEL_KEY = Symbol.for('trusted-server.logLevel'); +const sharedGlobal = globalThis as typeof globalThis & { [LOG_LEVEL_KEY]?: unknown }; describe('config', () => { - beforeEach(async () => { - // reset module state between tests - await vi.resetModules(); + beforeEach(() => { + delete sharedGlobal[LOG_LEVEL_KEY]; + vi.resetModules(); + }); + + afterEach(() => { + delete sharedGlobal[LOG_LEVEL_KEY]; }); it('sets and gets config, controls log level', async () => { diff --git a/crates/trusted-server-js/lib/test/core/context.test.ts b/crates/trusted-server-js/lib/test/core/context.test.ts index 74854837e..97e4f34e5 100644 --- a/crates/trusted-server-js/lib/test/core/context.test.ts +++ b/crates/trusted-server-js/lib/test/core/context.test.ts @@ -1,8 +1,18 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const CONTEXT_PROVIDERS_KEY = Symbol.for('trusted-server.contextProviders'); +const sharedGlobal = globalThis as typeof globalThis & { + [CONTEXT_PROVIDERS_KEY]?: unknown; +}; describe('context provider registry', () => { - beforeEach(async () => { - await vi.resetModules(); + beforeEach(() => { + delete sharedGlobal[CONTEXT_PROVIDERS_KEY]; + vi.resetModules(); + }); + + afterEach(() => { + delete sharedGlobal[CONTEXT_PROVIDERS_KEY]; }); it('returns empty context when no providers registered', async () => { @@ -46,6 +56,17 @@ describe('context provider registry', () => { expect(collectContext()).toEqual({ survived: true }); }); + it('shares providers between separately loaded module instances', async () => { + const first = await import('../../src/core/context'); + first.registerContextProvider('first', () => ({ a: 1 })); + + vi.resetModules(); + const second = await import('../../src/core/context'); + expect(second.collectContext()).toEqual({ a: 1 }); + second.registerContextProvider('second', () => ({ b: 2 })); + expect(first.collectContext()).toEqual({ a: 1, b: 2 }); + }); + it('re-registration with same id replaces previous provider', async () => { const { registerContextProvider, collectContext } = await import('../../src/core/context'); registerContextProvider('dup', () => ({ v: 1 })); diff --git a/crates/trusted-server-js/lib/test/core/log.test.ts b/crates/trusted-server-js/lib/test/core/log.test.ts new file mode 100644 index 000000000..64f11ae66 --- /dev/null +++ b/crates/trusted-server-js/lib/test/core/log.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const LOG_LEVEL_KEY = Symbol.for('trusted-server.logLevel'); +const sharedGlobal = globalThis as typeof globalThis & { + [LOG_LEVEL_KEY]?: unknown; +}; + +describe('shared logger', () => { + beforeEach(() => { + delete sharedGlobal[LOG_LEVEL_KEY]; + vi.resetModules(); + }); + + afterEach(() => { + delete sharedGlobal[LOG_LEVEL_KEY]; + vi.restoreAllMocks(); + }); + + it('defaults to warn', async () => { + const { log } = await import('../../src/core/log'); + expect(log.getLevel()).toBe('warn'); + }); + + it('shares level changes between separately loaded module instances', async () => { + const first = await import('../../src/core/log'); + first.log.setLevel('debug'); + + vi.resetModules(); + const second = await import('../../src/core/log'); + expect(second.log.getLevel()).toBe('debug'); + second.log.setLevel('silent'); + expect(first.log.getLevel()).toBe('silent'); + }); + + it.each([ + ['silent', []], + ['error', ['error']], + ['warn', ['error', 'warn']], + ['info', ['error', 'warn', 'info']], + ['debug', ['error', 'warn', 'info', 'debug']], + ] as const)('gates all console methods at %s', async (level, enabled) => { + const spies = { + error: vi.spyOn(console, 'error').mockImplementation(() => {}), + warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), + info: vi.spyOn(console, 'info').mockImplementation(() => {}), + debug: vi.spyOn(console, 'log').mockImplementation(() => {}), + }; + const { log } = await import('../../src/core/log'); + log.setLevel(level); + for (const method of ['error', 'warn', 'info', 'debug'] as const) { + log[method]('example'); + expect(spies[method]).toHaveBeenCalledTimes( + enabled.some((value) => value === method) ? 1 : 0 + ); + } + }); +}); diff --git a/crates/trusted-server-js/lib/test/iife-state-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/iife-state-artifact-integration.test.mjs new file mode 100644 index 000000000..7da3d28dc --- /dev/null +++ b/crates/trusted-server-js/lib/test/iife-state-artifact-integration.test.mjs @@ -0,0 +1,173 @@ +// @vitest-environment node + +// Unit tests share one module graph. These production IIFEs must share state +// even though each bundle contains its own copy of the context and log modules. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { JSDOM } from 'jsdom'; +import { build } from 'vite'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { moduleBuildOptions } from '../build-module-options.mjs'; + +const libDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const bundles = new Map(); +let outputDirectory; +let dom; + +beforeAll(async () => { + outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-server-iife-state-')); + for (const name of ['core', 'creative', 'permutive']) { + const entryPath = + name === 'core' + ? path.join(libDir, 'src', 'core', 'index.ts') + : path.join(libDir, 'src', 'integrations', name, 'index.ts'); + await build(moduleBuildOptions({ name, entryPath, outDir: outputDirectory })); + bundles.set(name, fs.readFileSync(path.join(outputDirectory, `tsjs-${name}.js`), 'utf8')); + } +}, 60_000); + +afterEach(() => { + dom?.window.close(); +}); + +afterAll(() => { + if (outputDirectory) fs.rmSync(outputDirectory, { recursive: true, force: true }); +}); + +function createPage({ modules = ['core', 'creative', 'permutive'], logLevel, debug = false } = {}) { + dom = new JSDOM('
', { + url: `https://publisher.example.com/${debug ? '?tsdebug=1' : ''}`, + runScripts: 'outside-only', + }); + const { window } = dom; + // Avoid SDK polling and external requests. Script elements are not fetched + // with outside-only execution and no resource loader. + window.permutive = { config: {} }; + window.localStorage.setItem( + 'permutive-app', + JSON.stringify({ core: { cohorts: { all: ['111', '222'] } } }) + ); + const info = vi.fn(); + const debugLog = vi.fn(); + window.console.info = info; + window.console.log = debugLog; + window.fetch = vi.fn(async () => ({ + ok: true, + headers: { get: () => 'application/json' }, + json: async () => ({ seatbid: [] }), + })); + if (logLevel) { + window.tsjs = { que: [() => window.tsjs.setConfig({ logLevel })] }; + } + // Match bundle.rs: core first and independent IIFEs joined into one script. + window.eval(modules.map((name) => bundles.get(name)).join(';\n')); + return { window, info, debugLog }; +} + +function insertPermutiveScript(window) { + const script = window.document.createElement('script'); + script.src = 'https://cdn.permutive.com/example-web.js'; + window.document.head.appendChild(script); + expect(script.src).toBe('https://publisher.example.com/integrations/permutive/sdk'); +} + +function expectPermutiveInfo(window, info, enabled) { + info.mockClear(); + insertPermutiveScript(window); + const lines = info.mock.calls.filter((args) => + args.some((arg) => String(arg).includes('Permutive guard: rewriting')) + ); + expect(lines).toHaveLength(enabled ? 1 : 0); +} + +function requestContext(window) { + window.tsjs.addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } }); + window.tsjs.requestAds(); + expect(window.fetch).toHaveBeenCalledTimes(1); + const [url, init] = window.fetch.mock.calls[0]; + expect(url).toBe('/auction'); + expect(init.method).toBe('POST'); + return JSON.parse(init.body).config; +} + +describe('shared state across production IIFEs', () => { + it('sends Permutive context from an integration bundle through core requestAds', () => { + const { window } = createPage(); + expect(requestContext(window)).toEqual({ permutive_segments: ['111', '222'] }); + }); + + it('keeps context empty when Permutive has no cohorts', () => { + const { window } = createPage(); + window.localStorage.removeItem('permutive-app'); + expect(requestContext(window)).toEqual({}); + }); + + it('keeps context empty when the Permutive integration is not loaded', () => { + const { window } = createPage({ modules: ['core', 'creative'] }); + expect(requestContext(window)).toEqual({}); + }); + + it('keeps default logging at warn, including creative installation', () => { + const { window, info } = createPage(); + expect(window.tsjs.log.getLevel()).toBe('warn'); + expect(info).not.toHaveBeenCalled(); + expectPermutiveInfo(window, info, false); + }); + + it('shares setConfig logLevel changes with integration bundles', () => { + const { window, info } = createPage(); + window.tsjs.setConfig({ logLevel: 'info' }); + expectPermutiveInfo(window, info, true); + window.tsjs.setConfig({ logLevel: 'warn' }); + expectPermutiveInfo(window, info, false); + }); + + it('shares setConfig debug changes with integration bundles', () => { + const { window, info, debugLog } = createPage(); + window.tsjs.setConfig({ debug: true }); + expectPermutiveInfo(window, info, true); + debugLog.mockClear(); + window.tsjs.requestAds(); + expect( + debugLog.mock.calls.some((args) => + args.some((arg) => String(arg).includes('getPermutiveSegments: found segments')) + ) + ).toBe(true); + }); + + it('shares direct log.setLevel changes with integration bundles', () => { + const { window, info } = createPage(); + window.tsjs.log.setLevel('info'); + expectPermutiveInfo(window, info, true); + window.tsjs.log.setLevel('silent'); + expectPermutiveInfo(window, info, false); + }); + + it('preserves publisher logging configured before integration bundles load', () => { + const { window, info } = createPage({ logLevel: 'info' }); + expect(window.tsjs.log.getLevel()).toBe('info'); + expectPermutiveInfo(window, info, true); + }); + + it('does not let creative overwrite an explicit publisher warn level', () => { + const { window, info } = createPage({ logLevel: 'warn' }); + expect(window.tsjs.log.getLevel()).toBe('warn'); + expect(info).not.toHaveBeenCalled(); + }); + + it('preserves the explicit creative tsdebug flag', () => { + const { window, info } = createPage({ debug: true }); + expect(window.tsjs.log.getLevel()).toBe('debug'); + expectPermutiveInfo(window, info, true); + }); + + it('adopts context registered by an integration loaded before core', () => { + const { window } = createPage({ modules: ['permutive'] }); + window.eval(bundles.get('core')); + expect(requestContext(window)).toEqual({ permutive_segments: ['111', '222'] }); + }); +}); diff --git a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs index f0283f0a7..24db647d3 100644 --- a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs @@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url'; import { JSDOM, requestInterceptor } from 'jsdom'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { moduleBuildOptions } from '../build-module-options.mjs'; import { main, verifyPrebidPackageVersion } from '../build-prebid-external.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -54,29 +55,13 @@ beforeAll(async () => { }); const { build } = await import('vite'); - await build({ - configFile: false, - root: libDir, - build: { - emptyOutDir: false, + await build( + moduleBuildOptions({ + name: 'prebid', + entryPath: path.join(libDir, 'src', 'integrations', 'prebid', 'index.ts'), outDir: outputDirectory, - assetsDir: '.', - sourcemap: false, - minify: 'esbuild', - rollupOptions: { - input: path.join(libDir, 'src', 'integrations', 'prebid', 'index.ts'), - output: { - format: 'iife', - dir: outputDirectory, - entryFileNames: 'tsjs-prebid.js', - inlineDynamicImports: true, - extend: false, - name: 'tsjs_prebid', - }, - }, - }, - logLevel: 'warn', - }); + }) + ); shimCode = fs.readFileSync(path.join(outputDirectory, 'tsjs-prebid.js'), 'utf8'); }, 240_000); From 55a3d1e5cc1759173f5eea33d1f9e505f7529ecc Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 1 Oct 2026 08:59:25 -0500 Subject: [PATCH 2/2] Clarify and test shared tsdebug logging behavior --- CHANGELOG.md | 2 +- .../iife-state-artifact-integration.test.mjs | 42 ++++++++++++++++++- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ffa2faae..f906c979f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Browser integration bundles now share context providers and the configured log level with TSJS core (#1196). With Permutive enabled and cohorts available, `tsjs.requestAds()` now sends `config.permutive_segments` to `/auction`; the server retains that key only when `auction.allowed_context_keys` includes `"permutive_segments"`. This restores a previously inactive data flow, not a consent gate: review publisher consent handling before relying on it. Integration logs now follow `tsjs.setConfig` and `tsjs.log.setLevel`, and creative no longer raises the default `warn` level to `info`; explicit `tsdebug` behavior remains available. +- Browser integration bundles now share context providers and the configured log level with TSJS core (#1196). With Permutive enabled and cohorts available, `tsjs.requestAds()` now sends `config.permutive_segments` to `/auction`; the server retains that key only when `auction.allowed_context_keys` includes `"permutive_segments"`. This restores a previously inactive data flow, not a consent gate: review publisher consent handling before relying on it. Integration logs now follow `tsjs.setConfig` and `tsjs.log.setLevel`, and creative no longer raises the default `warn` level to `info`. The `?tsdebug=1` query parameter or `localStorage['tsdebug'] = '1'` intentionally enables `debug` logging across all bundles when creative installs, overriding any log level set earlier through `tsjs.setConfig` or `tsjs.log.setLevel`. Later calls to either API can change the level again. - TSJS-generated envelopes now send `trustedServer.params.storedRequest: false`, preventing accidental PBS stored lookups without suppressing eligible non-PBS demand. PBS filters unusable impressions after overrides; explicit `true` and omission in valid envelopes retain inline-first stored fallback. A malformed envelope disables stored fallback for the entire slot, including independent direct demand left unusable after overrides. Publisher intent survives repeated and refresh auctions. Deploy compatible server admission everywhere before serving the new JS, and retain it during rollback while cached clients remain. See the Prebid deployment guide. - Protocol-relative creative URLs now honor `rewrite.exclude_domains`, so excluded creative assets stay direct and excluded absolute or protocol-relative URLs submitted to `/first-party/sign` are rejected. - Server-side ad template bids now always carry `hb_adid` in `window.tsjs.bids`. Bidders that return neither a Prebid Cache UUID nor an `adid` previously produced no `hb_adid` at all, so no `hb_adid` GPT targeting key was set and the Universal Creative render bridge had nothing to match — the winning creative never rendered. The OpenRTB bid `id`, which is mandatory per spec, is now the last-resort source; `cache_id` and `adid` still take priority where present. Blank `cacheId`/`adid` values no longer win that precedence and emit an unusable empty `hb_adid`, and `hb_cache_host`/`hb_cache_path` are now emitted only alongside a real Prebid Cache UUID — without one they pointed the Universal Creative at a guaranteed cache miss instead of letting it fall through to the inline creative. diff --git a/crates/trusted-server-js/lib/test/iife-state-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/iife-state-artifact-integration.test.mjs index 7da3d28dc..127b122de 100644 --- a/crates/trusted-server-js/lib/test/iife-state-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/iife-state-artifact-integration.test.mjs @@ -38,12 +38,18 @@ afterAll(() => { if (outputDirectory) fs.rmSync(outputDirectory, { recursive: true, force: true }); }); -function createPage({ modules = ['core', 'creative', 'permutive'], logLevel, debug = false } = {}) { +function createPage({ + modules = ['core', 'creative', 'permutive'], + logLevel, + debug = false, + storedDebug = false, +} = {}) { dom = new JSDOM('
', { url: `https://publisher.example.com/${debug ? '?tsdebug=1' : ''}`, runScripts: 'outside-only', }); const { window } = dom; + if (storedDebug) window.localStorage.setItem('tsdebug', '1'); // Avoid SDK polling and external requests. Script elements are not fetched // with outside-only execution and no resource loader. window.permutive = { config: {} }; @@ -159,12 +165,44 @@ describe('shared state across production IIFEs', () => { expect(info).not.toHaveBeenCalled(); }); - it('preserves the explicit creative tsdebug flag', () => { + it('enables shared logging with the explicit tsdebug flag', () => { const { window, info } = createPage({ debug: true }); expect(window.tsjs.log.getLevel()).toBe('debug'); expectPermutiveInfo(window, info, true); }); + it.each([ + ['query', 'warn'], + ['query', 'silent'], + ['localStorage', 'warn'], + ['localStorage', 'silent'], + ])('lets %s tsdebug override an earlier publisher %s level', (source, logLevel) => { + const { window, info, debugLog } = createPage({ + logLevel, + debug: source === 'query', + storedDebug: source === 'localStorage', + }); + expect(window.tsjs.log.getLevel()).toBe('debug'); + expectPermutiveInfo(window, info, true); + debugLog.mockClear(); + expect(requestContext(window)).toEqual({ permutive_segments: ['111', '222'] }); + expect( + debugLog.mock.calls.some((args) => + args.some((arg) => String(arg).includes('getPermutiveSegments: found segments')) + ) + ).toBe(true); + + window.tsjs.setConfig({ logLevel }); + expect(window.tsjs.log.getLevel()).toBe(logLevel); + expectPermutiveInfo(window, info, false); + + window.tsjs.log.setLevel('debug'); + expectPermutiveInfo(window, info, true); + window.tsjs.log.setLevel(logLevel); + expect(window.tsjs.log.getLevel()).toBe(logLevel); + expectPermutiveInfo(window, info, false); + }); + it('adopts context registered by an integration loaded before core', () => { const { window } = createPage({ modules: ['permutive'] }); window.eval(bundles.get('core'));