From d8f667493e9e20e3fe2d4b2e5c5c3577e7a6009f Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 1 Oct 2026 06:43:59 +0100 Subject: [PATCH 1/9] A format can name the viewport widths at which the page asks for it `CreativeOpportunityFormat` gains optional `min_viewport_width` and `max_viewport_width`, in CSS pixels. A slot with a bounded format carries `viewport_sizes` in its page data, one band per width at which the formats on offer change, widest first, and the Google ad tag's head bootstrap and the bundle's slot definition apply it as a size mapping when they define the slot, so a slot asks for a large size only where the page has room for one. A slot whose formats name no bound behaves exactly as before, and a server-side auction is still offered every format. A binary without the fields refuses a document carrying them, so it is promoted before any configuration uses them. --- CHANGELOG.md | 1 + .../src/fixtures/page-change.recorded.html | 39 +++- crates/ad-tag/google/src/gpt_bootstrap.js | 39 +++- crates/ad-tag/google/src/lib.rs | 2 +- .../src/creative_opportunities.rs | 210 ++++++++++++++++++ crates/trusted-server-core/src/publisher.rs | 77 ++++++- .../trusted-server-js/lib/src/core/types.ts | 6 + .../lib/src/integrations/gpt/index.ts | 40 +++- .../lib/test/integrations/gpt/ad_init.test.ts | 126 +++++++---- .../integrations/gpt/gpt_bootstrap.test.ts | 121 ++++++---- docs/guide/configuration.md | 33 +++ 11 files changed, 576 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3d965d1e7..a80a98439d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- A creative-opportunity format may name `min_viewport_width` and `max_viewport_width`, and a slot with such a format asks for only the formats that fit each band of viewport widths. The slot's page data carries `viewport_sizes`, and the Google ad tag's head bootstrap and the bundle's slot definition apply it as a size mapping. A slot whose formats name no bound behaves as before. - A device module may answer the confidence that a person is looking at the page, `DeviceSignals::human_confidence`, which nothing in core gates on and which stays `Unknown` for every module that does not score the request, and may tell a bidder what the device is through `DeviceModule::advertising_attributes`, which reaches the bid request's device object as its type, make, model, operating system and screen. A module that resolves nothing adds no device object. - A prefix module, selected by `[prefix] module`, gives the start of every name the browser holds for a publisher and of every key the publisher's entries take in a shared store. Core's own module gives `ts` and `ts.` and runs when nothing is selected, so a default deployment's names are unchanged. The page's bundle tag tells the client script the public prefix as `data-ts-public-prefix`, and a template stored under one prefix is never served under another. - `[shared.]`, one table per vendor holding what every module of that vendor shares, read by the vendor's crates through `Settings::shared_config` and by nothing in core. A builder marked with `IntegrationBuilder::with_shared_settings` runs when its vendor's table is present and is selected by no section, a table no builder reads is refused when the registry is built, a table not named as a folder is refused when the settings load, a refusal of a module nothing supplies names the absent table, and a secret in the table is declared and resolved as a module's own are. diff --git a/crates/ad-tag/google/src/fixtures/page-change.recorded.html b/crates/ad-tag/google/src/fixtures/page-change.recorded.html index 3d3a347ffd..d4bddeb355 100644 --- a/crates/ad-tag/google/src/fixtures/page-change.recorded.html +++ b/crates/ad-tag/google/src/fixtures/page-change.recorded.html @@ -360,6 +360,33 @@ ); } + // Defines a slot and, where the page data gives viewport bands, the sizes + // it asks for at each width. Each band is a viewport and the sizes asked + // for from that width up to the next band's, widest first. A slot with no + // bands asks for every format at every width. + function defineWithViewportSizes(api, slot, elementId) { + var definedSlot = api.defineSlot( + slot.gam_unit_path, + slot.formats, + elementId, + ); + var bands = slot.viewport_sizes; + if ( + definedSlot && + bands && + bands.length && + api.sizeMapping && + definedSlot.defineSizeMapping + ) { + var builder = api.sizeMapping(); + bands.forEach(function (band) { + builder.addSize(band[0], band[1]); + }); + definedSlot.defineSizeMapping(builder.build()); + } + return definedSlot; + } + function normalizedGptFormats(formats) { return formats.length === 2 && formats.every(function (format) { @@ -682,11 +709,7 @@ var tsOwned = false; if (!gptSlot) { gptSlot = runHandoffInternal(function () { - return window.googletag.defineSlot( - slot.gam_unit_path, - slot.formats, - element.id, - ); + return defineWithViewportSizes(window.googletag, slot, element.id); }); if (!gptSlot) { releaseTrustedServerFirstImpressionClaim(element, fallbackClaim); @@ -834,11 +857,7 @@ // Define TS's fallback on the publisher's actual div. The scoped // handoff wrapper returns this slot if the publisher defines it later. s = runHandoffInternal(function () { - return googletag.defineSlot( - slot.gam_unit_path, - slot.formats, - actualDivId, - ); + return defineWithViewportSizes(googletag, slot, actualDivId); }); if (!s) { releaseTrustedServerFirstImpressionClaim(el, tsClaim); diff --git a/crates/ad-tag/google/src/gpt_bootstrap.js b/crates/ad-tag/google/src/gpt_bootstrap.js index afae775350..7563d9e182 100644 --- a/crates/ad-tag/google/src/gpt_bootstrap.js +++ b/crates/ad-tag/google/src/gpt_bootstrap.js @@ -358,6 +358,33 @@ ); } + // Defines a slot and, where the page data gives viewport bands, the sizes + // it asks for at each width. Each band is a viewport and the sizes asked + // for from that width up to the next band's, widest first. A slot with no + // bands asks for every format at every width. + function defineWithViewportSizes(api, slot, elementId) { + var definedSlot = api.defineSlot( + slot.gam_unit_path, + slot.formats, + elementId, + ); + var bands = slot.viewport_sizes; + if ( + definedSlot && + bands && + bands.length && + api.sizeMapping && + definedSlot.defineSizeMapping + ) { + var builder = api.sizeMapping(); + bands.forEach(function (band) { + builder.addSize(band[0], band[1]); + }); + definedSlot.defineSizeMapping(builder.build()); + } + return definedSlot; + } + function normalizedGptFormats(formats) { return formats.length === 2 && formats.every(function (format) { @@ -680,11 +707,7 @@ var tsOwned = false; if (!gptSlot) { gptSlot = runHandoffInternal(function () { - return window.googletag.defineSlot( - slot.gam_unit_path, - slot.formats, - element.id, - ); + return defineWithViewportSizes(window.googletag, slot, element.id); }); if (!gptSlot) { releaseTrustedServerFirstImpressionClaim(element, fallbackClaim); @@ -832,11 +855,7 @@ // Define TS's fallback on the publisher's actual div. The scoped // handoff wrapper returns this slot if the publisher defines it later. s = runHandoffInternal(function () { - return googletag.defineSlot( - slot.gam_unit_path, - slot.formats, - actualDivId, - ); + return defineWithViewportSizes(googletag, slot, actualDivId); }); if (!s) { releaseTrustedServerFirstImpressionClaim(el, tsClaim); diff --git a/crates/ad-tag/google/src/lib.rs b/crates/ad-tag/google/src/lib.rs index 523eb2c55f..c781aa0704 100644 --- a/crates/ad-tag/google/src/lib.rs +++ b/crates/ad-tag/google/src/lib.rs @@ -1354,7 +1354,7 @@ mod tests { "bootstrap should install idempotent GPT handoff wrappers" ); assert!( - combined.contains("return googletag.defineSlot") && combined.contains("actualDivId"), + combined.contains("return defineWithViewportSizes(googletag, slot, actualDivId)"), "bootstrap should define the TS fallback on the actual inner div" ); assert!( diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index 0c88c8d2c2..09f419dda8 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -942,6 +942,43 @@ impl CreativeOpportunitySlot { self.div_id.as_deref().unwrap_or(&self.id) } + /// The sizes this slot asks for at each viewport width. + /// + /// [`None`] when no format names a viewport bound, so the slot asks for + /// every format at every width. Otherwise a band starts at zero and at + /// every width where the formats on offer change, and a band may be + /// empty, meaning the slot asks for nothing at those widths. + #[must_use] + pub fn viewport_sizes(&self) -> Option { + if self.formats.iter().all(|format| { + format.min_viewport_width.is_none() && format.max_viewport_width.is_none() + }) { + return None; + } + let mut edges = vec![0_u32]; + for format in &self.formats { + edges.extend(format.min_viewport_width); + edges.extend(format.max_viewport_width.and_then(|max| max.checked_add(1))); + } + edges.sort_unstable(); + edges.dedup(); + let mut bands: ViewportSizes = edges + .into_iter() + .map(|edge| { + let sizes = self + .formats + .iter() + .filter(|format| format.offered_at(edge)) + .map(|format| [format.width, format.height]) + .collect(); + (edge, sizes) + }) + .collect(); + bands.dedup_by(|later, earlier| later.1 == earlier.1); + bands.reverse(); + Some(bands) + } + /// Converts this slot into an [`AdSlot`] ready for use in an auction request. /// /// Prebid Server bidder params are wired into the `bidders` map keyed by @@ -995,8 +1032,21 @@ pub struct CreativeOpportunityFormat { /// Media type for this format. Defaults to `Banner`. #[serde(default)] pub media_type: MediaType, + /// The narrowest browser viewport, in CSS pixels, at which the page asks + /// for this format. Unset means no lower bound. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_viewport_width: Option, + /// The widest browser viewport, in CSS pixels, at which the page asks for + /// this format. Unset means no upper bound. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_viewport_width: Option, } +/// The sizes a slot asks for by viewport width, widest first. Each entry is +/// the narrowest viewport width of a band and the `[width, height]` sizes +/// asked for from that width up to the next entry's. +pub type ViewportSizes = Vec<(u32, Vec<[u32; 2]>)>; + impl CreativeOpportunityFormat { fn validate_runtime(&self, slot_id: &str) -> Result<(), String> { if self.width == 0 || self.height == 0 { @@ -1004,10 +1054,28 @@ impl CreativeOpportunityFormat { "slot `{slot_id}` format must have positive width and height" )); } + if let (Some(min), Some(max)) = (self.min_viewport_width, self.max_viewport_width) + && min > max + { + return Err(format!( + "slot `{slot_id}` format {}x{} has min_viewport_width {min} above \ + max_viewport_width {max}", + self.width, self.height + )); + } Ok(()) } + /// Whether the page asks for this format at a viewport this wide. + fn offered_at(&self, viewport_width: u32) -> bool { + self.min_viewport_width + .is_none_or(|min| viewport_width >= min) + && self + .max_viewport_width + .is_none_or(|max| viewport_width <= max) + } + fn to_ad_format(&self) -> AdFormat { AdFormat { media_type: self.media_type.clone(), @@ -1455,6 +1523,8 @@ mod tests { width: 300, height: 250, media_type: crate::auction::types::MediaType::Banner, + min_viewport_width: None, + max_viewport_width: None, }], floor_price: Some(0.50), targeting: Default::default(), @@ -1464,6 +1534,146 @@ mod tests { } } + fn banded( + width: u32, + height: u32, + min_viewport_width: Option, + max_viewport_width: Option, + ) -> CreativeOpportunityFormat { + CreativeOpportunityFormat { + width, + height, + media_type: crate::auction::types::MediaType::Banner, + min_viewport_width, + max_viewport_width, + } + } + + #[test] + fn viewport_sizes_is_none_when_no_format_names_a_bound() { + let slot = make_slot("atf", vec!["/**"]); + + assert!( + slot.viewport_sizes().is_none(), + "a slot without bounds should ask for every format at every width" + ); + } + + #[test] + fn viewport_sizes_gives_each_band_only_the_formats_that_fit_it() { + let mut slot = make_slot("billboard", vec!["/**"]); + slot.formats = vec![ + banded(970, 250, Some(1040), None), + banded(728, 90, Some(800), Some(1039)), + banded(300, 250, Some(600), Some(799)), + banded(320, 50, None, Some(599)), + ]; + + assert_eq!( + slot.viewport_sizes(), + Some(vec![ + (1040, vec![[970, 250]]), + (800, vec![[728, 90]]), + (600, vec![[300, 250]]), + (0, vec![[320, 50]]), + ]), + "should band the formats widest first" + ); + } + + #[test] + fn viewport_sizes_keeps_an_unbounded_format_in_every_band() { + let mut slot = make_slot("rail", vec!["/**"]); + slot.formats = vec![ + banded(300, 600, Some(1040), None), + banded(300, 250, None, None), + ]; + + assert_eq!( + slot.viewport_sizes(), + Some(vec![ + (1040, vec![[300, 600], [300, 250]]), + (0, vec![[300, 250]]), + ]), + "should offer the unbounded format at every width" + ); + } + + #[test] + fn viewport_sizes_leaves_a_band_empty_when_no_format_fits() { + let mut slot = make_slot("leaderboard", vec!["/**"]); + slot.formats = vec![banded(728, 90, Some(800), None)]; + + assert_eq!( + slot.viewport_sizes(), + Some(vec![(800, vec![[728, 90]]), (0, vec![])]), + "should ask for nothing below the narrowest bound" + ); + } + + #[test] + fn viewport_sizes_merges_neighbouring_bands_that_offer_the_same_formats() { + let mut slot = make_slot("footer", vec!["/**"]); + slot.formats = vec![ + banded(728, 90, Some(800), None), + banded(300, 250, None, Some(799)), + banded(320, 50, None, Some(799)), + ]; + + assert_eq!( + slot.viewport_sizes(), + Some(vec![ + (800, vec![[728, 90]]), + (0, vec![[300, 250], [320, 50]]), + ]), + "two bounds at the same width should make one band" + ); + } + + #[test] + fn validate_runtime_refuses_a_format_whose_min_viewport_exceeds_its_max() { + let mut slot = make_slot("atf", vec!["/**"]); + slot.formats = vec![banded(300, 250, Some(800), Some(600))]; + slot.compile_patterns(); + + let error = slot + .validate_runtime() + .expect_err("should refuse an inverted viewport range"); + + assert!( + error.contains("min_viewport_width 800 above max_viewport_width 600"), + "should name both bounds: {error}" + ); + } + + #[test] + fn a_format_without_bounds_serializes_without_them() { + let value = + serde_json::to_value(banded(300, 250, None, None)).expect("should serialize a format"); + + assert!( + value.get("min_viewport_width").is_none() && value.get("max_viewport_width").is_none(), + "an unbounded format should serialize as it did before: {value}" + ); + } + + #[test] + fn a_format_reads_its_viewport_bounds() { + let format: CreativeOpportunityFormat = serde_json::from_value(serde_json::json!({ + "width": 728, + "height": 90, + "min_viewport_width": 800, + "max_viewport_width": 1039, + })) + .expect("should read both bounds"); + + assert_eq!( + (format.min_viewport_width, format.max_viewport_width), + (Some(800), Some(1039)), + "should keep the bounds as written" + ); + } + #[test] fn compile_patterns_populates_cache_and_match_uses_it() { let mut slot = make_slot("atf", vec!["/20**", "/about"]); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index e6772f9257..827a45a936 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -6722,8 +6722,9 @@ pub(crate) fn build_empty_bids_script() -> String { /// [`handle_page_bids`] (SPA navigation) so the slot wire shape has a single /// definition and the two paths cannot silently diverge. Property names match /// what the client-side TSJS bundle expects: `gam_unit_path`, `div_id`, -/// `formats`, and `targeting`. Returns `None` when the slot's dynamic GAM unit -/// path exceeds its rendering limit. +/// `formats`, and `targeting`, with `viewport_sizes` added when a format names +/// a viewport bound. Returns `None` when the slot's dynamic GAM unit path +/// exceeds its rendering limit. pub(crate) fn build_slot_json( slot: &crate::creative_opportunities::CreativeOpportunitySlot, co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, @@ -6741,13 +6742,22 @@ pub(crate) fn build_slot_json( .iter() .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) .collect(); - Some(serde_json::json!({ + let mut json = serde_json::json!({ "id": slot.id, "gam_unit_path": gam_path, "div_id": div_id, "formats": formats, "targeting": targeting, - })) + }); + // Each band as the browser takes it, a `[width, 0]` viewport and the + // sizes asked for from that width up to the next band's. + if let Some(bands) = slot.viewport_sizes() { + json["viewport_sizes"] = bands + .into_iter() + .map(|(width, sizes)| serde_json::json!([[width, 0], sizes])) + .collect(); + } + Some(json) } /// Match creative-opportunity slots and omit dynamic GAM paths that cannot be @@ -8455,6 +8465,8 @@ mod tests { width: 300, height: 250, media_type: MediaType::Banner, + min_viewport_width: None, + max_viewport_width: None, }], floor_price: None, targeting: Default::default(), @@ -11370,6 +11382,8 @@ mod tests { width: 728, height: 90, media_type: MediaType::Banner, + min_viewport_width: None, + max_viewport_width: None, }], floor_price: None, targeting: Default::default(), @@ -17519,6 +17533,8 @@ mod tests { width: 300, height: 250, media_type: MediaType::Banner, + min_viewport_width: None, + max_viewport_width: None, }], floor_price: None, targeting: Default::default(), @@ -18006,6 +18022,8 @@ mod tests { width: 300, height: 250, media_type: MediaType::Banner, + min_viewport_width: None, + max_viewport_width: None, }], floor_price: None, targeting: Default::default(), @@ -24698,6 +24716,8 @@ mod tests { width: 300, height: 250, media_type: MediaType::Banner, + min_viewport_width: None, + max_viewport_width: None, }], floor_price: Some(0.50), targeting: [("pos".to_string(), "atf".to_string())] @@ -24770,6 +24790,51 @@ mod tests { ); } + #[test] + fn slot_json_carries_viewport_sizes_only_when_a_format_is_bounded() { + let config = make_config(); + let plain = crate::publisher::build_slot_json(&make_slot(), &config, "example") + .expect("should build slot JSON"); + assert!( + plain.get("viewport_sizes").is_none(), + "a slot without bounds should carry no viewport sizes" + ); + + let mut slot = make_slot(); + slot.formats = vec![ + CreativeOpportunityFormat { + width: 300, + height: 600, + media_type: MediaType::Banner, + min_viewport_width: Some(1040), + max_viewport_width: None, + }, + CreativeOpportunityFormat { + width: 300, + height: 250, + media_type: MediaType::Banner, + min_viewport_width: None, + max_viewport_width: None, + }, + ]; + let banded = crate::publisher::build_slot_json(&slot, &config, "example") + .expect("should build slot JSON"); + + assert_eq!( + banded["viewport_sizes"], + serde_json::json!([ + [[1040, 0], [[300, 600], [300, 250]]], + [[0, 0], [[300, 250]]] + ]), + "should carry each band as a viewport and its sizes, widest first" + ); + assert_eq!( + banded["formats"], + serde_json::json!([[300, 600], [300, 250]]), + "formats should still list every size" + ); + } + #[test] fn ad_slots_script_is_xss_safe() { let slots = vec![make_slot()]; @@ -26500,6 +26565,8 @@ mod tests { width: 300, height: 250, media_type: crate::auction::types::MediaType::Banner, + min_viewport_width: None, + max_viewport_width: None, }], floor_price: Some(0.50), targeting: Default::default(), @@ -27522,6 +27589,8 @@ mod tests { width: 300, height: 250, media_type: MediaType::Banner, + min_viewport_width: None, + max_viewport_width: None, }], floor_price: Some(0.50), targeting: Default::default(), diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 742e8355af..f4631a1f67 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -27,6 +27,12 @@ export interface AuctionSlot { div_id: string; formats: Array<[number, number]>; targeting?: Record; + /** + * The sizes asked for by viewport width, widest first. Each entry is a + * `[width, 0]` viewport and the sizes asked for from that width up to the + * next entry's. Absent when no format names a viewport bound. + */ + viewport_sizes?: Array<[[number, number], Array<[number, number]>]>; } /** Debug-only copy of server-side bid fields exposed for pipeline inspection. */ diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index ed07fae993..e5b04a0549 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -85,6 +85,7 @@ interface GoogleTagSlot { clearTargeting?(key?: string): GoogleTagSlot; addService(service: GoogleTagPubAdsService): GoogleTagSlot; getTargeting?(key: string): string[]; + defineSizeMapping?(mapping: unknown): GoogleTagSlot; } interface SlotRenderEndedEvent { @@ -370,9 +371,15 @@ interface GoogleTag { display(target: GoogleTagDisplayTarget): void; setConfig?(config: GoogleTagConfig): void; getConfig?(keys: string | string[]): GoogleTagEffectiveConfig | undefined; + sizeMapping?(): ViewportSizeBuilder; _loaded_?: boolean; } +interface ViewportSizeBuilder { + addSize(viewport: number[], sizes: Array): ViewportSizeBuilder; + build(): unknown; +} + type GptWindow = Window & { googletag?: Partial; __tsjs_slim_prebid_url?: string; @@ -848,6 +855,32 @@ function displayTargetElementId(target: GoogleTagDisplayTarget): string | undefi return (target as Element).id || undefined; } +/** + * Defines a slot and, where the page data gives viewport bands, the sizes it + * asks for at each width. Each band is a viewport and the sizes asked for from + * that width up to the next band's, widest first. A slot with no bands asks for + * every format at every width. + */ +function defineWithViewportSizes( + api: { + defineSlot?(adUnitPath: string, size: Array, elementId?: string): S | null; + sizeMapping?(): ViewportSizeBuilder; + }, + slot: AuctionSlot, + elementId: string +): S | null | undefined { + const definedSlot = api.defineSlot?.(slot.gam_unit_path, slot.formats, elementId); + const bands = slot.viewport_sizes; + if (definedSlot && bands?.length && api.sizeMapping && definedSlot.defineSizeMapping) { + const builder = api.sizeMapping(); + for (const [viewport, sizes] of bands) { + builder.addSize(viewport, sizes); + } + definedSlot.defineSizeMapping(builder.build()); + } + return definedSlot; +} + function normalizedGptFormats(formats: Array): Array { return formats.length === 2 && formats.every((format) => typeof format === 'number') ? [formats as number[]] @@ -1155,9 +1188,8 @@ function schedulePublisherFirstImpressionFallback( let tsOwned = false; if (!gptSlot) { gptSlot = - withGptSlotHandoffInternal(ts, () => - g.defineSlot?.(slot.gam_unit_path, slot.formats, element.id) - ) ?? undefined; + withGptSlotHandoffInternal(ts, () => defineWithViewportSizes(g, slot, element.id)) ?? + undefined; if (!gptSlot) { releaseTrustedServerFirstImpressionClaim(ts, element, claim); return; @@ -1352,7 +1384,7 @@ export function installTsAdInit(): void { // defineSlot() for this div is handed the same slot by the scoped GPT // wrapper, preventing a competing container-slot request. const defined = withGptSlotHandoffInternal(ts, () => - g.defineSlot?.(slot.gam_unit_path, slot.formats, actualDivId) + defineWithViewportSizes(g, slot, actualDivId) ); if (!defined) { releaseTrustedServerFirstImpressionClaim(ts, el, firstImpression); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 540d15229a..1f138c7934 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -689,54 +689,88 @@ describe('installTsAdInit', () => { fetchSpy.mockRestore(); }); - it('displays TS-defined slots and does not include them in refresh', async () => { - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const nativeRefresh = vi.fn(); - const mockPubads = { - enableSingleRequest: vi.fn(), - // Publisher has not defined this slot, so TS defines (owns) it. - getSlots: vi.fn().mockReturnValue([]), - addEventListener: vi.fn(), - refresh: nativeRefresh, - }; - const defineSlotMock = vi.fn().mockReturnValue(mockSlot); - const displayMock = vi.fn(); - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: defineSlotMock, - display: displayMock, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; + it.each([ + { name: 'without viewport bands', bands: undefined }, + { + name: 'with viewport bands', + bands: [ + [ + [1040, 0], + [ + [300, 600], + [300, 250], + ], + ], + [[0, 0], [[300, 250]]], + ] as Array<[[number, number], Array<[number, number]>]>, + }, + ])( + 'displays TS-defined slots and does not include them in refresh ($name)', + async ({ bands }) => { + const builder = { + addSize: vi.fn().mockReturnThis(), + build: vi.fn().mockReturnValue('built mapping'), + }; + const sizeMapping = vi.fn().mockReturnValue(builder); + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + defineSizeMapping: vi.fn().mockReturnThis(), + getTargeting: vi.fn().mockReturnValue([]), + }; + const nativeRefresh = vi.fn(); + const mockPubads = { + enableSingleRequest: vi.fn(), + // Publisher has not defined this slot, so TS defines (owns) it. + getSlots: vi.fn().mockReturnValue([]), + addEventListener: vi.fn(), + refresh: nativeRefresh, + }; + const defineSlotMock = vi.fn().mockReturnValue(mockSlot); + const displayMock = vi.fn(); + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: defineSlotMock, + display: displayMock, + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + sizeMapping, + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + ...(bands ? { viewport_sizes: bands } : {}), + targeting: {}, + }, + ], + bids: {}, + }; - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); - expect(defineSlotMock).toHaveBeenCalled(); - // GPT requires display() to register/render a freshly-defined slot. - expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); - // TS-owned slots are displayed, not refreshed (refresh() no-ops for a slot - // that was never displayed). - expect(nativeRefresh).not.toHaveBeenCalled(); - }); + expect(defineSlotMock).toHaveBeenCalled(); + // GPT requires display() to register/render a freshly-defined slot. + expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); + // TS-owned slots are displayed, not refreshed (refresh() no-ops for a slot + // that was never displayed). + expect(nativeRefresh).not.toHaveBeenCalled(); + + if (bands) { + expect(builder.addSize.mock.calls).toEqual(bands); + expect(mockSlot.defineSizeMapping).toHaveBeenCalledWith('built mapping'); + } else { + expect(sizeMapping).not.toHaveBeenCalled(); + expect(mockSlot.defineSizeMapping).not.toHaveBeenCalled(); + } + } + ); it('hands a late publisher definition the TS inner-div slot without a second request', async () => { type FakeSlot = { diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts index 1f8df3a3a6..0743e91ada 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts @@ -28,6 +28,7 @@ interface MockGoogleTag { enableServices: () => void; display: (divId: string) => void; setConfig?: (config: Record) => void; + sizeMapping?: () => unknown; } // `tsjs` is declared globally as the full `TsjsApi`; `Omit` drops it from @@ -822,51 +823,85 @@ describe('gpt_bootstrap.js fallback', () => { expect(ts.adSlots).toEqual([liveSlot]); }); - it('fallback adInit defines, targets, and displays a TS slot through the command queue', () => { - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([]), - refresh: vi.fn(), - }; - const defineSlot = vi.fn().mockReturnValue(mockSlot); - const display = vi.fn(); - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - display, - }; - document.body.innerHTML = - '
'; - runBootstrap(); - const ts = (window as TestWindow).tsjs!; - ts.adSlots = [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: { ts_route: 'home' }, - }, - ]; - ts.bids = { atf_sidebar_ad: { hb_pb: '1.00' } }; + it.each([ + { name: 'without viewport bands', bands: undefined }, + { + name: 'with viewport bands', + bands: [ + [ + [1040, 0], + [ + [300, 600], + [300, 250], + ], + ], + [[0, 0], [[300, 250]]], + ] as Array<[[number, number], Array<[number, number]>]>, + }, + ])( + 'fallback adInit defines, targets, and displays a TS slot through the command queue ($name)', + ({ bands }) => { + const builder = { + addSize: vi.fn().mockReturnThis(), + build: vi.fn().mockReturnValue('built mapping'), + }; + const sizeMapping = vi.fn().mockReturnValue(builder); + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + defineSizeMapping: vi.fn().mockReturnThis(), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([]), + refresh: vi.fn(), + }; + const defineSlot = vi.fn().mockReturnValue(mockSlot); + const display = vi.fn(); + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot, + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + display, + sizeMapping, + }; + document.body.innerHTML = + '
'; + runBootstrap(); + const ts = (window as TestWindow).tsjs!; + ts.adSlots = [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + ...(bands ? { viewport_sizes: bands } : {}), + targeting: { ts_route: 'home' }, + }, + ]; + ts.bids = { atf_sidebar_ad: { hb_pb: '1.00' } }; - ts.adInit!(); + ts.adInit!(); - expect(defineSlot).toHaveBeenCalledWith('/123/atf', [[300, 250]], 'div-atf-sidebar'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_route', 'home'); - expect(ts.prevSlotTargetingKeys).toEqual({ 'div-atf-sidebar': ['ts_route'] }); - expect(display).toHaveBeenCalledWith('div-atf-sidebar'); - expect(ts.servicesEnabled).toBe(true); - }); + expect(defineSlot).toHaveBeenCalledWith('/123/atf', [[300, 250]], 'div-atf-sidebar'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_route', 'home'); + expect(ts.prevSlotTargetingKeys).toEqual({ 'div-atf-sidebar': ['ts_route'] }); + expect(display).toHaveBeenCalledWith('div-atf-sidebar'); + expect(ts.servicesEnabled).toBe(true); + + if (bands) { + expect(builder.addSize.mock.calls).toEqual(bands); + expect(mockSlot.defineSizeMapping).toHaveBeenCalledWith('built mapping'); + } else { + expect(sizeMapping).not.toHaveBeenCalled(); + expect(mockSlot.defineSizeMapping).not.toHaveBeenCalled(); + } + } + ); it('fallback adInit leaves a publisher-rendered slot untouched', () => { const mockSlot = { diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 2e1dee42a7..8775065208 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -2747,6 +2747,39 @@ page_patterns = ["/", "/news", "/news/*", "/reviews", "/reviews/*"] formats = [{ width = 728, height = 90 }] ``` +#### Sizes by viewport width + +A format may name the viewport widths at which the page asks for it, so a slot +asks for a large size only where the page has room for one. `min_viewport_width` +is the narrowest viewport, in CSS pixels, and `max_viewport_width` the widest. +Either may be left out, meaning no bound on that side, and `min_viewport_width` +may not exceed `max_viewport_width`. + +```toml +[[creative_opportunities.slot]] +id = "billboard" +page_patterns = ["/**"] +formats = [ + { width = 970, height = 250, min_viewport_width = 1040 }, + { width = 728, height = 90, min_viewport_width = 800, max_viewport_width = 1039 }, + { width = 300, height = 250, min_viewport_width = 600, max_viewport_width = 799 }, + { width = 320, height = 50, max_viewport_width = 599 }, +] +``` + +When any format of a slot names a bound, the slot's page data carries +`viewport_sizes`, one entry for each band of widths, widest first, and the page +asks for only that band's sizes. A band starts at zero and at every width where +the formats on offer change, and a band with no format asks for nothing. A slot +whose formats name no bound asks for every format at every width, as before. +The bounds apply in the browser only. A server-side auction is still offered +every format. + +> [!WARNING] +> A binary released before these fields refuses a format carrying either of +> them, and fails to load its settings, which makes every request fail. Promote +> the binary that reads them before pushing a configuration that uses them. + The same switch can be overridden through the typed CLI environment overlay. Because EdgeZero only replaces TOML leaves that already exist, first add `enabled = true` to the `[creative_opportunities]` block in the base config From 9c2e6e8026675b92fd1037e9bee51128ec21ebbe Mon Sep 17 00:00:00 2001 From: James Rogers Date: Mon, 12 Oct 2026 15:18:13 +0100 Subject: [PATCH 2/9] The refresh auction offers only the sizes of the viewport's band The bundle's refresh auction built its banner sizes from every format, so on a phone a bidder could win with a billboard the size mapping would not show. It now offers the sizes of the band the viewport is in, and leaves a slot whose band asks for nothing out of the auction. The band shape is stated once, on CreativeOpportunitySlot::viewport_sizes, and the guide drops the paragraph that repeated it. --- CHANGELOG.md | 2 +- .../src/fixtures/page-change.recorded.html | 5 +- crates/ad-tag/google/src/gpt_bootstrap.js | 5 +- .../src/creative_opportunities.rs | 20 ++-- crates/trusted-server-core/src/publisher.rs | 4 +- .../trusted-server-js/lib/src/core/types.ts | 5 +- .../lib/src/integrations/gpt/index.ts | 7 +- .../lib/src/integrations/prebid/index.ts | 23 ++++- .../test/integrations/prebid/index.test.ts | 91 +++++++++++++++++++ docs/guide/configuration.md | 8 -- 10 files changed, 129 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fde26a7cc0..fed5f75256 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,7 +83,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- A creative-opportunity format may name `min_viewport_width` and `max_viewport_width`, and a slot with such a format asks for only the formats that fit each band of viewport widths. The slot's page data carries `viewport_sizes`, and the Google ad tag's head bootstrap and the bundle's slot definition apply it as a size mapping. A slot whose formats name no bound behaves as before. +- A creative-opportunity format may name `min_viewport_width` and `max_viewport_width`, and a slot with such a format asks for only the formats that fit each band of viewport widths. The slot's page data carries `viewport_sizes`, and the Google ad tag's head bootstrap and the bundle's slot definition apply it as a size mapping, and the bundle's refresh auction offers a bidder only the sizes of the band the viewport is in. A slot whose formats name no bound behaves as before. - A device module may answer the confidence that a person is looking at the page, `DeviceSignals::human_confidence`, which nothing in core gates on and which stays `Unknown` for every module that does not score the request, and may tell a bidder what the device is through `DeviceModule::advertising_attributes`, which reaches the bid request's device object as its type, make, model, operating system and screen. A module that resolves nothing adds no device object. - A prefix module, selected by `[prefix] module`, gives the start of every name the browser holds for a publisher and of every key the publisher's entries take in a shared store. Core's own module gives `ts` and `ts.` and runs when nothing is selected, so a default deployment's names are unchanged. The page's bundle tag tells the client script the public prefix as `data-ts-public-prefix`, and a template stored under one prefix is never served under another. - `[shared.]`, one table per vendor holding what every module of that vendor shares, read by the vendor's crates through `Settings::shared_config` and by nothing in core. A builder marked with `IntegrationBuilder::with_shared_settings` runs when its vendor's table is present and is selected by no section, a table no builder reads is refused when the registry is built, a table whose name is not lower case letters, digits, `_` or `-` is refused when the settings load, a refusal of a module nothing supplies names the absent table, and a secret in the table is declared and resolved as a module's own are. diff --git a/crates/ad-tag/google/src/fixtures/page-change.recorded.html b/crates/ad-tag/google/src/fixtures/page-change.recorded.html index d4bddeb355..a6f6a55c05 100644 --- a/crates/ad-tag/google/src/fixtures/page-change.recorded.html +++ b/crates/ad-tag/google/src/fixtures/page-change.recorded.html @@ -360,10 +360,7 @@ ); } - // Defines a slot and, where the page data gives viewport bands, the sizes - // it asks for at each width. Each band is a viewport and the sizes asked - // for from that width up to the next band's, widest first. A slot with no - // bands asks for every format at every width. + // Defines the slot and applies its viewport bands as a size mapping. function defineWithViewportSizes(api, slot, elementId) { var definedSlot = api.defineSlot( slot.gam_unit_path, diff --git a/crates/ad-tag/google/src/gpt_bootstrap.js b/crates/ad-tag/google/src/gpt_bootstrap.js index 7563d9e182..374f7bbb5c 100644 --- a/crates/ad-tag/google/src/gpt_bootstrap.js +++ b/crates/ad-tag/google/src/gpt_bootstrap.js @@ -358,10 +358,7 @@ ); } - // Defines a slot and, where the page data gives viewport bands, the sizes - // it asks for at each width. Each band is a viewport and the sizes asked - // for from that width up to the next band's, widest first. A slot with no - // bands asks for every format at every width. + // Defines the slot and applies its viewport bands as a size mapping. function defineWithViewportSizes(api, slot, elementId) { var definedSlot = api.defineSlot( slot.gam_unit_path, diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index 09f419dda8..e84a000095 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -944,10 +944,12 @@ impl CreativeOpportunitySlot { /// The sizes this slot asks for at each viewport width. /// - /// [`None`] when no format names a viewport bound, so the slot asks for - /// every format at every width. Otherwise a band starts at zero and at - /// every width where the formats on offer change, and a band may be - /// empty, meaning the slot asks for nothing at those widths. + /// Each band is the narrowest viewport width of the band and the + /// `[width, height]` sizes asked for from that width up to the next band's, + /// widest first. A band starts at zero and at every width where the formats + /// on offer change, and a band may be empty, meaning the slot asks for + /// nothing at those widths. [`None`] when no format names a viewport bound, + /// so the slot asks for every format at every width. #[must_use] pub fn viewport_sizes(&self) -> Option { if self.formats.iter().all(|format| { @@ -1032,19 +1034,15 @@ pub struct CreativeOpportunityFormat { /// Media type for this format. Defaults to `Banner`. #[serde(default)] pub media_type: MediaType, - /// The narrowest browser viewport, in CSS pixels, at which the page asks - /// for this format. Unset means no lower bound. + /// Narrowest viewport width, in CSS pixels, at which the page asks for this format. #[serde(default, skip_serializing_if = "Option::is_none")] pub min_viewport_width: Option, - /// The widest browser viewport, in CSS pixels, at which the page asks for - /// this format. Unset means no upper bound. + /// Widest viewport width, in CSS pixels, at which the page asks for this format. #[serde(default, skip_serializing_if = "Option::is_none")] pub max_viewport_width: Option, } -/// The sizes a slot asks for by viewport width, widest first. Each entry is -/// the narrowest viewport width of a band and the `[width, height]` sizes -/// asked for from that width up to the next entry's. +/// The bands [`CreativeOpportunitySlot::viewport_sizes`] gives. pub type ViewportSizes = Vec<(u32, Vec<[u32; 2]>)>; impl CreativeOpportunityFormat { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 43ec0ba4dc..d218743091 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -6739,8 +6739,8 @@ pub(crate) fn build_slot_json( "formats": formats, "targeting": targeting, }); - // Each band as the browser takes it, a `[width, 0]` viewport and the - // sizes asked for from that width up to the next band's. + // Each band of `CreativeOpportunitySlot::viewport_sizes` as the browser + // takes it, with a `[width, 0]` viewport. if let Some(bands) = slot.viewport_sizes() { json["viewport_sizes"] = bands .into_iter() diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index f4631a1f67..df563733af 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -28,9 +28,8 @@ export interface AuctionSlot { formats: Array<[number, number]>; targeting?: Record; /** - * The sizes asked for by viewport width, widest first. Each entry is a - * `[width, 0]` viewport and the sizes asked for from that width up to the - * next entry's. Absent when no format names a viewport bound. + * The bands of the core's `CreativeOpportunitySlot::viewport_sizes`, each + * with a `[width, 0]` viewport. Absent when no format names a viewport bound. */ viewport_sizes?: Array<[[number, number], Array<[number, number]>]>; } diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index e5b04a0549..04867d1813 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -855,12 +855,7 @@ function displayTargetElementId(target: GoogleTagDisplayTarget): string | undefi return (target as Element).id || undefined; } -/** - * Defines a slot and, where the page data gives viewport bands, the sizes it - * asks for at each width. Each band is a viewport and the sizes asked for from - * that width up to the next band's, widest first. A slot with no bands asks for - * every format at every width. - */ +/** Defines the slot and applies its viewport bands as a size mapping. */ function defineWithViewportSizes( api: { defineSlot?(adUnitPath: string, size: Array, elementId?: string): S | null; 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 a5b47f2d79..e1efb6057a 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -1134,11 +1134,28 @@ function bannerSizesFromGptSlot(slot: RefreshGptSlot): BannerSize[] | undefined return parsedSizes.length > 0 ? parsedSizes : undefined; } +/** + * The sizes the slot's viewport band asks for at the current viewport width, + * or `undefined` when the slot names no band. See `AuctionSlot.viewport_sizes`. + */ +function injectedSlotBandSizes(slot: AuctionSlot | undefined): Array<[number, number]> | undefined { + const bands = slot?.viewport_sizes; + if (!bands?.length) return undefined; + const width = window.innerWidth; + return bands.find(([[minimumWidth]]) => width >= minimumWidth)?.[1] ?? []; +} + function bannerSizesFromInjectedSlot(slot: AuctionSlot | undefined): BannerSize[] | undefined { - const parsedSizes = slot?.formats?.map(parseBannerSize).filter(isDefined) ?? []; + const sizes = injectedSlotBandSizes(slot) ?? slot?.formats; + const parsedSizes = sizes?.map(parseBannerSize).filter(isDefined) ?? []; return parsedSizes.length > 0 ? parsedSizes : undefined; } +/** Whether the slot's viewport band asks for no size at the current width. */ +function asksForNoSizeAtThisWidth(slot: RefreshGptSlot): boolean { + return injectedSlotBandSizes(findInjectedSlotForRefresh(slot))?.length === 0; +} + function refreshSlotElementId(slot: RefreshGptSlot): string | undefined { const elementId = slot.getSlotElementId?.(); return elementId && elementId.length > 0 ? elementId : undefined; @@ -2701,7 +2718,9 @@ export function installRefreshHandler(timeoutMs = 1500): void { getInjectedConfig()?.excludedGamAdUnitPathSuffixes ); const auctionSlots = independentSlots.filter( - (slot) => !isExcludedFromRefreshAuction(slot, excludedGamAdUnitPathSuffixes) + (slot) => + !isExcludedFromRefreshAuction(slot, excludedGamAdUnitPathSuffixes) && + !asksForNoSizeAtThisWidth(slot) ); if (!auctionSlots.length) { const immediateSlotCodes = new Map(); 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 520827f858..c5a5feff4f 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 @@ -3416,6 +3416,97 @@ describe('prebid/installRefreshHandler', () => { ); }); + describe('a slot with viewport bands', () => { + const originalInnerWidth = window.innerWidth; + + afterEach(() => { + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: originalInnerWidth, + }); + }); + + function installBandedSlot(viewportWidth: number) { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: viewportWidth }); + const originalRefresh = vi.fn(); + const gptSlot = { + getSlotElementId: vi.fn(() => 'div-ad-billboard'), + getTargeting: vi.fn(() => []), + }; + const pubads = { + refresh: originalRefresh, + getSlots: vi.fn(() => [gptSlot]), + }; + testWindow.googletag = { + cmd: { push: (fn: () => void) => fn() }, + pubads: () => pubads, + }; + testWindow.tsjs = { + adSlots: [ + { + id: 'billboard', + gam_unit_path: '/123/billboard', + div_id: 'div-ad-billboard', + formats: [ + [970, 250], + [300, 250], + [320, 50], + ], + viewport_sizes: [ + [[1040, 0], [[970, 250]]], + [[600, 0], []], + [[0, 0], [[320, 50]]], + ], + }, + ], + }; + installRefreshHandler(750); + return { pubads, originalRefresh, gptSlot }; + } + + it('offers the refresh auction only the sizes of the band the viewport is in', () => { + const { pubads } = installBandedSlot(375); + + pubads.refresh(); + + expect(mockRequestBids).toHaveBeenCalledWith( + expect.objectContaining({ + adUnits: [ + expect.objectContaining({ + code: 'div-ad-billboard', + mediaTypes: { banner: { sizes: [[320, 50]] } }, + }), + ], + }) + ); + }); + + it('offers the widest band its sizes on a wide viewport', () => { + const { pubads } = installBandedSlot(1280); + + pubads.refresh(); + + expect(mockRequestBids).toHaveBeenCalledWith( + expect.objectContaining({ + adUnits: [ + expect.objectContaining({ + mediaTypes: { banner: { sizes: [[970, 250]] } }, + }), + ], + }) + ); + }); + + it('leaves a slot out of the refresh auction where its band asks for no size', () => { + const { pubads, originalRefresh } = installBandedSlot(700); + + pubads.refresh(); + + expect(mockRequestBids).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalled(); + }); + }); + it('resolves the exact slot when div_ids share a prefix', () => { // Regression: a single find() with a startsWith() clause returned the // first slot whose div_id is a prefix of the element id. With div_ids diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index c97e2a7c59..33ebcc83fe 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -2765,14 +2765,6 @@ formats = [ ] ``` -When any format of a slot names a bound, the slot's page data carries -`viewport_sizes`, one entry for each band of widths, widest first, and the page -asks for only that band's sizes. A band starts at zero and at every width where -the formats on offer change, and a band with no format asks for nothing. A slot -whose formats name no bound asks for every format at every width, as before. -The bounds apply in the browser only. A server-side auction is still offered -every format. - > [!WARNING] > A binary released before these fields refuses a format carrying either of > them, and fails to load its settings, which makes every request fail. Promote From ccea158a8501d48678f713077d3b2ce47970d251 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 11:29:47 +0100 Subject: [PATCH 3/9] A slot whose viewport band asks for no size is not defined The head bootstrap and the bundle defined every slot and left an empty band to the size mapping, after the page had painted. No ad can serve at such a width, and GPT sizes a slot's div when it is displayed if the page's style gives it no size, so the only effect was to move the page. Both now define nothing there and release the first-impression claim, so the container keeps the space the page gave it. The band lookup is shared by the bundle's slot definition and its refresh auction, and the recorded page carries the new bootstrap. --- .../src/fixtures/page-change.recorded.html | 27 ++++++- crates/ad-tag/google/src/gpt_bootstrap.js | 27 ++++++- .../lib/src/core/viewport_sizes.ts | 16 +++++ .../lib/src/integrations/gpt/index.ts | 13 +++- .../lib/src/integrations/prebid/index.ts | 16 +---- .../lib/test/integrations/gpt/ad_init.test.ts | 72 +++++++++++++++++++ .../integrations/gpt/gpt_bootstrap.test.ts | 67 +++++++++++++++++ 7 files changed, 222 insertions(+), 16 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/core/viewport_sizes.ts diff --git a/crates/ad-tag/google/src/fixtures/page-change.recorded.html b/crates/ad-tag/google/src/fixtures/page-change.recorded.html index a6f6a55c05..b239f2213a 100644 --- a/crates/ad-tag/google/src/fixtures/page-change.recorded.html +++ b/crates/ad-tag/google/src/fixtures/page-change.recorded.html @@ -360,8 +360,33 @@ ); } - // Defines the slot and applies its viewport bands as a size mapping. + // The sizes the slot's viewport band asks for at the current viewport width, + // an empty list where that band asks for none, or null when the slot names + // no band. Bands come widest first, so the first one the width reaches holds. + function viewportBandSizes(slot) { + var bands = slot.viewport_sizes; + if (!bands || !bands.length) return null; + var width = window.innerWidth; + var band = bands.find(function (candidate) { + return width >= candidate[0][0]; + }); + return band ? band[1] : []; + } + + // Defines the slot and applies its viewport bands as a size mapping. Defines + // nothing where the band at the current viewport width asks for no size, as + // no ad can serve there and GPT would otherwise size or collapse the slot's + // container after the page has painted. function defineWithViewportSizes(api, slot, elementId) { + var bandSizes = viewportBandSizes(slot); + if (bandSizes && !bandSizes.length) { + if (ts.log && typeof ts.log.debug === "function") { + ts.log.debug("GPT slot asks for no size at this viewport width", { + divId: slot.div_id, + }); + } + return null; + } var definedSlot = api.defineSlot( slot.gam_unit_path, slot.formats, diff --git a/crates/ad-tag/google/src/gpt_bootstrap.js b/crates/ad-tag/google/src/gpt_bootstrap.js index 374f7bbb5c..75b58d89ef 100644 --- a/crates/ad-tag/google/src/gpt_bootstrap.js +++ b/crates/ad-tag/google/src/gpt_bootstrap.js @@ -358,8 +358,33 @@ ); } - // Defines the slot and applies its viewport bands as a size mapping. + // The sizes the slot's viewport band asks for at the current viewport width, + // an empty list where that band asks for none, or null when the slot names + // no band. Bands come widest first, so the first one the width reaches holds. + function viewportBandSizes(slot) { + var bands = slot.viewport_sizes; + if (!bands || !bands.length) return null; + var width = window.innerWidth; + var band = bands.find(function (candidate) { + return width >= candidate[0][0]; + }); + return band ? band[1] : []; + } + + // Defines the slot and applies its viewport bands as a size mapping. Defines + // nothing where the band at the current viewport width asks for no size, as + // no ad can serve there and GPT would otherwise size or collapse the slot's + // container after the page has painted. function defineWithViewportSizes(api, slot, elementId) { + var bandSizes = viewportBandSizes(slot); + if (bandSizes && !bandSizes.length) { + if (ts.log && typeof ts.log.debug === "function") { + ts.log.debug("GPT slot asks for no size at this viewport width", { + divId: slot.div_id, + }); + } + return null; + } var definedSlot = api.defineSlot( slot.gam_unit_path, slot.formats, diff --git a/crates/trusted-server-js/lib/src/core/viewport_sizes.ts b/crates/trusted-server-js/lib/src/core/viewport_sizes.ts new file mode 100644 index 0000000000..fa691b3920 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/viewport_sizes.ts @@ -0,0 +1,16 @@ +import type { AuctionSlot } from './types'; + +/** + * The sizes a slot's viewport band asks for at the current viewport width, an + * empty list where that band asks for none, or `undefined` when the slot names + * no band. Bands come widest first, each from its narrowest width, so the first + * band the width reaches is the one in effect. + */ +export function viewportBandSizes( + slot: AuctionSlot | undefined +): Array<[number, number]> | undefined { + const bands = slot?.viewport_sizes; + if (!bands?.length) return undefined; + const width = window.innerWidth; + return bands.find(([[minimumWidth]]) => width >= minimumWidth)?.[1] ?? []; +} diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 04867d1813..f0a1e1c4fb 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -16,6 +16,7 @@ import type { GptSlotHandoff, TsjsApi, } from '../../core/types'; +import { viewportBandSizes } from '../../core/viewport_sizes'; import { APS_UNIVERSAL_CREATIVE_RENDERER, APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, @@ -855,7 +856,13 @@ function displayTargetElementId(target: GoogleTagDisplayTarget): string | undefi return (target as Element).id || undefined; } -/** Defines the slot and applies its viewport bands as a size mapping. */ +/** + * Defines the slot and applies its viewport bands as a size mapping. + * + * Defines nothing where the band at the current viewport width asks for no + * size, as no ad can serve there and GPT would otherwise size or collapse the + * slot's container after the page has painted. + */ function defineWithViewportSizes( api: { defineSlot?(adUnitPath: string, size: Array, elementId?: string): S | null; @@ -864,6 +871,10 @@ function defineWithViewportSizes 0 ? parsedSizes : undefined; } -/** - * The sizes the slot's viewport band asks for at the current viewport width, - * or `undefined` when the slot names no band. See `AuctionSlot.viewport_sizes`. - */ -function injectedSlotBandSizes(slot: AuctionSlot | undefined): Array<[number, number]> | undefined { - const bands = slot?.viewport_sizes; - if (!bands?.length) return undefined; - const width = window.innerWidth; - return bands.find(([[minimumWidth]]) => width >= minimumWidth)?.[1] ?? []; -} - function bannerSizesFromInjectedSlot(slot: AuctionSlot | undefined): BannerSize[] | undefined { - const sizes = injectedSlotBandSizes(slot) ?? slot?.formats; + const sizes = viewportBandSizes(slot) ?? slot?.formats; const parsedSizes = sizes?.map(parseBannerSize).filter(isDefined) ?? []; return parsedSizes.length > 0 ? parsedSizes : undefined; } /** Whether the slot's viewport band asks for no size at the current width. */ function asksForNoSizeAtThisWidth(slot: RefreshGptSlot): boolean { - return injectedSlotBandSizes(findInjectedSlotForRefresh(slot))?.length === 0; + return viewportBandSizes(findInjectedSlotForRefresh(slot))?.length === 0; } function refreshSlotElementId(slot: RefreshGptSlot): string | undefined { diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 1f138c7934..637269ad5f 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -772,6 +772,78 @@ describe('installTsAdInit', () => { } ); + it('leaves a slot alone where its viewport band asks for no size', async () => { + const originalInnerWidth = window.innerWidth; + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 700 }); + try { + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + defineSizeMapping: vi.fn().mockReturnThis(), + getTargeting: vi.fn().mockReturnValue([]), + }; + const nativeRefresh = vi.fn(); + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([]), + addEventListener: vi.fn(), + refresh: nativeRefresh, + }; + const defineSlotMock = vi.fn().mockReturnValue(mockSlot); + const displayMock = vi.fn(); + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: defineSlotMock, + display: displayMock, + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + sizeMapping: vi.fn().mockReturnValue({ + addSize: vi.fn().mockReturnThis(), + build: vi.fn().mockReturnValue('built mapping'), + }), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [ + [970, 250], + [320, 50], + ], + viewport_sizes: [ + [[1040, 0], [[970, 250]]], + [[600, 0], []], + [[0, 0], [[320, 50]]], + ], + targeting: {}, + }, + ], + bids: { atf_sidebar_ad: { hb_pb: '1.00' } }, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + + // No ad can serve at this width, so any GPT call could only size or + // collapse the container after the page has painted. + expect(defineSlotMock).not.toHaveBeenCalled(); + expect(displayMock).not.toHaveBeenCalled(); + expect(nativeRefresh).not.toHaveBeenCalled(); + expect( + (window as TestWindow).tsjs!.firstImpression?.slots['div-atf-sidebar'] + ).toBeUndefined(); + } finally { + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: originalInnerWidth, + }); + } + }); + it('hands a late publisher definition the TS inner-div slot without a second request', async () => { type FakeSlot = { addService(service: unknown): FakeSlot; diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts index 0743e91ada..ce731701da 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts @@ -903,6 +903,73 @@ describe('gpt_bootstrap.js fallback', () => { } ); + it('fallback adInit leaves a slot alone where its viewport band asks for no size', () => { + const originalInnerWidth = window.innerWidth; + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 700 }); + try { + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-billboard'), + defineSizeMapping: vi.fn().mockReturnThis(), + }; + const nativeRefresh = vi.fn(); + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([]), + refresh: nativeRefresh, + }; + const defineSlot = vi.fn().mockReturnValue(mockSlot); + const display = vi.fn(); + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot, + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + display, + sizeMapping: vi.fn().mockReturnValue({ + addSize: vi.fn().mockReturnThis(), + build: vi.fn().mockReturnValue('built mapping'), + }), + }; + document.body.innerHTML = '
'; + runBootstrap(); + const ts = (window as TestWindow).tsjs!; + ts.adSlots = [ + { + id: 'billboard', + gam_unit_path: '/123/billboard', + div_id: 'div-billboard', + formats: [ + [970, 250], + [320, 50], + ], + viewport_sizes: [ + [[1040, 0], [[970, 250]]], + [[600, 0], []], + [[0, 0], [[320, 50]]], + ], + targeting: {}, + }, + ]; + ts.bids = { billboard: { hb_pb: '1.00' } }; + + ts.adInit!(); + + // No ad can serve at this width, so any GPT call could only size or + // collapse the container after the page has painted. + expect(defineSlot).not.toHaveBeenCalled(); + expect(display).not.toHaveBeenCalled(); + expect(nativeRefresh).not.toHaveBeenCalled(); + expect(ts.firstImpression?.slots['div-billboard']).toBeUndefined(); + } finally { + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: originalInnerWidth, + }); + } + }); + it('fallback adInit leaves a publisher-rendered slot untouched', () => { const mockSlot = { addService: vi.fn().mockReturnThis(), From 2a582ca7492c8f52ab58ed5d88427148be10ffe2 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 11:36:59 +0100 Subject: [PATCH 4/9] A winning bid with no size of its own takes the size of the viewport band The render bridge sized a winning bid that carried no width or height as the slot's first configured format, so on a phone a slot whose band asks for 320x50 rendered a 970x250 frame. It now takes the first size of the band the viewport is in, and falls back to the first format only where the slot names no band or the band asks for nothing. The comment on the server side that names the fallback says the same. --- crates/trusted-server-core/src/publisher.rs | 7 ++- .../lib/src/integrations/gpt/index.ts | 9 +-- .../lib/test/integrations/gpt/ad_init.test.ts | 62 +++++++++++++++++++ 3 files changed, 71 insertions(+), 7 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index b042715b2d..e2fbac23f5 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -6467,9 +6467,10 @@ pub(crate) fn build_bid_map_with_auction_id( serde_json::Value::String(auction_id.to_owned()), ); } - // Winning creative dimensions — the bridge sizes the inline - // render from these, falling back to the first configured slot - // format only when absent, which mis-sizes a multi-size slot. + // Winning creative dimensions. The bridge sizes the inline + // render from these. When they are absent it falls back to the + // first size of the slot's viewport band, then to the first + // configured format, which mis-sizes a multi-size slot. // Omit a zero dimension (missing OpenRTB w/h parse to 0) so the // bridge falls back rather than sizing the frame to 0. if bid.width > 0 { diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index f0a1e1c4fb..aab03738d1 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -2187,10 +2187,11 @@ export function installTsRenderBridge(): void { const attemptId = safelyRecordCreativeRequest(slotId); const slot = window.tsjs?.adSlots?.find((s) => s.id === slotId); - // Prefer the winning creative's own dimensions; the first configured slot - // format is only a fallback and mis-sizes a multi-size slot whose winner is - // not the first format. - const [fallbackWidth, fallbackHeight] = slot?.formats?.[0] ?? [728, 90]; + // Prefer the winning creative's own dimensions. Without them, use the first + // size of the slot's viewport band at this width, then the first configured + // format, which mis-sizes a multi-size slot whose winner is another format. + const [fallbackWidth, fallbackHeight] = viewportBandSizes(slot)?.[0] ?? + slot?.formats?.[0] ?? [728, 90]; const width = matchedBid.w ?? fallbackWidth; const height = matchedBid.h ?? fallbackHeight; const inlineAdm = isNonEmptyString(matchedBid.adm) ? matchedBid.adm : undefined; diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 637269ad5f..91f53ebcb9 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -5262,6 +5262,68 @@ describe('installTsRenderBridge', () => { } }); + it('sizes a winning bid that carries no size from the viewport band', async () => { + const originalInnerWidth = window.innerWidth; + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 375 }); + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + (window as TestWindow).tsjs = { + bids: { + homepage_header: { + hb_adid: 'sizeless-adid', + hb_bidder: 'ix', + hb_pb: '1.00', + adm: '
Sizeless
', + }, + }, + adSlots: [ + { + id: 'homepage_header', + formats: [ + [970, 250], + [320, 50], + ] as [number, number][], + viewport_sizes: [ + [[1040, 0], [[970, 250]]], + [[0, 0], [[320, 50]]], + ], + gam_unit_path: '/a/b/c', + div_id: 'div-header', + targeting: {}, + }, + ], + divToSlotId: { 'div-header': 'homepage_header' }, + }; + + const bridgeListener = await captureBridgeListener(); + const portMessages: string[] = []; + const source = createTrustedSlotIframe(); + + try { + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'sizeless-adid' }), + ports: [{ postMessage: (s: string) => portMessages.push(s) }], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(portMessages).toHaveLength(1); + + // A 375 pixel viewport is in the band that asks for 320x50, so the first + // configured format, 970x250, would not fit. + const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; + expect([parsed.width, parsed.height]).toEqual([320, 50]); + } finally { + beaconSpy.mockRestore(); + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: originalInnerWidth, + }); + } + }); + it('resolves the requesting slot bid when two slots share one hb_adid', async () => { // Duplicate hb_adid across slots: PBS Cache is absent, so hb_adid falls back // to a creative id that a bidder reuses across slots. The bridge must resolve From 0a39baeba0878148deae51d8839ebb346eac5fbd Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 11:47:56 +0100 Subject: [PATCH 5/9] The refresh auction narrows only a slot defined with the viewport bands The refresh auction found a slot's page data by element id and offered the sizes of its band whoever defined the slot, so a slot the publisher defined first, which carries no size mapping, asked the ad server for its own sizes while bidders were offered only the band's, or left it out of the auction where the band was empty. The head bootstrap and the bundle now record each GPT slot they give the mapping, and the refresh auction narrows only those. Every other slot is offered its formats, as before the bands. --- .../src/fixtures/page-change.recorded.html | 5 ++- crates/ad-tag/google/src/gpt_bootstrap.js | 5 ++- .../trusted-server-js/lib/src/core/types.ts | 6 +++ .../lib/src/integrations/gpt/index.ts | 13 ++++-- .../lib/src/integrations/prebid/index.ts | 25 ++++++++--- .../lib/test/integrations/gpt/ad_init.test.ts | 4 ++ .../integrations/gpt/gpt_bootstrap.test.ts | 4 ++ .../test/integrations/prebid/index.test.ts | 42 ++++++++++++++++++- 8 files changed, 92 insertions(+), 12 deletions(-) diff --git a/crates/ad-tag/google/src/fixtures/page-change.recorded.html b/crates/ad-tag/google/src/fixtures/page-change.recorded.html index b239f2213a..c9882a002e 100644 --- a/crates/ad-tag/google/src/fixtures/page-change.recorded.html +++ b/crates/ad-tag/google/src/fixtures/page-change.recorded.html @@ -373,7 +373,8 @@ return band ? band[1] : []; } - // Defines the slot and applies its viewport bands as a size mapping. Defines + // Defines the slot and applies its viewport bands as a size mapping, recording + // the slot in ts.viewportMappedSlots once the mapping is applied. Defines // nothing where the band at the current viewport width asks for no size, as // no ad can serve there and GPT would otherwise size or collapse the slot's // container after the page has painted. @@ -405,6 +406,8 @@ builder.addSize(band[0], band[1]); }); definedSlot.defineSizeMapping(builder.build()); + ts.viewportMappedSlots = ts.viewportMappedSlots || new WeakSet(); + ts.viewportMappedSlots.add(definedSlot); } return definedSlot; } diff --git a/crates/ad-tag/google/src/gpt_bootstrap.js b/crates/ad-tag/google/src/gpt_bootstrap.js index 75b58d89ef..730b6e0488 100644 --- a/crates/ad-tag/google/src/gpt_bootstrap.js +++ b/crates/ad-tag/google/src/gpt_bootstrap.js @@ -371,7 +371,8 @@ return band ? band[1] : []; } - // Defines the slot and applies its viewport bands as a size mapping. Defines + // Defines the slot and applies its viewport bands as a size mapping, recording + // the slot in ts.viewportMappedSlots once the mapping is applied. Defines // nothing where the band at the current viewport width asks for no size, as // no ad can serve there and GPT would otherwise size or collapse the slot's // container after the page has painted. @@ -403,6 +404,8 @@ builder.addSize(band[0], band[1]); }); definedSlot.defineSizeMapping(builder.build()); + ts.viewportMappedSlots = ts.viewportMappedSlots || new WeakSet(); + ts.viewportMappedSlots.add(definedSlot); } return definedSlot; } diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index df563733af..a608566fcd 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -533,6 +533,12 @@ export interface TsjsApi { gptSlotHandoffs?: Record; /** True only while TS calls a GPT function that the handoff wrappers observe. */ gptSlotHandoffInternal?: boolean; + /** + * GPT slots TS defined with their viewport bands as a size mapping. Only + * these ask the ad server for a band's sizes, so the refresh auction offers + * only these the sizes of their band. + */ + viewportMappedSlots?: WeakSet; /** Per-navigation first-impression ownership shared by GPT and Prebid. */ firstImpression?: FirstImpressionState; /** Guards the shared production GPT lifecycle listener installation. */ diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index aab03738d1..0a91535dd8 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -857,13 +857,17 @@ function displayTargetElementId(target: GoogleTagDisplayTarget): string | undefi } /** - * Defines the slot and applies its viewport bands as a size mapping. + * Defines the slot and applies its viewport bands as a size mapping, recording + * the slot in `viewportMappedSlots` once the mapping is applied. * * Defines nothing where the band at the current viewport width asks for no * size, as no ad can serve there and GPT would otherwise size or collapse the * slot's container after the page has painted. */ -function defineWithViewportSizes( +function defineWithViewportSizes< + S extends object & { defineSizeMapping?(mapping: unknown): unknown }, +>( + ts: TsjsApi, api: { defineSlot?(adUnitPath: string, size: Array, elementId?: string): S | null; sizeMapping?(): ViewportSizeBuilder; @@ -883,6 +887,7 @@ function defineWithViewportSizes defineWithViewportSizes(g, slot, element.id)) ?? + withGptSlotHandoffInternal(ts, () => defineWithViewportSizes(ts, g, slot, element.id)) ?? undefined; if (!gptSlot) { releaseTrustedServerFirstImpressionClaim(ts, element, claim); @@ -1390,7 +1395,7 @@ export function installTsAdInit(): void { // defineSlot() for this div is handed the same slot by the scoped GPT // wrapper, preventing a competing container-slot request. const defined = withGptSlotHandoffInternal(ts, () => - defineWithViewportSizes(g, slot, actualDivId) + defineWithViewportSizes(ts, g, slot, actualDivId) ); if (!defined) { releaseTrustedServerFirstImpressionClaim(ts, el, firstImpression); 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 859d3f8730..0dfbf83ba1 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -1135,15 +1135,30 @@ function bannerSizesFromGptSlot(slot: RefreshGptSlot): BannerSize[] | undefined return parsedSizes.length > 0 ? parsedSizes : undefined; } -function bannerSizesFromInjectedSlot(slot: AuctionSlot | undefined): BannerSize[] | undefined { - const sizes = viewportBandSizes(slot) ?? slot?.formats; +/** + * The sizes of the viewport band a GPT slot asks the ad server for at the + * current width, or `undefined` for a slot TS did not define with its bands as + * a size mapping, which asks for the sizes it was defined with. + */ +function mappedBandSizes( + slot: RefreshGptSlot, + injectedSlot: AuctionSlot | undefined +): Array<[number, number]> | undefined { + return window.tsjs?.viewportMappedSlots?.has(slot) ? viewportBandSizes(injectedSlot) : undefined; +} + +function bannerSizesFromInjectedSlot( + slot: RefreshGptSlot, + injectedSlot: AuctionSlot | undefined +): BannerSize[] | undefined { + const sizes = mappedBandSizes(slot, injectedSlot) ?? injectedSlot?.formats; const parsedSizes = sizes?.map(parseBannerSize).filter(isDefined) ?? []; return parsedSizes.length > 0 ? parsedSizes : undefined; } -/** Whether the slot's viewport band asks for no size at the current width. */ +/** Whether the slot's viewport band asks the ad server for no size at the current width. */ function asksForNoSizeAtThisWidth(slot: RefreshGptSlot): boolean { - return viewportBandSizes(findInjectedSlotForRefresh(slot))?.length === 0; + return mappedBandSizes(slot, findInjectedSlotForRefresh(slot))?.length === 0; } function refreshSlotElementId(slot: RefreshGptSlot): string | undefined { @@ -2751,7 +2766,7 @@ export function installRefreshHandler(timeoutMs = 1500): void { publisherZoneForRefresh(candidateCodes); const banner: TrustedServerBanner = { sizes: - bannerSizesFromInjectedSlot(injectedSlot) ?? + bannerSizesFromInjectedSlot(slot, injectedSlot) ?? bannerSizesFromGptSlot(slot) ?? DEFAULT_REFRESH_SIZES, ...(zone ? { name: zone } : {}), diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 91f53ebcb9..b4d7501d0c 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -762,12 +762,16 @@ describe('installTsAdInit', () => { // that was never displayed). expect(nativeRefresh).not.toHaveBeenCalled(); + // The refresh auction narrows only a slot recorded as carrying the mapping. + const mapped = (window as TestWindow).tsjs!.viewportMappedSlots?.has(mockSlot) ?? false; if (bands) { expect(builder.addSize.mock.calls).toEqual(bands); expect(mockSlot.defineSizeMapping).toHaveBeenCalledWith('built mapping'); + expect(mapped).toBe(true); } else { expect(sizeMapping).not.toHaveBeenCalled(); expect(mockSlot.defineSizeMapping).not.toHaveBeenCalled(); + expect(mapped).toBe(false); } } ); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts index ce731701da..59324ae2c5 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts @@ -893,12 +893,16 @@ describe('gpt_bootstrap.js fallback', () => { expect(display).toHaveBeenCalledWith('div-atf-sidebar'); expect(ts.servicesEnabled).toBe(true); + // The refresh auction narrows only a slot recorded as carrying the mapping. + const mapped = ts.viewportMappedSlots?.has(mockSlot) ?? false; if (bands) { expect(builder.addSize.mock.calls).toEqual(bands); expect(mockSlot.defineSizeMapping).toHaveBeenCalledWith('built mapping'); + expect(mapped).toBe(true); } else { expect(sizeMapping).not.toHaveBeenCalled(); expect(mockSlot.defineSizeMapping).not.toHaveBeenCalled(); + expect(mapped).toBe(false); } } ); 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 fe69c1a1ca..55c4a4e1ab 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 @@ -3549,7 +3549,9 @@ describe('prebid/installRefreshHandler', () => { }); }); - function installBandedSlot(viewportWidth: number) { + // A slot Trusted Server defined carries its bands as a size mapping, and a + // slot the publisher defined first carries none. + function installBandedSlot(viewportWidth: number, { definedWithMapping = true } = {}) { Object.defineProperty(window, 'innerWidth', { configurable: true, value: viewportWidth }); const originalRefresh = vi.fn(); const gptSlot = { @@ -3565,6 +3567,7 @@ describe('prebid/installRefreshHandler', () => { pubads: () => pubads, }; testWindow.tsjs = { + ...(definedWithMapping ? { viewportMappedSlots: new WeakSet([gptSlot]) } : {}), adSlots: [ { id: 'billboard', @@ -3628,6 +3631,43 @@ describe('prebid/installRefreshHandler', () => { expect(mockRequestBids).not.toHaveBeenCalled(); expect(originalRefresh).toHaveBeenCalled(); }); + + it('offers a slot the publisher defined its formats, as its ad request has no band', () => { + const { pubads } = installBandedSlot(375, { definedWithMapping: false }); + + pubads.refresh(); + + expect(mockRequestBids).toHaveBeenCalledWith( + expect.objectContaining({ + adUnits: [ + expect.objectContaining({ + code: 'div-ad-billboard', + mediaTypes: { + banner: { + sizes: [ + [970, 250], + [300, 250], + [320, 50], + ], + }, + }, + }), + ], + }) + ); + }); + + it('auctions a slot the publisher defined where its band asks for no size', () => { + const { pubads } = installBandedSlot(700, { definedWithMapping: false }); + + pubads.refresh(); + + expect(mockRequestBids).toHaveBeenCalledWith( + expect.objectContaining({ + adUnits: [expect.objectContaining({ code: 'div-ad-billboard' })], + }) + ); + }); }); it('resolves the exact slot when div_ids share a prefix', () => { From bd1c912c8aacd0f3117ad57b64edcc56a82c3492 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 11:52:56 +0100 Subject: [PATCH 6/9] Tests cover the viewport bands at both claim-expiry fallback sites The head bootstrap and the bundle each define a slot in two places, the adInit pass and the fallback that runs when a publisher's first-impression claim lapses, and only the adInit pass was tested with bands. A test for each fallback now checks that a band with sizes gives the slot its mapping and records it, and that a band with no size defines nothing and releases the claim. --- .../lib/test/integrations/gpt/ad_init.test.ts | 101 +++++++++++++++ .../integrations/gpt/gpt_bootstrap.test.ts | 115 ++++++++++++++++++ 2 files changed, 216 insertions(+) diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index b4d7501d0c..e366e5904e 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -316,6 +316,107 @@ describe('installTsAdInit', () => { } }); + it.each([ + { name: 'a band with sizes', viewportWidth: 375, defines: true }, + { name: 'a band with no size', viewportWidth: 700, defines: false }, + ])( + 'the claim-expiry fallback applies the viewport bands ($name)', + async ({ viewportWidth, defines }) => { + vi.useFakeTimers(); + const originalInnerWidth = window.innerWidth; + Object.defineProperty(window, 'innerWidth', { configurable: true, value: viewportWidth }); + try { + const builder = { + addSize: vi.fn().mockReturnThis(), + build: vi.fn().mockReturnValue('built mapping'), + }; + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + clearTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + defineSizeMapping: vi.fn().mockReturnThis(), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + // The publisher never defined the slot, so the fallback defines it. + getSlots: vi.fn().mockReturnValue([]), + addEventListener: vi.fn(), + refresh: vi.fn(), + }; + const defineSlot = vi.fn().mockReturnValue(mockSlot); + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot, + display: vi.fn(), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + sizeMapping: vi.fn().mockReturnValue(builder), + }; + const bands: Array<[[number, number], Array<[number, number]>]> = [ + [[1040, 0], [[970, 250]]], + [[600, 0], []], + [[0, 0], [[320, 50]]], + ]; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [ + [970, 250], + [320, 50], + ], + viewport_sizes: bands, + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { + hb_pb: '1.10', + hb_adid: 'example-fallback-ad', + adm: '
Fallback
', + }, + }, + }; + const ts = (window as TestWindow).tsjs as TsjsApi; + registerPublisherFirstImpressionAuctions(ts, ['div-atf-sidebar']); + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + ts.adInit!(); + expect(defineSlot).not.toHaveBeenCalled(); + + // The publisher's claim lapses and the fallback defines the slot. + vi.advanceTimersByTime(5001); + + if (defines) { + expect(defineSlot).toHaveBeenCalledWith( + '/123/atf', + [ + [970, 250], + [320, 50], + ], + 'div-atf-sidebar' + ); + expect(builder.addSize.mock.calls).toEqual(bands); + expect(mockSlot.defineSizeMapping).toHaveBeenCalledWith('built mapping'); + expect(ts.viewportMappedSlots?.has(mockSlot)).toBe(true); + } else { + expect(defineSlot).not.toHaveBeenCalled(); + expect(ts.firstImpression?.slots['div-atf-sidebar']).toBeUndefined(); + } + } finally { + vi.useRealTimers(); + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: originalInnerWidth, + }); + } + } + ); + it('does not clear targeting or request again after TS claims an existing slot', async () => { const recordTrustedServerOpportunity = vi.fn(); const { mockPubads, mockSlot } = configureOpportunityDiagnostics( diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts index 59324ae2c5..bfd282815b 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts @@ -453,6 +453,121 @@ describe('gpt_bootstrap.js fallback', () => { } }); + it.each([ + { name: 'a band with sizes', viewportWidth: 375, defines: true }, + { name: 'a band with no size', viewportWidth: 700, defines: false }, + ])( + 'the claim-expiry fallback applies the viewport bands ($name)', + ({ viewportWidth, defines }) => { + vi.useFakeTimers(); + vi.setSystemTime(100); + const originalInnerWidth = window.innerWidth; + Object.defineProperty(window, 'innerWidth', { configurable: true, value: viewportWidth }); + try { + const builder = { + addSize: vi.fn().mockReturnThis(), + build: vi.fn().mockReturnValue('built mapping'), + }; + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('banded-fallback'), + defineSizeMapping: vi.fn().mockReturnThis(), + }; + const pubads = { + getSlots: vi.fn(() => []), + refresh: vi.fn(), + enableSingleRequest: vi.fn(), + }; + const defineSlot = vi.fn(() => mockSlot); + (window as TestWindow).googletag = makeGoogleTag({ + cmd: { push: (command) => command() }, + defineSlot, + pubads: vi.fn(() => pubads), + sizeMapping: vi.fn(() => builder), + }); + document.body.innerHTML = '
'; + + runBootstrap(); + const ts = (window as TestWindow).tsjs!; + const element = document.getElementById('banded-fallback')!; + const publisherClaim: FirstImpressionSlotClaim = { + generation: 0, + slotElementId: element.id, + element, + owner: 'publisher', + phase: 'auctioning', + expiresAt: 5_100, + publisherAuctions: { + original: { + token: 'original', + adUnitCode: element.id, + phase: 'auctioning', + expiresAt: 5_100, + adIds: [], + suppressDelivery: false, + }, + }, + }; + ts.firstImpression = { + generation: 0, + nextToken: 1, + slots: { [element.id]: publisherClaim }, + fallbackSlots: {}, + }; + const bands: Array<[[number, number], Array<[number, number]>]> = [ + [[1040, 0], [[970, 250]]], + [[600, 0], []], + [[0, 0], [[320, 50]]], + ]; + ts.adSlots = [ + { + id: 'banded-fallback-ad', + gam_unit_path: '/123/banded-fallback', + div_id: element.id, + formats: [ + [970, 250], + [320, 50], + ], + viewport_sizes: bands, + targeting: {}, + }, + ]; + ts.bids = { 'banded-fallback-ad': { hb_pb: '1.00' } }; + + ts.adInit!(); + expect(defineSlot).not.toHaveBeenCalled(); + + // The publisher's claim lapses and the fallback defines the slot. + vi.advanceTimersByTime(5_001); + + if (defines) { + expect(defineSlot).toHaveBeenCalledWith( + '/123/banded-fallback', + [ + [970, 250], + [320, 50], + ], + element.id + ); + expect(builder.addSize.mock.calls).toEqual(bands); + expect(mockSlot.defineSizeMapping).toHaveBeenCalledWith('built mapping'); + expect(ts.viewportMappedSlots?.has(mockSlot)).toBe(true); + } else { + expect(defineSlot).not.toHaveBeenCalled(); + expect(ts.firstImpression.slots[element.id]).toBeUndefined(); + } + } finally { + vi.clearAllTimers(); + vi.useRealTimers(); + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: originalInnerWidth, + }); + } + } + ); + it.each([true, false])( 'settles the persistent bootstrap listener on render (empty=%s)', (isEmpty) => { From 27bef56c6ec9eee88eb8828d7fb655a2d8248d23 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 12:05:46 +0100 Subject: [PATCH 7/9] A size mapping that throws leaves the slot with every format The calls that build and apply a slot's size mapping ran after defineSlot with nothing to catch an exception, so a throw skipped addService and the release of the first-impression claim, and stopped the rest of the adInit pass. The head bootstrap and the bundle now catch it, log a warning and return the slot as defined, asking for every format and not recorded as mapped, so the refresh auction offers it every format too. The claim-expiry fallback test also types its command queue. --- .../src/fixtures/page-change.recorded.html | 26 +++++--- crates/ad-tag/google/src/gpt_bootstrap.js | 26 +++++--- .../lib/src/integrations/gpt/index.ts | 17 +++-- .../lib/test/integrations/gpt/ad_init.test.ts | 66 +++++++++++++++++++ .../integrations/gpt/gpt_bootstrap.test.ts | 65 +++++++++++++++++- 5 files changed, 177 insertions(+), 23 deletions(-) diff --git a/crates/ad-tag/google/src/fixtures/page-change.recorded.html b/crates/ad-tag/google/src/fixtures/page-change.recorded.html index c9882a002e..0cb4951f41 100644 --- a/crates/ad-tag/google/src/fixtures/page-change.recorded.html +++ b/crates/ad-tag/google/src/fixtures/page-change.recorded.html @@ -377,7 +377,8 @@ // the slot in ts.viewportMappedSlots once the mapping is applied. Defines // nothing where the band at the current viewport width asks for no size, as // no ad can serve there and GPT would otherwise size or collapse the slot's - // container after the page has painted. + // container after the page has painted. A mapping that throws leaves the slot + // defined with every format, so the caller still adds it to the ad service. function defineWithViewportSizes(api, slot, elementId) { var bandSizes = viewportBandSizes(slot); if (bandSizes && !bandSizes.length) { @@ -401,13 +402,22 @@ api.sizeMapping && definedSlot.defineSizeMapping ) { - var builder = api.sizeMapping(); - bands.forEach(function (band) { - builder.addSize(band[0], band[1]); - }); - definedSlot.defineSizeMapping(builder.build()); - ts.viewportMappedSlots = ts.viewportMappedSlots || new WeakSet(); - ts.viewportMappedSlots.add(definedSlot); + try { + var builder = api.sizeMapping(); + bands.forEach(function (band) { + builder.addSize(band[0], band[1]); + }); + definedSlot.defineSizeMapping(builder.build()); + ts.viewportMappedSlots = ts.viewportMappedSlots || new WeakSet(); + ts.viewportMappedSlots.add(definedSlot); + } catch (err) { + if (ts.log && typeof ts.log.warn === "function") { + ts.log.warn("GPT size mapping failed; slot keeps every format", { + divId: slot.div_id, + error: err, + }); + } + } } return definedSlot; } diff --git a/crates/ad-tag/google/src/gpt_bootstrap.js b/crates/ad-tag/google/src/gpt_bootstrap.js index 730b6e0488..1ec0ed21de 100644 --- a/crates/ad-tag/google/src/gpt_bootstrap.js +++ b/crates/ad-tag/google/src/gpt_bootstrap.js @@ -375,7 +375,8 @@ // the slot in ts.viewportMappedSlots once the mapping is applied. Defines // nothing where the band at the current viewport width asks for no size, as // no ad can serve there and GPT would otherwise size or collapse the slot's - // container after the page has painted. + // container after the page has painted. A mapping that throws leaves the slot + // defined with every format, so the caller still adds it to the ad service. function defineWithViewportSizes(api, slot, elementId) { var bandSizes = viewportBandSizes(slot); if (bandSizes && !bandSizes.length) { @@ -399,13 +400,22 @@ api.sizeMapping && definedSlot.defineSizeMapping ) { - var builder = api.sizeMapping(); - bands.forEach(function (band) { - builder.addSize(band[0], band[1]); - }); - definedSlot.defineSizeMapping(builder.build()); - ts.viewportMappedSlots = ts.viewportMappedSlots || new WeakSet(); - ts.viewportMappedSlots.add(definedSlot); + try { + var builder = api.sizeMapping(); + bands.forEach(function (band) { + builder.addSize(band[0], band[1]); + }); + definedSlot.defineSizeMapping(builder.build()); + ts.viewportMappedSlots = ts.viewportMappedSlots || new WeakSet(); + ts.viewportMappedSlots.add(definedSlot); + } catch (err) { + if (ts.log && typeof ts.log.warn === "function") { + ts.log.warn("GPT size mapping failed; slot keeps every format", { + divId: slot.div_id, + error: err, + }); + } + } } return definedSlot; } diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 0a91535dd8..1ea47aae6e 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -862,7 +862,8 @@ function displayTargetElementId(target: GoogleTagDisplayTarget): string | undefi * * Defines nothing where the band at the current viewport width asks for no * size, as no ad can serve there and GPT would otherwise size or collapse the - * slot's container after the page has painted. + * slot's container after the page has painted. A mapping that throws leaves the + * slot defined with every format, so the caller still adds it to the service. */ function defineWithViewportSizes< S extends object & { defineSizeMapping?(mapping: unknown): unknown }, @@ -882,12 +883,16 @@ function defineWithViewportSizes< const definedSlot = api.defineSlot?.(slot.gam_unit_path, slot.formats, elementId); const bands = slot.viewport_sizes; if (definedSlot && bands?.length && api.sizeMapping && definedSlot.defineSizeMapping) { - const builder = api.sizeMapping(); - for (const [viewport, sizes] of bands) { - builder.addSize(viewport, sizes); + try { + const builder = api.sizeMapping(); + for (const [viewport, sizes] of bands) { + builder.addSize(viewport, sizes); + } + definedSlot.defineSizeMapping(builder.build()); + (ts.viewportMappedSlots ??= new WeakSet()).add(definedSlot); + } catch (error) { + log.warn('GPT size mapping failed; slot keeps every format', { divId: slot.div_id, error }); } - definedSlot.defineSizeMapping(builder.build()); - (ts.viewportMappedSlots ??= new WeakSet()).add(definedSlot); } return definedSlot; } diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index e366e5904e..ce8a0d427c 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -949,6 +949,72 @@ describe('installTsAdInit', () => { } }); + it('keeps every format when the size mapping throws', async () => { + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + defineSizeMapping: vi.fn(() => { + throw new Error('mapping refused'); + }), + getTargeting: vi.fn().mockReturnValue([]), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([]), + addEventListener: vi.fn(), + refresh: vi.fn(), + }; + const displayMock = vi.fn(); + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + display: displayMock, + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + sizeMapping: vi.fn().mockReturnValue({ + addSize: vi.fn().mockReturnThis(), + build: vi.fn().mockReturnValue('built mapping'), + }), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [ + [300, 600], + [300, 250], + ], + viewport_sizes: [ + [ + [1040, 0], + [ + [300, 600], + [300, 250], + ], + ], + [[0, 0], [[300, 250]]], + ], + targeting: {}, + }, + ], + bids: {}, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + const ts = (window as TestWindow).tsjs!; + expect(() => ts.adInit!()).not.toThrow(); + + // The slot is still added to the ad service and displayed, asking for + // every format, and the refresh auction does not narrow it. + expect(mockSlot.addService).toHaveBeenCalledWith(mockPubads); + expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); + expect(ts.viewportMappedSlots?.has(mockSlot) ?? false).toBe(false); + }); + it('hands a late publisher definition the TS inner-div slot without a second request', async () => { type FakeSlot = { addService(service: unknown): FakeSlot; diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts index bfd282815b..a5610c005e 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts @@ -481,7 +481,7 @@ describe('gpt_bootstrap.js fallback', () => { }; const defineSlot = vi.fn(() => mockSlot); (window as TestWindow).googletag = makeGoogleTag({ - cmd: { push: (command) => command() }, + cmd: { push: (command: () => void) => command() }, defineSlot, pubads: vi.fn(() => pubads), sizeMapping: vi.fn(() => builder), @@ -1089,6 +1089,69 @@ describe('gpt_bootstrap.js fallback', () => { } }); + it('fallback adInit keeps every format when the size mapping throws', () => { + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + defineSizeMapping: vi.fn(() => { + throw new Error('mapping refused'); + }), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([]), + refresh: vi.fn(), + }; + const display = vi.fn(); + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + display, + sizeMapping: vi.fn().mockReturnValue({ + addSize: vi.fn().mockReturnThis(), + build: vi.fn().mockReturnValue('built mapping'), + }), + }; + document.body.innerHTML = + '
'; + runBootstrap(); + const ts = (window as TestWindow).tsjs!; + ts.adSlots = [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [ + [300, 600], + [300, 250], + ], + viewport_sizes: [ + [ + [1040, 0], + [ + [300, 600], + [300, 250], + ], + ], + [[0, 0], [[300, 250]]], + ], + targeting: {}, + }, + ]; + ts.bids = { atf_sidebar_ad: { hb_pb: '1.00' } }; + + expect(() => ts.adInit!()).not.toThrow(); + + // The slot is still added to the ad service and displayed, asking for + // every format, and the refresh auction does not narrow it. + expect(mockSlot.addService).toHaveBeenCalledWith(mockPubads); + expect(display).toHaveBeenCalledWith('div-atf-sidebar'); + expect(ts.viewportMappedSlots?.has(mockSlot) ?? false).toBe(false); + }); + it('fallback adInit leaves a publisher-rendered slot untouched', () => { const mockSlot = { addService: vi.fn().mockReturnThis(), From 2cea39ef56492408b77852395455dd3e92f7e14c Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:03:57 +0100 Subject: [PATCH 8/9] The ad templates details name each format's viewport bounds `ts config ad-templates --details` printed each format as its size and media type, so a slot whose formats name viewport bounds looked the same as one whose formats name none. Each format now carries any `min_viewport_width` and `max_viewport_width` it names, under those names, and a test pins the output. The comment on `build_ad_slots_script` names `viewport_sizes` among the properties of the page data. --- .../src/commands/config/ad_templates.rs | 53 ++++++++++++++++++- crates/trusted-server-core/src/publisher.rs | 3 +- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/config/ad_templates.rs b/crates/trusted-server-cli/src/commands/config/ad_templates.rs index 20f96b5f51..00821fb85c 100644 --- a/crates/trusted-server-cli/src/commands/config/ad_templates.rs +++ b/crates/trusted-server-cli/src/commands/config/ad_templates.rs @@ -487,13 +487,26 @@ fn format_slot(slot: &CreativeOpportunitySlot, gam_network_id: &str, section: &s ) } +/// Formats one format for `--details`, with any viewport bound under the name +/// the configuration gives it. fn format_format(format: &CreativeOpportunityFormat) -> String { let media_type = match format.media_type { MediaType::Banner => "banner", MediaType::Video => "video", MediaType::Native => "native", }; - format!("{}x{} {media_type}", format.width, format.height) + let bounds: String = [ + format + .min_viewport_width + .map(|min| format!(" min_viewport_width={min}")), + format + .max_viewport_width + .map(|max| format!(" max_viewport_width={max}")), + ] + .into_iter() + .flatten() + .collect(); + format!("{}x{} {media_type}{bounds}", format.width, format.height) } fn format_providers(slot: &CreativeOpportunitySlot) -> String { @@ -615,6 +628,44 @@ mod tests { ); } + #[test] + fn match_details_include_each_formats_viewport_bounds() { + let config_text = format!( + "{}\n\ + [[creative_opportunities.slot]]\n\ + id = \"billboard\"\n\ + page_patterns = [\"/video/*\"]\n\ + formats = [\n\ + {{ width = 970, height = 250, min_viewport_width = 1040 }},\n\ + {{ width = 728, height = 90, min_viewport_width = 800, max_viewport_width = 1039 }},\n\ + {{ width = 320, height = 50, max_viewport_width = 599 }},\n\ + ]\n", + config_with_slots() + ); + let (_temp, config) = project_with_config(&config_text); + let mut out = Vec::new(); + + run_ad_templates_with_writer( + &AdTemplatesCommand::Match(AdTemplatesMatchArgs { + config, + path_or_url: "/video/clip".to_string(), + details: true, + }), + &mut out, + ) + .expect("should match slots"); + + let output = String::from_utf8(out).expect("should be utf8"); + assert!( + output.contains( + "formats=[970x250 banner min_viewport_width=1040, \ + 728x90 banner min_viewport_width=800 max_viewport_width=1039, \ + 320x50 banner max_viewport_width=599]" + ), + "should print each format's viewport bounds: {output}" + ); + } + #[test] fn check_rejects_unexpected_extra_slots_by_default() { let (_temp, config) = project_with_config(&config_with_slots()); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index e2fbac23f5..c9cf02293d 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -7527,7 +7527,8 @@ fn permissions_json_or_empty(permissions_json: &str) -> Cow<'_, str> { /// Build the `tsjs.adSlots` `