feat: 增强 PayPal 登录流程,支持自动检测邮箱和密码输入,增加超时处理和相应测试用例

This commit is contained in:
QLHazyCoder
2026-04-26 05:47:44 +08:00
parent b86114fa3a
commit 3271dc2f83
3 changed files with 295 additions and 14 deletions
+102 -4
View File
@@ -4,6 +4,8 @@
const PAYPAL_SOURCE = 'paypal-flow';
const PLUS_CHECKOUT_SOURCE = 'plus-checkout';
const PAYPAL_INJECT_FILES = ['content/utils.js', 'content/paypal-flow.js'];
const PAYPAL_LOGIN_TRANSITION_TIMEOUT_MS = 30000;
const PAYPAL_LOGIN_TRANSITION_POLL_MS = 500;
function createPayPalApproveExecutor(deps = {}) {
const {
@@ -87,6 +89,89 @@
if (result?.error) {
throw new Error(result.error);
}
return result || {};
}
function isPayPalUrl(url = '') {
return /paypal\./i.test(String(url || ''));
}
function isPayPalPasswordState(pageState = {}) {
return Boolean(pageState.hasPasswordInput)
|| pageState.loginPhase === 'password'
|| pageState.loginPhase === 'login_combined';
}
async function waitForPayPalPostLoginDecision(tabId, actionResult = {}) {
const phase = String(actionResult?.phase || '').trim();
const startedAt = Date.now();
while (Date.now() - startedAt < PAYPAL_LOGIN_TRANSITION_TIMEOUT_MS) {
const tab = await chrome.tabs.get(tabId).catch(() => null);
if (!tab) {
throw new Error('步骤 8:PayPal 标签页已关闭,无法继续识别登录后的页面。');
}
const currentUrl = tab.url || '';
if (!currentUrl) {
await sleepWithStop(PAYPAL_LOGIN_TRANSITION_POLL_MS);
continue;
}
if (currentUrl && !isPayPalUrl(currentUrl)) {
return {
outcome: 'left_paypal',
url: currentUrl,
};
}
if (tab.status !== 'complete') {
await sleepWithStop(PAYPAL_LOGIN_TRANSITION_POLL_MS);
continue;
}
await ensurePayPalReady(
tabId,
phase === 'email_submitted'
? '步骤 8:PayPal 账号已提交,正在识别下一页...'
: '步骤 8:PayPal 密码已提交,正在识别跳转结果...'
);
const pageState = await getPayPalState(tabId);
if (pageState.hasPasskeyPrompt) {
return {
outcome: 'prompt',
pageState,
};
}
if (pageState.approveReady) {
return {
outcome: 'approve_ready',
pageState,
};
}
if (phase === 'email_submitted' && isPayPalPasswordState(pageState)) {
return {
outcome: 'password_ready',
pageState,
};
}
if (phase === 'password_submitted' && !pageState.needsLogin) {
return {
outcome: 'post_login_state',
pageState,
};
}
await sleepWithStop(PAYPAL_LOGIN_TRANSITION_POLL_MS);
}
return {
outcome: 'timeout',
phase,
};
}
async function clickApprove(tabId) {
@@ -109,7 +194,7 @@
let loggedWaiting = false;
while (true) {
const currentUrl = (await chrome.tabs.get(tabId).catch(() => null))?.url || '';
if (currentUrl && !/paypal\./i.test(currentUrl)) {
if (currentUrl && !isPayPalUrl(currentUrl)) {
await addLog('步骤 8:PayPal 已跳转离开授权页,准备进入回跳确认。', 'ok');
break;
}
@@ -118,9 +203,22 @@
const pageState = await getPayPalState(tabId);
if (pageState.needsLogin) {
await submitLogin(tabId, state);
await waitForTabCompleteUntilStopped(tabId);
await sleepWithStop(1000);
const submitResult = await submitLogin(tabId, state);
const decision = await waitForPayPalPostLoginDecision(tabId, submitResult);
if (decision.outcome === 'left_paypal') {
await addLog('步骤 8:PayPal 登录后已跳转离开登录/授权页,继续进入回跳确认。', 'ok');
break;
}
if (decision.outcome === 'password_ready') {
await addLog('步骤 8:PayPal 账号页提交后已识别到密码页,继续填写密码。', 'info');
} else if (decision.outcome === 'approve_ready') {
await addLog('步骤 8:PayPal 登录后已识别到授权确认页,继续点击授权。', 'info');
} else if (decision.outcome === 'prompt') {
await addLog('步骤 8:PayPal 登录后已识别到提示弹窗,继续处理弹窗。', 'info');
} else if (decision.outcome === 'timeout') {
await addLog('步骤 8:PayPal 登录动作后暂未识别到新页面,重新检查当前页面状态。', 'warn');
}
loggedWaiting = false;
continue;
}
+47 -10
View File
@@ -48,12 +48,17 @@ async function handlePayPalCommand(message) {
async function waitUntil(predicate, options = {}) {
const intervalMs = Math.max(50, Math.floor(Number(options.intervalMs) || 250));
const timeoutMs = Math.max(0, Math.floor(Number(options.timeoutMs) || 0));
const startedAt = Date.now();
while (true) {
throwIfStopped();
const value = await predicate();
if (value) {
return value;
}
if (timeoutMs > 0 && Date.now() - startedAt >= timeoutMs) {
throw new Error(options.timeoutMessage || 'PayPal page timed out waiting for target state.');
}
await sleep(intervalMs);
}
}
@@ -179,6 +184,13 @@ function hasPasskeyPrompt() {
return findPasskeyPromptButtons().length > 0;
}
function getPayPalLoginPhase(emailInput, passwordInput) {
if (emailInput && passwordInput) return 'login_combined';
if (passwordInput) return 'password';
if (emailInput) return 'email';
return '';
}
async function submitPayPalLogin(payload = {}) {
await waitForDocumentComplete();
@@ -192,19 +204,34 @@ async function submitPayPalLogin(payload = {}) {
const emailInput = findEmailInput();
if (!passwordInput && emailInput && email) {
fillInput(emailInput, email);
const nextButton = findLoginNextButton();
if (nextButton && isEnabledControl(nextButton)) {
simulateClick(nextButton);
if (normalizeText(emailInput.value || '') !== email) {
fillInput(emailInput, email);
}
passwordInput = await waitUntil(() => findPasswordInput(), { intervalMs: 250 });
const nextButton = await waitUntil(() => {
const button = findLoginNextButton();
return button && isEnabledControl(button) ? button : null;
}, {
intervalMs: 250,
timeoutMs: 8000,
timeoutMessage: 'PayPal email page did not expose a clickable next/continue button.',
});
simulateClick(nextButton);
return {
submitted: false,
phase: 'email_submitted',
awaiting: 'password_page',
};
} else if (!passwordInput && emailInput && !email) {
throw new Error('PayPal 账号为空,请先在侧边栏配置。');
} else if (emailInput && email && !String(emailInput.value || '').trim()) {
} else if (emailInput && email && normalizeText(emailInput.value || '') !== email) {
fillInput(emailInput, email);
}
passwordInput = passwordInput || await waitUntil(() => findPasswordInput(), { intervalMs: 250 });
passwordInput = passwordInput || await waitUntil(() => findPasswordInput(), {
intervalMs: 250,
timeoutMs: 8000,
timeoutMessage: 'PayPal password page did not expose a password input.',
});
fillInput(passwordInput, password);
await sleep(1000);
@@ -214,10 +241,18 @@ async function submitPayPalLogin(payload = {}) {
/登录|登入|继续/i,
]);
return button && isEnabledControl(button) ? button : null;
}, { intervalMs: 250 });
}, {
intervalMs: 250,
timeoutMs: 8000,
timeoutMessage: 'PayPal password page did not expose a clickable login/continue button.',
});
simulateClick(loginButton);
return { submitted: true };
return {
submitted: true,
phase: 'password_submitted',
awaiting: 'redirect_or_approval',
};
}
async function dismissPayPalPrompts() {
@@ -261,10 +296,12 @@ function inspectPayPalState() {
const emailInput = findEmailInput();
const passwordInput = findPasswordInput();
const approveButton = findApproveButton();
const loginPhase = getPayPalLoginPhase(emailInput, passwordInput);
return {
url: location.href,
readyState: document.readyState,
needsLogin: Boolean(emailInput || passwordInput),
needsLogin: Boolean(loginPhase),
loginPhase,
hasEmailInput: Boolean(emailInput),
hasPasswordInput: Boolean(passwordInput),
approveReady: Boolean(approveButton && isEnabledControl(approveButton)),
+146
View File
@@ -0,0 +1,146 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background/steps/paypal-approve.js', 'utf8');
function loadModule() {
const self = {};
return new Function('self', `${source}; return self.MultiPageBackgroundPayPalApprove;`)(self);
}
function createExecutor({ pageStates, submitResults, tabUrls = [] }) {
const api = loadModule();
const events = {
completed: [],
logs: [],
messages: [],
submittedPayloads: [],
};
const stateQueue = [...pageStates];
const submitQueue = [...submitResults];
const urlQueue = [...tabUrls];
let lastUrl = urlQueue.shift() || 'https://www.paypal.com/signin';
const executor = api.createPayPalApproveExecutor({
addLog: async (message, level = 'info') => {
events.logs.push({ message, level });
},
chrome: {
tabs: {
get: async () => {
if (urlQueue.length) {
lastUrl = urlQueue.shift();
}
return {
id: 1,
status: 'complete',
url: lastUrl,
};
},
},
},
completeStepFromBackground: async (step, payload) => {
events.completed.push({ step, payload });
},
ensureContentScriptReadyOnTabUntilStopped: async () => {},
getTabId: async (source) => (source === 'paypal-flow' ? 1 : null),
isTabAlive: async () => true,
sendTabMessageUntilStopped: async (_tabId, _source, message) => {
events.messages.push(message.type);
if (message.type === 'PAYPAL_GET_STATE') {
return stateQueue.shift() || pageStates[pageStates.length - 1] || {};
}
if (message.type === 'PAYPAL_SUBMIT_LOGIN') {
events.submittedPayloads.push(message.payload);
return submitQueue.shift() || { submitted: true, phase: 'password_submitted' };
}
if (message.type === 'PAYPAL_DISMISS_PROMPTS') {
return { clicked: 0 };
}
if (message.type === 'PAYPAL_CLICK_APPROVE') {
return { clicked: true };
}
return {};
},
setState: async () => {},
sleepWithStop: async () => {},
waitForTabCompleteUntilStopped: async () => {},
waitForTabUrlMatchUntilStopped: async () => {},
});
return { executor, events };
}
test('PayPal approve keeps original combined email and password login path', async () => {
const { executor, events } = createExecutor({
pageStates: [
{ needsLogin: true, hasEmailInput: true, hasPasswordInput: true, loginPhase: 'login_combined' },
{ needsLogin: false, approveReady: true },
{ needsLogin: false, approveReady: true },
],
submitResults: [
{ submitted: true, phase: 'password_submitted', awaiting: 'redirect_or_approval' },
],
});
await executor.executePayPalApprove({
paypalEmail: 'user@example.com',
paypalPassword: 'secret',
});
assert.equal(events.submittedPayloads.length, 1);
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: [
{ needsLogin: true, hasEmailInput: true, hasPasswordInput: false, loginPhase: 'email' },
{ needsLogin: true, hasEmailInput: false, hasPasswordInput: true, loginPhase: 'password' },
{ needsLogin: true, hasEmailInput: false, hasPasswordInput: true, loginPhase: 'password' },
{ needsLogin: false, approveReady: true },
{ needsLogin: false, approveReady: true },
],
submitResults: [
{ submitted: false, phase: 'email_submitted', awaiting: 'password_page' },
{ submitted: true, phase: 'password_submitted', awaiting: 'redirect_or_approval' },
],
});
await executor.executePayPalApprove({
paypalEmail: 'user@example.com',
paypalPassword: 'secret',
});
assert.equal(events.submittedPayloads.length, 2);
assert.deepEqual(events.completed.map((item) => item.step), [8]);
assert.equal(events.logs.some(({ message }) => /识别到密码页/.test(message)), true);
assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true);
});
test('PayPal approve finishes when login redirects away from PayPal', async () => {
const { executor, events } = createExecutor({
pageStates: [
{ needsLogin: true, hasEmailInput: false, hasPasswordInput: true, loginPhase: 'password' },
],
submitResults: [
{ submitted: true, phase: 'password_submitted', awaiting: 'redirect_or_approval' },
],
tabUrls: [
'https://www.paypal.com/signin',
'https://www.paypal.com/signin',
'https://checkout.openai.com/return',
],
});
await executor.executePayPalApprove({
paypalEmail: 'user@example.com',
paypalPassword: 'secret',
});
assert.equal(events.submittedPayloads.length, 1);
assert.deepEqual(events.completed.map((item) => item.step), [8]);
assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), false);
});