fix(extension): control the player frame without having to elect it first

Reported from the live site: a media title was recognised and audio processing
worked, but play and pause did nothing. Both halves of the command path were
gated on frame election, and the election had named the top frame.

Outbound, commands went to the elected frame alone, which holds no video, so
they were delivered and ignored. Inbound, isCurrentContentSender() required
sender.frameId to equal the elected frame, so the user's own play and pause
arriving from the real player frame were discarded as a stale sender — which is
why the room never saw them.

Neither direction actually needs the election. Every content-script command
handler already begins with findVideo() and returns when there is none, so a
tab-wide broadcast is delivered to all frames and acted on only by the one that
owns the player. And an inbound media event proves where the player is:
sender.frameId is authoritative, costs no permission and has no timing window,
so the reporting frame is adopted as the target and later commands are addressed
directly again.

Both relaxations apply only while the elected frame reports no video. A good
election still takes the strict path, so the hidden-player rejections are
unaffected.

This is the general answer to losing webNavigation.getAllFrames(). That call
observed the frame tree without touching it, so it never had a failure window;
executeScript has to enter every frame and reliably loses that race against a
player which renavigates and rebuilds its video, as Kodik does. The fix is to
stop depending on the answer rather than to keep chasing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Timo
2026-08-18 19:47:12 +02:00
parent 0bb203320e
commit b54eefdb5a
2 changed files with 108 additions and 9 deletions
+32
View File
@@ -741,6 +741,38 @@ test('stays ready on a page whose ad frames keep mutating', async ({ context, ex
.toBe('ready');
});
test('controls and adopts a nested player even while the top frame is elected', async ({ context, extensionId, baseURL }) => {
// The failure mode reported from the live site: the election names the top
// frame, which holds no video, so commands go nowhere and the user's own
// play/pause from the real player frame is discarded as a stale sender.
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);
expect(response).toMatchObject({ status: 'ok', frameId: 0, hasVideo: false });
// Build the player without giving the monitor a chance to promote first.
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').count()).toBe(1);
// A command must reach the frame that owns the video regardless of election.
await sendServerCommand(context, extensionId, tabId, 'play', { time: 1 });
await expect
.poll(() => deferred.locator('video').evaluate(video => video.paused), { timeout: 15000 })
.toBe(false);
// And once that frame reports playback, it becomes the addressed target.
await expect
.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' })
.then(state => state.targetFrameId), { timeout: 15000 })
.not.toBe(0);
const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
expect(status).toMatchObject({ targetTabId: tabId, targetHasVideo: 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