merge main and harden canonical media recovery

This commit is contained in:
Timo
2026-08-25 16:58:15 +02:00
81 changed files with 3422 additions and 1133 deletions
+1 -2
View File
@@ -67,8 +67,7 @@ Useful focused checks from the repository root:
node -c extension/background.js
node -c extension/content.js
node -c extension/popup.js
node scripts/test-episode-utils.mjs
node scripts/test-title-privacy.mjs
npx vitest run extension/episode-utils.test.mjs extension/title-privacy.test.mjs
node scripts/test-audio-settings.mjs
node scripts/test-locales.cjs
```
+95 -71
View File
@@ -1,4 +1,4 @@
import { EVENTS, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js';
import { EVENTS, ERROR_CODES, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js';
import { generateUsername } from './shared/names.js';
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
import { sameEpisode, extractEpisodeId } from './episode-utils.js';
@@ -1052,15 +1052,19 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null)
return true;
}
async function leaveRoomAfterIdleGrace(reason) {
if (!currentRoom) return;
async function endRoomSession({ notifyServer = false, reason = 'Left Room' } = {}) {
webJoinCoordinator.invalidate();
connectIntent = false;
reconnectFailed = false;
reconnectAttempts = 0;
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
reconnectStartTime = null;
completeForceSyncBeforeTargetChange(null);
emit(EVENTS.LEAVE_ROOM, { peerId });
forceDisconnect();
if (notifyServer) emit(EVENTS.LEAVE_ROOM, { peerId });
// Stop room-specific polling before the content script itself is removed.
// Every terminal room exit must pass through the exact target identity while
// it is still available, regardless of who initiated the exit.
clearEpisodeLobbyState();
currentRoom = null;
clearCanonicalMediaRecovery();
clearChatActivity();
@@ -1072,15 +1076,22 @@ async function leaveRoomAfterIdleGrace(reason) {
// Notify content.js/popup BEFORE currentTabId is cleared so they can reset
// any stale guest-side HCM state (dialog/badge/desync) — H-2.
broadcastControlMode();
if (currentTabId) await deactivateTargetTab(currentTabId);
if (currentTabId) await deactivateTargetTab(currentTabId, currentContentTarget());
invalidateTargetActivations();
currentTabId = null;
currentTabTitle = null;
clearCurrentContentTarget();
roomIdleSince = null;
lastContentHeartbeatAt = null;
clearEpisodeLobbyState();
await clearPendingTarget();
isForceSyncInitiator = false;
forceSyncAcks.clear();
expectedAcksCount = 0;
if (forceSyncTimeout) {
clearTimeout(forceSyncTimeout);
forceSyncTimeout = null;
}
await chrome.storage.session.set({
currentRoom: null,
chatActivityTimeline: [],
@@ -1091,22 +1102,36 @@ async function leaveRoomAfterIdleGrace(reason) {
currentTargetHasVideo: false,
roomIdleSince: null,
lastContentHeartbeatAt: null,
isForceSyncInitiator: false,
forceSyncAcks: [],
forceSyncDeadline: null,
expectedAcksCount: 0,
episodeLobby: null,
hcmDesynced: false
hcmDesynced: false,
reconnectFailed: false,
reconnectAttempts: 0,
reconnectStartTime: null
}).catch(() => {});
chatSecretGuard = '';
invalidateChatSession();
await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
addLog(reason, 'info');
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
forceDisconnect();
addLog(reason, 'info');
updateBadgeStatus();
}
async function leaveRoomAfterIdleGrace(reason) {
if (!currentRoom) return;
await endRoomSession({ notifyServer: true, reason });
}
async function connect() {
if (isConnecting) return;
isConnecting = true;
const startingGeneration = connectionGeneration;
let attemptGeneration = startingGeneration;
let handedOffToSocket = false;
let finalUrl = '';
try {
@@ -1234,7 +1259,7 @@ async function connect() {
} else {
awaitingRoomData = false;
pendingRoomDataRoomId = null;
flushEventQueue();
flushEventQueue().catch(error => addLog(`Queue replay failed: ${error.message}`, 'warn'));
}
} else if (msg.startsWith('42')) {
try {
@@ -1295,6 +1320,7 @@ async function connect() {
const logType = reconnectAttempts > 1 ? 'error' : 'warn';
addLog('WebSocket Error: Connection failed', logType);
};
handedOffToSocket = true;
} catch (e) {
throw new Error(`[Connection Error] ${e.message}`);
}
@@ -1309,6 +1335,8 @@ async function connect() {
if (currentRoom || connectIntent) {
scheduleReconnect();
}
} finally {
if (!handedOffToSocket) isConnecting = false;
}
}
@@ -2092,6 +2120,13 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
}
case EVENTS.ERROR:
isConnecting = false;
const terminalRoomError = data.code === ERROR_CODES.ROOM_CLOSED
|| data.code === ERROR_CODES.PEER_TIMED_OUT
|| data.message === 'Room closed'
|| data.message === 'Removed from room after inactivity';
if (currentRoom && terminalRoomError) {
await endRoomSession({ reason: `Room session ended: ${data.message}` });
}
// If we get a server error before successfully joining a room,
// clear persisted credentials as well, otherwise service-worker
// restart would immediately retry the rejected room.
@@ -2999,10 +3034,46 @@ function executeScriptWithTimeout(options, timeoutMs = SCRIPT_INJECTION_TIMEOUT_
});
}
/**
* Runs as a tiny all-frame beacon before the heavier monitor injection.
* Chromium can reject an allFrames result wholesale when one unrelated ad
* frame disappears mid-sweep, even though stable frames already executed the
* function. Those stable frames announce their ids through the sender metadata,
* letting the next step address them individually without webNavigation.
*/
async function announcePotentialMediaFrame() {
let relevant = false;
try {
const identity = `${window.location.href} ${window.name || ''}`;
relevant = !!document.querySelector('video, iframe, frame')
|| /player|video|stream|watch|embed|media|xfp/i.test(identity);
} catch { /* inaccessible or already-detached document */ }
if (!relevant) return false;
try {
await chrome.runtime.sendMessage({ type: 'MEDIA_FRAME_DISCOVERED' });
} catch { /* extension context or document disappeared */ }
return true;
}
async function injectMediaFrameMonitors(tabId, contentTarget) {
// The sweep is best effort; the known frames are addressed individually so a
// rejected sweep cannot leave the deep player frame without a monitor — and
// therefore without any way to report itself later.
try {
const discoveries = await executeScriptWithTimeout({
target: { tabId, allFrames: true },
func: announcePotentialMediaFrame
}, 750);
for (const entry of discoveries || []) {
if (entry?.result === true) rememberFrameId(tabId, entry.frameId);
}
} catch {
// Stable frames still announce themselves if a disappearing ad frame
// makes Chromium reject the aggregate allFrames result.
}
// Give those sender messages one task boundary to update the registry before
// taking the snapshot used for individual monitor injections below.
await new Promise(resolve => setTimeout(resolve, 0));
const targets = [
...listMediaFrameScriptTargets(tabId),
...listKnownFrameIds(tabId)
@@ -3684,6 +3755,14 @@ async function selectedMediaTargetMoved(tabId) {
}
return false;
}
// A disappearing ad frame can make the parent-visibility handshake
// inconclusive while still leaving one hidden mirror as the only video
// candidate. Never rebuild toward an unconfirmed nested frame: its monitor
// or a later clean probe will announce it again if it is genuinely visible.
if (normalizeFrameId(resolved.frameId) !== 0 && resolved.visibilityConfirmed !== true) {
refreshMediaFrameMonitors(tabId).catch(() => {});
return false;
}
if (currentTargetHasVideo !== true) return true;
return normalizeFrameId(resolved.frameId) !== normalizeFrameId(currentTargetFrameId)
|| (typeof resolved.documentId === 'string'
@@ -4137,6 +4216,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
const senderTabId = normalizeTabId(sender?.tab?.id);
if (senderTabId !== null) rememberFrameId(senderTabId, sender?.frameId);
if (message.type === 'MEDIA_FRAME_DISCOVERED') {
sendResponse({ status: 'ok' });
return;
}
const mediaLifecycleMessage = message.type === 'MEDIA_FRAME_CANDIDATE_CHANGED'
|| message.type === 'MEDIA_FRAME_VISIBILITY'
|| message.type === 'MEDIA_TARGET_REFRESH';
@@ -4470,66 +4553,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}
sendResponse({ status: 'ok' });
} else if (message.type === 'LEAVE_ROOM') {
webJoinCoordinator.invalidate();
completeForceSyncBeforeTargetChange(null);
connectIntent = false;
reconnectFailed = false;
reconnectAttempts = 0;
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
emit(EVENTS.LEAVE_ROOM, { peerId });
currentRoom = null;
clearCanonicalMediaRecovery();
clearChatActivity();
controlMode = CONTROL_MODES.EVERYONE;
hostPeerId = null;
controllers = [];
serverCapabilities = [];
hcmDesynced = false;
// Notify content.js/popup BEFORE currentTabId is cleared so they drop any
// stale guest-side HCM state (dialog/badge/desync) — H-2/H-3.
broadcastControlMode();
if (currentTabId) await deactivateTargetTab(currentTabId, currentContentTarget());
invalidateTargetActivations();
currentTabId = null;
currentTabTitle = null;
clearCurrentContentTarget();
roomIdleSince = null;
lastContentHeartbeatAt = null;
updateBadgeStatus();
isForceSyncInitiator = false;
forceSyncAcks.clear();
expectedAcksCount = 0;
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
// Cancel any active episode lobby
clearEpisodeLobbyState();
await clearPendingTarget();
chrome.storage.session.set({
currentRoom: null,
chatActivityTimeline: [],
currentTabId: null,
currentTabTitle: null,
currentTargetFrameId: 0,
currentTargetDocumentId: null,
currentTargetHasVideo: false,
roomIdleSince: null,
lastContentHeartbeatAt: null,
isForceSyncInitiator: false,
forceSyncAcks: [],
forceSyncDeadline: null,
episodeLobby: null,
expectedAcksCount: 0,
hcmDesynced: false
});
chatSecretGuard = '';
invalidateChatSession();
chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
addLog('Left Room', 'info');
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
forceDisconnect();
await endRoomSession({ notifyServer: true, reason: 'Left Room' });
sendResponse({ status: 'ok' });
} else if (message.type === 'CLEAR_LOGS') {
logs = [];
@@ -8,8 +8,9 @@ const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'
const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'utf8');
function functionBody(source, name, nextName) {
const start = source.indexOf(`function ${name}`);
const end = source.indexOf(`function ${nextName}`, start + 1);
const start = source.indexOf(`function ${name}(`);
const end = source.indexOf(`function ${nextName}(`, start + 1);
expect(start).toBeGreaterThan(-1);
return source.slice(start, end === -1 ? source.length : end);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { CAPABILITIES, MAX_MEDIA_TIME } from './shared/constants.js';
import { CAPABILITIES, MAX_MEDIA_TIME } from '../shared/constants.js';
export function validateCanonicalMediaState(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
+4 -1
View File
@@ -39,12 +39,15 @@ describe('chat crypto', () => {
const secret = generateChatSecret(webcrypto);
let deriveCalls = 0;
let releaseDerive;
let markDeriveStarted;
const deriveStarted = new Promise(resolve => { markDeriveStarted = resolve; });
const delayedCrypto = {
...webcrypto,
subtle: {
importKey: (...args) => webcrypto.subtle.importKey(...args),
deriveKey: async (...args) => {
deriveCalls++;
markDeriveStarted();
await new Promise(resolve => { releaseDerive = resolve; });
return webcrypto.subtle.deriveKey(...args);
}
@@ -52,7 +55,7 @@ describe('chat crypto', () => {
};
const first = deriveChatKey('ROOM-1', secret, delayedCrypto);
const second = deriveChatKey('ROOM-1', secret, delayedCrypto);
await Promise.resolve();
await deriveStarted;
expect(deriveCalls).toBe(1);
clearChatKeyCache();
releaseDerive();
+8 -1
View File
@@ -660,7 +660,14 @@
|| Number(style.opacity) === 0)) {
return false;
}
if (typeof current.checkVisibility === 'function'
// `display: contents` deliberately gives the wrapper no box, so
// Chromium reports the wrapper itself as not visible even while
// its children are fully rendered. Crunchyroll's player layout
// uses exactly that shape. The explicit CSS checks above still
// reject genuinely hidden ancestors; skip only this boxless
// wrapper case when walking up from the video.
if (style?.display !== 'contents'
&& typeof current.checkVisibility === 'function'
&& !current.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) {
return false;
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { extractEpisodeId, sameEpisode } from './episode-utils.js';
describe('episode title matching', () => {
it.each([
['S01E01', 'S01E01'],
['S1E1', 'S01E01'],
['s01e01', 'S01E01'],
['Season 1 Episode 2', 'S01E02'],
['season 01 episode 02', 'S01E02'],
['S01 - E01', 'S01E01'],
['S01.E01', 'S01E01'],
['S01/E01', 'S01E01'],
['S01:E01', 'S01E01'],
['S01,E01', 'S01E01'],
['S01 E01', 'S01E01'],
['Folge 5', 'EP005'],
['Episode 12', 'EP012'],
['Ep. 3', 'EP003'],
['#42', 'EP042'],
['S01E001', 'S01E001']
])('extracts %s as %s', (title, expected) => {
expect(extractEpisodeId(title)).toBe(expected);
});
it.each([null, undefined, '', 123, 'Some Movie Title', 'Breaking Bad'])(
'returns null for non-episode input %j',
input => expect(extractEpisodeId(input)).toBeNull()
);
it.each([
['S01E01', 'S01E01'],
['S01E01 - Pilot', 'S01E01'],
['Folge 5', 'Episode 5'],
['Episode 12', 'Ep. 12'],
['#42', 'Folge 42'],
[null, null],
['', ''],
['Some Movie', 'Some Movie']
])('matches equivalent titles %j and %j', (left, right) => {
expect(sameEpisode(left, right)).toBe(true);
});
it.each([
['S01E01', 'S01E02'],
['S01E01', 'S02E01'],
['Folge 1', 'Folge 2'],
['Some Movie', 'Other Movie'],
['S01E01', null],
[null, 'Episode 5'],
['S01E05', 'Episode 5'],
['S01E01', 'EP001']
])('rejects different titles %j and %j', (left, right) => {
expect(sameEpisode(left, right)).toBe(false);
});
});
+196
View File
@@ -0,0 +1,196 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
HOST_ACCESS_REQUIRED_STATUS,
addTabHostAccessRequest,
describeTabUrl,
inspectTabHostAccess,
isHostAccessError,
normalizeTabId,
removeTabHostAccessRequest,
requestOriginPermission
} from './host-access.js';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
describe('host access helpers', () => {
afterEach(() => vi.useRealTimers());
it('normalizes only positive safe tab IDs', () => {
expect(HOST_ACCESS_REQUIRED_STATUS).toBe('host_permission_required');
for (const invalid of [null, undefined, '', 0, true, [42], '42.5', Number.MAX_SAFE_INTEGER + 1]) {
expect(normalizeTabId(invalid)).toBeNull();
}
expect(normalizeTabId('42')).toBe(42);
expect(normalizeTabId(' 42 ')).toBe(42);
});
it('describes supported origins with Firefox-compatible localhost permissions', () => {
expect(describeTabUrl('https://emby.example:8443/web/index.html')).toEqual({
url: 'https://emby.example:8443/web/index.html',
host: 'emby.example:8443',
originPattern: 'https://emby.example:8443/*'
});
expect(describeTabUrl('http://localhost:8096/web/', { includePort: false })).toEqual({
url: 'http://localhost:8096/web/',
host: 'localhost:8096',
originPattern: 'http://localhost/*'
});
expect(describeTabUrl('chrome://extensions/')).toBeNull();
expect(describeTabUrl('not a url')).toBeNull();
expect(describeTabUrl('file:///Users/koala/movie.mp4')).toEqual({
url: 'file:///Users/koala/movie.mp4',
host: 'local file',
originPattern: 'file:///*'
});
});
it('checks the selected tab origin and preserves an unknown callback result', async () => {
let containsRequest;
const deniedChrome = {
tabs: { get: async tabId => ({ id: tabId, url: 'https://video.example/watch' }) },
permissions: {
contains: async request => {
containsRequest = request;
return false;
}
}
};
await expect(inspectTabHostAccess(deniedChrome, 42)).resolves.toMatchObject({
granted: false,
host: 'video.example',
originPattern: 'https://video.example/*'
});
expect(containsRequest).toEqual({ origins: ['https://video.example/*'] });
const unknownChrome = {
runtime: {},
tabs: { get: async tabId => ({ id: tabId, url: 'https://video.example/watch' }) },
permissions: { contains: (_request, callback) => callback(undefined) }
};
await expect(inspectTabHostAccess(unknownChrome, 42)).resolves.toMatchObject({ granted: null });
});
it('uses Firefox host patterns without ports', async () => {
let containsRequest;
const chromeApi = {
runtime: { getBrowserInfo: async () => ({ name: 'Firefox' }) },
tabs: {
get: async tabId => ({
id: tabId,
url: 'http://localhost:8096/web/',
pendingUrl: 'https://different.example/loading'
})
},
permissions: {
contains: async request => {
containsRequest = request;
return false;
}
}
};
await expect(inspectTabHostAccess(chromeApi, 42)).resolves.toMatchObject({
host: 'localhost:8096',
originPattern: 'http://localhost/*'
});
expect(containsRequest).toEqual({ origins: ['http://localhost/*'] });
});
it('adds, removes, and requests permissions through promise and callback APIs', async () => {
let added;
expect(await addTabHostAccessRequest({
permissions: { addHostAccessRequest: async request => { added = request; } }
}, 42, 'https://video.example/*')).toBe(true);
expect(added).toEqual({ tabId: 42, pattern: 'https://video.example/*' });
expect(await addTabHostAccessRequest({ permissions: {} }, 42)).toBe(false);
let removed;
expect(await removeTabHostAccessRequest({
permissions: { removeHostAccessRequest: async request => { removed = request; } }
}, 42, 'https://video.example/*')).toBe(true);
expect(removed).toEqual({ tabId: 42, pattern: 'https://video.example/*' });
expect(await removeTabHostAccessRequest({ permissions: {} }, 42)).toBe(false);
const callbackChrome = {
runtime: {},
permissions: { request: (_request, callback) => callback(true) }
};
await expect(requestOriginPermission(callbackChrome, 'https://video.example/*')).resolves.toBe(true);
await expect(requestOriginPermission({ permissions: {} }, 'https://video.example/*')).resolves.toBeNull();
await expect(requestOriginPermission(callbackChrome, '')).resolves.toBeNull();
await expect(requestOriginPermission({
permissions: { request: async () => { throw new Error('denied'); } }
}, 'https://video.example/*')).resolves.toBe(false);
await expect(addTabHostAccessRequest({
permissions: { addHostAccessRequest: async () => { throw new Error('denied'); } }
}, 42)).resolves.toBe(false);
await expect(removeTabHostAccessRequest({
permissions: { removeHostAccessRequest: async () => { throw new Error('denied'); } }
}, 42)).resolves.toBe(false);
expect(isHostAccessError(new Error('Missing host permission for the tab'))).toBe(true);
expect(isHostAccessError(new Error('No tab with id: 42'))).toBe(false);
});
it('treats permission inspection errors and timeouts as advisory unknowns', async () => {
const base = {
tabs: { get: async () => ({ url: 'https://video.example/watch' }) }
};
await expect(inspectTabHostAccess({
...base,
permissions: { contains: async () => { throw new Error('permission API failed'); } }
}, 42)).resolves.toMatchObject({ granted: null });
await expect(inspectTabHostAccess({
...base,
runtime: { lastError: { message: 'permission callback failed' } },
permissions: { contains: (_request, callback) => callback(false) }
}, 42)).resolves.toMatchObject({ granted: null });
vi.useFakeTimers();
const pending = inspectTabHostAccess({
...base,
permissions: { contains: () => undefined }
}, 42);
await vi.advanceTimersByTimeAsync(1000);
await expect(pending).resolves.toMatchObject({ granted: null });
});
});
describe('host access recovery contracts', () => {
it('keeps activation, permission recovery, and target identity guarded', () => {
const background = fs.readFileSync(path.join(repoRoot, 'extension/background.js'), 'utf8');
const popup = fs.readFileSync(path.join(repoRoot, 'extension/popup.js'), 'utf8');
const popupHtml = fs.readFileSync(path.join(repoRoot, 'extension/popup.html'), 'utf8');
const tabManager = fs.readFileSync(path.join(repoRoot, 'extension/modules/tab-manager.js'), 'utf8');
expect(background).toMatch(/await activateTargetTab\((?:message\.tabId|selectedTabId), message\.tabTitle\)/);
expect(background).toMatch(/addTabHostAccessRequest\(chrome, tabId, access\.originPattern\)/);
expect(background).toMatch(/retryPendingTarget\(\)/);
expect(background).toMatch(/activationGeneration !== targetActivationGeneration/);
expect(background).toMatch(/pendingTargetRequestId/);
expect(background).toMatch(/addedOrigins\.includes\(pending\.originPattern\)/);
expect(background).toMatch(/isCurrentTargetIdentity\(tabId, targetGeneration\)/);
expect(background).toMatch(/message\.expectedTabId/);
expect(background).toMatch(/completeForceSyncBeforeTargetChange\(selectedTabId\)/);
expect(background).toMatch(/FORCE_SYNC_ACK'[\s\S]*ignored_unselected_tab/);
expect(background).toMatch(/removeTabHostAccessRequest\([\s\S]*pendingTabId/);
const activationBody = background.slice(
background.indexOf('async function activateTargetTab'),
background.indexOf('async function retryPendingTarget')
);
expect(activationBody.indexOf('await injectContentScript')).toBeLessThan(
activationBody.indexOf('currentTabId = selectedTabId')
);
expect(popup).toMatch(/response\?\.status === 'host_permission_required'/);
expect(popup).toMatch(/requestOriginPermission\(chrome, requestedOriginPattern\)/);
expect(popup).toMatch(/expectedCurrentTabId: tabId/);
expect(popup).toMatch(/expectedTabId: tabId/);
expect(tabManager).not.toMatch(/injectContentScript/);
expect((background.match(/tabs\.onRemoved\.addListener/g) || []).length
+ (tabManager.match(/tabs\.onRemoved\.addListener/g) || []).length).toBe(1);
expect(popupHtml).toMatch(/id="siteAccessNotice"/);
});
});
+1 -1
View File
@@ -3,7 +3,7 @@
"default_locale": "en",
"name": "__MSG_appName__",
"short_name": "KoalaSync",
"version": "3.1.3",
"version": "3.1.5",
"description": "__MSG_appDesc__",
"permissions": [
"storage",
+1
View File
@@ -467,6 +467,7 @@ function contentTarget(tabId, selected, discoveredFrameIds = null) {
documentId,
frameUrl: typeof selected?.result?.href === 'string' ? selected.result.href : null,
hasVideo: !!selected?.result?.bestVideo,
visibilityConfirmed: selected?.result?.parentFrameVisible === true,
scriptTarget: documentId
? { tabId, documentIds: [documentId] }
: (frameId === 0 ? { tabId } : { tabId, frameIds: [frameId] })
+3
View File
@@ -184,6 +184,7 @@ describe('cross-origin media-frame targeting', () => {
documentId: 'document-8',
frameUrl: 'https://player-8.example/embed',
hasVideo: true,
visibilityConfirmed: true,
// Reported back so the caller can address these frames directly when
// a later all-frames sweep is rejected wholesale.
discoveredFrameIds: [0, 8],
@@ -242,6 +243,7 @@ describe('cross-origin media-frame targeting', () => {
documentId: null,
frameUrl: null,
hasVideo: false,
visibilityConfirmed: false,
discoveredFrameIds: [0, 6],
scriptTarget: { tabId: 42 }
});
@@ -582,6 +584,7 @@ describe('embedded player access diagnosis', () => {
documentId: null,
frameUrl: null,
hasVideo: false,
visibilityConfirmed: false,
discoveredFrameIds: [0],
scriptTarget: { tabId: 42 }
});
@@ -35,10 +35,14 @@ describe('offline media intent background integration', () => {
const roomDataStart = backgroundSource.indexOf('case EVENTS.ROOM_DATA:');
const roomDataEnd = backgroundSource.indexOf('case EVENTS.CONTROL_MODE:', roomDataStart);
const roomData = backgroundSource.slice(roomDataStart, roomDataEnd);
expect(roomData.indexOf('applyQueuedRoomPolicy(data.roomId'))
.toBeLessThan(roomData.indexOf('handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent)'));
expect(roomData.indexOf('handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent)'))
.toBeLessThan(roomData.indexOf('flushEventQueue(replaySettings)'));
const policyIndex = roomData.indexOf('applyQueuedRoomPolicy(data.roomId');
const canonicalIndex = roomData.indexOf('handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent)');
const flushIndex = roomData.indexOf('flushEventQueue(replaySettings)');
expect(policyIndex).toBeGreaterThan(-1);
expect(canonicalIndex).toBeGreaterThan(-1);
expect(flushIndex).toBeGreaterThan(-1);
expect(policyIndex).toBeLessThan(canonicalIndex);
expect(canonicalIndex).toBeLessThan(flushIndex);
expect(roomData).toContain('activeLobby: !!episodeLobby');
expect(roomData).toContain('desynced: hcmDesynced');
expect(roomData).toContain('if (!data?.activeLobby && episodeLobby && !hasQueuedLocalLobby)');
@@ -53,7 +57,8 @@ describe('offline media intent background integration', () => {
backgroundSource.indexOf("message.type === 'LEAVE_ROOM'"),
backgroundSource.indexOf("message.type === 'CLEAR_LOGS'")
);
expect(leaveHandler).toContain('forceDisconnect()');
expect(leaveHandler).toContain("endRoomSession({ notifyServer: true, reason: 'Left Room' })");
expect(functionBody('endRoomSession', 'leaveRoomAfterIdleGrace')).toContain('forceDisconnect()');
const retryHandler = backgroundSource.slice(
backgroundSource.indexOf("message.type === 'RETRY_CONNECT'"),
backgroundSource.indexOf("message.type === 'GET_STATUS'")
+16 -8
View File
@@ -1,4 +1,4 @@
import { EVENTS, MAX_MEDIA_TIME } from './shared/constants.js';
import { EVENTS, MAX_MEDIA_TIME } from '../shared/constants.js';
export const MEDIA_INTENT_KIND = 'media-intent';
export const MAX_LOGICAL_QUEUE_SIZE = 50;
@@ -245,6 +245,7 @@ export function normalizePersistedEventQueue(value, roomId, maxEntries = MAX_LOG
if (intentEntry) {
intentEntry = repairIntentSequences(intentEntry, maximumSequence);
normalized.push(intentEntry);
normalized = trimQueue(normalized, maxEntries).queue;
maximumSequence = Math.max(maximumSequence, maxQueuedSequence([intentEntry]));
}
continue;
@@ -464,11 +465,18 @@ export async function drainQueuedBatch(queue, {
droppedStaleIntents++;
continue;
}
const frames = isQueuedMediaIntent(entry)
? materializeMediaIntent(entry)
: [{ event: entry.event, data: entry.data }];
const deliveryEntries = entry?.event === EVENTS.FORCE_SYNC_PREPARE
&& remaining[1]?.event === EVENTS.FORCE_SYNC_EXECUTE
? remaining.slice(0, 2)
: [entry];
const frames = deliveryEntries.flatMap(deliveryEntry => {
const deliveryFrames = isQueuedMediaIntent(deliveryEntry)
? materializeMediaIntent(deliveryEntry)
: [{ event: deliveryEntry.event, data: deliveryEntry.data }];
return deliveryFrames.map(frame => ({ frame, entry: deliveryEntry }));
});
if (frames.length === 0) {
remaining.shift();
remaining.splice(0, deliveryEntries.length);
continue;
}
if (sentWireEvents > 0 && sentWireEvents + frames.length > maxWireEvents) {
@@ -479,8 +487,8 @@ export async function drainQueuedBatch(queue, {
}
let sentEntryFrames = 0;
for (const frame of frames) {
if (!await sendFrame(frame, entry)) {
for (const { frame, entry: deliveryEntry } of frames) {
if (!await sendFrame(frame, deliveryEntry)) {
return {
queue: remaining,
sentWireEvents: sentWireEvents + sentEntryFrames,
@@ -491,7 +499,7 @@ export async function drainQueuedBatch(queue, {
sentEntryFrames++;
}
sentWireEvents += sentEntryFrames;
remaining.shift();
remaining.splice(0, deliveryEntries.length);
if (sentWireEvents >= maxWireEvents) {
return { queue: remaining, sentWireEvents, droppedStaleIntents, status: 'batch_full' };
}
+54 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { EVENTS, MAX_MEDIA_TIME } from './shared/constants.js';
import { EVENTS, MAX_MEDIA_TIME } from '../shared/constants.js';
import { canonicalMediaStateFromRoomData } from './canonical-media-state.js';
import {
discardQueuedMediaIntents,
@@ -159,6 +159,26 @@ describe('offline media intent coalescing', () => {
expect(maxQueuedSequence(restored)).toBe(4);
});
it('enforces the cap while restoring consecutive persisted media intents', () => {
const persisted = [10, 20, 30].map((currentTime, index) => ({
kind: 'media-intent',
roomId,
intent: {
playbackState: 'paused',
currentTime,
latestEvent: EVENTS.PAUSE,
previousSeq: null,
latestSeq: index + 1,
actionTimestamp: index + 1,
mediaTitle: null,
sourceEventCount: 1
}
}));
const restored = normalizePersistedEventQueue(persisted, roomId, 2);
expect(restored).toHaveLength(2);
expect(restored.map(entry => entry.intent.currentTime)).toEqual([20, 30]);
});
it('drops room-scoped barriers from another room and unknown persisted events', () => {
const restored = normalizePersistedEventQueue([
{ kind: 'event', roomId: 'room-b', event: EVENTS.FORCE_SYNC_EXECUTE, data: { seq: 1 } },
@@ -336,6 +356,39 @@ describe('offline media intent drain', () => {
expect(queue.some(entry => entry.event === EVENTS.FORCE_SYNC_EXECUTE)).toBe(false);
});
it('keeps adjacent Force Sync PREPARE and EXECUTE in the same replay batch', async () => {
let queue = [];
for (let index = 0; index < 9; index++) {
queue = enqueueQueuedEvent(queue, EVENTS.EPISODE_READY, { seq: index + 1 }, { roomId }).queue;
}
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_PREPARE, { targetTime: 100, seq: 10 }, { roomId }).queue;
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_EXECUTE, { seq: 11 }, { roomId }).queue;
const sent = [];
const result = await drainQueuedBatch(queue, {
roomId,
maxWireEvents: 10,
sendFrame: async frame => { sent.push(frame.event); return true; }
});
expect(sent).toEqual(Array(9).fill(EVENTS.EPISODE_READY));
expect(result.queue.map(entry => entry.event)).toEqual([
EVENTS.FORCE_SYNC_PREPARE,
EVENTS.FORCE_SYNC_EXECUTE
]);
});
it('retains the full Force Sync transaction if EXECUTE replay fails', async () => {
let queue = enqueueQueuedEvent([], EVENTS.FORCE_SYNC_PREPARE, { targetTime: 100, seq: 1 }, { roomId }).queue;
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_EXECUTE, { seq: 2 }, { roomId }).queue;
const result = await drainQueuedBatch(queue, {
roomId,
maxWireEvents: 10,
sendFrame: async frame => frame.event !== EVENTS.FORCE_SYNC_EXECUTE
});
expect(result.status).toBe('send_failed');
expect(result.sentWireEvents).toBe(1);
expect(result.queue).toEqual(queue);
});
it('reports logical and actual-wire queue sizes separately', () => {
let queue = reserve(media(EVENTS.PLAY, { currentTime: 10, seq: 1 }), 2);
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_PREPARE, { seq: 3 }, { roomId }).queue;
+20
View File
@@ -0,0 +1,20 @@
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 popupSource = fs.readFileSync(path.join(extensionDir, 'popup.html'), 'utf8');
describe('popup layout containment', () => {
it('prevents dynamic descendants from changing the 360px popup width', () => {
expect(popupSource).toMatch(/html\s*\{[^}]*width:\s*360px;/s);
expect(popupSource).toMatch(/html\s*\{[^}]*min-width:\s*360px;/s);
expect(popupSource).toMatch(/html\s*\{[^}]*max-width:\s*360px;/s);
expect(popupSource).toMatch(/html\s*\{[^}]*overflow-x:\s*hidden;/s);
expect(popupSource).toMatch(/body\s*\{[^}]*width:\s*360px;/s);
expect(popupSource).toMatch(/body\s*\{[^}]*max-width:\s*360px;/s);
expect(popupSource).toMatch(/body\s*\{[^}]*contain:\s*inline-size;/s);
expect(popupSource).toMatch(/body\s*\{[^}]*overflow-x:\s*hidden;/s);
});
});
+25 -11
View File
@@ -180,11 +180,25 @@
color-scheme: light;
}
/* Defensive popup boundary: descendants may be populated dynamically
after Chrome has measured the action popup. Keep their intrinsic
width from resizing the popup window while preserving normal vertical
layout and scrolling inside the established 360px surface. */
html {
width: 360px;
min-width: 360px;
max-width: 360px;
overflow-x: hidden;
}
body {
width: 360px;
max-width: 360px;
margin: 0;
padding: 18px;
box-sizing: border-box;
contain: inline-size;
overflow-x: hidden;
background: var(--bg);
color: var(--text);
font-family: 'Twemoji Country Flags', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
@@ -1413,16 +1427,16 @@
</a>
</div>
<div class="tabs">
<button class="tab-btn active" data-tab="tab-room" data-i18n="TAB_ROOM" data-i18n-title="TAB_ROOM_TOOLTIP" title="Room settings and connection">Room</button>
<button class="tab-btn" data-tab="tab-sync" data-i18n="TAB_SYNC" data-i18n-title="TAB_SYNC_TOOLTIP" title="Video sync controls and remote actions">Sync</button>
<button class="tab-btn" data-tab="tab-settings" data-i18n="TAB_SETTINGS" data-i18n-title="TAB_SETTINGS_TOOLTIP" title="Extension preferences">Settings</button>
<button class="tab-btn" data-tab="tab-dev" data-i18n="TAB_STATUS" data-i18n-title="TAB_STATUS_TOOLTIP" title="Advanced Diagnostics & Logs">Status</button>
<button id="devToolsTabBtn" class="tab-btn" data-tab="tab-devtools" style="display:none;">Dev</button>
<div class="tabs" role="tablist" aria-label="KoalaSync sections">
<button id="tab-room-button" class="tab-btn active" role="tab" aria-selected="true" aria-controls="tab-room" data-tab="tab-room" data-i18n="TAB_ROOM" data-i18n-title="TAB_ROOM_TOOLTIP" title="Room settings and connection">Room</button>
<button id="tab-sync-button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-sync" tabindex="-1" data-tab="tab-sync" data-i18n="TAB_SYNC" data-i18n-title="TAB_SYNC_TOOLTIP" title="Video sync controls and remote actions">Sync</button>
<button id="tab-settings-button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-settings" tabindex="-1" data-tab="tab-settings" data-i18n="TAB_SETTINGS" data-i18n-title="TAB_SETTINGS_TOOLTIP" title="Extension preferences">Settings</button>
<button id="tab-dev-button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-dev" tabindex="-1" data-tab="tab-dev" data-i18n="TAB_STATUS" data-i18n-title="TAB_STATUS_TOOLTIP" title="Advanced Diagnostics & Logs">Status</button>
<button id="devToolsTabBtn" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-devtools" tabindex="-1" data-tab="tab-devtools" style="display:none;">Dev</button>
</div>
<!-- Room Tab -->
<div id="tab-room" class="tab-content active">
<div id="tab-room" class="tab-content active" role="tabpanel" aria-labelledby="tab-room-button">
<!-- JOIN SECTION: Visible when not in a room -->
<div id="section-join">
@@ -1510,7 +1524,7 @@
</div>
<!-- Sync Tab -->
<div id="tab-sync" class="tab-content">
<div id="tab-sync" class="tab-content" role="tabpanel" aria-labelledby="tab-sync-button" aria-hidden="true">
<!-- SYNC ACTIVE: Visible when in a room -->
<div id="sync-active">
<div class="form-group" style="position: relative;">
@@ -1576,7 +1590,7 @@
</div>
<!-- Settings Tab -->
<div id="tab-settings" class="tab-content">
<div id="tab-settings" class="tab-content" role="tabpanel" aria-labelledby="tab-settings-button" aria-hidden="true">
<details class="form-group" name="settings-accordion" open>
<summary title="Change your username, theme, and language." data-i18n="LABEL_SETTINGS_GROUP_PROFILE" data-i18n-title="LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP">Profile &amp; Appearance</summary>
<div class="details-content">
@@ -1813,7 +1827,7 @@
</div>
<!-- Dev Tab -->
<div id="tab-dev" class="tab-content">
<div id="tab-dev" class="tab-content" role="tabpanel" aria-labelledby="tab-dev-button" aria-hidden="true">
<label title="Current WebSocket connection state" data-i18n="LABEL_CONN_STATUS" data-i18n-title="LABEL_CONN_STATUS_TOOLTIP">Connection Status</label>
<div id="connStatus" class="info-card" style="display:flex; align-items:center; gap: 10px;">
<span id="connDot" class="status-dot status-offline"></span>
@@ -1845,7 +1859,7 @@
<div id="logList"></div>
</div>
<div id="tab-devtools" class="tab-content">
<div id="tab-devtools" class="tab-content" role="tabpanel" aria-labelledby="devToolsTabBtn" aria-hidden="true">
<label>Remote Seek</label>
<div class="info-card" style="display:flex; gap:8px; margin-bottom:15px;">
<button id="remoteSeekBack" class="secondary" style="flex:1; font-size:12px;">-30s</button>
+26 -2
View File
@@ -1791,12 +1791,22 @@ elements.serverUrl.addEventListener('change', () => {
elements.tabs.forEach(btn => {
btn.addEventListener('click', () => {
elements.tabs.forEach(b => b.classList.remove('active'));
elements.contents.forEach(c => c.classList.remove('active'));
elements.tabs.forEach(b => {
b.classList.remove('active');
b.setAttribute('aria-selected', 'false');
b.tabIndex = -1;
});
elements.contents.forEach(c => {
c.classList.remove('active');
c.setAttribute('aria-hidden', 'true');
});
btn.classList.add('active');
btn.setAttribute('aria-selected', 'true');
btn.tabIndex = 0;
const targetContent = document.getElementById(btn.dataset.tab);
targetContent.classList.add('active');
targetContent.removeAttribute('aria-hidden');
targetContent.classList.remove('tab-active-animate');
void targetContent.offsetWidth; // Force reflow to restart animation
@@ -1808,6 +1818,20 @@ elements.tabs.forEach(btn => {
chrome.storage.local.set({ activeTab: btn.dataset.tab });
});
btn.addEventListener('keydown', event => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
const visibleTabs = [...elements.tabs].filter(tab => window.getComputedStyle(tab).display !== 'none');
const currentIndex = visibleTabs.indexOf(btn);
const nextIndex = event.key === 'Home'
? 0
: event.key === 'End'
? visibleTabs.length - 1
: (currentIndex + (event.key === 'ArrowRight' ? 1 : -1) + visibleTabs.length) % visibleTabs.length;
event.preventDefault();
visibleTabs[nextIndex].focus();
visibleTabs[nextIndex].click();
});
});
function showToast(message, type = 'info', duration = 3000) {
+36
View File
@@ -9,6 +9,8 @@ const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'ut
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'));
const sharedConstantsSource = fs.readFileSync(path.join(extensionDir, '..', 'shared', 'constants.js'), 'utf8');
const serverSource = fs.readFileSync(path.join(extensionDir, '..', 'server', 'index.js'), 'utf8');
describe('target tab lifecycle', () => {
it('injects playback and chat scripts only into the explicitly selected tab', () => {
@@ -96,6 +98,37 @@ describe('target tab lifecycle', () => {
expect(overlaySource).toContain("message?.type === 'TARGET_DEACTIVATE'");
});
it('routes every terminal room exit through the full target unhook', () => {
const teardownStart = backgroundSource.indexOf('async function endRoomSession');
const teardownEnd = backgroundSource.indexOf('async function leaveRoomAfterIdleGrace', teardownStart);
const teardownSource = backgroundSource.slice(teardownStart, teardownEnd);
expect(teardownSource).toContain('await deactivateTargetTab(currentTabId, currentContentTarget())');
expect(teardownSource.indexOf('await deactivateTargetTab(currentTabId, currentContentTarget())'))
.toBeLessThan(teardownSource.indexOf('currentTabId = null'));
expect(teardownSource).toContain('await clearPendingTarget()');
expect(teardownSource).toContain('forceDisconnect()');
expect(backgroundSource).toContain('await endRoomSession({ notifyServer: true, reason });');
expect(backgroundSource).toContain("await endRoomSession({ notifyServer: true, reason: 'Left Room' });");
expect(backgroundSource).toContain('data.code === ERROR_CODES.ROOM_CLOSED');
expect(backgroundSource).toContain('data.code === ERROR_CODES.PEER_TIMED_OUT');
expect(backgroundSource).toContain("data.message === 'Room closed'");
expect(backgroundSource).toContain("data.message === 'Removed from room after inactivity'");
expect(backgroundSource).toContain('await endRoomSession({ reason: `Room session ended: ${data.message}` });');
expect(sharedConstantsSource).toContain("ROOM_CLOSED: 'room_closed'");
expect(sharedConstantsSource).toContain("PEER_TIMED_OUT: 'peer_timed_out'");
expect(serverSource).toContain('code: ERROR_CODES.ROOM_CLOSED');
expect(serverSource).toContain('code: ERROR_CODES.PEER_TIMED_OUT');
expect(serverSource).toContain("removePeerFromRoom(sid, roomId, 'room-timeout')");
});
it('does not promote a nested media target without confirmed parent visibility', () => {
expect(backgroundSource).toContain(
'normalizeFrameId(resolved.frameId) !== 0 && resolved.visibilityConfirmed !== true'
);
});
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*\}\)/);
@@ -105,6 +138,9 @@ describe('target tab lifecycle', () => {
it('uses all-frame probing for cross-origin targets without navigation permissions', () => {
expect(backgroundSource).toContain("files: ['media-frame-monitor.js']");
expect(backgroundSource).toContain('async function announcePotentialMediaFrame()');
expect(backgroundSource).toContain("{ type: 'MEDIA_FRAME_DISCOVERED' }");
expect(backgroundSource).toContain('func: announcePotentialMediaFrame');
// Monitors must reach the frames we know about, not only whatever the
// all-frames sweep happens to accept — both on the way in and out.
expect(backgroundSource.match(/\.\.\.listMediaFrameScriptTargets\(tabId\),/g)?.length).toBe(2);
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import {
TITLE_PRIVACY_MODES,
applyTitlePrivacyToPayload,
normalizeSendTabTitle,
normalizeTabTitle,
normalizeTitlePrivacyMode,
sanitizeSharedTitle,
sanitizeTabTitle
} from './title-privacy.js';
describe('title privacy', () => {
it('normalizes settings and tab notification prefixes', () => {
expect(normalizeTitlePrivacyMode(undefined)).toBe(TITLE_PRIVACY_MODES.FULL);
expect(normalizeTitlePrivacyMode('unknown')).toBe(TITLE_PRIVACY_MODES.FULL);
expect(normalizeTitlePrivacyMode(TITLE_PRIVACY_MODES.HIDDEN)).toBe(TITLE_PRIVACY_MODES.HIDDEN);
expect(normalizeSendTabTitle(undefined, TITLE_PRIVACY_MODES.FULL)).toBe(true);
expect(normalizeSendTabTitle(undefined, TITLE_PRIVACY_MODES.EPISODE)).toBe(false);
expect(normalizeSendTabTitle(true, TITLE_PRIVACY_MODES.HIDDEN)).toBe(true);
expect(normalizeSendTabTitle(false, TITLE_PRIVACY_MODES.FULL)).toBe(false);
expect(normalizeTabTitle('(12) Testvideo - YouTube')).toBe('Testvideo - YouTube');
expect(normalizeTabTitle('[999+] Testvideo - YouTube')).toBe('Testvideo - YouTube');
expect(normalizeTabTitle('(500) Days of Summer')).toBe('Days of Summer');
for (const title of ['[7] Testvideo', '(99+) Testvideo', '(999+) Testvideo', '(101) Testvideo', '[101] Testvideo']) {
expect(normalizeTabTitle(title)).toBe('Testvideo');
}
expect(normalizeTabTitle(null)).toBeNull();
expect(normalizeTabTitle(' ')).toBeNull();
expect(sanitizeTabTitle('', true)).toBeNull();
});
it('keeps tab-title and media-title privacy independent', () => {
expect(sanitizeTabTitle('(12) Private Tab', true)).toBe('Private Tab');
expect(sanitizeTabTitle('Private Tab', false)).toBeNull();
expect(sanitizeSharedTitle('Example Movie', 'full')).toBe('Example Movie');
expect(sanitizeSharedTitle('', 'full')).toBeNull();
expect(sanitizeSharedTitle(null, 'full')).toBeNull();
expect(sanitizeSharedTitle('Show Name - S01/E04 - Title', 'episode')).toBe('S01E04');
expect(sanitizeSharedTitle('Folge 7 - Private Server', 'episode')).toBe('EP007');
expect(sanitizeSharedTitle('Example Movie', 'episode')).toBeNull();
expect(sanitizeSharedTitle('Show Name - S01E04', 'hidden')).toBeNull();
});
it('rewrites only present media keys without mutating the input', () => {
const input = {
tabTitle: 'Private Tab',
mediaTitle: 'Private Media',
expectedTitle: 'S01E04',
title: 'S01E04',
currentTime: 42
};
expect(applyTitlePrivacyToPayload(input, 'hidden')).toEqual({
tabTitle: 'Private Tab',
mediaTitle: null,
expectedTitle: null,
title: null,
currentTime: 42
});
expect(input.mediaTitle).toBe('Private Media');
expect(applyTitlePrivacyToPayload({ tabTitle: 'Private Tab', status: 'heartbeat' }, 'episode')).toEqual({
tabTitle: 'Private Tab',
status: 'heartbeat'
});
});
});