diff --git a/extension/async-session-guards.test.mjs b/extension/async-session-guards.test.mjs index 01615b0..44eb2f4 100644 --- a/extension/async-session-guards.test.mjs +++ b/extension/async-session-guards.test.mjs @@ -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()'); } diff --git a/extension/background.js b/extension/background.js index a6810fc..b376def 100644 --- a/extension/background.js +++ b/extension/background.js @@ -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; } diff --git a/extension/page-api-player.test.mjs b/extension/page-api-player.test.mjs index 12aa3a8..0a1f8d9 100644 --- a/extension/page-api-player.test.mjs +++ b/extension/page-api-player.test.mjs @@ -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'); diff --git a/extension/popup.js b/extension/popup.js index 59fbad9..dd5d255 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -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') { diff --git a/extension/target-tab-lifecycle.test.mjs b/extension/target-tab-lifecycle.test.mjs index 0bda1e4..ef967e0 100644 --- a/extension/target-tab-lifecycle.test.mjs +++ b/extension/target-tab-lifecycle.test.mjs @@ -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' });"); diff --git a/tests/e2e/extension.spec.mjs b/tests/e2e/extension.spec.mjs index 698b8a4..28574fb 100644 --- a/tests/e2e/extension.spec.mjs +++ b/tests/e2e/extension.spec.mjs @@ -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.