Files
KoalaSync/tests/e2e/extension.spec.mjs
T
Timo 75a9ba5d3d fix(extension): control nested players without permission prompts or churn
Google Drive and YummyAnime host their player in a cross-origin iframe. The
3.1.2 targeting work reached those frames but misdiagnosed and destabilized
them in four separate ways. No manifest permission is added or restored;
webNavigation stays removed.

Access diagnosis was inferred, not measured. Every frame probe error was
swallowed, and any origin that failed to answer was reported as missing host
access. A slow or still-loading player frame therefore produced
"Host access required for youtube.googleapis.com" for an origin the extension
already held. The resolver now asks permissions.contains() before raising an
access error, and treats a granted-but-unresponsive origin as a retry, not a
user decision.

Probes were unbounded. Every executeScript in the resolver now runs under a
timeout, so one unreachable frame can no longer stall an activation, and the
retry budget drops from eight passes to three.

The chat overlay followed the player into its frame, which rendered it on top
of the video and scoped closing and minimizing to that frame. It is now always
installed in the tab's top document, with all chat traffic routed to frame 0,
while only the playback controller goes into the selected media frame.

Nested targets reactivated continuously. Every heartbeat and content event
revalidated the target with a full teardown and reinjection, and the media
monitor treated ordinary play, pause and buffering as frame layout changes.
Both paths now reactivate only when the selected frame or document actually
moves.

Also restores the audio-route retention that keeps a deselected tab audible:
createMediaElementSource() can only be called once per element, so a
reinjected content script must adopt the existing route rather than rebuild it.

Verified with 90 unit tests, 40 browser E2E tests including two new
Drive-shaped fixtures that assert the controller lands in the player frame
while the chat stays in the top document, and npm run verify.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 00:15:10 +02:00

696 lines
34 KiB
JavaScript

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;
}