diff --git a/extension/background.js b/extension/background.js index dcb2a68..75b5ccc 100644 --- a/extension/background.js +++ b/extension/background.js @@ -2059,17 +2059,26 @@ async function getReadyTabVideoState(tabId, expectedGeneration = targetActivatio return { error: 'Target tab changed before video state could be read' }; } let state = await getTabVideoState(tabId); - if (!state || state.error || state.found === false) { - const activation = await refreshCurrentMediaTarget(tabId); - if (activation?.status !== 'ok') { + // "No video" is a legitimate answer, not a broken injection: an anime or + // Drive page has no video element until the viewer starts playback. Forcing + // a reactivation for it made every poll of this function tear the content + // script down and reinject it, which kept the target permanently activating. + // Only an unreachable content script justifies recovery. + if (!state || state.error) { + const activation = await refreshCurrentMediaTarget(tabId, { onlyIfTargetMoved: true }); + if (activation?.status !== 'ok' && activation?.status !== 'unchanged') { return { error: 'Target tab changed before content script recovery completed' }; } + // An unchanged target reports no generation of its own. + const generation = Number.isInteger(activation.generation) + ? activation.generation + : targetActivationGeneration; await new Promise(resolve => setTimeout(resolve, 250)); - if (!isCurrentTargetIdentity(tabId, activation.generation)) { + if (!isCurrentTargetIdentity(tabId, generation)) { return { error: 'Target tab changed before video state could be read' }; } state = await getTabVideoState(tabId); - if (!isCurrentTargetIdentity(tabId, activation.generation)) { + if (!isCurrentTargetIdentity(tabId, generation)) { return { error: 'Target tab changed while video state was being read' }; } } diff --git a/extension/media-frame-target.js b/extension/media-frame-target.js index 9062cfd..0bdacde 100644 --- a/extension/media-frame-target.js +++ b/extension/media-frame-target.js @@ -526,11 +526,17 @@ async function originAccessIsWithheld(chromeApi, originPattern) { } export async function resolveMediaContentTarget(chromeApi, tabId, { - attempts = 3, + // v3.1.2's retry budget: a player frame can take several seconds to appear, + // and giving up early is what turns a slow page into "no video found". + attempts = 8, retryDelayMs = 200, probeDelayMs = 60, - probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS + probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS, + // ...but the budget is now wall-clock bounded, so a page whose frames all + // time out cannot hold the activation open for minutes. + deadlineMs = 12000 } = {}) { + const startedAt = Date.now(); let fallback = null; let missingAccess = null; let ambiguous = false; @@ -636,6 +642,9 @@ export async function resolveMediaContentTarget(chromeApi, tabId, { } } + if (Number.isFinite(deadlineMs) && deadlineMs > 0 && Date.now() - startedAt >= deadlineMs) { + break; + } if (attempt < attempts - 1) { await new Promise(resolve => setTimeout(resolve, retryDelayMs)); } diff --git a/extension/target-tab-lifecycle.test.mjs b/extension/target-tab-lifecycle.test.mjs index 2468296..546733b 100644 --- a/extension/target-tab-lifecycle.test.mjs +++ b/extension/target-tab-lifecycle.test.mjs @@ -46,7 +46,7 @@ describe('target tab lifecycle', () => { it('does not reactivate the target for ordinary playback churn', () => { expect(backgroundSource).toContain('async function selectedMediaTargetMoved(tabId)'); expect(backgroundSource).toContain('onlyIfTargetMoved = false'); - expect(backgroundSource.match(/onlyIfTargetMoved: true/g)?.length).toBe(4); + expect(backgroundSource.match(/onlyIfTargetMoved: true/g)?.length).toBe(5); // Playback state must stay out of the candidate signature, otherwise // every play/pause looks like a frame layout change. expect(monitorSource).not.toContain('element.paused ? 0 : 1'); @@ -60,7 +60,8 @@ describe('target tab lifecycle', () => { ); expect(resolverSource).toContain('async function originAccessIsWithheld(chromeApi, originPattern)'); expect(resolverSource).toContain('probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS'); - expect(resolverSource).toContain('attempts = 3'); + expect(resolverSource).toContain('attempts = 8'); + expect(resolverSource).toContain('deadlineMs = 12000'); // A swallowed probe error is what turned a slow player frame into a // permission prompt for an origin the extension already held. expect(resolverSource).toContain('errors.push({ target, error })'); diff --git a/tests/e2e/extension.spec.mjs b/tests/e2e/extension.spec.mjs index 4406957..d2ba6d9 100644 --- a/tests/e2e/extension.spec.mjs +++ b/tests/e2e/extension.spec.mjs @@ -674,6 +674,38 @@ test('selects an anime tab before playback and promotes the player once it appea expect(promoted).toMatchObject({ targetTabId: tabId, targetActivationState: 'ready' }); }); +test('polling video state on a page with no video does not restart the target', async ({ context, extensionId, baseURL }) => { + // The dev panel polls GET_VIDEO_STATE on a timer. On an anime page the + // answer is legitimately "no video" until playback starts, and treating + // that as a broken injection reactivated the target on every poll — which + // is what pinned the popup on "activating" forever. + 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); + + for (let poll = 0; poll < 6; poll++) { + const state = await getExtensionState(context, extensionId, { type: 'GET_VIDEO_STATE', tabId }); + expect(state?.error, 'reading video state must not report a target change').toBeFalsy(); + expect(state).toMatchObject({ found: false }); + const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' }); + expect(status, `poll ${poll} must leave the target ready`).toMatchObject({ + targetTabId: tabId, + targetReady: true, + targetActivationState: 'ready' + }); + } + + // And the player is still picked up once it exists. + 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: 15000 }) + .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