From daa2bce708b04dea7c27227e9ac119d5225de218 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:41:16 -0500 Subject: [PATCH] Fix YouTube live PiP referrer identification Initialize the native PiP document from its opener before rendering embeds so YouTube receives the required Referer header. Cover real browser requests and PiP lifecycle without loading external providers in CI. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/frontend/src/components/LivePip.astro | 5 ++ src/frontend/tests/e2e/live-pip.spec.ts | 105 ++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 src/frontend/tests/e2e/live-pip.spec.ts diff --git a/src/frontend/src/components/LivePip.astro b/src/frontend/src/components/LivePip.astro index 974e3a89b..8dedd57d7 100644 --- a/src/frontend/src/components/LivePip.astro +++ b/src/frontend/src/components/LivePip.astro @@ -320,6 +320,7 @@ import LiveSvg from '@assets/icons/live.svg'; const doc = pipWindow.document; const iframe = doc.createElement('iframe'); + iframe.referrerPolicy = 'strict-origin-when-cross-origin'; iframe.src = embed.url; iframe.allow = embed.allow; iframe.setAttribute('aria-label', embed.label); @@ -370,6 +371,10 @@ import LiveSvg from '@assets/icons/live.svg'; width: 480, height: 270, }); + // Give the initial about:blank document the opener's URL so embeds send a Referer. + // Do this before registering listeners: document.open() clears them. + pipWindow.document.open(); + pipWindow.document.close(); state.pipWindow = pipWindow; state.activeEmbedKey = ''; pipWindow.addEventListener( diff --git a/src/frontend/tests/e2e/live-pip.spec.ts b/src/frontend/tests/e2e/live-pip.spec.ts new file mode 100644 index 000000000..451c4e819 --- /dev/null +++ b/src/frontend/tests/e2e/live-pip.spec.ts @@ -0,0 +1,105 @@ +import { expect, test, type Request } from '@playwright/test'; +import { dismissCookieConsentIfVisible } from '@tests/e2e/helpers'; + +// Native Document PiP needs the full browser rather than headless shell. +test.use({ channel: 'chromium' }); + +for (const videoId of ['DtdP0JBawPI', null]) { + test(`native YouTube PiP sends a referrer for the ${videoId ? 'video' : 'channel fallback'}`, async ({ + page, + context, + isMobile, + }) => { + test.skip(isMobile, 'Native Document Picture-in-Picture requires a desktop browser.'); + + const snapshot = { + isLive: true, + primarySource: 'twitch', + twitch: { live: true, channel: 'aspiredotdev' }, + youtube: { live: true, videoId }, + updatedAt: new Date().toISOString(), + }; + await page.route(/\/api\/live\/?$/, (route) => route.fulfill({ json: snapshot })); + await page.route(/\/api\/live\/stream\/?$/, (route) => + route.fulfill({ + contentType: 'text/event-stream', + body: `event: state\ndata: ${JSON.stringify(snapshot)}\n\n`, + }) + ); + + // Stub only the provider response, not PiP: Chromium must generate the real request. + const embeds: Request[] = []; + await context.route( + /^https:\/\/(?:www\.youtube-nocookie\.com\/embed\/|player\.twitch\.tv\/)/, + async (route) => { + embeds.push(route.request()); + await route.fulfill({ + contentType: 'text/html', + body: 'Provider player

Provider player

', + }); + } + ); + + await page.goto('/'); + await dismissCookieConsentIfVisible(page); + expect(await page.evaluate(() => 'documentPictureInPicture' in window)).toBe(true); + const expectedReferrer = new URL(page.url()).origin + '/'; + const liveButton = () => page.locator('.live-btn:visible').first(); + const menu = page.getByRole('dialog', { name: 'Choose how to watch' }); + const youtube = menu.getByRole('button', { name: /Open YouTube Picture-in-Picture/ }); + const twitch = menu.getByRole('button', { name: /Open Twitch Picture-in-Picture/ }); + + await expect(liveButton()).toHaveAttribute('data-source', 'both'); + await liveButton().click(); + const opened = context.waitForEvent('page'); + await youtube.click(); + const pip = await opened; + + await expect.poll(() => embeds.length).toBe(1); + expect(await embeds[0].headerValue('referer')).toBe(expectedReferrer); + const embedUrl = new URL(embeds[0].url()); + expect(embedUrl.origin).toBe('https://www.youtube-nocookie.com'); + expect(embedUrl.pathname).toBe(`/embed/${videoId ?? 'live_stream'}`); + if (!videoId) { + expect(embedUrl.searchParams.get('channel')).toBe('UCW_UJkc7RhM_NPcDXnOCfrQ'); + } + expect(embedUrl.searchParams.get('autoplay')).toBe('1'); + expect(embedUrl.searchParams.get('mute')).toBe('1'); + await expect(liveButton()).toHaveAttribute('data-pip-open', 'true'); + + await page.locator('header a[href="/docs/"]:visible').click(); + await expect(page).toHaveURL(/\/docs\/$/); + await expect(liveButton()).toHaveAttribute('data-pip-open', 'true'); + expect(pip.isClosed()).toBe(false); + await liveButton().click(); + await youtube.click(); + expect(embeds).toHaveLength(1); + + await liveButton().click(); + await twitch.click(); + await expect.poll(() => embeds.length).toBe(2); + const twitchUrl = new URL(embeds[1].url()); + expect(twitchUrl.origin).toBe('https://player.twitch.tv'); + expect(twitchUrl.searchParams.get('parent')).toBe(new URL(page.url()).hostname); + + await liveButton().click(); + await youtube.click(); + await expect.poll(() => embeds.length).toBe(3); + expect(embeds[2].url()).toBe(embeds[0].url()); + expect(await embeds[2].headerValue('referer')).toBe(expectedReferrer); + + await pip.close(); + await expect(liveButton()).toHaveAttribute('data-pip-open', 'false'); + await expect(liveButton()).toHaveAttribute('data-live', 'true'); + await expect(page).toHaveURL(/\/docs\/$/); + + await liveButton().click(); + const reopened = context.waitForEvent('page'); + await youtube.click(); + const nextPip = await reopened; + await expect.poll(() => embeds.length).toBe(4); + expect(await embeds[3].headerValue('referer')).toBe(expectedReferrer); + await nextPip.close(); + await expect(liveButton()).toHaveAttribute('data-pip-open', 'false'); + }); +}