feat: 增强 PayPal 登录流程,支持自动检测邮箱和密码输入,增加超时处理和相应测试用例
This commit is contained in:
@@ -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
@@ -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)),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user