'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); function createEventEmitter() { const listeners = new Set(); return { addListener(listener) { listeners.add(listener); }, removeListener(listener) { listeners.delete(listener); }, hasListener(listener) { return listeners.has(listener); }, emit(...args) { for (const listener of listeners) listener(...args); }, }; } // Mock chrome APIs before loading the module const mockStorage = {}; globalThis.chrome = { storage: { local: { get: async (keys) => { const key = Array.isArray(keys) ? keys[0] : keys; return { [key]: mockStorage[key] ?? undefined }; }, set: async (items) => { Object.assign(mockStorage, items); }, }, }, tabs: { group: async (opts) => 0, get: async () => ({ id: 0, groupId: -1, windowId: 0 }), move: async () => {}, ungroup: async () => {}, query: async (opts) => opts?.groupId != null ? [] : [], onAttached: createEventEmitter(), onCreated: createEventEmitter(), onDetached: createEventEmitter(), onMoved: createEventEmitter(), onRemoved: createEventEmitter(), onUpdated: createEventEmitter(), }, tabGroups: { query: async () => [], update: async () => {}, get: async () => null, onCreated: createEventEmitter(), onRemoved: createEventEmitter(), onUpdated: createEventEmitter(), }, }; require('./chrome-tab-groups-sync.js'); const { loadChromeTabGroupsSetting, saveChromeTabGroupsSetting, syncChromeTabGroups, collapseChromeTabGroupsInWindow, syncChromeTabGroupExpansionForTab, resetChromeGroupState, isChromeTabGroupsEnabled, getChromeGroupCount, populateChromeGroupMap, queryExistingChromeGroups, setImportMode, subscribeToChromeTabGroupChanges, assignGroupColor, getGroupTitle, } = globalThis.TabOutChromeTabGroups; test('assignGroupColor returns blue for session groups', () => { assert.equal(assignGroupColor('__session_group__:g1', 0), 'blue'); assert.equal(assignGroupColor('__session_group__:g2', 5), 'blue'); }); test('assignGroupColor returns yellow for landing pages', () => { assert.equal(assignGroupColor('__landing-pages__', 0), 'yellow'); assert.equal(assignGroupColor('__landing-pages__', 3), 'yellow'); }); test('assignGroupColor cycles through colors for domain groups', () => { const colors = ['grey', 'red', 'green', 'pink', 'purple', 'cyan', 'orange']; colors.forEach((expected, i) => { assert.equal(assignGroupColor('example.com', i), expected); }); // Cycles back assert.equal(assignGroupColor('test.com', 7), colors[0]); }); test('getGroupTitle returns friendly domain for regular groups', () => { const group = { domain: 'github.com', tabs: [] }; const title = getGroupTitle(group); assert.equal(typeof title, 'string'); assert.ok(title.length > 0); }); test('getGroupTitle uses label for custom groups', () => { const group = { domain: 'custom-key', label: 'Work', tabs: [] }; assert.equal(getGroupTitle(group), 'Work'); }); test('getGroupTitle returns Homepages for landing pages', () => { const group = { domain: '__landing-pages__', tabs: [] }; assert.equal(getGroupTitle(group), 'Homepages'); }); test('loadChromeTabGroupsSetting returns false by default', async () => { resetChromeGroupState(); delete mockStorage.chromeTabGroupsEnabled; const result = await loadChromeTabGroupsSetting(); assert.equal(result, false); assert.equal(isChromeTabGroupsEnabled(), false); }); test('saveChromeTabGroupsSetting persists and updates cached state', async () => { resetChromeGroupState(); const saved = await saveChromeTabGroupsSetting(true); assert.equal(saved, true); assert.equal(isChromeTabGroupsEnabled(), true); assert.equal(mockStorage.chromeTabGroupsEnabled, true); const loaded = await loadChromeTabGroupsSetting(); assert.equal(loaded, true); }); test('saveChromeTabGroupsSetting toggles off correctly', async () => { resetChromeGroupState(); await saveChromeTabGroupsSetting(true); assert.equal(isChromeTabGroupsEnabled(), true); await saveChromeTabGroupsSetting(false); assert.equal(isChromeTabGroupsEnabled(), false); assert.equal(mockStorage.chromeTabGroupsEnabled, false); }); test('syncChromeTabGroups removes groups when disabled', async () => { resetChromeGroupState(); await saveChromeTabGroupsSetting(false); const groups = [ { domain: 'github.com', tabs: [{ id: 1, windowId: 1, url: 'https://github.com' }] }, ]; // Should not throw when disabled await syncChromeTabGroups(groups); assert.equal(getChromeGroupCount(), 0); }); test('syncChromeTabGroups creates groups when enabled', async () => { resetChromeGroupState(); let groupCallCount = 0; let updateCallArgs = []; globalThis.chrome.tabs.group = async (opts) => { groupCallCount++; return 100 + groupCallCount; }; globalThis.chrome.tabGroups.update = async (id, opts) => { updateCallArgs.push({ id, ...opts }); }; globalThis.chrome.tabGroups.query = async () => []; globalThis.chrome.tabs.query = async (opts) => []; await saveChromeTabGroupsSetting(true); const groups = [ { domain: 'github.com', tabs: [{ id: 1, windowId: 1, url: 'https://github.com' }] }, { domain: 'example.com', tabs: [{ id: 2, windowId: 1, url: 'https://example.com' }] }, ]; await syncChromeTabGroups(groups); assert.equal(groupCallCount, 2); assert.equal(updateCallArgs.length, 2); assert.equal(updateCallArgs[0].collapsed, true); assert.equal(updateCallArgs[1].collapsed, true); assert.equal(updateCallArgs[0].color, 'grey'); assert.equal(updateCallArgs[1].color, 'red'); assert.equal(getChromeGroupCount(), 2); }); test('syncChromeTabGroups handles tabs in different windows', async () => { resetChromeGroupState(); let groupCallCount = 0; globalThis.chrome.tabs.group = async (opts) => { groupCallCount++; return 200 + groupCallCount; }; globalThis.chrome.tabGroups.update = async () => {}; globalThis.chrome.tabGroups.query = async () => []; globalThis.chrome.tabs.query = async (opts) => []; await saveChromeTabGroupsSetting(true); const groups = [ { domain: 'github.com', tabs: [ { id: 1, windowId: 1, url: 'https://github.com' }, { id: 3, windowId: 2, url: 'https://github.com/other' }, ], }, ]; await syncChromeTabGroups(groups); // Should create 1 group for each window → 2 total chrome.tabs.group calls assert.equal(groupCallCount, 2); }); test('syncChromeTabGroups reorders tabs inside a chrome group to match desired order', async () => { resetChromeGroupState(); const moveCalls = []; globalThis.chrome.tabs.group = async () => 901; globalThis.chrome.tabs.move = async (tabId, opts) => { moveCalls.push({ tabId, ...opts }); }; globalThis.chrome.tabGroups.update = async () => {}; globalThis.chrome.tabGroups.query = async () => []; globalThis.chrome.tabs.query = async (opts) => { if (opts?.groupId === 901) { return [ { id: 2, groupId: 901, windowId: 1, index: 7 }, { id: 1, groupId: 901, windowId: 1, index: 8 }, ]; } return []; }; await saveChromeTabGroupsSetting(true); await syncChromeTabGroups([ { domain: 'github.com', tabs: [ { id: 1, windowId: 1, url: 'https://github.com/1' }, { id: 2, windowId: 1, url: 'https://github.com/2' }, ], }, ]); assert.deepEqual(moveCalls, [ { tabId: 1, windowId: 1, index: 7 }, { tabId: 2, windowId: 1, index: 8 }, ]); }); test('syncChromeTabGroups reorders grouped tabs across groups to match desired group order in a window', async () => { resetChromeGroupState(); const moveCalls = []; globalThis.chrome.tabs.group = async (opts) => opts.groupId ?? 0; globalThis.chrome.tabs.move = async (tabId, opts) => { moveCalls.push({ tabId, ...opts }); }; globalThis.chrome.tabGroups.update = async () => {}; globalThis.chrome.tabGroups.query = async () => [ { id: 501, title: 'GitHub', color: 'grey', windowId: 1 }, { id: 502, title: 'Bilibili', color: 'red', windowId: 1 }, ]; globalThis.chrome.tabs.query = async (opts) => { if (opts?.groupId === 501) { return [ { id: 11, groupId: 501, windowId: 1, index: 8 }, ]; } if (opts?.groupId === 502) { return [ { id: 22, groupId: 502, windowId: 1, index: 5 }, ]; } if (opts?.windowId === 1) { return [ { id: 22, windowId: 1, index: 5 }, { id: 11, windowId: 1, index: 8 }, ]; } return []; }; await saveChromeTabGroupsSetting(true); await populateChromeGroupMap([ { virtualGroupKey: 'github.com', windowId: 1, chromeGroupId: 501 }, { virtualGroupKey: 'bilibili.com', windowId: 1, chromeGroupId: 502 }, ]); await syncChromeTabGroups([ { domain: 'github.com', tabs: [{ id: 11, windowId: 1, url: 'https://github.com' }] }, { domain: 'bilibili.com', tabs: [{ id: 22, windowId: 1, url: 'https://bilibili.com' }] }, ]); assert.deepEqual( moveCalls.slice(-2), [ { tabId: 11, windowId: 1, index: 5 }, { tabId: 22, windowId: 1, index: 6 }, ] ); }); test('syncChromeTabGroups cleans up when disabled after being enabled', async () => { resetChromeGroupState(); let ungroupCalls = []; globalThis.chrome.tabs.ungroup = async (tabIds) => { ungroupCalls.push(tabIds); }; globalThis.chrome.tabs.group = async (opts) => 300; globalThis.chrome.tabGroups.update = async () => {}; globalThis.chrome.tabGroups.query = async () => []; globalThis.chrome.tabs.query = async (opts) => []; await saveChromeTabGroupsSetting(true); const groups = [ { domain: 'test.com', tabs: [{ id: 10, windowId: 1, url: 'https://test.com' }] }, ]; await syncChromeTabGroups(groups); assert.ok(getChromeGroupCount() > 0); // Now disable await saveChromeTabGroupsSetting(false); await syncChromeTabGroups(groups); assert.equal(getChromeGroupCount(), 0); }); test('syncChromeTabGroups handles empty tabs gracefully', async () => { resetChromeGroupState(); await saveChromeTabGroupsSetting(true); await syncChromeTabGroups([]); assert.equal(getChromeGroupCount(), 0); }); test('syncChromeTabGroups handles groups with no tabs', async () => { resetChromeGroupState(); await saveChromeTabGroupsSetting(true); await syncChromeTabGroups([{ domain: 'empty.com', tabs: [] }]); assert.equal(getChromeGroupCount(), 0); }); test('populateChromeGroupMap adds mappings to internal state', () => { resetChromeGroupState(); populateChromeGroupMap([ { virtualGroupKey: 'github.com', windowId: 1, chromeGroupId: 101 }, { virtualGroupKey: 'github.com', windowId: 2, chromeGroupId: 102 }, ]); assert.equal(getChromeGroupCount(), 1); }); test('queryExistingChromeGroups returns groups from chrome.tabGroups.query', async () => { globalThis.chrome.tabGroups.query = async () => [ { id: 1, title: 'Work', color: 'blue' }, { id: 2, title: 'Research', color: 'red' }, ]; const groups = await queryExistingChromeGroups(); assert.equal(groups.length, 2); assert.equal(groups[0].title, 'Work'); }); test('syncChromeTabGroups reuses chromeGroupMap populated by populateChromeGroupMap', async () => { resetChromeGroupState(); let lastGroupCall = null; globalThis.chrome.tabs.group = async (opts) => { lastGroupCall = opts; return 500; }; globalThis.chrome.tabGroups.update = async () => {}; globalThis.chrome.tabGroups.query = async () => [{ id: 500, title: 'GitHub', color: 'grey' }]; globalThis.chrome.tabs.query = async (opts) => []; await saveChromeTabGroupsSetting(true); // Pre-populate the mapping (simulating "pull from Chrome groups" scenario) populateChromeGroupMap([ { virtualGroupKey: 'github.com', windowId: 1, chromeGroupId: 500 }, ]); const groups = [ { domain: 'github.com', tabs: [{ id: 10, windowId: 1, url: 'https://github.com' }] }, ]; await syncChromeTabGroups(groups); // Should have reused the existing group — called with groupId (reuse path), not tabIds (create path) assert.equal(lastGroupCall.groupId, 500); assert.deepEqual(lastGroupCall.tabIds, [10]); }); test('syncChromeTabGroups only removes obsolete mappings in synced windows', async () => { resetChromeGroupState(); const ungroupedTabIds = []; globalThis.chrome.tabs.group = async (opts) => opts.groupId ?? 700; globalThis.chrome.tabs.ungroup = async (tabIds) => { ungroupedTabIds.push(...tabIds); }; globalThis.chrome.tabGroups.update = async () => {}; globalThis.chrome.tabGroups.query = async () => [ { id: 501, title: 'GitHub', color: 'grey', windowId: 1 }, { id: 502, title: 'GitHub', color: 'grey', windowId: 2 }, { id: 503, title: 'Docs', color: 'blue', windowId: 2 }, ]; globalThis.chrome.tabs.query = async (opts) => { if (opts?.groupId === 502) return [{ id: 202, windowId: 2 }]; if (opts?.groupId === 503) return [{ id: 203, windowId: 2 }]; return []; }; await saveChromeTabGroupsSetting(true); populateChromeGroupMap([ { virtualGroupKey: 'github.com', windowId: 1, chromeGroupId: 501 }, { virtualGroupKey: 'github.com', windowId: 2, chromeGroupId: 502 }, { virtualGroupKey: 'docs.example', windowId: 2, chromeGroupId: 503 }, ]); await syncChromeTabGroups([ { domain: 'github.com', tabs: [{ id: 101, windowId: 1, url: 'https://github.com' }] }, ]); assert.deepEqual(ungroupedTabIds, []); assert.equal(getChromeGroupCount(), 2); }); test('syncChromeTabGroups in import mode skips creating new groups for ungrouped tabs', async () => { resetChromeGroupState(); let createCalls = 0; let reuseCalls = 0; globalThis.chrome.tabs.group = async (opts) => { if (opts.groupId != null) { reuseCalls++; return opts.groupId; } createCalls++; return 600 + createCalls; }; globalThis.chrome.tabGroups.update = async () => {}; globalThis.chrome.tabGroups.query = async () => [{ id: 500, title: 'Work', color: 'blue' }]; globalThis.chrome.tabs.query = async (opts) => []; await saveChromeTabGroupsSetting(true); // Simulate import: populate chromeGroupMap with existing Chrome groups populateChromeGroupMap([ { virtualGroupKey: '__session_group__:g1', windowId: 1, chromeGroupId: 500 }, ]); // Enable import mode: only reuse, don't create setImportMode(true); const groups = [ { domain: '__session_group__:g1', label: 'Work', tabs: [{ id: 1, windowId: 1, url: 'https://a.com' }] }, { domain: 'github.com', tabs: [{ id: 5, windowId: 1, url: 'https://github.com' }] }, ]; await syncChromeTabGroups(groups); // Work group reused existing Chrome group assert.equal(reuseCalls, 1); // github.com was SKIPPED (import mode, no matching Chrome group) assert.equal(createCalls, 0); // Disable import mode and sync again — now github.com should get a new group setImportMode(false); await syncChromeTabGroups(groups); assert.equal(createCalls, 1); }); test('subscribeToChromeTabGroupChanges notifies on external Chrome group changes', async () => { resetChromeGroupState(); await saveChromeTabGroupsSetting(true); const events = []; const unsubscribe = subscribeToChromeTabGroupChanges(event => { events.push(event); }); globalThis.chrome.tabGroups.onUpdated.emit(501, { title: 'Work' }); assert.equal(events.length, 1); assert.equal(events[0].source, 'tabGroups.onUpdated'); unsubscribe(); }); test('subscribeToChromeTabGroupChanges ignores collapse-only group updates', async () => { resetChromeGroupState(); await saveChromeTabGroupsSetting(true); const events = []; const unsubscribe = subscribeToChromeTabGroupChanges(event => { events.push(event); }); globalThis.chrome.tabGroups.onUpdated.emit(501, { collapsed: true }); assert.equal(events.length, 0); unsubscribe(); }); test('subscribeToChromeTabGroupChanges notifies when a grouped tab is moved', async () => { resetChromeGroupState(); await saveChromeTabGroupsSetting(true); const events = []; globalThis.chrome.tabs.get = async (tabId) => ({ id: tabId, groupId: 501, windowId: 1, }); const unsubscribe = subscribeToChromeTabGroupChanges(event => { events.push(event); }); globalThis.chrome.tabs.onMoved.emit(42, { windowId: 1, fromIndex: 3, toIndex: 6 }); await new Promise(resolve => setTimeout(resolve, 0)); assert.equal(events.length, 1); assert.equal(events[0].source, 'tabs.onMoved'); assert.equal(events[0].tabId, 42); assert.equal(events[0].tab.groupId, 501); unsubscribe(); }); test('syncChromeTabGroupExpansionForTab expands target group and collapses sibling groups in same window', async () => { resetChromeGroupState(); await saveChromeTabGroupsSetting(true); const updateCalls = []; globalThis.chrome.tabGroups.query = async () => [ { id: 101, windowId: 1, collapsed: true }, { id: 102, windowId: 1, collapsed: false }, { id: 103, windowId: 2, collapsed: false }, ]; globalThis.chrome.tabGroups.update = async (id, opts) => { updateCalls.push({ id, ...opts }); }; await syncChromeTabGroupExpansionForTab({ groupId: 101, windowId: 1 }); assert.deepEqual(updateCalls, [ { id: 101, collapsed: false }, { id: 102, collapsed: true }, ]); }); test('syncChromeTabGroupExpansionForTab skips work when Chrome sync is disabled', async () => { resetChromeGroupState(); await saveChromeTabGroupsSetting(false); let queryCount = 0; globalThis.chrome.tabGroups.query = async () => { queryCount++; return []; }; await syncChromeTabGroupExpansionForTab({ groupId: 101, windowId: 1 }); assert.equal(queryCount, 0); }); test('collapseChromeTabGroupsInWindow collapses every expanded group in the target window', async () => { resetChromeGroupState(); await saveChromeTabGroupsSetting(true); const updateCalls = []; globalThis.chrome.tabGroups.query = async () => [ { id: 101, windowId: 1, collapsed: false }, { id: 102, windowId: 1, collapsed: true }, { id: 103, windowId: 2, collapsed: false }, ]; globalThis.chrome.tabGroups.update = async (id, opts) => { updateCalls.push({ id, ...opts }); }; await collapseChromeTabGroupsInWindow(1); assert.deepEqual(updateCalls, [ { id: 101, collapsed: true }, ]); });