fix(extension): find the player when the all-frames sweep comes back empty

Diagnosed against a fixture rebuilt from the live yummyanime.tv page, with the
ad churn the real site produces. Under that churn the resolver reported
frame=0, hasVideo=false while the video demonstrably existed two frame levels
down: one ad slot tearing down mid-call makes Chromium reject the whole
allFrames sweep, and the resolver then silently fell back to the top frame and
never looked again.

v3.1.2 did not have this failure because webNavigation.getAllFrames() gave it
an explicit frame list. That list is now rebuilt without the permission: every
content script that messages the background carries sender.frameId, so the
background keeps a per-tab registry of frames it has seen and the resolver asks
any frame the sweep missed directly. One rejected probe now costs one frame
instead of the whole page.

Two supporting fixes fell out of the same investigation. Frames reported hidden
by an ancestor that could inspect them directly — the 0x0 same-origin wrapper an
anime host parks unwatched mirrors in — are now excluded without waiting for the
postMessage visibility handshake, which was the tie the resolver kept failing to
break. And leaf frames with no video and no nested frames are left out of that
handshake entirely, so a churning ad slot can no longer make every phase wait on
a frame that is already gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Timo
2026-08-18 15:56:17 +02:00
parent f450584562
commit 68f2d9f27c
5 changed files with 197 additions and 13 deletions
+35
View File
@@ -706,6 +706,41 @@ test('polling video state on a page with no video does not restart the target',
.toBe('true');
});
test('stays ready on a page whose ad frames keep mutating', async ({ context, extensionId, baseURL }) => {
test.setTimeout(90000);
// Live ad churn wakes the media-frame monitor several times a second. Each
// wake used to schedule a trailing refresh that rebuilt the target
// unconditionally, and rebuilding produced more churn — a loop that never
// let the activation settle and pinned the popup on "activating".
const url = `${baseURL}/pages/yummy-churning-player.html`;
const page = await context.newPage();
await page.goto(url);
await page.waitForFunction(() => window.__fixtureReady === true);
const { tabId } = await selectTargetTab(context, extensionId, url);
for (let sample = 0; sample < 8; sample++) {
await page.waitForTimeout(700);
const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
expect(status, `sample ${sample} must not be stuck activating`).toMatchObject({
targetTabId: tabId,
targetReady: true,
targetActivationState: 'ready'
});
}
// The player still has to be picked up while the churn continues.
const deferred = page.frames().find(frame => frame.url().endsWith('/frames/deferred-player-frame.html'));
await deferred.locator('#poster').click();
await expect
.poll(() => deferred.locator('video').getAttribute('data-koala-attached'), { timeout: 20000 })
.toBe('true');
await expect
.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' })
.then(state => state.targetActivationState), { timeout: 20000 })
.toBe('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,43 @@
<!doctype html>
<meta charset="utf-8">
<title>Anime page with live ad churn</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; }
#ads iframe { width: 300px; height: 60px; display: block; }
</style>
<!--
The anime layout plus what the real site actually does while you look at it:
ad slots that add, remove and resize frames continuously. Every one of those
mutations wakes the media-frame monitor, so this is the fixture that exposes a
reactivation loop — a static page never will.
-->
<div id="info">
<h1>Series page</h1>
<div id="ads"></div>
</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 ads = document.getElementById('ads');
let tick = 0;
setInterval(() => {
tick++;
const stale = ads.firstElementChild;
if (stale && tick % 2 === 0) stale.remove();
const slot = document.createElement('iframe');
slot.src = `about:blank#ad-${tick}`;
slot.style.height = `${40 + (tick % 5) * 8}px`;
ads.append(slot);
}, 150);
const wrappers = [...document.querySelectorAll('#visible-wrapper, #hidden-wrapper')];
Promise.all(wrappers.map(f => new Promise(resolve => {
f.addEventListener('load', resolve, { once: true });
}))).then(() => { window.__fixtureReady = true; });
</script>