feat: add configurable operation delay

This commit is contained in:
root
2026-05-08 12:50:20 -04:00
parent 872f382815
commit c23461261b
44 changed files with 4770 additions and 193 deletions
+1
View File
@@ -11,5 +11,6 @@
/node_modules
/.runtime
/docs/新步骤顺序
.worktrees/
# Local exported runtime settings
/config.json
+6
View File
@@ -498,6 +498,12 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
- `重新开始`:重置当前流程进度,从 Step 1 开始新一轮
- `继续当前`:把 `已完成 / 已跳过` 视为已处理,从第一个未处理步骤继续往后执行
### 操作间延迟
`操作间延迟` 默认开启。开启后,自动流程和手动单步在每个页面输入、选择、点击、提交、继续或授权操作完成后固定等待 2 秒;第一项页面操作不会提前等待。分格 OTP/验证码会先整组填完,然后只等待一次。
该开关不同于步间间隔,不影响邮箱轮询、短信/WhatsApp 轮询、后台 API、网络重试、后台定时器或存储持久化,也不影响 `confirm-oauth``platform-verify` 的交互节奏。
## 工作流
### 单步模式
+6 -2
View File
@@ -669,6 +669,7 @@ const PERSISTED_SETTING_DEFAULTS = {
autoRunFallbackThreadIntervalMinutes: 0,
oauthFlowTimeoutEnabled: true,
autoRunDelayEnabled: false,
operationDelayEnabled: true,
autoRunDelayMinutes: 30,
autoStepDelaySeconds: null,
step6CookieCleanupEnabled: false,
@@ -2417,6 +2418,9 @@ function normalizePersistentSettingValue(key, value) {
case 'oauthFlowTimeoutEnabled':
case 'gopayHelperLocalSmsHelperEnabled':
case 'autoRunDelayEnabled':
return Boolean(value);
case 'operationDelayEnabled':
return typeof value === 'boolean' ? value : true;
case 'step6CookieCleanupEnabled':
case 'phoneVerificationEnabled':
case 'freePhoneReuseEnabled':
@@ -10694,7 +10698,7 @@ async function resumeAutoRun() {
// ============================================================
const SIGNUP_ENTRY_URL = 'https://chatgpt.com/';
const SIGNUP_PAGE_INJECT_FILES = ['content/utils.js', 'content/auth-page-recovery.js', 'content/phone-country-utils.js', 'content/phone-auth.js', 'content/signup-page.js'];
const SIGNUP_PAGE_INJECT_FILES = ['content/utils.js', 'content/operation-delay.js', 'content/auth-page-recovery.js', 'content/phone-country-utils.js', 'content/phone-auth.js', 'content/signup-page.js'];
const panelBridge = self.MultiPageBackgroundPanelBridge?.createPanelBridge({
chrome,
addLog,
@@ -11377,7 +11381,7 @@ function getMailConfig(state) {
source: 'mail-2925',
url: 'https://2925.com/#/mailList',
label: '2925 邮箱',
inject: ['content/utils.js', 'content/mail-2925.js'],
inject: ['content/utils.js', 'content/operation-delay.js', 'content/mail-2925.js'],
injectSource: 'mail-2925',
};
}
+1 -1
View File
@@ -32,7 +32,7 @@
const MAIL2925_SOURCE = 'mail-2925';
const MAIL2925_URL = 'https://2925.com/#/mailList';
const MAIL2925_LOGIN_URL = 'https://2925.com/login/';
const MAIL2925_INJECT = ['content/utils.js', 'content/mail-2925.js'];
const MAIL2925_INJECT = ['content/utils.js', 'content/operation-delay.js', 'content/mail-2925.js'];
const MAIL2925_INJECT_SOURCE = 'mail-2925';
const MAIL2925_COOKIE_DOMAINS = [
'2925.com',
+1 -1
View File
@@ -3,7 +3,7 @@
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundPlusCheckoutCreateModule() {
const PLUS_CHECKOUT_SOURCE = 'plus-checkout';
const PLUS_CHECKOUT_ENTRY_URL = 'https://chatgpt.com/';
const PLUS_CHECKOUT_INJECT_FILES = ['content/utils.js', 'content/plus-checkout.js'];
const PLUS_CHECKOUT_INJECT_FILES = ['content/utils.js', 'content/operation-delay.js', 'content/plus-checkout.js'];
const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';
const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';
const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper';
+1 -1
View File
@@ -2,7 +2,7 @@
root.MultiPageBackgroundPlusCheckoutBilling = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundPlusCheckoutBillingModule() {
const PLUS_CHECKOUT_SOURCE = 'plus-checkout';
const PLUS_CHECKOUT_INJECT_FILES = ['content/utils.js', 'content/plus-checkout.js'];
const PLUS_CHECKOUT_INJECT_FILES = ['content/utils.js', 'content/operation-delay.js', 'content/plus-checkout.js'];
const PLUS_CHECKOUT_URL_PATTERN = /^https:\/\/chatgpt\.com\/checkout(?:\/|$)/i;
const PLUS_CHECKOUT_FRAME_READY_DELAY_MS = 500;
const PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS = 3;
+1 -1
View File
@@ -4,7 +4,7 @@
const GOPAY_SOURCE = 'gopay-flow';
const GOPAY_OTP_SOURCE = 'gopay-otp-flow';
const PLUS_CHECKOUT_SOURCE = 'plus-checkout';
const GOPAY_INJECT_FILES = ['content/utils.js', 'content/gopay-flow.js'];
const GOPAY_INJECT_FILES = ['content/utils.js', 'content/operation-delay.js', 'content/gopay-flow.js'];
const GOPAY_WAIT_TIMEOUT_MS = 120000;
const GOPAY_POLL_INTERVAL_MS = 1000;
const GOPAY_LINKING_RETRY_WAIT_MS = 15000;
+1 -1
View File
@@ -3,7 +3,7 @@
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundPayPalApproveModule() {
const PAYPAL_SOURCE = 'paypal-flow';
const PLUS_CHECKOUT_SOURCE = 'plus-checkout';
const PAYPAL_INJECT_FILES = ['content/utils.js', 'content/paypal-flow.js'];
const PAYPAL_INJECT_FILES = ['content/utils.js', 'content/operation-delay.js', 'content/paypal-flow.js'];
const PAYPAL_LOGIN_TRANSITION_TIMEOUT_MS = 30000;
const PAYPAL_LOGIN_TRANSITION_POLL_MS = 500;
+8 -1
View File
@@ -15,6 +15,7 @@
isActionEnabled,
isVisibleElement,
log,
performOperationWithDelay: injectedPerformOperationWithDelay,
routeErrorPattern = null,
simulateClick,
sleep,
@@ -122,6 +123,10 @@
}
async function recoverAuthRetryPage(options = {}) {
const rootScope = typeof self !== 'undefined' ? self : globalThis;
const performOperationWithDelay = injectedPerformOperationWithDelay
|| rootScope.CodexOperationDelay?.performOperationWithDelay
|| (async (_metadata, operation) => operation());
const {
logLabel = '',
maxClickAttempts = 5,
@@ -173,7 +178,9 @@
if (typeof humanPause === 'function') {
await humanPause(300, 800);
}
simulateClick(retryState.retryButton);
await performOperationWithDelay({ stepKey: options.stepKey || '', kind: 'click', label: 'auth-retry-click' }, async () => {
simulateClick(retryState.retryButton);
});
const recoveryResult = await waitForRetryPageRecoveryAfterClick({
pathPatterns,
pollIntervalMs,
+7 -5
View File
@@ -95,11 +95,13 @@ async function fetchDuckEmail(payload = {}) {
for (let attempt = 1; attempt <= 2; attempt++) {
await humanPause(500, 1300);
if (typeof simulateClick === 'function') {
simulateClick(generatorButton);
} else {
generatorButton.click();
}
await window.CodexOperationDelay.performOperationWithDelay({ stepKey: 'fetch-signup-code', kind: 'click', label: 'duck-generate-address' }, async () => {
if (typeof simulateClick === 'function') {
simulateClick(generatorButton);
} else {
generatorButton.click();
}
});
log(`Duck 邮箱:已点击“生成 Duck 私有地址”按钮(${attempt}/2`);
try {
+77 -15
View File
@@ -35,6 +35,12 @@ if (document.documentElement.getAttribute(GOPAY_FLOW_LISTENER_SENTINEL) !== '1')
console.log('[MultiPage:gopay-flow] 消息监听已存在,跳过重复注册');
}
async function performGoPayOperationWithDelay(metadata, operation) {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
}
async function handleGoPayCommand(message) {
switch (message.type) {
case 'GOPAY_GET_STATE':
@@ -622,20 +628,33 @@ function fillVisibleOtpInputs(code = '') {
}
async function submitGoPayPhone(payload = {}) {
const delayOperation = typeof performGoPayOperationWithDelay === 'function'
? performGoPayOperationWithDelay
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
await waitForDocumentComplete();
const countryCode = normalizeGoPayCountryCode(payload.countryCode || payload.gopayCountryCode || '+86');
const phone = normalizeGoPayNationalPhone(payload.phone || payload.gopayPhone || '', countryCode);
if (!phone) {
throw new Error('GoPay 手机号为空,请先在侧边栏配置。');
}
const countryResult = await ensureGoPayCountryCode(countryCode);
const input = await waitUntil(() => findPhoneInput(), {
label: 'GoPay 手机号输入框',
intervalMs: 250,
timeoutMs: 15000,
});
fillInput(input, phone);
const clickResult = await clickContinueIfPresent();
const { countryResult, clickResult } = await delayOperation({ stepKey: 'gopay-approve', kind: 'submit', label: 'submit-phone' }, async () => {
const nextCountryResult = await ensureGoPayCountryCode(countryCode);
fillInput(input, phone);
const nextClickResult = await clickContinueIfPresent();
return {
countryResult: nextCountryResult,
clickResult: nextClickResult,
};
});
return {
phoneSubmitted: true,
countryCode,
@@ -647,17 +666,27 @@ async function submitGoPayPhone(payload = {}) {
}
async function submitGoPayOtp(payload = {}) {
const delayOperation = typeof performGoPayOperationWithDelay === 'function'
? performGoPayOperationWithDelay
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
await waitForDocumentComplete();
const code = normalizeOtp(payload.code || payload.otp || '');
if (!code) {
throw new Error('GoPay WhatsApp 验证码为空。');
}
const filled = await waitUntil(() => fillVisibleOtpInputs(code), {
label: 'GoPay 验证码输入框',
intervalMs: 250,
timeoutMs: 15000,
const { filled, clickResult } = await delayOperation({ stepKey: 'gopay-approve', kind: 'submit', label: 'submit-otp' }, async () => {
const filledOtp = await waitUntil(() => fillVisibleOtpInputs(code), {
label: 'GoPay 验证码输入框',
intervalMs: 250,
timeoutMs: 15000,
});
const continueResult = await clickContinueIfPresent();
return { filled: filledOtp, clickResult: continueResult };
});
const clickResult = await clickContinueIfPresent();
return {
otpSubmitted: Boolean(filled),
clicked: Boolean(clickResult.clicked),
@@ -667,17 +696,27 @@ async function submitGoPayOtp(payload = {}) {
}
async function submitGoPayPin(payload = {}) {
const delayOperation = typeof performGoPayOperationWithDelay === 'function'
? performGoPayOperationWithDelay
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
await waitForDocumentComplete();
const pin = normalizeOtp(payload.pin || payload.gopayPin || '');
if (!pin) {
throw new Error('GoPay PIN 为空,请先在侧边栏配置。');
}
const filled = await waitUntil(() => fillVisiblePinInputs(pin), {
label: 'GoPay PIN 输入框',
intervalMs: 250,
timeoutMs: 15000,
const { filled, clickResult } = await delayOperation({ stepKey: 'gopay-approve', kind: 'submit', label: 'submit-pin' }, async () => {
const filledPin = await waitUntil(() => fillVisiblePinInputs(pin), {
label: 'GoPay PIN 输入框',
intervalMs: 250,
timeoutMs: 15000,
});
const continueResult = await clickContinueIfPresent();
return { filled: filledPin, clickResult: continueResult };
});
const clickResult = await clickContinueIfPresent();
return {
pinSubmitted: Boolean(filled),
clicked: Boolean(clickResult.clicked),
@@ -722,18 +761,41 @@ function getGoPayPayNowTarget() {
}
async function clickGoPayContinue() {
const delayOperation = typeof performGoPayOperationWithDelay === 'function'
? performGoPayOperationWithDelay
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
await waitForDocumentComplete();
const clickResult = await clickContinueIfPresent({ afterMs: 1200 });
const button = findContinueButton();
if (!button) {
return { clicked: false, clickTarget: '' };
}
const clickResult = await delayOperation({ stepKey: 'gopay-approve', kind: 'click', label: 'click-continue' }, async () => {
await humanClickElement(button, { afterMs: 1200 });
return { clicked: true, target: describeElement(button) };
});
return { clicked: Boolean(clickResult.clicked), clickTarget: clickResult.target || '' };
}
async function clickGoPayPayNow() {
const delayOperation = typeof performGoPayOperationWithDelay === 'function'
? performGoPayOperationWithDelay
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
await waitForDocumentComplete();
const button = findPayNowButton();
if (!button) {
return { clicked: false, clickTarget: '' };
}
await humanClickElement(button, { afterMs: 1500 });
await delayOperation({ stepKey: 'gopay-approve', kind: 'click', label: 'click-pay-now' }, async () => {
await humanClickElement(button, { afterMs: 1500 });
});
return { clicked: true, clickTarget: describeElement(button) };
}
+17 -4
View File
@@ -501,7 +501,9 @@ async function ensureAgreementChecked() {
if (isCheckboxChecked(checkbox)) {
continue;
}
simulateClick(checkbox);
await performOperationWithDelay({ stepKey: 'fetch-signup-code', kind: 'click', label: 'mail2925-agreement-checkbox' }, async () => {
simulateClick(checkbox);
});
changed = true;
await sleep(120);
}
@@ -1052,6 +1054,11 @@ async function waitForMail2925View(targetView, timeoutMs = 45000) {
return detectMail2925ViewState();
}
async function performOperationWithDelay(metadata, operation) {
const gate = window.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
}
async function ensureMail2925Session(payload = {}) {
const email = String(payload?.email || '').trim();
const password = String(payload?.password || '');
@@ -1135,13 +1142,19 @@ async function ensureMail2925Session(payload = {}) {
}
await ensureAgreementChecked();
fillInput(emailInput, email);
await performOperationWithDelay({ stepKey: 'fetch-signup-code', kind: 'fill', label: 'mail2925-login-email' }, async () => {
fillInput(emailInput, email);
});
await sleep(150);
fillInput(passwordInput, password);
await performOperationWithDelay({ stepKey: 'fetch-signup-code', kind: 'fill', label: 'mail2925-login-password' }, async () => {
fillInput(passwordInput, password);
});
await sleep(200);
await sleep(1000);
log(`步骤 0:2925 已定位到登录表单,准备点击“登录”,当前地址 ${location.href}`, 'info');
simulateClick(loginButton);
await performOperationWithDelay({ stepKey: 'fetch-signup-code', kind: 'submit', label: 'mail2925-login-submit' }, async () => {
simulateClick(loginButton);
});
log(`步骤 0:2925 已点击“登录”,点击后地址 ${location.href}`, 'info');
const finalState = await waitForMail2925View('mailbox', 40000);
+98
View File
@@ -0,0 +1,98 @@
(function attachOperationDelay(root) {
const OPERATION_DELAY_MS = 2000;
const SETTING_RESTORE_FALLBACK_MS = 50;
const EXCLUDED_STEP_KEYS = new Set(['confirm-oauth', 'platform-verify']);
let operationDelayEnabled = true;
let operationDelaySettingReady = null;
let operationDelaySettingRevision = 0;
function normalizeOperationDelayEnabled(value) {
return typeof value === 'boolean' ? value : true;
}
function getOperationDelayEnabled() {
return operationDelayEnabled;
}
async function refreshOperationDelaySetting() {
const restoreRevision = ++operationDelaySettingRevision;
const ready = (async () => {
let nextEnabled = true;
try {
const data = await root.chrome?.storage?.local?.get?.(['operationDelayEnabled']);
nextEnabled = normalizeOperationDelayEnabled(data?.operationDelayEnabled);
} catch {
nextEnabled = true;
}
if (operationDelaySettingRevision === restoreRevision) {
operationDelayEnabled = nextEnabled;
}
return operationDelayEnabled;
})();
operationDelaySettingReady = ready;
try {
return await ready;
} finally {
if (operationDelaySettingReady === ready) {
operationDelaySettingReady = null;
}
}
}
function shouldDelayOperation(metadata = {}) {
if (metadata.skipOperationDelay === true) return false;
if (EXCLUDED_STEP_KEYS.has(String(metadata.stepKey || '').trim())) return false;
const enabled = Object.prototype.hasOwnProperty.call(metadata, 'enabled')
? normalizeOperationDelayEnabled(metadata.enabled)
: getOperationDelayEnabled();
if (enabled === false) return false;
return true;
}
function waitForOperationDelaySettingFallback() {
return new Promise((resolve) => {
const schedule = root.setTimeout || (typeof setTimeout === 'function' ? setTimeout : null);
if (typeof schedule === 'function') {
schedule(() => resolve(getOperationDelayEnabled()), SETTING_RESTORE_FALLBACK_MS);
return;
}
resolve(getOperationDelayEnabled());
});
}
async function resolveOperationDelayEnabled(metadata = {}, options = {}) {
if (typeof options.getEnabled === 'function') {
return normalizeOperationDelayEnabled(options.getEnabled());
}
if (Object.prototype.hasOwnProperty.call(metadata, 'enabled')) {
return normalizeOperationDelayEnabled(metadata.enabled);
}
if (operationDelaySettingReady) {
return normalizeOperationDelayEnabled(await Promise.race([
operationDelaySettingReady,
waitForOperationDelaySettingFallback(),
]));
}
return getOperationDelayEnabled();
}
async function performOperationWithDelay(metadata = {}, operation, options = {}) {
const result = await operation();
const enabled = await resolveOperationDelayEnabled(metadata, options);
if (shouldDelayOperation({ ...metadata, enabled })) {
const wait = options.sleep || root.sleep;
await wait(OPERATION_DELAY_MS);
}
return result;
}
root.chrome?.storage?.onChanged?.addListener?.((changes, areaName) => {
if (areaName === 'local' && Object.prototype.hasOwnProperty.call(changes, 'operationDelayEnabled')) {
operationDelaySettingRevision += 1;
operationDelayEnabled = normalizeOperationDelayEnabled(changes.operationDelayEnabled?.newValue);
}
});
refreshOperationDelaySetting().catch(() => { operationDelayEnabled = true; });
root.CodexOperationDelay = { OPERATION_DELAY_MS, normalizeOperationDelayEnabled, refreshOperationDelaySetting, getOperationDelayEnabled, shouldDelayOperation, performOperationWithDelay };
})(typeof self !== 'undefined' ? self : globalThis);
+107 -33
View File
@@ -31,6 +31,12 @@ if (document.documentElement.getAttribute(PAYPAL_FLOW_LISTENER_SENTINEL) !== '1'
console.log('[MultiPage:paypal-flow] 消息监听已存在,跳过重复注册');
}
async function performPayPalOperationWithDelay(metadata, operation) {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
}
async function handlePayPalCommand(message) {
switch (message.type) {
case 'PAYPAL_GET_STATE':
@@ -142,15 +148,50 @@ function findInputByPatterns(patterns) {
}
function findEmailInput() {
return findInputByPatterns([
const isPasswordCandidate = (input) => {
const type = String(input?.getAttribute?.('type') || input?.type || '').trim().toLowerCase();
const metadataText = normalizeText([
input?.textContent,
input?.getAttribute?.('aria-label'),
input?.getAttribute?.('title'),
input?.getAttribute?.('placeholder'),
input?.getAttribute?.('name'),
input?.id,
].filter(Boolean).join(' '));
return type === 'password' || /password|pass|密码/i.test(metadataText);
};
const inputs = getVisibleControls('input')
.filter((input) => {
const type = String(input.getAttribute('type') || input.type || '').trim().toLowerCase();
return isEnabledControl(input)
&& !['hidden', 'checkbox', 'radio', 'submit', 'button', 'file'].includes(type)
&& !isPasswordCandidate(input);
});
return inputs.find((input) => [
/email|login|user|账号|邮箱/i,
]) || getVisibleControls('input[type="email"]').find(isVisibleElement) || null;
].some((pattern) => pattern.test(getActionText(input))))
|| getVisibleControls('input[type="email"]').find((input) => isVisibleElement(input) && !isPasswordCandidate(input))
|| null;
}
function findPasswordInput() {
return findInputByPatterns([
/password|pass|密码/i,
]) || getVisibleControls('input[type="password"]').find(isVisibleElement) || null;
const inputs = getVisibleControls('input')
.filter((input) => {
const type = String(input.getAttribute('type') || input.type || '').trim().toLowerCase();
return isEnabledControl(input) && !['hidden', 'checkbox', 'radio', 'submit', 'button', 'file'].includes(type);
});
return inputs.find((input) => {
const type = String(input.getAttribute('type') || input.type || '').trim().toLowerCase();
const metadataText = normalizeText([
input?.textContent,
input?.getAttribute?.('aria-label'),
input?.getAttribute?.('title'),
input?.getAttribute?.('placeholder'),
input?.getAttribute?.('name'),
input?.id,
].filter(Boolean).join(' '));
return type === 'password' || /password|pass|密码/i.test(metadataText);
}) || getVisibleControls('input[type="password"]').find(isVisibleElement) || null;
}
function findLoginNextButton() {
@@ -240,6 +281,13 @@ function refillPayPalEmailInput(emailInput, email) {
}
async function submitPayPalLogin(payload = {}) {
const delayOperation = typeof performPayPalOperationWithDelay === 'function'
? performPayPalOperationWithDelay
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
await waitForDocumentComplete();
const email = normalizeText(payload.email || '');
@@ -253,8 +301,10 @@ async function submitPayPalLogin(payload = {}) {
const emailNextButton = findEmailNextButton();
if (emailInput && emailNextButton && isEnabledControl(emailNextButton) && (!passwordInput || !findPasswordLoginButton())) {
refillPayPalEmailInput(emailInput, email);
simulateClick(emailNextButton);
await delayOperation({ stepKey: 'paypal-approve', kind: 'submit', label: 'paypal-email' }, async () => {
refillPayPalEmailInput(emailInput, email);
simulateClick(emailNextButton);
});
return {
submitted: false,
phase: 'email_submitted',
@@ -263,16 +313,18 @@ async function submitPayPalLogin(payload = {}) {
}
if (!passwordInput && emailInput && email) {
refillPayPalEmailInput(emailInput, email);
const nextButton = await waitUntil(() => {
const button = findEmailNextButton() || findLoginNextButton();
return button && isEnabledControl(button) ? button : null;
}, {
intervalMs: 250,
timeoutMs: 8000,
timeoutMessage: 'PayPal email page did not expose a clickable next/continue button.',
await delayOperation({ stepKey: 'paypal-approve', kind: 'submit', label: 'paypal-email' }, async () => {
refillPayPalEmailInput(emailInput, email);
const nextButton = await waitUntil(() => {
const button = findEmailNextButton() || 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);
});
simulateClick(nextButton);
return {
submitted: false,
phase: 'email_submitted',
@@ -281,7 +333,9 @@ async function submitPayPalLogin(payload = {}) {
} else if (!passwordInput && emailInput && !email) {
throw new Error('PayPal 账号为空,请先在侧边栏配置。');
} else if (emailInput && email) {
refillPayPalEmailInput(emailInput, email);
await delayOperation({ stepKey: 'paypal-approve', kind: 'fill', label: 'paypal-email' }, async () => {
refillPayPalEmailInput(emailInput, email);
});
}
passwordInput = passwordInput || await waitUntil(() => findPasswordInput(), {
@@ -289,22 +343,24 @@ async function submitPayPalLogin(payload = {}) {
timeoutMs: 8000,
timeoutMessage: 'PayPal password page did not expose a password input.',
});
fillInput(passwordInput, password);
await sleep(1000);
await delayOperation({ stepKey: 'paypal-approve', kind: 'submit', label: 'paypal-password' }, async () => {
fillInput(passwordInput, password);
await sleep(1000);
const loginButton = await waitUntil(() => {
const button = findClickableByText([
/login|log\s*in|sign\s*in|continue/i,
/登录|登入|继续/i,
]);
return button && isEnabledControl(button) ? button : null;
}, {
intervalMs: 250,
timeoutMs: 8000,
timeoutMessage: 'PayPal password page did not expose a clickable login/continue button.',
const loginButton = await waitUntil(() => {
const button = findClickableByText([
/login|log\s*in|sign\s*in|continue/i,
/登录|登入|继续/i,
]);
return button && isEnabledControl(button) ? button : null;
}, {
intervalMs: 250,
timeoutMs: 8000,
timeoutMessage: 'PayPal password page did not expose a clickable login/continue button.',
});
simulateClick(loginButton);
});
simulateClick(loginButton);
return {
submitted: true,
phase: 'password_submitted',
@@ -313,6 +369,13 @@ async function submitPayPalLogin(payload = {}) {
}
async function dismissPayPalPrompts() {
const delayOperation = typeof performPayPalOperationWithDelay === 'function'
? performPayPalOperationWithDelay
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
await waitForDocumentComplete();
const buttons = findPasskeyPromptButtons();
let clicked = 0;
@@ -320,7 +383,9 @@ async function dismissPayPalPrompts() {
if (!isVisibleElement(button) || !isEnabledControl(button)) {
continue;
}
simulateClick(button);
await delayOperation({ stepKey: 'paypal-approve', kind: 'click', label: 'paypal-dismiss-prompt' }, async () => {
simulateClick(button);
});
clicked += 1;
await sleep(500);
}
@@ -331,6 +396,13 @@ async function dismissPayPalPrompts() {
}
async function clickPayPalApprove() {
const delayOperation = typeof performPayPalOperationWithDelay === 'function'
? performPayPalOperationWithDelay
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
await waitForDocumentComplete();
await dismissPayPalPrompts().catch(() => ({ clicked: 0 }));
@@ -342,7 +414,9 @@ async function clickPayPalApprove() {
};
}
simulateClick(button);
await delayOperation({ stepKey: 'paypal-approve', kind: 'click', label: 'paypal-approve' }, async () => {
simulateClick(button);
});
return {
clicked: true,
buttonText: getActionText(button),
+38 -11
View File
@@ -13,6 +13,7 @@
isConsentReady,
isPhoneVerificationPageReady,
isVisibleElement,
performOperationWithDelay: injectedPerformOperationWithDelay,
simulateClick,
sleep,
throwIfStopped,
@@ -34,6 +35,11 @@
let lastPhoneRoute405RecoveryFailedAt = 0;
let activePhoneResendPromise = null;
async function performOperationWithDelay(metadata, operation) {
const gate = injectedPerformOperationWithDelay || rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
}
function dispatchInputEvents(element) {
if (!element) return;
element.dispatchEvent(new Event('input', { bubbles: true }));
@@ -334,10 +340,15 @@
}
const selectedOption = getSelectedCountryOption();
if (selectedOption && isSameCountryOption(selectedOption, targetOption)) {
await performOperationWithDelay({ stepKey: 'phone-auth', kind: 'select', label: 'phone-country-select' }, async () => {
dispatchInputEvents(select);
});
return true;
}
select.value = String(targetOption.value || '');
dispatchInputEvents(select);
await performOperationWithDelay({ stepKey: 'phone-auth', kind: 'select', label: 'phone-country-select' }, async () => {
select.value = String(targetOption.value || '');
dispatchInputEvents(select);
});
await sleep(250);
const nextSelectedOption = getSelectedCountryOption();
return Boolean(nextSelectedOption && isSameCountryOption(nextSelectedOption, targetOption));
@@ -616,7 +627,9 @@
}
clicked += 1;
await humanPause(200, 500);
simulateClick(retryButton);
await performOperationWithDelay({ stepKey: 'phone-auth', kind: 'click', label: 'phone-route-retry' }, async () => {
simulateClick(retryButton);
});
await sleep(1000);
continue;
}
@@ -715,13 +728,19 @@
}
await humanPause(250, 700);
fillInput(phoneInput, nationalPhoneNumber);
await performOperationWithDelay({ stepKey: 'phone-auth', kind: 'fill', label: 'phone-number' }, async () => {
fillInput(phoneInput, nationalPhoneNumber);
});
if (hiddenPhoneNumberInput) {
hiddenPhoneNumberInput.value = phoneNumber;
dispatchInputEvents(hiddenPhoneNumberInput);
await performOperationWithDelay({ stepKey: 'phone-auth', kind: 'hidden-sync', label: 'phone-number-hidden-sync' }, async () => {
hiddenPhoneNumberInput.value = phoneNumber;
dispatchInputEvents(hiddenPhoneNumberInput);
});
}
await sleep(250);
simulateClick(submitButton);
await performOperationWithDelay({ stepKey: 'phone-auth', kind: 'submit', label: 'phone-number-submit' }, async () => {
simulateClick(submitButton);
});
return waitForPhoneVerificationReady();
}
@@ -797,9 +816,13 @@
}
await humanPause(250, 700);
fillInput(codeInput, code);
await performOperationWithDelay({ stepKey: 'phone-auth', kind: 'fill', label: 'phone-verification-code' }, async () => {
fillInput(codeInput, code);
});
await sleep(250);
simulateClick(submitButton);
await performOperationWithDelay({ stepKey: 'phone-auth', kind: 'submit', label: 'phone-verification-submit' }, async () => {
simulateClick(submitButton);
});
if (is405MethodNotAllowedPage()) {
await recoverPhoneRoute405(12000);
}
@@ -850,7 +873,9 @@
const resendButton = getPhoneVerificationResendButton({ allowDisabled: true });
if (resendButton && isActionEnabled(resendButton)) {
await humanPause(250, 700);
simulateClick(resendButton);
await performOperationWithDelay({ stepKey: 'phone-auth', kind: 'click', label: 'phone-verification-resend' }, async () => {
simulateClick(resendButton);
});
await sleep(1000);
if (is405MethodNotAllowedPage()) {
await recoverRoute405WithinResend();
@@ -902,7 +927,9 @@
throw new Error('The auth page is not currently on phone verification or add-phone page.');
}
location.assign('/add-phone');
await performOperationWithDelay({ stepKey: 'phone-auth', kind: 'navigation', label: 'phone-return-add-phone' }, async () => {
location.assign('/add-phone');
});
await waitForAddPhoneReady(timeout);
return {
addPhonePage: true,
+38 -13
View File
@@ -60,6 +60,12 @@ const PAYMENT_METHOD_CONFIGS = {
},
};
async function performOperationWithDelay(metadata, operation) {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
}
if (document.documentElement.getAttribute(PLUS_CHECKOUT_LISTENER_SENTINEL) !== '1') {
document.documentElement.setAttribute(PLUS_CHECKOUT_LISTENER_SENTINEL, '1');
@@ -688,7 +694,9 @@ async function selectPaymentMethod(method = PLUS_PAYMENT_METHOD_PAYPAL) {
intervalMs: 250,
});
console.info(`[MultiPage:plus-checkout] ${config.label} target selected`, summarizeElementForDebug(target));
simulateClick(target);
await performOperationWithDelay({ stepKey: 'plus-checkout-billing', kind: 'select', label: 'select-payment-method' }, async () => {
simulateClick(target);
});
log(`Plus Checkout:已点击 ${config.label} 付款方式,正在确认选中状态。`);
if (!await waitForPaymentMethodActive(config.id)) {
@@ -1417,8 +1425,6 @@ async function fillPlusBillingAndSubmit(payload = {}) {
async function fillPlusBillingAddress(payload = {}) {
await waitForDocumentComplete();
await fillFullName(payload.fullName || '');
const countryText = readCountryText();
const seed = payload.addressSeed || {
query: 'Berlin Mitte',
@@ -1434,10 +1440,21 @@ async function fillPlusBillingAddress(payload = {}) {
const fields = getStructuredAddressFields();
const useDirectStructuredBranch = Boolean(seed.skipAutocomplete || isDropdownStructuredAddressForm(fields));
if (!useDirectStructuredBranch) {
selected = await selectAddressSuggestion(seed);
await performOperationWithDelay({ stepKey: 'plus-checkout-billing', kind: 'fill', label: 'fill-address-query' }, async () => {
await fillFullName(payload.fullName || '');
await fillAddressQuery(seed);
});
selected = await performOperationWithDelay({ stepKey: 'plus-checkout-billing', kind: 'select', label: 'select-address-suggestion' }, async () => (
clickAddressSuggestion(seed)
));
}
const structuredAddress = await ensureStructuredAddress(seed, {
overwrite: useDirectStructuredBranch,
const structuredAddress = await performOperationWithDelay({ stepKey: 'plus-checkout-billing', kind: 'fill', label: 'fill-billing-address' }, async () => {
if (useDirectStructuredBranch) {
await fillFullName(payload.fullName || '');
}
return ensureStructuredAddress(seed, {
overwrite: useDirectStructuredBranch,
});
});
return {
@@ -1449,9 +1466,11 @@ async function fillPlusBillingAddress(payload = {}) {
async function fillPlusAddressQuery(payload = {}) {
await waitForDocumentComplete();
await fillFullName(payload.fullName || '');
const seed = payload.addressSeed || {};
await fillAddressQuery(seed);
await performOperationWithDelay({ stepKey: 'plus-checkout-billing', kind: 'fill', label: 'fill-address-query' }, async () => {
await fillFullName(payload.fullName || '');
await fillAddressQuery(seed);
});
return {
countryText: readCountryText(),
queryFilled: true,
@@ -1460,7 +1479,9 @@ async function fillPlusAddressQuery(payload = {}) {
async function selectPlusAddressSuggestion(payload = {}) {
await waitForDocumentComplete();
const selected = await clickAddressSuggestion(payload.addressSeed || {});
const selected = await performOperationWithDelay({ stepKey: 'plus-checkout-billing', kind: 'select', label: 'select-address-suggestion' }, async () => (
clickAddressSuggestion(payload.addressSeed || {})
));
return {
selectedAddressText: selected.selectedText,
suggestionIndex: selected.suggestionIndex,
@@ -1469,9 +1490,11 @@ async function selectPlusAddressSuggestion(payload = {}) {
async function ensurePlusStructuredBillingAddress(payload = {}) {
await waitForDocumentComplete();
const structuredAddress = await ensureStructuredAddress(payload.addressSeed || {}, {
overwrite: Boolean(payload.overwriteStructuredAddress),
});
const structuredAddress = await performOperationWithDelay({ stepKey: 'plus-checkout-billing', kind: 'fill', label: 'fill-billing-address' }, async () => (
ensureStructuredAddress(payload.addressSeed || {}, {
overwrite: Boolean(payload.overwriteStructuredAddress),
})
));
return {
countryText: readCountryText(),
structuredAddress,
@@ -1494,7 +1517,9 @@ async function clickPlusSubscribe(payload = {}) {
});
await sleep(Math.max(0, Math.floor(Number(payload.beforeClickDelayMs) || 0)));
await humanLikeClick(subscribeButton);
await performOperationWithDelay({ stepKey: 'plus-checkout-billing', kind: 'submit', label: 'click-subscribe' }, async () => {
await humanLikeClick(subscribeButton);
});
return {
clicked: true,
};
+344 -96
View File
@@ -6,6 +6,14 @@ console.log('[MultiPage:signup-page] Content script loaded on', location.href);
const SIGNUP_PAGE_LISTENER_SENTINEL = 'data-multipage-signup-page-listener';
function getOperationDelayRunner() {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function'
? gate
: async (_metadata, operation) => operation();
}
if (document.documentElement.getAttribute(SIGNUP_PAGE_LISTENER_SENTINEL) !== '1') {
document.documentElement.setAttribute(SIGNUP_PAGE_LISTENER_SENTINEL, '1');
@@ -251,6 +259,13 @@ function isEmailVerificationPage() {
}
async function resendVerificationCode(step, timeout = 45000) {
const performOperationWithDelay = typeof getOperationDelayRunner === 'function'
? getOperationDelayRunner()
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
if (step === 8) {
await waitForLoginVerificationPageReady(10000, step);
}
@@ -275,7 +290,9 @@ async function resendVerificationCode(step, timeout = 45000) {
if (action && isActionEnabled(action)) {
log(`步骤 ${step}:重新发送验证码按钮已可用。`);
await humanPause(350, 900);
simulateClick(action);
await performOperationWithDelay({ stepKey: step === 8 ? 'oauth-login' : 'fetch-signup-code', kind: 'click', label: 'resend-verification-code' }, async () => {
simulateClick(action);
});
await sleep(1200);
// After clicking resend, check if 405 error appeared
@@ -955,6 +972,13 @@ function logSignupPasswordDiagnostics(context, level = 'warn') {
}
async function waitForSignupEntryState(options = {}) {
const performOperationWithDelay = typeof getOperationDelayRunner === 'function'
? getOperationDelayRunner()
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
const {
timeout = 15000,
autoOpenEntry = false,
@@ -995,7 +1019,9 @@ async function waitForSignupEntryState(options = {}) {
}
log('步骤 2:检测到手机号输入模式,正在切换到邮箱输入模式...');
await humanPause(350, 900);
simulateClick(snapshot.switchToEmailTrigger);
await performOperationWithDelay({ stepKey: 'signup-entry', kind: 'click', label: 'switch-to-signup-email' }, async () => {
simulateClick(snapshot.switchToEmailTrigger);
});
} else if (!snapshot.switchToEmailTrigger && !loggedMissingSwitchToEmail) {
loggedMissingSwitchToEmail = true;
log('步骤 2:检测到手机号输入模式,但暂未识别到“改用邮箱/继续使用电子邮件地址登录”按钮,继续等待界面稳定...', 'warn');
@@ -1023,7 +1049,9 @@ async function waitForSignupEntryState(options = {}) {
}
log('步骤 2:正在点击官网注册入口...');
await humanPause(350, 900);
simulateClick(snapshot.signupTrigger);
await performOperationWithDelay({ stepKey: 'signup-entry', kind: 'click', label: 'open-signup-entry' }, async () => {
simulateClick(snapshot.signupTrigger);
});
}
}
@@ -1093,6 +1121,13 @@ async function ensureSignupPasswordPageReady(timeout = 20000) {
}
async function fillSignupEmailAndContinue(email, step) {
const performOperationWithDelay = typeof getOperationDelayRunner === 'function'
? getOperationDelayRunner()
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
if (!email) throw new Error(`未提供邮箱地址,步骤 ${step} 无法继续。`);
const normalizedEmail = String(email || '').trim().toLowerCase();
@@ -1123,7 +1158,9 @@ async function fillSignupEmailAndContinue(email, step) {
log(`步骤 ${step}:正在填写邮箱:${email}`);
await humanPause(500, 1400);
fillInput(snapshot.emailInput, email);
await performOperationWithDelay({ stepKey: step === 2 ? 'signup-entry' : 'fill-password', kind: 'fill', label: 'signup-email' }, async () => {
fillInput(snapshot.emailInput, email);
});
log(`步骤 ${step}:邮箱已填写`);
const continueButton = snapshot.continueButton || getSignupEmailContinueButton({ allowDisabled: true });
@@ -1132,16 +1169,18 @@ async function fillSignupEmailAndContinue(email, step) {
}
log(`步骤 ${step}:邮箱已准备提交,正在前往密码页...`);
window.setTimeout(() => {
try {
throwIfStopped();
try {
await sleep(120);
throwIfStopped();
await performOperationWithDelay({ stepKey: step === 2 ? 'signup-entry' : 'fill-password', kind: 'submit', label: 'submit-signup-email' }, async () => {
simulateClick(continueButton);
} catch (error) {
if (!isStopError(error)) {
console.error('[MultiPage:signup-page] deferred signup email submit failed:', error?.message || error);
}
});
} catch (error) {
if (!isStopError(error)) {
console.error('[MultiPage:signup-page] signup email submit failed:', error?.message || error);
}
}, 120);
throw error;
}
return {
submitted: true,
@@ -1655,6 +1694,13 @@ function findSignupPhoneCountryOptionByPhoneNumber(phoneInput, phoneNumber) {
}
async function trySelectSignupPhoneCountryOption(select, targetOption, phoneInput = getSignupPhoneInput(), options = {}) {
const performOperationWithDelay = typeof getOperationDelayRunner === 'function'
? getOperationDelayRunner()
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
if (!select || !targetOption) {
return false;
}
@@ -1662,12 +1708,16 @@ async function trySelectSignupPhoneCountryOption(select, targetOption, phoneInpu
? (select.options?.[select.selectedIndex] || null)
: null;
if (selectedOption && isSameSignupCountryOption(selectedOption, targetOption)) {
dispatchSignupPhoneFieldEvents(select);
await performOperationWithDelay({ stepKey: 'signup-phone-entry', kind: 'select', label: 'signup-phone-country-select' }, async () => {
dispatchSignupPhoneFieldEvents(select);
});
await sleep(120);
return isSignupPhoneCountrySelectionSynced(phoneInput, targetOption, options);
}
select.value = String(targetOption.value || '');
dispatchSignupPhoneFieldEvents(select);
await performOperationWithDelay({ stepKey: 'signup-phone-entry', kind: 'select', label: 'signup-phone-country-select' }, async () => {
select.value = String(targetOption.value || '');
dispatchSignupPhoneFieldEvents(select);
});
await sleep(250);
return isSignupPhoneCountrySelectionSynced(phoneInput, targetOption, options);
}
@@ -1722,6 +1772,13 @@ function findSignupPhoneCountryListboxOption(targetOption, options = {}) {
}
async function trySelectSignupPhoneCountryListboxOption(phoneInput, targetOption, options = {}) {
const performOperationWithDelay = typeof getOperationDelayRunner === 'function'
? getOperationDelayRunner()
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
const button = getSignupPhoneCountryButton(phoneInput);
if (!button) {
return false;
@@ -1802,7 +1859,9 @@ async function trySelectSignupPhoneCountryListboxOption(phoneInput, targetOption
return scrolled;
};
simulateClick(button);
await performOperationWithDelay({ stepKey: 'signup-phone-entry', kind: 'click', label: 'open-signup-phone-country-listbox' }, async () => {
simulateClick(button);
});
await sleep(200);
resetListboxScroll();
@@ -1812,7 +1871,9 @@ async function trySelectSignupPhoneCountryListboxOption(phoneInput, targetOption
throwIfStopped();
const option = findSignupPhoneCountryListboxOption(targetOption, options);
if (option) {
simulateClick(option);
await performOperationWithDelay({ stepKey: 'signup-phone-entry', kind: 'select', label: 'signup-phone-country-listbox-option' }, async () => {
simulateClick(option);
});
await sleep(450);
if (isSignupPhoneCountrySelectionSynced(phoneInput, targetOption, options)) {
return true;
@@ -2108,6 +2169,13 @@ function getLoginPhoneInputCandidateDiagnostics(limit = 12) {
}
async function fillLoginPhoneInputAndConfirm(phoneInput, options = {}) {
const performOperationWithDelay = typeof getOperationDelayRunner === 'function'
? getOperationDelayRunner()
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
const {
phoneNumber = '',
dialCode = '',
@@ -2132,7 +2200,9 @@ async function fillLoginPhoneInputAndConfirm(phoneInput, options = {}) {
const fillCandidates = getLoginPhoneFillCandidates(phoneNumber, dialCode, currentInput);
for (const attemptedValue of fillCandidates) {
currentInput.focus?.();
fillInput(currentInput, attemptedValue);
await performOperationWithDelay({ stepKey: 'oauth-login', kind: 'fill', label: 'login-phone-number' }, async () => {
fillInput(currentInput, attemptedValue);
});
lastVerification = await waitForPhoneInputValue(currentInput, inputValue, {
resolvePhoneInput,
phoneNumber,
@@ -2142,7 +2212,9 @@ async function fillLoginPhoneInputAndConfirm(phoneInput, options = {}) {
});
if (lastVerification.ok) {
const verifiedInput = lastVerification.input || currentInput;
const hiddenSync = syncPhoneHiddenFormValue(verifiedInput, { phoneNumber, dialCode, inputValue });
const hiddenSync = await performOperationWithDelay({ stepKey: 'oauth-login', kind: 'hidden-sync', label: 'login-phone-hidden-sync' }, async () => (
syncPhoneHiddenFormValue(verifiedInput, { phoneNumber, dialCode, inputValue })
));
const expectedHiddenDigits = normalizePhoneDigits(phoneNumber) || `${normalizePhoneDigits(dialCode)}${normalizePhoneDigits(inputValue)}`;
if (hiddenSync && expectedHiddenDigits && normalizePhoneDigits(hiddenSync.value) !== expectedHiddenDigits) {
throw new Error(`\u6b65\u9aa4 ${visibleStep}\uff1a\u624b\u673a\u53f7\u9690\u85cf\u63d0\u4ea4\u5b57\u6bb5\u540c\u6b65\u5931\u8d25\uff0c\u671f\u671b ${expectedHiddenDigits}\uff0c\u5b9e\u9645 ${normalizePhoneDigits(hiddenSync.value) || '\u7a7a'}\u3002`);
@@ -2198,6 +2270,13 @@ function resolveSignupPhoneDialCode(phoneInput, options = {}) {
}
async function waitForSignupPhoneEntryState(options = {}) {
const performOperationWithDelay = typeof getOperationDelayRunner === 'function'
? getOperationDelayRunner()
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
const {
timeout = 20000,
step = 2,
@@ -2226,14 +2305,18 @@ async function waitForSignupPhoneEntryState(options = {}) {
lastSwitchToPhoneAt = Date.now();
log(`步骤 ${step}:检测到邮箱输入模式,正在切换到手机号注册入口...`);
await humanPause(350, 900);
simulateClick(switchToPhone);
await performOperationWithDelay({ stepKey: 'signup-phone-entry', kind: 'click', label: 'switch-to-signup-phone' }, async () => {
simulateClick(switchToPhone);
});
} else {
const moreOptionsTrigger = findSignupMoreOptionsTrigger();
if (moreOptionsTrigger && Date.now() - lastMoreOptionsClickAt >= 1500) {
lastMoreOptionsClickAt = Date.now();
log(`步骤 ${step}:手机号入口可能隐藏在更多选项中,正在展开更多选项...`);
await humanPause(350, 900);
simulateClick(moreOptionsTrigger);
await performOperationWithDelay({ stepKey: 'signup-phone-entry', kind: 'click', label: 'signup-phone-more-options' }, async () => {
simulateClick(moreOptionsTrigger);
});
} else if (!switchToPhone && !slowSnapshotLogged && Date.now() - start >= 5000) {
slowSnapshotLogged = true;
log(`步骤 ${step}:尚未找到手机号入口,页面诊断快照:${JSON.stringify(getSignupEntryDiagnostics())}`, 'warn');
@@ -2248,7 +2331,9 @@ async function waitForSignupPhoneEntryState(options = {}) {
lastTriggerClickAt = Date.now();
log(`步骤 ${step}:正在点击官网注册入口...`);
await humanPause(350, 900);
simulateClick(snapshot.signupTrigger);
await performOperationWithDelay({ stepKey: 'signup-phone-entry', kind: 'click', label: 'open-signup-entry' }, async () => {
simulateClick(snapshot.signupTrigger);
});
}
await sleep(250);
continue;
@@ -2268,6 +2353,13 @@ async function waitForSignupPhoneEntryState(options = {}) {
}
async function submitSignupPhoneNumberAndContinue(payload = {}) {
const performOperationWithDelay = typeof getOperationDelayRunner === 'function'
? getOperationDelayRunner()
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
const phoneNumber = String(payload.phoneNumber || '').trim();
const countryLabel = String(payload.countryLabel || '').trim();
if (!phoneNumber) {
@@ -2312,11 +2404,15 @@ async function submitSignupPhoneNumberAndContinue(payload = {}) {
log(`步骤 2:正在填写手机号:${phoneNumber}`);
await humanPause(500, 1400);
fillInput(snapshot.phoneInput, inputValue);
await performOperationWithDelay({ stepKey: 'signup-phone-entry', kind: 'fill', label: 'signup-phone-number' }, async () => {
fillInput(snapshot.phoneInput, inputValue);
});
const hiddenPhoneNumberInput = getSignupPhoneHiddenNumberInput(snapshot.phoneInput);
const e164PhoneNumber = toE164PhoneNumber(phoneNumber, dialCode);
if (hiddenPhoneNumberInput && e164PhoneNumber) {
fillInput(hiddenPhoneNumberInput, e164PhoneNumber);
await performOperationWithDelay({ stepKey: 'signup-phone-entry', kind: 'hidden-sync', label: 'signup-phone-hidden-sync' }, async () => {
fillInput(hiddenPhoneNumberInput, e164PhoneNumber);
});
}
log(`步骤 2:手机号已填写:${phoneNumber}${dialCode ? `(区号 +${dialCode},本地号 ${inputValue}` : ''}`);
@@ -2326,16 +2422,18 @@ async function submitSignupPhoneNumberAndContinue(payload = {}) {
}
log('步骤 2:手机号已准备提交,正在前往下一页...');
window.setTimeout(() => {
try {
throwIfStopped();
try {
await sleep(120);
throwIfStopped();
await performOperationWithDelay({ stepKey: 'signup-phone-entry', kind: 'submit', label: 'submit-signup-phone' }, async () => {
simulateClick(continueButton);
} catch (error) {
if (!isStopError(error)) {
console.error('[MultiPage:signup-page] deferred signup phone submit failed:', error?.message || error);
}
});
} catch (error) {
if (!isStopError(error)) {
console.error('[MultiPage:signup-page] signup phone submit failed:', error?.message || error);
}
}, 120);
throw error;
}
return {
submitted: true,
@@ -2363,6 +2461,13 @@ async function step2_clickRegister(payload = {}) {
// ============================================================
async function step3_fillEmailPassword(payload) {
const performOperationWithDelay = typeof getOperationDelayRunner === 'function'
? getOperationDelayRunner()
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
const { email, password } = payload;
if (!password) throw new Error('未提供密码,步骤 3 需要可用密码。');
const normalizedEmail = String(email || '').trim().toLowerCase();
@@ -2426,7 +2531,9 @@ async function step3_fillEmailPassword(payload) {
}
await humanPause(600, 1500);
fillInput(snapshot.passwordInput, password);
await performOperationWithDelay({ stepKey: 'fill-password', kind: 'fill', label: 'signup-password' }, async () => {
fillInput(snapshot.passwordInput, password);
});
log('步骤 3:密码已填写');
const submitBtn = snapshot.submitButton
@@ -2439,8 +2546,6 @@ async function step3_fillEmailPassword(payload) {
logSignupPasswordDiagnostics('步骤 3:当前密码页同时存在一次性验证码入口', 'info');
}
// Report complete BEFORE submit, because submit causes page navigation
// which kills the content script connection
const signupVerificationRequestedAt = submitBtn ? Date.now() : null;
const completionPayload = {
email,
@@ -2450,6 +2555,7 @@ async function step3_fillEmailPassword(payload) {
signupVerificationRequestedAt,
deferredSubmit: Boolean(submitBtn),
};
reportComplete(3, completionPayload);
if (submitBtn) {
@@ -2458,7 +2564,9 @@ async function step3_fillEmailPassword(payload) {
throwIfStopped();
await sleep(500);
await humanPause(500, 1300);
simulateClick(submitBtn);
await performOperationWithDelay({ stepKey: 'fill-password', kind: 'submit', label: 'submit-signup-password' }, async () => {
simulateClick(submitBtn);
});
log('步骤 3:表单已提交');
} catch (error) {
if (!isStopError(error)) {
@@ -3282,6 +3390,13 @@ function getCurrentAuthRetryPageState(flow = 'auth') {
}
async function recoverCurrentAuthRetryPage(payload = {}) {
const performOperationWithDelay = typeof getOperationDelayRunner === 'function'
? getOperationDelayRunner()
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
const {
flow = 'auth',
logLabel = '',
@@ -3300,6 +3415,7 @@ async function recoverCurrentAuthRetryPage(payload = {}) {
maxClickAttempts,
pathPatterns: resolvedPathPatterns,
step,
stepKey: step === 8 || flow === 'login' ? 'oauth-login' : 'fetch-signup-code',
timeoutMs,
waitAfterClickMs,
});
@@ -3332,7 +3448,9 @@ async function recoverCurrentAuthRetryPage(payload = {}) {
clickCount += 1;
log(`${logLabel || `步骤 ${step || '?'}:检测到重试页,正在点击“重试”恢复`}(第 ${clickCount} 次)...`, 'warn');
await humanPause(300, 800);
simulateClick(retryState.retryButton);
await performOperationWithDelay({ stepKey: step === 8 || flow === 'login' ? 'oauth-login' : 'fetch-signup-code', kind: 'click', label: 'auth-retry-click' }, async () => {
simulateClick(retryState.retryButton);
});
const settleStart = Date.now();
while (Date.now() - settleStart < waitAfterClickMs) {
throwIfStopped();
@@ -4369,28 +4487,37 @@ function throwForStep6FatalState(snapshot, visibleStep = 7) {
async function triggerLoginSubmitAction(button, fallbackField) {
const form = button?.form || fallbackField?.form || button?.closest?.('form') || fallbackField?.closest?.('form') || null;
const performOperationWithDelay = typeof getOperationDelayRunner === 'function'
? getOperationDelayRunner()
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
await humanPause(400, 1100);
if (button && isActionEnabled(button)) {
simulateClick(button);
return;
}
if (form && typeof form.requestSubmit === 'function') {
if (button && button.form === form) {
form.requestSubmit(button);
} else {
form.requestSubmit();
await performOperationWithDelay({ stepKey: 'oauth-login', kind: 'submit', label: 'login-submit' }, async () => {
if (button && isActionEnabled(button)) {
simulateClick(button);
return;
}
return;
}
if (button && typeof button.click === 'function') {
button.click();
return;
}
if (form && typeof form.requestSubmit === 'function') {
if (button && button.form === form) {
form.requestSubmit(button);
} else {
form.requestSubmit();
}
return;
}
throw new Error('未找到可用的登录提交按钮。URL: ' + location.href);
if (button && typeof button.click === 'function') {
button.click();
return;
}
throw new Error('未找到可用的登录提交按钮。URL: ' + location.href);
});
}
function isSignupPasswordErrorPage() {
@@ -4480,6 +4607,13 @@ async function waitForSignupVerificationTransition(timeout = 5000) {
}
async function prepareSignupVerificationFlow(payload = {}, timeout = 30000) {
const performOperationWithDelay = typeof getOperationDelayRunner === 'function'
? getOperationDelayRunner()
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
const { password } = payload;
const prepareSource = String(payload?.prepareSource || '').trim() || 'step4_execute';
const prepareLogLabel = String(payload?.prepareLogLabel || '').trim()
@@ -4554,13 +4688,17 @@ async function prepareSignupVerificationFlow(payload = {}, timeout = 30000) {
if ((snapshot.passwordInput.value || '') !== password) {
log(`${prepareLogLabel}:页面仍停留在密码页,正在重新填写密码...`, 'warn');
await humanPause(450, 1100);
fillInput(snapshot.passwordInput, password);
await performOperationWithDelay({ stepKey: 'fill-password', kind: 'fill', label: 'retry-signup-password' }, async () => {
fillInput(snapshot.passwordInput, password);
});
}
if (snapshot.submitButton && isActionEnabled(snapshot.submitButton)) {
log(`${prepareLogLabel}:页面仍停留在密码页,正在重新点击“继续”(第 ${recoveryRound}/${maxRecoveryRounds} 次)...`, 'warn');
await humanPause(350, 900);
simulateClick(snapshot.submitButton);
await performOperationWithDelay({ stepKey: 'fill-password', kind: 'submit', label: 'retry-submit-signup-password' }, async () => {
simulateClick(snapshot.submitButton);
});
await sleep(1200);
continue;
}
@@ -4763,6 +4901,13 @@ async function waitForSplitVerificationInputsFilled(inputs, code, timeout = 2500
async function fillVerificationCode(step, payload) {
const { code, signupProfile } = payload;
if (!code) throw new Error('未提供验证码。');
const performOperationWithDelay = typeof getOperationDelayRunner === 'function'
? getOperationDelayRunner()
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
if (step === 4) {
const postVerificationState = getStep4PostVerificationState();
@@ -4854,17 +4999,18 @@ async function fillVerificationCode(step, payload) {
if (splitInputs?.length >= 6) {
log(`步骤 ${step}:发现分开的单字符验证码输入框,正在逐个填写...`);
for (let i = 0; i < 6 && i < splitInputs.length; i++) {
const targetInput = splitInputs[i];
try {
targetInput.focus?.();
} catch {}
fillInput(splitInputs[i], code[i]);
try {
targetInput.dispatchEvent(new KeyboardEvent('keyup', { key: code[i], bubbles: true }));
} catch {}
await sleep(100);
}
await performOperationWithDelay({ stepKey: 'fetch-signup-code', kind: 'grouped-code', label: 'split-code' }, async () => {
for (let i = 0; i < 6 && i < splitInputs.length; i++) {
const targetInput = splitInputs[i];
try {
targetInput.focus?.();
} catch {}
fillInput(splitInputs[i], code[i]);
try {
targetInput.dispatchEvent(new KeyboardEvent('keyup', { key: code[i], bubbles: true }));
} catch {}
}
});
const filled = await waitForSplitVerificationInputsFilled(splitInputs, code, 2500);
if (!filled) {
const current = Array.from(splitInputs)
@@ -4880,7 +5026,9 @@ async function fillVerificationCode(step, payload) {
const splitSubmitBtn = await waitForVerificationSubmitButton(splitInputs[0], 2000).catch(() => null);
if (splitSubmitBtn) {
await humanPause(450, 1200);
simulateClick(splitSubmitBtn);
await performOperationWithDelay({ stepKey: 'fetch-signup-code', kind: 'submit', label: 'submit-code' }, async () => {
simulateClick(splitSubmitBtn);
});
log(`步骤 ${step}:分格验证码已提交`);
} else {
log(`步骤 ${step}:分格验证码页面未找到可点击提交按钮,继续等待页面自动推进。`, 'info');
@@ -4906,7 +5054,9 @@ async function fillVerificationCode(step, payload) {
throw new Error('未找到验证码输入框。URL: ' + location.href);
}
fillInput(codeInput, code);
await performOperationWithDelay({ stepKey: step === 8 ? 'oauth-login' : 'fetch-signup-code', kind: 'fill', label: 'verification-code' }, async () => {
fillInput(codeInput, code);
});
log(`步骤 ${step}:验证码已填写`);
// Submit
@@ -4915,7 +5065,9 @@ async function fillVerificationCode(step, payload) {
if (submitBtn) {
await humanPause(450, 1200);
simulateClick(submitBtn);
await performOperationWithDelay({ stepKey: step === 8 ? 'oauth-login' : 'fetch-signup-code', kind: 'submit', label: 'submit-code' }, async () => {
simulateClick(submitBtn);
});
log(`步骤 ${step}:验证码已提交`);
} else {
log(`步骤 ${step}:未找到可提交的验证码按钮,先等待页面自动推进或反馈结果。`, 'warn');
@@ -5199,6 +5351,13 @@ async function waitForPhoneLoginEntrySwitchTransition(timeout = 10000) {
}
async function step6OpenLoginEntry(payload, snapshot) {
const performOperationWithDelay = typeof getOperationDelayRunner === 'function'
? getOperationDelayRunner()
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
const visibleStep = Math.floor(Number(payload?.visibleStep) || 0) || 7;
const currentSnapshot = normalizeStep6Snapshot(snapshot || inspectLoginAuthState());
const preferPhoneLogin = String(payload?.loginIdentifierType || '').trim() === 'phone' || (!payload?.email && payload?.phoneNumber);
@@ -5215,7 +5374,9 @@ async function step6OpenLoginEntry(payload, snapshot) {
log(`检测到登录入口页,正在点击 "${getActionText(trigger).slice(0, 80)}"...`, 'info', { step: visibleStep, stepKey: 'oauth-login' });
await humanPause(350, 900);
simulateClick(trigger);
await performOperationWithDelay({ stepKey: 'oauth-login', kind: 'click', label: 'open-login-entry' }, async () => {
simulateClick(trigger);
});
const nextSnapshot = await waitForLoginEntryOpenTransition();
if (nextSnapshot.state === 'email_page') {
@@ -5267,6 +5428,13 @@ async function step6OpenLoginEntry(payload, snapshot) {
}
async function step6SwitchToOneTimeCodeLogin(payload, snapshot) {
const performOperationWithDelay = typeof getOperationDelayRunner === 'function'
? getOperationDelayRunner()
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
const visibleStep = Math.floor(Number(payload?.visibleStep) || 0) || 7;
const switchTrigger = snapshot?.switchTrigger || findOneTimeCodeLoginTrigger();
if (!switchTrigger || !isActionEnabled(switchTrigger)) {
@@ -5278,7 +5446,9 @@ async function step6SwitchToOneTimeCodeLogin(payload, snapshot) {
log('已检测到一次性验证码登录入口,准备切换...', 'info', { step: visibleStep, stepKey: 'oauth-login' });
const loginVerificationRequestedAt = Date.now();
await humanPause(350, 900);
simulateClick(switchTrigger);
await performOperationWithDelay({ stepKey: 'oauth-login', kind: 'click', label: 'switch-one-time-code-login' }, async () => {
simulateClick(switchTrigger);
});
log('已点击一次性验证码登录', 'info', { step: visibleStep, stepKey: 'oauth-login' });
await sleep(1200);
const result = await waitForStep6SwitchTransition(loginVerificationRequestedAt, 10000, { visibleStep });
@@ -5305,6 +5475,13 @@ async function step6SwitchToOneTimeCodeLogin(payload, snapshot) {
}
async function step6LoginFromPhonePage(payload, snapshot) {
const performOperationWithDelay = typeof getOperationDelayRunner === 'function'
? getOperationDelayRunner()
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
const visibleStep = Math.floor(Number(payload?.visibleStep) || 0) || 7;
const currentSnapshot = normalizeStep6Snapshot(snapshot || inspectLoginAuthState());
const phoneInput = currentSnapshot.phoneInput || getLoginPhoneInput();
@@ -5358,7 +5535,9 @@ async function step6LoginFromPhonePage(payload, snapshot) {
await sleep(500);
const verifiedPhoneInput = fillResult.input || phoneInput;
const hiddenSync = syncPhoneHiddenFormValue(verifiedPhoneInput, { phoneNumber, dialCode, inputValue });
const hiddenSync = await performOperationWithDelay({ stepKey: 'oauth-login', kind: 'hidden-sync', label: 'login-phone-pre-submit-hidden-sync' }, async () => (
syncPhoneHiddenFormValue(verifiedPhoneInput, { phoneNumber, dialCode, inputValue })
));
const submitButton = getLoginSubmitButton({ allowDisabled: true }) || currentSnapshot.submitButton;
const preSubmitRenderedValue = getPhoneInputRenderedValue(verifiedPhoneInput);
const preSubmitHiddenInput = hiddenSync?.input || getPhoneHiddenValueInput(verifiedPhoneInput);
@@ -5416,6 +5595,13 @@ async function step6LoginFromPhonePage(payload, snapshot) {
}
async function switchFromEmailPageToPhoneLogin(payload, snapshot) {
const performOperationWithDelay = typeof getOperationDelayRunner === 'function'
? getOperationDelayRunner()
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
const visibleStep = Math.floor(Number(payload?.visibleStep) || 0) || 7;
let currentSnapshot = normalizeStep6Snapshot(snapshot || inspectLoginAuthState());
let phoneEntryTrigger = currentSnapshot.phoneEntryTrigger || findLoginPhoneEntryTrigger();
@@ -5427,7 +5613,9 @@ async function switchFromEmailPageToPhoneLogin(payload, snapshot) {
stepKey: 'oauth-login',
});
await humanPause(350, 900);
simulateClick(moreOptionsTrigger);
await performOperationWithDelay({ stepKey: 'oauth-login', kind: 'click', label: 'login-more-options' }, async () => {
simulateClick(moreOptionsTrigger);
});
await sleep(800);
currentSnapshot = normalizeStep6Snapshot(inspectLoginAuthState());
phoneEntryTrigger = currentSnapshot.phoneEntryTrigger || findLoginPhoneEntryTrigger();
@@ -5442,7 +5630,9 @@ async function switchFromEmailPageToPhoneLogin(payload, snapshot) {
log(`步骤 ${visibleStep}:当前在邮箱入口,正在切换到手机号登录...`, 'info', { step: visibleStep, stepKey: 'oauth-login' });
await humanPause(350, 900);
simulateClick(phoneEntryTrigger);
await performOperationWithDelay({ stepKey: 'oauth-login', kind: 'click', label: 'switch-phone-login' }, async () => {
simulateClick(phoneEntryTrigger);
});
const nextSnapshot = normalizeStep6Snapshot(await waitForPhoneLoginEntrySwitchTransition(20000));
if (nextSnapshot.state === 'phone_entry_page') {
return step6LoginFromPhonePage(payload, nextSnapshot);
@@ -5491,6 +5681,13 @@ async function switchFromEmailPageToPhoneLogin(payload, snapshot) {
}
async function step6LoginFromPasswordPage(payload, snapshot) {
const performOperationWithDelay = typeof getOperationDelayRunner === 'function'
? getOperationDelayRunner()
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
const visibleStep = Math.floor(Number(payload?.visibleStep) || 0) || 7;
const currentSnapshot = normalizeStep6Snapshot(snapshot || inspectLoginAuthState());
const hasPassword = Boolean(String(payload?.password || '').trim());
@@ -5509,7 +5706,9 @@ async function step6LoginFromPasswordPage(payload, snapshot) {
log('已进入密码页,准备填写密码...', 'info', { step: visibleStep, stepKey: 'oauth-login' });
await humanPause(550, 1450);
fillInput(currentSnapshot.passwordInput, payload.password);
await performOperationWithDelay({ stepKey: 'oauth-login', kind: 'fill', label: 'login-password' }, async () => {
fillInput(currentSnapshot.passwordInput, payload.password);
});
log('已填写密码', 'info', { step: visibleStep, stepKey: 'oauth-login' });
await sleep(500);
@@ -5560,6 +5759,13 @@ async function step6LoginFromPasswordPage(payload, snapshot) {
}
async function step6LoginFromEmailPage(payload, snapshot) {
const performOperationWithDelay = typeof getOperationDelayRunner === 'function'
? getOperationDelayRunner()
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
const visibleStep = Math.floor(Number(payload?.visibleStep) || 0) || 7;
const currentSnapshot = normalizeStep6Snapshot(snapshot || inspectLoginAuthState());
if (String(payload?.loginIdentifierType || '').trim() === 'phone' && payload?.phoneNumber) {
@@ -5572,7 +5778,9 @@ async function step6LoginFromEmailPage(payload, snapshot) {
if ((emailInput.value || '').trim() !== payload.email) {
await humanPause(500, 1400);
fillInput(emailInput, payload.email);
await performOperationWithDelay({ stepKey: 'oauth-login', kind: 'fill', label: 'login-email' }, async () => {
fillInput(emailInput, payload.email);
});
log('已填写邮箱', 'info', { step: visibleStep, stepKey: 'oauth-login' });
} else {
log('邮箱已在输入框中,准备提交...', 'info', { step: visibleStep, stepKey: 'oauth-login' });
@@ -5783,6 +5991,13 @@ async function waitForAddEmailSubmitOutcome(timeout = 45000) {
}
async function submitAddEmailAndContinue(payload = {}) {
const performOperationWithDelay = typeof getOperationDelayRunner === 'function'
? getOperationDelayRunner()
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
const email = String(payload.email || '').trim().toLowerCase();
if (!email) {
throw new Error('未提供邮箱地址,无法添加邮箱。');
@@ -5795,7 +6010,9 @@ async function submitAddEmailAndContinue(payload = {}) {
}
await humanPause(500, 1400);
fillInput(emailInput, email);
await performOperationWithDelay({ stepKey: 'oauth-login', kind: 'fill', label: 'add-email' }, async () => {
fillInput(emailInput, email);
});
log(`步骤 8:已填写邮箱:${email}`);
await sleep(500);
@@ -6087,6 +6304,13 @@ async function waitForCombinedSignupVerificationProfilePage(timeout = 2500) {
async function step5_fillNameBirthday(payload) {
const { firstName, lastName, age, year, month, day, prefillOnly = false } = payload;
if (!firstName || !lastName) throw new Error('未提供姓名数据。');
const performOperationWithDelay = typeof getOperationDelayRunner === 'function'
? getOperationDelayRunner()
: async (metadata, operation) => {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
return typeof gate === 'function' ? gate(metadata, operation) : operation();
};
const resolvedAge = age ?? (year ? new Date().getFullYear() - Number(year) : null);
const hasBirthdayData = [year, month, day].every(value => value != null && !Number.isNaN(Number(value)));
@@ -6113,7 +6337,9 @@ async function step5_fillNameBirthday(payload) {
throw new Error('未找到姓名输入框。URL: ' + location.href);
}
await humanPause(500, 1300);
fillInput(nameInput, fullName);
await performOperationWithDelay({ stepKey: 'fill-profile', kind: 'fill', label: 'fill-name' }, async () => {
fillInput(nameInput, fullName);
});
log(`步骤 5:姓名已填写:${fullName}`);
let birthdayMode = false;
@@ -6183,11 +6409,17 @@ async function step5_fillNameBirthday(payload) {
log('步骤 5:检测到 React Aria 下拉生日字段,正在填写生日...');
await humanPause(450, 1100);
await setReactAriaBirthdaySelect(yearReactSelect, year);
await performOperationWithDelay({ stepKey: 'fill-profile', kind: 'select', label: 'select-birthday-year' }, async () => {
await setReactAriaBirthdaySelect(yearReactSelect, year);
});
await humanPause(250, 650);
await setReactAriaBirthdaySelect(monthReactSelect, month);
await performOperationWithDelay({ stepKey: 'fill-profile', kind: 'select', label: 'select-birthday-month' }, async () => {
await setReactAriaBirthdaySelect(monthReactSelect, month);
});
await humanPause(250, 650);
await setReactAriaBirthdaySelect(dayReactSelect, day);
await performOperationWithDelay({ stepKey: 'fill-profile', kind: 'select', label: 'select-birthday-day' }, async () => {
await setReactAriaBirthdaySelect(dayReactSelect, day);
});
if (hiddenBirthday) {
const start = Date.now();
@@ -6228,20 +6460,28 @@ async function step5_fillNameBirthday(payload) {
}
await humanPause(450, 1100);
await setSpinButton(yearSpinner, year);
await performOperationWithDelay({ stepKey: 'fill-profile', kind: 'fill', label: 'fill-birthday-year' }, async () => {
await setSpinButton(yearSpinner, year);
});
await humanPause(250, 650);
await setSpinButton(monthSpinner, String(month).padStart(2, '0'));
await performOperationWithDelay({ stepKey: 'fill-profile', kind: 'fill', label: 'fill-birthday-month' }, async () => {
await setSpinButton(monthSpinner, String(month).padStart(2, '0'));
});
await humanPause(250, 650);
await setSpinButton(daySpinner, String(day).padStart(2, '0'));
await performOperationWithDelay({ stepKey: 'fill-profile', kind: 'fill', label: 'fill-birthday-day' }, async () => {
await setSpinButton(daySpinner, String(day).padStart(2, '0'));
});
log(`步骤 5:生日已填写:${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`);
}
const hiddenBirthday = document.querySelector('input[name="birthday"]');
if (hiddenBirthday) {
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
hiddenBirthday.value = dateStr;
hiddenBirthday.dispatchEvent(new Event('input', { bubbles: true }));
hiddenBirthday.dispatchEvent(new Event('change', { bubbles: true }));
await performOperationWithDelay({ stepKey: 'fill-profile', kind: 'hidden-sync', label: 'profile-dom-sync' }, async () => {
hiddenBirthday.value = dateStr;
hiddenBirthday.dispatchEvent(new Event('input', { bubbles: true }));
hiddenBirthday.dispatchEvent(new Event('change', { bubbles: true }));
});
log(`步骤 5:已设置隐藏生日输入框:${dateStr}`);
}
} else if (ageInput) {
@@ -6249,7 +6489,9 @@ async function step5_fillNameBirthday(payload) {
throw new Error('检测到年龄字段,但未提供年龄数据。');
}
await humanPause(500, 1300);
fillInput(ageInput, String(resolvedAge));
await performOperationWithDelay({ stepKey: 'fill-profile', kind: 'fill', label: 'fill-birthday' }, async () => {
fillInput(ageInput, String(resolvedAge));
});
log(`步骤 5:年龄已填写:${resolvedAge}`);
} else {
throw new Error('未找到生日或年龄输入项。URL: ' + location.href);
@@ -6261,15 +6503,19 @@ async function step5_fillNameBirthday(payload) {
if (!isStep5CheckboxChecked(allConsentCheckbox)) {
const checkboxLabel = allConsentCheckbox.closest('label');
await humanPause(500, 1500);
if (checkboxLabel && isVisibleElement(checkboxLabel)) {
simulateClick(checkboxLabel);
} else {
simulateClick(allConsentCheckbox);
}
await performOperationWithDelay({ stepKey: 'fill-profile', kind: 'click', label: 'accept-profile-consent' }, async () => {
if (checkboxLabel && isVisibleElement(checkboxLabel)) {
simulateClick(checkboxLabel);
} else {
simulateClick(allConsentCheckbox);
}
});
await sleep(250);
if (!isStep5CheckboxChecked(allConsentCheckbox)) {
allConsentCheckbox.click();
await performOperationWithDelay({ stepKey: 'fill-profile', kind: 'click', label: 'accept-profile-consent-fallback' }, async () => {
allConsentCheckbox.click();
});
await sleep(250);
}
@@ -6303,7 +6549,9 @@ async function step5_fillNameBirthday(payload) {
}
await humanPause(500, 1300);
simulateClick(completeBtn);
await performOperationWithDelay({ stepKey: 'fill-profile', kind: 'submit', label: 'submit-profile' }, async () => {
simulateClick(completeBtn);
});
const completionPayload = getStep5DirectCompletionPayload({ isAgeMode });
reportComplete(5, completionPayload);
@@ -0,0 +1,136 @@
# Operation Delay Drift Adjudication Ledger
Immutable spec path: `/root/projects/resister-codex/codex-oauth-automation-extension-upstream-base-vu7.3/codex-oauth-automation-extension/.worktrees/feature-operation-delay-ultra73/docs/superpowers/specs/2026-05-07-operation-delay-design.md`
Immutable plan path: `/root/projects/resister-codex/codex-oauth-automation-extension-upstream-base-vu7.3/codex-oauth-automation-extension/.worktrees/feature-operation-delay-ultra73/docs/superpowers/plans/2026-05-07-operation-delay-plan.md`
Controller-only mutation rule: only the P9/controller may create, append, supersede, close, summarize, or otherwise mutate this ledger. Subagents may read it, cite `DRIFT_ID`, report evidence, and request supersession, but may not edit it directly.
## Active Adjudications Summary
| DRIFT_ID | STATUS | SCOPE | P9_FINAL_ADJUDICATION | EFFECTIVE_EXECUTION_CONTRACT |
|---|---|---|---|---|
| DRIFT-2026-05-08-T2-001 | ACTIVE | Task 2 sidepanel switch copy | PLAN_DRIFT_CONTINUE_SAFE | Use sidepanel switch copy that communicates input, selection, click, submit, continue, authorization, and 2 seconds; do not regress to the narrower frozen-plan snippet. |
| DRIFT-2026-05-08-T3-001 | ACTIVE | Task 3 2925 provider metadata test scope | PLAN_DRIFT_CONTINUE_SAFE | Treat `tests/background-icloud-mail-provider.test.js` as in scope for Task 3 checkpoint because it asserts the 2925 provider metadata changed by the required operation-delay injection. |
| DRIFT-2026-05-08-T4-001 | ACTIVE | Task 4 Step 3 direct-complete test scope | PLAN_DRIFT_CONTINUE_SAFE | Treat `tests/step3-direct-complete.test.js` as in scope for Task 4 checkpoint because it verifies Step 3 completion-before-navigation plus deferred password-submit operation-delay behavior. |
| DRIFT-2026-05-08-T7-001 | ACTIVE | Task 7 mail-2925 full-suite harness scope | PLAN_DRIFT_CONTINUE_SAFE | Treat `tests/mail-2925-content.test.js` as in scope for Task 7 only for a narrow stale-harness fix providing `performOperationWithDelay` to the existing `ensureAgreementChecked` harness. |
## DRIFT-2026-05-08-T2-001
DRIFT_ID: DRIFT-2026-05-08-T2-001
STATUS: ACTIVE
SUPERSEDES_DRIFT_ID: NONE
SUPERSEDED_BY_DRIFT_ID: NONE
SCOPE: Task 2 sidepanel switch copy
TRIGGER: Task 2 spec reviewer reported that the frozen Task 2 Step 3 HTML copy snippet omitted required operation categories while the implementation used spec-complete copy.
FROZEN_PLAN_STEP: `docs/superpowers/plans/2026-05-07-operation-delay-plan.md:330-348`, especially line 337 title copy and line 344 caption copy.
GOVERNING_SPEC_REQUIREMENT: `docs/superpowers/specs/2026-05-07-operation-delay-design.md:36-38` requires the sidepanel boolean operation-delay switch label to communicate waits after page input, selection, click, submit, continue, and authorization operations.
REPO_OR_WORKTREE_EVIDENCE: `sidepanel/sidepanel.html:578-589` implements the switch; line 582 title copy and line 589 visible caption copy cover input, selection, click, submit, continue, authorization, and 2 seconds.
INVESTIGATOR_JUDGMENTS: Two fresh read-only investigators returned `PLAN_DRIFT_CONTINUE_SAFE`. Investigator 1 found the implementation aligned with the immutable spec and literal frozen-plan copy would under-satisfy the spec. Investigator 2 found Task 2 Step 3's literal HTML snippet not executable as written for the copy requirement, while the rest of Task 2 remains safely executable.
P9_FINAL_ADJUDICATION: PLAN_DRIFT_CONTINUE_SAFE
EFFECTIVE_EXECUTION_CONTRACT: For Task 2 and later reviews, treat the immutable spec's all-six-category sidepanel switch copy requirement as controlling. Preserve copy that communicates input, selection, click, submit, continue, authorization, and 2 seconds. Do not request or implement a regression to the narrower frozen-plan snippet.
SAFE_TO_CONTINUE: YES
REOPEN_ONLY_IF: New immutable spec evidence changes the sidepanel switch copy requirement, or repo evidence shows the implementation no longer communicates all six operation categories and the 2 second duration.
## DRIFT-2026-05-08-T3-001
DRIFT_ID: DRIFT-2026-05-08-T3-001
STATUS: ACTIVE
SUPERSEDES_DRIFT_ID: NONE
SUPERSEDED_BY_DRIFT_ID: NONE
SCOPE: Task 3 2925 provider metadata test scope
TRIGGER: Task 3 spec reviewer reported that the frozen Task 3 file list and commit step omitted `tests/background-icloud-mail-provider.test.js`, but full-suite verification required updating that existing 2925 provider metadata expectation after the required 2925 operation-delay injection change.
FROZEN_PLAN_STEP: `docs/superpowers/plans/2026-05-07-operation-delay-plan.md:421-431` lists Task 3 files and tests without `tests/background-icloud-mail-provider.test.js`; `docs/superpowers/plans/2026-05-07-operation-delay-plan.md:656-660` gives a Task 3 commit step that also omits that test file.
GOVERNING_SPEC_REQUIREMENT: `docs/superpowers/specs/2026-05-07-operation-delay-design.md:16-22`, `44-52`, and `80-83` require covered page operations to use the centralized content-side operation delay gate and current enabled state, while excluding only disabled/background/excluded work.
REPO_OR_WORKTREE_EVIDENCE: `background.js:11173-11180` implements the 2925 provider reuse metadata with `content/utils.js`, `content/operation-delay.js`, and `content/mail-2925.js` in order. `background/mail-2925-session.js:32-36` implements the matching session injection order. `tests/background-icloud-mail-provider.test.js:140-152` is an existing assertion over the same 2925 provider metadata and must expect `content/operation-delay.js` for the full suite to remain aligned. `npm test` passed 817/817 with that update.
INVESTIGATOR_JUDGMENTS: Two fresh read-only investigators returned `PLAN_DRIFT_CONTINUE_SAFE`. Both found the frozen plan's 2925 production requirement spec-correct, but the file list and commit step incomplete because they omitted an existing metadata test naturally affected by the required `background.js` provider metadata change.
P9_FINAL_ADJUDICATION: PLAN_DRIFT_CONTINUE_SAFE
EFFECTIVE_EXECUTION_CONTRACT: For Task 3 and later reviews, treat `tests/background-icloud-mail-provider.test.js` as in scope. Include it in the Task 3 checkpoint commit with the frozen Task 3 files. Preserve 2925 injection order as `content/utils.js`, `content/operation-delay.js`, `content/mail-2925.js` in both provider reuse and Mail2925 session paths. Do not treat this test update as unrelated scope creep.
SAFE_TO_CONTINUE: YES
REOPEN_ONLY_IF: New repo evidence shows `tests/background-icloud-mail-provider.test.js` no longer asserts the 2925 provider metadata changed by Task 3, or immutable spec evidence removes the 2925/provider reuse operation-delay injection need.
## DRIFT-2026-05-08-T4-001
DRIFT_ID: DRIFT-2026-05-08-T4-001
STATUS: ACTIVE
SUPERSEDES_DRIFT_ID: NONE
SUPERSEDED_BY_DRIFT_ID: NONE
SCOPE: Task 4 Step 3 direct-complete test scope
TRIGGER: Task 4 spec reviewer reported that the frozen Task 4 file list and commit step omitted `tests/step3-direct-complete.test.js`, but Step 3 operation-delay migration must preserve the existing completion-before-navigation contract while routing the deferred password submit through the operation-delay gate.
FROZEN_PLAN_STEP: `docs/superpowers/plans/2026-05-07-operation-delay-plan.md:667-674` lists Task 4 files and tests without `tests/step3-direct-complete.test.js`; `docs/superpowers/plans/2026-05-07-operation-delay-plan.md:788-790` gives a Task 4 commit step that also omits that test file.
GOVERNING_SPEC_REQUIREMENT: `docs/superpowers/specs/2026-05-07-operation-delay-design.md:18-20`, `44-52`, `55-60`, and `80-83` require covered page fills/submits/continues to use the centralized content-side operation delay gate. `docs/superpowers/specs/2026-05-07-operation-delay-design.md:111` makes `data/step-definitions.js` canonical for step keys, and `data/step-definitions.js:14` defines Step 3 as `fill-password`.
REPO_OR_WORKTREE_EVIDENCE: `content/signup-page.js:2463-2580` implements `step3_fillEmailPassword`; `content/signup-page.js:2533-2536` routes password fill through `performOperationWithDelay`; `content/signup-page.js:2559` reports Step 3 completion before deferred submit navigation; `content/signup-page.js:2561-2570` routes the deferred password submit through `performOperationWithDelay`. `tests/step3-direct-complete.test.js:54-235` verifies completion-before-navigation and deferred submit operation-delay behavior. Targeted verification including this test passed 38/38.
INVESTIGATOR_JUDGMENTS: Two fresh read-only investigators returned `PLAN_DRIFT_CONTINUE_SAFE`. Both found the frozen plan's broad Task 4 requirement covers Step 3 signup-page operations, but its exact file list and commit step are incomplete because they omit an existing regression test materially affected by the required `content/signup-page.js` change.
P9_FINAL_ADJUDICATION: PLAN_DRIFT_CONTINUE_SAFE
EFFECTIVE_EXECUTION_CONTRACT: For Task 4 and later reviews, treat `tests/step3-direct-complete.test.js` as in scope. Include it in the Task 4 checkpoint commit with the frozen Task 4 files. Preserve Step 3 completion/report before the deferred navigation-prone submit, and keep both password fill and deferred password submit routed through the shared operation-delay gate.
SAFE_TO_CONTINUE: YES
REOPEN_ONLY_IF: New repo evidence shows `tests/step3-direct-complete.test.js` no longer asserts Step 3 direct-completion behavior affected by Task 4, or immutable spec evidence removes Step 3 password fill/submit from covered OpenAI/auth page operations.
## DRIFT-2026-05-08-T7-001
DRIFT_ID: DRIFT-2026-05-08-T7-001
STATUS: ACTIVE
SUPERSEDES_DRIFT_ID: NONE
SUPERSEDED_BY_DRIFT_ID: NONE
SCOPE: Task 7 mail-2925 full-suite harness scope
TRIGGER: Task 7 implementer reported that targeted docs/exclusion tests passed but full `npm test` failed in `tests/mail-2925-content.test.js` because its extracted `ensureAgreementChecked` harness did not provide the new `performOperationWithDelay` dependency introduced by the spec-correct Task 6 `content/mail-2925.js` operation-delay wrapping.
FROZEN_PLAN_STEP: `docs/superpowers/plans/2026-05-07-operation-delay-plan.md:1031-1039` lists Task 7 files and full existing test verification but omits `tests/mail-2925-content.test.js`; `docs/superpowers/plans/2026-05-07-operation-delay-plan.md:1107-1115` requires full `npm test` to pass; `docs/superpowers/plans/2026-05-07-operation-delay-plan.md:1117-1121` gives a Task 7 commit step that omits that harness file.
GOVERNING_SPEC_REQUIREMENT: `docs/superpowers/specs/2026-05-07-operation-delay-design.md:18-22`, `44-52`, `53-62`, and `80-83` require covered page clicks/submits/controls to use the centralized content-side operation delay gate, while `64-71` excludes polling/background work, not 2925 login/session UI clicks.
REPO_OR_WORKTREE_EVIDENCE: `content/mail-2925.js:490-512` has `ensureAgreementChecked()` calling `performOperationWithDelay` for checkbox clicks, `content/mail-2925.js:1057-1060` defines that helper, and `content/mail-2925.js:1144` calls `ensureAgreementChecked()` from `ensureMail2925Session()`. `tests/mail-2925-content.test.js:896-902` extracts `ensureAgreementChecked` without extracting or stubbing `performOperationWithDelay`, causing `node --test tests/mail-2925-content.test.js` to fail 14/1 and full `npm test` to fail 841/1.
INVESTIGATOR_JUDGMENTS: Two fresh read-only investigators returned `PLAN_DRIFT_CONTINUE_SAFE`. Both found the production `content/mail-2925.js` change spec-correct and the failure a narrow stale test harness issue. Both found Task 7's full-suite requirement makes the harness update necessary despite omission from the frozen Task 7 file list and commit step.
P9_FINAL_ADJUDICATION: PLAN_DRIFT_CONTINUE_SAFE
EFFECTIVE_EXECUTION_CONTRACT: For Task 7 and later reviews, treat `tests/mail-2925-content.test.js` as in scope only for a narrow stale-harness fix that provides or extracts `performOperationWithDelay` for the existing `ensureAgreementChecked` test. Include that test update in the Task 7 checkpoint. Do not change runtime `content/mail-2925.js`, do not remove operation-delay wrapping from 2925 session UI, and do not wrap polling/cleanup handlers.
SAFE_TO_CONTINUE: YES
REOPEN_ONLY_IF: New evidence shows `tests/mail-2925-content.test.js` no longer exercises `ensureAgreementChecked`, or immutable spec evidence removes 2925 login/session UI checkbox clicks from covered operation-delay behavior.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,115 @@
# Operation Delay Design Spec
## Goal
Add a default-enabled, sidepanel-controlled 2 second delay between individual page automation operations in Ultra7.0. The feature reduces rapid page interaction failures on slow or unstable networks while keeping the behavior modular enough to port across future upstream versions.
## Non-Goals
- The feature does not add step-to-step delay as its primary behavior.
- The feature does not slow email polling, SMS polling, backend API calls, network retries, background timers, or storage persistence.
- The feature does not run inside `confirm-oauth` or `platform-verify` flows.
- The feature does not create a second tips or log console.
- The feature does not expose a user-editable delay duration in this version.
- The feature does not become Auto-only; manual single-step execution uses the same behavior.
## Scope
The feature covers page-side operations triggered by extension automation during Auto runs and manual single-step runs. A covered operation includes any automation action that writes page DOM state, changes page execution state, selects a value, clicks a page control, submits a form, continues a page flow, or authorizes a page flow.
The delay duration is fixed at 2000 milliseconds. When the setting is enabled, automation waits 2000 milliseconds after each covered page operation completes. For ordinary chains, that pause separates one covered operation from the next. The first covered operation after a page is ready starts immediately. Terminal submit, continue, and authorization actions also receive the 2000 millisecond pause after they complete, even when no later covered operation follows.
The feature treats grouped split verification-code entry as one covered operation. A grouped split code operation fills the full group without per-character or per-field pauses, then receives one 2000 millisecond delay after the group is filled.
## Actors And Entry Points
- Sidepanel user: views and toggles the `操作间延迟` setting in the existing sidepanel settings surface.
- Auto runner: executes the full automation flow and applies the same operation delay setting.
- Manual step runner: executes a selected step from the sidepanel and applies the same operation delay setting.
- Content scripts: perform covered page operations and enforce the delay gate after those operations complete.
- Bottom tips/log console: reports setting changes and setting recovery failures through the existing `log-area` flow.
## Functional Behavior
### Sidepanel Setting
- The sidepanel displays a boolean `操作间延迟` switch in the existing settings surface.
- The switch defaults to enabled when no valid persisted value exists, including first install and upgrades from versions that did not store this setting.
- The switch label communicates that enabled mode waits 2 seconds after page input, selection, click, submit, continue, and authorization operations.
- Toggling the switch persists the new value through the existing settings channel.
- Toggling the switch writes one bottom tips/log entry that states the new enabled or disabled state.
- The enabled-state log message includes the 2 second delay duration.
- The disabled-state log message states that operation delay is off.
### Operation Delay Runtime
- The operation delay gate runs after each covered page operation while the setting is enabled.
- The operation delay gate is skipped while the setting is disabled.
- The operation delay gate is skipped inside steps whose step key is `confirm-oauth` or `platform-verify`.
- The operation delay gate is skipped for background-only work that does not execute page operations.
- A setting change during an active run takes effect after the current covered page operation finishes. A delay already in progress completes unless the user stops the flow.
- A user Stop request interrupts an active 2 second operation delay and propagates the same stop error behavior used by existing content-script waits.
### Covered Operation Examples
- Filling a visible input field is one covered operation.
- Filling a hidden synchronization field is one covered operation.
- Updating page execution state through DOM writes or synthetic input/change events is one covered operation.
- Selecting a dropdown value is one covered operation.
- Clicking a button, link, checkbox, or role-based control is one covered operation.
- Submitting a form through click, dispatch, or request-submit behavior is one covered operation.
- Clicking a continue or authorization button is one covered operation unless the current step key is excluded.
- Filling split OTP or split verification-code fields is one grouped covered operation.
### Excluded Work Examples
- Waiting for an element to appear is not a covered operation.
- Polling mailbox or SMS provider APIs is not a covered operation.
- Sleeping between retry attempts is not a covered operation.
- Writing logs is not a covered operation.
- Persisting sidepanel settings or run state in extension storage is not a covered operation.
- Background tab bookkeeping is not a covered operation.
## State And Data Contracts
- `operationDelayEnabled` is a persisted boolean setting.
- If the persisted value is absent or invalid, the runtime uses `operationDelayEnabled = true`.
- A valid persisted `false` remains `false` across later restores and upgrades.
- The fixed delay duration is 2000 milliseconds.
- The delay duration is not user-editable.
- Runtime page-operation code reads the current enabled state at the start of the delay gate for a covered operation.
- The exclusion set contains exactly these step keys: `confirm-oauth`, `platform-verify`.
- The sidepanel writes setting-change feedback to the existing bottom tips/log stream. It does not write to a separate console.
- Content-side delay enforcement is centralized behind a shared operation delay gate so new page-operation call sites have one integration point.
## Error Handling And Edge Cases
- Missing persisted setting value resolves to enabled.
- Invalid persisted setting value resolves to enabled.
- If setting restoration fails in the sidepanel, the sidepanel displays the default enabled state and writes one warning entry to the bottom tips/log stream.
- If saving a user toggle fails, the sidepanel writes one error entry to the bottom tips/log stream and keeps the visible switch aligned with the last confirmed persisted state.
- If content-side code cannot resolve the setting for a covered operation, the operation delay gate uses enabled mode and a 2000 millisecond delay.
- If a Stop request arrives during an operation delay, the delay rejects with the existing stop error and the current flow stops through the existing stop path.
- Split verification-code input remains a single grouped operation even when the page exposes multiple fields.
- `confirm-oauth` and `platform-verify` perform no operation delay even when they click or submit page controls.
## Acceptance Criteria
- Opening the sidepanel with no persisted setting shows `操作间延迟` enabled.
- Disabling the setting persists `operationDelayEnabled = false` and writes a bottom tips/log entry stating that operation delay is off.
- Enabling the setting persists `operationDelayEnabled = true` and writes a bottom tips/log entry stating that page operations wait 2 seconds.
- Auto execution and manual single-step execution both honor the same setting.
- With the setting enabled, a profile-fill flow starts the first covered page operation immediately after the page is ready, waits 2 seconds after that operation completes, then starts the next covered operation, waits 2 seconds after that operation completes, then starts the next one, and waits 2 seconds after the terminal submit or complete action completes.
- With the setting enabled, a split verification-code flow fills the full group as one covered operation, then waits 2 seconds after the group is filled. It does not wait 2 seconds between individual split fields.
- With the setting disabled, covered page operations run without the 2 second operation delay.
- Email polling, SMS polling, backend retries, background timers, and storage persistence keep their existing cadence.
- `confirm-oauth` and `platform-verify` keep their existing interaction cadence.
- A Stop request during a 2 second operation delay stops the flow through the existing stop behavior.
## Locked Assumptions
- Ultra7.0 uses `data/step-definitions.js` as the canonical source for step keys.
- The existing bottom `log-area` flow is the only tips/log feedback target for this feature.
- The first implementation uses one boolean sidepanel switch and one fixed 2000 millisecond duration.
- The implementation uses a shared content-side operation delay gate instead of scattered business-step sleeps.
- Future page operation call sites use the shared operation delay gate for covered operations.
+8
View File
@@ -70,3 +70,11 @@
4. 如果需要支付和订阅,再看 `06``07`
5. 如果需要代理配置,再看 `08`
6. 如果需要 `GPC` 卡密、`API Key` 或自动化接入,再看 `09`
---
## 7. 操作间延迟
`操作间延迟` 默认开启。开启后,自动流程和手动单步在每个页面输入、选择、点击、提交、继续或授权操作完成后固定等待 2 秒;第一项页面操作不会提前等待。分格 OTP/验证码会先整组填完,然后只等待一次。
该开关不同于步间间隔,不影响邮箱轮询、短信/WhatsApp 轮询、后台 API、网络重试、后台定时器或存储持久化,也不影响 `confirm-oauth``platform-verify` 的交互节奏。
+2
View File
@@ -50,6 +50,7 @@
"js": [
"content/activation-utils.js",
"content/utils.js",
"content/operation-delay.js",
"content/auth-page-recovery.js",
"content/phone-country-utils.js",
"content/phone-auth.js",
@@ -106,6 +107,7 @@
"js": [
"content/activation-utils.js",
"content/utils.js",
"content/operation-delay.js",
"content/duck-mail.js"
],
"run_at": "document_idle"
+15
View File
@@ -577,6 +577,21 @@
</div>
</div>
</div>
<div class="data-row" id="row-operation-delay-settings">
<span class="data-label">操作间延迟</span>
<div class="data-inline setting-pair operation-delay-setting-pair">
<div class="setting-group setting-group-primary operation-delay-setting">
<label class="toggle-switch" for="input-operation-delay-enabled" title="开启后,页面输入、选择、点击、提交、继续、授权操作完成后等待 2 秒">
<input type="checkbox" id="input-operation-delay-enabled" checked />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
<span>启用</span>
</label>
<span class="setting-caption">页面输入、选择、点击、提交、继续、授权后等待 2 秒</span>
</div>
</div>
</div>
<div class="data-row">
<span class="data-label">自动重试</span>
<div class="data-inline setting-pair">
+62
View File
@@ -380,6 +380,7 @@ const inputStep6CookieCleanupEnabled = document.getElementById('input-step6-cook
const inputAutoDelayEnabled = document.getElementById('input-auto-delay-enabled');
const inputAutoDelayMinutes = document.getElementById('input-auto-delay-minutes');
const inputAutoStepDelaySeconds = document.getElementById('input-auto-step-delay-seconds');
const inputOperationDelayEnabled = document.getElementById('input-operation-delay-enabled');
const inputOAuthFlowTimeoutEnabled = document.getElementById('input-oauth-flow-timeout-enabled');
const inputVerificationResendCount = document.getElementById('input-verification-resend-count');
const rowPhoneVerificationEnabled = document.getElementById('row-phone-verification-enabled');
@@ -518,6 +519,7 @@ const DEFAULT_SIGNUP_METHOD = SIGNUP_METHOD_EMAIL;
let currentPlusModeEnabled = false;
let currentPlusPaymentMethod = DEFAULT_PLUS_PAYMENT_METHOD;
let currentSignupMethod = DEFAULT_SIGNUP_METHOD;
let lastConfirmedOperationDelayEnabled = true;
let heroSmsCountrySelectionOrder = [];
let phoneSmsProviderOrderSelection = [];
let heroSmsCountryMenuSearchKeyword = '';
@@ -2050,6 +2052,53 @@ function syncLatestState(nextState) {
renderAccountRecords(latestState);
}
function normalizeOperationDelayEnabled(value) {
return typeof value === 'boolean' ? value : true;
}
function appendOperationDelayLog(enabled, level = 'info', message = '') {
appendLog({
timestamp: Date.now(),
level,
message: message || (enabled
? '操作间延迟已开启:页面输入、选择、点击、提交、继续、授权后等待 2 秒。'
: '操作间延迟已关闭:页面操作将连续执行。'),
});
}
function applyOperationDelayState(state = latestState, options = {}) {
const enabled = options.restoreFailed ? true : normalizeOperationDelayEnabled(state?.operationDelayEnabled);
lastConfirmedOperationDelayEnabled = enabled;
if (inputOperationDelayEnabled) inputOperationDelayEnabled.checked = enabled;
if (typeof syncLatestState === 'function') {
syncLatestState({ operationDelayEnabled: enabled });
}
if (options.restoreFailed) {
appendOperationDelayLog(true, 'warn', '操作间延迟设置读取失败,已回退为默认开启。');
}
}
async function persistOperationDelayToggle() {
const nextEnabled = normalizeOperationDelayEnabled(inputOperationDelayEnabled?.checked);
try {
const response = await chrome.runtime.sendMessage({
type: 'SAVE_SETTING',
source: 'sidepanel',
payload: { operationDelayEnabled: nextEnabled },
});
if (response?.error) throw new Error(response.error);
const confirmed = normalizeOperationDelayEnabled(response?.state?.operationDelayEnabled ?? nextEnabled);
lastConfirmedOperationDelayEnabled = confirmed;
if (inputOperationDelayEnabled) inputOperationDelayEnabled.checked = confirmed;
syncLatestState({ operationDelayEnabled: confirmed });
appendOperationDelayLog(confirmed);
} catch (error) {
if (inputOperationDelayEnabled) inputOperationDelayEnabled.checked = lastConfirmedOperationDelayEnabled;
appendOperationDelayLog(lastConfirmedOperationDelayEnabled, 'error', `操作间延迟设置保存失败,已恢复为上一次确认的状态:${error.message}`);
throw error;
}
}
function normalizePlusPaymentMethod(value = '') {
const rootScope = typeof window !== 'undefined' ? window : globalThis;
if (rootScope.GoPayUtils?.normalizePlusPaymentMethod) {
@@ -7964,6 +8013,9 @@ function applySettingsState(state) {
return Math.max(1, Math.min(1440, numeric));
};
syncLatestState(state);
if (typeof applyOperationDelayState === 'function') {
applyOperationDelayState(state);
}
syncAutoRunState(state);
renderStepStatuses(latestState);
@@ -8470,6 +8522,9 @@ async function restoreState() {
renderContributionMode();
} catch (err) {
console.error('Failed to restore state:', err);
if (typeof applyOperationDelayState === 'function') {
applyOperationDelayState(undefined, { restoreFailed: true });
}
}
}
@@ -11413,6 +11468,10 @@ inputPlusModeEnabled?.addEventListener('change', () => {
saveSettings({ silent: true }).catch(() => { });
});
inputOperationDelayEnabled?.addEventListener('change', () => {
persistOperationDelayToggle().catch(() => { });
});
selectPlusPaymentMethod?.addEventListener('change', () => {
selectPlusPaymentMethod.value = normalizePlusPaymentMethod(selectPlusPaymentMethod.value);
updatePlusModeUI();
@@ -13159,6 +13218,9 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
case 'DATA_UPDATED': {
syncLatestState(message.payload);
if (message.payload.operationDelayEnabled !== undefined && typeof applyOperationDelayState === 'function') {
applyOperationDelayState(message.payload);
}
if (message.payload.email !== undefined) {
inputEmail.value = message.payload.email || '';
queueCustomEmailPoolRefresh();
+66
View File
@@ -1,7 +1,24 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const { createAuthPageRecovery } = require('../content/auth-page-recovery.js');
const source = fs.readFileSync('content/auth-page-recovery.js', 'utf8');
function extractFunction(sourceText, name) {
const start = sourceText.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `missing ${name}`);
const bodyStart = sourceText.indexOf('{', start);
let depth = 0;
for (let i = bodyStart; i < sourceText.length; i += 1) {
if (sourceText[i] === '{') depth += 1;
if (sourceText[i] === '}') {
depth -= 1;
if (depth === 0) return sourceText.slice(start, i + 1);
}
}
throw new Error(`unterminated ${name}`);
}
function createRetryButton() {
return {
@@ -42,9 +59,13 @@ function createRecoveryApi(state) {
isActionEnabled: (element) => Boolean(element) && !element.disabled && element.getAttribute('aria-disabled') !== 'true',
isVisibleElement: () => true,
log: () => {},
performOperationWithDelay: state.performOperationWithDelay,
routeErrorPattern: /405\s+method\s+not\s+allowed|route\s+error.*405|did\s+not\s+provide\s+an?\s+[`'"]?action|post\s+request\s+to\s+["']?\/email-verification/i,
simulateClick: () => {
state.clickCount += 1;
if (Array.isArray(state.events)) {
state.events.push('click:retry');
}
if (typeof state.onClick === 'function') {
state.onClick(state);
return;
@@ -54,6 +75,9 @@ function createRecoveryApi(state) {
},
sleep: async (ms = 0) => {
await new Promise((resolve) => setTimeout(resolve, Math.max(1, Math.min(5, ms))));
if (Array.isArray(state.events) && ms === 250) {
state.events.push(`poll-sleep:${ms}`);
}
if (typeof state.onSleep === 'function') {
state.onSleep(state);
}
@@ -163,6 +187,48 @@ test('auth page recovery clicks retry and waits until page recovers', async () =
assert.equal(state.retryVisible, false);
});
test('auth page recovery routes retry click through operation delay without wrapping polling sleeps', async () => {
const authRetryEvents = [];
const state = {
clickCount: 0,
events: authRetryEvents,
pageText: 'Something went wrong. Please try again.',
retryVisible: true,
onClick() {},
onSleep(currentState) {
currentState.retryVisible = false;
currentState.pageText = 'Recovered login form';
},
async performOperationWithDelay(metadata, operation) {
authRetryEvents.push(`operation:${metadata.label}:start`);
const result = await operation();
authRetryEvents.push(`operation:${metadata.label}:end`);
authRetryEvents.push(`delay:${metadata.label}:2000`);
return result;
},
};
const api = createRecoveryApi(state);
await api.recoverAuthRetryPage({
logLabel: '步骤 8:检测到重试页,正在点击“重试”恢复',
pathPatterns: [/\/log-in(?:[/?#]|$)/i],
step: 8,
timeoutMs: 1000,
waitAfterClickMs: 1000,
pollIntervalMs: 250,
});
assert.deepStrictEqual(authRetryEvents, [
'operation:auth-retry-click:start',
'click:retry',
'operation:auth-retry-click:end',
'delay:auth-retry-click:2000',
'poll-sleep:250',
]);
assert.equal(authRetryEvents.filter((event) => event.startsWith('delay:auth-retry-click')).length, 1);
assert.doesNotMatch(extractFunction(source, 'waitForRetryPageRecoveryAfterClick'), /performOperationWithDelay\(/);
});
test('auth page recovery can click retry twice before page recovers', async () => {
const state = {
clickCount: 0,
@@ -147,7 +147,7 @@ test('getMailConfig keeps provider metadata for 2925 mailboxes', () => {
source: 'mail-2925',
url: 'https://2925.com/#/mailList',
label: '2925 邮箱',
inject: ['content/utils.js', 'content/mail-2925.js'],
inject: ['content/utils.js', 'content/operation-delay.js', 'content/mail-2925.js'],
injectSource: 'mail-2925',
});
});
@@ -130,3 +130,35 @@ test('SAVE_SETTING broadcasts free phone reuse setting updates for realtime side
'expected SAVE_SETTING to broadcast free reuse switch updates'
);
});
test('SAVE_SETTING broadcasts operation delay setting without background success log', async () => {
const source = fs.readFileSync('background/message-router.js', 'utf8');
const globalScope = { console };
const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope);
const broadcasts = [];
const logs = [];
let state = { operationDelayEnabled: true, plusModeEnabled: false, plusPaymentMethod: 'paypal' };
const router = api.createMessageRouter({
addLog: async (message, level = 'info') => logs.push({ message, level }),
buildLuckmailSessionSettingsPayload: () => ({}),
buildPersistentSettingsPayload: (input = {}) => Object.prototype.hasOwnProperty.call(input, 'operationDelayEnabled')
? { operationDelayEnabled: input.operationDelayEnabled === false ? false : true }
: {},
broadcastDataUpdate: (payload) => broadcasts.push(payload),
getState: async () => ({ ...state }),
setPersistentSettings: async () => {},
setState: async (updates) => { state = { ...state, ...updates }; },
});
const response = await router.handleMessage({
type: 'SAVE_SETTING',
source: 'sidepanel',
payload: { operationDelayEnabled: false },
});
assert.equal(response.ok, true);
assert.equal(state.operationDelayEnabled, false);
assert.deepStrictEqual(broadcasts.at(-1), { operationDelayEnabled: false });
assert.equal(logs.length, 0);
});
@@ -0,0 +1,22 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
test('confirm-oauth and platform-verify stay free of operation delay gate calls', () => {
for (const file of [
'background/steps/confirm-oauth.js',
'background/steps/platform-verify.js',
'background/panel-bridge.js',
'content/sub2api-panel.js',
]) {
const source = fs.readFileSync(file, 'utf8');
assert.doesNotMatch(source, /performOperationWithDelay\(/, `${file} must not call the operation delay gate`);
assert.doesNotMatch(source, /content\/operation-delay\.js/, `${file} must not inject operation delay`);
}
});
test('operation delay gate names exactly the two excluded step keys', () => {
const source = fs.readFileSync('content/operation-delay.js', 'utf8');
assert.match(source, /confirm-oauth/);
assert.match(source, /platform-verify/);
});
@@ -0,0 +1,67 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background.js', 'utf8');
function extractFunction(name) {
const start = source.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `missing ${name}`);
let parenDepth = 0;
let signatureEnded = false;
let bodyStart = -1;
for (let i = start; i < source.length; i += 1) {
const ch = source[i];
if (ch === '(') {
parenDepth += 1;
} else if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) signatureEnded = true;
} else if (ch === '{' && signatureEnded) {
bodyStart = i;
break;
}
}
assert.notEqual(bodyStart, -1, `missing body for ${name}`);
let depth = 0;
for (let i = bodyStart; i < source.length; i += 1) {
if (source[i] === '{') depth += 1;
if (source[i] === '}') {
depth -= 1;
if (depth === 0) return source.slice(start, i + 1);
}
}
throw new Error(`unterminated ${name}`);
}
test('operationDelayEnabled defaults to enabled and strictly normalizes values', () => {
const defaultsBlock = source.slice(
source.indexOf('const PERSISTED_SETTING_DEFAULTS = {'),
source.indexOf('const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);')
);
assert.match(defaultsBlock, /operationDelayEnabled:\s*true/);
const api = new Function(`${extractFunction('normalizePersistentSettingValue')}; return { normalizePersistentSettingValue };`)();
for (const value of [undefined, null, '', 0, 'false']) {
assert.equal(api.normalizePersistentSettingValue('operationDelayEnabled', value), true);
}
assert.equal(api.normalizePersistentSettingValue('operationDelayEnabled', true), true);
assert.equal(api.normalizePersistentSettingValue('operationDelayEnabled', false), false);
});
test('operationDelayEnabled is normalized through the background settings payload path', () => {
const api = new Function(`
const PERSISTED_SETTING_DEFAULTS = { operationDelayEnabled: true };
const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);
function resolveLegacyAutoStepDelaySeconds() { return undefined; }
${extractFunction('normalizePersistentSettingValue')}
${extractFunction('buildPersistentSettingsPayload')}
return { buildPersistentSettingsPayload };
`)();
assert.equal(api.buildPersistentSettingsPayload({}, { fillDefaults: true }).operationDelayEnabled, true);
for (const value of [undefined, null, '', 0, 'false', true]) {
assert.equal(api.buildPersistentSettingsPayload({ operationDelayEnabled: value }, { fillDefaults: true }).operationDelayEnabled, true);
}
assert.equal(api.buildPersistentSettingsPayload({ operationDelayEnabled: false }).operationDelayEnabled, false);
});
+268
View File
@@ -0,0 +1,268 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
function loadOperationDelayApi(overrides = {}) {
const source = fs.readFileSync('content/operation-delay.js', 'utf8');
const root = {
console,
sleep: async (ms) => overrides.events?.push(`sleep:${ms}`),
chrome: overrides.chrome || { storage: { local: { get: async () => ({}) }, onChanged: { addListener() {} } } },
};
new Function('self', 'globalThis', `${source}; return self.CodexOperationDelay;`)(root, root);
return root.CodexOperationDelay;
}
async function resolveOrPending(promise, timeoutMs = 10) {
return Promise.race([
promise.then(() => 'resolved'),
new Promise((resolve) => setTimeout(() => resolve('pending'), timeoutMs)),
]);
}
function waitForTimer() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
test('operation delay runs action first and waits once after completion', async () => {
const events = [];
const api = loadOperationDelayApi({ events });
await api.performOperationWithDelay({ stepKey: 'fill-profile', kind: 'fill' }, async () => {
events.push('operation:start');
events.push('operation:end');
}, { sleep: async (ms) => events.push(`sleep:${ms}`), getEnabled: () => true });
assert.deepStrictEqual(events, ['operation:start', 'operation:end', 'sleep:2000']);
});
test('operation delay skips disabled mode and excluded step keys', async () => {
const api = loadOperationDelayApi();
assert.equal(api.shouldDelayOperation({ enabled: false, stepKey: 'fill-profile', kind: 'click' }), false);
assert.equal(api.shouldDelayOperation({ enabled: true, stepKey: 'confirm-oauth', kind: 'click' }), false);
assert.equal(api.shouldDelayOperation({ enabled: true, stepKey: 'platform-verify', kind: 'submit' }), false);
assert.equal(api.shouldDelayOperation({ enabled: true, stepKey: 'fill-profile', kind: 'fill' }), true);
});
test('operation delay defaults unresolved settings to enabled', async () => {
const events = [];
const api = loadOperationDelayApi({ events, chrome: { storage: { local: { get: async () => ({}) }, onChanged: { addListener() {} } } } });
await api.performOperationWithDelay({ stepKey: 'fill-profile', kind: 'fill' }, async () => events.push('operation'), { sleep: async (ms) => events.push(`sleep:${ms}`) });
assert.deepStrictEqual(events, ['operation', 'sleep:2000']);
});
test('persisted disabled setting prevents first operation delay even before initial restore resolves', async () => {
const events = [];
let resolveStorage;
const storageReady = new Promise((resolve) => { resolveStorage = resolve; });
const api = loadOperationDelayApi({
events,
chrome: { storage: { local: { get: async () => storageReady }, onChanged: { addListener() {} } } },
});
const pendingOperation = api.performOperationWithDelay(
{ stepKey: 'fill-profile', kind: 'fill' },
async () => events.push('operation'),
{ sleep: async (ms) => events.push(`sleep:${ms}`) }
);
await Promise.resolve();
assert.deepStrictEqual(events, ['operation']);
resolveStorage({ operationDelayEnabled: false });
await pendingOperation;
assert.deepStrictEqual(events, ['operation']);
});
test('newer disabled storage change wins over older pending restore', async () => {
const events = [];
let changeListener = null;
let resolveStorage;
const storageReady = new Promise((resolve) => { resolveStorage = resolve; });
const api = loadOperationDelayApi({
events,
chrome: {
storage: {
local: { get: async () => storageReady },
onChanged: { addListener(listener) { changeListener = listener; } },
},
},
});
changeListener({ operationDelayEnabled: { newValue: false } }, 'local');
assert.equal(api.getOperationDelayEnabled(), false);
resolveStorage({ operationDelayEnabled: true });
await waitForTimer();
assert.equal(api.getOperationDelayEnabled(), false);
await api.performOperationWithDelay(
{ stepKey: 'fill-profile', kind: 'fill' },
async () => events.push('operation'),
{ sleep: async (ms) => events.push(`sleep:${ms}`) }
);
assert.deepStrictEqual(events, ['operation']);
});
test('newer enabled storage change wins over older pending restore', async () => {
const events = [];
let changeListener = null;
let resolveStorage;
const storageReady = new Promise((resolve) => { resolveStorage = resolve; });
const api = loadOperationDelayApi({
events,
chrome: {
storage: {
local: { get: async () => storageReady },
onChanged: { addListener(listener) { changeListener = listener; } },
},
},
});
changeListener({ operationDelayEnabled: { newValue: true } }, 'local');
assert.equal(api.getOperationDelayEnabled(), true);
resolveStorage({ operationDelayEnabled: false });
await waitForTimer();
assert.equal(api.getOperationDelayEnabled(), true);
await api.performOperationWithDelay(
{ stepKey: 'fill-profile', kind: 'fill' },
async () => events.push('operation'),
{ sleep: async (ms) => events.push(`sleep:${ms}`) }
);
assert.deepStrictEqual(events, ['operation', 'sleep:2000']);
});
test('first operation honors async persisted disabled setting before fallback budget expires', async () => {
const events = [];
const api = loadOperationDelayApi({
events,
chrome: {
storage: {
local: { get: async () => new Promise((resolve) => setTimeout(() => resolve({ operationDelayEnabled: false }), 5)) },
onChanged: { addListener() {} },
},
},
});
await api.performOperationWithDelay(
{ stepKey: 'fill-profile', kind: 'fill' },
async () => events.push('operation'),
{ sleep: async (ms) => events.push(`sleep:${ms}`) }
);
assert.deepStrictEqual(events, ['operation']);
});
test('hanging initial storage restore falls back to enabled delay and returns', async () => {
const events = [];
const api = loadOperationDelayApi({
events,
chrome: { storage: { local: { get: async () => new Promise(() => {}) }, onChanged: { addListener() {} } } },
});
const operation = api.performOperationWithDelay(
{ stepKey: 'fill-profile', kind: 'fill' },
async () => events.push('operation'),
{ sleep: async (ms) => events.push(`sleep:${ms}`) }
);
assert.equal(await resolveOrPending(operation, 100), 'resolved');
assert.deepStrictEqual(events, ['operation', 'sleep:2000']);
});
test('explicit disabled metadata does not wait for hanging initial storage restore', async () => {
const events = [];
const api = loadOperationDelayApi({
events,
chrome: { storage: { local: { get: async () => new Promise(() => {}) }, onChanged: { addListener() {} } } },
});
const operation = api.performOperationWithDelay(
{ enabled: false, stepKey: 'fill-profile', kind: 'fill' },
async () => events.push('operation'),
{ sleep: async (ms) => events.push(`sleep:${ms}`) }
);
assert.equal(await resolveOrPending(operation), 'resolved');
assert.deepStrictEqual(events, ['operation']);
});
test('invalid persisted values still resolve to enabled', async () => {
const api = loadOperationDelayApi({ chrome: { storage: { local: { get: async () => ({ operationDelayEnabled: 'false' }) }, onChanged: { addListener() {} } } } });
await api.refreshOperationDelaySetting();
assert.equal(api.getOperationDelayEnabled(), true);
});
test('shouldDelayOperation uses cached disabled setting by default', async () => {
const api = loadOperationDelayApi({ chrome: { storage: { local: { get: async () => ({ operationDelayEnabled: false }) }, onChanged: { addListener() {} } } } });
await api.refreshOperationDelaySetting();
assert.equal(api.getOperationDelayEnabled(), false);
assert.equal(api.shouldDelayOperation({ stepKey: 'fill-profile', kind: 'fill' }), false);
});
test('storage change to disabled is honored by later delayed operations', async () => {
const events = [];
let changeListener = null;
const api = loadOperationDelayApi({
events,
chrome: {
storage: {
local: { get: async () => ({ operationDelayEnabled: true }) },
onChanged: { addListener(listener) { changeListener = listener; } },
},
},
});
await api.refreshOperationDelaySetting();
changeListener({ operationDelayEnabled: { newValue: false } }, 'local');
assert.equal(api.getOperationDelayEnabled(), false);
assert.equal(api.shouldDelayOperation({ stepKey: 'fill-profile', kind: 'fill' }), false);
await api.performOperationWithDelay(
{ stepKey: 'fill-profile', kind: 'fill' },
async () => events.push('operation'),
{ sleep: async (ms) => events.push(`sleep:${ms}`) }
);
assert.deepStrictEqual(events, ['operation']);
});
test('storage change to enabled is honored by later delayed operations', async () => {
const events = [];
let changeListener = null;
const api = loadOperationDelayApi({
events,
chrome: {
storage: {
local: { get: async () => ({ operationDelayEnabled: false }) },
onChanged: { addListener(listener) { changeListener = listener; } },
},
},
});
await api.refreshOperationDelaySetting();
changeListener({ operationDelayEnabled: { newValue: true } }, 'local');
assert.equal(api.getOperationDelayEnabled(), true);
assert.equal(api.shouldDelayOperation({ stepKey: 'fill-profile', kind: 'fill' }), true);
await api.performOperationWithDelay(
{ stepKey: 'fill-profile', kind: 'fill' },
async () => events.push('operation'),
{ sleep: async (ms) => events.push(`sleep:${ms}`) }
);
assert.deepStrictEqual(events, ['operation', 'sleep:2000']);
});
test('operation delay uses stop-aware sleep and propagates stop errors', async () => {
const api = loadOperationDelayApi();
await assert.rejects(
() => api.performOperationWithDelay({ stepKey: 'fill-profile', kind: 'click' }, async () => {}, {
sleep: async () => { throw new Error('流程已被用户停止。'); },
getEnabled: () => true,
}),
/流程已被用户停止/
);
});
test('grouped split-code metadata still delays only once', async () => {
const events = [];
const api = loadOperationDelayApi();
await api.performOperationWithDelay({ stepKey: 'fetch-signup-code', kind: 'grouped-code' }, async () => {
for (let index = 0; index < 6; index += 1) events.push(`fill:${index}`);
}, { sleep: async (ms) => events.push(`sleep:${ms}`), getEnabled: () => true });
assert.deepStrictEqual(events, ['fill:0', 'fill:1', 'fill:2', 'fill:3', 'fill:4', 'fill:5', 'sleep:2000']);
});
+237
View File
@@ -1,6 +1,7 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const source = fs.readFileSync('content/gopay-flow.js', 'utf8');
@@ -48,6 +49,200 @@ function extractFunction(name) {
return source.slice(start, end);
}
function createGoPayContentHarness() {
const gopayEvents = [];
const attrs = new Map();
let listener = null;
let elements = [];
let activeOperationLabel = null;
const body = { innerText: 'Phone number: +86', textContent: 'Phone number: +86' };
function createElement({ tagName = 'DIV', text = '', type = '', id = '', name = '', placeholder = '', attrs: initialAttrs = {}, onClick = null } = {}) {
const attrMap = new Map(Object.entries(initialAttrs));
if (type) attrMap.set('type', type);
if (id) attrMap.set('id', id);
if (name) attrMap.set('name', name);
if (placeholder) attrMap.set('placeholder', placeholder);
return {
nodeType: 1,
tagName,
type,
id,
name,
placeholder,
textContent: text,
innerText: text,
value: '',
className: initialAttrs.class || '',
disabled: false,
hidden: false,
parentElement: null,
style: { display: 'block', visibility: 'visible', opacity: '1' },
getAttribute(key) {
if (key === 'class') return this.className;
if (key === 'type') return this.type || attrMap.get(key) || '';
if (key === 'id') return this.id || attrMap.get(key) || '';
if (key === 'name') return this.name || attrMap.get(key) || '';
if (key === 'placeholder') return this.placeholder || attrMap.get(key) || '';
return attrMap.has(key) ? attrMap.get(key) : null;
},
scrollIntoView() {},
focus() {},
dispatchEvent() { return true; },
click() {
if (typeof onClick === 'function') onClick(this);
const field = this.getAttribute('data-test-field');
if (field) {
gopayEvents.push({ type: 'click', field, label: activeOperationLabel });
}
},
getBoundingClientRect() { return { left: 20, top: 30, width: 180, height: 44 }; },
};
}
const context = {
console: { log() {}, warn() {}, error() {}, info() {} },
location: { href: 'https://gopay.co.id/linking' },
window: {},
document: {
readyState: 'complete',
body,
documentElement: {
getAttribute(name) { return attrs.get(name) || null; },
setAttribute(name, value) { attrs.set(name, String(value)); },
},
querySelector(selector) {
if (selector === '.search-country') return null;
return null;
},
querySelectorAll(selector) {
const text = String(selector || '');
if (text.includes('country-item') || text.includes('[role="option"]') || text.includes('li')) {
return elements.filter((element) => element.tagName === 'LI' || /country-item/i.test(element.className) || element.getAttribute('role') === 'option');
}
if (text.includes('button') || text.includes('[role="button"]') || text.includes('a')) return elements.filter((element) => element.tagName === 'BUTTON' || element.tagName === 'A');
if (text.includes('input') || text.includes('textarea')) return elements.filter((element) => element.tagName === 'INPUT' || element.tagName === 'TEXTAREA');
if (text.includes('li') || text.includes('[role="option"]') || text.includes('div') || text.includes('span')) return [];
return [];
},
},
chrome: {
runtime: {
onMessage: { addListener(fn) { listener = fn; } },
},
},
CodexOperationDelay: {
async performOperationWithDelay(metadata, operation) {
gopayEvents.push({ type: 'operation', label: metadata.label, kind: metadata.kind });
const previousOperationLabel = activeOperationLabel;
activeOperationLabel = metadata.label;
let result;
try {
result = await operation();
} finally {
activeOperationLabel = previousOperationLabel;
}
gopayEvents.push({ type: 'delay', label: metadata.label, ms: 2000 });
return result;
},
},
resetStopState() {},
isStopError() { return false; },
throwIfStopped() {},
sleep() { return Promise.resolve(); },
fillInput(element, value) {
element.value = value;
const field = element.getAttribute?.('data-test-field');
if (field) {
gopayEvents.push({ type: 'fill', field, label: activeOperationLabel, value });
}
},
MouseEvent: class TestMouseEvent { constructor(type) { this.type = type; } },
PointerEvent: class TestPointerEvent { constructor(type) { this.type = type; } },
KeyboardEvent: class TestKeyboardEvent { constructor(type) { this.type = type; } },
};
context.window = context;
context.window.getComputedStyle = (element) => element?.style || { display: 'block', visibility: 'visible', opacity: '1' };
context.window.screenX = 0;
context.window.screenY = 0;
context.window.innerWidth = 390;
context.window.innerHeight = 844;
vm.createContext(context);
vm.runInContext(source, context);
assert.equal(typeof listener, 'function');
async function send(message) {
return await new Promise((resolve) => {
listener(message, {}, resolve);
});
}
return {
gopayEvents,
send,
showPhonePage() {
body.innerText = 'Phone number: +86';
body.textContent = body.innerText;
elements = [
createElement({ tagName: 'INPUT', type: 'tel', id: 'phone', name: 'gopay_phone', placeholder: 'GoPay phone' }),
createElement({ tagName: 'BUTTON', text: 'Continue', id: 'continue-phone' }),
];
},
showPhonePageWithCountryMismatch() {
body.innerText = 'Phone number: +86';
body.textContent = body.innerText;
const countryToggle = createElement({ tagName: 'BUTTON', text: '+86', id: 'country-toggle', attrs: { class: 'phone-code-wrapper', 'data-test-field': 'country-toggle' } });
const countrySearch = createElement({ tagName: 'INPUT', type: 'text', id: 'country-search', name: 'country_search', placeholder: 'Country code', attrs: { 'data-test-field': 'country-search' } });
const countryOption = createElement({
tagName: 'LI',
text: 'Indonesia +62',
id: 'country-option-id',
attrs: { class: 'country-item', role: 'option', 'data-test-field': 'country-option' },
onClick: () => {
countryToggle.textContent = '+62';
countryToggle.innerText = '+62';
body.innerText = 'Phone number: +62';
body.textContent = body.innerText;
},
});
elements = [
countryToggle,
countrySearch,
countryOption,
createElement({ tagName: 'INPUT', type: 'tel', id: 'phone', name: 'gopay_phone', placeholder: 'GoPay phone' }),
createElement({ tagName: 'BUTTON', text: 'Continue', id: 'continue-phone' }),
];
},
showOtpPage() {
body.innerText = 'Enter OTP code from WhatsApp';
body.textContent = body.innerText;
elements = [
createElement({ tagName: 'INPUT', type: 'text', id: 'otp', name: 'otp', placeholder: 'OTP code' }),
createElement({ tagName: 'BUTTON', text: 'Continue', id: 'continue-otp' }),
];
},
showPinPage() {
body.innerText = 'Enter PIN to continue';
body.textContent = body.innerText;
elements = [
createElement({ tagName: 'INPUT', type: 'password', id: 'pin', name: 'pin', placeholder: 'PIN' }),
createElement({ tagName: 'BUTTON', text: 'Continue', id: 'continue-pin' }),
];
},
showContinuePage() {
body.innerText = 'Link your GoPay account';
body.textContent = body.innerText;
elements = [createElement({ tagName: 'BUTTON', text: 'Hubungkan', id: 'continue' })];
},
showPayNowPage() {
body.innerText = 'Confirm payment';
body.textContent = body.innerText;
elements = [createElement({ tagName: 'BUTTON', text: 'Pay now' })];
},
};
}
test('GoPay human click helper dispatches pointer and mouse sequence before native click', async () => {
const bundle = [
extractFunction('dispatchPointerMouseSequence'),
@@ -85,6 +280,48 @@ return { humanClickElement };
assert.equal(events.at(-2), 'native-click');
});
test('GoPay content routes form submits and terminal clicks through the operation delay gate', async () => {
const harness = createGoPayContentHarness();
harness.showPhonePage();
assert.equal((await harness.send({ type: 'GOPAY_SUBMIT_PHONE', payload: { phone: '+8613800138000', countryCode: '+86' } })).ok, true);
harness.showOtpPage();
assert.equal((await harness.send({ type: 'GOPAY_SUBMIT_OTP', payload: { otp: '123456' } })).ok, true);
harness.showPinPage();
assert.equal((await harness.send({ type: 'GOPAY_SUBMIT_PIN', payload: { pin: '654321' } })).ok, true);
harness.showContinuePage();
assert.equal((await harness.send({ type: 'GOPAY_CLICK_CONTINUE', payload: {} })).ok, true);
harness.showPayNowPage();
assert.equal((await harness.send({ type: 'GOPAY_CLICK_PAY_NOW', payload: {} })).ok, true);
assert.deepStrictEqual(harness.gopayEvents.filter((event) => event.type === 'delay').map((event) => event.label), [
'submit-phone',
'submit-otp',
'submit-pin',
'click-continue',
'click-pay-now',
]);
assert.equal(harness.gopayEvents.some((event) => event.type === 'delay' && event.ms !== 2000), false);
});
test('GoPay country-code changes occur inside the submit-phone operation delay gate', async () => {
const harness = createGoPayContentHarness();
harness.showPhonePageWithCountryMismatch();
const result = await harness.send({ type: 'GOPAY_SUBMIT_PHONE', payload: { phone: '+6281234567890', countryCode: '+62' } });
assert.equal(result.ok, true);
assert.equal(result.countryChanged, true);
const countryEvents = harness.gopayEvents.filter((event) => ['country-toggle', 'country-search', 'country-option'].includes(event.field));
assert.deepStrictEqual(countryEvents.map((event) => event.type), ['click', 'fill', 'click']);
assert.deepStrictEqual(countryEvents.map((event) => event.label), ['submit-phone', 'submit-phone', 'submit-phone']);
assert.deepStrictEqual(harness.gopayEvents.filter((event) => event.type === 'delay').map((event) => event.label), ['submit-phone']);
});
test('GoPay continue target exposes a debugger-clickable rect', () => {
const bundle = [
+11
View File
@@ -954,16 +954,23 @@ const window = {
},
};
const operationDelayCalls = [];
async function sleep() {}
function simulateClick(node) {
node.click();
}
async function performOperationWithDelay(metadata, operation) {
operationDelayCalls.push({ label: metadata.label, kind: metadata.kind });
return await operation();
}
${bundle}
return {
rememberCheckbox,
agreementCheckbox,
operationDelayCalls,
ensureAgreementChecked,
};
`)();
@@ -973,4 +980,8 @@ return {
assert.equal(result, true);
assert.equal(api.rememberCheckbox.checked, true);
assert.equal(api.agreementCheckbox.checked, true);
assert.deepStrictEqual(api.operationDelayCalls, [
{ label: 'mail2925-agreement-checkbox', kind: 'click' },
{ label: 'mail2925-agreement-checkbox', kind: 'click' },
]);
});
@@ -0,0 +1,83 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
function extractPollEmailHandler(source) {
const start = source.indexOf("message.type === 'POLL_EMAIL'");
assert.notEqual(start, -1, 'missing POLL_EMAIL handler');
const nextHandler = source.indexOf("message.type === '", start + 1);
return nextHandler === -1 ? source.slice(start) : source.slice(start, nextHandler);
}
function extractFunction(source, 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 function ${name}`);
let signatureDepth = 0;
let signatureEnded = false;
let bodyStart = -1;
for (let i = start; i < source.length; i += 1) {
const ch = source[i];
if (ch === '(') signatureDepth += 1;
if (ch === ')') {
signatureDepth -= 1;
if (signatureDepth === 0) signatureEnded = true;
}
if (ch === '{' && signatureEnded) {
bodyStart = i;
break;
}
}
assert.notEqual(bodyStart, -1, `missing body for ${name}`);
let depth = 0;
for (let i = bodyStart; 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 function ${name}`);
}
test('mail polling handlers and cleanup handlers are not wrapped by operation delay', () => {
const protectedFunctions = {
'content/mail-163.js': ['handlePollEmail', 'returnToInbox', 'openMailAndGetMessageText', 'deleteEmail', 'refreshInbox'],
'content/qq-mail.js': ['handlePollEmail', 'refreshInbox'],
'content/icloud-mail.js': ['openMailItemAndRead', 'refreshInbox', 'handlePollEmail'],
'content/mail-2925.js': ['handlePollEmail', 'openMailAndGetMessageText', 'deleteCurrentMailboxEmail', 'openMailAndDeleteAfterRead', 'deleteAllMailboxEmails', 'refreshInbox'],
'content/gmail-mail.js': ['refreshInbox', 'openRowAndGetMessageText', 'handlePollEmail'],
'content/inbucket-mail.js': ['refreshMailbox', 'openMailboxEntry', 'deleteCurrentMailboxMessage', 'handlePollEmail'],
};
for (const [file, functionNames] of Object.entries(protectedFunctions)) {
const source = fs.readFileSync(file, 'utf8');
const pollHandler = extractPollEmailHandler(source);
assert.doesNotMatch(pollHandler, /performOperationWithDelay\(/, `${file} POLL_EMAIL handler must stay delay-free`);
for (const functionName of functionNames) {
const functionBody = extractFunction(source, functionName);
assert.doesNotMatch(functionBody, /performOperationWithDelay\(/, `${file} ${functionName} must stay delay-free`);
}
}
const mail2925Source = fs.readFileSync('content/mail-2925.js', 'utf8');
const deleteAllStart = mail2925Source.indexOf("message.type === 'DELETE_ALL_EMAILS'");
assert.notEqual(deleteAllStart, -1, 'missing DELETE_ALL_EMAILS handler');
const deleteAllBlock = mail2925Source.slice(deleteAllStart, mail2925Source.indexOf('return false;', deleteAllStart));
assert.doesNotMatch(deleteAllBlock, /performOperationWithDelay\(/, '2925 cleanup must stay delay-free');
});
test('mail polling bundles do not load operation delay module', () => {
const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8'));
for (const file of ['content/mail-163.js', 'content/qq-mail.js', 'content/icloud-mail.js']) {
const bundle = manifest.content_scripts.find((entry) => entry.js.includes(file))?.js || [];
assert.equal(bundle.includes('content/operation-delay.js'), false, `${file} bundle must not include operation delay`);
}
});
test('WhatsApp code reader remains polling-only and delay-free', () => {
const source = fs.readFileSync('content/whatsapp-flow.js', 'utf8');
assert.doesNotMatch(source, /performOperationWithDelay\(/);
});
+15
View File
@@ -0,0 +1,15 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
for (const file of ['README.md', 'docs/使用教程/使用教程.md']) {
test(`${file} documents operation delay`, () => {
const source = fs.readFileSync(file, 'utf8');
assert.match(source, /操作间延迟/);
assert.match(source, /默认开启/);
assert.match(source, /2\s*秒/);
assert.match(source, /分格|OTP|验证码/);
assert.match(source, /邮箱|短信|轮询/);
assert.match(source, /confirm-oauth|platform-verify/);
});
}
+63
View File
@@ -0,0 +1,63 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
function assertOrdered(list, before, after) {
assert.ok(list.includes(before), `missing ${before}`);
assert.ok(list.includes(after), `missing ${after}`);
assert.ok(list.indexOf(before) < list.indexOf(after), `${before} must load before ${after}`);
}
test('manifest loads operation delay after utils only for covered auth/provider bundles', () => {
const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8'));
const authBundle = manifest.content_scripts.find((entry) => entry.js.includes('content/signup-page.js')).js;
assertOrdered(authBundle, 'content/utils.js', 'content/operation-delay.js');
assertOrdered(authBundle, 'content/operation-delay.js', 'content/auth-page-recovery.js');
assertOrdered(authBundle, 'content/operation-delay.js', 'content/signup-page.js');
const duckBundle = manifest.content_scripts.find((entry) => entry.js.includes('content/duck-mail.js')).js;
assertOrdered(duckBundle, 'content/utils.js', 'content/operation-delay.js');
assertOrdered(duckBundle, 'content/operation-delay.js', 'content/duck-mail.js');
for (const pollingFile of ['content/qq-mail.js', 'content/mail-163.js', 'content/icloud-mail.js']) {
const bundle = manifest.content_scripts.find((entry) => entry.js.includes(pollingFile))?.js || [];
assert.equal(bundle.includes('content/operation-delay.js'), false, `${pollingFile} polling bundle must not load operation delay`);
}
});
test('dynamic covered injections load operation delay after utils', () => {
const expectations = [
['background.js', 'SIGNUP_PAGE_INJECT_FILES'],
['background/steps/create-plus-checkout.js', 'PLUS_CHECKOUT_INJECT_FILES'],
['background/steps/fill-plus-checkout.js', 'PLUS_CHECKOUT_INJECT_FILES'],
['background/steps/paypal-approve.js', 'PAYPAL_INJECT_FILES'],
['background/steps/gopay-approve.js', 'GOPAY_INJECT_FILES'],
['background/mail-2925-session.js', 'MAIL2925_INJECT'],
];
for (const [file, constantName] of expectations) {
const source = fs.readFileSync(file, 'utf8');
const match = source.match(new RegExp(`const\\s+${constantName}\\s*=\\s*\\[([^\\]]+)\\]`));
assert.ok(match, `missing ${constantName} in ${file}`);
const block = match[1];
assert.match(block, /'content\/utils\.js'[\s\S]*'content\/operation-delay\.js'/, `${file} must inject operation delay after utils`);
if (constantName === 'SIGNUP_PAGE_INJECT_FILES') {
assert.match(block, /'content\/operation-delay\.js'[\s\S]*'content\/auth-page-recovery\.js'/, 'auth recovery must load after operation delay');
}
}
});
test('2925 provider reuse path also injects operation delay', () => {
const source = fs.readFileSync('background.js', 'utf8');
const start = source.indexOf("if (provider === '2925')");
assert.notEqual(start, -1, 'missing 2925 provider config');
const end = source.indexOf("return { source: 'qq-mail'", start);
const block = source.slice(start, end);
assert.match(block, /inject:\s*\[[\s\S]*'content\/utils\.js'[\s\S]*'content\/operation-delay\.js'[\s\S]*'content\/mail-2925\.js'[\s\S]*\]/);
});
test('excluded platform verification paths do not load operation delay', () => {
for (const file of ['background/steps/platform-verify.js', 'background/panel-bridge.js']) {
const source = fs.readFileSync(file, 'utf8');
assert.doesNotMatch(source, /content\/operation-delay\.js/);
}
});
+241
View File
@@ -1,8 +1,10 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const source = fs.readFileSync('background/steps/paypal-approve.js', 'utf8');
const paypalContentSource = fs.readFileSync('content/paypal-flow.js', 'utf8');
function loadModule() {
const self = {};
@@ -85,6 +87,158 @@ function createExecutor({
return { executor, events };
}
function createPayPalContentHarness() {
const paypalEvents = [];
const attrs = new Map();
let listener = null;
let elements = [];
const body = { innerText: '', textContent: '' };
function createElement({ tagName = 'DIV', text = '', type = '', id = '', name = '', placeholder = '' } = {}) {
return {
nodeType: 1,
tagName,
type,
id,
name,
placeholder,
textContent: text,
value: '',
disabled: false,
hidden: false,
parentElement: null,
style: { display: 'block', visibility: 'visible', opacity: '1' },
getAttribute(key) {
if (key === 'type') return this.type;
if (key === 'id') return this.id;
if (key === 'name') return this.name;
if (key === 'placeholder') return this.placeholder;
return null;
},
getBoundingClientRect() {
return { left: 10, top: 10, width: 180, height: 44 };
},
};
}
const emailInput = createElement({ tagName: 'INPUT', type: 'email', id: 'email', name: 'login_email', placeholder: 'Email' });
const nextButton = createElement({ tagName: 'BUTTON', text: 'Next', id: 'btnNext' });
const passwordInput = createElement({ tagName: 'INPUT', type: 'password', id: 'password', name: 'login_password', placeholder: 'Password' });
const loginButton = createElement({ tagName: 'BUTTON', text: 'Log In', id: 'btnLogin' });
const promptButton = createElement({ tagName: 'BUTTON', text: 'Not now', id: 'notNow' });
const approveButton = createElement({ tagName: 'BUTTON', text: 'Agree and Continue', id: 'approve' });
elements = [emailInput, nextButton];
const context = {
console: { log() {}, warn() {}, error() {}, info() {} },
location: { href: 'https://www.paypal.com/signin' },
window: {},
document: {
readyState: 'complete',
body,
documentElement: {
getAttribute(name) {
return attrs.get(name) || null;
},
setAttribute(name, value) {
attrs.set(name, String(value));
},
},
querySelectorAll(selector) {
if (selector === 'input') return elements.filter((element) => element.tagName === 'INPUT');
if (selector === 'input[type="email"]') return elements.filter((element) => element.type === 'email');
if (selector === 'input[type="password"]') return elements.filter((element) => element.type === 'password');
if (String(selector || '').includes('button') || String(selector || '').includes('[role="button"]')) {
return elements.filter((element) => element.tagName === 'BUTTON');
}
return [];
},
},
chrome: {
runtime: {
onMessage: {
addListener(fn) {
listener = fn;
},
},
},
},
CodexOperationDelay: {
async performOperationWithDelay(metadata, operation) {
paypalEvents.push(`operation:${metadata.label}:start`);
const result = await operation();
paypalEvents.push(`operation:${metadata.label}:end`);
paypalEvents.push(`delay:${metadata.label}:2000`);
return result;
},
},
resetStopState() {},
isStopError() { return false; },
throwIfStopped() {},
sleep() { return Promise.resolve(); },
fillInput(element, value) {
element.value = value;
if (element === emailInput && value) {
paypalEvents.push('fill:paypal-email');
} else if (element === passwordInput && value) {
paypalEvents.push('fill:paypal-password');
}
},
simulateClick(element) {
if (element === approveButton) {
paypalEvents.push('click:paypal-approve');
} else if (element === loginButton) {
paypalEvents.push('click:paypal-password');
} else if (element === promptButton) {
paypalEvents.push('click:paypal-dismiss-prompt');
}
},
};
context.window = context;
context.window.getComputedStyle = (element) => element?.style || { display: 'block', visibility: 'visible', opacity: '1' };
vm.createContext(context);
vm.runInContext(paypalContentSource, context);
assert.equal(typeof listener, 'function');
async function send(message) {
return await new Promise((resolve) => {
listener(message, {}, resolve);
});
}
function showApprovalPage() {
context.location.href = 'https://www.paypal.com/checkoutnow/approve';
body.innerText = '';
body.textContent = '';
elements = [approveButton];
}
function showPasswordPage() {
context.location.href = 'https://www.paypal.com/signin';
body.innerText = '';
body.textContent = '';
elements = [passwordInput, loginButton];
}
function showCombinedLoginPageWithPrefilledEmail(email) {
context.location.href = 'https://www.paypal.com/signin';
body.innerText = '';
body.textContent = '';
emailInput.value = email;
elements = [emailInput, passwordInput, loginButton];
}
function showPasskeyPrompt() {
context.location.href = 'https://www.paypal.com/signin';
body.innerText = 'Save a passkey for faster login';
body.textContent = body.innerText;
elements = [promptButton];
}
return { paypalEvents, send, showApprovalPage, showCombinedLoginPageWithPrefilledEmail, showPasswordPage, showPasskeyPrompt };
}
test('PayPal approve keeps original combined email and password login path', async () => {
const { executor, events } = createExecutor({
pageStates: [
@@ -107,6 +261,93 @@ test('PayPal approve keeps original combined email and password login path', asy
assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true);
});
test('PayPal content routes email and approve page operations through the operation delay gate', async () => {
const { paypalEvents, send, showApprovalPage } = createPayPalContentHarness();
const loginResult = await send({
type: 'PAYPAL_SUBMIT_LOGIN',
source: 'test',
payload: { email: 'user@example.com', password: 'secret' },
});
assert.equal(loginResult.ok, true);
assert.equal(loginResult.phase, 'email_submitted');
showApprovalPage();
const approveResult = await send({ type: 'PAYPAL_CLICK_APPROVE', source: 'test', payload: {} });
assert.equal(approveResult.ok, true);
assert.equal(approveResult.clicked, true);
assert.deepStrictEqual(paypalEvents, [
'operation:paypal-email:start',
'fill:paypal-email',
'operation:paypal-email:end',
'delay:paypal-email:2000',
'operation:paypal-approve:start',
'click:paypal-approve',
'operation:paypal-approve:end',
'delay:paypal-approve:2000',
]);
});
test('PayPal content routes password submit and prompt dismissal through the operation delay gate', async () => {
const { paypalEvents, send, showPasswordPage, showPasskeyPrompt } = createPayPalContentHarness();
showPasswordPage();
const passwordResult = await send({
type: 'PAYPAL_SUBMIT_LOGIN',
source: 'test',
payload: { email: 'user@example.com', password: 'secret' },
});
assert.equal(passwordResult.ok, true);
assert.equal(passwordResult.phase, 'password_submitted');
assert.deepStrictEqual(paypalEvents, [
'operation:paypal-password:start',
'fill:paypal-password',
'click:paypal-password',
'operation:paypal-password:end',
'delay:paypal-password:2000',
]);
paypalEvents.length = 0;
showPasskeyPrompt();
const promptResult = await send({ type: 'PAYPAL_DISMISS_PROMPTS', source: 'test', payload: {} });
assert.equal(promptResult.ok, true);
assert.equal(promptResult.clicked, 1);
assert.deepStrictEqual(paypalEvents, [
'operation:paypal-dismiss-prompt:start',
'click:paypal-dismiss-prompt',
'operation:paypal-dismiss-prompt:end',
'delay:paypal-dismiss-prompt:2000',
]);
});
test('PayPal content keeps prefilled email values containing pass on the paypal-email path', async () => {
const { paypalEvents, send, showCombinedLoginPageWithPrefilledEmail } = createPayPalContentHarness();
showCombinedLoginPageWithPrefilledEmail('compass@example.com');
const loginResult = await send({
type: 'PAYPAL_SUBMIT_LOGIN',
source: 'test',
payload: { email: 'compass@example.com', password: 'secret' },
});
assert.equal(loginResult.ok, true);
assert.equal(loginResult.phase, 'password_submitted');
assert.deepStrictEqual(paypalEvents, [
'operation:paypal-email:start',
'fill:paypal-email',
'operation:paypal-email:end',
'delay:paypal-email:2000',
'operation:paypal-password:start',
'fill:paypal-password',
'click:paypal-password',
'operation:paypal-password:end',
'delay:paypal-password:2000',
]);
});
test('PayPal approve prefers the selected paypal pool account over legacy fields', async () => {
const { executor, events } = createExecutor({
pageStates: [
+216
View File
@@ -1,11 +1,159 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const source = fs.readFileSync('background/steps/create-plus-checkout.js', 'utf8');
const plusCheckoutSource = fs.readFileSync('content/plus-checkout.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundPlusCheckoutCreate;`)(globalScope);
function createCheckoutContentHarness() {
const checkoutEvents = [];
const attrs = new Map();
let listener = null;
const elements = [];
function createElement({ tagName = 'DIV', text = '', attrs: initialAttrs = {}, id = '', type = '', value = '' } = {}) {
const attrMap = new Map(Object.entries(initialAttrs));
if (id) attrMap.set('id', id);
if (type) attrMap.set('type', type);
const element = {
nodeType: 1,
tagName,
id,
type,
value,
textContent: text,
innerText: text,
className: initialAttrs.class || '',
checked: initialAttrs.checked === 'true',
disabled: false,
hidden: false,
dataset: {},
children: [],
parentElement: null,
style: { display: 'block', visibility: 'visible' },
getAttribute(name) {
if (name === 'class') return this.className;
if (name === 'id') return this.id || attrMap.get(name) || '';
if (name === 'type') return this.type || attrMap.get(name) || '';
return attrMap.has(name) ? attrMap.get(name) : '';
},
setAttribute(name, nextValue) {
attrMap.set(name, String(nextValue));
},
closest() {
return null;
},
querySelector() {
return null;
},
scrollIntoView() {},
focus() {},
dispatchEvent() {
return true;
},
click() {},
getBoundingClientRect() {
return { left: 10, top: 20, width: 180, height: 44 };
},
};
return element;
}
const paymentButton = createElement({ tagName: 'BUTTON', text: 'PayPal', attrs: { role: 'tab', 'aria-selected': '' } });
const fullNameInput = createElement({ tagName: 'INPUT', id: 'name', type: 'text', attrs: { name: 'billingName', placeholder: 'Full name' } });
const addressInput = createElement({ tagName: 'INPUT', id: 'address', type: 'text', attrs: { name: 'addressLine1', placeholder: 'Address line 1' } });
const cityInput = createElement({ tagName: 'INPUT', id: 'city', type: 'text', attrs: { name: 'locality', placeholder: 'City' } });
const postalInput = createElement({ tagName: 'INPUT', id: 'postal', type: 'text', attrs: { name: 'postalCode', placeholder: 'Postal code' } });
const suggestionOption = createElement({ tagName: 'LI', text: 'Unter den Linden 1, Berlin', attrs: { role: 'option', class: 'pac-item' } });
const subscribeButton = createElement({ tagName: 'BUTTON', text: 'Subscribe', attrs: { type: 'submit', 'aria-label': 'Subscribe' } });
subscribeButton.type = 'submit';
elements.push(paymentButton, fullNameInput, addressInput, cityInput, postalInput, suggestionOption, subscribeButton);
const context = {
console: { log() {}, warn() {}, error() {}, info() {} },
location: { href: 'https://chatgpt.com/checkout/openai_ie/cs_test' },
window: {},
CSS: { escape: (value) => String(value) },
Event: class TestEvent { constructor(type) { this.type = type; } },
MouseEvent: class TestMouseEvent { constructor(type) { this.type = type; } },
PointerEvent: class TestPointerEvent { constructor(type) { this.type = type; } },
document: {
readyState: 'complete',
body: {},
documentElement: {
getAttribute(name) {
return attrs.get(name) || null;
},
setAttribute(name, nextValue) {
attrs.set(name, String(nextValue));
},
},
getElementById() {
return null;
},
querySelectorAll(selector) {
const text = String(selector || '');
if (text.includes('label[for=')) return [];
if (text.includes('[role="option"]') || text.includes('.pac-item') || text === 'li') return [suggestionOption];
if (text === 'input, textarea') return elements.filter((element) => element.tagName === 'INPUT');
if (text.includes('button[type="submit"]')) return [subscribeButton];
if (text.includes('button') || text.includes('[role=') || text.includes('[tabindex]') || text.includes('[data-testid]')) {
return elements.filter((element) => element.tagName === 'BUTTON');
}
if (text.includes('select') || text.includes('[aria-haspopup="listbox"]')) return [];
return [];
},
},
chrome: {
runtime: {
onMessage: {
addListener(fn) {
listener = fn;
},
},
},
},
CodexOperationDelay: {
async performOperationWithDelay(metadata, operation) {
checkoutEvents.push({ type: 'operation', label: metadata.label, kind: metadata.kind });
const result = await operation();
checkoutEvents.push({ type: 'delay', label: metadata.label, ms: 2000 });
return result;
},
},
resetStopState() {},
isStopError() { return false; },
throwIfStopped() {},
sleep() { return Promise.resolve(); },
log() {},
fillInput(element, nextValue) {
element.value = nextValue;
},
simulateClick(element) {
if (element === paymentButton) {
paymentButton.setAttribute('aria-selected', 'true');
}
},
};
context.window = context;
context.window.getComputedStyle = (element) => element?.style || { display: 'block', visibility: 'visible' };
vm.createContext(context);
vm.runInContext(plusCheckoutSource, context);
assert.equal(typeof listener, 'function');
async function send(message) {
return await new Promise((resolve) => {
listener(message, {}, resolve);
});
}
return { checkoutEvents, send };
}
test('Plus checkout create does not wait 20 seconds after opening checkout page', async () => {
const events = [];
const executor = api.createPlusCheckoutCreateExecutor({
@@ -107,6 +255,74 @@ test('GoPay plus checkout create forwards gopay payment method to the checkout c
assert.deepStrictEqual(events[0]?.payload, { paymentMethod: 'gopay' });
});
test('Plus checkout content routes billing operations through the operation delay gate', async () => {
const { checkoutEvents, send } = createCheckoutContentHarness();
const result = await send({
type: 'FILL_PLUS_BILLING_AND_SUBMIT',
source: 'test',
payload: {
fullName: 'Ada Lovelace',
addressSeed: {
skipAutocomplete: true,
fallback: {
address1: 'Unter den Linden',
city: 'Berlin',
region: 'Berlin',
postalCode: '10117',
},
},
},
});
assert.equal(result.ok, true);
assert.deepStrictEqual(checkoutEvents.filter((event) => event.type === 'operation').map((event) => event.label), [
'select-payment-method',
'fill-billing-address',
'click-subscribe',
]);
assert.deepStrictEqual(checkoutEvents.filter((event) => event.type === 'delay').map((event) => event.ms), [2000, 2000, 2000]);
});
test('Plus checkout content routes same-frame autocomplete query and suggestion through separate operation delays', async () => {
const { checkoutEvents, send } = createCheckoutContentHarness();
const result = await send({
type: 'FILL_PLUS_BILLING_AND_SUBMIT',
source: 'test',
payload: {
fullName: 'Ada Lovelace',
addressSeed: {
query: 'Unter den Linden',
suggestionIndex: 0,
fallback: {
address1: 'Unter den Linden',
city: 'Berlin',
region: 'Berlin',
postalCode: '10117',
},
},
},
});
assert.equal(result.ok, true);
assert.deepStrictEqual(checkoutEvents.filter((event) => event.type === 'operation').map((event) => event.label), [
'select-payment-method',
'fill-address-query',
'select-address-suggestion',
'fill-billing-address',
'click-subscribe',
]);
assert.deepStrictEqual(checkoutEvents.filter((event) => event.type === 'delay').map((event) => event.label), [
'select-payment-method',
'fill-address-query',
'select-address-suggestion',
'fill-billing-address',
'click-subscribe',
]);
assert.equal(checkoutEvents.some((event) => event.type === 'delay' && event.ms !== 2000), false);
});
test('GPC checkout injects Plus script before reading ChatGPT session token and sends X-API-Key', async () => {
const events = [];
const fetchCalls = [];
+17
View File
@@ -0,0 +1,17 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
test('Duck address generation routes the generator click through operation delay', () => {
const source = fs.readFileSync('content/duck-mail.js', 'utf8');
assert.match(source, /performOperationWithDelay\([\s\S]*duck-generate-address/);
});
test('2925 session preparation routes through operation delay while cleanup stays delay-free', () => {
const source = fs.readFileSync('content/mail-2925.js', 'utf8');
assert.match(source, /ENSURE_MAIL2925_SESSION[\s\S]*performOperationWithDelay/);
const deleteAllStart = source.indexOf("message.type === 'DELETE_ALL_EMAILS'");
assert.notEqual(deleteAllStart, -1, 'missing DELETE_ALL_EMAILS handler');
const deleteAllBlock = source.slice(deleteAllStart, source.indexOf('return false;', deleteAllStart));
assert.doesNotMatch(deleteAllBlock, /performOperationWithDelay\(/);
});
+132
View File
@@ -0,0 +1,132 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
const source = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
function extractFunction(name) {
let start = source.indexOf(`async function ${name}(`);
if (start === -1) {
start = source.indexOf(`function ${name}(`);
}
assert.notEqual(start, -1, `missing ${name}`);
let depth = 0;
let signatureEnded = false;
let bodyStart = -1;
for (let i = start; i < source.length; i += 1) {
const ch = source[i];
if (ch === '(') depth += 1;
if (ch === ')') {
depth -= 1;
if (depth === 0) signatureEnded = true;
}
if (ch === '{' && signatureEnded) {
bodyStart = i;
break;
}
}
let braceDepth = 0;
for (let i = bodyStart; i < source.length; i += 1) {
const ch = source[i];
if (ch === '{') braceDepth += 1;
if (ch === '}') {
braceDepth -= 1;
if (braceDepth === 0) return source.slice(start, i + 1);
}
}
throw new Error(`unterminated ${name}`);
}
test('sidepanel exposes one operation delay switch in the existing settings surface', () => {
assert.match(html, /id="row-operation-delay-settings"/);
assert.match(html, /id="input-operation-delay-enabled"/);
assert.match(html, /操作间延迟/);
assert.match(html, /2\s*秒/);
assert.match(html, /id="row-operation-delay-settings"[\s\S]{0,900}输入[\s\S]{0,900}选择[\s\S]{0,900}点击[\s\S]{0,900}提交[\s\S]{0,900}继续[\s\S]{0,900}授权/);
});
test('sidepanel owns operation delay toggle feedback exactly once', () => {
assert.match(source, /inputOperationDelayEnabled/);
assert.match(source, /operationDelayEnabled/);
assert.match(source, /appendLog\(\{[\s\S]*操作间延迟/);
assert.doesNotMatch(source, /operationDelayEnabled[\s\S]{0,240}addLog\(/);
});
test('sidepanel operation delay restore and save failure contracts are explicit', () => {
assert.match(source, /normalizeOperationDelayEnabled/);
assert.match(source, /lastConfirmedOperationDelayEnabled/);
assert.match(source, /操作间延迟设置读取失败/);
assert.match(source, /操作间延迟设置保存失败/);
assert.match(source, /inputOperationDelayEnabled\.checked\s*=\s*lastConfirmedOperationDelayEnabled/);
assert.match(source, /typeof\s+applyOperationDelayState\s*===\s*['"]function['"]/);
});
test('sidepanel syncs operation delay DATA_UPDATED without becoming the success-log owner', () => {
assert.match(source, /message\.payload\.operationDelayEnabled\s*!==\s*undefined[\s\S]{0,240}applyOperationDelayState\(message\.payload\)/);
});
function loadOperationDelaySidepanelHarness() {
const runtimeMessages = [];
const logs = [];
const inputOperationDelayEnabled = { checked: true, disabled: false };
const chrome = {
runtime: {
sendMessage: async (message) => {
runtimeMessages.push(message);
return { ok: true, state: { operationDelayEnabled: message.payload.operationDelayEnabled } };
},
},
};
const harness = new Function('chrome', 'appendLog', 'inputOperationDelayEnabled', 'initialLatestState', `
let latestState = initialLatestState;
let lastConfirmedOperationDelayEnabled = true;
function syncLatestState(nextState) {
latestState = { ...(latestState || {}), ...(nextState || {}) };
}
${extractFunction('normalizeOperationDelayEnabled')}
${extractFunction('appendOperationDelayLog')}
${extractFunction('applyOperationDelayState')}
${extractFunction('persistOperationDelayToggle')}
return {
applyOperationDelayState,
persistOperationDelayToggle,
getLatestState: () => latestState,
getLastConfirmedOperationDelayEnabled: () => lastConfirmedOperationDelayEnabled,
setLastConfirmedOperationDelayEnabled: (value) => { lastConfirmedOperationDelayEnabled = value; },
};
`)(chrome, (entry) => logs.push(entry), inputOperationDelayEnabled, { operationDelayEnabled: true });
return { chrome, harness, inputOperationDelayEnabled, logs, runtimeMessages };
}
test('operation delay switch logs once, defaults restore failures to enabled, and rolls back save failures', async () => {
const { chrome, harness: helpers, inputOperationDelayEnabled: input, logs, runtimeMessages: sentMessages } = loadOperationDelaySidepanelHarness();
helpers.applyOperationDelayState({ operationDelayEnabled: false });
assert.equal(input.checked, false);
assert.equal(helpers.getLatestState().operationDelayEnabled, false);
helpers.applyOperationDelayState({ operationDelayEnabled: undefined }, { restoreFailed: true });
assert.equal(input.checked, true);
assert.equal(helpers.getLatestState().operationDelayEnabled, true);
assert.equal(logs.filter((entry) => entry.level === 'warn').length, 1);
logs.length = 0;
input.checked = false;
await helpers.persistOperationDelayToggle();
assert.equal(sentMessages.at(-1).payload.operationDelayEnabled, false);
assert.equal(logs.length, 1);
assert.match(logs[0].message, /关闭|off/i);
sentMessages.length = 0;
chrome.runtime.sendMessage = async () => { throw new Error('network down'); };
logs.length = 0;
input.checked = true;
helpers.setLastConfirmedOperationDelayEnabled(false);
await assert.rejects(() => helpers.persistOperationDelayToggle(), /network down/);
assert.equal(input.checked, false);
assert.equal(logs.filter((entry) => entry.level === 'error').length, 1);
});
+58 -5
View File
@@ -57,6 +57,9 @@ const logs = [];
const completions = [];
const clicks = [];
const scheduled = [];
const events = [];
let releaseSubmitDelay;
const submitDelay = new Promise((resolve) => { releaseSubmitDelay = resolve; });
const snapshot = {
state: 'password_page',
@@ -66,10 +69,23 @@ const snapshot = {
};
const window = {
setTimeout(fn) {
scheduled.push(fn);
setTimeout(fn, ms) {
scheduled.push({ fn, ms });
return scheduled.length;
},
CodexOperationDelay: {
async performOperationWithDelay(metadata, operation) {
events.push('operation:' + metadata.label + ':start');
const result = await operation();
events.push('operation:' + metadata.label + ':end');
if (metadata.kind === 'submit') {
events.push('delay:' + metadata.label + ':pending');
await submitDelay;
}
events.push('delay:' + metadata.label + ':2000');
return result;
},
},
};
const location = {
@@ -114,6 +130,7 @@ async function waitForElementByText() {
function fillInput(input, value) {
input.value = value;
events.push('fill-password:' + value);
}
async function humanPause() {}
@@ -125,14 +142,21 @@ function isStopError() {
function log(message, level = 'info') {
logs.push({ message, level });
events.push('log:' + message);
}
function reportComplete(step, payload) {
completions.push({ step, payload });
events.push('report:' + payload.deferredSubmit);
}
function simulateClick(target) {
clicks.push(target.textContent || 'button');
events.push('click:' + (target.textContent || 'button'));
}
function getOperationDelayRunner() {
return window.CodexOperationDelay.performOperationWithDelay;
}
${extractFunction('step3_fillEmailPassword')}
@@ -145,28 +169,42 @@ return {
if (!scheduled.length) {
throw new Error('missing deferred submit');
}
await scheduled[0]();
await scheduled[0].fn();
},
releaseSubmitDelay() {
releaseSubmitDelay();
},
snapshot() {
return {
logs,
completions,
clicks,
events,
passwordValue: snapshot.passwordInput.value,
scheduledCount: scheduled.length,
scheduledDelayMs: scheduled[0]?.ms,
};
},
};
`)();
const result = await api.run({
let settled = false;
const tracked = api.run({
email: 'user@example.com',
password: 'Secret123!',
}).then((value) => {
settled = true;
return value;
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(settled, true, 'step 3 must return completion before deferred submit can navigate');
const result = await tracked;
const beforeSubmit = api.snapshot();
assert.equal(beforeSubmit.passwordValue, 'Secret123!');
assert.equal(beforeSubmit.scheduledCount, 1);
assert.equal(beforeSubmit.scheduledDelayMs, 120);
assert.deepStrictEqual(beforeSubmit.clicks, []);
assert.equal(beforeSubmit.completions.length, 1);
assert.equal(beforeSubmit.completions[0].step, 3);
@@ -174,9 +212,24 @@ return {
assert.equal(result.email, 'user@example.com');
assert.equal(result.deferredSubmit, true);
assert.equal(typeof result.signupVerificationRequestedAt, 'number');
assert.equal(beforeSubmit.events.includes('report:true'), true);
assert.equal(beforeSubmit.events.includes('operation:submit-signup-password:start'), false);
await api.flushDeferredSubmit();
let flushSettled = false;
const flushed = api.flushDeferredSubmit().then(() => {
flushSettled = true;
});
await new Promise((resolve) => setImmediate(resolve));
const duringSubmit = api.snapshot();
assert.equal(duringSubmit.events.includes('operation:submit-signup-password:start'), true);
assert.equal(duringSubmit.events.includes('delay:submit-signup-password:pending'), true);
assert.equal(flushSettled, false);
api.releaseSubmitDelay();
await flushed;
const afterSubmit = api.snapshot();
assert.deepStrictEqual(afterSubmit.clicks, ['Continue']);
assert.equal(afterSubmit.events.includes('delay:submit-signup-password:2000'), true);
});
+334 -2
View File
@@ -57,9 +57,24 @@ test('fillVerificationCode submits after split inputs are stably filled', async
const logs = [];
const clicks = [];
const filledValues = [];
const splitCodeEvents = [];
let activeOperationLabel = '';
let submitClicked = false;
const VERIFICATION_CODE_INPUT_SELECTOR = 'input[data-verification-code]';
const location = { href: 'https://auth.openai.com/email-verification' };
const window = {
CodexOperationDelay: {
async performOperationWithDelay(metadata, operation) {
splitCodeEvents.push(\`operation:\${metadata.label}:start\`);
activeOperationLabel = metadata.label;
const result = await operation();
activeOperationLabel = '';
splitCodeEvents.push(\`operation:\${metadata.label}:end\`);
splitCodeEvents.push(\`delay:\${metadata.label}:2000\`);
return result;
},
},
};
function KeyboardEvent(type, init = {}) {
this.type = type;
Object.assign(this, init);
@@ -113,8 +128,16 @@ async function handle405ResendError() {}
function fillInput(el, value) {
el.value = value;
filledValues.push(value);
const splitIndex = inputs.indexOf(el);
if (splitIndex >= 0) {
splitCodeEvents.push(\`fill-code:\${splitIndex}\`);
}
}
async function sleep(ms) {
if (activeOperationLabel) {
splitCodeEvents.push(\`sleep:\${activeOperationLabel}:\${ms}\`);
}
}
async function sleep() {}
async function waitForDocumentLoadComplete() {}
function isStep5Ready() { return false; }
function isStep8Ready() { return false; }
@@ -125,7 +148,7 @@ function isVisibleElement() { return true; }
function isActionEnabled(el) { return Boolean(el) && !el.disabled; }
function getActionText(el) { return el.textContent || ''; }
async function humanPause() {}
function simulateClick(el) { el.click(); clicks.push(el.textContent); }
function simulateClick(el) { el.click(); clicks.push(el.textContent); splitCodeEvents.push('click:submit-code'); }
async function waitForVerificationSubmitOutcome() { return { success: true }; }
${extractFunction('getVisibleSplitVerificationInputs')}
@@ -150,6 +173,7 @@ return {
logs,
clicks,
filledValues,
splitCodeEvents,
submitClicked,
currentValue: inputs.map((input) => input.value).join(''),
};
@@ -164,6 +188,22 @@ return {
assert.equal(snapshot.currentValue, '123456');
assert.equal(snapshot.submitClicked, true);
assert.deepStrictEqual(snapshot.clicks, ['Continue']);
assert.deepStrictEqual(snapshot.splitCodeEvents, [
'operation:split-code:start',
'fill-code:0',
'fill-code:1',
'fill-code:2',
'fill-code:3',
'fill-code:4',
'fill-code:5',
'operation:split-code:end',
'delay:split-code:2000',
'operation:submit-code:start',
'click:submit-code',
'operation:submit-code:end',
'delay:submit-code:2000',
]);
assert.equal(snapshot.splitCodeEvents.filter((event) => event.startsWith('delay:split-code')).length, 1);
});
test('fillVerificationCode does not short-circuit on mixed email-verification profile page before verification exits', async () => {
@@ -895,3 +935,295 @@ return {
assert.equal(result.clicks.length, 0);
assert.equal(result.logs.some(({ message }) => /检测到密码页报错/.test(message)), true);
});
test('fillSignupEmailAndContinue waits for submit operation delay before returning', async () => {
const api = new Function(`
const events = [];
let releaseSubmitDelay;
const submitDelay = new Promise((resolve) => { releaseSubmitDelay = resolve; });
const emailInput = { value: '' };
const continueButton = { textContent: 'Continue' };
const location = { href: 'https://auth.openai.com/u/signup' };
const window = {
setTimeout(callback, ms) {
events.push(\`timer:\${ms}\`);
return 1;
},
CodexOperationDelay: {
async performOperationWithDelay(metadata, operation) {
events.push(\`operation:\${metadata.label}:start\`);
const result = await operation();
events.push(\`operation:\${metadata.label}:end\`);
if (metadata.kind === 'submit') {
events.push(\`delay:\${metadata.label}:pending\`);
await submitDelay;
}
events.push(\`delay:\${metadata.label}:2000\`);
return result;
},
},
};
function getOperationDelayRunner() { return window.CodexOperationDelay.performOperationWithDelay; }
function throwIfStopped() {}
function isStopError() { return false; }
function log() {}
async function humanPause() {}
async function sleep(ms) { events.push(\`sleep:\${ms}\`); }
function fillInput(input, value) { input.value = value; events.push(\`fill:\${value}\`); }
function simulateClick(el) { events.push(\`click:\${el.textContent}\`); }
async function waitForSignupEntryState() {
return { state: 'email_entry', emailInput, continueButton, url: location.href };
}
function getSignupEmailContinueButton() { return continueButton; }
function isActionEnabled() { return true; }
${extractFunction('fillSignupEmailAndContinue')}
return {
events,
releaseSubmitDelay() { releaseSubmitDelay(); },
run() { return fillSignupEmailAndContinue('ada@example.com', 2); },
};
`)();
let settled = false;
const tracked = api.run().then((result) => {
settled = true;
api.events.push('run:resolved');
return result;
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(settled, false, 'email submit must not return before submit delay resolves');
assert.equal(api.events.includes('delay:submit-signup-email:pending'), true);
assert.equal(api.events.includes('run:resolved'), false);
api.releaseSubmitDelay();
const result = await tracked;
assert.equal(result.submitted, true);
assert.deepStrictEqual(api.events, [
'operation:signup-email:start',
'fill:ada@example.com',
'operation:signup-email:end',
'delay:signup-email:2000',
'sleep:120',
'operation:submit-signup-email:start',
'click:Continue',
'operation:submit-signup-email:end',
'delay:submit-signup-email:pending',
'delay:submit-signup-email:2000',
'run:resolved',
]);
});
test('submitSignupPhoneNumberAndContinue waits for submit operation delay before returning', async () => {
const api = new Function(`
const events = [];
let releaseSubmitDelay;
const submitDelay = new Promise((resolve) => { releaseSubmitDelay = resolve; });
const phoneInput = { value: '' };
const continueButton = { textContent: 'Continue' };
const location = { href: 'https://auth.openai.com/u/signup/phone' };
const window = {
setTimeout(callback, ms) {
events.push(\`timer:\${ms}\`);
return 1;
},
CodexOperationDelay: {
async performOperationWithDelay(metadata, operation) {
events.push(\`operation:\${metadata.label}:start\`);
const result = await operation();
events.push(\`operation:\${metadata.label}:end\`);
if (metadata.kind === 'submit') {
events.push(\`delay:\${metadata.label}:pending\`);
await submitDelay;
}
events.push(\`delay:\${metadata.label}:2000\`);
return result;
},
},
};
function getOperationDelayRunner() { return window.CodexOperationDelay.performOperationWithDelay; }
function throwIfStopped() {}
function isStopError() { return false; }
function log() {}
async function humanPause() {}
async function sleep(ms) { events.push(\`sleep:\${ms}\`); }
function fillInput(input, value) { input.value = value; events.push(\`fill:\${value}\`); }
function simulateClick(el) { events.push(\`click:\${el.textContent}\`); }
async function waitForSignupPhoneEntryState() {
return { state: 'phone_entry', phoneInput, url: location.href };
}
async function ensureSignupPhoneCountrySelected() {
return { hasCountryControl: false, matched: true, selectedOption: null };
}
function resolveSignupPhoneDialCode() { return '1'; }
function toNationalPhoneNumber() { return '5551234567'; }
function getSignupPhoneHiddenNumberInput() { return null; }
function toE164PhoneNumber() { return '+15551234567'; }
function getSignupEmailContinueButton() { return continueButton; }
function isActionEnabled() { return true; }
${extractFunction('submitSignupPhoneNumberAndContinue')}
return {
events,
releaseSubmitDelay() { releaseSubmitDelay(); },
run() { return submitSignupPhoneNumberAndContinue({ phoneNumber: '+15551234567' }); },
};
`)();
let settled = false;
const tracked = api.run().then((result) => {
settled = true;
api.events.push('run:resolved');
return result;
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(settled, false, 'phone submit must not return before submit delay resolves');
assert.equal(api.events.includes('delay:submit-signup-phone:pending'), true);
assert.equal(api.events.includes('run:resolved'), false);
api.releaseSubmitDelay();
const result = await tracked;
assert.equal(result.submitted, true);
assert.deepStrictEqual(api.events, [
'operation:signup-phone-number:start',
'fill:5551234567',
'operation:signup-phone-number:end',
'delay:signup-phone-number:2000',
'sleep:120',
'operation:submit-signup-phone:start',
'click:Continue',
'operation:submit-signup-phone:end',
'delay:submit-signup-phone:pending',
'delay:submit-signup-phone:2000',
'run:resolved',
]);
});
test('step3_fillEmailPassword reports complete before deferred submit while submit still waits for operation delay', async () => {
const api = new Function(`
const events = [];
const reports = [];
const scheduled = [];
let releaseSubmitDelay;
const submitDelay = new Promise((resolve) => { releaseSubmitDelay = resolve; });
const Date = { now: () => 12345 };
const passwordInput = { value: '' };
const submitButton = { textContent: 'Continue' };
const location = { href: 'https://auth.openai.com/u/signup/password' };
const window = {
setTimeout(callback, ms) {
events.push(\`timer:\${ms}\`);
scheduled.push(callback);
return scheduled.length;
},
CodexOperationDelay: {
async performOperationWithDelay(metadata, operation) {
events.push(\`operation:\${metadata.label}:start\`);
const result = await operation();
events.push(\`operation:\${metadata.label}:end\`);
if (metadata.kind === 'submit') {
events.push(\`delay:\${metadata.label}:pending\`);
await submitDelay;
}
events.push(\`delay:\${metadata.label}:2000\`);
return result;
},
},
};
function getOperationDelayRunner() { return window.CodexOperationDelay.performOperationWithDelay; }
function throwIfStopped() {}
function isStopError() { return false; }
function log(message) { events.push(\`log:\${message}\`); }
async function humanPause() {}
async function sleep(ms) { events.push(\`sleep:\${ms}\`); }
function fillInput(input, value) { input.value = value; events.push(\`fill-password:\${value}\`); }
function simulateClick(el) { events.push(\`click:\${el.textContent}\`); }
function inspectSignupEntryState() {
return { state: 'password_page', passwordInput, submitButton, displayedEmail: 'ada@example.com' };
}
function getSignupPasswordSubmitButton() { return submitButton; }
async function waitForElementByText() { return null; }
function logSignupPasswordDiagnostics() {}
function reportComplete(step, payload) {
reports.push({ step, payload });
events.push(\`report:\${payload.deferredSubmit}\`);
}
${extractFunction('step3_fillEmailPassword')}
return {
events,
reports,
scheduledCount() { return scheduled.length; },
async flushDeferredSubmit() {
if (!scheduled.length) throw new Error('missing deferred submit');
await scheduled[0]();
},
releaseSubmitDelay() { releaseSubmitDelay(); },
run() { return step3_fillEmailPassword({ email: 'ada@example.com', password: 'Secret123!' }); },
};
`)();
let settled = false;
const tracked = api.run().then((result) => {
settled = true;
api.events.push('run:resolved');
return result;
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(settled, true, 'step 3 must report and return before deferred submit can navigate');
const result = await tracked;
assert.equal(result.deferredSubmit, true);
assert.equal(api.reports.length, 1);
assert.equal(api.scheduledCount(), 1);
assert.equal(api.events.includes('operation:submit-signup-password:start'), false);
assert.deepStrictEqual(api.events, [
'operation:signup-password:start',
'fill-password:Secret123!',
'operation:signup-password:end',
'delay:signup-password:2000',
'log:步骤 3:密码已填写',
'report:true',
'timer:120',
'run:resolved',
]);
let flushed = false;
const flush = api.flushDeferredSubmit().then(() => {
flushed = true;
api.events.push('flush:resolved');
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(api.events.includes('operation:submit-signup-password:start'), true);
assert.equal(api.events.includes('delay:submit-signup-password:pending'), true);
assert.equal(flushed, false, 'deferred submit callback must wait for submit delay before resolving');
api.releaseSubmitDelay();
await flush;
assert.deepStrictEqual(api.events, [
'operation:signup-password:start',
'fill-password:Secret123!',
'operation:signup-password:end',
'delay:signup-password:2000',
'log:步骤 3:密码已填写',
'report:true',
'timer:120',
'run:resolved',
'sleep:500',
'operation:submit-signup-password:start',
'click:Continue',
'operation:submit-signup-password:end',
'delay:submit-signup-password:pending',
'delay:submit-signup-password:2000',
'log:步骤 3:表单已提交',
'flush:resolved',
]);
});
+137
View File
@@ -227,3 +227,140 @@ return {
},
]);
});
test('step 5 routes fallback native consent checkbox click through operation delay', async () => {
const api = new Function(`
const operationEvents = [];
let activeOperationLabel = '';
const nameInput = { value: '', hidden: false };
const ageInput = { value: '', hidden: false };
const completeButton = {
tagName: 'BUTTON',
textContent: '\\u5b8c\\u6210\\u8d26\\u6237\\u521b\\u5efa',
hidden: false,
};
const allConsentLabel = {
hidden: false,
textContent: '\\u6211\\u540c\\u610f\\u4ee5\\u4e0b\\u6240\\u6709\\u5404\\u9879',
closest() {
return null;
},
};
const allConsentCheckbox = {
checked: false,
hidden: true,
name: 'allCheckboxes',
type: 'checkbox',
click() {
operationEvents.push(\`native-click:\${activeOperationLabel || 'outside'}\`);
this.checked = true;
},
getAttribute(name) {
if (name === 'name') return this.name;
if (name === 'type') return this.type;
return '';
},
closest(selector) {
if (selector === 'label') return allConsentLabel;
return null;
},
};
const window = {
CodexOperationDelay: {
async performOperationWithDelay(metadata, operation) {
operationEvents.push(\`operation:\${metadata.label}:start\`);
activeOperationLabel = metadata.label;
const result = await operation();
activeOperationLabel = '';
operationEvents.push(\`operation:\${metadata.label}:end\`);
operationEvents.push(\`delay:\${metadata.label}:2000\`);
return result;
},
},
};
const document = {
querySelector(selector) {
switch (selector) {
case '[role="spinbutton"][data-type="year"]':
case '[role="spinbutton"][data-type="month"]':
case '[role="spinbutton"][data-type="day"]':
case 'input[name="birthday"]':
return null;
case 'input[name="age"]':
return ageInput;
case 'button[type="submit"]':
return completeButton;
default:
return null;
}
},
querySelectorAll(selector) {
if (selector === 'input[name="allCheckboxes"][type="checkbox"]') {
return [allConsentCheckbox];
}
if (selector === 'input[type="checkbox"]') {
return [allConsentCheckbox];
}
return [];
},
execCommand() {},
};
const location = { href: 'https://auth.openai.com/u/signup/profile' };
function log() {}
async function waitForElement() { return nameInput; }
async function humanPause() {}
async function sleep() {}
function fillInput(input, value) { input.value = value; }
function findBirthdayReactAriaSelect() { return null; }
function isVisibleElement(el) { return Boolean(el) && !el.hidden; }
async function setReactAriaBirthdaySelect() { throw new Error('setReactAriaBirthdaySelect should not run in age-mode test'); }
async function waitForElementByText() { throw new Error('waitForElementByText should not run in this test'); }
function simulateClick(el) {
operationEvents.push(\`simulate-click:\${activeOperationLabel || 'outside'}:\${el.textContent || el.tagName || 'element'}\`);
}
function reportComplete() {}
function normalizeInlineText(text) { return String(text || '').replace(/\\s+/g, ' ').trim(); }
${getStep5Bundle()}
return {
async run(payload) {
return step5_fillNameBirthday(payload);
},
snapshot() {
return { operationEvents, consentChecked: allConsentCheckbox.checked };
},
};
`)();
await api.run({
firstName: 'Mia',
lastName: 'Harris',
age: 19,
});
const { operationEvents, consentChecked } = api.snapshot();
assert.equal(consentChecked, true);
assert.equal(operationEvents.includes('native-click:outside'), false);
assert.ok(
operationEvents.indexOf('delay:accept-profile-consent:2000')
< operationEvents.indexOf('operation:accept-profile-consent-fallback:start'),
'fallback click must be a separate delayed operation after the first consent attempt'
);
assert.deepStrictEqual(
operationEvents.slice(
operationEvents.indexOf('operation:accept-profile-consent-fallback:start'),
operationEvents.indexOf('delay:accept-profile-consent-fallback:2000') + 1
),
[
'operation:accept-profile-consent-fallback:start',
'native-click:accept-profile-consent-fallback',
'operation:accept-profile-consent-fallback:end',
'delay:accept-profile-consent-fallback:2000',
]
);
});
+389
View File
@@ -224,3 +224,392 @@ return {
'日志应明确说明 Step 5 已直接完成'
);
});
test('step 5 routes profile fill and submit operations through operation delay', async () => {
const api = new Function(`
const logs = [];
const completions = [];
const profileEvents = [];
const nameInput = { value: '', hidden: false };
const ageInput = { value: '', hidden: false };
const completeButton = {
tagName: 'BUTTON',
textContent: '完成帐户创建',
hidden: false,
};
const window = {
CodexOperationDelay: {
async performOperationWithDelay(metadata, operation) {
profileEvents.push(\`operation:\${metadata.label}:start\`);
const result = await operation();
profileEvents.push(\`operation:\${metadata.label}:end\`);
profileEvents.push(\`delay:\${metadata.label}:2000\`);
return result;
},
},
};
const document = {
querySelector(selector) {
switch (selector) {
case '[role="spinbutton"][data-type="year"]':
case '[role="spinbutton"][data-type="month"]':
case '[role="spinbutton"][data-type="day"]':
case 'input[name="birthday"]':
return null;
case 'input[name="age"]':
return ageInput;
case 'button[type="submit"]':
return completeButton;
default:
return null;
}
},
querySelectorAll(selector) {
if (selector === 'input[name="allCheckboxes"][type="checkbox"]') {
return [];
}
return [];
},
execCommand() {},
};
const location = {
href: 'https://auth.openai.com/u/signup/profile',
};
function log(message, level = 'info') {
logs.push({ message, level });
}
async function waitForElement() {
return nameInput;
}
async function humanPause() {}
async function sleep() {}
function fillInput(input, value) {
input.value = value;
profileEvents.push(input === nameInput ? 'fill:name' : 'fill:birthday');
}
function findBirthdayReactAriaSelect() {
return null;
}
function isVisibleElement(el) {
return Boolean(el) && !el.hidden;
}
async function setReactAriaBirthdaySelect() {}
async function waitForElementByText() {
throw new Error('waitForElementByText should not run in this test');
}
function simulateClick() {
profileEvents.push('click:complete');
}
function reportComplete(step, payload) {
completions.push({ step, payload });
}
function normalizeInlineText(text) {
return String(text || '').replace(/\s+/g, ' ').trim();
}
${getStep5Bundle()}
return {
async run(payload) {
return step5_fillNameBirthday(payload);
},
snapshot() {
return {
completions,
profileEvents,
nameValue: nameInput.value,
ageValue: ageInput.value,
};
},
};
`)();
await api.run({
firstName: 'Test',
lastName: 'User',
age: 23,
});
const snapshot = api.snapshot();
assert.deepStrictEqual(snapshot.profileEvents, [
'operation:fill-name:start',
'fill:name',
'operation:fill-name:end',
'delay:fill-name:2000',
'operation:fill-birthday:start',
'fill:birthday',
'operation:fill-birthday:end',
'delay:fill-birthday:2000',
'operation:submit-profile:start',
'click:complete',
'operation:submit-profile:end',
'delay:submit-profile:2000',
]);
assert.ok(snapshot.profileEvents.indexOf('operation:fill-name:start') < snapshot.profileEvents.indexOf('delay:fill-name:2000'));
assert.equal(snapshot.nameValue, 'Test User');
assert.equal(snapshot.ageValue, '23');
});
test('step 5 routes React Aria birthday dropdown selections through per-field operation delays', async () => {
const api = new Function(`
const operationEvents = [];
const selectedBirthday = {};
let activeOperationLabel = '';
const nameInput = { value: '', hidden: false };
const hiddenBirthday = { value: '', hidden: false, dispatchEvent() {} };
const birthdaySelects = {
'\\u5e74': { field: 'year', label: '\\u5e74', button: { hidden: false }, nativeSelect: {} },
'\\u6708': { field: 'month', label: '\\u6708', button: { hidden: false }, nativeSelect: {} },
'\\u5929': { field: 'day', label: '\\u5929', button: { hidden: false }, nativeSelect: {} },
};
const window = {
CodexOperationDelay: {
async performOperationWithDelay(metadata, operation) {
operationEvents.push(\`operation:\${metadata.label}:start\`);
activeOperationLabel = metadata.label;
const result = await operation();
activeOperationLabel = '';
operationEvents.push(\`operation:\${metadata.label}:end\`);
operationEvents.push(\`delay:\${metadata.label}:2000\`);
return result;
},
},
};
const document = {
querySelector(selector) {
switch (selector) {
case '[role="spinbutton"][data-type="year"]':
case '[role="spinbutton"][data-type="month"]':
case '[role="spinbutton"][data-type="day"]':
case 'input[name="age"]':
case 'button[type="submit"]':
return null;
case 'input[name="birthday"]':
return hiddenBirthday;
default:
return null;
}
},
querySelectorAll() { return []; },
execCommand() {},
};
const location = { href: 'https://auth.openai.com/u/signup/profile' };
function Event(type, init = {}) { this.type = type; this.bubbles = Boolean(init.bubbles); }
function log() {}
async function waitForElement() { return nameInput; }
async function humanPause() {}
async function sleep() {}
function fillInput(input, value) { input.value = value; }
function findBirthdayReactAriaSelect(label) { return birthdaySelects[label] || null; }
function isVisibleElement(el) { return Boolean(el) && !el.hidden; }
async function setReactAriaBirthdaySelect(select, value) {
operationEvents.push(\`select:\${select.field}:\${activeOperationLabel || 'outside'}\`);
selectedBirthday[select.field] = String(value).padStart(select.field === 'year' ? 4 : 2, '0');
if (selectedBirthday.year && selectedBirthday.month && selectedBirthday.day) {
hiddenBirthday.value = \`\${selectedBirthday.year}-\${selectedBirthday.month}-\${selectedBirthday.day}\`;
}
}
async function waitForElementByText() { throw new Error('waitForElementByText should not run in prefill test'); }
function simulateClick() { throw new Error('simulateClick should not run in prefill test'); }
function reportComplete() {}
function normalizeInlineText(text) { return String(text || '').replace(/\\s+/g, ' ').trim(); }
${getStep5Bundle()}
return {
async run(payload) { return step5_fillNameBirthday(payload); },
snapshot() { return { operationEvents, birthdayValue: hiddenBirthday.value }; },
};
`)();
const result = await api.run({
firstName: 'Test',
lastName: 'User',
year: 2003,
month: 6,
day: 19,
prefillOnly: true,
});
const snapshot = api.snapshot();
assert.deepStrictEqual(result, { prefilled: true });
assert.equal(snapshot.birthdayValue, '2003-06-19');
assert.deepStrictEqual(snapshot.operationEvents, [
'operation:fill-name:start',
'operation:fill-name:end',
'delay:fill-name:2000',
'operation:select-birthday-year:start',
'select:year:select-birthday-year',
'operation:select-birthday-year:end',
'delay:select-birthday-year:2000',
'operation:select-birthday-month:start',
'select:month:select-birthday-month',
'operation:select-birthday-month:end',
'delay:select-birthday-month:2000',
'operation:select-birthday-day:start',
'select:day:select-birthday-day',
'operation:select-birthday-day:end',
'delay:select-birthday-day:2000',
'operation:profile-dom-sync:start',
'operation:profile-dom-sync:end',
'delay:profile-dom-sync:2000',
]);
});
test('step 5 routes birthday spinbutton fields through per-field operation delays', async () => {
const api = new Function(`
const operationEvents = [];
let activeOperationLabel = '';
function createSpinner(name) {
return {
name,
hidden: false,
value: '',
focus() {},
blur() {},
dispatchEvent(event) {
if (event?.type === 'input' && event.data) {
operationEvents.push(\`spin:\${name}:\${event.data}:\${activeOperationLabel || 'outside'}\`);
this.value += event.data;
}
},
};
}
const nameInput = { value: '', hidden: false };
const yearSpinner = createSpinner('year');
const monthSpinner = createSpinner('month');
const daySpinner = createSpinner('day');
const window = {
CodexOperationDelay: {
async performOperationWithDelay(metadata, operation) {
operationEvents.push(\`operation:\${metadata.label}:start\`);
activeOperationLabel = metadata.label;
const result = await operation();
activeOperationLabel = '';
operationEvents.push(\`operation:\${metadata.label}:end\`);
operationEvents.push(\`delay:\${metadata.label}:2000\`);
return result;
},
},
};
const document = {
querySelector(selector) {
switch (selector) {
case '[role="spinbutton"][data-type="year"]':
return yearSpinner;
case '[role="spinbutton"][data-type="month"]':
return monthSpinner;
case '[role="spinbutton"][data-type="day"]':
return daySpinner;
case 'input[name="birthday"]':
case 'input[name="age"]':
case 'button[type="submit"]':
return null;
default:
return null;
}
},
querySelectorAll() { return []; },
execCommand(command) {
if (command === 'selectAll' && activeOperationLabel) {
const target = {
'fill-birthday-year': yearSpinner,
'fill-birthday-month': monthSpinner,
'fill-birthday-day': daySpinner,
}[activeOperationLabel];
if (target) target.value = '';
}
},
};
const location = { href: 'https://auth.openai.com/u/signup/profile' };
function KeyboardEvent(type, init = {}) { this.type = type; Object.assign(this, init); }
function InputEvent(type, init = {}) { this.type = type; Object.assign(this, init); }
function log() {}
async function waitForElement() { return nameInput; }
async function humanPause() {}
async function sleep() {}
function fillInput(input, value) { input.value = value; }
function findBirthdayReactAriaSelect() { return null; }
function isVisibleElement(el) { return Boolean(el) && !el.hidden; }
async function setReactAriaBirthdaySelect() { throw new Error('setReactAriaBirthdaySelect should not run in spinbutton test'); }
async function waitForElementByText() { throw new Error('waitForElementByText should not run in prefill test'); }
function simulateClick() { throw new Error('simulateClick should not run in prefill test'); }
function reportComplete() {}
function normalizeInlineText(text) { return String(text || '').replace(/\\s+/g, ' ').trim(); }
${getStep5Bundle()}
return {
async run(payload) { return step5_fillNameBirthday(payload); },
snapshot() {
return {
operationEvents,
yearValue: yearSpinner.value,
monthValue: monthSpinner.value,
dayValue: daySpinner.value,
};
},
};
`)();
const result = await api.run({
firstName: 'Test',
lastName: 'User',
year: 2003,
month: 6,
day: 19,
prefillOnly: true,
});
const snapshot = api.snapshot();
assert.deepStrictEqual(result, { prefilled: true });
assert.equal(snapshot.yearValue, '2003');
assert.equal(snapshot.monthValue, '06');
assert.equal(snapshot.dayValue, '19');
assert.deepStrictEqual(snapshot.operationEvents, [
'operation:fill-name:start',
'operation:fill-name:end',
'delay:fill-name:2000',
'operation:fill-birthday-year:start',
'spin:year:2:fill-birthday-year',
'spin:year:0:fill-birthday-year',
'spin:year:0:fill-birthday-year',
'spin:year:3:fill-birthday-year',
'operation:fill-birthday-year:end',
'delay:fill-birthday-year:2000',
'operation:fill-birthday-month:start',
'spin:month:0:fill-birthday-month',
'spin:month:6:fill-birthday-month',
'operation:fill-birthday-month:end',
'delay:fill-birthday-month:2000',
'operation:fill-birthday-day:start',
'spin:day:1:fill-birthday-day',
'spin:day:9:fill-birthday-day',
'operation:fill-birthday-day:end',
'delay:fill-birthday-day:2000',
]);
});
+169
View File
@@ -3,6 +3,7 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('content/signup-page.js', 'utf8');
const phoneAuthSource = fs.readFileSync('content/phone-auth.js', 'utf8');
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
@@ -694,3 +695,171 @@ test('step 7 stops before submit when phone fill never includes the local number
assert.equal(api.getValue(), '+44');
assert.deepEqual(api.getFills(), ['+447780579093', '7780579093', '+447780579093', '7780579093']);
});
function createPhoneAuthSubmitHarness(runModeEvents, runMode) {
const selectedOption = { value: 'GB', textContent: 'United Kingdom (+44)' };
const select = {
value: 'GB',
selectedIndex: 0,
options: [selectedOption],
dispatchEvent() {},
};
const phoneInput = {
value: '',
closest() {
return addPhoneForm;
},
};
const hiddenPhoneNumberInput = {
value: '',
events: [],
dispatchEvent(event) {
this.events.push(event.type);
},
};
const submitButton = {
disabled: false,
textContent: 'Continue',
getAttribute(name) {
if (name === 'aria-disabled') return 'false';
return '';
},
};
const dialCodeSpan = { textContent: '44' };
let phoneVerificationReady = false;
const addPhoneForm = {
querySelector(selector) {
if (selector === 'input[type="tel"], input[name="__reservedForPhoneNumberInput_tel"], input[autocomplete="tel"]') {
return phoneInput;
}
if (selector === 'input[name="phoneNumber"]') {
return hiddenPhoneNumberInput;
}
if (selector === 'select') {
return select;
}
return null;
},
querySelectorAll(selector) {
if (selector === 'button[type="submit"], input[type="submit"]') {
return [submitButton];
}
if (selector === 'span') {
return [dialCodeSpan];
}
return [];
},
};
const phoneVerificationForm = {
querySelector() {
return null;
},
querySelectorAll() {
return [];
},
};
const root = {
document: {
querySelector(selector) {
if (selector === 'form[action*="/add-phone" i]') {
return addPhoneForm;
}
if (selector === 'form[action*="/phone-verification" i]') {
return phoneVerificationReady ? phoneVerificationForm : null;
}
return null;
},
querySelectorAll() {
return [];
},
title: '',
},
location: {
href: 'https://auth.openai.com/add-phone',
pathname: '/add-phone',
},
Event: function Event(type, init = {}) {
this.type = type;
this.bubbles = Boolean(init.bubbles);
},
};
const performOperationWithDelay = async (metadata, operation) => {
const result = await operation();
runModeEvents[runMode].push({
delayMs: 2000,
kind: metadata.kind,
label: metadata.label,
});
return result;
};
root.CodexOperationDelay = { performOperationWithDelay };
const phoneAuthModule = new Function('self', 'globalThis', `
const document = self.document;
const location = self.location;
const Event = self.Event;
${phoneAuthSource}
return self.MultiPagePhoneAuth;
`)(root, root);
const helpers = phoneAuthModule.createPhoneAuthHelpers({
fillInput(input, value) {
input.value = value;
},
getActionText(element) {
return element?.textContent || '';
},
getPageTextSnapshot() {
return '';
},
getVerificationErrorText() {
return '';
},
humanPause: async () => {},
isActionEnabled(element) {
return Boolean(element) && !element.disabled && element.getAttribute?.('aria-disabled') !== 'true';
},
isAddPhonePageReady() {
return true;
},
isConsentReady() {
return false;
},
isPhoneVerificationPageReady() {
return phoneVerificationReady;
},
isVisibleElement(element) {
return Boolean(element);
},
performOperationWithDelay,
simulateClick(element) {
if (element === submitButton) {
phoneVerificationReady = true;
}
},
sleep: async () => {},
throwIfStopped() {},
waitForElement: async () => phoneInput,
});
return { helpers };
}
test('phone auth operation delay metadata is identical for auto and manual submit runs', async () => {
const runModeEvents = { auto: [], manual: [] };
for (const runMode of ['auto', 'manual']) {
const { helpers } = createPhoneAuthSubmitHarness(runModeEvents, runMode);
const result = await helpers.submitPhoneNumber({
countryLabel: 'United Kingdom',
phoneNumber: '447780579093',
runMode,
});
assert.equal(result.phoneVerificationPage, true);
}
assert.ok(runModeEvents.auto.length > 0);
assert.deepStrictEqual(runModeEvents.auto.map((event) => event.delayMs), runModeEvents.manual.map((event) => event.delayMs));
assert.deepStrictEqual(runModeEvents.auto.map((event) => event.kind), runModeEvents.manual.map((event) => event.kind));
});