mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-09-03 14:37:55 +00:00
fix(extension): clear stale target after room exit
This commit is contained in:
@@ -81,7 +81,8 @@ describe('async room-session guards', () => {
|
||||
for (const [start, end] of [
|
||||
["message.type === 'CONNECT'", "message.type === 'RETRY_CONNECT'"],
|
||||
["message.type === 'RETRY_CONNECT'", "message.type === 'GET_STATUS'"],
|
||||
["message.type === 'WEB_JOIN_REQUEST'", "message.type === 'REGENERATE_ID'"]
|
||||
["message.type === 'WEB_JOIN_REQUEST'", "message.type === 'REGENERATE_ID'"],
|
||||
["message.type === 'SET_TARGET_TAB'", "message.type === 'LOG'"]
|
||||
]) {
|
||||
expect(sourceBetween(start, end)).toContain('await waitForRoomTeardown()');
|
||||
}
|
||||
|
||||
+46
-44
@@ -1142,7 +1142,11 @@ function releaseUnreachableFrameTarget(tabId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) {
|
||||
async function clearTargetSelectionForLifecycle({
|
||||
expectedTabId = null,
|
||||
expectedGeneration = null,
|
||||
markRoomIdle = false
|
||||
} = {}) {
|
||||
if (expectedTabId !== null && normalizeTabId(currentTabId) !== normalizeTabId(expectedTabId)) {
|
||||
return false;
|
||||
}
|
||||
@@ -1150,30 +1154,52 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null)
|
||||
return false;
|
||||
}
|
||||
|
||||
const previousTabId = normalizeTabId(currentTabId);
|
||||
const previousContentTarget = currentContentTarget();
|
||||
const clearedTabId = normalizeTabId(userSelectedTabId) ?? previousTabId;
|
||||
|
||||
completeForceSyncBeforeTargetChange(null);
|
||||
invalidateTargetActivations();
|
||||
clearPendingTarget().catch(() => {});
|
||||
if (currentTabId) deactivateTargetTab(currentTabId).catch(() => {});
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
clearCurrentContentTarget();
|
||||
lastContentHeartbeatAt = null;
|
||||
if (currentRoom) {
|
||||
if (markRoomIdle && currentRoom) {
|
||||
roomIdleSince = Date.now();
|
||||
}
|
||||
chrome.storage.session.set({
|
||||
|
||||
resetUserSelectionState();
|
||||
const cleanupTasks = [clearPendingTarget()];
|
||||
if (previousTabId !== null) {
|
||||
cleanupTasks.push(deactivateTargetTab(previousTabId, previousContentTarget));
|
||||
}
|
||||
await Promise.all(cleanupTasks);
|
||||
await chrome.storage.session.set({
|
||||
currentTabId,
|
||||
currentTabTitle,
|
||||
currentTargetFrameId,
|
||||
currentTargetDocumentId,
|
||||
currentTargetHasVideo,
|
||||
roomIdleSince,
|
||||
lastContentHeartbeatAt
|
||||
}).catch(() => {});
|
||||
lastContentHeartbeatAt,
|
||||
selectedTabId: null,
|
||||
selectedTabTitle: null,
|
||||
selectionErrorTabId: null,
|
||||
selectionErrorMessage: null
|
||||
});
|
||||
updateBadgeStatus();
|
||||
chrome.runtime.sendMessage({ type: 'TARGET_TAB_CLEARED', tabId: clearedTabId }).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) {
|
||||
return clearTargetSelectionForLifecycle({
|
||||
expectedTabId,
|
||||
expectedGeneration,
|
||||
markRoomIdle: true
|
||||
});
|
||||
}
|
||||
|
||||
async function performRoomSessionTeardown({ notifyServer = false, reason = 'Left Room' } = {}) {
|
||||
webJoinCoordinator.invalidate();
|
||||
connectIntent = false;
|
||||
@@ -1198,14 +1224,8 @@ async function performRoomSessionTeardown({ notifyServer = false, reason = 'Left
|
||||
// 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, currentContentTarget());
|
||||
invalidateTargetActivations();
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
clearCurrentContentTarget();
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
await clearPendingTarget();
|
||||
await clearTargetSelectionForLifecycle();
|
||||
|
||||
isForceSyncInitiator = false;
|
||||
forceSyncAcks.clear();
|
||||
@@ -3829,10 +3849,7 @@ async function clearUserSelection(expectedTabId = null) {
|
||||
&& normalizeTabId(userSelectedTabId) !== normalizeTabId(expectedTabId)) {
|
||||
return false;
|
||||
}
|
||||
userSelectedTabId = null;
|
||||
userSelectedTabTitle = null;
|
||||
userSelectionErrorTabId = null;
|
||||
userSelectionErrorMessage = null;
|
||||
resetUserSelectionState();
|
||||
await chrome.storage.session.set({
|
||||
selectedTabId: null,
|
||||
selectedTabTitle: null,
|
||||
@@ -3842,6 +3859,13 @@ async function clearUserSelection(expectedTabId = null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function resetUserSelectionState() {
|
||||
userSelectedTabId = null;
|
||||
userSelectedTabTitle = null;
|
||||
userSelectionErrorTabId = null;
|
||||
userSelectionErrorMessage = null;
|
||||
}
|
||||
|
||||
async function activateTargetTab(tabId, tabTitle, {
|
||||
requestHostAccess = true,
|
||||
expectedGeneration = null,
|
||||
@@ -4359,7 +4383,7 @@ async function _routeToContentInternal(tabId, action, payload, actionTimestamp,
|
||||
}
|
||||
if (retries >= 3) {
|
||||
addLog(`Content Script not responding in tab ${tabId} after ${retries} retries`, 'warn');
|
||||
clearTargetTabForIdle(tabId, targetGeneration);
|
||||
await clearTargetTabForIdle(tabId, targetGeneration);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4386,7 +4410,7 @@ async function _routeToContentInternal(tabId, action, payload, actionTimestamp,
|
||||
}
|
||||
|
||||
addLog(`Content Script not responding in tab ${tabId}`, 'warn');
|
||||
clearTargetTabForIdle(tabId, targetGeneration);
|
||||
await clearTargetTabForIdle(tabId, targetGeneration);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5332,31 +5356,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
});
|
||||
return true;
|
||||
} else if (message.type === 'SET_TARGET_TAB') {
|
||||
await waitForRoomTeardown();
|
||||
if (message.tabId === null || message.tabId === undefined || message.tabId === '') {
|
||||
const previousTabId = currentTabId;
|
||||
const previousContentTarget = currentContentTarget();
|
||||
completeForceSyncBeforeTargetChange(null);
|
||||
invalidateTargetActivations();
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
clearCurrentContentTarget();
|
||||
lastContentHeartbeatAt = null;
|
||||
if (currentRoom) roomIdleSince = Date.now();
|
||||
if (previousTabId) {
|
||||
await deactivateTargetTab(previousTabId, previousContentTarget);
|
||||
}
|
||||
await clearUserSelection();
|
||||
await clearPendingTarget();
|
||||
await chrome.storage.session.set({
|
||||
currentTabId: null,
|
||||
currentTabTitle: null,
|
||||
currentTargetFrameId: 0,
|
||||
currentTargetDocumentId: null,
|
||||
currentTargetHasVideo: false,
|
||||
roomIdleSince,
|
||||
lastContentHeartbeatAt: null
|
||||
});
|
||||
updateBadgeStatus();
|
||||
await clearTargetSelectionForLifecycle({ markRoomIdle: true });
|
||||
sendResponse({ status: 'ok', tabId: null });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
|
||||
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8')
|
||||
.replace(/\r\n/g, '\n');
|
||||
const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'utf8');
|
||||
const providerSource = fs.readFileSync(path.join(extensionDir, 'page-api-seek-overrides.js'), 'utf8');
|
||||
|
||||
|
||||
+6
-5
@@ -660,7 +660,7 @@ function selectTargetTab(tabId, tabTitle) {
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshTargetAccessState() {
|
||||
async function refreshTargetAccessState({ autoSelectMatch = true } = {}) {
|
||||
const status = await new Promise(resolve => {
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, response => {
|
||||
if (chrome.runtime.lastError) {
|
||||
@@ -682,7 +682,8 @@ async function refreshTargetAccessState() {
|
||||
}
|
||||
await populateTabs(
|
||||
status.peers,
|
||||
status.targetTabId ?? status.pendingTargetTabId ?? null
|
||||
status.targetTabId ?? status.pendingTargetTabId ?? null,
|
||||
autoSelectMatch
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1214,7 +1215,7 @@ async function resetBlacklistDomains() {
|
||||
await populateTabs();
|
||||
}
|
||||
|
||||
async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
||||
async function populateTabs(providedPeers = null, providedTargetTabId = null, autoSelectMatch = true) {
|
||||
const token = {};
|
||||
populateTabsToken = token;
|
||||
|
||||
@@ -1335,7 +1336,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
||||
elements.targetTab.value = currentTargetTabId;
|
||||
} else {
|
||||
const matchOpt = options.find(o => o.textContent.includes('⭐ MATCH:'));
|
||||
if (matchOpt && elements.targetTab.options.length > 1) {
|
||||
if (autoSelectMatch && matchOpt && elements.targetTab.options.length > 1) {
|
||||
elements.targetTab.value = matchOpt.value;
|
||||
const tabTitle = matchOpt.dataset.originalTitle || null;
|
||||
selectTargetTab(parseInt(matchOpt.value), tabTitle);
|
||||
@@ -2545,7 +2546,7 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
} else if (msg.type === 'TARGET_TAB_ACCESS_REQUIRED') {
|
||||
refreshTargetAccessState().catch(() => {});
|
||||
} else if (msg.type === 'TARGET_TAB_CLEARED') {
|
||||
refreshTargetAccessState().catch(() => {});
|
||||
refreshTargetAccessState({ autoSelectMatch: false }).catch(() => {});
|
||||
} else if (msg.type === 'PING_UPDATE') {
|
||||
updatePingDisplay(msg.ping);
|
||||
} else if (msg.type === 'HISTORY_UPDATE') {
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 popupSource = fs.readFileSync(path.join(extensionDir, 'popup.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');
|
||||
@@ -102,11 +103,24 @@ describe('target tab lifecycle', () => {
|
||||
const teardownStart = backgroundSource.indexOf('async function performRoomSessionTeardown');
|
||||
const teardownEnd = backgroundSource.indexOf('async function endRoomSession', 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('await clearTargetSelectionForLifecycle()');
|
||||
expect(teardownSource).toContain('forceDisconnect()');
|
||||
expect(teardownSource.indexOf('completeForceSyncBeforeTargetChange(null)'))
|
||||
.toBeLessThan(teardownSource.indexOf('currentRoom = null'));
|
||||
|
||||
const clearStart = backgroundSource.indexOf('async function clearTargetSelectionForLifecycle');
|
||||
const clearEnd = backgroundSource.indexOf('function clearTargetTabForIdle', clearStart);
|
||||
const clearSource = backgroundSource.slice(clearStart, clearEnd);
|
||||
expect(clearSource).toContain('const previousTabId = normalizeTabId(currentTabId)');
|
||||
expect(clearSource).toContain('const previousContentTarget = currentContentTarget()');
|
||||
expect(clearSource.indexOf('const previousContentTarget = currentContentTarget()'))
|
||||
.toBeLessThan(clearSource.indexOf('currentTabId = null'));
|
||||
expect(clearSource).toContain('resetUserSelectionState()');
|
||||
expect(clearSource).toContain('deactivateTargetTab(previousTabId, previousContentTarget)');
|
||||
expect(clearSource).toContain('selectedTabId: null');
|
||||
expect(clearSource).toContain("type: 'TARGET_TAB_CLEARED'");
|
||||
expect(popupSource).toContain("refreshTargetAccessState({ autoSelectMatch: false })");
|
||||
expect(popupSource).toContain('if (autoSelectMatch && matchOpt && elements.targetTab.options.length > 1)');
|
||||
|
||||
expect(backgroundSource).toContain('await endRoomSession({ notifyServer: true, reason });');
|
||||
expect(backgroundSource).toContain("await endRoomSession({ notifyServer: true, reason: 'Left Room' });");
|
||||
|
||||
@@ -1459,7 +1459,7 @@ test('keeps the tab selected when its activation fails', async ({ context, exten
|
||||
expect(second).toMatchObject({ targetTabId: tabId, targetActivationState: 'error' });
|
||||
});
|
||||
|
||||
test('drops the selection only when the user clears it', async ({ context, extensionId, baseURL }) => {
|
||||
test('drops the selection when the user clears it', async ({ context, extensionId, baseURL }) => {
|
||||
const url = `${baseURL}/pages/simple-player.html`;
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
@@ -1485,6 +1485,129 @@ test('drops the selection only when the user clears it', async ({ context, exten
|
||||
});
|
||||
});
|
||||
|
||||
test('clears the selected target after inactivity removal and room closure', async ({ context, extensionId, baseURL }) => {
|
||||
test.setTimeout(45_000);
|
||||
const relay = await import('../../server/index.js');
|
||||
let legacy = null;
|
||||
try {
|
||||
await relay.startServer(0, '127.0.0.1');
|
||||
const port = relay.httpServer.address().port;
|
||||
const url = `${baseURL}/pages/simple-player.html`;
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
await page.waitForFunction(() => window.__fixtureReady === true);
|
||||
|
||||
const connectRoom = async (roomId) => {
|
||||
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async settings => {
|
||||
await chrome.storage.local.set(settings);
|
||||
return chrome.runtime.sendMessage({ type: 'CONNECT' });
|
||||
}, {
|
||||
serverUrl: `ws://127.0.0.1:${port}`,
|
||||
useCustomServer: true,
|
||||
roomId,
|
||||
password: '',
|
||||
username: 'terminal-target-e2e'
|
||||
}));
|
||||
await expectConnectedRoom(context, extensionId, roomId, {
|
||||
targetReady: true,
|
||||
targetActivationState: 'ready'
|
||||
});
|
||||
};
|
||||
|
||||
const expectTargetCleared = async (popup) => {
|
||||
await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' }))
|
||||
.toMatchObject({
|
||||
status: 'idle',
|
||||
roomId: null,
|
||||
targetTabId: null,
|
||||
targetReady: false,
|
||||
targetActivationState: 'none'
|
||||
});
|
||||
await expect.poll(() => popup.locator('#targetTab').inputValue()).toBe('');
|
||||
await expect.poll(() => page.evaluate(() => window.koalaSyncInjected === true)).toBe(false);
|
||||
const sessionState = await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(() => (
|
||||
chrome.storage.session.get(['currentTabId', 'selectedTabId', 'selectionErrorTabId'])
|
||||
)));
|
||||
expect(sessionState).toMatchObject({
|
||||
currentTabId: null,
|
||||
selectedTabId: null,
|
||||
selectionErrorTabId: null
|
||||
});
|
||||
};
|
||||
|
||||
const firstSelection = await selectTargetTab(context, extensionId, url);
|
||||
expect(firstSelection.response).toMatchObject({ status: 'ok' });
|
||||
const timeoutRoomId = `e2e-target-timeout-${Date.now()}`;
|
||||
await connectRoom(timeoutRoomId);
|
||||
|
||||
const popup = await context.newPage();
|
||||
await popup.goto(`chrome-extension://${extensionId}/popup.html`);
|
||||
await expect.poll(() => popup.locator('#targetTab').inputValue())
|
||||
.toBe(String(firstSelection.tabId));
|
||||
|
||||
const timeoutRoom = relay.rooms.get(timeoutRoomId);
|
||||
expect(timeoutRoom).toBeTruthy();
|
||||
for (const peer of timeoutRoom.peerData.values()) {
|
||||
peer.lastSeen = Date.now() - (10 * 60 * 1000);
|
||||
}
|
||||
relay.cleanupInactiveRooms(Date.now());
|
||||
await expectTargetCleared(popup);
|
||||
|
||||
const secondSelection = await selectTargetTab(context, extensionId, url);
|
||||
expect(secondSelection).toMatchObject({
|
||||
tabId: firstSelection.tabId,
|
||||
response: { status: 'ok' }
|
||||
});
|
||||
const closedRoomId = `e2e-target-room-close-${Date.now()}`;
|
||||
await connectRoom(closedRoomId);
|
||||
await expect.poll(() => popup.locator('#targetTab').inputValue())
|
||||
.toBe(String(firstSelection.tabId));
|
||||
|
||||
const closedRoom = relay.rooms.get(closedRoomId);
|
||||
expect(closedRoom).toBeTruthy();
|
||||
closedRoom.lastActivity = Date.now() - (3 * 60 * 60 * 1000);
|
||||
for (const peer of closedRoom.peerData.values()) peer.lastSeen = Date.now();
|
||||
relay.cleanupInactiveRooms(Date.now());
|
||||
await expectTargetCleared(popup);
|
||||
|
||||
const thirdSelection = await selectTargetTab(context, extensionId, url);
|
||||
expect(thirdSelection.response).toMatchObject({ status: 'ok' });
|
||||
const manualRoomId = `e2e-target-manual-clear-${Date.now()}`;
|
||||
await connectRoom(manualRoomId);
|
||||
legacy = await connectLegacyRelayClient(port);
|
||||
await joinLegacyRelayRoom(legacy, manualRoomId, 'matching-peer');
|
||||
sendLegacyRelayEvent(legacy, 'peer_status', {
|
||||
peerId: 'matching-peer',
|
||||
username: 'Matching Peer',
|
||||
tabTitle: 'Simple player',
|
||||
status: 'heartbeat'
|
||||
});
|
||||
await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' })
|
||||
.then(state => state.peers.some(peer => peer.peerId === 'matching-peer' && peer.tabTitle === 'Simple player')))
|
||||
.toBe(true);
|
||||
|
||||
const manuallyCleared = await getExtensionState(context, extensionId, {
|
||||
type: 'SET_TARGET_TAB',
|
||||
tabId: null
|
||||
});
|
||||
expect(manuallyCleared).toMatchObject({ status: 'ok', tabId: null });
|
||||
await popup.waitForTimeout(500);
|
||||
await expect(popup.locator('#targetTab')).toHaveValue('');
|
||||
await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' }))
|
||||
.toMatchObject({
|
||||
status: 'connected',
|
||||
roomId: manualRoomId,
|
||||
targetTabId: null,
|
||||
targetActivationState: 'none'
|
||||
});
|
||||
await popup.close();
|
||||
await page.close();
|
||||
} finally {
|
||||
try { legacy?.close(); } catch { /* already closed */ }
|
||||
await relay.stopServerForTests();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Reads one global from the top document and from the player frame separately,
|
||||
* so a test can prove which frame a script was installed in.
|
||||
|
||||
Reference in New Issue
Block a user