fix(extension): release a dead frame election and break the discovery deadlock

Reported as "Receiving end does not exist" with targetReady true and no
activation errors: the election named a frame the player had already torn down.
Three defects were stacked, each hidden by the one in front of it.

The election was never released. getReadyTabVideoState() recovered through the
guarded refresh, which reports "unchanged" when no video is reachable, so the
stale frameId/documentId survived; adoption compounded it by setting hasVideo,
after which the target only moves on a frame change. An unreachable content
script — as opposed to a page that simply has no video yet — now releases the
frame election back to the top frame. The tab selection is never touched.

Switching frames destroyed the top frame's scripts. Promoting the target out of
frame 0 called deactivateTargetTab() on the previous target, which sent
TARGET_DEACTIVATE to frame 0 and tore down its content script and the chat
overlay with it. That is why chat delivery failed after promotion, and why
releasing the election pointed at an empty frame. An in-tab frame switch now
leaves the top frame alone.

Discovery could deadlock. Monitors announce new players, but a rebuilt frame is
a new document with no monitor, so the video created in it was never reported —
and nothing then triggered the upkeep that would have installed one. Monitors
are reinstalled on every lifecycle notification with a trailing-edge debounce,
and a bounded discovery poll breaks the cycle when no notification arrives at
all: 2s, capped, only while a tab is selected with no video found, stopping the
moment one is.

The new browser test adopts a nested player, destroys its document the way the
real player does, and asserts both the release and that the rebuilt player is
picked up again without touching the popup. It was flaky until the deadlock was
closed, and now passes repeatedly at a stable 8.3s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Timo
2026-08-18 20:43:58 +02:00
parent 488a9a086f
commit 226453fd45
3 changed files with 248 additions and 47 deletions
+62
View File
@@ -773,6 +773,68 @@ test('controls and adopts a nested player even while the top frame is elected',
expect(status).toMatchObject({ targetTabId: tabId, targetHasVideo: true });
});
test('recovers when the adopted player frame is torn down and rebuilt', async ({ context, extensionId, baseURL }) => {
test.setTimeout(90000);
// Kodik rebuilds its player frame on quality and part changes, which kills
// the documentId the election is pinned to. The election has to be given up,
// otherwise every later message fails with "Receiving end does not exist"
// and nothing moves the target back.
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 } = await selectTargetTab(context, extensionId, url);
// After the rebuild both the detached and the live frame carry the same URL,
// so take the most recent attached one or the test drives a dead document.
const deferredFrame = () => page.frames()
.filter(frame => !frame.isDetached()
&& frame.url().endsWith('/frames/deferred-player-frame.html'))
.pop();
await deferredFrame().locator('#poster').click();
await expect
.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' })
.then(state => state.targetHasVideo), { timeout: 20000 })
.toBe(true);
const adoptedFrameId = (await getExtensionState(context, extensionId, { type: 'GET_STATUS' })).targetFrameId;
expect(adoptedFrameId).not.toBe(0);
// Destroy the elected document the way the real player does.
const wrapper = page.frames().find(frame => frame.url().includes('xfp-wrapper.html?player=deferred'));
await wrapper.evaluate(() => {
const inner = document.getElementById('inner');
inner.src = inner.src;
});
await expect.poll(() => deferredFrame()?.locator('#poster').count().catch(() => 0)).toBe(1);
// The dead election must be released rather than kept forever.
await expect
.poll(() => getExtensionState(context, extensionId, { type: 'GET_VIDEO_STATE', tabId })
.then(state => state?.error || null), { timeout: 20000 })
.toBeNull();
const released = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
expect(released).toMatchObject({ targetTabId: tabId, targetHasVideo: false });
// And the rebuilt player is picked up again without touching the popup. The
// rebuilt document wires its poster from an inline script, so a click can
// land before the handler exists — retry until the player is really built.
await expect.poll(async () => {
const frame = deferredFrame();
if (!frame) return 0;
if (await frame.locator('video').count() > 0) return 1;
await frame.locator('#poster').click({ timeout: 2000 }).catch(() => {});
return 0;
}, { timeout: 20000 }).toBe(1);
await expect
.poll(() => deferredFrame().locator('video').getAttribute('data-koala-attached'), { timeout: 20000 })
.toBe('true');
await expect
.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' })
.then(state => state.targetHasVideo), { timeout: 20000 })
.toBe(true);
});
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