diff --git a/background/steps/paypal-approve.js b/background/steps/paypal-approve.js index 3dee859..d69540d 100644 --- a/background/steps/paypal-approve.js +++ b/background/steps/paypal-approve.js @@ -27,6 +27,11 @@ if (paypalTabId && await isTabAlive(PAYPAL_SOURCE)) { return paypalTabId; } + const discoveredPayPalTabId = await findOpenPayPalTabId(); + if (discoveredPayPalTabId) { + await addLog('步骤 8:已从当前浏览器标签中发现 PayPal 页面,正在接管继续执行。', 'info'); + return discoveredPayPalTabId; + } const checkoutTabId = await getTabId(PLUS_CHECKOUT_SOURCE); if (checkoutTabId) { return checkoutTabId; @@ -38,6 +43,27 @@ throw new Error('步骤 8:未找到 PayPal 标签页,请先完成步骤 7。'); } + async function findOpenPayPalTabId() { + if (!chrome?.tabs?.query) { + return 0; + } + + const tabs = await chrome.tabs.query({}).catch(() => []); + const candidates = (Array.isArray(tabs) ? tabs : []) + .filter((tab) => Number.isInteger(tab?.id) && isPayPalUrl(tab.url || '')); + if (!candidates.length) { + return 0; + } + + const match = candidates.find((tab) => tab.active && tab.currentWindow) + || candidates.find((tab) => tab.active) + || candidates[0]; + if (match?.id && chrome?.tabs?.update) { + await chrome.tabs.update(match.id, { active: true }).catch(() => {}); + } + return match?.id || 0; + } + async function ensurePayPalReady(tabId, logMessage = '') { await waitForTabUrlMatchUntilStopped(tabId, (url) => /paypal\./i.test(url)); await waitForTabCompleteUntilStopped(tabId); diff --git a/content/paypal-flow.js b/content/paypal-flow.js index 7cc4217..9f34e51 100644 --- a/content/paypal-flow.js +++ b/content/paypal-flow.js @@ -70,6 +70,22 @@ async function waitForDocumentComplete() { function isVisibleElement(el) { if (!el) return false; + let node = el; + while (node && node.nodeType === 1) { + if (node.hidden || node.getAttribute?.('aria-hidden') === 'true' || node.getAttribute?.('inert') !== null) { + return false; + } + const nodeStyle = window.getComputedStyle(node); + if ( + nodeStyle.display === 'none' + || nodeStyle.visibility === 'hidden' + || nodeStyle.visibility === 'collapse' + || Number(nodeStyle.opacity) === 0 + ) { + return false; + } + node = node.parentElement; + } const style = window.getComputedStyle(el); const rect = el.getBoundingClientRect(); return style.display !== 'none' @@ -117,7 +133,7 @@ function findInputByPatterns(patterns) { const inputs = getVisibleControls('input') .filter((input) => { const type = String(input.getAttribute('type') || input.type || '').trim().toLowerCase(); - return !['hidden', 'checkbox', 'radio', 'submit', 'button', 'file'].includes(type); + return isEnabledControl(input) && !['hidden', 'checkbox', 'radio', 'submit', 'button', 'file'].includes(type); }); return inputs.find((input) => { const text = getActionText(input); @@ -144,6 +160,21 @@ function findLoginNextButton() { ]); } +function findEmailNextButton() { + return findClickableByText([ + /next|btn\s*next|btnnext/i, + /下一页|下一步/i, + ]); +} + +function findPasswordLoginButton() { + const button = findClickableByText([ + /login|log\s*in|sign\s*in/i, + /登录|登入/i, + ]); + return button && button !== findEmailNextButton() ? button : null; +} + function findApproveButton() { return findClickableByText([ /同意并继续|同意|继续|授权|确认并继续/i, @@ -185,6 +216,11 @@ function hasPasskeyPrompt() { } function getPayPalLoginPhase(emailInput, passwordInput) { + const emailNextButton = findEmailNextButton(); + const passwordLoginButton = findPasswordLoginButton(); + if (emailInput && emailNextButton && isEnabledControl(emailNextButton) && (!passwordInput || !passwordLoginButton)) { + return 'email'; + } if (emailInput && passwordInput) return 'login_combined'; if (passwordInput) return 'password'; if (emailInput) return 'email'; @@ -202,13 +238,26 @@ async function submitPayPalLogin(payload = {}) { let passwordInput = findPasswordInput(); const emailInput = findEmailInput(); + const emailNextButton = findEmailNextButton(); + + if (emailInput && emailNextButton && isEnabledControl(emailNextButton) && (!passwordInput || !findPasswordLoginButton())) { + if (normalizeText(emailInput.value || '') !== email) { + fillInput(emailInput, email); + } + simulateClick(emailNextButton); + return { + submitted: false, + phase: 'email_submitted', + awaiting: 'password_page', + }; + } if (!passwordInput && emailInput && email) { if (normalizeText(emailInput.value || '') !== email) { fillInput(emailInput, email); } const nextButton = await waitUntil(() => { - const button = findLoginNextButton(); + const button = findEmailNextButton() || findLoginNextButton(); return button && isEnabledControl(button) ? button : null; }, { intervalMs: 250, diff --git a/tests/paypal-approve-detection.test.js b/tests/paypal-approve-detection.test.js index 3adf6a1..14644e5 100644 --- a/tests/paypal-approve-detection.test.js +++ b/tests/paypal-approve-detection.test.js @@ -9,13 +9,21 @@ function loadModule() { return new Function('self', `${source}; return self.MultiPageBackgroundPayPalApprove;`)(self); } -function createExecutor({ pageStates, submitResults, tabUrls = [] }) { +function createExecutor({ + pageStates, + submitResults, + tabUrls = [], + getTabId = async (source) => (source === 'paypal-flow' ? 1 : null), + isTabAlive = async () => true, + queryTabs = [], +}) { const api = loadModule(); const events = { completed: [], logs: [], messages: [], submittedPayloads: [], + updatedTabs: [], }; const stateQueue = [...pageStates]; const submitQueue = [...submitResults]; @@ -28,24 +36,29 @@ function createExecutor({ pageStates, submitResults, tabUrls = [] }) { }, chrome: { tabs: { - get: async () => { + get: async (tabId = 1) => { if (urlQueue.length) { lastUrl = urlQueue.shift(); } return { - id: 1, + id: tabId, status: 'complete', url: lastUrl, }; }, + query: async () => queryTabs, + update: async (tabId, updateInfo) => { + events.updatedTabs.push({ tabId, updateInfo }); + return {}; + }, }, }, completeStepFromBackground: async (step, payload) => { events.completed.push({ step, payload }); }, ensureContentScriptReadyOnTabUntilStopped: async () => {}, - getTabId: async (source) => (source === 'paypal-flow' ? 1 : null), - isTabAlive: async () => true, + getTabId, + isTabAlive, sendTabMessageUntilStopped: async (_tabId, _source, message) => { events.messages.push(message.type); if (message.type === 'PAYPAL_GET_STATE') { @@ -94,6 +107,38 @@ test('PayPal approve keeps original combined email and password login path', asy assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true); }); +test('PayPal approve discovers an already open unregistered PayPal tab', async () => { + const { executor, events } = createExecutor({ + pageStates: [ + { needsLogin: false, approveReady: true }, + ], + submitResults: [], + getTabId: async () => null, + isTabAlive: async () => false, + queryTabs: [ + { + id: 7, + active: true, + currentWindow: true, + url: 'https://www.paypal.com/pay/?token=BA-demo', + }, + ], + tabUrls: [ + 'https://www.paypal.com/pay/?token=BA-demo', + ], + }); + + await executor.executePayPalApprove({ + paypalEmail: 'user@example.com', + paypalPassword: 'secret', + }); + + assert.deepEqual(events.updatedTabs, [{ tabId: 7, updateInfo: { active: true } }]); + assert.equal(events.logs.some(({ message }) => /发现 PayPal 页面/.test(message)), true); + assert.deepEqual(events.completed.map((item) => item.step), [8]); + assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true); +}); + test('PayPal approve auto-detects split email then password pages', async () => { const { executor, events } = createExecutor({ pageStates: [ diff --git a/tests/paypal-flow-content.test.js b/tests/paypal-flow-content.test.js new file mode 100644 index 0000000..d2f0ffd --- /dev/null +++ b/tests/paypal-flow-content.test.js @@ -0,0 +1,205 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('content/paypal-flow.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 index = start; index < source.length; index += 1) { + const char = source[index]; + if (char === '(') { + parenDepth += 1; + } else if (char === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (char === '{' && signatureEnded) { + braceStart = index; + break; + } + } + if (braceStart < 0) { + throw new Error(`missing body for function ${name}`); + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const char = source[end]; + if (char === '{') depth += 1; + if (char === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +function createElement({ + tag = 'div', + type = '', + id = '', + name = '', + text = '', + value = '', + placeholder = '', + attrs = {}, + style = {}, + rect = { width: 160, height: 40 }, + parentElement = null, +} = {}) { + return { + nodeType: 1, + tag, + type, + id, + name, + textContent: text, + value, + placeholder, + disabled: false, + hidden: Boolean(attrs.hidden), + style: { + display: 'block', + visibility: 'visible', + opacity: '1', + ...style, + }, + parentElement, + getAttribute(key) { + if (key === 'type') return type; + if (key === 'id') return id; + if (key === 'name') return name; + if (key === 'placeholder') return placeholder; + if (key === 'value') return value; + return Object.prototype.hasOwnProperty.call(attrs, key) ? attrs[key] : null; + }, + getBoundingClientRect() { + return rect; + }, + }; +} + +function loadApi(elements) { + const document = { + documentElement: {}, + querySelectorAll(selector) { + if (selector === 'input') { + return elements.filter((el) => el.tag === 'input'); + } + if (selector === 'input[type="email"]') { + return elements.filter((el) => el.tag === 'input' && el.type === 'email'); + } + if (selector === 'input[type="password"]') { + return elements.filter((el) => el.tag === 'input' && el.type === 'password'); + } + if (selector.includes('button') || selector.includes('[role="button"]')) { + return elements.filter((el) => el.tag === 'button' || el.attrs?.role === 'button'); + } + return []; + }, + }; + const window = { + getComputedStyle(el) { + return el?.style || { display: 'block', visibility: 'visible', opacity: '1' }; + }, + }; + + return new Function('document', 'window', ` +${extractFunction('isVisibleElement')} +${extractFunction('normalizeText')} +${extractFunction('getActionText')} +${extractFunction('getVisibleControls')} +${extractFunction('isEnabledControl')} +${extractFunction('findClickableByText')} +${extractFunction('findInputByPatterns')} +${extractFunction('findEmailInput')} +${extractFunction('findPasswordInput')} +${extractFunction('findLoginNextButton')} +${extractFunction('findEmailNextButton')} +${extractFunction('findPasswordLoginButton')} +${extractFunction('getPayPalLoginPhase')} +return { + findEmailInput, + findPasswordInput, + findEmailNextButton, + findPasswordLoginButton, + getPayPalLoginPhase, +}; +`)(document, window); +} + +test('PayPal email page ignores hidden pre-rendered password input', () => { + const hiddenPanel = createElement({ attrs: { 'aria-hidden': 'true' } }); + const emailInput = createElement({ + tag: 'input', + type: 'text', + id: 'login_email', + name: 'login_email', + value: 'user@example.com', + placeholder: 'Email', + }); + const hiddenPasswordInput = createElement({ + tag: 'input', + type: 'password', + id: 'login_password', + name: 'login_password', + parentElement: hiddenPanel, + }); + const nextButton = createElement({ + tag: 'button', + id: 'btnNext', + text: 'Next', + }); + + const api = loadApi([emailInput, hiddenPasswordInput, nextButton]); + + assert.equal(api.findEmailInput(), emailInput); + assert.equal(api.findPasswordInput(), null); + assert.equal(api.findEmailNextButton(), nextButton); + assert.equal(api.findPasswordLoginButton(), null); + assert.equal(api.getPayPalLoginPhase(emailInput, api.findPasswordInput()), 'email'); +}); + +test('PayPal combined login page still sees visible password input', () => { + const emailInput = createElement({ + tag: 'input', + type: 'text', + id: 'login_email', + name: 'login_email', + }); + const passwordInput = createElement({ + tag: 'input', + type: 'password', + id: 'login_password', + name: 'login_password', + }); + const loginButton = createElement({ + tag: 'button', + id: 'btnLogin', + text: 'Log In', + }); + + const api = loadApi([emailInput, passwordInput, loginButton]); + + assert.equal(api.findEmailInput(), emailInput); + assert.equal(api.findPasswordInput(), passwordInput); + assert.equal(api.findPasswordLoginButton(), loginButton); + assert.equal(api.getPayPalLoginPhase(emailInput, passwordInput), 'login_combined'); +});