diff --git a/background.js b/background.js index d5aaa81..012d897 100644 --- a/background.js +++ b/background.js @@ -234,18 +234,22 @@ function isLocalhostOAuthCallbackUrl(rawUrl) { if (!parsed) return false; if (!['http:', 'https:'].includes(parsed.protocol)) return false; if (!['localhost', '127.0.0.1'].includes(parsed.hostname)) return false; - if (parsed.pathname !== '/auth/callback') return false; + if (!['/auth/callback', '/codex/callback'].includes(parsed.pathname)) return false; const code = (parsed.searchParams.get('code') || '').trim(); const state = (parsed.searchParams.get('state') || '').trim(); return Boolean(code && state); } -function buildLocalhostCleanupPrefix(rawUrl) { - if (!isLocalhostOAuthCallbackUrl(rawUrl)) return ''; - +function isLocalCpaUrl(rawUrl) { const parsed = parseUrlSafely(rawUrl); - return parsed ? `${parsed.origin}/auth` : ''; + if (!parsed) return false; + if (!['http:', 'https:'].includes(parsed.protocol)) return false; + return ['localhost', '127.0.0.1'].includes(parsed.hostname); +} + +function shouldBypassStep9ForLocalCpa(state) { + return Boolean(state?.localhostUrl) && isLocalCpaUrl(state?.vpsUrl); } function matchesSourceUrlFamily(source, candidateUrl, referenceUrl) { @@ -312,21 +316,43 @@ async function closeConflictingTabsForSource(source, currentUrl, options = {}) { await addLog(`已关闭 ${matchedIds.length} 个旧的${getSourceLabel(source)}标签页。`, 'info'); } -async function closeTabsByUrlPrefix(prefix, options = {}) { - if (!prefix) return 0; +function isLocalhostOAuthCallbackTabMatch(callbackUrl, candidateUrl) { + if (!isLocalhostOAuthCallbackUrl(callbackUrl) || !isLocalhostOAuthCallbackUrl(candidateUrl)) { + return false; + } + + const callback = parseUrlSafely(callbackUrl); + const candidate = parseUrlSafely(candidateUrl); + if (!callback || !candidate) return false; + + return callback.origin === candidate.origin + && callback.pathname === candidate.pathname + && callback.searchParams.get('code') === candidate.searchParams.get('code') + && callback.searchParams.get('state') === candidate.searchParams.get('state'); +} + +async function closeLocalhostCallbackTabs(callbackUrl, options = {}) { + if (!isLocalhostOAuthCallbackUrl(callbackUrl)) return 0; const { excludeTabIds = [] } = options; const excluded = new Set(excludeTabIds.filter(id => Number.isInteger(id))); const tabs = await chrome.tabs.query({}); const matchedIds = tabs .filter((tab) => Number.isInteger(tab.id) && !excluded.has(tab.id)) - .filter((tab) => typeof tab.url === 'string' && tab.url.startsWith(prefix)) + .filter((tab) => isLocalhostOAuthCallbackTabMatch(callbackUrl, tab.url)) .map((tab) => tab.id); if (!matchedIds.length) return 0; await chrome.tabs.remove(matchedIds).catch(() => { }); - await addLog(`已关闭 ${matchedIds.length} 个匹配 ${prefix} 的 localhost 残留标签页。`, 'info'); + + const registry = await getTabRegistry(); + if (registry['signup-page']?.tabId && matchedIds.includes(registry['signup-page'].tabId)) { + registry['signup-page'] = null; + await setState({ tabRegistry: registry }); + } + + await addLog(`已关闭 ${matchedIds.length} 个匹配当前 OAuth callback 的 localhost 残留标签页。`, 'info'); return matchedIds.length; } @@ -871,6 +897,26 @@ async function addLog(message, level = 'info') { chrome.runtime.sendMessage({ type: 'LOG_ENTRY', payload: entry }).catch(() => { }); } +function getStep8CallbackUrlFromNavigation(details, signupTabId) { + if (!Number.isInteger(signupTabId) || !details) return ''; + if (details.tabId !== signupTabId) return ''; + if (details.frameId !== 0) return ''; + return isLocalhostOAuthCallbackUrl(details.url) ? details.url : ''; +} + +function getStep8CallbackUrlFromTabUpdate(tabId, changeInfo, tab, signupTabId) { + if (!Number.isInteger(signupTabId) || tabId !== signupTabId) return ''; + + const candidates = [changeInfo?.url, tab?.url]; + for (const candidate of candidates) { + if (isLocalhostOAuthCallbackUrl(candidate)) { + return candidate; + } + } + + return ''; +} + function getSourceLabel(source) { const labels = { 'sidepanel': '侧边栏', @@ -1394,9 +1440,8 @@ async function handleStepData(step, payload) { } break; case 9: { - const localhostPrefix = buildLocalhostCleanupPrefix(payload.localhostUrl); - if (localhostPrefix) { - await closeTabsByUrlPrefix(localhostPrefix); + if (payload.localhostUrl) { + await closeLocalhostCallbackTabs(payload.localhostUrl); } break; } @@ -1475,10 +1520,8 @@ async function requestStop(options = {}) { stopRequested = true; cancelPendingCommands(); - if (webNavListener) { - chrome.webNavigation.onBeforeNavigate.removeListener(webNavListener); - webNavListener = null; - } + cleanupStep8NavigationListeners(); + rejectPendingStep8(new Error(STOP_ERROR_MESSAGE)); await addLog(logMessage, 'warn'); await broadcastStopToContentScripts(); @@ -2558,6 +2601,9 @@ async function executeStep7(state) { // ============================================================ let webNavListener = null; +let webNavCommittedListener = null; +let step8TabUpdatedListener = null; +let step8PendingReject = null; const STEP8_CLICK_EFFECT_TIMEOUT_MS = 3500; const STEP8_CLICK_RETRY_DELAY_MS = 500; const STEP8_READY_WAIT_TIMEOUT_MS = 30000; @@ -2569,6 +2615,34 @@ const STEP8_STRATEGIES = [ { mode: 'debugger', label: 'debugger click retry' }, ]; +function cleanupStep8NavigationListeners() { + if (webNavListener) { + chrome.webNavigation.onBeforeNavigate.removeListener(webNavListener); + webNavListener = null; + } + if (webNavCommittedListener) { + chrome.webNavigation.onCommitted.removeListener(webNavCommittedListener); + webNavCommittedListener = null; + } + if (step8TabUpdatedListener) { + chrome.tabs.onUpdated.removeListener(step8TabUpdatedListener); + step8TabUpdatedListener = null; + } +} + +function rejectPendingStep8(error) { + if (!step8PendingReject) return; + const reject = step8PendingReject; + step8PendingReject = null; + reject(error); +} + +function throwIfStep8SettledOrStopped(isSettled = false) { + if (isSettled || stopRequested) { + throw new Error(STOP_ERROR_MESSAGE); + } +} + async function getStep8PageState(tabId, responseTimeoutMs = 1500) { try { const result = await sendTabMessageWithTimeout(tabId, 'signup-page', { @@ -2703,13 +2777,11 @@ async function executeStep8(state) { let signupTabId = null; const cleanupListener = () => { - if (webNavListener) { - chrome.webNavigation.onBeforeNavigate.removeListener(webNavListener); - webNavListener = null; - } + cleanupStep8NavigationListeners(); + step8PendingReject = null; }; - const failStep8 = (error) => { + const rejectStep8 = (error) => { if (resolved) return; resolved = true; clearTimeout(timeout); @@ -2717,44 +2789,67 @@ async function executeStep8(state) { reject(error); }; + const finalizeStep8Callback = (callbackUrl) => { + if (resolved || !callbackUrl) return; + + resolved = true; + cleanupListener(); + clearTimeout(timeout); + + addLog(`步骤 8:已捕获 localhost 地址:${callbackUrl}`, 'ok').then(() => { + return completeStepFromBackground(8, { localhostUrl: callbackUrl }); + }).then(() => { + resolve(); + }).catch((err) => { + reject(err); + }); + }; + const timeout = setTimeout(() => { - failStep8(new Error('120 秒内未捕获到 localhost 回调跳转,步骤 8 已持续重试点击“继续”但页面仍未完成授权。')); + rejectStep8(new Error('120 秒内未捕获到 localhost 回调跳转,步骤 8 的点击可能被拦截了。')); }, 120000); + step8PendingReject = (error) => { + rejectStep8(error); + }; + webNavListener = (details) => { - if (resolved || !signupTabId) return; - if (details.tabId !== signupTabId) return; - if (details.frameId !== 0) return; - if (isLocalhostOAuthCallbackUrl(details.url)) { - console.log(LOG_PREFIX, `已捕获 localhost OAuth 回调:${details.url}`); - resolved = true; - cleanupListener(); - clearTimeout(timeout); - addLog(`步骤 8:已捕获 localhost 地址:${details.url}`, 'ok').then(() => { - return completeStepFromBackground(8, { localhostUrl: details.url }); - }).then(() => { - resolve(); - }).catch((err) => { - reject(err); - }); - } + const callbackUrl = getStep8CallbackUrlFromNavigation(details, signupTabId); + finalizeStep8Callback(callbackUrl); + }; + + webNavCommittedListener = (details) => { + const callbackUrl = getStep8CallbackUrlFromNavigation(details, signupTabId); + finalizeStep8Callback(callbackUrl); + }; + + step8TabUpdatedListener = (tabId, changeInfo, tab) => { + const callbackUrl = getStep8CallbackUrlFromTabUpdate(tabId, changeInfo, tab, signupTabId); + finalizeStep8Callback(callbackUrl); }; (async () => { try { + throwIfStep8SettledOrStopped(resolved); signupTabId = await getTabId('signup-page'); - if (signupTabId) { + throwIfStep8SettledOrStopped(resolved); + + if (signupTabId && await isTabAlive('signup-page')) { await chrome.tabs.update(signupTabId, { active: true }); - await addLog('步骤 8:已切回认证页,准备循环确认“继续”按钮直到页面真正跳转...'); + await addLog('步骤 8:已切回认证页,正在准备调试器点击...'); } else { signupTabId = await reuseOrCreateTab('signup-page', state.oauthUrl); - await addLog('步骤 8:已重新打开认证页,准备循环确认“继续”按钮直到页面真正跳转...'); + await addLog('步骤 8:已重新打开认证页,正在准备调试器点击...'); } + throwIfStep8SettledOrStopped(resolved); chrome.webNavigation.onBeforeNavigate.addListener(webNavListener); + chrome.webNavigation.onCommitted.addListener(webNavCommittedListener); + chrome.tabs.onUpdated.addListener(step8TabUpdatedListener); let attempt = 0; while (!resolved) { + throwIfStep8SettledOrStopped(resolved); const pageState = await waitForStep8Ready(signupTabId); if (!pageState?.consentReady) { await sleepWithStop(STEP8_CLICK_RETRY_DELAY_MS); @@ -2769,9 +2864,8 @@ async function executeStep8(state) { if (strategy.mode === 'debugger') { const clickTarget = await prepareStep8DebuggerClick(); - if (!resolved) { - await clickWithDebugger(signupTabId, clickTarget?.rect); - } + throwIfStep8SettledOrStopped(resolved); + await clickWithDebugger(signupTabId, clickTarget?.rect); } else { await triggerStep8ContentStrategy(strategy.strategy); } @@ -2794,7 +2888,7 @@ async function executeStep8(state) { await sleepWithStop(STEP8_CLICK_RETRY_DELAY_MS); } } catch (err) { - failStep8(err); + rejectStep8(err); } })(); }); @@ -2815,6 +2909,15 @@ async function executeStep9(state) { throw new Error('尚未填写 CPA 地址,请先在侧边栏输入。'); } + if (shouldBypassStep9ForLocalCpa(state)) { + await addLog('步骤 9:检测到本地 CPA,步骤 8 完成后已自动添加,无需重复提交回调地址。', 'info'); + await completeStepFromBackground(9, { + localhostUrl: state.localhostUrl, + verifiedStatus: 'local-auto', + }); + return; + } + await addLog('步骤 9:正在打开 CPA 面板...'); const injectFiles = ['content/utils.js', 'content/vps-panel.js']; diff --git a/content/vps-panel.js b/content/vps-panel.js index 0b7ca49..7f5a2e5 100644 --- a/content/vps-panel.js +++ b/content/vps-panel.js @@ -169,7 +169,7 @@ function isLocalhostOAuthCallbackUrl(rawUrl) { if (!parsed) return false; if (!['http:', 'https:'].includes(parsed.protocol)) return false; if (!['localhost', '127.0.0.1'].includes(parsed.hostname)) return false; - if (parsed.pathname !== '/auth/callback') return false; + if (!['/auth/callback', '/codex/callback'].includes(parsed.pathname)) return false; const code = (parsed.searchParams.get('code') || '').trim(); const state = (parsed.searchParams.get('state') || '').trim(); diff --git a/tests/step8-callback-handling.test.js b/tests/step8-callback-handling.test.js new file mode 100644 index 0000000..6c01ea9 --- /dev/null +++ b/tests/step8-callback-handling.test.js @@ -0,0 +1,104 @@ +const assert = require('assert'); +const fs = require('fs'); + +const source = fs.readFileSync('background.js', 'utf8'); + +function extractFunction(name) { + const start = source.indexOf(`function ${name}(`); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + const braceStart = source.indexOf('{', start); + let depth = 0; + let end = braceStart; + for (; end < source.length; end++) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +const bundle = [ + extractFunction('parseUrlSafely'), + extractFunction('isLocalhostOAuthCallbackUrl'), + extractFunction('getStep8CallbackUrlFromNavigation'), + extractFunction('getStep8CallbackUrlFromTabUpdate'), +].join('\n'); + +const api = new Function(`${bundle}; return { getStep8CallbackUrlFromNavigation, getStep8CallbackUrlFromTabUpdate };`)(); + +const callbackUrl = 'http://127.0.0.1:8317/codex/callback?code=abc&state=xyz'; + +assert.strictEqual( + api.getStep8CallbackUrlFromNavigation({ + tabId: 123, + frameId: 0, + url: callbackUrl, + }, 123), + callbackUrl, + '应识别 onCommitted/onBeforeNavigate 命中的 callback' +); + +assert.strictEqual( + api.getStep8CallbackUrlFromNavigation({ + tabId: 123, + frameId: 1, + url: callbackUrl, + }, 123), + '', + '子 frame 不应命中 callback' +); + +assert.strictEqual( + api.getStep8CallbackUrlFromNavigation({ + tabId: 999, + frameId: 0, + url: callbackUrl, + }, 123), + '', + '非 signup tab 不应命中 callback' +); + +assert.strictEqual( + api.getStep8CallbackUrlFromTabUpdate( + 123, + { url: callbackUrl }, + { url: callbackUrl }, + 123 + ), + callbackUrl, + 'tabs.onUpdated 应能从 changeInfo.url 捕获 callback' +); + +assert.strictEqual( + api.getStep8CallbackUrlFromTabUpdate( + 123, + {}, + { url: callbackUrl }, + 123 + ), + callbackUrl, + 'tabs.onUpdated 应能从 tab.url 兜底捕获 callback' +); + +assert.strictEqual( + api.getStep8CallbackUrlFromTabUpdate( + 999, + { url: callbackUrl }, + { url: callbackUrl }, + 123 + ), + '', + '非 signup tab 的 tabs.onUpdated 不应命中 callback' +); + +console.log('step8 callback handling tests passed'); diff --git a/tests/step8-debugger-stop.test.js b/tests/step8-debugger-stop.test.js new file mode 100644 index 0000000..306b8a5 --- /dev/null +++ b/tests/step8-debugger-stop.test.js @@ -0,0 +1,109 @@ +const assert = require('assert'); +const fs = require('fs'); + +const source = fs.readFileSync('background.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map(marker => source.indexOf(marker)) + .find(index => index >= 0); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let i = start; i < source.length; i++) { + const ch = source[i]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = i; + break; + } + } + if (braceStart < 0) { + throw new Error(`missing body for function ${name}`); + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end++) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +const bundle = [ + extractFunction('throwIfStopped'), + extractFunction('clickWithDebugger'), +].join('\n'); + +const api = new Function(` +let stopRequested = false; +const STOP_ERROR_MESSAGE = '流程已被用户停止。'; +const commands = []; +let attachCount = 0; +let detachCount = 0; + +const chrome = { + debugger: { + async attach(target, version) { + attachCount += 1; + }, + async sendCommand(target, command, payload) { + commands.push(command); + if (command === 'Page.bringToFront') { + stopRequested = true; + } + }, + async detach(target) { + detachCount += 1; + }, + }, +}; + +${bundle} + +return { + clickWithDebugger, + snapshot() { + return { + commands, + attachCount, + detachCount, + }; + }, +}; +`)(); + +(async () => { + const error = await api.clickWithDebugger(123, { centerX: 10, centerY: 20 }).catch((err) => err); + const state = api.snapshot(); + + assert.strictEqual(error?.message, '流程已被用户停止。', 'debugger 点击过程中收到 Stop 后应终止'); + assert.deepStrictEqual(state.commands, ['Page.bringToFront'], 'Stop 后不应继续发送鼠标事件'); + assert.strictEqual(state.attachCount, 1, '应先附加 debugger'); + assert.strictEqual(state.detachCount, 1, '即使停止也应在 finally 中释放 debugger'); + + console.log('step8 debugger stop tests passed'); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/tests/step8-state-timeout-retry.test.js b/tests/step8-state-timeout-retry.test.js new file mode 100644 index 0000000..da68280 --- /dev/null +++ b/tests/step8-state-timeout-retry.test.js @@ -0,0 +1,54 @@ +const assert = require('assert'); +const fs = require('fs'); + +const source = fs.readFileSync('background.js', 'utf8'); + +function extractFunction(name) { + const start = source.indexOf(`function ${name}(`); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + const braceStart = source.indexOf('{', start); + let depth = 0; + let end = braceStart; + for (; end < source.length; end++) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +const bundle = [ + extractFunction('isRetryableContentScriptTransportError'), +].join('\n'); + +const api = new Function(`${bundle}; return { isRetryableContentScriptTransportError };`)(); + +assert.strictEqual( + api.isRetryableContentScriptTransportError(new Error('Content script on signup-page did not respond in 2s. Try refreshing the tab and retry.')), + true, + 'Step 8 状态探测短超时应被视为可重试错误' +); + +assert.strictEqual( + api.isRetryableContentScriptTransportError(new Error('Content script on signup-page did not respond in 30s. Try refreshing the tab and retry.')), + true, + '普通内容脚本超时也应沿用可重试分支' +); + +assert.strictEqual( + api.isRetryableContentScriptTransportError(new Error('按钮不存在')), + false, + '真实业务错误不应被误判为可重试传输错误' +); + +console.log('step8 state timeout retry tests passed'); diff --git a/tests/step8-stop-cleanup.test.js b/tests/step8-stop-cleanup.test.js new file mode 100644 index 0000000..f770a14 --- /dev/null +++ b/tests/step8-stop-cleanup.test.js @@ -0,0 +1,209 @@ +const assert = require('assert'); +const fs = require('fs'); + +const source = fs.readFileSync('background.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map(marker => source.indexOf(marker)) + .find(index => index >= 0); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let i = start; i < source.length; i++) { + const ch = source[i]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = i; + break; + } + } + if (braceStart < 0) { + throw new Error(`missing body for function ${name}`); + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end++) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +const bundle = [ + extractFunction('throwIfStopped'), + extractFunction('cleanupStep8NavigationListeners'), + extractFunction('rejectPendingStep8'), + extractFunction('throwIfStep8SettledOrStopped'), + extractFunction('requestStop'), + extractFunction('executeStep8'), +].join('\n'); + +const api = new Function(` +let stopRequested = false; +const STOP_ERROR_MESSAGE = '流程已被用户停止。'; +let webNavListener = null; +let webNavCommittedListener = null; +let step8TabUpdatedListener = null; +let step8PendingReject = null; +let autoRunActive = true; +let autoRunCurrentRun = 2; +let autoRunTotalRuns = 3; +let autoRunAttemptRun = 4; + +const added = { + beforeNavigate: 0, + committed: 0, + tabUpdated: 0, +}; +const removed = { + beforeNavigate: 0, + committed: 0, + tabUpdated: 0, +}; +const sentMessages = []; +let clickCount = 0; +let resolveTabId = null; + +const chrome = { + webNavigation: { + onBeforeNavigate: { + addListener(listener) { + added.beforeNavigate += 1; + }, + removeListener(listener) { + removed.beforeNavigate += 1; + }, + }, + onCommitted: { + addListener(listener) { + added.committed += 1; + }, + removeListener(listener) { + removed.committed += 1; + }, + }, + }, + tabs: { + onUpdated: { + addListener(listener) { + added.tabUpdated += 1; + }, + removeListener(listener) { + removed.tabUpdated += 1; + }, + }, + async update() {}, + }, +}; + +const stepWaiters = new Map(); +let resumeWaiter = null; + +function cancelPendingCommands() {} +async function addLog() {} +async function broadcastStopToContentScripts() {} +async function markRunningStepsStopped() {} +async function broadcastAutoRunStatus() {} +function getStep8CallbackUrlFromNavigation() { return ''; } +function getStep8CallbackUrlFromTabUpdate() { return ''; } +async function completeStepFromBackground() {} +async function getTabId() { + return await new Promise((resolve) => { + resolveTabId = resolve; + }); +} +async function reuseOrCreateTab() { + return 999; +} +async function isTabAlive() { + return true; +} +async function sendToContentScript(source, message) { + sentMessages.push({ source, type: message.type }); + return { rect: { centerX: 10, centerY: 20 } }; +} +async function clickWithDebugger() { + clickCount += 1; +} + +${bundle} + +return { + executeStep8, + requestStop, + resolveTabId(tabId) { + if (!resolveTabId) { + throw new Error('resolveTabId is not ready'); + } + resolveTabId(tabId); + }, + snapshot() { + return { + stopRequested, + webNavListener, + webNavCommittedListener, + step8TabUpdatedListener, + step8PendingReject, + added, + removed, + sentMessages, + clickCount, + autoRunActive, + }; + }, +}; +`)(); + +(async () => { + const step8Promise = api.executeStep8({ oauthUrl: 'https://example.com/oauth' }); + const settledStep8Promise = step8Promise.catch((err) => err); + + await new Promise((resolve) => setImmediate(resolve)); + await api.requestStop(); + await new Promise((resolve) => setImmediate(resolve)); + api.resolveTabId(123); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + + const error = await settledStep8Promise; + const state = api.snapshot(); + + assert.strictEqual(error?.message, '流程已被用户停止。', 'Stop 后 Step 8 promise 应被拒绝为停止错误'); + assert.deepStrictEqual( + state.added, + { beforeNavigate: 0, committed: 0, tabUpdated: 0 }, + 'Stop 先发生时,不应再注册 Step 8 监听' + ); + assert.strictEqual(state.sentMessages.length, 0, 'Stop 后不应再发送 STEP8_FIND_AND_CLICK 命令'); + assert.strictEqual(state.clickCount, 0, 'Stop 后不应再触发 debugger 点击'); + assert.strictEqual(state.webNavListener, null, 'Stop 后 onBeforeNavigate 引用应为空'); + assert.strictEqual(state.webNavCommittedListener, null, 'Stop 后 onCommitted 引用应为空'); + assert.strictEqual(state.step8TabUpdatedListener, null, 'Stop 后 tabs.onUpdated 引用应为空'); + assert.strictEqual(state.step8PendingReject, null, 'Stop 后不应保留 Step 8 挂起 reject'); + + console.log('step8 stop cleanup tests passed'); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/tests/step9-cpa-mode.test.js b/tests/step9-cpa-mode.test.js new file mode 100644 index 0000000..0c2d8e7 --- /dev/null +++ b/tests/step9-cpa-mode.test.js @@ -0,0 +1,58 @@ +const assert = require('assert'); +const fs = require('fs'); + +const source = fs.readFileSync('background.js', 'utf8'); + +function extractFunction(name) { + const start = source.indexOf(`function ${name}(`); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + const braceStart = source.indexOf('{', start); + let depth = 0; + let end = braceStart; + for (; end < source.length; end++) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +const bundle = [ + extractFunction('parseUrlSafely'), + extractFunction('isLocalCpaUrl'), + extractFunction('shouldBypassStep9ForLocalCpa'), +].join('\n'); + +const api = new Function(`${bundle}; return { isLocalCpaUrl, shouldBypassStep9ForLocalCpa };`)(); + +assert.strictEqual(api.isLocalCpaUrl('http://127.0.0.1:8317/management.html#/oauth'), true, '127.0.0.1 应视为本地 CPA'); +assert.strictEqual(api.isLocalCpaUrl('http://localhost:1455/management.html#/oauth'), true, 'localhost 应视为本地 CPA'); +assert.strictEqual(api.isLocalCpaUrl('https://example.com/management.html#/oauth'), false, '远程域名不应视为本地 CPA'); +assert.strictEqual(api.isLocalCpaUrl('notaurl'), false, '非法 URL 不应视为本地 CPA'); + +assert.strictEqual(api.shouldBypassStep9ForLocalCpa({ + vpsUrl: 'http://127.0.0.1:8317/management.html#/oauth', + localhostUrl: 'http://127.0.0.1:8317/codex/callback?code=abc&state=xyz', +}), true, '本地 CPA 且已有 callback 时应跳过远程提交流程'); + +assert.strictEqual(api.shouldBypassStep9ForLocalCpa({ + vpsUrl: 'https://example.com/management.html#/oauth', + localhostUrl: 'http://127.0.0.1:8317/codex/callback?code=abc&state=xyz', +}), false, '远程 CPA 不应跳过步骤 9'); + +assert.strictEqual(api.shouldBypassStep9ForLocalCpa({ + vpsUrl: 'http://127.0.0.1:8317/management.html#/oauth', + localhostUrl: '', +}), false, '没有 callback 时不应跳过步骤 9'); + +console.log('step9 cpa mode tests passed'); diff --git a/tests/step9-localhost-cleanup-scope.test.js b/tests/step9-localhost-cleanup-scope.test.js new file mode 100644 index 0000000..edd7319 --- /dev/null +++ b/tests/step9-localhost-cleanup-scope.test.js @@ -0,0 +1,193 @@ +const assert = require('assert'); +const fs = require('fs'); + +const source = fs.readFileSync('background.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map(marker => source.indexOf(marker)) + .find(index => index >= 0); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let i = start; i < source.length; i++) { + const ch = source[i]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = i; + break; + } + } + if (braceStart < 0) { + throw new Error(`missing body for function ${name}`); + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end++) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +const bundle = [ + extractFunction('getTabRegistry'), + extractFunction('parseUrlSafely'), + extractFunction('isLocalhostOAuthCallbackUrl'), + extractFunction('isLocalhostOAuthCallbackTabMatch'), + extractFunction('closeLocalhostCallbackTabs'), + extractFunction('handleStepData'), +].join('\n'); + +const api = new Function(` +let currentState = { + tabRegistry: { + 'signup-page': { tabId: 1, ready: true }, + 'vps-panel': { tabId: 99, ready: true }, + }, +}; +let currentTabs = []; +const removedBatches = []; +const logMessages = []; + +const chrome = { + tabs: { + async query() { + return currentTabs; + }, + async remove(ids) { + removedBatches.push(ids); + currentTabs = currentTabs.filter((tab) => !ids.includes(tab.id)); + }, + }, +}; + +async function getState() { + return currentState; +} + +async function setState(updates) { + currentState = { ...currentState, ...updates }; +} + +async function setEmailState(email) { + currentState = { ...currentState, email }; +} + +function broadcastDataUpdate() {} + +async function addLog(message) { + logMessages.push(message); +} + +${bundle} + +return { + handleStepData, + closeLocalhostCallbackTabs, + isLocalhostOAuthCallbackTabMatch, + reset({ tabs, tabRegistry }) { + currentTabs = tabs; + removedBatches.length = 0; + logMessages.length = 0; + currentState = { + tabRegistry: tabRegistry || {}, + }; + }, + snapshot() { + return { + currentState, + removedBatches, + logMessages, + }; + }, +}; +`)(); + +(async () => { + const codexCallbackUrl = 'http://127.0.0.1:8317/codex/callback?code=abc&state=xyz'; + const authCallbackUrl = 'http://localhost:1455/auth/callback?code=def&state=uvw'; + + assert.strictEqual( + api.isLocalhostOAuthCallbackTabMatch(codexCallbackUrl, codexCallbackUrl), + true, + '真实 callback 页应命中清理规则' + ); + assert.strictEqual( + api.isLocalhostOAuthCallbackTabMatch(codexCallbackUrl, authCallbackUrl), + false, + '/codex/callback 不应误伤 /auth/callback' + ); + assert.strictEqual( + api.isLocalhostOAuthCallbackTabMatch(authCallbackUrl, codexCallbackUrl), + false, + '/auth/callback 不应误伤 /codex/callback' + ); + + api.reset({ + tabs: [ + { id: 1, url: codexCallbackUrl }, + { id: 2, url: 'http://127.0.0.1:8317/codex/dashboard' }, + { id: 3, url: 'http://127.0.0.1:8317/codex/callback?code=other&state=xyz' }, + { id: 4, url: authCallbackUrl }, + ], + tabRegistry: { + 'signup-page': { tabId: 1, ready: true }, + 'vps-panel': { tabId: 99, ready: true }, + }, + }); + + await api.handleStepData(9, { localhostUrl: codexCallbackUrl }); + let snapshot = api.snapshot(); + assert.deepStrictEqual(snapshot.removedBatches, [[1]], 'handleStepData(9) 只应关闭当前 callback 页'); + assert.strictEqual( + snapshot.currentState.tabRegistry['signup-page'], + null, + '关闭 callback 页后应同步清理 signup-page 的 tabRegistry' + ); + assert.deepStrictEqual( + snapshot.currentState.tabRegistry['vps-panel'], + { tabId: 99, ready: true }, + '不相关的 tabRegistry 项不应被误清理' + ); + + api.reset({ + tabs: [ + { id: 1, url: codexCallbackUrl }, + { id: 4, url: authCallbackUrl }, + { id: 5, url: 'http://localhost:1455/auth/dashboard' }, + ], + tabRegistry: {}, + }); + + const closedCount = await api.closeLocalhostCallbackTabs(authCallbackUrl); + snapshot = api.snapshot(); + assert.strictEqual(closedCount, 1, 'auth callback 也应只关闭当前命中的 callback 页'); + assert.deepStrictEqual(snapshot.removedBatches, [[4]], '不应按 /auth 前缀批量清理页面'); + assert.strictEqual(snapshot.logMessages.length, 1, '发生清理时应记录一条日志'); + + console.log('step9 localhost cleanup scope tests passed'); +})().catch((error) => { + console.error(error); + process.exit(1); +});