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
+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));
});