mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-19 15:46:15 +00:00
68f2d9f27c
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>
164 lines
11 KiB
JavaScript
164 lines
11 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
|
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
|
|
const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'utf8');
|
|
const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8');
|
|
const monitorSource = fs.readFileSync(path.join(extensionDir, 'media-frame-monitor.js'), 'utf8');
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(extensionDir, 'manifest.base.json'), 'utf8'));
|
|
|
|
describe('target tab lifecycle', () => {
|
|
it('injects playback and chat scripts only into the explicitly selected tab', () => {
|
|
expect(backgroundSource).not.toContain('chrome.tabs.onActivated');
|
|
expect(backgroundSource).not.toContain('chrome.tabs.query({})');
|
|
expect(backgroundSource).toMatch(/contentTarget = await resolveMediaContentTarget\(chrome, tabId, \{\s*knownFrameIds/);
|
|
// Frame ids must come from observed senders, never from a navigation permission.
|
|
expect(backgroundSource).toContain('function rememberFrameId(tabId, frameId)');
|
|
expect(backgroundSource).toContain('rememberFrameId(senderTabId, sender?.frameId)');
|
|
expect(backgroundSource).toContain('target: scriptTarget');
|
|
expect(backgroundSource).toContain("files: ['chat-format.js', 'chat-overlay.js', 'content.js']");
|
|
expect(backgroundSource).toContain("chrome.tabs.query({ url: 'https://sync.koalastuff.net/*' })");
|
|
|
|
const activationStart = backgroundSource.indexOf('async function activateTargetTab');
|
|
const activationEnd = backgroundSource.indexOf('async function reactivateCurrentTarget', activationStart);
|
|
const activationSource = backgroundSource.slice(activationStart, activationEnd);
|
|
expect(activationSource.indexOf('await deactivateTargetTab(previousTabId)'))
|
|
.toBeLessThan(activationSource.indexOf('await injectContentScript(selectedTabId'));
|
|
expect(activationSource).toContain('previousTabId !== selectedTabId');
|
|
expect(contentSource).toContain('if (window.koalaSyncInjected && chrome.runtime.id)');
|
|
expect(overlaySource).toContain('if (window.koalaSyncChatOverlay?.refresh)');
|
|
});
|
|
|
|
it('keeps the chat overlay in the top document when the player is nested', () => {
|
|
expect(backgroundSource).toContain('function sendMessageToChatOverlay(message)');
|
|
expect(backgroundSource).toContain('return sendMessageToFrame(tabId, 0, message)');
|
|
// Every chat-facing message must reach the overlay's frame, not the
|
|
// player's. A stray sendMessageToCurrentContent here renders the chat
|
|
// inside the video on Drive.
|
|
expect(backgroundSource).not.toMatch(/sendMessageToCurrentContent\(\{\s*type: 'CHAT/);
|
|
expect(backgroundSource).toMatch(
|
|
/target: \{ tabId, frameIds: \[0\] \},\s*files: \['chat-format\.js', 'chat-overlay\.js'\]/
|
|
);
|
|
expect(backgroundSource).toContain("files: ['content.js']");
|
|
expect(backgroundSource).toContain('if (normalizeFrameId(target.frameId) !== 0)');
|
|
});
|
|
|
|
it('does not reactivate the target for ordinary playback churn', () => {
|
|
expect(backgroundSource).toContain('async function selectedMediaTargetMoved(tabId)');
|
|
expect(backgroundSource).toContain('onlyIfTargetMoved = true');
|
|
// Forcing a rebuild must stay rare and deliberate: an unreachable content
|
|
// script, an explicit request, and a completed navigation. Everything else
|
|
// takes the guarded path by default.
|
|
expect(backgroundSource.match(/onlyIfTargetMoved: false/g)?.length).toBe(3);
|
|
// 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');
|
|
expect(monitorSource).toContain('element.readyState > 0 ? 1 : 0');
|
|
});
|
|
|
|
it('bounds every frame probe and verifies withheld origins', () => {
|
|
const resolverSource = fs.readFileSync(
|
|
path.join(extensionDir, 'media-frame-target.js'),
|
|
'utf8'
|
|
);
|
|
expect(resolverSource).toContain('async function originAccessIsWithheld(chromeApi, originPattern)');
|
|
expect(resolverSource).toContain('probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS');
|
|
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 })');
|
|
});
|
|
|
|
it('fully deactivates old and superseded target injections', () => {
|
|
expect(backgroundSource).toContain("{ type: 'TARGET_DEACTIVATE' }");
|
|
expect(backgroundSource).toContain('target.documentId');
|
|
expect(backgroundSource.match(/await deactivateTargetTab\(selectedTabId,/g)?.length).toBeGreaterThanOrEqual(6);
|
|
expect(contentSource).toContain("if (message.type === 'TARGET_DEACTIVATE')");
|
|
expect(overlaySource).toContain("message?.type === 'TARGET_DEACTIVATE'");
|
|
});
|
|
|
|
it('removes monitors injected by a superseded cross-tab activation', () => {
|
|
expect(backgroundSource).toContain('function isTargetActivationSuperseded(tabId, activationGeneration)');
|
|
expect(backgroundSource).toMatch(/navigationRetries: navigationRetries - 1,\s*activationGeneration\s*\}\)/);
|
|
expect(backgroundSource).toMatch(/await injectMediaFrameMonitors\(tabId, contentTarget\);[\s\S]*if \(isTargetActivationSuperseded\(tabId, activationGeneration\)\)[\s\S]*await deactivateMediaFrameMonitors\(tabId\);/);
|
|
expect(backgroundSource).toContain("error.code = 'target_activation_superseded'");
|
|
});
|
|
|
|
it('uses all-frame probing for cross-origin targets without navigation permissions', () => {
|
|
expect(backgroundSource).toContain("files: ['media-frame-monitor.js']");
|
|
expect(backgroundSource).toContain('const targets = listMediaFrameScriptTargets(tabId)');
|
|
expect(backgroundSource).toContain('One denied widget frame must not block the selected player');
|
|
expect(backgroundSource).toContain("navigationError.code = 'media_target_navigated'");
|
|
expect(backgroundSource).toContain("{ type: 'MEDIA_MONITOR_DEACTIVATE' }");
|
|
expect(backgroundSource).toContain('async function deactivateMediaFrameMonitors(tabId)');
|
|
expect(backgroundSource).toContain('{ documentId }');
|
|
expect(monitorSource).toContain("type: 'MEDIA_FRAME_CANDIDATE_CHANGED'");
|
|
expect(monitorSource).toContain("attributeFilter: ['class', 'style', 'hidden', 'src', 'controls']");
|
|
expect(monitorSource).toContain('if (!force && nextSignature === lastCandidateSignature) return');
|
|
expect(monitorSource).toContain("const MEDIA_STATE_EVENTS = ['play', 'pause', 'loadedmetadata'");
|
|
expect(monitorSource).toContain("node.querySelector?.('video, iframe, frame')");
|
|
expect(manifest.permissions).toEqual([
|
|
'storage',
|
|
'tabs',
|
|
'scripting',
|
|
'alarms',
|
|
'activeTab',
|
|
'notifications'
|
|
]);
|
|
expect(backgroundSource).not.toMatch(/chrome\.(?:web)?Navigation/);
|
|
});
|
|
|
|
it('serializes content commands and coalesces target refreshes', () => {
|
|
expect(backgroundSource).toContain('contentCommandQueue.catch(() => {}).then(deliver)');
|
|
expect(backgroundSource).toContain('if (mediaTargetRefreshTask && mediaTargetRefreshTabId === selectedTabId)');
|
|
expect(backgroundSource).toContain('if (queueIfRunning) mediaTargetRefreshDirty = true');
|
|
expect(backgroundSource).toContain('&& pass < 2');
|
|
expect(backgroundSource).toContain('const needsFollowup = mediaTargetRefreshDirty');
|
|
expect(backgroundSource).not.toContain('Re-elect before every remote command');
|
|
expect(backgroundSource).toContain('await sendMessageToContentTab(tabId');
|
|
});
|
|
|
|
it('tears down every persistent content-script resource', () => {
|
|
expect(contentSource).toContain('function destroyContentScript({ preserveAudioRoute = false } = {})');
|
|
// Deselecting a tab hands the page back to itself; it must not go mute.
|
|
expect(contentSource).toContain('destroyContentScript({ preserveAudioRoute: true });');
|
|
expect(contentSource).toContain('if (!preserveAudioRoute) closeAudioContext();');
|
|
expect(contentSource).toContain('observer.disconnect()');
|
|
expect(contentSource).toContain('keepAlivePort.disconnect()');
|
|
expect(contentSource).toContain('for (const video of [...attachedVideos]) detachVideoListeners(video);');
|
|
expect(contentSource).toContain("document.removeEventListener('visibilitychange', handleVisibilityChange)");
|
|
expect(contentSource).toContain("window.removeEventListener('pagehide', handlePageHide)");
|
|
expect(contentSource).toContain("window.removeEventListener('pageshow', handlePageShow)");
|
|
expect(contentSource).toContain("window.removeEventListener('resize', handleMediaFrameResize)");
|
|
expect(contentSource).toContain('chrome.storage.onChanged.removeListener(handleStorageChanged)');
|
|
expect(contentSource).toContain('chrome.runtime.onMessage.removeListener(handleRuntimeMessage)');
|
|
expect(contentSource).toContain('window.koalaSyncInjected = false');
|
|
});
|
|
|
|
it('stops the MAIN-world seek bridge when its target is deactivated', () => {
|
|
expect(backgroundSource).toContain('const timelineInterval = setInterval');
|
|
expect(backgroundSource).toContain('clearInterval(timelineInterval)');
|
|
expect(backgroundSource).toContain("window.removeEventListener('message', handleBridgeMessage)");
|
|
expect(contentSource).toContain("kind: 'destroy'");
|
|
});
|
|
|
|
it('self-cleans instead of throwing when an extension reload invalidates the context', () => {
|
|
expect(contentSource).toContain('if (!chrome.runtime?.id)');
|
|
expect(contentSource).toContain('destroyContentScript()');
|
|
expect(contentSource).toContain('return chrome.runtime.sendMessage(message, callback) || Promise.resolve(undefined)');
|
|
expect(contentSource).toContain('function handleVisibilityChange()');
|
|
});
|
|
|
|
it('suspends background work for bfcache and restores it without duplicate injection', () => {
|
|
expect(contentSource).toContain('pageSuspended = true');
|
|
expect(contentSource).toContain('if (destroyed || pageSuspended) return');
|
|
expect(contentSource).toContain('if (!destroyed && !pageSuspended) scheduleLifecycleTimeout(connectKeepAlivePort, 1000)');
|
|
expect(contentSource).toMatch(/function handlePageShow\(event\)[\s\S]*if \(!event\.persisted\) return;[\s\S]*pageSuspended = false/);
|
|
expect(contentSource).toMatch(/function handlePageShow\(event\)[\s\S]*observer\.observe\(document\.documentElement[\s\S]*setupListeners\(\)[\s\S]*connectKeepAlivePort\(\)/);
|
|
});
|
|
});
|