diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 73da261f3..58a18c50b 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -911,7 +911,7 @@ pub fn register( .with_proxy(integration.clone()) .with_attribute_rewriter(integration.clone()) .with_head_injector(integration) - .without_js() + .with_deferred_js() .build(), )) } @@ -3016,13 +3016,18 @@ passphrase = "test-secret-key-32-bytes-minimum" !processed.contains("cdn.prebid.org/prebid.js"), "Prebid preload should be removed when auto-config is enabled" ); + // Both scripts are `defer`, so they execute in document order. The + // bundle must run first: the shim disables the whole integration when + // it finds no Prebid.js API on window.pbjs. + let bundle_index = processed + .find(PREBID_BUNDLE_ROUTE) + .expect("should inject external prebid bundle route"); + let shim_index = processed + .find("tsjs-prebid.min.js") + .expect("should inject deferred tsjs prebid shim"); assert!( - processed.contains(PREBID_BUNDLE_ROUTE), - "External prebid bundle route should be injected" - ); - assert!( - !processed.contains("tsjs-prebid.min.js"), - "Embedded deferred prebid bundle should not be injected" + bundle_index < shim_index, + "external prebid bundle must execute before the deferred tsjs shim" ); } diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index c81ccc4f7..0644fb522 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -1955,7 +1955,7 @@ mod tests { } #[test] - fn js_module_ids_exclude_prebid_and_include_core_js_only_modules() { + fn js_module_ids_defer_prebid_and_include_core_js_only_modules() { let settings = crate::test_support::tests::create_test_settings(); let mut settings_with_prebid = settings; settings_with_prebid @@ -1981,8 +1981,8 @@ mod tests { let deferred = registry.js_module_ids_deferred(); assert!( - !all.contains(&"prebid"), - "should not include prebid in embedded TSJS module IDs" + all.contains(&"prebid"), + "should include the prebid shim in embedded TSJS module IDs" ); assert!( immediate.contains(&"creative"), @@ -1997,8 +1997,8 @@ mod tests { "should not include prebid in immediate IDs" ); assert!( - !deferred.contains(&"prebid"), - "should not include prebid in deferred IDs" + deferred.contains(&"prebid"), + "should serve the prebid shim as a deferred module" ); } @@ -2083,7 +2083,7 @@ mod tests { } #[test] - fn js_module_ids_exclude_prebid_when_external_bundle_is_configured() { + fn js_module_ids_defer_prebid_shim_when_external_bundle_is_configured() { let mut settings = crate::test_support::tests::create_test_settings(); settings .integrations @@ -2100,16 +2100,16 @@ mod tests { let registry = IntegrationRegistry::new(&settings).expect("should create registry"); assert!( - !registry.js_module_ids().contains(&"prebid"), - "external bundle mode should not include prebid in embedded TSJS modules" + registry.js_module_ids().contains(&"prebid"), + "external bundle mode should include the prebid shim in embedded TSJS modules" ); assert!( !registry.js_module_ids_immediate().contains(&"prebid"), - "external bundle mode should not include prebid in immediate TSJS modules" + "the prebid shim should not load in the immediate TSJS bundle" ); assert!( - !registry.js_module_ids_deferred().contains(&"prebid"), - "external bundle mode should not include prebid in deferred TSJS modules" + registry.js_module_ids_deferred().contains(&"prebid"), + "the prebid shim should load as a deferred TSJS module" ); assert!( registry.has_route(&Method::GET, "/integrations/prebid/bundle.js"), diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index a15a999f0..6c77a7fab 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -5380,7 +5380,7 @@ mod tests { } #[test] - fn tsjs_dynamic_does_not_serve_embedded_prebid() { + fn tsjs_dynamic_serves_prebid_shim_when_enabled() { let settings = create_test_settings(); let registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); @@ -5392,8 +5392,8 @@ mod tests { let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); assert_eq!( response.status(), - StatusCode::NOT_FOUND, - "should not serve embedded prebid module" + StatusCode::OK, + "should serve the deferred prebid shim module when prebid is enabled" ); } diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 280b88bb3..133e6d011 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -198,12 +198,13 @@ mod tests { } #[test] - fn tsjs_deferred_script_src_uses_empty_hash_for_external_or_unknown_module() { - assert_eq!( - tsjs_deferred_script_src("prebid"), - "/static/tsjs=tsjs-prebid.min.js?v=", - "prebid now ships as an external bundle and has no local hash" + fn tsjs_deferred_script_src_hashes_prebid_shim_and_empties_unknown_module() { + let prebid_src = tsjs_deferred_script_src("prebid"); + assert!( + prebid_src.starts_with("/static/tsjs=tsjs-prebid.min.js?v="), + "prebid shim should be served from the deferred tsjs route" ); + assert_sha256_hex_hash(hash_query_value(&prebid_src)); assert_eq!( tsjs_deferred_script_src("unknown-module"), "/static/tsjs=tsjs-unknown-module.min.js?v=", diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index df261bd4f..2bfee01b1 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -8,9 +8,10 @@ * tsjs-core.js — core API (always included) * tsjs-.js — one per discovered integration * - * Prebid is intentionally excluded from this embedded build. Use - * build-prebid-external.mjs to generate publisher-specific Prebid bundles - * outside the Cargo build. + * The prebid integration builds here as the tsjs shim only — Prebid.js itself + * is never bundled into tsjs. Use build-prebid-external.mjs to generate the + * pure Prebid.js external bundle (core + adapters + user ID modules) that the + * shim requires at runtime via integrations.prebid.external_bundle_url. */ import fs from 'node:fs'; @@ -34,9 +35,7 @@ const integrationModules = fs.existsSync(integrationsDir) .filter((name) => { const fullPath = path.join(integrationsDir, name); return ( - name !== 'prebid' && - fs.statSync(fullPath).isDirectory() && - fs.existsSync(path.join(fullPath, 'index.ts')) + fs.statSync(fullPath).isDirectory() && fs.existsSync(path.join(fullPath, 'index.ts')) ); }) .sort() diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index 4e89723ed..eb6e42826 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -126,6 +126,45 @@ export function renderIncludedUserIdModulesExport(moduleNames) { return `export const INCLUDED_PREBID_USER_ID_MODULES = ${JSON.stringify(moduleNames)};`; } +/** + * Derive the registered Prebid bidder codes (including aliases) for the given + * adapter module names from prebid.js metadata. + * + * Module file stems and runtime bidder codes are not equivalent: the + * `adfBidAdapter.js` module registers `adf` plus the `adform` and + * `adformOpenRTB` aliases, and `a1MediaBidAdapter.js` registers `a1media`. + * The shim validates `client_side_bidders` (runtime codes) against this + * list, while the module-name list is retained separately for audit output. + */ +export function readAdapterBidderCodes(adapterNames) { + const metadataDir = path.join(PREBID_PACKAGE_DIR, 'metadata', 'modules'); + const bidderCodes = new Set(); + + for (const name of adapterNames) { + const metadataPath = path.join(metadataDir, `${name}BidAdapter.json`); + if (!fs.existsSync(metadataPath)) { + // No metadata shipped for this module — fall back to the module stem so + // the bundle still stamps something the shim can validate against. + bidderCodes.add(name); + continue; + } + + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); + const bidderComponents = (metadata.components ?? []).filter( + (component) => component.componentType === 'bidder' && component.componentName + ); + if (bidderComponents.length === 0) { + bidderCodes.add(name); + continue; + } + for (const component of bidderComponents) { + bidderCodes.add(component.componentName); + } + } + + return [...bidderCodes].sort(); +} + function generateAdapterImports(adapterNames, adaptersFile) { const modulesDir = path.join(PREBID_PACKAGE_DIR, 'modules'); const imports = []; @@ -182,9 +221,62 @@ function createTemporaryModulePaths() { temporaryDir, adaptersFile: path.join(temporaryDir, '_adapters.generated.ts'), userIdsFile: path.join(temporaryDir, '_user_ids.generated.ts'), + entryFile: path.join(temporaryDir, '_external_entry.generated.ts'), }; } +const SHIM_WATCHDOG_DELAY_MS = 5000; + +function generateExternalEntry(entryFile, adapters, bidderCodes) { + const content = [ + '// Auto-generated by build-prebid-external.mjs.', + '//', + '// Pure Prebid.js external bundle: core, consent modules, user ID modules,', + '// and client-side bid adapters. The Trusted Server prebid shim', + '// (tsjs-prebid, served by the server) installs the trustedServer adapter', + '// onto the `window.pbjs` global this bundle populates and drives queue', + '// processing — this bundle intentionally does NOT call processQueue()', + '// itself, except through the watchdog below.', + "import 'prebid.js';", + "import 'prebid.js/modules/consentManagementTcf.js';", + "import 'prebid.js/modules/consentManagementGpp.js';", + "import 'prebid.js/modules/consentManagementUsp.js';", + "import 'prebid.js/modules/userId.js';", + "import './_adapters.generated';", + "import { INCLUDED_PREBID_USER_ID_MODULES } from './_user_ids.generated';", + '', + '// Manifest consumed by the tsjs prebid shim to validate that every', + '// configured client_side_bidder has its adapter compiled in. adapters', + '// lists the module file stems for audit output; bidderCodes lists the', + '// registered runtime bidder codes, including aliases.', + 'const bundleWindow = window as unknown as {', + ' __tsjs_prebid_bundle?: unknown;', + ' __tsjsPrebidShimInstalled?: boolean;', + ' pbjs?: { processQueue?: () => void };', + '};', + 'bundleWindow.__tsjs_prebid_bundle = Object.freeze({', + ` adapters: ${JSON.stringify(adapters)},`, + ` bidderCodes: ${JSON.stringify(bidderCodes)},`, + ' userIdModules: INCLUDED_PREBID_USER_ID_MODULES,', + '});', + '', + '// Watchdog: the shim owns processQueue(), but it is a separate artifact', + '// that can fail to load independently (adblock filters, CSP, a', + '// /static/tsjs= error). If it has not installed within the grace period,', + '// drain the queue anyway so publisher pbjs.que callbacks still run', + '// against plain Prebid.js. processQueue() is safe to call again when the', + '// shim arrives late.', + 'setTimeout(() => {', + ' if (!bundleWindow.__tsjsPrebidShimInstalled) {', + ' bundleWindow.pbjs?.processQueue?.();', + ' }', + `}, ${SHIM_WATCHDOG_DELAY_MS});`, + '', + ].join('\n'); + + fs.writeFileSync(entryFile, content); +} + export function deriveBundleMetadata(bundleBytes) { const sha256 = crypto.createHash('sha256').update(bundleBytes).digest('hex'); const sri = `sha384-${crypto.createHash('sha384').update(bundleBytes).digest('base64')}`; @@ -224,6 +316,13 @@ async function buildExternalBundle(outDir, generatedModules) { 'node_modules/prebid.js/dist/src/src/adapterManager.js' ), }, + { + find: 'prebid.js/src/adRendering.js', + replacement: path.resolve( + __dirname, + 'node_modules/prebid.js/dist/src/src/adRendering.js' + ), + }, ], }, build: { @@ -233,7 +332,7 @@ async function buildExternalBundle(outDir, generatedModules) { sourcemap: false, minify: 'esbuild', rollupOptions: { - input: path.join(prebidDir, 'index.ts'), + input: generatedModules.entryFile, output: { format: 'iife', dir: outDir, @@ -269,11 +368,14 @@ export async function main(argv = process.argv.slice(2)) { try { const adapters = generateAdapterImports(args.adapters, generatedModules.adaptersFile); + const bidderCodes = readAdapterBidderCodes(adapters); const userIdModules = generateUserIdImports(args.userIdModules, generatedModules.userIdsFile); + generateExternalEntry(generatedModules.entryFile, adapters, bidderCodes); const bundle = await buildExternalBundle(args.outDir, generatedModules); const manifest = { prebidVersion: prebidPackageVersion(), adapters, + bidderCodes, userIdModules, sha256: bundle.sha256, sri: bundle.sri, diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/_adapters.generated.ts b/crates/trusted-server-js/lib/src/integrations/prebid/_adapters.generated.ts deleted file mode 100644 index eca3dc4e9..000000000 --- a/crates/trusted-server-js/lib/src/integrations/prebid/_adapters.generated.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Placeholder for generated Prebid adapter imports. -// -// build-prebid-external.mjs aliases this module to a temporary file containing -// publisher-specific imports during external bundle generation. - -export {}; diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/_user_ids.generated.ts b/crates/trusted-server-js/lib/src/integrations/prebid/_user_ids.generated.ts deleted file mode 100644 index e7c0112a9..000000000 --- a/crates/trusted-server-js/lib/src/integrations/prebid/_user_ids.generated.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Placeholder for generated Prebid User ID module imports. -// -// build-prebid-external.mjs aliases this module to a temporary file containing -// publisher-specific imports and the corresponding module-name list during -// external bundle generation. - -export const INCLUDED_PREBID_USER_ID_MODULES: string[] = []; diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index f5267cac6..f67830b5a 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -11,29 +11,85 @@ // The shim on requestBids injects "trustedServer" into every ad unit so all // bids flow through the orchestrator. -import pbjs from 'prebid.js'; -import adapterManager from 'prebid.js/src/adapterManager.js'; -import 'prebid.js/modules/consentManagementTcf.js'; -import 'prebid.js/modules/consentManagementGpp.js'; -import 'prebid.js/modules/consentManagementUsp.js'; -import 'prebid.js/modules/userId.js'; - -// Client-side bid adapters — self-register with prebid.js on import. -// The external bundle generator aliases these placeholder modules to temporary -// modules built from its --adapters and --user-id-modules options. When a bidder -// is listed in `client_side_bidders` in trusted-server.toml, the requestBids -// shim leaves its bids untouched and the corresponding adapter handles them -// natively in the browser. -import './_adapters.generated'; +import type _pbjsDefault from 'prebid.js'; import { log } from '../../core/log'; import { buildAdRequest, parseAuctionResponse } from '../../core/auction'; import type { AuctionBid, AuctionEid } from '../../core/auction'; import type { AuctionSlot } from '../../core/types'; -import { INCLUDED_PREBID_USER_ID_MODULES } from './_user_ids.generated'; import { PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; +/** + * Prebid.js public API surface (type-only; erased at build time). + * + * `getUserIdsAsEids` is added by the userId module at runtime, which the base + * package typing does not model. + */ +type PbjsGlobal = typeof _pbjsDefault & { + getUserIdsAsEids?: () => unknown[]; +}; + +// Prebid.js itself is NOT bundled into this module. It is served as the +// external bundle configured via `integrations.prebid.external_bundle_url` +// (required whenever the prebid integration is enabled) and owns the +// `window.pbjs` global. The Rust head injector emits a stub +// (`window.pbjs = window.pbjs || {que:[],cmd:[]}`) before any script runs and +// Prebid.js installs its API onto that same object, so capturing the reference +// at module scope is safe regardless of evaluation order. +const pbjs: PbjsGlobal = ( + typeof window !== 'undefined' + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + ((window as any).pbjs ??= { que: [], cmd: [] }) + : { que: [], cmd: [] } +) as PbjsGlobal; + +/** + * Manifest stamped on `window.__tsjs_prebid_bundle` by the external Prebid.js + * bundle (see build-prebid-external.mjs): which client-side bid adapters and + * user ID modules were compiled into it. + */ +interface ExternalPrebidBundleManifest { + adapters?: string[]; + bidderCodes?: string[]; + userIdModules?: string[]; +} + +function sanitizeManifestList(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + return value.filter((entry): entry is string => typeof entry === 'string'); +} + +function getExternalBundleManifest(): ExternalPrebidBundleManifest | undefined { + if (typeof window === 'undefined') { + return undefined; + } + // The manifest is a plain window global any page script can overwrite, so + // validate its shape instead of trusting the declared type: a non-array + // field must degrade to "not stamped" diagnostics, not a TypeError. + const raw = (window as { __tsjs_prebid_bundle?: unknown }).__tsjs_prebid_bundle; + if (raw === null || typeof raw !== 'object') { + return undefined; + } + const manifest = raw as Record; + return { + adapters: sanitizeManifestList(manifest.adapters), + bidderCodes: sanitizeManifestList(manifest.bidderCodes), + userIdModules: sanitizeManifestList(manifest.userIdModules), + }; +} + +/** + * Whether the captured `window.pbjs` carries the real Prebid.js API rather + * than the head-injected `{ que, cmd }` stub left behind when the external + * bundle fails to load. + */ +function hasPrebidJsApi(): boolean { + return typeof (pbjs as { registerBidAdapter?: unknown }).registerBidAdapter === 'function'; +} + const ADAPTER_CODE = 'trustedServer'; // OpenRTB permits vendor-specific agent types; PAIR uses 571187. // Keep this range aligned with the signed 32-bit Rust/OpenRTB representation. @@ -141,19 +197,39 @@ function readConfiguredUserIdNames(): string[] { ); } +/** Warn-once flag for an unstamped User ID manifest; reset by installPrebidNpm. */ +let warnedMissingUserIdManifest = false; + function recordUserIdModuleDiagnostics(): PrebidUserIdDiagnostics { + const manifestUserIdModules = getExternalBundleManifest()?.userIdModules; + const includedUserIdModules = manifestUserIdModules ?? []; const configuredUserIdNames = [...new Set(readConfiguredUserIdNames())].sort(); const coveredConfigNames = new Set( PREBID_USER_ID_MODULE_REGISTRY.filter((entry) => - INCLUDED_PREBID_USER_ID_MODULES.includes(entry.moduleName) + includedUserIdModules.includes(entry.moduleName) ).flatMap((entry) => entry.configNames) ); - const missingConfiguredUserIdNames = configuredUserIdNames.filter( - (name) => !coveredConfigNames.has(name) - ); + // An older or unstamped bundle must not make every configured module look + // absent: warn once about the missing manifest instead of once per module, + // mirroring the client-side adapter validation in installPrebidNpm. + const missingConfiguredUserIdNames = + manifestUserIdModules === undefined + ? [] + : configuredUserIdNames.filter((name) => !coveredConfigNames.has(name)); + if ( + manifestUserIdModules === undefined && + configuredUserIdNames.length > 0 && + !warnedMissingUserIdManifest + ) { + warnedMissingUserIdManifest = true; + log.warn( + '[tsjs-prebid] external Prebid bundle did not stamp a User ID module manifest; ' + + 'cannot verify configured User ID modules' + ); + } const diagnostics: PrebidUserIdDiagnostics = { - includedModules: [...INCLUDED_PREBID_USER_ID_MODULES], + includedModules: [...includedUserIdModules], configuredUserIdNames, missingConfiguredUserIdNames, }; @@ -802,8 +878,34 @@ function collectAuctionEids(): AuctionEid[] | undefined { * Config resolution (values from later sources override earlier ones): * 1. `window.__tsjs_prebid` — injected by the server from trusted-server.toml * 2. `config` argument — explicit overrides from the publisher's JS + * + * Idempotent per page: a `window.__tsjsPrebidShimInstalled` sentinel makes + * repeat calls (double script inclusion, a bundle that still carries a + * baked-in shim) a no-op instead of a double adapter registration. */ export function installPrebidNpm(config?: Partial): typeof pbjs { + // The prebid integration requires the external Prebid.js bundle + // (integrations.prebid.external_bundle_url). When it failed to load (network + // error, SRI mismatch) window.pbjs is still the head-injected stub with no + // API — installing the adapter is impossible, so bail out loudly. + if (!hasPrebidJsApi()) { + log.error( + '[tsjs-prebid] window.pbjs has no Prebid.js API — the external Prebid bundle ' + + 'failed to load. Prebid integration disabled.' + ); + return pbjs; + } + + const sentinelWindow = + typeof window === 'undefined' ? undefined : (window as { __tsjsPrebidShimInstalled?: boolean }); + if (sentinelWindow?.__tsjsPrebidShimInstalled) { + return pbjs; + } + if (sentinelWindow) { + sentinelWindow.__tsjsPrebidShimInstalled = true; + } + + warnedMissingUserIdManifest = false; publisherAdUnitSnapshots = new Map(); pendingPublisherBids = new Map(); pendingPublisherCodes = new Map(); @@ -1030,24 +1132,31 @@ export function installPrebidNpm(config?: Partial): typeof pbjs pbjs.processQueue(); recordUserIdModuleDiagnostics(); - // Validate that every client-side bidder has its adapter registered. - // Adapters self-register on import, so a missing adapter means the bidder - // was listed in client_side_bidders but not included in the generated - // external Prebid bundle. Without the adapter the bidder is silently dropped - // from both server-side and client-side auctions. - for (const bidder of clientSideBidders) { - try { - if (!adapterManager.getBidAdapter(bidder)) { + // Validate that every client-side bidder has its adapter compiled into the + // external Prebid.js bundle. The bundle stamps the registered bidder codes + // (including aliases such as adform/adformOpenRTB for the adf module) on + // window.__tsjs_prebid_bundle; a missing code means the bidder was listed + // in client_side_bidders but not included in the generated bundle, so it is + // silently dropped from both server-side and client-side auctions. Fall + // back to the module-name list for bundles stamped before bidderCodes. + const manifest = getExternalBundleManifest(); + const bundledBidderCodes = manifest?.bidderCodes ?? manifest?.adapters; + if (bundledBidderCodes === undefined) { + if (clientSideBidders.size > 0) { + log.warn( + '[tsjs-prebid] external Prebid bundle did not stamp an adapter manifest; ' + + 'cannot verify client_side_bidders adapters' + ); + } + } else { + for (const bidder of clientSideBidders) { + if (!bundledBidderCodes.includes(bidder)) { log.error( - `[tsjs-prebid] client-side bidder "${bidder}" has no adapter loaded. ` + - `Add it to build-prebid-external.mjs --adapters.` + `[tsjs-prebid] client-side bidder "${bidder}" has no adapter in the external ` + + 'Prebid bundle. Add its adapter to [integrations.prebid.bundle].adapters in ' + + 'trusted-server.toml and rebuild it with `ts prebid bundle`.' ); } - } catch { - log.error( - `[tsjs-prebid] client-side bidder "${bidder}" has no adapter loaded. ` + - `Add it to build-prebid-external.mjs --adapters.` - ); } } @@ -1210,8 +1319,8 @@ export function installRefreshHandler(timeoutMs = 1500): void { /** * Configure identity sync behavior for the generated Prebid User ID modules. * - * The external bundle generator statically imports the selected modules through - * `_user_ids.generated.ts`. This post-window-load configuration controls when + * The external bundle generator statically imports the selected modules into + * its generated entry. This post-window-load configuration controls when * those modules synchronize identities; it does not select or register modules. */ export function installUserIdModules(): void { @@ -1309,20 +1418,26 @@ function syncPrebidEidsCookie(): void { // Self-initialize when loaded in a browser (same pattern as other integrations). if (typeof window !== 'undefined') { installPrebidNpm(); - installRefreshHandler(); - // The slim-Prebid lazy loader appends this bundle from a window.load - // handler, so `load` may already have fired by the time this code runs — - // waiting for it again would skip user ID setup entirely on that path. - if (document.readyState === 'complete') { - installUserIdModules(); - } else { - window.addEventListener( - 'load', - () => { - installUserIdModules(); - }, - { once: true } - ); + // When the external bundle failed to load, installPrebidNpm bailed out and + // pbjs.requestBids is undefined. Installing the refresh handler anyway + // would clear TS-applied GPT targeting on every publisher refresh and then + // fail to run the replacement auction — leave GPT untouched instead. + if (hasPrebidJsApi()) { + installRefreshHandler(); + // The slim-Prebid lazy loader appends this bundle from a window.load + // handler, so `load` may already have fired by the time this code runs — + // waiting for it again would skip user ID setup entirely on that path. + if (document.readyState === 'complete') { + installUserIdModules(); + } else { + window.addEventListener( + 'load', + () => { + installUserIdModules(); + }, + { once: true } + ); + } } } diff --git a/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs index 682843570..f22717f79 100644 --- a/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs +++ b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs @@ -11,6 +11,7 @@ import { deriveBundleMetadata, main, parseArgs, + readAdapterBidderCodes, renderIncludedUserIdModulesExport, } from '../build-prebid-external.mjs'; @@ -33,6 +34,22 @@ describe('build-prebid-external metadata', () => { ); }); + it('derives registered bidder codes including aliases from prebid metadata', () => { + // adfBidAdapter.js registers adf plus the adform/adformOpenRTB aliases. + expect(readAdapterBidderCodes(['adf'])).toEqual(['adf', 'adform', 'adformOpenRTB']); + }); + + it('maps a module file stem to its registered bidder code', () => { + // a1MediaBidAdapter.js registers a1media — the stem itself is not a code. + const bidderCodes = readAdapterBidderCodes(['a1Media']); + expect(bidderCodes).toContain('a1media'); + expect(bidderCodes).not.toContain('a1Media'); + }); + + it('falls back to the module stem when no metadata is shipped', () => { + expect(readAdapterBidderCodes(['noSuchAdapterEver'])).toEqual(['noSuchAdapterEver']); + }); + it('includes generated User ID metadata in the production external bundle', async () => { const outputDirectory = fs.mkdtempSync( path.join(os.tmpdir(), 'trusted-server-prebid-build-test-') @@ -54,6 +71,7 @@ describe('build-prebid-external metadata', () => { const bundle = fs.readFileSync(path.join(outputDirectory, manifest.filename), 'utf8'); expect(manifest.userIdModules).toEqual(['pairIdSystem', 'lockrAIMIdSystem']); + expect(manifest.bidderCodes).toEqual(['rubicon']); expect(bundle).toContain('"pairIdSystem"'); expect(bundle).toContain('"lockrAIMIdSystem"'); } finally { diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 74a9e4279..732af0e6e 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -1,6 +1,74 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -// Define mocks using vi.hoisted so they're available inside vi.mock factories +/** + * Default external-bundle manifest for tests. Mirrors what the real external + * Prebid.js bundle stamps on `window.__tsjs_prebid_bundle` (see + * build-prebid-external.mjs). Individual tests override and restore it. + */ +const DEFAULT_BUNDLE_MANIFEST = { + adapters: ['rubicon', 'openx', 'exampleBrowser', 'appnexus'], + bidderCodes: ['rubicon', 'openx', 'exampleBrowser', 'appnexus'], + userIdModules: ['sharedIdSystem'], +}; + +/** Loose bid shape used by the requestBids shim tests. */ +interface TestBid { + bidder: string; + params?: Record; +} + +/** Loose ad unit shape used by the requestBids shim tests. */ +interface TestAdUnit { + code?: string; + bids?: TestBid[]; +} + +/** Window properties the prebid shim reads and writes in these tests. */ +interface PrebidTestWindow { + pbjs?: unknown; + tsjs?: unknown; + googletag?: unknown; + __tsjs_prebid?: Record; + __tsjsPrebidShimInstalled?: boolean; + __tsjs_prebid_bundle?: unknown; + __tsjs_prebid_diagnostics?: { + userIdModules?: { + includedModules: string[]; + configuredUserIdNames: string[]; + missingConfiguredUserIdNames: string[]; + }; + }; +} + +const testWindow = window as unknown as PrebidTestWindow; + +/** Argument type accepted by the shimmed `pbjs.requestBids`. */ +type RequestBidsArg = Parameters['requestBids']>[0]; + +/** The bid adapter spec object registered via `pbjs.registerBidAdapter`. */ +interface TestAdapterSpec { + code: string; + supportedMediaTypes: string[]; + isBidRequestValid: (bid: Record) => boolean; + buildRequests: ( + bidRequests: Array>, + bidderRequest?: Record + ) => { + method: string; + url: string; + data: Record; + options: Record; + }; + interpretResponse: ( + response: Record, + request?: Record + ) => Array>; +} + +// Define mocks using vi.hoisted so they exist before the module under test is +// imported. The shim reads Prebid.js from the `window.pbjs` global (owned by +// the external bundle in production), so tests install the mock there instead +// of mocking module imports. const { mockSetConfig, mockProcessQueue, @@ -10,30 +78,23 @@ const { mockGetConfig, mockRemoveAdUnit, mockPbjs, - mockGetBidAdapter, - mockAdapterManager, } = vi.hoisted(() => { const mockSetConfig = vi.fn(); const mockProcessQueue = vi.fn(); const mockRequestBids = vi.fn(); const mockRegisterBidAdapter = vi.fn(); - const mockGetBidAdapter = vi.fn(); const mockGetUserIdsAsEids = vi.fn( - () => - [] as Array<{ - source: string; - uids?: Array<{ id: string; atype?: number; ext?: Record }>; - }> + () => [] as Array<{ source: string; uids?: Array<{ id: string; atype?: number }> }> ); const mockGetConfig = vi.fn(); - // Only called after mockPbjs is initialized below, so the forward reference is safe. + const mockRemoveAdUnit = vi.fn((adUnitCode?: string | string[]) => { if (!adUnitCode) { mockPbjs.adUnits = []; return; } const codes = new Set(Array.isArray(adUnitCode) ? adUnitCode : [adUnitCode]); - mockPbjs.adUnits = mockPbjs.adUnits.filter((unit) => !codes.has(unit.code!)); + mockPbjs.adUnits = mockPbjs.adUnits.filter((unit) => !codes.has(unit.code)); }); const mockPbjs: { setConfig: typeof mockSetConfig; @@ -44,6 +105,7 @@ const { getConfig: typeof mockGetConfig; removeAdUnit: ReturnType; adUnits: TestAdUnit[]; + setTargetingForGPTAsync?: (adUnitCodes?: string[]) => void; [key: string]: unknown; } = { setConfig: mockSetConfig, @@ -53,11 +115,25 @@ const { getUserIdsAsEids: mockGetUserIdsAsEids, getConfig: mockGetConfig, removeAdUnit: mockRemoveAdUnit, - adUnits: [], + adUnits: [] as TestAdUnit[], + setTargetingForGPTAsync: undefined as ((adUnitCodes?: string[]) => void) | undefined, + que: [] as Array<() => void>, + cmd: [] as Array<() => void>, + }; + + // Install the mock global BEFORE the shim module evaluates — the shim + // captures `window.pbjs` at module scope. + const w = globalThis.window as unknown as { + pbjs?: unknown; + __tsjs_prebid_bundle?: unknown; }; - const mockAdapterManager = { - getBidAdapter: mockGetBidAdapter, + w.pbjs = mockPbjs; + w.__tsjs_prebid_bundle = { + adapters: ['rubicon', 'openx', 'exampleBrowser', 'appnexus'], + bidderCodes: ['rubicon', 'openx', 'exampleBrowser', 'appnexus'], + userIdModules: ['sharedIdSystem'], }; + return { mockSetConfig, mockProcessQueue, @@ -67,28 +143,9 @@ const { mockGetConfig, mockRemoveAdUnit, mockPbjs, - mockGetBidAdapter, - mockAdapterManager, }; }); -// Mock prebid.js before importing the module under test. -// The real prebid.js cannot run in jsdom, so we provide a minimal stub. -vi.mock('prebid.js', () => ({ default: mockPbjs })); -vi.mock('prebid.js/src/adapterManager.js', () => ({ default: mockAdapterManager })); - -// Side-effect imports are no-ops in tests -vi.mock('prebid.js/modules/consentManagementTcf.js', () => ({})); -vi.mock('prebid.js/modules/consentManagementGpp.js', () => ({})); -vi.mock('prebid.js/modules/consentManagementUsp.js', () => ({})); -vi.mock('prebid.js/modules/userId.js', () => ({})); - -// Mock the build-generated imports in tests. -vi.mock('../../../src/integrations/prebid/_adapters.generated', () => ({})); -vi.mock('../../../src/integrations/prebid/_user_ids.generated', () => ({ - INCLUDED_PREBID_USER_ID_MODULES: ['sharedIdSystem'], -})); - import { collectBidders, getInjectedConfig, @@ -98,123 +155,12 @@ import { } from '../../../src/integrations/prebid/index'; import type { AuctionBid } from '../../../src/core/auction'; import { log } from '../../../src/core/log'; -import type { AuctionSlot } from '../../../src/core/types'; - -/** Bid entry the prebid suites build and assert on. */ -interface TestBid { - bidder?: string; - params?: Record; -} - -/** - * Ad unit shape these suites hand to the shim. - * - * Every field is optional because several cases deliberately exercise partial - * units, which is why the real Prebid `AdUnitDefinition` cannot be reused here. - */ -interface TestAdUnit { - code?: string; - mediaTypes?: { banner?: { name?: string; sizes?: number[][] } }; - bids?: TestBid[]; -} - -/** The `trustedServer` bid the shim injects, with the params it folds onto it. */ -interface TrustedServerTestBid { - bidder: string; - params: { - bidderParams?: Record>; - zone?: string; - [key: string]: unknown; - }; -} - -/** Synthetic refresh ad unit the refresh handler passes to `requestBids`. */ -interface RefreshAdUnit { - code?: string; - mediaTypes?: { banner?: { name?: string; sizes?: number[][] } }; - bids: TrustedServerTestBid[]; -} - -/** Options object the shimmed `requestBids` accepts in these tests. */ -interface TestRequestBidsOptions { - adUnits?: TestAdUnit[]; - bidsBackHandler?: (...args: unknown[]) => void; - timeout?: number; -} - -/** The slice of the installed pbjs surface these tests drive. */ -interface TestPbjs { - requestBids(request?: TestRequestBidsOptions): void; - removeAdUnit(adUnitCode?: string | string[]): void; - setTargetingForGPTAsync(adUnitCode?: unknown, customSlotMatching?: unknown): void; -} - -/** The `trustedServer` adapter spec the module registers with Prebid. */ -interface TestAdapterSpec { - code: string; - supportedMediaTypes: string[]; - isBidRequestValid: (bid: unknown) => boolean; - buildRequests: (bidRequests: unknown[]) => { - method: string; - url: string; - data: string; - options: { contentType: string }; - }; - interpretResponse: ( - serverResponse: unknown, - request: unknown - ) => Array<{ requestId: string; cpm: number }>; -} -/** GPT slot stub the refresh and delivery suites install. */ -interface TestGptSlot { - getSlotElementId?: () => string; - getTargeting?: (key: string) => string[]; - getSizes?: () => unknown[]; - clearTargeting?: (key?: string) => void; -} - -/** GPT stub these suites install on `window.googletag`. */ -interface TestGoogletag { - cmd: { push: (fn: () => void) => void }; - pubads: () => unknown; -} - -/** - * Window surface these tests poke: the server-injected prebid config, the - * diagnostics blob the module publishes, a GPT stub, and a partial tsjs API. - * - * `tsjs` is replaced rather than intersected because the suites assign partial - * payloads that the full `TsjsApi` declared in `src/core/global.d.ts` rejects. - */ -type TestWindow = Omit & { - __tsjs_prebid?: { - accountId?: string; - timeout?: number; - debug?: boolean; - bidders?: string[]; - clientSideBidders?: string[]; - }; - __tsjs_prebid_diagnostics?: { - userIdModules: { - includedModules: string[]; - configuredUserIdNames: string[]; - missingConfiguredUserIdNames: string[]; - }; - }; - googletag?: TestGoogletag; - tsjs?: { adSlots?: Array>; adInitRefreshInProgress?: boolean }; -}; - -/** Nested server-side bidder params the mutation-isolation test rewrites. */ -type NestedServerParams = { - bidderParams: { - exampleServer: { placement: { rules: Array<{ label: string }>; sizes: number[] } }; - }; -}; - -/** Nested client-side bidder params the mutation-isolation test rewrites. */ -type NestedBrowserParams = { groups: Array<{ values: string[] }> }; +// installPrebidNpm is a per-page no-op once the sentinel is set (the module +// self-init above already set it), so every test starts from a clean page. +beforeEach(() => { + delete testWindow.__tsjsPrebidShimInstalled; +}); describe('prebid/collectBidders', () => { it('returns empty array for empty ad units', () => { @@ -245,7 +191,7 @@ describe('prebid/collectBidders', () => { describe('prebid/getInjectedConfig', () => { afterEach(() => { - delete (window as TestWindow).__tsjs_prebid; + delete testWindow.__tsjs_prebid; }); it('returns undefined when window.__tsjs_prebid is not set', () => { @@ -253,7 +199,7 @@ describe('prebid/getInjectedConfig', () => { }); it('returns the injected config when present', () => { - (window as TestWindow).__tsjs_prebid = { accountId: 'server-42', timeout: 2000 }; + testWindow.__tsjs_prebid = { accountId: 'server-42', timeout: 2000 }; expect(getInjectedConfig()).toEqual({ accountId: 'server-42', timeout: 2000 }); }); }); @@ -359,8 +305,8 @@ describe('prebid/installPrebidNpm', () => { mockGetUserIdsAsEids.mockReturnValue([]); mockGetConfig.mockReset(); document.cookie = 'ts-eids=; Path=/; Max-Age=0'; - delete (window as TestWindow).__tsjs_prebid; - delete (window as TestWindow).__tsjs_prebid_diagnostics; + delete testWindow.__tsjs_prebid; + delete testWindow.__tsjs_prebid_diagnostics; }); afterEach(() => { @@ -410,7 +356,7 @@ describe('prebid/installPrebidNpm', () => { it('reports the User ID modules selected by the generated bundle', () => { installPrebidNpm(); - expect((window as TestWindow).__tsjs_prebid_diagnostics!.userIdModules).toEqual({ + expect(testWindow.__tsjs_prebid_diagnostics.userIdModules).toEqual({ includedModules: ['sharedIdSystem'], configuredUserIdNames: [], missingConfiguredUserIdNames: [], @@ -427,7 +373,7 @@ describe('prebid/installPrebidNpm', () => { mockPbjs.requestBids({ adUnits: [] }); mockPbjs.requestBids({ adUnits: [] }); - expect((window as TestWindow).__tsjs_prebid_diagnostics!.userIdModules).toEqual({ + expect(testWindow.__tsjs_prebid_diagnostics.userIdModules).toEqual({ includedModules: ['sharedIdSystem'], configuredUserIdNames: ['pairId', 'sharedId'], missingConfiguredUserIdNames: ['pairId'], @@ -442,10 +388,48 @@ describe('prebid/installPrebidNpm', () => { expect(result).toBe(mockPbjs); }); + it('installs only once per page via the __tsjsPrebidShimInstalled sentinel', () => { + const first = installPrebidNpm(); + const wrappedRequestBids = mockPbjs.requestBids; + const second = installPrebidNpm(); + + expect(second).toBe(first); + expect(mockRegisterBidAdapter).toHaveBeenCalledTimes(1); + expect(mockPbjs.requestBids).toBe(wrappedRequestBids); + expect(testWindow.__tsjsPrebidShimInstalled).toBe(true); + }); + + it('warns once about an unstamped User ID manifest instead of once per module', () => { + delete testWindow.__tsjs_prebid_bundle; + mockGetConfig.mockImplementation((key?: string) => + key === 'userSync.userIds' ? [{ name: 'sharedId' }, { name: 'pairId' }] : {} + ); + const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); + + installPrebidNpm(); + mockPbjs.requestBids({ adUnits: [] }); + + expect(testWindow.__tsjs_prebid_diagnostics.userIdModules).toEqual({ + includedModules: [], + configuredUserIdNames: ['pairId', 'sharedId'], + missingConfiguredUserIdNames: [], + }); + const manifestWarnings = warnSpy.mock.calls.filter(([message]) => + String(message).includes('did not stamp a User ID module manifest') + ); + expect(manifestWarnings).toHaveLength(1); + const moduleWarnings = warnSpy.mock.calls.filter(([message]) => + String(message).includes('is not included in the external bundle') + ); + expect(moduleWarnings).toHaveLength(0); + + testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; + }); + describe('adapter spec', () => { function getAdapterSpec(): TestAdapterSpec { installPrebidNpm(); - return mockRegisterBidAdapter.mock.calls[0][2]; + return mockRegisterBidAdapter.mock.calls[0][2] as TestAdapterSpec; } it('isBidRequestValid always returns true', () => { @@ -717,43 +701,41 @@ describe('prebid/installPrebidNpm', () => { describe('requestBids shim', () => { it('injects trustedServer bidder into every ad unit', () => { - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); const adUnits = [ { bids: [{ bidder: 'appnexus', params: {} }] }, { bids: [{ bidder: 'rubicon', params: {} }] }, ]; - pbjs.requestBids({ adUnits }); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); // Each ad unit should have trustedServer added for (const unit of adUnits) { - const hasTsBidder = unit.bids.some((b) => b.bidder === 'trustedServer'); + const hasTsBidder = unit.bids.some((b: TestBid) => b.bidder === 'trustedServer'); expect(hasTsBidder).toBe(true); } - const trustedServerBid = adUnits[0].bids.find( - (b) => b.bidder === 'trustedServer' - ) as TrustedServerTestBid; + const trustedServerBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer'); expect(trustedServerBid.params.bidderParams).toEqual({ appnexus: {} }); - expect(adUnits[0].bids.map((b) => b.bidder)).toEqual(['trustedServer']); - expect(adUnits[1].bids.map((b) => b.bidder)).toEqual(['trustedServer']); + expect(adUnits[0].bids.map((b: TestBid) => b.bidder)).toEqual(['trustedServer']); + expect(adUnits[1].bids.map((b: TestBid) => b.bidder)).toEqual(['trustedServer']); // Should call through to original requestBids expect(mockRequestBids).toHaveBeenCalled(); }); it('does not duplicate trustedServer if already present', () => { - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); const adUnits = [{ bids: [{ bidder: 'trustedServer', params: {} }] }]; - pbjs.requestBids({ adUnits }); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsCount = adUnits[0].bids.filter((b) => b.bidder === 'trustedServer').length; + const tsCount = adUnits[0].bids.filter((b: TestBid) => b.bidder === 'trustedServer').length; expect(tsCount).toBe(1); }); it('captures per-bidder params on trustedServer bid', () => { - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); const adUnits = [ { @@ -763,21 +745,19 @@ describe('prebid/installPrebidNpm', () => { ], }, ]; - pbjs.requestBids({ adUnits }); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const trustedServerBid = adUnits[0].bids.find( - (b) => b.bidder === 'trustedServer' - ) as TrustedServerTestBid; + const trustedServerBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer'); expect(trustedServerBid).toBeDefined(); expect(trustedServerBid.params.bidderParams).toEqual({ appnexus: { placementId: 123 }, rubicon: { accountId: 'abc' }, }); - expect(adUnits[0].bids.map((b) => b.bidder)).toEqual(['trustedServer']); + expect(adUnits[0].bids.map((b: TestBid) => b.bidder)).toEqual(['trustedServer']); }); it('preserves captured bidder params when requestBids runs twice on the same ad unit', () => { - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); // First auction: inline server-side params supplied by the publisher. const adUnits = [ @@ -789,16 +769,16 @@ describe('prebid/installPrebidNpm', () => { ], }, ]; - pbjs.requestBids({ adUnits }); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); // Second auction (refresh/re-auction) with the SAME ad unit object: the // server-side bidder entries were already pruned, so the shim must not // overwrite the captured params with an empty object. - pbjs.requestBids({ adUnits }); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); const trustedServerBid = adUnits[0].bids.find( - (b) => b.bidder === 'trustedServer' - ) as TrustedServerTestBid; + (b: TestBid) => b.bidder === 'trustedServer' + ) as TestBid; expect(trustedServerBid.params.bidderParams).toEqual({ appnexus: { placementId: 123 }, rubicon: { accountId: 'abc' }, @@ -806,28 +786,28 @@ describe('prebid/installPrebidNpm', () => { }); it('adds bids array to ad units that have none', () => { - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); const adUnits = [{ code: 'div-1' }] as TestAdUnit[]; - pbjs.requestBids({ adUnits }); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); expect(adUnits[0].bids).toHaveLength(1); - expect(adUnits[0].bids![0].bidder).toBe('trustedServer'); + expect(adUnits[0].bids[0].bidder).toBe('trustedServer'); }); it('normalizes a truthy non-array bids value without throwing', () => { - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); const adUnits = [ { code: 'example-malformed-slot', bids: { malformed: true } }, - ] as unknown as TestAdUnit[]; + ] as TestAdUnit[]; - expect(() => pbjs.requestBids({ adUnits })).not.toThrow(); + expect(() => pbjs.requestBids({ adUnits } as unknown as RequestBidsArg)).not.toThrow(); expect(adUnits[0].bids).toEqual([{ bidder: 'trustedServer', params: { bidderParams: {} } }]); }); it('includes zone from mediaTypes.banner.name in trustedServer params', () => { - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); const adUnits = [ { @@ -841,21 +821,17 @@ describe('prebid/installPrebidNpm', () => { bids: [{ bidder: 'kargo', params: { placementId: '_def' } }], }, ]; - pbjs.requestBids({ adUnits }); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid0 = adUnits[0].bids.find( - (b) => b.bidder === 'trustedServer' - ) as TrustedServerTestBid; + const tsBid0 = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; expect(tsBid0.params.zone).toBe('header'); - const tsBid1 = adUnits[1].bids.find( - (b) => b.bidder === 'trustedServer' - ) as TrustedServerTestBid; + const tsBid1 = adUnits[1].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; expect(tsBid1.params.zone).toBe('fixed_bottom'); }); it('omits zone when mediaTypes.banner.name is not set', () => { - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); const adUnits = [ { @@ -864,28 +840,24 @@ describe('prebid/installPrebidNpm', () => { bids: [{ bidder: 'appnexus', params: {} }], }, ]; - pbjs.requestBids({ adUnits }); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid = adUnits[0].bids.find( - (b) => b.bidder === 'trustedServer' - ) as TrustedServerTestBid; + const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; expect(tsBid.params.zone).toBeUndefined(); }); it('omits zone when ad unit has no mediaTypes', () => { - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); const adUnits = [{ bids: [{ bidder: 'rubicon', params: {} }] }]; - pbjs.requestBids({ adUnits }); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid = adUnits[0].bids.find( - (b) => b.bidder === 'trustedServer' - ) as TrustedServerTestBid; + const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; expect(tsBid.params.zone).toBeUndefined(); }); it('clears stale zone when existing trustedServer bid is reused', () => { - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); const adUnits = [ { @@ -898,27 +870,29 @@ describe('prebid/installPrebidNpm', () => { }, ]; - pbjs.requestBids({ adUnits }); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - let tsBid = adUnits[0].bids.find((b) => b.bidder === 'trustedServer') as TrustedServerTestBid; + let tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; expect(tsBid.params.zone).toBe('header'); expect(tsBid.params.custom).toBe('keep'); - delete (adUnits[0].mediaTypes.banner as { name?: string }).name; - pbjs.requestBids({ adUnits }); + delete adUnits[0].mediaTypes.banner.name; + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - tsBid = adUnits[0].bids.find((b) => b.bidder === 'trustedServer') as TrustedServerTestBid; + tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; expect(tsBid.params.zone).toBeUndefined(); expect(tsBid.params.custom).toBe('keep'); }); it('falls back to pbjs.adUnits when requestObj has no adUnits', () => { - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); - mockPbjs.adUnits = [{ bids: [{ bidder: 'openx', params: {} }] }]; - pbjs.requestBids({}); + mockPbjs.adUnits = [{ bids: [{ bidder: 'openx', params: {} }] }] as TestAdUnit[]; + pbjs.requestBids({} as RequestBidsArg); - const hasTsBidder = mockPbjs.adUnits[0].bids!.some((b) => b.bidder === 'trustedServer'); + const hasTsBidder = (mockPbjs.adUnits[0].bids ?? []).some( + (b: TestBid) => b.bidder === 'trustedServer' + ); expect(hasTsBidder).toBe(true); }); @@ -936,8 +910,10 @@ describe('prebid/installPrebidNpm', () => { }, ]); - const pbjs = installPrebidNpm() as TestPbjs; - pbjs.requestBids({ adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }] }); + const pbjs = installPrebidNpm(); + pbjs.requestBids({ + adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }], + } as unknown as RequestBidsArg); const cookieValue = document.cookie.match(/(?:^|; )ts-eids=([^;]+)/)?.[1]; expect(cookieValue).toBeDefined(); @@ -959,8 +935,10 @@ describe('prebid/installPrebidNpm', () => { }); mockGetUserIdsAsEids.mockReturnValue([]); - const pbjs = installPrebidNpm() as TestPbjs; - pbjs.requestBids({ adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }] }); + const pbjs = installPrebidNpm(); + pbjs.requestBids({ + adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }], + } as unknown as RequestBidsArg); expect(document.cookie).toBe(''); }); @@ -975,15 +953,15 @@ describe('prebid/installPrebidNpm with server-injected config', () => { mockGetUserIdsAsEids.mockReset(); mockGetUserIdsAsEids.mockReturnValue([]); document.cookie = 'ts-eids=; Path=/; Max-Age=0'; - delete (window as TestWindow).__tsjs_prebid; + delete testWindow.__tsjs_prebid; }); afterEach(() => { - delete (window as TestWindow).__tsjs_prebid; + delete testWindow.__tsjs_prebid; }); it('reads timeout and debug from window.__tsjs_prebid', () => { - (window as TestWindow).__tsjs_prebid = { timeout: 1500, debug: true }; + testWindow.__tsjs_prebid = { timeout: 1500, debug: true }; installPrebidNpm(); @@ -993,7 +971,7 @@ describe('prebid/installPrebidNpm with server-injected config', () => { }); it('explicit config overrides server-injected values', () => { - (window as TestWindow).__tsjs_prebid = { timeout: 1500, debug: true }; + testWindow.__tsjs_prebid = { timeout: 1500, debug: true }; installPrebidNpm({ timeout: 3000, debug: false }); @@ -1016,13 +994,13 @@ describe('prebid/installRefreshHandler', () => { mockRequestBids.mockReset(); mockPbjs.requestBids = mockRequestBids; mockPbjs.adUnits = []; - (window as TestWindow).tsjs = undefined; - delete (window as TestWindow).googletag; + testWindow.tsjs = undefined; + delete testWindow.googletag; }); afterEach(() => { - (window as TestWindow).tsjs = undefined; - delete (window as TestWindow).googletag; + testWindow.tsjs = undefined; + delete testWindow.googletag; }); it('builds refresh ad units from injected slot metadata', () => { @@ -1035,11 +1013,11 @@ describe('prebid/installRefreshHandler', () => { refresh: originalRefresh, getSlots: vi.fn(() => [gptSlot]), }; - (window as TestWindow).googletag = { + testWindow.googletag = { cmd: { push: (fn: () => void) => fn() }, pubads: () => pubads, }; - (window as TestWindow).tsjs = { + testWindow.tsjs = { adSlots: [ { id: 'homepage_header_ad', @@ -1093,11 +1071,11 @@ describe('prebid/installRefreshHandler', () => { refresh: originalRefresh, getSlots: vi.fn(() => [gptSlot]), }; - (window as TestWindow).googletag = { + testWindow.googletag = { cmd: { push: (fn: () => void) => fn() }, pubads: () => pubads, }; - (window as TestWindow).tsjs = { + testWindow.tsjs = { adSlots: [ { id: 'prefix_ad', @@ -1154,11 +1132,11 @@ describe('prebid/installRefreshHandler', () => { refresh: originalRefresh, getSlots: vi.fn(() => [headerSlot]), }; - (window as TestWindow).googletag = { + testWindow.googletag = { cmd: { push: (fn: () => void) => fn() }, pubads: () => pubads, }; - (window as TestWindow).tsjs = { + testWindow.tsjs = { adSlots: [ { id: 'header_ad', @@ -1184,11 +1162,11 @@ describe('prebid/installRefreshHandler', () => { expect(setTargetingForGPTAsync).toHaveBeenCalledWith(['div-ad-header']); expect(originalRefresh).toHaveBeenCalledWith([headerSlot], undefined); - delete mockPbjs.setTargetingForGPTAsync; + mockPbjs.setTargetingForGPTAsync = undefined; }); it('includes configured client-side bidders in refresh ad units', () => { - (window as TestWindow).__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; // Original publisher ad unit carries a client-side rubicon bid. mockPbjs.adUnits = [ { @@ -1208,11 +1186,11 @@ describe('prebid/installRefreshHandler', () => { refresh: originalRefresh, getSlots: vi.fn(() => [gptSlot]), }; - (window as TestWindow).googletag = { + testWindow.googletag = { cmd: { push: (fn: () => void) => fn() }, pubads: () => pubads, }; - (window as TestWindow).tsjs = { + testWindow.tsjs = { adSlots: [ { id: 'homepage_header_ad', @@ -1241,7 +1219,7 @@ describe('prebid/installRefreshHandler', () => { }) ); - delete (window as TestWindow).__tsjs_prebid; + delete testWindow.__tsjs_prebid; mockPbjs.adUnits = []; }); @@ -1263,11 +1241,11 @@ describe('prebid/installRefreshHandler', () => { refresh: originalRefresh, getSlots: vi.fn(() => [gptSlot]), }; - (window as TestWindow).googletag = { + testWindow.googletag = { cmd: { push: (fn: () => void) => fn() }, pubads: () => pubads, }; - (window as TestWindow).tsjs = { + testWindow.tsjs = { adSlots: [ { id: 'homepage_header_ad', @@ -1309,7 +1287,7 @@ describe('prebid/installRefreshHandler', () => { // publisher's Prebid ad unit is keyed by the inner div_id. The synthetic // refresh code stays the GPT element id (so GPT can match it), while params // and client-side bids are recovered from the injected div_id candidate. - (window as TestWindow).__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; mockPbjs.adUnits = [ { code: 'div-ad-x', @@ -1328,11 +1306,11 @@ describe('prebid/installRefreshHandler', () => { refresh: originalRefresh, getSlots: vi.fn(() => [gptSlot]), }; - (window as TestWindow).googletag = { + testWindow.googletag = { cmd: { push: (fn: () => void) => fn() }, pubads: () => pubads, }; - (window as TestWindow).tsjs = { + testWindow.tsjs = { adSlots: [ { id: 'x_ad', @@ -1368,7 +1346,7 @@ describe('prebid/installRefreshHandler', () => { }) ); - delete (window as TestWindow).__tsjs_prebid; + delete testWindow.__tsjs_prebid; mockPbjs.adUnits = []; }); @@ -1396,11 +1374,11 @@ describe('prebid/installRefreshHandler', () => { refresh: originalRefresh, getSlots: vi.fn(() => [gptSlot]), }; - (window as TestWindow).googletag = { + testWindow.googletag = { cmd: { push: (fn: () => void) => fn() }, pubads: () => pubads, }; - (window as TestWindow).tsjs = { + testWindow.tsjs = { adSlots: [ { id: 'homepage_header_ad', @@ -1459,11 +1437,11 @@ describe('prebid/installRefreshHandler', () => { }; const setTargetingForGPTAsync = vi.fn(); mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; - (window as TestWindow).googletag = { + testWindow.googletag = { cmd: { push: (fn: () => void) => fn() }, pubads: () => pubads, }; - (window as TestWindow).tsjs = { + testWindow.tsjs = { adSlots: [ { id: 'homepage_header_ad', @@ -1528,11 +1506,11 @@ describe('prebid/installRefreshHandler', () => { refresh: originalRefresh, getSlots: vi.fn(() => [gptSlot]), }; - (window as TestWindow).googletag = { + testWindow.googletag = { cmd: { push: (fn: () => void) => fn() }, pubads: () => pubads, }; - (window as TestWindow).tsjs = { adInitRefreshInProgress: true }; + testWindow.tsjs = { adInitRefreshInProgress: true }; installRefreshHandler(750); pubads.refresh([gptSlot]); @@ -1553,11 +1531,11 @@ describe('prebid/installRefreshHandler', () => { refresh: originalRefresh, getSlots: vi.fn(() => [gptSlot]), }; - (window as TestWindow).googletag = { + testWindow.googletag = { cmd: { push: (fn: () => void) => fn() }, pubads: () => pubads, }; - (window as TestWindow).tsjs = { adInitRefreshInProgress: false }; + testWindow.tsjs = { adInitRefreshInProgress: false }; installRefreshHandler(750); pubads.refresh([gptSlot]); @@ -1569,7 +1547,7 @@ describe('prebid/installRefreshHandler', () => { describe('prebid publisher snapshots and delivery refreshes', () => { let deliveryAdIds = new WeakMap(); - let installedGptSlots: TestGptSlot[] = []; + let installedGptSlots: Array> = []; let auctionSequence = 0; beforeEach(() => { @@ -1580,24 +1558,26 @@ describe('prebid publisher snapshots and delivery refreshes', () => { mockRequestBids.mockReset(); mockPbjs.requestBids = mockRequestBids; mockPbjs.removeAdUnit = mockRemoveAdUnit; - delete mockPbjs.__tsRemoveAdUnitWrapped; + delete (mockPbjs as unknown as Record).__tsRemoveAdUnitWrapped; mockPbjs.adUnits = []; mockGetUserIdsAsEids.mockReset(); mockGetUserIdsAsEids.mockReturnValue([]); - mockGetBidAdapter.mockReturnValue({}); - delete mockPbjs.setTargetingForGPTAsync; - delete (window as TestWindow).__tsjs_prebid; - (window as TestWindow).tsjs = undefined; - delete (window as TestWindow).googletag; + // By default the manifest declares all adapters compiled in. + (window as unknown as { __tsjs_prebid_bundle?: unknown }).__tsjs_prebid_bundle = + DEFAULT_BUNDLE_MANIFEST; + mockPbjs.setTargetingForGPTAsync = undefined; + delete testWindow.__tsjs_prebid; + testWindow.tsjs = undefined; + delete testWindow.googletag; }); afterEach(() => { - delete (window as TestWindow).__tsjs_prebid; - (window as TestWindow).tsjs = undefined; - delete (window as TestWindow).googletag; + delete testWindow.__tsjs_prebid; + testWindow.tsjs = undefined; + delete testWindow.googletag; }); - function installGpt(slots: TestGptSlot[]) { + function installGpt(slots: Array>) { installedGptSlots = slots; for (const slot of slots) { if (!slot || typeof slot !== 'object') continue; @@ -1614,7 +1594,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { refresh: originalRefresh, getSlots: vi.fn(() => slots), }; - (window as TestWindow).googletag = { + testWindow.googletag = { cmd: { push: (fn: () => void) => fn() }, pubads: () => pubads, }; @@ -1622,7 +1602,9 @@ describe('prebid publisher snapshots and delivery refreshes', () => { return { originalRefresh, pubads }; } - function refreshAdUnitFromLastRequest(): RefreshAdUnit { + function refreshAdUnitFromLastRequest(): + | (Record & { code?: string; bids?: TestBid[] }) + | undefined { const lastCall = mockRequestBids.mock.calls[mockRequestBids.mock.calls.length - 1]; return lastCall?.[0]?.adUnits?.[0]; } @@ -1632,10 +1614,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { options: { auctionId?: string; applyTargeting?: boolean } = {} ): void { const auctionId = options.auctionId ?? `example-auction-${auctionSequence++}`; - const bidResponses: Record< - string, - { bids: Array<{ adId: string; adUnitCode: string; auctionId: string }> } - > = {}; + const bidResponses: Record> }> = {}; for (const unit of opts?.adUnits ?? []) { if (!unit.code) continue; @@ -1656,7 +1635,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { } it('recovers inline params, ordered client bids, and zone when pbjs.adUnits is empty', () => { - (window as TestWindow).__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; + testWindow.__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; const runtimeInstance = 'example-runtime-instance'; const code = `example-slot-${runtimeInstance}`; const slot = { @@ -1666,7 +1645,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { pubads } = installGpt([slot]); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); const firstParams = { placement: 'first' }; const effectiveParams = { placement: 'effective' }; @@ -1683,7 +1662,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { ], }, ], - }); + } as unknown as RequestBidsArg); effectiveParams.placement = 'changed-after-auction'; pubads.refresh([slot]); @@ -1708,7 +1687,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }); it('isolates nested bidder-param objects and arrays from later publisher mutation', () => { - (window as TestWindow).__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; + testWindow.__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; const code = 'example-nested-params-slot'; const slot = { getSlotElementId: () => code, @@ -1717,7 +1696,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { pubads } = installGpt([slot]); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); const serverParams = { placement: { rules: [{ label: 'original-rule' }], @@ -1738,7 +1717,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { ], }, ], - }); + } as unknown as RequestBidsArg); serverParams.placement.rules[0].label = 'changed-rule'; serverParams.placement.sizes.push(999); browserParams.groups[0].values[0] = 'changed-value'; @@ -1767,12 +1746,10 @@ describe('prebid publisher snapshots and delivery refreshes', () => { const firstRefreshBids = refreshAdUnitFromLastRequest().bids; expect(firstRefreshBids).toEqual(expectedBids); - const refreshServerParams = firstRefreshBids[0].params as NestedServerParams; - refreshServerParams.bidderParams.exampleServer.placement.rules[0].label = + firstRefreshBids[0].params.bidderParams.exampleServer.placement.rules[0].label = 'changed-refresh-rule'; - refreshServerParams.bidderParams.exampleServer.placement.sizes.push(777); - const refreshBrowserParams = firstRefreshBids[1].params as NestedBrowserParams; - refreshBrowserParams.groups[0].values[0] = 'changed-refresh-value'; + firstRefreshBids[0].params.bidderParams.exampleServer.placement.sizes.push(777); + firstRefreshBids[1].params.groups[0].values[0] = 'changed-refresh-value'; pubads.refresh([slot]); expect(refreshAdUnitFromLastRequest().bids).toEqual(expectedBids); @@ -1787,7 +1764,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { pubads } = installGpt([slot]); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); pbjs.requestBids({ adUnits: [ @@ -1797,7 +1774,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { bids: [{ bidder: 'exampleServer', params: { placement: 'one' } }], }, ], - }); + } as unknown as RequestBidsArg); pubads.refresh([slot]); expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: { exampleServer: { placement: 'one' } }, @@ -1818,7 +1795,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { bids: [{ bidder: 'exampleServer', params: { placement: 'two' } }], }, ], - }); + } as unknown as RequestBidsArg); pubads.refresh([slot]); expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ @@ -1847,7 +1824,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { pubads } = installGpt([slotOne, slotTwo, globalSlot]); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); pbjs.requestBids({ adUnits: [ @@ -1860,7 +1837,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { bids: [{ bidder: 'exampleServer', params: { placement: 'two' } }], }, ], - }); + } as unknown as RequestBidsArg); mockPbjs.adUnits = [ { code: 'example-global-code', @@ -1883,7 +1860,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }); it('prefers a rich live unit when a fresh same-code request overwrites the snapshot with empty bids', () => { - (window as TestWindow).__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; + testWindow.__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; const code = 'example-live-rich-slot'; const slot = { getSlotElementId: () => code, @@ -1900,10 +1877,10 @@ describe('prebid publisher snapshots and delivery refreshes', () => { ], }; mockPbjs.adUnits = [liveUnit]; - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); pbjs.requestBids(); - pbjs.requestBids({ adUnits: [{ code, bids: [] }] }); + pbjs.requestBids({ adUnits: [{ code, bids: [] }] } as unknown as RequestBidsArg); pubads.refresh([slot]); expect(refreshAdUnitFromLastRequest().bids).toEqual([ @@ -1924,11 +1901,11 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { pubads } = installGpt([slot]); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); pbjs.requestBids({ adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: { placement: 'snapshot' } }] }], - }); + } as unknown as RequestBidsArg); mockPbjs.adUnits = [{ code, bids: [] }]; pubads.refresh([slot]); @@ -1946,16 +1923,20 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), })); const { pubads } = installGpt(slots); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); pbjs.requestBids({ adUnits: codes.map((code) => ({ code, bids: [{ bidder: 'exampleServer', params: { placement: code } }], })), - }); - pbjs.removeAdUnit(codes[0]); - pbjs.removeAdUnit([codes[1]]); + } as unknown as RequestBidsArg); + (pbjs as unknown as { removeAdUnit: (adUnitCode?: string | string[]) => void }).removeAdUnit( + codes[0] + ); + (pbjs as unknown as { removeAdUnit: (adUnitCode?: string | string[]) => void }).removeAdUnit([ + codes[1], + ]); pubads.refresh([slots[0]]); expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); @@ -1966,7 +1947,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { exampleServer: { placement: codes[2] }, }); - pbjs.removeAdUnit(); + (pbjs as unknown as { removeAdUnit: (adUnitCode?: string | string[]) => void }).removeAdUnit(); pubads.refresh([slots[2]]); expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); }); @@ -1988,7 +1969,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { pubads } = installGpt([oldestSlot, activeSlot]); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); for (let index = 0; index < capacity; index += 1) { pbjs.requestBids({ @@ -1998,7 +1979,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { bids: [{ bidder: 'exampleServer', params: { placement: index } }], }, ], - }); + } as unknown as RequestBidsArg); } pubads.refresh([activeSlot]); @@ -2009,7 +1990,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { bids: [{ bidder: 'exampleServer', params: { placement: capacity } }], }, ], - }); + } as unknown as RequestBidsArg); pubads.refresh([oldestSlot]); expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); @@ -2030,12 +2011,12 @@ describe('prebid publisher snapshots and delivery refreshes', () => { getTargeting: () => [], clearTargeting: vi.fn(), }; - (window as TestWindow).tsjs = { + testWindow.tsjs = { adSlots: [{ div_id: 'example-covered-two', formats: [[300, 250]], targeting: {} }], }; const { originalRefresh, pubads } = installGpt([slotOne, slotTwo]); mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); pbjs.requestBids({ adUnits: [ @@ -2046,7 +2027,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { pubads.refresh([slotOne]); pubads.refresh([slotTwo]); }, - }); + } as unknown as RequestBidsArg); expect(mockRequestBids).toHaveBeenCalledTimes(1); expect(slotOne.clearTargeting).not.toHaveBeenCalled(); @@ -2065,11 +2046,11 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }; const { originalRefresh, pubads } = installGpt([slot]); mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); pbjs.requestBids({ adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - }); + } as unknown as RequestBidsArg); pubads.refresh([slot]); expect(mockRequestBids).toHaveBeenCalledTimes(1); @@ -2098,12 +2079,12 @@ describe('prebid publisher snapshots and delivery refreshes', () => { syntheticBidsBackHandler = opts.bidsBackHandler; } }); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); pbjs.requestBids({ adUnits: [{ code: 'example-sra-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }], bidsBackHandler: () => pubads.refresh([deliverySlot, independentSlot], refreshOptions), - }); + } as unknown as RequestBidsArg); expect(originalRefresh).not.toHaveBeenCalled(); expect(independentSlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); @@ -2127,12 +2108,12 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }; const { originalRefresh, pubads } = installGpt([coveredSlot, gamOnlySlot]); mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); pbjs.requestBids({ adUnits: [{ code: 'example-covered', bids: [{ bidder: 'exampleServer', params: {} }] }], bidsBackHandler: () => pubads.refresh(), - }); + } as unknown as RequestBidsArg); expect(mockRequestBids).toHaveBeenCalledTimes(2); expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); @@ -2156,7 +2137,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }; const { originalRefresh, pubads } = installGpt([coveredSlot, unrelatedSlot]); mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); pbjs.requestBids({ adUnits: [{ code: 'example-covered', bids: [{ bidder: 'exampleServer', params: {} }] }], @@ -2164,15 +2145,15 @@ describe('prebid publisher snapshots and delivery refreshes', () => { pubads.refresh([unrelatedSlot]); pubads.refresh([coveredSlot, unrelatedSlot]); }, - }); + } as unknown as RequestBidsArg); expect(mockRequestBids).toHaveBeenCalledTimes(3); - expect(mockRequestBids.mock.calls[1][0].adUnits.map((unit: TestAdUnit) => unit.code)).toEqual([ - 'example-unrelated', - ]); - expect(mockRequestBids.mock.calls[2][0].adUnits.map((unit: TestAdUnit) => unit.code)).toEqual([ - 'example-unrelated', - ]); + expect( + mockRequestBids.mock.calls[1][0].adUnits.map((unit: { code?: string }) => unit.code) + ).toEqual(['example-unrelated']); + expect( + mockRequestBids.mock.calls[2][0].adUnits.map((unit: { code?: string }) => unit.code) + ).toEqual(['example-unrelated']); expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('ts_initial'); expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path'); @@ -2195,7 +2176,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { const refreshSlots = [...coveredSlots, gamOnlySlot]; const { originalRefresh, pubads } = installGpt(refreshSlots); mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); pbjs.requestBids({ adUnits: coveredSlots.map((_, index) => ({ @@ -2203,7 +2184,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { bids: [{ bidder: 'exampleServer', params: { placement: index } }], })), bidsBackHandler: () => pubads.refresh(refreshSlots), - }); + } as unknown as RequestBidsArg); expect(mockRequestBids).toHaveBeenCalledTimes(2); coveredSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); @@ -2226,11 +2207,11 @@ describe('prebid publisher snapshots and delivery refreshes', () => { mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts, { applyTargeting: false }) ); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); pbjs.requestBids({ adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - }); + } as unknown as RequestBidsArg); vi.advanceTimersByTime(5001); pubads.refresh([slot]); @@ -2255,11 +2236,11 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }; const { originalRefresh, pubads } = installGpt([slot]); mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); pbjs.requestBids({ adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - }); + } as unknown as RequestBidsArg); vi.advanceTimersByTime(5001); pubads.refresh([slot]); @@ -2287,7 +2268,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts, { auctionId, applyTargeting: false }) ); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); pbjs.requestBids({ adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], @@ -2297,7 +2278,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { pubads.refresh([slot]); }, 1500); }, - }); + } as unknown as RequestBidsArg); vi.advanceTimersByTime(1500); @@ -2332,24 +2313,28 @@ describe('prebid publisher snapshots and delivery refreshes', () => { mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts, { auctionId, applyTargeting: false }) ); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); pbjs.requestBids({ adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], bidsBackHandler: () => { - pbjs.setTargetingForGPTAsync(null, () => () => true); + ( + pbjs as unknown as { setTargetingForGPTAsync: (codes?: string[]) => void } + ).setTargetingForGPTAsync(null, () => () => true); pubads.refresh([slot]); }, - }); + } as unknown as RequestBidsArg); auctionId = 'example-no-argument-auction'; pbjs.requestBids({ adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], bidsBackHandler: () => { - pbjs.setTargetingForGPTAsync(); + ( + pbjs as unknown as { setTargetingForGPTAsync: (codes?: string[]) => void } + ).setTargetingForGPTAsync(); pubads.refresh([slot]); }, - }); + } as unknown as RequestBidsArg); expect(setTargetingForGPTAsync).toHaveBeenNthCalledWith(1, null, expect.any(Function)); expect(setTargetingForGPTAsync).toHaveBeenNthCalledWith(2); @@ -2357,7 +2342,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(slot.clearTargeting).not.toHaveBeenCalled(); expect(originalRefresh).toHaveBeenNthCalledWith(1, [slot], undefined); expect(originalRefresh).toHaveBeenNthCalledWith(2, [slot], undefined); - delete mockPbjs.setTargetingForGPTAsync; + mockPbjs.setTargetingForGPTAsync = undefined; }); it('correlates requested no-bid slots without manufacturing unrelated bid state', () => { @@ -2373,14 +2358,14 @@ describe('prebid publisher snapshots and delivery refreshes', () => { opts?.bidsBackHandler?.({ 'example-no-bid-delivery': { bids: [null, {}] } }, false, 'bad'); } ); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); pbjs.requestBids({ adUnits: [ { code: 'example-no-bid-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, ], bidsBackHandler: () => pubads.refresh([slot]), - }); + } as unknown as RequestBidsArg); expect(mockRequestBids).toHaveBeenCalledTimes(1); expect(slot.clearTargeting).not.toHaveBeenCalled(); @@ -2399,13 +2384,13 @@ describe('prebid publisher snapshots and delivery refreshes', () => { mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts, { applyTargeting: false }) ); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); // Model an initial impression rendered with display() after an auction // that did not apply hb_adid targeting. Its code-only state is unconsumed. pbjs.requestBids({ adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - }); + } as unknown as RequestBidsArg); pubads.refresh([slot]); expect(mockRequestBids).toHaveBeenCalledTimes(1); @@ -2430,12 +2415,12 @@ describe('prebid publisher snapshots and delivery refreshes', () => { mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts, { applyTargeting: false }) ); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); pbjs.requestBids({ adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], bidsBackHandler: () => pubads.refresh([slot]), - }); + } as unknown as RequestBidsArg); expect(mockRequestBids).toHaveBeenCalledTimes(2); expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); @@ -2467,11 +2452,11 @@ describe('prebid publisher snapshots and delivery refreshes', () => { } completePublisherAuction(opts); }); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); pbjs.requestBids({ adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - }); + } as unknown as RequestBidsArg); deliveryAdIds.set(slot, oldestAdId); pubads.refresh([slot]); @@ -2499,7 +2484,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { const refreshSlots = [innerSlot, outerSlot, gamOnlySlot]; const { originalRefresh, pubads } = installGpt(refreshSlots); mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); pbjs.requestBids({ adUnits: [ @@ -2511,9 +2496,9 @@ describe('prebid publisher snapshots and delivery refreshes', () => { { code: 'example-inner-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, ], bidsBackHandler: () => pubads.refresh(refreshSlots), - }); + } as unknown as RequestBidsArg); }, - }); + } as unknown as RequestBidsArg); expect(mockRequestBids).toHaveBeenCalledTimes(3); expect(innerSlot.clearTargeting).not.toHaveBeenCalled(); @@ -2534,7 +2519,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts, { applyTargeting: false }) ); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); let deferredRefresh: Promise | undefined; pbjs.requestBids({ @@ -2544,7 +2529,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { bidsBackHandler: () => { deferredRefresh = Promise.resolve().then(() => pubads.refresh([slot])); }, - }); + } as unknown as RequestBidsArg); await deferredRefresh; expect(mockRequestBids).toHaveBeenCalledTimes(1); @@ -2566,7 +2551,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts, { auctionId, applyTargeting: false }) ); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); let deferredRefresh: Promise | undefined; pbjs.requestBids({ @@ -2577,7 +2562,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { pubads.refresh([slot]); }); }, - }); + } as unknown as RequestBidsArg); await deferredRefresh; expect(mockRequestBids).toHaveBeenCalledTimes(1); @@ -2595,16 +2580,16 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }; const { originalRefresh, pubads } = installGpt([slot]); mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); pbjs.requestBids({ adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], bidsBackHandler: () => {}, - }); + } as unknown as RequestBidsArg); pbjs.requestBids({ adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], bidsBackHandler: () => {}, - }); + } as unknown as RequestBidsArg); pubads.refresh([slot]); expect(mockRequestBids).toHaveBeenCalledTimes(2); @@ -2629,12 +2614,13 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }; const { originalRefresh, pubads } = installGpt([slot]); mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); pbjs.requestBids({ adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => pubads.refresh([slot, undefined, null]), - }); + bidsBackHandler: () => + pubads.refresh([slot, undefined, null] as unknown as Array>), + } as unknown as RequestBidsArg); expect(mockRequestBids).toHaveBeenCalledTimes(1); expect(slot.clearTargeting).not.toHaveBeenCalled(); @@ -2654,13 +2640,13 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }; const { originalRefresh, pubads } = installGpt([slot]); mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); const request = { adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], }; - pbjs.requestBids(request); - pbjs.requestBids(request); + pbjs.requestBids(request as unknown as RequestBidsArg); + pbjs.requestBids(request as unknown as RequestBidsArg); pubads.refresh([slot]); expect(request).not.toHaveProperty('bidsBackHandler'); @@ -2773,10 +2759,12 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }); it('does not stack the removeAdUnit lifecycle wrapper across installation', () => { - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); installPrebidNpm(); - pbjs.removeAdUnit('example-reinstalled-slot'); + (pbjs as unknown as { removeAdUnit: (adUnitCode?: string | string[]) => void }).removeAdUnit( + 'example-reinstalled-slot' + ); expect(mockRemoveAdUnit).toHaveBeenCalledTimes(1); }); @@ -2794,7 +2782,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }; const { originalRefresh, pubads } = installGpt([outerSlot, innerSlot]); mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); pbjs.requestBids({ adUnits: [ @@ -2806,10 +2794,10 @@ describe('prebid publisher snapshots and delivery refreshes', () => { { code: 'example-inner-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, ], bidsBackHandler: () => pubads.refresh([innerSlot]), - }); + } as unknown as RequestBidsArg); pubads.refresh([outerSlot]); }, - }); + } as unknown as RequestBidsArg); expect(mockRequestBids).toHaveBeenCalledTimes(2); expect(innerSlot.clearTargeting).not.toHaveBeenCalled(); @@ -2829,7 +2817,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts, { applyTargeting: false }) ); - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); expect(() => pbjs.requestBids({ @@ -2842,7 +2830,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { bidsBackHandler: () => { throw new Error('example callback failure'); }, - }) + } as unknown as RequestBidsArg) ).toThrow('example callback failure'); pubads.refresh([slot]); @@ -2878,19 +2866,19 @@ describe('prebid/client-side bidders', () => { mockPbjs.adUnits = []; mockGetUserIdsAsEids.mockReset(); mockGetUserIdsAsEids.mockReturnValue([]); - // By default, pretend all adapters are registered - mockGetBidAdapter.mockReturnValue({}); - delete (window as TestWindow).__tsjs_prebid; + // By default the manifest declares all adapters compiled in. + testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; + delete testWindow.__tsjs_prebid; }); afterEach(() => { - delete (window as TestWindow).__tsjs_prebid; + delete testWindow.__tsjs_prebid; }); it('excludes client-side bidders from trustedServer bidderParams', () => { - (window as TestWindow).__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); const adUnits = [ { @@ -2901,11 +2889,11 @@ describe('prebid/client-side bidders', () => { ], }, ]; - pbjs.requestBids({ adUnits }); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid = adUnits[0].bids.find((b) => b.bidder === 'trustedServer') as TrustedServerTestBid; + const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; expect(tsBid).toBeDefined(); - // rubicon should NOT be in bidderParams because it runs client-side + // rubicon should NOT be in bidderParams — it runs client-side expect(tsBid.params.bidderParams).toEqual({ appnexus: { placementId: 123 }, kargo: { placementId: 'k1' }, @@ -2913,9 +2901,9 @@ describe('prebid/client-side bidders', () => { }); it('preserves client-side bidder bids as standalone entries', () => { - (window as TestWindow).__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); const adUnits = [ { @@ -2925,19 +2913,19 @@ describe('prebid/client-side bidders', () => { ], }, ]; - pbjs.requestBids({ adUnits }); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); // rubicon bid should remain untouched as a standalone entry - const rubiconBid = adUnits[0].bids.find((b) => b.bidder === 'rubicon') as TestBid; + const rubiconBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'rubicon') as TestBid; expect(rubiconBid).toBeDefined(); expect(rubiconBid.params).toEqual({ accountId: 'abc' }); - expect(adUnits[0].bids.find((b) => b.bidder === 'appnexus')).toBeUndefined(); + expect(adUnits[0].bids.find((b: TestBid) => b.bidder === 'appnexus')).toBeUndefined(); }); it('handles multiple client-side bidders', () => { - (window as TestWindow).__tsjs_prebid = { clientSideBidders: ['rubicon', 'openx'] }; + testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon', 'openx'] }; - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); const adUnits = [ { @@ -2948,23 +2936,23 @@ describe('prebid/client-side bidders', () => { ], }, ]; - pbjs.requestBids({ adUnits }); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid = adUnits[0].bids.find((b) => b.bidder === 'trustedServer') as TrustedServerTestBid; + const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; // Only appnexus should be in bidderParams expect(tsBid.params.bidderParams).toEqual({ appnexus: { placementId: 123 }, }); // Both client-side bidders should remain - expect(adUnits[0].bids.find((b) => b.bidder === 'rubicon')).toBeDefined(); - expect(adUnits[0].bids.find((b) => b.bidder === 'openx')).toBeDefined(); - expect(adUnits[0].bids.find((b) => b.bidder === 'appnexus')).toBeUndefined(); + expect(adUnits[0].bids.find((b: TestBid) => b.bidder === 'rubicon')).toBeDefined(); + expect(adUnits[0].bids.find((b: TestBid) => b.bidder === 'openx')).toBeDefined(); + expect(adUnits[0].bids.find((b: TestBid) => b.bidder === 'appnexus')).toBeUndefined(); }); it('behaves normally when no client-side bidders are configured', () => { - // No __tsjs_prebid at all, so all bidders go server-side - const pbjs = installPrebidNpm() as TestPbjs; + // No __tsjs_prebid at all — all bidders go server-side + const pbjs = installPrebidNpm(); const adUnits = [ { @@ -2974,9 +2962,9 @@ describe('prebid/client-side bidders', () => { ], }, ]; - pbjs.requestBids({ adUnits }); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid = adUnits[0].bids.find((b) => b.bidder === 'trustedServer') as TrustedServerTestBid; + const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; expect(tsBid.params.bidderParams).toEqual({ appnexus: { placementId: 123 }, rubicon: { accountId: 'abc' }, @@ -2984,9 +2972,9 @@ describe('prebid/client-side bidders', () => { }); it('behaves normally when client-side bidders list is empty', () => { - (window as TestWindow).__tsjs_prebid = { clientSideBidders: [] }; + testWindow.__tsjs_prebid = { clientSideBidders: [] }; - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); const adUnits = [ { @@ -2996,9 +2984,9 @@ describe('prebid/client-side bidders', () => { ], }, ]; - pbjs.requestBids({ adUnits }); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid = adUnits[0].bids.find((b) => b.bidder === 'trustedServer') as TrustedServerTestBid; + const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; expect(tsBid.params.bidderParams).toEqual({ appnexus: { placementId: 123 }, rubicon: { accountId: 'abc' }, @@ -3006,9 +2994,9 @@ describe('prebid/client-side bidders', () => { }); it('still injects trustedServer when all bidders are client-side', () => { - (window as TestWindow).__tsjs_prebid = { clientSideBidders: ['rubicon', 'appnexus'] }; + testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon', 'appnexus'] }; - const pbjs = installPrebidNpm() as TestPbjs; + const pbjs = installPrebidNpm(); const adUnits = [ { @@ -3018,29 +3006,27 @@ describe('prebid/client-side bidders', () => { ], }, ]; - pbjs.requestBids({ adUnits }); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); // trustedServer should still be present (even with empty bidderParams) - const tsBid = adUnits[0].bids.find((b) => b.bidder === 'trustedServer') as TrustedServerTestBid; + const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; expect(tsBid).toBeDefined(); expect(tsBid.params.bidderParams).toEqual({}); }); - it('logs error when a client-side bidder has no adapter loaded', () => { - // rubicon is registered, but openx is not - mockGetBidAdapter.mockImplementation((bidder: string) => - bidder === 'rubicon' ? {} : undefined - ); - (window as TestWindow).__tsjs_prebid = { clientSideBidders: ['rubicon', 'openx'] }; + it('logs error when a client-side bidder has no adapter in the external bundle', () => { + // rubicon is compiled into the external bundle, but openx is not + testWindow.__tsjs_prebid_bundle = { + ...DEFAULT_BUNDLE_MANIFEST, + adapters: ['rubicon'], + bidderCodes: ['rubicon'], + }; + testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon', 'openx'] }; const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); installPrebidNpm(); - // Should have been called to check both bidders - expect(mockGetBidAdapter).toHaveBeenCalledWith('rubicon'); - expect(mockGetBidAdapter).toHaveBeenCalledWith('openx'); - // Should log an error for the missing adapter. // log.error() uses styled console output: console.error('%c[tsjs]%c ...:', style, reset, ...args) // so the actual message is the 4th argument. @@ -3048,30 +3034,127 @@ describe('prebid/client-side bidders', () => { const hasOpenxError = errorCalls.some((args) => args.some( (a) => - typeof a === 'string' && a.includes('client-side bidder "openx" has no adapter loaded') + typeof a === 'string' && + a.includes('client-side bidder "openx" has no adapter in the external Prebid bundle') ) ); expect(hasOpenxError).toBe(true); - // Should NOT log an error for the registered adapter + // The error should point at the operator surface: the CLI config key, + // not the internal build script. + const pointsAtBundleConfig = errorCalls.some((args) => + args.some((a) => typeof a === 'string' && a.includes('[integrations.prebid.bundle].adapters')) + ); + expect(pointsAtBundleConfig).toBe(true); + + // Should NOT log an error for the compiled-in adapter const hasRubiconError = errorCalls.some((args) => args.some((a) => typeof a === 'string' && a.includes('client-side bidder "rubicon"')) ); expect(hasRubiconError).toBe(false); errorSpy.mockRestore(); + testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; + }); + + it('accepts alias bidder codes stamped in bidderCodes', () => { + // The adf module registers adf plus the adform/adformOpenRTB aliases; + // the module-name list alone would flag them as missing. + testWindow.__tsjs_prebid_bundle = { + ...DEFAULT_BUNDLE_MANIFEST, + adapters: ['adf'], + bidderCodes: ['adf', 'adform', 'adformOpenRTB'], + }; + testWindow.__tsjs_prebid = { clientSideBidders: ['adform'] }; + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + installPrebidNpm(); + + const hasAdapterError = errorSpy.mock.calls.some((args) => + args.some( + (a) => typeof a === 'string' && a.includes('has no adapter in the external Prebid bundle') + ) + ); + expect(hasAdapterError).toBe(false); + + errorSpy.mockRestore(); + testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; + }); + + it('rejects a module file stem that is not a registered bidder code', () => { + // a1MediaBidAdapter.js registers a1media — configuring the file stem + // must be flagged even though the module itself is compiled in. + testWindow.__tsjs_prebid_bundle = { + ...DEFAULT_BUNDLE_MANIFEST, + adapters: ['a1Media'], + bidderCodes: ['a1media'], + }; + testWindow.__tsjs_prebid = { clientSideBidders: ['a1Media'] }; + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + installPrebidNpm(); + + const hasAdapterError = errorSpy.mock.calls.some((args) => + args.some( + (a) => + typeof a === 'string' && + a.includes('client-side bidder "a1Media" has no adapter in the external Prebid bundle') + ) + ); + expect(hasAdapterError).toBe(true); + + errorSpy.mockRestore(); + testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; + }); + + it('treats a malformed manifest as unstamped instead of throwing', () => { + // The manifest is a plain window global any page script can overwrite. + testWindow.__tsjs_prebid_bundle = { adapters: 'rubicon', userIdModules: 42 }; + testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(() => installPrebidNpm()).not.toThrow(); + + const hasManifestWarn = warnSpy.mock.calls.some((args) => + args.some((a) => typeof a === 'string' && a.includes('did not stamp an adapter manifest')) + ); + expect(hasManifestWarn).toBe(true); + + warnSpy.mockRestore(); + testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; + }); + + it('warns when the external bundle stamped no adapter manifest', () => { + delete testWindow.__tsjs_prebid_bundle; + testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + installPrebidNpm(); + + const hasManifestWarn = warnSpy.mock.calls.some((args) => + args.some((a) => typeof a === 'string' && a.includes('did not stamp an adapter manifest')) + ); + expect(hasManifestWarn).toBe(true); + + warnSpy.mockRestore(); + testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; }); it('does not log errors when all client-side bidders have adapters', () => { - mockGetBidAdapter.mockReturnValue({}); - (window as TestWindow).__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); installPrebidNpm(); const hasAdapterError = errorSpy.mock.calls.some((args) => - args.some((a) => typeof a === 'string' && a.includes('has no adapter loaded')) + args.some( + (a) => typeof a === 'string' && a.includes('has no adapter in the external Prebid bundle') + ) ); expect(hasAdapterError).toBe(false); @@ -3079,6 +3162,55 @@ describe('prebid/client-side bidders', () => { }); }); +describe('prebid/self-init without the external bundle', () => { + afterEach(() => { + // Restore the module registry and the full mock global for later suites. + testWindow.pbjs = mockPbjs; + delete testWindow.googletag; + vi.resetModules(); + }); + + it('disables the integration and leaves pbjs and GPT untouched', async () => { + // Simulate a failed external bundle load: window.pbjs is still the + // head-injected stub with no Prebid.js API. The module captures the + // global at evaluation time, so reset the registry and re-import. + vi.resetModules(); + const barePbjs: { + que: Array<() => void>; + cmd: Array<() => void>; + requestBids?: unknown; + } = { que: [], cmd: [] }; + testWindow.pbjs = barePbjs; + const pubads = { refresh: vi.fn() }; + const cmdPush = vi.fn((callback: () => void) => callback()); + testWindow.googletag = { cmd: { push: cmdPush }, pubads: () => pubads }; + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await import('../../../src/integrations/prebid/index'); + + // The bail-out is logged loudly. + const hasBailOutError = errorSpy.mock.calls.some((args) => + args.some((a) => typeof a === 'string' && a.includes('has no Prebid.js API')) + ); + expect(hasBailOutError).toBe(true); + + // requestBids is left unwrapped and no adapter registration was attempted. + expect(barePbjs.requestBids).toBeUndefined(); + + // The refresh handler must not install: a wrapped googletag refresh + // would clear TS-applied targeting and then fail to run any auction. + expect(cmdPush).not.toHaveBeenCalled(); + expect( + (pubads as { refresh: unknown; __tsRefreshWrapped?: boolean }).__tsRefreshWrapped + ).toBeUndefined(); + + // The sentinel stays unset so a later successful install can still run. + expect(testWindow.__tsjsPrebidShimInstalled).toBeUndefined(); + + errorSpy.mockRestore(); + }); +}); + describe('prebid self-init user ID module timing', () => { const userSyncCallCount = () => mockSetConfig.mock.calls.filter(([arg]) => arg && typeof arg === 'object' && 'userSync' in arg) @@ -3099,7 +3231,7 @@ describe('prebid self-init user ID module timing', () => { it('installs user ID modules immediately when the bundle loads after window load', async () => { // The GPT slim loader appends this bundle from a window.load handler, so - // the document is already complete and a load listener would never fire. + // the document is already complete — a load listener would never fire. setReadyState('complete'); await import('../../../src/integrations/prebid/index'); @@ -3117,7 +3249,7 @@ describe('prebid self-init user ID module timing', () => { window.dispatchEvent(new Event('load')); expect(userSyncCallCount()).toBe(1); - // { once: true }: a second load event must not reinstall. + // { once: true } — a second load event must not reinstall. window.dispatchEvent(new Event('load')); expect(userSyncCallCount()).toBe(1); }); 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 new file mode 100644 index 000000000..27c189668 --- /dev/null +++ b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs @@ -0,0 +1,210 @@ +// @vitest-environment node + +// Builds and evaluates both production Prebid artifacts together: the +// external Prebid.js bundle (build-prebid-external.mjs) and the server-served +// tsjs shim (the same vite invocation build-all.mjs uses). This is the only +// coverage that proves the generated bundle entry populates the public API +// the real shim consumes — unit suites mock window.pbjs entirely. +// +// Runs in the node environment (vite/esbuild cannot run under jsdom globals) +// and evaluates the artifacts in an explicit JSDOM window instead. + +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 { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { main } from '../build-prebid-external.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const libDir = path.resolve(__dirname, '..'); + +let outputDirectory; +let bundleCode; +let shimCode; +let prebidVersion; + +beforeAll(async () => { + outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-server-prebid-artifacts-')); + + await main([ + '--adapters', + 'adf', + '--user-id-modules', + 'sharedIdSystem', + '--out', + outputDirectory, + ]); + const manifest = JSON.parse(fs.readFileSync(path.join(outputDirectory, 'manifest.json'), 'utf8')); + bundleCode = fs.readFileSync(path.join(outputDirectory, manifest.filename), 'utf8'); + prebidVersion = manifest.prebidVersion; + + const { build } = await import('vite'); + await build({ + configFile: false, + root: libDir, + build: { + emptyOutDir: false, + 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); + +afterAll(() => { + fs.rmSync(outputDirectory, { recursive: true, force: true }); +}); + +describe('tsjs-prebid shim artifact', () => { + it('stays Prebid-free: no core markers and an order-of-magnitude size gap', () => { + // The embedded version string is the core marker. Prove it appears in the + // external bundle first so this test fails loudly if the marker rots + // instead of silently passing. + expect(bundleCode).toContain(prebidVersion); + expect(shimCode).not.toContain(prebidVersion); + + // A value-import of 'prebid.js' would multiply the shim size; the shim + // must stay an order of magnitude smaller than Prebid core. + expect(bundleCode.length).toBeGreaterThan(200_000); + expect(shimCode.length).toBeLessThan(150_000); + }); +}); + +describe('external bundle + served shim evaluated together', () => { + it('populates the public API, installs the shim exactly once, and routes an /auction request', async () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + pretendToBeVisual: true, + }); + const pageWindow = dom.window; + + // Stub the network before any artifact runs: Prebid's ajax module + // captures window.fetch at evaluation time and builds Request objects. + // jsdom ships none of the fetch API, so lend it Node's — with relative + // URLs resolved against the page, as a browser Request would. + const fetchSpy = vi.fn( + async () => + new Response('{}', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + pageWindow.fetch = fetchSpy; + pageWindow.Request = class PageRequest extends Request { + constructor(resource, init) { + super( + typeof resource === 'string' + ? new URL(resource, 'https://pub.example.com').href + : resource, + init + ); + } + }; + pageWindow.Headers = Headers; + pageWindow.Response = Response; + pageWindow.AbortController = AbortController; + if (!('isSecureContext' in pageWindow)) { + pageWindow.isSecureContext = true; + } + + // Mirror the server's head-injected state, which always precedes the + // bundle script in document order. + pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); + pageWindow.__tsjs_prebid = { clientSideBidders: [] }; + + pageWindow.eval(bundleCode); + + expect(typeof pageWindow.pbjs.requestBids).toBe('function'); + expect(typeof pageWindow.pbjs.registerBidAdapter).toBe('function'); + expect(pageWindow.__tsjs_prebid_bundle.adapters).toEqual(['adf']); + expect([...pageWindow.__tsjs_prebid_bundle.bidderCodes]).toEqual([ + 'adf', + 'adform', + 'adformOpenRTB', + ]); + expect([...pageWindow.__tsjs_prebid_bundle.userIdModules]).toEqual(['sharedIdSystem']); + + // Count trustedServer registrations across repeated shim evaluations. + const originalRegisterBidAdapter = pageWindow.pbjs.registerBidAdapter.bind(pageWindow.pbjs); + const registerSpy = vi.fn(originalRegisterBidAdapter); + pageWindow.pbjs.registerBidAdapter = registerSpy; + + pageWindow.eval(shimCode); + const wrappedRequestBids = pageWindow.pbjs.requestBids; + + // A second evaluation (double script inclusion, or a legacy bundle that + // still carries a baked-in shim running after this one) must be a no-op. + pageWindow.eval(shimCode); + + const trustedServerRegistrations = registerSpy.mock.calls.filter( + ([, bidderCode]) => bidderCode === 'trustedServer' + ); + expect(trustedServerRegistrations).toHaveLength(1); + expect(pageWindow.pbjs.requestBids).toBe(wrappedRequestBids); + expect(pageWindow.__tsjsPrebidShimInstalled).toBe(true); + + // Drive one real auction through the wrapped requestBids and assert the + // transformed request reaches /auction. + const slot = pageWindow.document.createElement('div'); + slot.id = 'ad-slot-1'; + pageWindow.document.body.appendChild(slot); + + pageWindow.pbjs.requestBids({ + adUnits: [ + { + code: 'ad-slot-1', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'appnexus', params: { placementId: 1 } }], + }, + ], + timeout: 1000, + }); + + const requestUrl = (resource) => + typeof resource === 'string' ? resource : String(resource?.url ?? resource); + + await vi.waitFor( + () => { + expect( + fetchSpy.mock.calls.some(([resource]) => requestUrl(resource).includes('/auction')) + ).toBe(true); + }, + { timeout: 10_000 } + ); + + const [resource, init] = fetchSpy.mock.calls.find(([target]) => + requestUrl(target).includes('/auction') + ); + const body = init?.body ?? (typeof resource === 'object' ? await resource.text() : undefined); + const method = init?.method ?? resource?.method; + expect(method).toBe('POST'); + const payload = JSON.parse(body); + const adUnit = payload.adUnits[0]; + expect(adUnit.code).toBe('ad-slot-1'); + // The server-side bidder was folded into the trustedServer request + // instead of running client-side. + const trustedServerBid = adUnit.bids.find((bid) => bid.bidder === 'trustedServer'); + expect(trustedServerBid.params.bidderParams).toEqual({ appnexus: { placementId: 1 } }); + + dom.window.close(); + }, 60_000); +}); diff --git a/crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts b/crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts index dc1228c3f..881a4515f 100644 --- a/crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts +++ b/crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts @@ -15,12 +15,10 @@ describe('Beacon Guard', () => { originalFetch = window.fetch; // Create spies that simulate real sendBeacon/fetch behaviour - sendBeaconSpy = vi.fn((_url: string | URL, _data?: BodyInit | null) => true); + sendBeaconSpy = vi.fn(() => true); navigator.sendBeacon = sendBeaconSpy; - fetchSpy = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) => - Promise.resolve(new Response('', { status: 200 })) - ); + fetchSpy = vi.fn(() => Promise.resolve(new Response('', { status: 200 }))); window.fetch = fetchSpy; config = { diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index 4bc73c0ce..08bceaadb 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -102,6 +102,17 @@ the generated manifest. Upload the generated JavaScript file manually, set any redirect targets) in `proxy.allowed_domains` before running `ts config validate` or `ts config push`. +The generated bundle is pure Prebid.js — core, consent modules, User ID +modules, and the selected bid adapters. The Trusted Server shim +(`tsjs-prebid`) is served separately by the server as a deferred script and +installs itself onto the `window.pbjs` global the bundle populates. The two +artifacts ship in lockstep: a bundle generated before the shim was split out +still carries a baked-in copy of the shim, so upgrading the server requires +regenerating and re-uploading the bundle (and pushing the updated +`external_bundle_sha256`/`external_bundle_sri` config) as part of the same +rollout. The shim refuses to install twice on one page via the +`window.__tsjsPrebidShimInstalled` sentinel. + ## Debug Mode When `debug = true`, the Prebid integration enables additional diagnostics on both the outgoing OpenRTB request and the incoming response. @@ -374,11 +385,13 @@ available modules and default preset are checked in at `--user-id-modules` to `build-prebid-external.mjs` when a publisher needs a specific subset; omit it to use the default preset. -This is deliberate: Trusted Server injects a generated Prebid.js bundle so we -can install the `trustedServer` adapter and route auctions through `/auction`, -but publishers often need different User ID submodules. Moving that selection to -the external bundle keeps publisher-specific Prebid choices out of the Trusted -Server WASM artifact while preserving a manifest and bundle hash for auditing. +This is deliberate: the external bundle is pure Prebid.js (core, consent and +User ID modules, and client-side bid adapters) while the server-served TSJS +prebid shim installs the `trustedServer` adapter onto `window.pbjs` and routes +auctions through `/auction` — but publishers often need different User ID +submodules. Moving that selection to the external bundle keeps +publisher-specific Prebid choices out of the Trusted Server WASM artifact while +preserving a manifest and bundle hash for auditing. The current preset includes common ID modules such as Yahoo ConnectID, Criteo, LiveIntent, SharedID, UID2, ID5, LiveRamp IdentityLink, PubProvidedID, and