mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-19 15:46:15 +00:00
75a9ba5d3d
Google Drive and YummyAnime host their player in a cross-origin iframe. The 3.1.2 targeting work reached those frames but misdiagnosed and destabilized them in four separate ways. No manifest permission is added or restored; webNavigation stays removed. Access diagnosis was inferred, not measured. Every frame probe error was swallowed, and any origin that failed to answer was reported as missing host access. A slow or still-loading player frame therefore produced "Host access required for youtube.googleapis.com" for an origin the extension already held. The resolver now asks permissions.contains() before raising an access error, and treats a granted-but-unresponsive origin as a retry, not a user decision. Probes were unbounded. Every executeScript in the resolver now runs under a timeout, so one unreachable frame can no longer stall an activation, and the retry budget drops from eight passes to three. The chat overlay followed the player into its frame, which rendered it on top of the video and scoped closing and minimizing to that frame. It is now always installed in the tab's top document, with all chat traffic routed to frame 0, while only the playback controller goes into the selected media frame. Nested targets reactivated continuously. Every heartbeat and content event revalidated the target with a full teardown and reinjection, and the media monitor treated ordinary play, pause and buffering as frame layout changes. Both paths now reactivate only when the selected frame or document actually moves. Also restores the audio-route retention that keeps a deselected tab audible: createMediaElementSource() can only be called once per element, so a reinjected content script must adopt the existing route rather than rebuild it. Verified with 90 unit tests, 40 browser E2E tests including two new Drive-shaped fixtures that assert the controller lands in the player frame while the chat stays in the top document, and npm run verify. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
157 lines
10 KiB
JavaScript
157 lines
10 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).toContain('contentTarget = await resolveMediaContentTarget(chrome, tabId)');
|
|
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 = false');
|
|
expect(backgroundSource.match(/onlyIfTargetMoved: true/g)?.length).toBe(4);
|
|
// 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 = 3');
|
|
// 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\(\)/);
|
|
});
|
|
});
|