diff --git a/extension/background-notify.test.js b/extension/background-notify.test.js new file mode 100644 index 0000000..eae8094 --- /dev/null +++ b/extension/background-notify.test.js @@ -0,0 +1,111 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const BACKGROUND_PATH = path.join(__dirname, 'background.js'); + +// background.js registers listeners at top level and caches its module, so we +// flush the cache and re-evaluate it for each scenario. +function loadBackground(chrome) { + global.chrome = chrome; + delete require.cache[require.resolve(BACKGROUND_PATH)]; + require(BACKGROUND_PATH); +} + +function buildChrome() { + const captured = {}; + const state = { + sendMessageCalls: [], + warnCalls: [], + }; + + const chrome = { + runtime: { + id: 'test-ext-id', + getURL: (p) => `chrome-extension://test-ext-id/${p}`, + onInstalled: { addListener: () => {} }, + onStartup: { addListener: () => {} }, + sendMessage: async () => {}, + }, + action: { + setBadgeText: async () => {}, + }, + tabs: { + query: async () => [], + remove: async () => {}, + onCreated: { addListener: (cb) => { captured.onCreated = cb; } }, + onRemoved: { addListener: (cb) => { captured.onRemoved = cb; } }, + onUpdated: { addListener: (cb) => { captured.onUpdated = cb; } }, + }, + storage: { + local: { get: async () => ({}) }, + }, + }; + + return { chrome, captured, state }; +} + +test('tab events broadcast via chrome.runtime.sendMessage, not content-script channel', async () => { + const { chrome, captured, state } = buildChrome(); + chrome.runtime.sendMessage = async (msg) => { + state.sendMessageCalls.push(msg); + }; + loadBackground(chrome); + assert.ok(captured.onCreated, 'background should register a tabs.onCreated listener'); + + await captured.onCreated({ id: 99 }); + + assert.strictEqual(state.sendMessageCalls.length, 1); + assert.deepStrictEqual(state.sendMessageCalls[0], { + action: 'tabs-changed', + source: 'tabs.onCreated', + triggerTabId: 99, + }); + + await captured.onRemoved(42); + const last = state.sendMessageCalls[state.sendMessageCalls.length - 1]; + assert.deepStrictEqual(last, { + action: 'tabs-changed', + source: 'tabs.onRemoved', + triggerTabId: 42, + }); +}); + +test('"Receiving end does not exist" rejection is swallowed silently', async () => { + const { chrome, captured, state } = buildChrome(); + chrome.runtime.sendMessage = async () => { + throw new Error('Could not establish connection. Receiving end does not exist.'); + }; + + const originalWarn = console.warn; + console.warn = (...args) => { state.warnCalls.push(args); }; + try { + loadBackground(chrome); + await captured.onCreated({ id: 7 }); + } finally { + console.warn = originalWarn; + } + + assert.strictEqual(state.warnCalls.length, 0); +}); + +test('non-connection errors from sendMessage are surfaced via console.warn', async () => { + const { chrome, captured, state } = buildChrome(); + chrome.runtime.sendMessage = async () => { + throw new Error('unexpected broadcast failure'); + }; + + const originalWarn = console.warn; + console.warn = (...args) => { state.warnCalls.push(args); }; + try { + loadBackground(chrome); + await captured.onCreated({ id: 7 }); + } finally { + console.warn = originalWarn; + } + + assert.strictEqual(state.warnCalls.length, 1); + assert.match(state.warnCalls[0].join(' '), /unexpected broadcast failure/); +}); diff --git a/extension/background.js b/extension/background.js index 5583249..69e0300 100644 --- a/extension/background.js +++ b/extension/background.js @@ -6,114 +6,97 @@ */ const TAB_HARBOR_BG_DEBUG = false; -if (TAB_HARBOR_BG_DEBUG) console.log('[tab-harbor bg] Service worker loaded, registering event listeners...'); +if (TAB_HARBOR_BG_DEBUG) + console.log( + "[tab-harbor bg] Service worker loaded, registering event listeners...", + ); // ─── Auto-close duplicate new tabs ─────────────────────────────────────────── function getNewTabUrls() { return new Set([ - chrome.runtime.getURL('index.html'), - chrome.runtime.getURL('extension/index.html'), + chrome.runtime.getURL("index.html"), + chrome.runtime.getURL("extension/index.html"), ]); } function isNewTabBlank(tab, newTabUrls) { - const knownNewTabUrls = newTabUrls instanceof Set - ? newTabUrls - : new Set(Array.isArray(newTabUrls) ? newTabUrls : [newTabUrls]); - const url = tab?.url || ''; - const pendingUrl = tab?.pendingUrl || ''; - if (pendingUrl && !knownNewTabUrls.has(pendingUrl) && pendingUrl !== 'chrome://newtab/') { + const knownNewTabUrls = + newTabUrls instanceof Set + ? newTabUrls + : new Set(Array.isArray(newTabUrls) ? newTabUrls : [newTabUrls]); + const url = tab?.url || ""; + const pendingUrl = tab?.pendingUrl || ""; + if ( + pendingUrl && + !knownNewTabUrls.has(pendingUrl) && + pendingUrl !== "chrome://newtab/" + ) { return false; } return ( - url === 'chrome://newtab/' || + url === "chrome://newtab/" || knownNewTabUrls.has(url) || - pendingUrl === 'chrome://newtab/' || + pendingUrl === "chrome://newtab/" || knownNewTabUrls.has(pendingUrl) || - url === '' || - (tab.status === 'loading' && !url) + url === "" || + (tab.status === "loading" && !url) ); } async function closeDuplicateNewTabs() { try { - const stored = await chrome.storage.local.get('themePreferences'); + const stored = await chrome.storage.local.get("themePreferences"); const prefs = stored.themePreferences || {}; if (prefs.closeDuplicateNewTabsEnabled !== true) return; const newTabUrls = getNewTabUrls(); const allTabs = await chrome.tabs.query({}); - const blankTabs = allTabs.filter(tab => isNewTabBlank(tab, newTabUrls)); + const blankTabs = allTabs.filter((tab) => isNewTabBlank(tab, newTabUrls)); if (blankTabs.length <= 1) return; // Keep the active tab; if none is active, keep the one with the largest id (newest) - const activeTab = blankTabs.find(tab => tab.active); - const toKeep = activeTab || blankTabs.reduce((a, b) => (a.id > b.id ? a : b)); - const toClose = blankTabs.filter(tab => tab.id !== toKeep.id).map(tab => tab.id); + const activeTab = blankTabs.find((tab) => tab.active); + const toKeep = + activeTab || blankTabs.reduce((a, b) => (a.id > b.id ? a : b)); + const toClose = blankTabs + .filter((tab) => tab.id !== toKeep.id) + .map((tab) => tab.id); if (toClose.length > 0) await chrome.tabs.remove(toClose); } catch (err) { - console.warn('[tab-harbor bg] closeDuplicateNewTabs error:', err.message); + console.warn("[tab-harbor bg] closeDuplicateNewTabs error:", err.message); } } async function updateBadge() { try { - await chrome.action.setBadgeText({ text: '' }); + await chrome.action.setBadgeText({ text: "" }); } catch { - chrome.action.setBadgeText({ text: '' }); + chrome.action.setBadgeText({ text: "" }); } } -function getTabHarborDashboardUrls() { - const extensionId = chrome.runtime.id; - return new Set([ - `chrome-extension://${extensionId}/index.html`, - `chrome-extension://${extensionId}/extension/index.html`, - ]); -} - // ─── Event listeners ────────────────────────────────────────────────────────── // Notify Tab Harbor pages when tabs change so they can refresh async function notifyTabHarborPages(eventMeta = {}) { - try { - // Find all Tab Harbor dashboard pages - const dashboardUrls = getTabHarborDashboardUrls(); + const message = { + action: "tabs-changed", + source: eventMeta.source || "tabs.changed", + triggerTabId: eventMeta.triggerTabId ?? null, + }; - // Query all tabs and filter manually for more reliable matching - const allTabs = await chrome.tabs.query({}); - - const dashboardTabs = allTabs.filter(tab => { - if (!tab.url) return false; - // Tab Harbor can appear as either: - // 1. chrome-extension://EXTENSION_ID/index.html (direct access) - // 2. chrome-extension://EXTENSION_ID/extension/index.html (root manifest entry) - // 3. chrome://newtab/ with title "Tab Harbor" (new tab override) - return ( - dashboardUrls.has(tab.url) || - (tab.url === 'chrome://newtab/' && tab.title === 'Tab Harbor') + try { + await chrome.runtime.sendMessage(message); + } catch (err) { + if (err?.message && !err.message.includes("Receiving end does not exist")) { + console.warn( + "[tab-harbor bg] Error notifying Tab Harbor pages:", + err.message, ); - }); - - if (dashboardTabs.length === 0) return; - - for (const tab of dashboardTabs) { - try { - await chrome.tabs.sendMessage(tab.id, { - action: 'tabs-changed', - source: eventMeta.source || 'tabs.changed', - triggerTabId: eventMeta.triggerTabId ?? null, - }); - } catch (err) { - // Tab might be closed or not ready, ignore - console.warn(`[tab-harbor bg] Failed to notify tab ${tab.id}:`, err.message); - } } - } catch (err) { - console.warn('[tab-harbor bg] Error in notifyTabHarborPages:', err); } } @@ -130,20 +113,20 @@ chrome.runtime.onStartup.addListener(() => { // Update badge and notify Tab Harbor pages whenever a tab is opened chrome.tabs.onCreated.addListener((tab) => { updateBadge(); - notifyTabHarborPages({ source: 'tabs.onCreated', triggerTabId: tab?.id }); + notifyTabHarborPages({ source: "tabs.onCreated", triggerTabId: tab?.id }); closeDuplicateNewTabs(); }); // Update badge and notify Tab Harbor pages whenever a tab is closed chrome.tabs.onRemoved.addListener((tabId) => { updateBadge(); - notifyTabHarborPages({ source: 'tabs.onRemoved', triggerTabId: tabId }); + notifyTabHarborPages({ source: "tabs.onRemoved", triggerTabId: tabId }); }); // Update badge and notify Tab Harbor pages when a tab's URL changes (e.g. navigating to/from chrome://) chrome.tabs.onUpdated.addListener((tabId) => { updateBadge(); - notifyTabHarborPages({ source: 'tabs.onUpdated', triggerTabId: tabId }); + notifyTabHarborPages({ source: "tabs.onUpdated", triggerTabId: tabId }); }); // ─── Initial run ───────────────────────────────────────────────────────────── diff --git a/extension/dashboard-runtime.js b/extension/dashboard-runtime.js index 07abf5a..3fc866b 100644 --- a/extension/dashboard-runtime.js +++ b/extension/dashboard-runtime.js @@ -3234,7 +3234,6 @@ async function buildDomainGroups(realTabs = getRealTabs()) { } if (landingTabs.length > 0) { - groupMap.__landing_pages__ = undefined; groupMap['__landing-pages__'] = { domain: '__landing-pages__', tabs: landingTabs }; } diff --git a/extension/manifest.json b/extension/manifest.json index bf6a807..6efc2d2 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,9 +1,16 @@ { "manifest_version": 3, "name": "Tab Harbor", - "version": "1.1.1", + "version": "1.1.2", "description": "A new tab dashboard for organizing open tabs, quick links, todos, and saved tab sessions in one calm workspace.", - "permissions": ["tabs", "storage", "search", "clipboardRead", "tabGroups", "favicon"], + "permissions": [ + "tabs", + "storage", + "search", + "clipboardRead", + "tabGroups", + "favicon" + ], "chrome_url_overrides": { "newtab": "index.html" }, "background": { "service_worker": "background.js" }, "action": { diff --git a/extension/ui-regression.test.js b/extension/ui-regression.test.js index 7a70d54..0308a54 100644 --- a/extension/ui-regression.test.js +++ b/extension/ui-regression.test.js @@ -345,7 +345,7 @@ test('optional local config is loaded safely before app mount', () => { }); test('background keeps the toolbar badge empty', () => { - assert.match(backgroundJs, /await chrome\.action\.setBadgeText\(\{\s*text:\s*''\s*\}\)/); + assert.match(backgroundJs, /await chrome\.action\.setBadgeText\(\{\s*text:\s*["']{2}\s*\}\)/); assert.doesNotMatch(backgroundJs, /String\(count\)/); }); @@ -1056,13 +1056,16 @@ test('dynamic animation styles are generated by JavaScript instead of hardcoded test('dashboard auto-refreshes when tabs change via background message', () => { const bgJs = fs.readFileSync(path.join(__dirname, 'background.js'), 'utf8'); - // Background should notify Tab Harbor pages + // Background should broadcast tab changes via chrome.runtime.sendMessage + // (the dashboard is an extension page, NOT a content script — sendMessage + // to a content script would always fail with "Receiving end does not exist"). assert.match(bgJs, /notifyTabHarborPages/); - assert.match(bgJs, /chrome\.tabs\.sendMessage/); - assert.match(bgJs, /action:\s*'tabs-changed'/); + assert.match(bgJs, /chrome\.runtime\.sendMessage/); + assert.doesNotMatch(bgJs, /chrome\.tabs\.sendMessage/); + assert.match(bgJs, /action:\s*["']tabs-changed["']/); assert.match(bgJs, /triggerTabId/); - assert.match(bgJs, /source:\s*'tabs\.onCreated'/); - assert.match(bgJs, /source:\s*'tabs\.onUpdated'/); + assert.match(bgJs, /source:\s*["']tabs\.onCreated["']/); + assert.match(bgJs, /source:\s*["']tabs\.onUpdated["']/); assert.match(bgJs, /chrome\.tabs\.onCreated\.addListener/); assert.match(bgJs, /chrome\.tabs\.onRemoved\.addListener/);