fix(extension): make anime-style nested players selectable again

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>
This commit is contained in:
Timo
2026-08-18 00:47:29 +02:00
parent aa5173e0d4
commit 096775d39f
8 changed files with 222 additions and 27 deletions
+67
View File
@@ -607,6 +607,73 @@ test('rejects a hidden cross-origin player after its iframe URL redirects', asyn
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
@@ -0,0 +1,27 @@
<!doctype html>
<meta charset="utf-8">
<title>Deferred player frame</title>
<style>
body { margin: 0; background: #111; }
#poster { width: 854px; height: 480px; color: #fff; font: 16px sans-serif;
display: flex; align-items: center; justify-content: center; cursor: pointer; }
</style>
<!--
The shape anime hosts actually ship: a poster with a play overlay, and no
<video> in the document at all until the viewer starts playback. Selecting the
tab before that point must still work, and the real player has to be picked up
the moment it appears.
-->
<div id="poster">Play</div>
<script>
document.getElementById('poster').addEventListener('click', () => {
const video = document.createElement('video');
video.id = 'deferred-player';
video.width = 854;
video.height = 480;
video.controls = true;
video.src = '../../media/player-480p-12s.mp4';
document.body.append(video);
document.getElementById('poster').remove();
}, { once: true });
</script>
@@ -0,0 +1,19 @@
<!doctype html>
<meta charset="utf-8">
<title>xfp wrapper</title>
<style>
html, body { margin: 0; height: 100%; }
iframe { border: 0; display: block; }
</style>
<!--
Same-origin shell the anime site puts between the page and the real player.
It holds no video itself; the playable element lives one more level down in a
cross-origin frame.
-->
<iframe id="inner" width="830" height="498" allowfullscreen></iframe>
<script>
const inner = document.getElementById('inner');
const params = new URLSearchParams(location.search);
const target = params.get('player') || 'player-frame.html';
inner.src = `http://127.0.0.1:${location.port}/pages/frames/${target}`;
</script>
@@ -0,0 +1,29 @@
<!doctype html>
<meta charset="utf-8">
<title>Anime-style player built on demand</title>
<style>
body { margin: 0; font: 14px sans-serif; }
#info { height: 900px; padding: 24px; }
iframe { border: 0; }
#visible-wrapper { display: block; }
#hidden-wrapper { width: 0; height: 0; display: block; }
</style>
<!--
Same layout as yummy-style-player.html, except the visible player builds its
<video> only when the viewer presses play. Until then the tab has no video at
all, which is the state the extension is in when the user picks the tab.
-->
<div id="info">
<h1>Series page</h1>
<p>Description block that pushes the player below the fold.</p>
</div>
<iframe id="visible-wrapper" name="xfplayer_visible" width="830" height="498"
src="frames/xfp-wrapper.html?player=deferred-player-frame.html" allowfullscreen></iframe>
<iframe id="hidden-wrapper" name="xfplayer_hidden"
src="frames/xfp-wrapper.html?player=player-frame-2.html" allowfullscreen></iframe>
<script>
const frames = [...document.querySelectorAll('iframe')];
Promise.all(frames.map(f => new Promise(resolve => {
f.addEventListener('load', resolve, { once: true });
}))).then(() => { window.__fixtureReady = true; });
</script>
@@ -0,0 +1,36 @@
<!doctype html>
<meta charset="utf-8">
<title>Anime-style nested player</title>
<style>
body { margin: 0; font: 14px sans-serif; }
#info { height: 900px; padding: 24px; }
iframe { border: 0; }
#visible-wrapper { display: block; }
#hidden-wrapper, #hidden-trailer { width: 0; height: 0; display: block; }
</style>
<!--
Mirrors the live yummyanime.tv layout:
top (no video)
├── visible same-origin wrapper 830x498 -> cross-origin player (the real one)
├── hidden same-origin wrapper 0x0 -> cross-origin mirror
└── hidden cross-origin trailer 0x0
The player sits two levels down and below the fold, and two other players are
loaded at zero size. Picking the visible one is the whole job.
-->
<div id="info">
<h1>Series page</h1>
<p>Description block that pushes the player below the fold.</p>
</div>
<iframe id="visible-wrapper" name="xfplayer_visible" width="830" height="498"
src="frames/xfp-wrapper.html?player=player-frame.html" allowfullscreen></iframe>
<iframe id="hidden-wrapper" name="xfplayer_hidden"
src="frames/xfp-wrapper.html?player=player-frame-2.html" allowfullscreen></iframe>
<iframe id="hidden-trailer" allowfullscreen></iframe>
<script>
const trailer = document.getElementById('hidden-trailer');
trailer.src = `http://127.0.0.1:${location.port}/pages/frames/late-player-frame.html`;
const frames = [...document.querySelectorAll('iframe')];
Promise.all(frames.map(f => new Promise(resolve => {
f.addEventListener('load', resolve, { once: true });
}))).then(() => { window.__fixtureReady = true; });
</script>