Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/trusted-server-core/src/integrations/gpt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1213,7 +1213,7 @@ mod tests {
"bootstrap should scan ID-bearing elements instead of interpolating div_id into CSS"
);
assert!(
combined.contains(".startsWith(slot.div_id)"),
combined.contains("candidate.id.startsWith(divId)"),
Comment thread
ChristianPavilonis marked this conversation as resolved.
"bootstrap should match metacharacter-containing div_id prefixes with startsWith"
);
assert!(
Expand Down
82 changes: 64 additions & 18 deletions crates/trusted-server-core/src/integrations/gpt_bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,66 @@
return target && target.id ? target.id : null;
}

function isElementVisible(element) {
var style = window.getComputedStyle(element);
return (
style.display !== "none" &&
style.visibility !== "hidden" &&
style.visibility !== "collapse"
);
}

function slotElementHasLayout(element) {
if (!isElementVisible(element)) return false;
var elementRect = element.getBoundingClientRect();
if (elementRect.width > 0 && elementRect.height > 0) return true;

var container = document.getElementById(element.id + "-container");
if (!container || !isElementVisible(container)) return false;
var containerRect = container.getBoundingClientRect();
return containerRect.width > 0 && containerRect.height > 0;
}

function findSlotElementByDivId(divId) {
if (!divId) return null;
var exact = document.getElementById(divId);
if (exact) return exact;

var idElements = document.querySelectorAll("[id]");
var prefixMatches = [];
for (var i = 0; i < idElements.length; i++) {
var candidate = idElements[i];
if (
candidate.id.startsWith(divId) &&
!candidate.id.endsWith("-container")
) {
prefixMatches.push(candidate);
}
}
// A unique prefix match may be a lazy slot that has not been sized yet.
// Geometry is only needed to disambiguate multiple responsive siblings.
if (prefixMatches.length === 1) return prefixMatches[0];

var visibleMatches = prefixMatches.filter(isElementVisible);
if (visibleMatches.length === 1) return visibleMatches[0];

var activeMatches = visibleMatches.filter(slotElementHasLayout);
if (activeMatches.length === 1) return activeMatches[0];

if (
prefixMatches.length > 1 &&
ts.log &&
typeof ts.log.warn === "function"
) {
ts.log.warn("GPT slot prefix did not resolve to one active element", {
Comment thread
ChristianPavilonis marked this conversation as resolved.
divId: divId,
prefixMatchCount: prefixMatches.length,
activeMatchCount: activeMatches.length,
});
}
return null;
}

function runHandoffInternal(callback) {
var wasInternal = ts.gptSlotHandoffInternal;
ts.gptSlotHandoffInternal = true;
Expand Down Expand Up @@ -217,24 +277,10 @@
// slot that was never displayed, so these are display()ed instead.
var slotsToDisplay = [];
slots.forEach(function (slot) {
// Resolve actual div ID: exact match first, then safe prefix scan.
// div_id in config may be a stable prefix (e.g. "ad-header-0-") when
// the suffix is dynamically generated by the framework at render time.
var el = document.getElementById(slot.div_id);
if (!el) {
var idElements = document.querySelectorAll("[id]");
for (var i = 0; i < idElements.length; i++) {
var candidate = idElements[i];
if (
slot.div_id &&
candidate.id.startsWith(slot.div_id) &&
!candidate.id.endsWith("-container")
) {
el = candidate;
break;
}
}
}
// Resolve actual div ID: exact match first, then the one active prefix
// match. Responsive publishers may emit several mutually exclusive
// siblings for one stable prefix, so document order is not sufficient.
var el = findSlotElementByDivId(slot.div_id);
if (!el) return;
var actualDivId = el.id;
var b = bids[slot.id] || {};
Expand Down
55 changes: 50 additions & 5 deletions crates/trusted-server-js/lib/src/integrations/gpt/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,50 @@ interface SlotRenderEndedEvent {
slot: GoogleTagSlot;
}

function isElementVisible(element: HTMLElement): boolean {
const style = window.getComputedStyle(element);
return (
style.display !== 'none' && style.visibility !== 'hidden' && style.visibility !== 'collapse'
);
}

function slotElementHasLayout(element: HTMLElement): boolean {
if (!isElementVisible(element)) return false;
const elementRect = element.getBoundingClientRect();
if (elementRect.width > 0 && elementRect.height > 0) return true;

const container = document.getElementById(`${element.id}-container`);
if (!container || !isElementVisible(container)) return false;
const containerRect = container.getBoundingClientRect();
return containerRect.width > 0 && containerRect.height > 0;
}

function findSlotElementByDivId(divId: string): HTMLElement | null {
if (!divId) return null;
Comment thread
ChristianPavilonis marked this conversation as resolved.
const exact = document.getElementById(divId);
if (exact) return exact;

return (
Array.from(document.querySelectorAll<HTMLElement>('[id]')).find(
(el) => el.id.startsWith(divId) && !el.id.endsWith('-container')
) ?? null
const prefixMatches = Array.from(document.querySelectorAll<HTMLElement>('[id]')).filter(
(element) => element.id.startsWith(divId) && !element.id.endsWith('-container')
);
// A unique prefix match may be a lazy slot that has not been sized yet.
// Geometry is only needed to disambiguate multiple responsive siblings.
if (prefixMatches.length === 1) return prefixMatches[0] ?? null;

const visibleMatches = prefixMatches.filter(isElementVisible);
if (visibleMatches.length === 1) return visibleMatches[0] ?? null;

const activeMatches = visibleMatches.filter(slotElementHasLayout);
if (activeMatches.length === 1) return activeMatches[0] ?? null;

if (prefixMatches.length > 1) {
log.warn('GPT slot prefix did not resolve to one active element', {
divId,
prefixMatchCount: prefixMatches.length,
activeMatchCount: activeMatches.length,
});
}
return null;
}

function candidateSlotRoots(divId: string): HTMLElement[] {
Expand Down Expand Up @@ -869,16 +904,26 @@ function waitForSlotElements(slots: AuctionSlot[], signal: AbortSignal): Promise

return new Promise<void>((resolve) => {
let settled = false;
let animationFrame: number | undefined;
const finish = (): void => {
if (settled) return;
settled = true;
if (animationFrame !== undefined) cancelAnimationFrame(animationFrame);
observer.disconnect();
clearTimeout(timer);
signal.removeEventListener('abort', finish);
resolve();
};
const observer = new MutationObserver(() => {
if (allPresent()) finish();
if (animationFrame !== undefined) return;
if (typeof requestAnimationFrame === 'undefined') {
if (allPresent()) finish();
return;
}
animationFrame = requestAnimationFrame(() => {
animationFrame = undefined;
if (allPresent()) finish();
});
});
observer.observe(document.documentElement, { childList: true, subtree: true });
const timer = setTimeout(finish, SPA_SLOT_WAIT_MS);
Expand Down
163 changes: 163 additions & 0 deletions crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,44 @@ type TestWindow = Window & {
tsjs?: any;
};

function appendResponsiveSlotElement(
id: string,
containerHasLayout: boolean,
elementHidden = false,
elementHasLayout = false,
containerVisible = containerHasLayout
): HTMLDivElement {
const container = document.createElement('div');
container.id = `${id}-container`;
container.dataset.responsiveSlotTest = 'true';
container.style.display = containerVisible ? 'block' : 'none';
container.getBoundingClientRect = () =>
({
width: containerHasLayout ? 320 : 0,
height: containerHasLayout ? 100 : 0,
}) as DOMRect;

const element = document.createElement('div');
element.id = id;
element.style.display = elementHidden ? 'none' : 'block';
element.getBoundingClientRect = () =>
({
width: elementHasLayout ? 300 : 0,
height: elementHasLayout ? 250 : 0,
}) as DOMRect;
container.appendChild(element);
document.body.appendChild(container);
return element;
}

function runGptBootstrap(): void {
const bootstrap = readFileSync(
resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'),
'utf8'
);
window.eval(bootstrap);
}

describe('installTsAdInit', () => {
beforeEach(() => {
vi.resetModules();
Expand All @@ -79,6 +117,7 @@ describe('installTsAdInit', () => {
document.getElementById('div-atf-sidebar')?.remove();
document.getElementById('ad-header-0-_r_1_')?.remove();
document.getElementById("ad'prefix-real")?.remove();
document.querySelectorAll('[data-responsive-slot-test]').forEach((element) => element.remove());
});

it('reads window.tsjs.bids synchronously and applies bid targeting before refresh', async () => {
Expand Down Expand Up @@ -1257,6 +1296,130 @@ describe('installTsAdInit', () => {
expect(mockPubads.refresh).toHaveBeenCalled();
});

it.each([
Comment thread
ChristianPavilonis marked this conversation as resolved.
{ implementation: 'runtime', activeIndexes: [2], publisherOwned: true, selectedIndex: 2 },
{ implementation: 'runtime', activeIndexes: [], selectedIndex: null },
{ implementation: 'runtime', activeIndexes: [], elementLayoutIndexes: [1], selectedIndex: 1 },
{ implementation: 'runtime', activeIndexes: [0, 2], selectedIndex: null },
{
implementation: 'runtime',
activeIndexes: [2, 3],
hiddenElementIndexes: [2],
selectedIndex: 3,
},
{
implementation: 'runtime',
activeIndexes: [],
hiddenElementIndexes: [0, 1, 3],
visibleContainerIndexes: [2],
selectedIndex: 2,
},
{ implementation: 'runtime', activeIndexes: [2], divId: '', selectedIndex: null },
{ implementation: 'bootstrap', activeIndexes: [2], publisherOwned: true, selectedIndex: 2 },
{ implementation: 'bootstrap', activeIndexes: [], selectedIndex: null },
{ implementation: 'bootstrap', activeIndexes: [], elementLayoutIndexes: [1], selectedIndex: 1 },
{ implementation: 'bootstrap', activeIndexes: [0, 2], selectedIndex: null },
{
implementation: 'bootstrap',
activeIndexes: [2, 3],
hiddenElementIndexes: [2],
selectedIndex: 3,
},
{
implementation: 'bootstrap',
activeIndexes: [],
hiddenElementIndexes: [0, 1, 3],
visibleContainerIndexes: [2],
selectedIndex: 2,
},
{ implementation: 'bootstrap', activeIndexes: [2], divId: '', selectedIndex: null },
] as const)(
'$implementation resolves responsive matches $activeIndexes to $selectedIndex',
async (testCase) => {
const { implementation, activeIndexes, selectedIndex } = testCase;
const hiddenElementIndexes =
'hiddenElementIndexes' in testCase ? testCase.hiddenElementIndexes : [];
const elementLayoutIndexes =
'elementLayoutIndexes' in testCase ? testCase.elementLayoutIndexes : [];
const visibleContainerIndexes =
'visibleContainerIndexes' in testCase ? testCase.visibleContainerIndexes : activeIndexes;
const divId = 'divId' in testCase ? testCase.divId : 'ad-responsive-';
const publisherOwned = 'publisherOwned' in testCase && testCase.publisherOwned;
const elements = ['a', 'b', 'c', 'd'].map((suffix, index) =>
appendResponsiveSlotElement(
`ad-responsive-${suffix}`,
(activeIndexes as readonly number[]).includes(index),
(hiddenElementIndexes as readonly number[]).includes(index),
(elementLayoutIndexes as readonly number[]).includes(index),
(visibleContainerIndexes as readonly number[]).includes(index)
)
);
const selectedElement = selectedIndex === null ? undefined : elements[selectedIndex];
const mockSlot = {
addService: vi.fn().mockReturnThis(),
setTargeting: vi.fn().mockReturnThis(),
getSlotElementId: vi.fn().mockReturnValue(selectedElement?.id ?? elements[0]!.id),
getTargeting: vi.fn().mockReturnValue([]),
};
const nativeRefresh = vi.fn();
const mockPubads = {
enableSingleRequest: vi.fn(),
getSlots: vi.fn().mockReturnValue(publisherOwned ? [mockSlot] : []),
addEventListener: vi.fn(),
refresh: nativeRefresh,
};
const defineSlot = vi.fn().mockReturnValue(mockSlot);
const nativeDisplay = vi.fn();
(window as TestWindow).googletag = {
cmd: { push: vi.fn((fn: () => void) => fn()) },
defineSlot,
display: nativeDisplay,
pubads: vi.fn().mockReturnValue(mockPubads),
enableServices: vi.fn(),
};
(window as TestWindow).tsjs = {
adSlots: [
{
id: 'responsive_slot',
gam_unit_path: '/123/responsive',
div_id: divId,
formats: [[300, 250]],
targeting: {},
},
],
bids: {},
};

if (implementation === 'runtime') {
const { installTsAdInit } = await import('../../../src/integrations/gpt/index');
installTsAdInit();
} else {
runGptBootstrap();
}
(window as TestWindow).tsjs!.adInit!();

if (selectedElement) {
if (publisherOwned) {
expect(defineSlot).not.toHaveBeenCalled();
expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]);
} else {
expect(defineSlot).toHaveBeenCalledWith(
'/123/responsive',
[[300, 250]],
selectedElement.id
);
expect(nativeDisplay).toHaveBeenCalledWith(selectedElement.id);
}
expect((window as TestWindow).tsjs!.divToSlotId).toEqual({
[selectedElement.id]: 'responsive_slot',
});
} else {
expect(defineSlot).not.toHaveBeenCalled();
expect((window as TestWindow).tsjs!.divToSlotId).toEqual({});
}
}
);

it('resolves dynamic div prefixes without interpolating div_id into a CSS selector', async () => {
const dynamicDiv = document.createElement('div');
dynamicDiv.id = "ad'prefix-real";
Expand Down
Loading
Loading