mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-19 15:46:15 +00:00
096775d39f
Reproduced against the live yummyanime.tv layout, which is: top (no video) ├── visible same-origin wrapper 830x498 -> cross-origin player ├── hidden same-origin wrapper 0x0 -> cross-origin mirror └── hidden cross-origin trailer 0x0 Three things kept that page from ever settling on a target. Equally-ranked players were a hard failure. Several mirrors or dubs loaded at once is an ordinary layout for these sites, and refusing to activate made them unusable. The resolver now holds the top frame and waits for one of them to start playing, which is the signal that breaks the tie. Inconclusive probes moved the target. A page whose players are still loading resolves differently from one call to the next, and every difference triggered a full teardown and reinjection, so activation never finished — the popup sat on "activating" with nothing in the log. A probe that finds no video now leaves the target where it is. The visibility handshake expired mid-probe. Its listener lived 1000ms while the probe sequence is six separate executeScript round trips; on a heavy page it was gone before the answer arrived, leaving every frame's visibility unknown — the exact state that makes two players look equal. It now outlives the sequence. A settled failure also no longer reports itself as "activating". Covered by two fixtures built from the real page: one where the player exists up front, and one where the host only creates it on play, asserting the target is promoted into the deep cross-origin frame without touching the popup again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
812 lines
39 KiB
JavaScript
812 lines
39 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();
|
|
});
|
|
|
|
test('selects the visible anime player nested behind a same-origin wrapper', async ({ context, extensionId, baseURL }) => {
|
|
const url = `${baseURL}/pages/yummy-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', hasVideo: true });
|
|
expect(response.frameId).not.toBe(0);
|
|
|
|
// The playable element is the one inside the visible wrapper. The two
|
|
// zero-sized mirrors must be ignored, not treated as equal candidates.
|
|
const playerFrame = suffix => page.frames()
|
|
.find(frame => frame.url().endsWith(`/frames/${suffix}`));
|
|
await expect
|
|
.poll(() => playerFrame('player-frame.html').locator('video').getAttribute('data-koala-attached'))
|
|
.toBe('true');
|
|
expect(await playerFrame('player-frame-2.html').locator('video')
|
|
.getAttribute('data-koala-attached')).toBeNull();
|
|
|
|
// And the selection has to settle, not keep re-resolving.
|
|
await page.waitForTimeout(1500);
|
|
const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
|
|
expect(status).toMatchObject({
|
|
targetTabId: tabId,
|
|
targetReady: true,
|
|
targetActivationState: 'ready'
|
|
});
|
|
});
|
|
|
|
test('selects an anime tab before playback and promotes the player once it appears', async ({ context, extensionId, baseURL }) => {
|
|
// The live case: at selection time the page has no video anywhere, because
|
|
// the host only builds the player when the viewer presses play.
|
|
const url = `${baseURL}/pages/yummy-deferred-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);
|
|
// Selecting must succeed and settle even with nothing to control yet.
|
|
expect(response).toMatchObject({ status: 'ok', frameId: 0, hasVideo: false });
|
|
|
|
await page.waitForTimeout(1200);
|
|
const idle = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
|
|
expect(idle).toMatchObject({
|
|
targetTabId: tabId,
|
|
targetReady: true,
|
|
targetActivationState: 'ready'
|
|
});
|
|
|
|
const deferred = page.frames().find(frame => frame.url().endsWith('/frames/deferred-player-frame.html'));
|
|
await deferred.locator('#poster').click();
|
|
|
|
// The monitor has to hand the target over to the frame that now owns the
|
|
// video, without the user touching the popup again.
|
|
await expect
|
|
.poll(() => deferred.locator('video').getAttribute('data-koala-attached'), { timeout: 15000 })
|
|
.toBe('true');
|
|
await expect
|
|
.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' })
|
|
.then(state => ({ ready: state.targetReady, frame: state.targetFrameId })), { timeout: 15000 })
|
|
.toMatchObject({ ready: true });
|
|
const promoted = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
|
|
expect(promoted.targetFrameId).not.toBe(0);
|
|
expect(promoted).toMatchObject({ targetTabId: tabId, targetActivationState: 'ready' });
|
|
});
|
|
|
|
test('keeps the tab selected when its activation fails', async ({ context, extensionId }) => {
|
|
// A page the extension is not allowed to script stands in for any activation
|
|
// failure the user can act on. Losing the selection here is what made the
|
|
// popup come back empty after it was closed and reopened.
|
|
const page = await context.newPage();
|
|
await page.goto('chrome://version');
|
|
|
|
const { tabId, response } = await selectTargetTab(context, extensionId, 'chrome://version/*');
|
|
expect(response?.status).not.toBe('ok');
|
|
|
|
const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
|
|
expect(status).toMatchObject({
|
|
targetTabId: tabId,
|
|
targetReady: false,
|
|
targetActivationState: 'error'
|
|
});
|
|
expect(status.targetActivationError).toBeTruthy();
|
|
|
|
// Reopening the popup must not quietly retry and must not lose the choice.
|
|
const second = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
|
|
expect(second).toMatchObject({ targetTabId: tabId, targetActivationState: 'error' });
|
|
});
|
|
|
|
test('drops the selection only when the user clears it', 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 } = await selectTargetTab(context, extensionId, url);
|
|
await expect
|
|
.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' })
|
|
.then(state => state.targetTabId))
|
|
.toBe(tabId);
|
|
|
|
const cleared = await getExtensionState(context, extensionId, {
|
|
type: 'SET_TARGET_TAB',
|
|
tabId: null
|
|
});
|
|
expect(cleared).toMatchObject({ status: 'ok', tabId: null });
|
|
|
|
const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
|
|
expect(status).toMatchObject({
|
|
targetTabId: null,
|
|
targetReady: false,
|
|
targetActivationState: 'none'
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 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;
|
|
}
|