diff --git a/crates/js/lib/src/core/types.ts b/crates/js/lib/src/core/types.ts index 9f726bb9..31f66d0a 100644 --- a/crates/js/lib/src/core/types.ts +++ b/crates/js/lib/src/core/types.ts @@ -20,6 +20,49 @@ export interface AdUnit { bids?: Bid[]; } +/** Minimal shape of a server-side auction slot injected into `window.tsjs.adSlots`. */ +export interface AuctionSlot { + id: string; + gam_unit_path: string; + div_id: string; + formats: Array<[number, number]>; + targeting?: Record; +} + +/** Debug-only copy of server-side bid fields exposed for pipeline inspection. */ +export interface AuctionDebugBidData { + slot_id?: string; + price?: number | null; + currency?: string; + creative?: string | null; + adomain?: string[] | null; + bidder?: string; + width?: number; + height?: number; + nurl?: string | null; + burl?: string | null; + ad_id?: string | null; + cache_id?: string | null; + cache_host?: string | null; + cache_path?: string | null; + metadata?: Record; +} + +/** Bid targeting data from the server-side auction, injected into `window.tsjs.bids`. */ +export interface AuctionBidData { + hb_pb?: string; + hb_bidder?: string; + hb_adid?: string; + hb_cache_host?: string; + hb_cache_path?: string; + nurl?: string; + burl?: string; + /** Raw creative markup. Only present when `[debug] inject_adm_for_testing = true`. */ + adm?: string; + /** Debug-only bid field mirror. Only present when `[debug] inject_adm_for_testing = true`. */ + debug_bid?: AuctionDebugBidData; +} + export interface TsjsApi { version: string; que: Array<() => void>; @@ -41,4 +84,20 @@ export interface TsjsApi { error(...args: unknown[]): void; debug(...args: unknown[]): void; }; + + // ── Server-side auction runtime (populated by TS edge injection) ────────── + /** Ad slot definitions injected at open. */ + adSlots?: AuctionSlot[]; + /** Winning bid targeting data injected before . */ + bids?: Record; + /** Initialises GPT slots with server-side bid targeting and calls refresh(). */ + adInit?: () => void; + /** GPT slot objects TS defined — used to destroy stale slots on SPA navigation. */ + prevGptSlots?: unknown[]; + /** Guards one-time-per-page enableSingleRequest/enableServices calls. */ + servicesEnabled?: boolean; + /** Maps actualDivId → slotId for slotRenderEnded billing lookup. */ + divToSlotId?: Record; + /** Guards SPA pushState hook installation. */ + spaHookInstalled?: boolean; } diff --git a/crates/js/lib/src/integrations/gpt/index.test.ts b/crates/js/lib/src/integrations/gpt/index.test.ts index 87455591..af57772b 100644 --- a/crates/js/lib/src/integrations/gpt/index.test.ts +++ b/crates/js/lib/src/integrations/gpt/index.test.ts @@ -1,4 +1,23 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Track every 'message' EventListener added to window across the entire test +// file. This lets the installTsRenderBridge suite remove all accumulated +// handlers (registered by each vi.resetModules() + import('./index') in the +// installTsAdInit suite) before dispatching its own events. +const allMessageHandlers: EventListener[] = []; +const _origWindowAddEventListener = window.addEventListener.bind(window); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(window as any).addEventListener = function ( + type: string, + handler: EventListenerOrEventListenerObject, + options?: unknown +) { + if (type === 'message') { + allMessageHandlers.push(handler as EventListener); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return _origWindowAddEventListener(type, handler as EventListener, options as any); +}; interface SlotRenderEvent { isEmpty: boolean; @@ -10,25 +29,19 @@ interface SlotRenderEvent { type TestWindow = Window & { googletag?: unknown; - __ts_ad_slots?: unknown; - __ts_bids?: unknown; - __tsAdInit?: () => void; - __tsPrevGptSlots?: unknown; - __tsServicesEnabled?: boolean; - __tsSpaHookInstalled?: boolean; - __tsDivToSlotId?: Record; + apstag?: { setDisplayBids?: () => void }; + // Typed as `any` to avoid the TypeScript intersection with the global + // Window.tsjs declaration (TsjsApi from core/types.ts), which would require + // every test fixture to satisfy the full TsjsApi shape. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tsjs?: any; }; describe('installTsAdInit', () => { beforeEach(() => { vi.resetModules(); - delete (window as TestWindow).__ts_ad_slots; - delete (window as TestWindow).__ts_bids; - delete (window as TestWindow).__tsAdInit; - delete (window as TestWindow).__tsPrevGptSlots; - delete (window as TestWindow).__tsSpaHookInstalled; - delete (window as TestWindow).__tsDivToSlotId; - (window as TestWindow).__tsServicesEnabled = false; + const tw = window as TestWindow; + delete tw.tsjs; // jsdom does not implement navigator.sendBeacon; polyfill it for tests if (!('sendBeacon' in navigator)) { Object.defineProperty(navigator, 'sendBeacon', { @@ -37,9 +50,20 @@ describe('installTsAdInit', () => { configurable: true, }); } + // adInit now queries the DOM for div elements by id/prefix — create the + // test div so getElementById and querySelector both resolve correctly. + if (!document.getElementById('div-atf-sidebar')) { + const div = document.createElement('div'); + div.id = 'div-atf-sidebar'; + document.body.appendChild(div); + } + }); + + afterEach(() => { + document.getElementById('div-atf-sidebar')?.remove(); }); - it('reads window.__ts_bids synchronously and applies bid targeting before refresh', async () => { + it('reads window.tsjs.bids synchronously and applies bid targeting before refresh', async () => { const mockSlot = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), @@ -48,6 +72,7 @@ describe('installTsAdInit', () => { }; const mockPubads = { enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), addEventListener: vi.fn(), refresh: vi.fn(), }; @@ -57,40 +82,103 @@ describe('installTsAdInit', () => { pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), }; - (window as TestWindow).__ts_ad_slots = [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: { pos: 'atf' }, + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: { pos: 'atf' }, + }, + ], + bids: { + atf_sidebar_ad: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'abc-uuid', + hb_cache_host: 'cache.example.com', + hb_cache_path: '/pbc/v1/cache', + nurl: 'https://ssp/win', + burl: 'https://ssp/bill', + }, }, - ]; - (window as TestWindow).__ts_bids = { - atf_sidebar_ad: { - hb_pb: '1.00', - hb_bidder: 'kargo', - hb_adid: 'abc', - nurl: 'https://ssp/win', - burl: 'https://ssp/bill', - }, - }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; const fetchSpy = vi.spyOn(global, 'fetch'); const { installTsAdInit } = await import('./index'); installTsAdInit(); - (window as TestWindow).__tsAdInit!(); + (window as TestWindow).tsjs!.adInit!(); expect(fetchSpy).not.toHaveBeenCalled(); expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'kargo'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_adid', 'abc-uuid'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_cache_host', 'cache.example.com'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_cache_path', '/pbc/v1/cache'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); expect(mockPubads.refresh).toHaveBeenCalled(); fetchSpy.mockRestore(); }); + it('keeps the GAM path when debug adm is present', async () => { + const slotEl = document.getElementById('div-atf-sidebar')!; + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue(['debug-uuid']), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + addEventListener: vi.fn(), + refresh: vi.fn(), + }; + const destroySlots = vi.fn(); + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + destroySlots, + 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: { pos: 'atf' }, + }, + ], + bids: { + atf_sidebar_ad: { + hb_pb: '0.20', + hb_bidder: 'mocktioneer', + hb_adid: 'debug-uuid', + adm: '
Debug creative
', + }, + }, + }; + + const { installTsAdInit } = await import('./index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + + expect(slotEl.innerHTML).toBe(''); + expect(destroySlots).not.toHaveBeenCalledWith([mockSlot]); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '0.20'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'mocktioneer'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_adid', 'debug-uuid'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); + expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); + }); + it('fires both nurl and burl via sendBeacon on slotRenderEnded when our bid won', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); let capturedListener: ((e: SlotRenderEvent) => void) | undefined; @@ -103,6 +191,7 @@ describe('installTsAdInit', () => { }; const mockPubads = { enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), refresh: vi.fn(), addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { if (event === 'slotRenderEnded') capturedListener = fn; @@ -114,28 +203,30 @@ describe('installTsAdInit', () => { pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), }; - (window as TestWindow).__ts_ad_slots = [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ]; - (window as TestWindow).__ts_bids = { - atf_sidebar_ad: { - hb_pb: '1.00', - hb_bidder: 'kargo', - hb_adid: 'abc', - nurl: 'https://ssp/win', - burl: 'https://ssp/bill', + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'abc', + nurl: 'https://ssp/win', + burl: 'https://ssp/bill', + }, }, }; const { installTsAdInit } = await import('./index'); installTsAdInit(); - (window as TestWindow).__tsAdInit!(); + (window as TestWindow).tsjs!.adInit!(); expect(capturedListener).toBeDefined(); capturedListener!({ isEmpty: false, slot: mockSlot }); @@ -157,6 +248,7 @@ describe('installTsAdInit', () => { }; const mockPubads = { enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), refresh: vi.fn(), addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { if (event === 'slotRenderEnded') capturedListener = fn; @@ -168,27 +260,29 @@ describe('installTsAdInit', () => { pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), }; - (window as TestWindow).__ts_ad_slots = [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ]; - (window as TestWindow).__ts_bids = { - atf_sidebar_ad: { - hb_pb: '1.50', - hb_bidder: 'aps', - nurl: 'https://aps/win', - burl: 'https://aps/bill', + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { + hb_pb: '1.50', + hb_bidder: 'aps', + nurl: 'https://aps/win', + burl: 'https://aps/bill', + }, }, }; const { installTsAdInit } = await import('./index'); installTsAdInit(); - (window as TestWindow).__tsAdInit!(); + (window as TestWindow).tsjs!.adInit!(); expect(capturedListener).toBeDefined(); capturedListener!({ isEmpty: false, slot: mockSlot }); @@ -215,6 +309,7 @@ describe('installTsAdInit', () => { }; const mockPubads = { enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlotNoMatch]), refresh: vi.fn(), addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { if (event === 'slotRenderEnded') capturedListener = fn; @@ -226,28 +321,30 @@ describe('installTsAdInit', () => { pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), }; - (window as TestWindow).__ts_ad_slots = [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ]; - (window as TestWindow).__ts_bids = { - atf_sidebar_ad: { - hb_pb: '1.00', - hb_bidder: 'kargo', - hb_adid: 'abc', - nurl: 'https://ssp/win', - burl: 'https://ssp/bill', + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'abc', + nurl: 'https://ssp/win', + burl: 'https://ssp/bill', + }, }, }; const { installTsAdInit } = await import('./index'); installTsAdInit(); - (window as TestWindow).__tsAdInit!(); + (window as TestWindow).tsjs!.adInit!(); capturedListener!({ isEmpty: false, slot: mockSlotNoMatch }); expect(beaconSpy).not.toHaveBeenCalled(); @@ -270,6 +367,7 @@ describe('installTsAdInit', () => { }; const mockPubads = { enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), refresh: vi.fn(), addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { if (event === 'slotRenderEnded') capturedListener = fn; @@ -281,22 +379,24 @@ describe('installTsAdInit', () => { pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), }; - (window as TestWindow).__ts_ad_slots = [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { hb_pb: '1.00', hb_bidder: 'kargo', hb_adid: 'abc' }, }, - ]; - (window as TestWindow).__ts_bids = { - atf_sidebar_ad: { hb_pb: '1.00', hb_bidder: 'kargo', hb_adid: 'abc' }, }; const { installTsAdInit } = await import('./index'); installTsAdInit(); - (window as TestWindow).__tsAdInit!(); + (window as TestWindow).tsjs!.adInit!(); capturedListener!({ isEmpty: false, slot: arenaSlot }); @@ -304,9 +404,110 @@ describe('installTsAdInit', () => { beaconSpy.mockRestore(); }); - it('calls refresh even when __ts_bids is empty (graceful fallback)', async () => { + it('calls apstag.setDisplayBids when hb_bidder is aps', async () => { + const setDisplayBidsSpy = vi.fn(); + (window as TestWindow).apstag = { setDisplayBids: setDisplayBidsSpy }; + + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; const mockPubads = { enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + addEventListener: vi.fn(), + refresh: 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(), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { hb_pb: '1.50', hb_bidder: 'aps', nurl: '', burl: '' }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const { installTsAdInit } = await import('./index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + + expect(setDisplayBidsSpy).toHaveBeenCalled(); + + delete (window as TestWindow).apstag; + }); + + it('does not call apstag.setDisplayBids when hb_bidder is not aps', async () => { + const setDisplayBidsSpy = vi.fn(); + (window as TestWindow).apstag = { setDisplayBids: setDisplayBidsSpy }; + + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + addEventListener: vi.fn(), + refresh: 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(), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { hb_pb: '1.00', hb_bidder: 'kargo' }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const { installTsAdInit } = await import('./index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + + expect(setDisplayBidsSpy).not.toHaveBeenCalled(); + + delete (window as TestWindow).apstag; + }); + + it('calls refresh even when tsjs.bids is empty (graceful fallback)', async () => { + const emptyTestSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([emptyTestSlot]), addEventListener: vi.fn(), refresh: vi.fn(), }; @@ -319,21 +520,208 @@ describe('installTsAdInit', () => { pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), }; - (window as TestWindow).__ts_ad_slots = [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ]; - (window as TestWindow).__ts_bids = {}; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: {}, + }; const { installTsAdInit } = await import('./index'); installTsAdInit(); - (window as TestWindow).__tsAdInit!(); + (window as TestWindow).tsjs!.adInit!(); expect(mockPubads.refresh).toHaveBeenCalled(); }); }); + +describe('installTsRenderBridge', () => { + let fetchStub: ReturnType; + + beforeEach(() => { + vi.resetModules(); + // Remove ALL accumulated 'message' handlers from previous test module imports + // to prevent stale bridge listeners from intercepting our test event. + for (const handler of allMessageHandlers) { + window.removeEventListener('message', handler); + } + allMessageHandlers.length = 0; + + fetchStub = vi.fn(); + vi.stubGlobal('fetch', fetchStub); + + (window as TestWindow).tsjs = { + bids: { + homepage_header: { + hb_adid: 'test-cache-uuid', + hb_bidder: 'kargo', + hb_pb: '1.50', + hb_cache_host: 'openads.example.com', + hb_cache_path: '/cache', + }, + }, + adSlots: [ + { + id: 'homepage_header', + formats: [[728, 90]] as [number, number][], + gam_unit_path: '/a/b/c', + div_id: 'div-header', + targeting: {}, + }, + ], + }; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + delete (window as TestWindow).tsjs; + }); + + it('calls stopImmediatePropagation and fetches PBS Cache for a TS bid', async () => { + const mockAd = '
Test Creative
'; + fetchStub.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockAd), + } as Response); + + // Capture the bridge's 'message' listener at module-init time. + let bridgeListener: ((e: MessageEvent) => unknown) | undefined; + const origAdd = window.addEventListener.bind(window); + const addSpy = vi + .spyOn(window, 'addEventListener') + .mockImplementation( + (type: string, handler: EventListenerOrEventListenerObject, opts?: unknown) => { + if (type === 'message') bridgeListener = handler as (e: MessageEvent) => unknown; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + origAdd(type, handler as EventListener, opts as any); + } + ); + await import('./index'); + addSpy.mockRestore(); // Restore only addEventListener — fetchStub must stay stubbed + + expect(bridgeListener, 'bridge listener should be registered').toBeDefined(); + + const stopSpy = vi.fn(); + const portMessages: string[] = []; + const fakePort = { postMessage: (s: string) => portMessages.push(s) }; + + // Dispatch the fake event — bridge listener fires synchronously, then runs + // fire-and-forget fetch().then() chains asynchronously. + bridgeListener!( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [fakePort], + stopImmediatePropagation: stopSpy, + }) as unknown as MessageEvent + ); + + // Flush microtasks so the fetch mock resolves and .then chains fire. + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(fetchStub).toHaveBeenCalledWith( + 'https://openads.example.com/cache?uuid=test-cache-uuid', + { mode: 'cors' } + ); + expect(stopSpy).toHaveBeenCalled(); + expect(portMessages).toHaveLength(1); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parsed = JSON.parse(portMessages[0]) as Record; + expect(parsed.message).toBe('Prebid Response'); + expect(parsed.adId).toBe('test-cache-uuid'); + expect(parsed.ad).toBe(mockAd); + }); + + it('responds with adm without fetching PBS Cache when debug adm is available', async () => { + const debugAdm = '
Debug Creative
'; + (window as TestWindow).tsjs = { + bids: { + homepage_header: { + hb_adid: 'debug-adid', + hb_bidder: 'mocktioneer', + hb_pb: '0.20', + adm: debugAdm, + }, + }, + adSlots: [ + { + id: 'homepage_header', + formats: [[728, 90]] as [number, number][], + gam_unit_path: '/a/b/c', + div_id: 'div-header', + targeting: {}, + }, + ], + }; + + let bridgeListener: ((e: MessageEvent) => unknown) | undefined; + const origAdd = window.addEventListener.bind(window); + const addSpy = vi + .spyOn(window, 'addEventListener') + .mockImplementation( + (type: string, handler: EventListenerOrEventListenerObject, opts?: unknown) => { + if (type === 'message') bridgeListener = handler as (e: MessageEvent) => unknown; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + origAdd(type, handler as EventListener, opts as any); + } + ); + await import('./index'); + addSpy.mockRestore(); + + expect(bridgeListener, 'bridge listener should be registered').toBeDefined(); + + const stopSpy = vi.fn(); + const portMessages: string[] = []; + const fakePort = { postMessage: (s: string) => portMessages.push(s) }; + + bridgeListener!( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'debug-adid' }), + ports: [fakePort], + stopImmediatePropagation: stopSpy, + }) as unknown as MessageEvent + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(fetchStub).not.toHaveBeenCalled(); + expect(stopSpy).toHaveBeenCalled(); + expect(portMessages).toHaveLength(1); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parsed = JSON.parse(portMessages[0]) as Record; + expect(parsed.message).toBe('Prebid Response'); + expect(parsed.adId).toBe('debug-adid'); + expect(parsed.ad).toBe(debugAdm); + expect(parsed.width).toBe(728); + expect(parsed.height).toBe(90); + }); + + it('ignores message when adId does not match any TS bid', async () => { + await import('./index'); + fetchStub.mockResolvedValue({ ok: true, text: () => Promise.resolve('') } as Response); + + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ message: 'Prebid Request', adId: 'unknown-id' }), + ports: [], + }) + ); + + await new Promise((r) => setTimeout(r, 100)); + expect(fetchStub).not.toHaveBeenCalled(); + }); + + it('ignores non-Prebid messages', async () => { + await import('./index'); + window.dispatchEvent( + new MessageEvent('message', { data: JSON.stringify({ message: 'Other' }) }) + ); + await new Promise((r) => setTimeout(r, 50)); + expect(fetchStub).not.toHaveBeenCalled(); + }); +}); diff --git a/crates/js/lib/src/integrations/gpt/index.ts b/crates/js/lib/src/integrations/gpt/index.ts index e1a1ee26..4276bc7b 100644 --- a/crates/js/lib/src/integrations/gpt/index.ts +++ b/crates/js/lib/src/integrations/gpt/index.ts @@ -1,4 +1,5 @@ import { log } from '../../core/log'; +import type { AuctionSlot, AuctionBidData, TsjsApi } from '../../core/types'; import { installGptGuard } from './script_guard'; @@ -23,6 +24,8 @@ import { installGptGuard } from './script_guard'; * - Rewrite ad-unit paths for A/B testing. */ +const TS_INITIAL_TARGETING_KEY = 'ts_initial' as const; + // ------------------------------------------------------------------ // googletag type stubs (minimal surface needed by the shim) // ------------------------------------------------------------------ @@ -46,6 +49,7 @@ interface GoogleTagPubAdsService { enableSingleRequest(): void; addEventListener(event: string, fn: (e: SlotRenderEndedEvent) => void): void; refresh(slots?: GoogleTagSlot[]): void; + getSlots?(): GoogleTagSlot[]; } interface GoogleTag { @@ -180,97 +184,189 @@ export function installGptShim(): boolean { } // ------------------------------------------------------------------ -// Trusted Server ad-init types +// GAM interceptor (testing only) // ------------------------------------------------------------------ -interface TsAdSlot { - id: string; - gam_unit_path: string; - div_id: string; - formats: Array; - targeting: Record; -} - -interface TsBidData { - hb_pb?: string; - hb_bidder?: string; - hb_adid?: string; - nurl?: string; - burl?: string; +/** + * Replace the GAM-rendered creative with the server-side auction adm. + * + * Adapted from PR #241 (github.com/IABTechLab/trusted-server/pull/241). + * Instead of reading from pbjs, reads adm directly from window.tsjs.bids. + * Only active when inject_adm_for_testing injects adm server-side. + * + * Strategy: + * 1. If adm contains an