import { test, expect } from './helpers/extension-fixture.mjs'; /** * Drives the packed extension itself: real background service worker, real * chrome.scripting injection, real runtime messaging. The detection specs cover * which element gets picked; this file covers whether the extension ever gets * far enough to pick one. */ /** Runs code in an extension page, where the privileged chrome.* APIs exist. */ async function withExtensionPage(context, extensionId, fn) { const page = await context.newPage(); await page.goto(`chrome-extension://${extensionId}/popup.html`); const result = await fn(page); await page.close(); return result; } async function selectTargetTab(context, extensionId, pageUrl) { return withExtensionPage(context, extensionId, page => page.evaluate(async (url) => { const [tab] = await chrome.tabs.query({ url }); if (!tab) throw new Error(`no tab matched ${url}`); const response = await chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId: tab.id }); return { tabId: tab.id, response }; }, pageUrl)); } async function sendServerCommand(context, extensionId, tabId, action, payload) { return withExtensionPage(context, extensionId, page => page.evaluate(async ({ tabId, action, payload }) => { return chrome.runtime.sendMessage({ type: 'CONTENT_EVENT', action, payload: payload || {}, expectedTabId: tabId }); }, { tabId, action, payload })); } async function sendServerCommandBurst(context, extensionId, tabId, commands) { return withExtensionPage(context, extensionId, page => page.evaluate(async ({ tabId, commands }) => { return Promise.all(commands.map(({ action, payload }) => chrome.runtime.sendMessage({ type: 'CONTENT_EVENT', action, payload: payload || {}, expectedTabId: tabId }))); }, { tabId, commands })); } async function getExtensionState(context, extensionId, message) { return withExtensionPage(context, extensionId, page => page.evaluate( request => chrome.runtime.sendMessage(request), message )); } async function setAudioSettings(context, extensionId, settings) { return withExtensionPage(context, extensionId, page => page.evaluate( value => chrome.storage.local.set({ audioSettings: value }), settings )); } async function getAudioRouteState(context, extensionId, pageUrl) { return withExtensionPage(context, extensionId, page => page.evaluate(async url => { const [tab] = await chrome.tabs.query({ url }); if (!tab) throw new Error(`no tab matched ${url}`); const [result] = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: () => { const route = window.__koalaSyncAudioRoute; if (!route) { return { hasRoute: false, contextState: null, chainActive: null, hasVideo: false, signalLevel: 0 }; } let analyser = route.testAnalyser; if (!analyser) { analyser = route.audioCtx.createAnalyser(); analyser.fftSize = 32; route.chain.limiter.connect(analyser); route.testAnalyser = analyser; } if (!route.testSignal) { const oscillator = route.audioCtx.createOscillator(); const gain = route.audioCtx.createGain(); gain.gain.value = 0.05; oscillator.frequency.value = 440; oscillator.connect(gain); gain.connect(route.chain.limiter); oscillator.start(); route.testSignal = { oscillator, gain }; } const samples = new Uint8Array(analyser.fftSize); analyser.getByteTimeDomainData(samples); return { hasRoute: true, contextState: route.audioCtx?.state || null, chainActive: route.chain?.active ?? null, hasVideo: !!route.video, signalLevel: Math.max(...Array.from(samples, sample => Math.abs(sample - 128))) }; } }); return result?.result || null; }, pageUrl)); } async function getFrameMonitorState(context, extensionId, pageUrl, frameUrlPart) { return withExtensionPage(context, extensionId, page => page.evaluate(async ({ pageUrl, frameUrlPart }) => { const [tab] = await chrome.tabs.query({ url: pageUrl }); if (!tab) throw new Error(`no tab matched ${pageUrl}`); const frameResults = await chrome.scripting.executeScript({ target: { tabId: tab.id, allFrames: true }, func: () => ({ href: location.href, cleanup: typeof window.__koalaMediaFrameMonitorCleanup }) }); const frame = frameResults.find(candidate => candidate.result?.href.includes(frameUrlPart)); if (!frame) throw new Error(`no frame matched ${frameUrlPart}`); const target = frame.documentId ? { tabId: tab.id, documentIds: [frame.documentId] } : { tabId: tab.id, frameIds: [frame.frameId] }; const [result] = await chrome.scripting.executeScript({ target, func: () => typeof window.__koalaMediaFrameMonitorCleanup }); return result?.result; }, { pageUrl, frameUrlPart })); } test('injects into the target tab and attaches to a same-origin frame player', async ({ context, extensionId, baseURL }) => { const url = `${baseURL}/pages/iframe-player.html`; const page = await context.newPage(); await page.goto(url); await page.waitForFunction(() => window.__fixtureReady === true); const { response } = await selectTargetTab(context, extensionId, url); expect(response?.status, 'SET_TARGET_TAB should not report a failure').not.toBe('error'); await expect.poll( () => page.evaluate(() => { const video = document.querySelector('iframe').contentDocument.querySelector('video'); return video ? video.dataset.koalaAttached : null; }), { message: 'content script should attach to the video inside the frame' } ).toBe('true'); }); test('keeps the selected tab after the popup page closes and reopens', async ({ context, extensionId, baseURL }) => { const url = `${baseURL}/pages/simple-player.html`; const page = await context.newPage(); await page.goto(url); await page.waitForFunction(() => window.__fixtureReady === true); const { tabId, response } = await selectTargetTab(context, extensionId, url); expect(response).toMatchObject({ status: 'ok' }); const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' }); expect(status).toMatchObject({ targetTabId: tabId, targetReady: true, targetActivationState: 'ready' }); }); test('keeps a selected target even when the page has no video element', async ({ context, extensionId, baseURL }) => { const url = `${baseURL}/pages/no-video.html`; const page = await context.newPage(); await page.goto(url); await page.waitForFunction(() => window.__fixtureReady === true); const { tabId, response } = await selectTargetTab(context, extensionId, url); expect(response).toMatchObject({ status: 'ok', tabId }); const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' }); expect(status).toMatchObject({ targetTabId: tabId, targetReady: true, targetActivationState: 'ready', targetHasVideo: false }); }); test('keeps a previously selected player audible after switching targets', async ({ context, extensionId, baseURL }) => { const firstUrl = `${baseURL}/pages/simple-player.html`; const secondUrl = `${baseURL}/pages/no-video.html`; const first = await context.newPage(); const second = await context.newPage(); await first.goto(firstUrl); await first.waitForFunction(() => window.__fixtureReady === true); await second.goto(secondUrl); await second.waitForFunction(() => window.__fixtureReady === true); await setAudioSettings(context, extensionId, { enabled: true, boostDb: 1, compressor: { enabled: false, preset: 'recommended', customParams: {} } }); const { tabId: firstTabId } = await selectTargetTab(context, extensionId, firstUrl); await first.evaluate(() => document.getElementById('player').play()); await expect.poll( () => getAudioRouteState(context, extensionId, firstUrl), { message: 'the selected player must have a live audio route' } ).toMatchObject({ hasRoute: true, contextState: 'running', hasVideo: true }); await expect.poll( () => getAudioRouteState(context, extensionId, firstUrl).then(state => state?.signalLevel || 0), { message: 'the selected player must produce a signal through the audio route' } ).toBeGreaterThan(1); const { response } = await selectTargetTab(context, extensionId, secondUrl); expect(response).toMatchObject({ status: 'ok' }); await first.evaluate(() => document.getElementById('player').play()); await expect.poll(() => first.locator('#player').evaluate(video => ({ paused: video.paused, muted: video.muted, volume: video.volume }))).toMatchObject({ paused: false, muted: false }); await expect.poll( () => getAudioRouteState(context, extensionId, firstUrl).then(state => state?.signalLevel || 0), { message: 'switching targets must not mute the previous audio route' } ).toBeGreaterThan(1); await selectTargetTab(context, extensionId, firstUrl); await expect.poll( () => getAudioRouteState(context, extensionId, firstUrl), { message: 'reselecting the player must reuse its live audio route' } ).toMatchObject({ hasRoute: true, contextState: 'running', hasVideo: true }); await expect.poll( () => getAudioRouteState(context, extensionId, firstUrl).then(state => state?.signalLevel || 0), { message: 'reselecting the player must keep the audio signal alive' } ).toBeGreaterThan(1); const playResponse = await sendServerCommand(context, extensionId, firstTabId, 'play'); expect(playResponse).toMatchObject({ status: 'ok_solo' }); await expect.poll(() => first.locator('#player').evaluate(video => video.paused)).toBe(false); }); test('applies remote play, pause and seek to the framed player', async ({ context, extensionId, baseURL }) => { const url = `${baseURL}/pages/iframe-player.html`; const page = await context.newPage(); await page.goto(url); await page.waitForFunction(() => window.__fixtureReady === true); const { tabId } = await selectTargetTab(context, extensionId, url); await expect.poll(() => page.evaluate(() => { const video = document.querySelector('iframe').contentDocument.querySelector('video'); return video ? video.dataset.koalaAttached : null; })).toBe('true'); const playResponse = await sendServerCommand(context, extensionId, tabId, 'play'); expect(playResponse).toMatchObject({ status: 'ok_solo' }); await expect.poll(() => page.evaluate(FRAMED_VIDEO_PAUSED), { message: 'remote play should start playback' }).toBe(false); await sendServerCommand(context, extensionId, tabId, 'pause'); await expect.poll(() => page.evaluate(FRAMED_VIDEO_PAUSED), { message: 'remote pause should stop playback' }).toBe(true); await sendServerCommand(context, extensionId, tabId, 'seek', { targetTime: 6 }); await expect.poll( () => page.evaluate(() => document.querySelector('iframe').contentDocument.querySelector('video').currentTime), { message: 'remote seek should move the framed player' } ).toBeGreaterThan(5); }); test('reinjects after the target tab navigates', async ({ context, extensionId, baseURL }) => { const first = `${baseURL}/pages/iframe-player.html`; const page = await context.newPage(); await page.goto(first); await page.waitForFunction(() => window.__fixtureReady === true); await selectTargetTab(context, extensionId, first); await expect.poll(() => page.evaluate(() => { const video = document.querySelector('iframe').contentDocument.querySelector('video'); return video ? video.dataset.koalaAttached : null; })).toBe('true'); await page.goto(`${baseURL}/pages/simple-player.html`); await page.waitForFunction(() => window.__fixtureReady === true); await expect.poll( () => page.evaluate(() => { const video = document.getElementById('player'); return video ? video.dataset.koalaAttached : null; }), { message: 'the content script should come back after a navigation' } ).toBe('true'); }); test('re-attaches after the player frame swaps its document', async ({ context, extensionId, baseURL }) => { const url = `${baseURL}/pages/reloading-frame.html`; const page = await context.newPage(); await page.goto(url); await page.waitForFunction(() => window.__fixtureReady === true); await selectTargetTab(context, extensionId, url); await expect.poll(() => page.evaluate(() => { const video = document.querySelector('iframe').contentDocument.getElementById('framed-player'); return video ? video.dataset.koalaAttached : null; })).toBe('true'); // Navigating the frame replaces its document without touching the top one, // so nothing but a load hook on the frame can notice the new player. await page.evaluate(() => { document.querySelector('iframe').src = 'frames/player-frame-2.html'; }); await expect.poll( () => page.evaluate(() => { const video = document.querySelector('iframe').contentDocument.getElementById('framed-player-2'); return video ? video.dataset.koalaAttached : null; }), { message: 'the content script should follow the frame to its new document' } ).toBe('true'); }); test('re-attaches when a nested player frame swaps its document', async ({ context, extensionId, baseURL }) => { const url = `${baseURL}/pages/nested-frame.html`; const page = await context.newPage(); await page.goto(url); await page.waitForFunction(() => window.__fixtureReady === true); await selectTargetTab(context, extensionId, url); const innerVideo = (id) => page.evaluate((videoId) => { const outer = document.querySelector('iframe').contentDocument; const inner = outer.querySelector('iframe').contentDocument; const video = inner.getElementById(videoId); return video ? video.dataset.koalaAttached : null; }, id); await expect.poll(() => innerVideo('framed-player')).toBe('true'); // The reloading frame sits at depth two. A load hook that only covers // top-level frames would never fire for it. await page.evaluate(() => { const outer = document.querySelector('iframe').contentDocument; outer.querySelector('iframe').src = 'player-frame-2.html'; }); await expect.poll( () => innerVideo('framed-player-2'), { message: 'a frame two levels down should be watched for reloads too' } ).toBe('true'); }); test('moves local event listeners after a CSS-only player switch', async ({ context, extensionId, baseURL }) => { const url = `${baseURL}/pages/player-css-switch.html`; const page = await context.newPage(); await page.goto(url); await page.waitForFunction(() => window.__fixtureReady === true); await selectTargetTab(context, extensionId, url); await expect.poll(() => page.locator('#first').getAttribute('data-koala-attached')).toBe('true'); await page.waitForTimeout(250); const beforeBurst = await getExtensionState(context, extensionId, { type: 'GET_STATUS' }); await page.evaluate(() => { const first = document.getElementById('first'); first.dispatchEvent(new window.Event('play')); first.dispatchEvent(new window.Event('pause')); window.switchPlayer(); }); await expect.poll(async () => { const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' }); return status.lastActionState.action === 'play' && status.lastActionState.timestamp > beforeBurst.lastActionState.timestamp; }).toBe(true); const leading = await getExtensionState(context, extensionId, { type: 'GET_STATUS' }); await expect.poll( () => page.locator('#second').getAttribute('data-koala-attached'), { message: 'attribute-only visibility changes must move the active controller' } ).toBe('true'); expect(await page.locator('#first').getAttribute('data-koala-attached')).toBeNull(); const afterSwitch = await getExtensionState(context, extensionId, { type: 'GET_STATUS' }); expect(afterSwitch.lastActionState.action).toBe('play'); expect(afterSwitch.lastActionState.timestamp).toBe(leading.lastActionState.timestamp); const before = await getExtensionState(context, extensionId, { type: 'GET_STATUS' }); await page.locator('#first').evaluate(video => video.dispatchEvent(new window.Event('play'))); await page.waitForTimeout(250); const afterStale = await getExtensionState(context, extensionId, { type: 'GET_STATUS' }); expect(afterStale.lastActionState.timestamp).toBe(before.lastActionState.timestamp); await page.locator('#second').evaluate(video => video.dispatchEvent(new window.Event('play'))); await expect.poll(async () => { const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' }); return status.lastActionState.timestamp; }).toBeGreaterThan(afterStale.lastActionState.timestamp); }); test('re-elects after a wrapper-only change inside an unselected cross-origin frame', async ({ context, extensionId, baseURL }) => { const url = `${baseURL}/pages/cross-origin-internal-switching.html`; const page = await context.newPage(); await page.goto(url); await page.waitForFunction(() => window.__fixtureReady === true); const first = page.frames().find(frame => frame.url().includes('slot=first')); const second = page.frames().find(frame => frame.url().includes('slot=second')); await selectTargetTab(context, extensionId, url); await expect.poll(() => first.locator('video').getAttribute('data-koala-attached')).toBe('true'); expect(await second.locator('video').getAttribute('data-koala-attached')).toBeNull(); await page.evaluate(() => window.switchInternalPlayer()); await expect.poll( () => second.locator('video').getAttribute('data-koala-attached'), { message: 'an unselected frame must announce its internally-visible player' } ).toBe('true'); expect(await first.locator('video').getAttribute('data-koala-attached')).toBeNull(); }); test('targets a visible nested cross-origin player and keeps top-page debug context', async ({ context, extensionId, baseURL }) => { const url = `${baseURL}/pages/cross-origin-nested.html`; const page = await context.newPage(); await page.goto(url); await page.waitForFunction(() => window.__fixtureReady === true); const visiblePlayer = () => page.frames().find(frame => frame.url().includes('/frames/player-frame.html?visible=1')); await expect.poll(async () => visiblePlayer()?.locator('video').getAttribute('src')).toContain('player-480p-12s.mp4'); const { tabId, response } = await selectTargetTab(context, extensionId, url); expect(response).toMatchObject({ status: 'ok' }); expect(response.frameId).toBeGreaterThan(0); await expect.poll(() => visiblePlayer()?.locator('video').getAttribute('data-koala-attached')).toBe('true'); const hiddenPlayer = page.frames().find(frame => frame.url().includes('/frames/player-frame-2.html?hidden=1')); expect(await hiddenPlayer.locator('video').getAttribute('data-koala-attached')).toBeNull(); const playResponse = await sendServerCommand(context, extensionId, tabId, 'play'); expect(playResponse).toMatchObject({ status: 'ok_solo' }); await expect.poll(() => visiblePlayer().locator('video').evaluate(video => video.paused)).toBe(false); await sendServerCommand(context, extensionId, tabId, 'pause'); await expect.poll(() => visiblePlayer().locator('video').evaluate(video => video.paused)).toBe(true); await sendServerCommand(context, extensionId, tabId, 'seek', { targetTime: 6 }); await expect.poll(() => visiblePlayer().locator('video').evaluate(video => video.currentTime)).toBeGreaterThan(5); await sendServerCommand(context, extensionId, tabId, 'pause'); await sendServerCommandBurst(context, extensionId, tabId, [ { action: 'seek', payload: { targetTime: 8 } }, { action: 'play', payload: { currentTime: 8 } } ]); await expect.poll(() => visiblePlayer().locator('video').evaluate(video => ({ paused: video.paused, currentTime: video.currentTime }))).toMatchObject({ paused: false, currentTime: expect.any(Number) }); await expect.poll(() => visiblePlayer().locator('video').evaluate(video => video.currentTime)).toBeGreaterThan(7); const state = await getExtensionState(context, extensionId, { type: 'GET_VIDEO_STATE', tabId }); expect(state).toMatchObject({ found: true, url, pageTitle: 'Nested cross-origin player', frameOrigin: new URL(baseURL.replace('localhost', '127.0.0.1')).origin, inIframe: true }); }); test('re-elects the visible cross-origin player after an iframe switch', async ({ context, extensionId, baseURL }) => { const url = `${baseURL}/pages/cross-origin-switching.html`; const page = await context.newPage(); await page.goto(url); await page.waitForFunction(() => window.__fixtureReady === true); const first = page.frames().find(frame => frame.url().includes('/frames/player-frame.html?slot=first')); const second = page.frames().find(frame => frame.url().includes('/frames/player-frame-2.html?slot=second')); const { tabId, response } = await selectTargetTab(context, extensionId, url); expect(response).toMatchObject({ status: 'ok' }); const firstFrameId = response.frameId; await expect.poll(() => first.locator('video').getAttribute('data-koala-attached')).toBe('true'); await page.evaluate(() => window.switchPlayer()); await expect.poll(() => second.locator('video').getAttribute('data-koala-attached')).toBe('true'); await expect.poll(async () => { const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' }); return status.targetFrameId; }).not.toBe(firstFrameId); const playResponse = await sendServerCommand(context, extensionId, tabId, 'play'); expect(playResponse).toMatchObject({ status: 'ok_solo' }); await expect.poll(() => second.locator('video').evaluate(video => video.paused)).toBe(false); expect(await first.locator('video').evaluate(video => video.paused)).toBe(true); }); test('keeps commands flowing during continuous player-frame geometry changes', async ({ context, extensionId, baseURL }) => { const url = `${baseURL}/pages/cross-origin-switching.html`; const page = await context.newPage(); await page.goto(url); await page.waitForFunction(() => window.__fixtureReady === true); const first = page.frames().find(frame => frame.url().includes('/frames/player-frame.html?slot=first')); const { tabId, response } = await selectTargetTab(context, extensionId, url); expect(response).toMatchObject({ status: 'ok' }); await expect.poll(() => first.locator('video').getAttribute('data-koala-attached')).toBe('true'); await page.evaluate(() => window.startGeometryChurn()); try { await page.waitForTimeout(300); await sendServerCommand(context, extensionId, tabId, 'play', { currentTime: 1 }); await expect.poll( () => first.locator('video').evaluate(video => video.paused), { timeout: 3000, message: 'bounded refresh passes must not starve commands' } ).toBe(false); } finally { await page.evaluate(() => window.stopGeometryChurn()); } }); test('deactivates media monitors in child frames after a target-tab switch', async ({ context, extensionId, baseURL }) => { const firstUrl = `${baseURL}/pages/cross-origin-nested.html`; const secondUrl = `${baseURL}/pages/simple-player.html`; const firstPage = await context.newPage(); const secondPage = await context.newPage(); await firstPage.goto(firstUrl); await firstPage.waitForFunction(() => window.__fixtureReady === true); await secondPage.goto(secondUrl); await secondPage.waitForFunction(() => window.__fixtureReady === true); await selectTargetTab(context, extensionId, firstUrl); await expect.poll(() => getFrameMonitorState( context, extensionId, firstUrl, '/frames/player-frame.html?visible=1' )).toBe('function'); await selectTargetTab(context, extensionId, secondUrl); await expect.poll( () => getFrameMonitorState( context, extensionId, firstUrl, '/frames/player-frame.html?visible=1' ), { message: 'child-frame monitor should be destroyed with the old target tab' } ).toBe('undefined'); }); test('re-attaches after a selected cross-origin frame navigates', async ({ context, extensionId, baseURL }) => { const url = `${baseURL}/pages/cross-origin-reloading.html`; const page = await context.newPage(); await page.goto(url); await page.waitForFunction(() => window.__fixtureReady === true); const first = page.frames().find(frame => frame.url().includes('generation=first')); const { tabId, response } = await selectTargetTab(context, extensionId, url); expect(response).toMatchObject({ status: 'ok' }); await expect.poll(() => first.locator('video').getAttribute('data-koala-attached')).toBe('true'); const firstDocumentId = (await getExtensionState(context, extensionId, { type: 'GET_STATUS' })).targetDocumentId; await page.evaluate(() => window.reloadPlayer()); await expect.poll(() => page.frames().find(frame => frame.url().includes('generation=second'))?.locator('video').getAttribute('data-koala-attached')).toBe('true'); await expect.poll(async () => { const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' }); return status.targetDocumentId; }).not.toBe(firstDocumentId); const state = await getExtensionState(context, extensionId, { type: 'GET_VIDEO_STATE', tabId }); expect(state).toMatchObject({ found: true, inIframe: true }); }); test('discovers a video inserted late inside a cross-origin frame', async ({ context, extensionId, baseURL }) => { const url = `${baseURL}/pages/cross-origin-late.html`; const page = await context.newPage(); await page.goto(url); await page.waitForFunction(() => window.__fixtureReady === true); const lateFrame = page.frames().find(frame => frame.url().includes('/frames/late-player-frame.html')); const { response } = await selectTargetTab(context, extensionId, url); expect(response).toMatchObject({ status: 'ok', hasVideo: false }); await expect.poll( () => lateFrame.locator('#late-player').getAttribute('data-koala-attached'), { timeout: 12_000, message: 'late cross-origin video should trigger target re-election' } ).toBe('true'); await expect.poll(async () => { const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' }); return status.targetHasVideo; }).toBe(true); }); test('rejects a cross-origin player hidden three frame levels deep', async ({ context, extensionId, baseURL }) => { const url = `${baseURL}/pages/deep-hidden-cross-origin.html`; const page = await context.newPage(); await page.goto(url); await page.waitForFunction(() => window.__fixtureReady === true); const hiddenFrame = page.frames().find(frame => frame.url().includes('deep=hidden')); await expect.poll(() => hiddenFrame?.locator('video').getAttribute('src')).toContain('player-1080p-30s.mp4'); const { response } = await selectTargetTab(context, extensionId, url); expect(response).toMatchObject({ status: 'ok', frameId: 0, hasVideo: false }); expect(await hiddenFrame.locator('video').getAttribute('data-koala-attached')).toBeNull(); }); test('rejects a player inside a hidden same-origin frame', async ({ context, extensionId, baseURL }) => { const url = `${baseURL}/pages/hidden-same-origin.html`; const page = await context.newPage(); await page.goto(url); await page.waitForFunction(() => window.__fixtureReady === true); const hiddenFrame = page.frames().find(frame => frame.url().includes('hidden=same-origin')); await expect.poll(() => hiddenFrame?.locator('video').getAttribute('src')).toContain('player-480p-12s.mp4'); const { response } = await selectTargetTab(context, extensionId, url); expect(response).toMatchObject({ status: 'ok', frameId: 0, hasVideo: false }); expect(await hiddenFrame.locator('video').getAttribute('data-koala-attached')).toBeNull(); }); test('rejects a hidden cross-origin player after its iframe URL redirects', async ({ context, extensionId, baseURL }) => { const url = `${baseURL}/pages/hidden-redirect-cross-origin.html`; const page = await context.newPage(); await page.goto(url); await page.waitForFunction(() => window.__fixtureReady === true); const redirectedFrame = page.frames().find(frame => frame.url().includes('redirected=hidden')); await expect.poll(() => redirectedFrame?.locator('video').getAttribute('src')).toContain('player-1080p-30s.mp4'); const { response } = await selectTargetTab(context, extensionId, url); expect(response).toMatchObject({ status: 'ok', frameId: 0, hasVideo: false }); expect(await redirectedFrame.locator('video').getAttribute('data-koala-attached')).toBeNull(); }); /** * Reads one global from the top document and from the player frame separately, * so a test can prove which frame a script was installed in. */ async function readPerFrameGlobal(context, extensionId, pageUrl, globalName) { return withExtensionPage(context, extensionId, page => page.evaluate(async ({ pageUrl, globalName }) => { const [tab] = await chrome.tabs.query({ url: pageUrl }); if (!tab) throw new Error(`no tab matched ${pageUrl}`); const results = await chrome.scripting.executeScript({ target: { tabId: tab.id, allFrames: true }, func: name => ({ href: location.href, isTop: window.top === window, present: typeof window[name] !== 'undefined' && window[name] !== null }), args: [globalName] }); const entries = results.map(entry => entry.result).filter(Boolean); return { top: entries.find(entry => entry.isTop)?.present ?? null, player: entries.find(entry => !entry.isTop && entry.href.includes('player-frame'))?.present ?? null }; }, { pageUrl, globalName })); } test('controls a Drive-style cross-origin player without moving the chat into it', async ({ context, extensionId, baseURL }) => { const url = `${baseURL}/pages/drive-style-player.html`; const page = await context.newPage(); await page.goto(url); await page.waitForFunction(() => window.__fixtureReady === true); const { response } = await selectTargetTab(context, extensionId, url); // The top document hosts no video, so the target must be the player frame. expect(response).toMatchObject({ status: 'ok', hasVideo: true }); expect(response.frameId).not.toBe(0); const playerFrame = page.frames().find(frame => frame.url().includes('player-frame')); await expect .poll(() => playerFrame.locator('video').getAttribute('data-koala-attached')) .toBe('true'); // The controller belongs in the player frame... await expect .poll(() => readPerFrameGlobal(context, extensionId, url, 'koalaSyncInjected')) .toMatchObject({ player: true }); // ...and the chat overlay belongs in the top document, never inside the // video. Installing it in the player frame is what rendered the chat on top // of the picture and made closing it affect only that frame. await expect .poll(() => readPerFrameGlobal(context, extensionId, url, 'koalaSyncChatOverlay')) .toMatchObject({ top: true, player: false }); }); test('keeps controlling a Drive-style player across an ordinary play and pause', async ({ context, extensionId, baseURL }) => { const url = `${baseURL}/pages/drive-style-player.html`; const page = await context.newPage(); await page.goto(url); await page.waitForFunction(() => window.__fixtureReady === true); const { tabId, response } = await selectTargetTab(context, extensionId, url); expect(response).toMatchObject({ status: 'ok' }); const selectedFrameId = response.frameId; const playerFrame = page.frames().find(frame => frame.url().includes('player-frame')); await playerFrame.locator('video').evaluate(video => video.play()); await playerFrame.locator('video').evaluate(video => video.pause()); await page.waitForTimeout(750); // Playback state changes are not frame layout changes. If they were treated // as such, the target would be torn down and re-injected mid-playback. const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' }); expect(status).toMatchObject({ targetTabId: tabId, targetReady: true, targetActivationState: 'ready', targetFrameId: selectedFrameId }); const command = await sendServerCommand(context, extensionId, tabId, 'play', { time: 1 }); expect(command).toMatchObject({ status: 'ok_solo' }); await expect.poll(() => playerFrame.locator('video').evaluate(video => video.paused)).toBe(false); }); function FRAMED_VIDEO_PAUSED() { return document.querySelector('iframe').contentDocument.querySelector('video').paused; }