diff --git a/background/steps/open-chatgpt.js b/background/steps/open-chatgpt.js index 248cdd5..91d03e5 100644 --- a/background/steps/open-chatgpt.js +++ b/background/steps/open-chatgpt.js @@ -9,14 +9,6 @@ 'auth0.openai.com', 'accounts.openai.com', ]; - const STEP1_COOKIE_CLEAR_ORIGINS = [ - 'https://chatgpt.com', - 'https://chat.openai.com', - 'https://auth.openai.com', - 'https://auth0.openai.com', - 'https://accounts.openai.com', - 'https://openai.com', - ]; function normalizeCookieDomainForStep1(domain) { return String(domain || '').trim().replace(/^\.+/, '').toLowerCase(); @@ -30,6 +22,16 @@ )); } + function buildStep1CookieKey(cookie, fallbackStoreId = '') { + return [ + cookie?.storeId || fallbackStoreId || '', + cookie?.domain || '', + cookie?.path || '', + cookie?.name || '', + cookie?.partitionKey ? JSON.stringify(cookie.partitionKey) : '', + ].join('|'); + } + function buildStep1CookieRemovalUrl(cookie) { const host = normalizeCookieDomainForStep1(cookie?.domain); const rawPath = String(cookie?.path || '/'); @@ -51,22 +53,39 @@ : [{ id: undefined }]; const cookies = []; const seen = new Set(); + const queryDomains = Array.from( + new Set( + STEP1_COOKIE_CLEAR_DOMAINS + .map((domain) => normalizeCookieDomainForStep1(domain)) + .filter(Boolean) + ) + ); for (const store of stores) { const storeId = store?.id; - const batch = await chromeApi.cookies.getAll(storeId ? { storeId } : {}); - for (const cookie of batch || []) { - if (!shouldClearStep1Cookie(cookie)) continue; - const key = [ - cookie.storeId || storeId || '', - cookie.domain || '', - cookie.path || '', - cookie.name || '', - cookie.partitionKey ? JSON.stringify(cookie.partitionKey) : '', - ].join('|'); - if (seen.has(key)) continue; - seen.add(key); - cookies.push(cookie); + for (const domain of queryDomains) { + let batch = []; + try { + batch = await chromeApi.cookies.getAll( + storeId + ? { storeId, domain } + : { domain } + ); + } catch (error) { + console.warn('[MultiPage:step1] query cookies failed', { + storeId: storeId || '', + domain, + message: getStep1ErrorMessage(error), + }); + continue; + } + for (const cookie of batch || []) { + if (!shouldClearStep1Cookie(cookie)) continue; + const key = buildStep1CookieKey(cookie, storeId); + if (seen.has(key)) continue; + seen.add(key); + cookies.push(cookie); + } } } @@ -112,6 +131,7 @@ return; } + const startedAt = Date.now(); await addLog('步骤 1:打开 ChatGPT 官网前清理 ChatGPT / OpenAI cookies...', 'info'); const cookies = await collectStep1Cookies(chromeApi); let removedCount = 0; @@ -121,18 +141,8 @@ } } - if (chromeApi.browsingData?.removeCookies) { - try { - await chromeApi.browsingData.removeCookies({ - since: 0, - origins: STEP1_COOKIE_CLEAR_ORIGINS, - }); - } catch (error) { - await addLog(`步骤 1:browsingData 补扫 cookies 失败:${getStep1ErrorMessage(error)}`, 'warn'); - } - } - - await addLog(`步骤 1:已清理 ${removedCount} 个 ChatGPT / OpenAI cookies。`, 'ok'); + const elapsedMs = Date.now() - startedAt; + await addLog(`步骤 1:已清理 ${removedCount} 个 ChatGPT / OpenAI cookies(耗时 ${elapsedMs}ms)。`, 'ok'); } async function executeStep1() { diff --git a/background/tab-runtime.js b/background/tab-runtime.js index 7ecc9f2..0844096 100644 --- a/background/tab-runtime.js +++ b/background/tab-runtime.js @@ -290,7 +290,7 @@ } async function closeConflictingTabsForSource(source, currentUrl, options = {}) { - const { excludeTabIds = [] } = options; + const { excludeTabIds = [], preserveActiveTab = false } = options; const excluded = new Set(excludeTabIds.filter((id) => Number.isInteger(id))); const state = await getState(); const lastUrl = getSourceMapValue(state.sourceLastUrls, source); @@ -299,8 +299,12 @@ if (!referenceUrls.length) return; const tabs = await queryTabsInAutomationWindow({}); + const activeTabId = preserveActiveTab + ? tabs.find((tab) => Boolean(tab?.active) && Number.isInteger(tab?.id))?.id || null + : null; const matchedIds = tabs .filter((tab) => Number.isInteger(tab.id) && !excluded.has(tab.id)) + .filter((tab) => !(preserveActiveTab && activeTabId !== null && tab.id === activeTabId)) .filter((tab) => referenceUrls.some((refUrl) => matchesSourceUrlFamily(source, tab.url, refUrl))) .map((tab) => tab.id); @@ -728,8 +732,11 @@ async function reuseOrCreateTab(source, url, options = {}) { if (options.forceNew) { - await closeConflictingTabsForSource(source, url); const tab = await createAutomationTab({ url, active: true }, options); + await closeConflictingTabsForSource(source, url, { + excludeTabIds: [tab.id], + preserveActiveTab: false, + }); if (options.inject) { await waitForTabUpdateComplete(tab.id); @@ -755,7 +762,10 @@ const alive = await isTabAlive(source); if (alive) { const tabId = await getTabId(source); - await closeConflictingTabsForSource(source, url, { excludeTabIds: [tabId] }); + await closeConflictingTabsForSource(source, url, { + excludeTabIds: [tabId], + preserveActiveTab: false, + }); const currentTab = await chrome.tabs.get(tabId); const sameUrl = currentTab.url === url; const shouldReloadOnReuse = sameUrl && options.reloadIfSameUrl; @@ -836,8 +846,11 @@ return tabId; } - await closeConflictingTabsForSource(source, url); const tab = await createAutomationTab({ url, active: true }, options); + await closeConflictingTabsForSource(source, url, { + excludeTabIds: [tab.id], + preserveActiveTab: false, + }); if (options.inject) { await waitForTabUpdateComplete(tab.id); diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index e91ae51..10bcb4d 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -9950,15 +9950,31 @@ function updateSaveButtonState() { btnSaveSettings.textContent = settingsSaveInFlight ? '保存中' : '保存'; } +function isEditableElementInSettingsCard(element) { + if (!element || !(element instanceof Element)) { + return false; + } + const tagName = String(element.tagName || '').toLowerCase(); + const isEditableInput = ( + tagName === 'textarea' + || (tagName === 'input' && !['checkbox', 'radio', 'button', 'submit', 'reset', 'range', 'file', 'color'].includes(String(element.type || '').toLowerCase())) + || Boolean(element.isContentEditable) + ); + if (!isEditableInput) { + return false; + } + return !settingsCard || settingsCard.contains(element); +} + function scheduleSettingsAutoSave() { clearTimeout(settingsAutoSaveTimer); settingsAutoSaveTimer = setTimeout(() => { - saveSettings({ silent: true }).catch(() => { }); - }, 500); + saveSettings({ silent: true, source: 'autosave' }).catch(() => { }); + }, 1200); } async function saveSettings(options = {}) { - const { silent = false, force = false } = options; + const { silent = false, force = false, source = '' } = options; clearTimeout(settingsAutoSaveTimer); if (!force && !settingsDirty && !settingsSaveInFlight && silent) { @@ -9970,6 +9986,14 @@ async function saveSettings(options = {}) { settingsSaveInFlight = true; updateSaveButtonState(); + const shouldSkipStateApplyForFocusedEditor = (() => { + if (!silent || source !== 'autosave') { + return false; + } + const activeEl = typeof document !== 'undefined' ? document.activeElement : null; + return isEditableElementInSettingsCard(activeEl); + })(); + try { const response = await chrome.runtime.sendMessage({ type: 'SAVE_SETTING', @@ -9982,7 +10006,12 @@ async function saveSettings(options = {}) { } if (response?.state && saveRevision === settingsSaveRevision) { - applySettingsState(response.state); + if (shouldSkipStateApplyForFocusedEditor) { + syncLatestState(response.state); + markSettingsDirty(false); + } else { + applySettingsState(response.state); + } } else { syncLatestState(payload); if (saveRevision === settingsSaveRevision) { @@ -16579,6 +16608,20 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { case 'DATA_UPDATED': { syncLatestState(message.payload); + const activeSettingsEditor = typeof document !== 'undefined' ? document.activeElement : null; + const shouldDeferDataUpdatedUiApply = settingsSaveInFlight + && isEditableElementInSettingsCard(activeSettingsEditor); + if (shouldDeferDataUpdatedUiApply) { + // Avoid overwriting the focused editor while the current save request + // is still in flight; otherwise typing can be interrupted by DATA_UPDATED. + if (message.payload.operationDelayEnabled !== undefined && typeof applyOperationDelayState === 'function') { + applyOperationDelayState(message.payload); + } + updateAccountRunHistorySettingsUI(); + renderContributionMode(); + void syncPlusManualConfirmationDialog(); + break; + } if (message.payload.operationDelayEnabled !== undefined && typeof applyOperationDelayState === 'function') { applyOperationDelayState(message.payload); } diff --git a/tests/background-step1-cookie-cleanup.test.js b/tests/background-step1-cookie-cleanup.test.js new file mode 100644 index 0000000..3dff752 --- /dev/null +++ b/tests/background-step1-cookie-cleanup.test.js @@ -0,0 +1,123 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +function loadStep1Module() { + const source = fs.readFileSync('background/steps/open-chatgpt.js', 'utf8'); + const globalScope = {}; + return new Function('self', `${source}; return self.MultiPageBackgroundStep1;`)(globalScope); +} + +test('step 1 cookie cleanup queries target domains and skips browsingData sweep when direct removals succeed', async () => { + const api = loadStep1Module(); + const events = { + getAllCalls: [], + removedCookies: [], + browsingDataCalls: [], + openedSteps: [], + completedNodes: [], + }; + + const chromeApi = { + cookies: { + getAllCookieStores: async () => [{ id: 'store-a' }], + getAll: async (query) => { + events.getAllCalls.push(query); + if (query?.domain === 'chatgpt.com') { + return [ + { domain: '.chatgpt.com', path: '/', name: 'session', storeId: 'store-a' }, + ]; + } + if (query?.domain === 'openai.com') { + return [ + { + domain: '.openai.com', + path: '/', + name: 'shared', + storeId: 'store-a', + partitionKey: { topLevelSite: 'https://chatgpt.com' }, + }, + ]; + } + return []; + }, + remove: async (details) => { + events.removedCookies.push(details); + return details; + }, + }, + browsingData: { + removeCookies: async (details) => { + events.browsingDataCalls.push(details); + }, + }, + }; + + const executor = api.createStep1Executor({ + addLog: async () => {}, + chrome: chromeApi, + openSignupEntryTab: async (step) => { + events.openedSteps.push(step); + }, + completeNodeFromBackground: async (nodeId) => { + events.completedNodes.push(nodeId); + }, + }); + + await executor.executeStep1(); + + assert.ok(events.getAllCalls.length > 0, 'should query cookies at least once'); + assert.ok(events.getAllCalls.every((entry) => typeof entry?.domain === 'string' && entry.domain.length > 0)); + assert.deepStrictEqual(events.removedCookies, [ + { + url: 'https://chatgpt.com/', + name: 'session', + storeId: 'store-a', + }, + { + url: 'https://openai.com/', + name: 'shared', + storeId: 'store-a', + partitionKey: { topLevelSite: 'https://chatgpt.com' }, + }, + ]); + assert.deepStrictEqual(events.browsingDataCalls, []); + assert.deepStrictEqual(events.openedSteps, [1]); + assert.deepStrictEqual(events.completedNodes, ['open-chatgpt']); +}); + +test('step 1 cookie cleanup skips browsingData sweep when no direct cookie is removed', async () => { + const api = loadStep1Module(); + const events = { + removedCookies: 0, + browsingDataCalls: [], + }; + + const chromeApi = { + cookies: { + getAllCookieStores: async () => [{ id: 'store-a' }], + getAll: async () => [], + remove: async () => { + events.removedCookies += 1; + return null; + }, + }, + browsingData: { + removeCookies: async (details) => { + events.browsingDataCalls.push(details); + }, + }, + }; + + const executor = api.createStep1Executor({ + addLog: async () => {}, + chrome: chromeApi, + openSignupEntryTab: async () => {}, + completeNodeFromBackground: async () => {}, + }); + + await executor.executeStep1(); + + assert.equal(events.removedCookies, 0); + assert.equal(events.browsingDataCalls.length, 0); +}); diff --git a/tests/background-tab-runtime-module.test.js b/tests/background-tab-runtime-module.test.js index 080e8b0..fe19522 100644 --- a/tests/background-tab-runtime-module.test.js +++ b/tests/background-tab-runtime-module.test.js @@ -383,6 +383,61 @@ test('tab runtime opens new automation tabs in the locked window', async () => { assert.equal(created[0].windowId, 100); }); +test('tab runtime force-new opens replacement before removing the active stale source tab', async () => { + const source = fs.readFileSync('background/tab-runtime.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundTabRuntime;`)(globalScope); + const events = []; + let tabs = [ + { id: 1, active: true, windowId: 100, url: 'https://chatgpt.com/' }, + ]; + const runtime = api.createTabRuntime({ + LOG_PREFIX: '[test]', + addLog: async () => {}, + chrome: { + tabs: { + create: async (payload) => { + events.push({ type: 'create', payload }); + const tab = { id: 2, active: true, windowId: payload.windowId, url: payload.url }; + tabs = tabs.map((item) => ({ ...item, active: false })).concat(tab); + return tab; + }, + get: async (tabId) => tabs.find((tab) => tab.id === tabId), + query: async () => tabs, + remove: async (ids) => { + events.push({ type: 'remove', ids }); + tabs = tabs.filter((tab) => !ids.includes(tab.id)); + }, + }, + }, + getSourceLabel: (sourceName) => sourceName || 'unknown', + getState: async () => ({ + automationWindowId: 100, + sourceLastUrls: { 'signup-page': 'https://chatgpt.com/' }, + tabRegistry: {}, + }), + matchesSourceUrlFamily: (sourceName, candidateUrl) => ( + sourceName === 'signup-page' + && /chatgpt\.com|auth\.openai\.com/.test(String(candidateUrl || '')) + ), + setState: async () => {}, + throwIfStopped: () => {}, + }); + + const tabId = await runtime.reuseOrCreateTab('signup-page', 'https://auth.openai.com/authorize', { + forceNew: true, + }); + + assert.equal(tabId, 2); + assert.deepEqual(events, [ + { type: 'create', payload: { url: 'https://auth.openai.com/authorize', active: true, windowId: 100 } }, + { type: 'remove', ids: [1] }, + ]); + assert.deepEqual(tabs, [ + { id: 2, active: true, windowId: 100, url: 'https://auth.openai.com/authorize' }, + ]); +}); + test('tab runtime scopes tab queries to the locked automation window', async () => { const source = fs.readFileSync('background/tab-runtime.js', 'utf8'); const globalScope = {}; diff --git a/tests/sidepanel-settings-autosave.test.js b/tests/sidepanel-settings-autosave.test.js new file mode 100644 index 0000000..5a826ad --- /dev/null +++ b/tests/sidepanel-settings-autosave.test.js @@ -0,0 +1,144 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('sidepanel/sidepanel.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map((marker) => source.indexOf(marker)) + .find((index) => index >= 0); + assert.notEqual(start, -1, `missing ${name}`); + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let i = start; i < source.length; i += 1) { + const ch = source[i]; + if (ch === '(') parenDepth += 1; + if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) signatureEnded = true; + } + if (ch === '{' && signatureEnded) { + braceStart = i; + break; + } + } + + let depth = 0; + for (let i = braceStart; i < source.length; i += 1) { + const ch = source[i]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + return source.slice(start, i + 1); + } + } + } + throw new Error(`unterminated ${name}`); +} + +test('focused settings editor still autosaves without repainting the focused field', async () => { + const bundle = [ + extractFunction('isEditableElementInSettingsCard'), + extractFunction('scheduleSettingsAutoSave'), + extractFunction('saveSettings'), + ].join('\n'); + + const api = new Function(` +class MockElement { + constructor(tagName, type = '') { + this.tagName = tagName; + this.type = type; + this.isContentEditable = false; + } +} +const Element = MockElement; +const focusedInput = new MockElement('input', 'text'); +const settingsCard = { contains: (element) => element === focusedInput }; +const document = { activeElement: focusedInput }; +let settingsAutoSaveTimer = null; +let scheduled = null; +let clearedTimer = null; +let settingsDirty = true; +let settingsSaveInFlight = false; +let settingsSaveRevision = 1; +const messages = []; +const syncedStates = []; +const dirtyMarks = []; +let applyCalls = 0; +function setTimeout(fn, delay) { + scheduled = { fn, delay }; + return 71; +} +function clearTimeout(value) { + clearedTimer = value; +} +function collectSettingsPayload() { + return { vpsUrl: 'typed-value' }; +} +function syncLatestState(state) { + syncedStates.push(state); +} +function markSettingsDirty(value = true) { + settingsDirty = value; + if (value) settingsSaveRevision += 1; + dirtyMarks.push(value); +} +function applySettingsState() { + applyCalls += 1; +} +function updateSaveButtonState() {} +function updatePanelModeUI() {} +function updateMailProviderUI() {} +function updateButtonStates() {} +const chrome = { + runtime: { + async sendMessage(message) { + messages.push(message); + return { state: { vpsUrl: message.payload.vpsUrl } }; + }, + }, +}; +${bundle} +return { + scheduleSettingsAutoSave, + async fireScheduled() { + await scheduled.fn(); + }, + getSnapshot() { + return { + applyCalls, + clearedTimer, + dirtyMarks, + messages, + scheduledDelay: scheduled?.delay, + syncedStates, + }; + }, +}; +`)(); + + api.scheduleSettingsAutoSave(); + let snapshot = api.getSnapshot(); + assert.equal(snapshot.scheduledDelay, 1200); + assert.equal(snapshot.messages.length, 0); + + await api.fireScheduled(); + snapshot = api.getSnapshot(); + + assert.equal(snapshot.clearedTimer, 71); + assert.equal(snapshot.applyCalls, 0); + assert.deepEqual(snapshot.messages, [ + { + type: 'SAVE_SETTING', + source: 'sidepanel', + payload: { vpsUrl: 'typed-value' }, + }, + ]); + assert.deepEqual(snapshot.syncedStates, [{ vpsUrl: 'typed-value' }]); + assert.deepEqual(snapshot.dirtyMarks, [false]); +}); diff --git a/tests/signup-page-tab-cleanup.test.js b/tests/signup-page-tab-cleanup.test.js index c6e76bc..5b021f6 100644 --- a/tests/signup-page-tab-cleanup.test.js +++ b/tests/signup-page-tab-cleanup.test.js @@ -209,6 +209,25 @@ return { assert.deepStrictEqual(snapshot.removedBatches, [[11, 12]]); assert.strictEqual(snapshot.currentState.tabRegistry['signup-page'], null); + api.reset({ + tabs: [ + { id: 21, active: true, url: 'https://chatgpt.com/' }, + { id: 22, active: false, url: 'https://auth.openai.com/authorize?client_id=test' }, + ], + state: { + sourceLastUrls: { + 'signup-page': 'https://auth.openai.com/authorize?client_id=test', + }, + tabRegistry: {}, + }, + }); + + await api.closeConflictingTabsForSource('signup-page', 'https://chatgpt.com/'); + + snapshot = api.snapshot(); + assert.deepStrictEqual(snapshot.removedBatches, [[21, 22]]); + assert.deepStrictEqual(snapshot.currentTabs, []); + console.log('signup page tab cleanup tests passed'); })().catch((error) => { console.error(error);