51 KiB
Operation Delay Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a default-enabled, sidepanel-controlled 2 second delay after covered page automation operations, without slowing polling, background work, or excluded OAuth/platform verification flows.
Architecture: Keep the product contract in one persisted boolean setting and enforce timing through one shared content-side gate loaded after content/utils.js. Background owns setting defaults and broadcast, the sidepanel owns all user-facing switch feedback, and covered content scripts wrap page DOM operations through the shared gate instead of adding scattered sleeps.
Tech Stack: Chrome extension MV3, plain JavaScript, node:test, chrome.storage.local, chrome.runtime messaging, existing content-script sleep() stop behavior.
File Structure
background.js: addoperationDelayEnabledtoPERSISTED_SETTING_DEFAULTS, strict boolean normalization, settings payload defaults,SIGNUP_PAGE_INJECT_FILESloading, and theprovider === '2925'dynamic provider injection list for the new content module.background/message-router.js: keepSAVE_SETTINGpersistence andDATA_UPDATEDbroadcast foroperationDelayEnabled; do not write operation-delay success logs from the background.sidepanel/sidepanel.html: add one操作间延迟switch near the existing delay settings.sidepanel/sidepanel.js: bind the switch, restore invalid or missing values as enabled, persist throughSAVE_SETTING, roll back on save failure, and write exactly one bottomlog-areaentry for each user-visible toggle result.content/operation-delay.js: new shared content module that caches the persisted setting, defaults unresolved values to enabled, skips excluded step keys, and waits 2000 ms after a covered operation by using the existing stop-awaresleep()fromcontent/utils.js.manifest.json: loadcontent/operation-delay.jsimmediately aftercontent/utils.jsonly in manifest content-script bundles that perform covered page operations.background/steps/create-plus-checkout.js,background/steps/fill-plus-checkout.js,background/steps/paypal-approve.js,background/steps/gopay-approve.js,background/mail-2925-session.js: addcontent/operation-delay.jsimmediately aftercontent/utils.jsin dynamic injection arrays for covered checkout, payment, and 2925 session pages.content/auth-page-recovery.js,content/signup-page.js,content/phone-auth.js: wrap OpenAI/auth page retry clicks, inputs, hidden DOM synchronizations, clicks, submits, continue actions, and grouped split-code entry.content/plus-checkout.js,content/paypal-flow.js,content/gopay-flow.js: wrap checkout, payment, and authorization page DOM writes and clicks.content/duck-mail.js,content/mail-2925.js: wrap only non-polling provider UI operations such as Duck address generation and 2925 login/session preparation.content/mail-163.js,content/qq-mail.js,content/icloud-mail.js,content/gmail-mail.js,content/inbucket-mail.js: leavePOLL_EMAILhandlers and their refresh/navigation/open/delete polling loops unwrapped so mailbox cadence is unchanged.content/mail-2925.js: leavePOLL_EMAILandDELETE_ALL_EMAILShandlers unwrapped because they are mailbox polling/cleanup support paths.content/whatsapp-flow.js: leave WhatsApp code-reading wait loops unwrapped because they are SMS/OTP polling, not covered page operations.background/steps/confirm-oauth.js,background/steps/platform-verify.js,background/panel-bridge.js,content/sub2api-panel.js: keep these excluded paths free of operation-delay injection and gate calls.README.md,docs/使用教程/使用教程.md: document the new switch, default-enabled behavior, 2 second fixed delay, grouped OTP behavior, and excluded work.tests/*.test.js: add focused settings, sidepanel, gate, injection, call-site timing, polling-exclusion, docs, and regression tests.
Implementation Rules
operationDelayEnablednormalization is strict: only booleanfalsedisables the feature, booleantrueenables it, and absent or invalid values such asundefined,null,'',0, and'false'resolve totrue.- Sidepanel is the only success-log owner for user toggles. Background broadcasts state but does not write a second success entry.
- The delay gate always runs the page operation first and then waits 2000 ms when active. The first covered operation has no pre-wait.
- The 2000 ms wait must call the existing content-script
sleep()fromcontent/utils.js, or a test-injected equivalent, so Stop interrupts the delay with the same stop error path. - Split verification-code fields are one grouped operation: fill all fields, delay once, then treat any submit/continue click as a separate covered operation with its own post-action delay.
confirm-oauthandplatform-verifyremain excluded by step key and by keeping their dedicated injection paths free ofcontent/operation-delay.js.POLL_EMAIL, SMS/OTP polling, background retries, timers, storage writes, and network-only API calls remain unwrapped.
TASK_GROUP 1: Persisted setting and sidepanel control
Task 1: Add the new persisted setting and broadcast contract
Files:
-
Modify:
background.js -
Modify:
background/message-router.js -
Test:
tests/background-operation-delay-settings.test.js -
Test:
tests/background-message-router-module.test.js -
Step 1: Write the failing tests
Add tests/background-operation-delay-settings.test.js with assertions for the strict persisted setting contract:
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}`);
const bodyStart = source.indexOf('{', start);
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);
});
Extend tests/background-message-router-module.test.js with a broadcast-only router assertion:
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);
});
- Step 2: Run the targeted tests and verify they fail
Run: node --test tests/background-operation-delay-settings.test.js tests/background-message-router-module.test.js
Expected: FAIL with assertions showing operationDelayEnabled is missing from defaults/normalization and no broadcast-only contract exists yet.
- Step 3: Write the minimal implementation
Patch background.js so the new setting is in the persisted defaults and uses strict boolean normalization:
const PERSISTED_SETTING_DEFAULTS = {
// existing keys stay in place
operationDelayEnabled: true,
};
function normalizePersistentSettingValue(key, value) {
switch (key) {
case 'operationDelayEnabled':
return typeof value === 'boolean' ? value : true;
// existing cases stay unchanged
}
}
Keep background/message-router.js on the existing SAVE_SETTING path. The required behavior is that buildPersistentSettingsPayload(message.payload) includes operationDelayEnabled when present, setState(stateUpdates) persists it into runtime state, and broadcastDataUpdate(stateUpdates) emits it once. Do not add an addLog() branch for operation-delay success feedback in the router.
- Step 4: Run the targeted tests and verify they pass
Run: node --test tests/background-operation-delay-settings.test.js tests/background-message-router-module.test.js
Expected: PASS for all tests in both files.
- Step 5: Commit
git add background.js background/message-router.js tests/background-operation-delay-settings.test.js tests/background-message-router-module.test.js
git commit -m "feat: persist operation delay setting"
Task 2: Add the sidepanel switch and single-owner tips/log feedback
Files:
-
Modify:
sidepanel/sidepanel.html -
Modify:
sidepanel/sidepanel.js -
Test:
tests/sidepanel-operation-delay.test.js -
Step 1: Write the failing test
Add tests/sidepanel-operation-delay.test.js with structural and behavior-level assertions:
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');
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*秒/);
});
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/);
});
Add a small executable harness in the same file for the new helper behavior. The helper names can be extracted from sidepanel.js, but the assertions must prove these contracts:
function loadOperationDelaySidepanelHarness() {
const source = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
const runtimeMessages = [];
const logs = [];
const inputOperationDelayEnabled = { checked: true, disabled: false };
function extractFunction(name) {
const 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}`);
}
const chrome = {
runtime: {
sendMessage: async (message) => {
runtimeMessages.push(message);
return { ok: true, state: { operationDelayEnabled: message.payload.operationDelayEnabled } };
},
},
};
const harness = new Function('chrome', 'appendLog', 'inputOperationDelayEnabled', 'latestState', `
let lastConfirmedOperationDelayEnabled = true;
function syncLatestState(nextState) {
latestState = { ...(latestState || {}), ...(nextState || {}) };
}
${extractFunction('normalizeOperationDelayEnabled')}
${extractFunction('appendOperationDelayLog')}
${extractFunction('applyOperationDelayState')}
${extractFunction('persistOperationDelayToggle')}
return {
applyOperationDelayState,
persistOperationDelayToggle,
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: undefined }, { restoreFailed: true });
assert.equal(input.checked, 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);
});
- Step 2: Run the targeted test and verify it fails
Run: node --test tests/sidepanel-operation-delay.test.js
Expected: FAIL with missing switch/helper/log assertions.
- Step 3: Write the minimal implementation
Add this row near row-auto-delay-settings in sidepanel/sidepanel.html:
<div class="data-row" id="row-operation-delay-settings">
<span class="data-label">操作间延迟</span>
<div class="data-inline setting-pair operation-delay-setting-pair">
<div class="setting-group setting-group-primary operation-delay-setting">
<label class="toggle-switch" for="input-operation-delay-enabled" title="开启后,每次页面操作完成后等待 2 秒">
<input type="checkbox" id="input-operation-delay-enabled" checked />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
<span>启用</span>
</label>
<span class="setting-caption">页面输入/点击/提交后等待 2 秒</span>
</div>
</div>
</div>
Patch sidepanel/sidepanel.js with a narrow sidepanel-owned setting handler:
const inputOperationDelayEnabled = document.getElementById('input-operation-delay-enabled');
let lastConfirmedOperationDelayEnabled = true;
function normalizeOperationDelayEnabled(value) {
return typeof value === 'boolean' ? value : true;
}
function appendOperationDelayLog(enabled, level = 'info', message = '') {
appendLog({
timestamp: Date.now(),
level,
message: message || (enabled
? '操作间延迟已开启:页面操作完成后等待 2 秒。'
: '操作间延迟已关闭:页面操作将连续执行。'),
});
}
function applyOperationDelayState(state = latestState, options = {}) {
const enabled = normalizeOperationDelayEnabled(state?.operationDelayEnabled);
lastConfirmedOperationDelayEnabled = enabled;
if (inputOperationDelayEnabled) inputOperationDelayEnabled.checked = enabled;
if (options.restoreFailed) {
appendOperationDelayLog(true, 'warn', '操作间延迟设置读取失败,已回退为默认开启。');
}
}
async function persistOperationDelayToggle() {
const nextEnabled = Boolean(inputOperationDelayEnabled?.checked);
const previousEnabled = lastConfirmedOperationDelayEnabled;
try {
const response = await chrome.runtime.sendMessage({
type: 'SAVE_SETTING',
source: 'sidepanel',
payload: { operationDelayEnabled: nextEnabled },
});
if (response?.error) throw new Error(response.error);
const confirmed = normalizeOperationDelayEnabled(response?.state?.operationDelayEnabled ?? nextEnabled);
lastConfirmedOperationDelayEnabled = confirmed;
if (inputOperationDelayEnabled) inputOperationDelayEnabled.checked = confirmed;
syncLatestState({ operationDelayEnabled: confirmed });
appendOperationDelayLog(confirmed);
} catch (error) {
if (inputOperationDelayEnabled) inputOperationDelayEnabled.checked = previousEnabled;
appendOperationDelayLog(previousEnabled, 'error', `操作间延迟设置保存失败,已恢复为上一次确认的状态:${error.message}`);
throw error;
}
}
Call applyOperationDelayState(state) from applySettingsState(state) and from the DATA_UPDATED branch when message.payload.operationDelayEnabled !== undefined. The DATA_UPDATED sync path must update the switch but must not call appendOperationDelayLog, because the user toggle handler is the single success-log owner. Add inputOperationDelayEnabled.addEventListener('change', () => { persistOperationDelayToggle().catch(() => {}); }); with the existing event listener block.
- Step 4: Run the targeted test and verify it passes
Run: node --test tests/sidepanel-operation-delay.test.js
Expected: PASS.
- Step 5: Commit
git add sidepanel/sidepanel.html sidepanel/sidepanel.js tests/sidepanel-operation-delay.test.js
git commit -m "feat: add operation delay sidepanel toggle"
TASK_GROUP 2: Shared content-side operation gate and loading
Task 3: Add the shared delay gate and load it on covered pages
Files:
-
Create:
content/operation-delay.js -
Modify:
manifest.json -
Modify:
background.js -
Modify:
background/steps/create-plus-checkout.js -
Modify:
background/steps/fill-plus-checkout.js -
Modify:
background/steps/paypal-approve.js -
Modify:
background/steps/gopay-approve.js -
Modify:
background/mail-2925-session.js -
Test:
tests/content-operation-delay.test.js -
Test:
tests/operation-delay-injection.test.js -
Step 1: Write the failing tests
Add tests/content-operation-delay.test.js for the gate contract:
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;
}
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('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('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']);
});
Add tests/operation-delay-injection.test.js to enforce load order and excluded paths:
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/);
}
});
- Step 2: Run the targeted tests and verify they fail
Run: node --test tests/content-operation-delay.test.js tests/operation-delay-injection.test.js
Expected: FAIL because content/operation-delay.js does not exist and the injection arrays do not include it.
- Step 3: Write the minimal implementation
Create content/operation-delay.js with this public content-side API shape:
(function attachOperationDelay(root) {
const OPERATION_DELAY_MS = 2000;
const EXCLUDED_STEP_KEYS = new Set(['confirm-oauth', 'platform-verify']);
let operationDelayEnabled = true;
function normalizeOperationDelayEnabled(value) {
return typeof value === 'boolean' ? value : true;
}
function getOperationDelayEnabled() {
return operationDelayEnabled;
}
async function refreshOperationDelaySetting() {
try {
const data = await root.chrome?.storage?.local?.get?.(['operationDelayEnabled']);
operationDelayEnabled = normalizeOperationDelayEnabled(data?.operationDelayEnabled);
} catch {
operationDelayEnabled = true;
}
return operationDelayEnabled;
}
function shouldDelayOperation(metadata = {}) {
if (metadata.skipOperationDelay === true) return false;
if (metadata.enabled === false) return false;
if (EXCLUDED_STEP_KEYS.has(String(metadata.stepKey || '').trim())) return false;
return true;
}
async function performOperationWithDelay(metadata = {}, operation, options = {}) {
const result = await operation();
const enabled = typeof options.getEnabled === 'function'
? normalizeOperationDelayEnabled(options.getEnabled())
: getOperationDelayEnabled();
if (shouldDelayOperation({ ...metadata, enabled })) {
const wait = options.sleep || root.sleep;
await wait(OPERATION_DELAY_MS);
}
return result;
}
root.chrome?.storage?.onChanged?.addListener?.((changes, areaName) => {
if (areaName === 'local' && Object.prototype.hasOwnProperty.call(changes, 'operationDelayEnabled')) {
operationDelayEnabled = normalizeOperationDelayEnabled(changes.operationDelayEnabled?.newValue);
}
});
refreshOperationDelaySetting().catch(() => { operationDelayEnabled = true; });
root.CodexOperationDelay = { OPERATION_DELAY_MS, normalizeOperationDelayEnabled, refreshOperationDelaySetting, getOperationDelayEnabled, shouldDelayOperation, performOperationWithDelay };
})(typeof self !== 'undefined' ? self : globalThis);
Add content/operation-delay.js immediately after content/utils.js in these covered load paths:
['content/utils.js', 'content/operation-delay.js', 'content/auth-page-recovery.js', 'content/phone-country-utils.js', 'content/phone-auth.js', 'content/signup-page.js']
['content/utils.js', 'content/operation-delay.js', 'content/plus-checkout.js']
['content/utils.js', 'content/operation-delay.js', 'content/paypal-flow.js']
['content/utils.js', 'content/operation-delay.js', 'content/gopay-flow.js']
['content/utils.js', 'content/operation-delay.js', 'content/duck-mail.js']
['content/utils.js', 'content/operation-delay.js', 'content/mail-2925.js']
For 2925, update both background/mail-2925-session.js MAIL2925_INJECT and the provider === '2925' injection object in background.js so an already-ready tab from the provider polling path still has window.CodexOperationDelay available before ENSURE_MAIL2925_SESSION runs. Do not add content/operation-delay.js to the manifest mail polling bundles, background/steps/platform-verify.js, or background/panel-bridge.js.
- Step 4: Run the targeted tests and verify they pass
Run: node --test tests/content-operation-delay.test.js tests/operation-delay-injection.test.js
Expected: PASS.
- Step 5: Commit
git add content/operation-delay.js manifest.json background.js background/steps/create-plus-checkout.js background/steps/fill-plus-checkout.js background/steps/paypal-approve.js background/steps/gopay-approve.js background/mail-2925-session.js tests/content-operation-delay.test.js tests/operation-delay-injection.test.js
git commit -m "feat: add shared operation delay gate"
TASK_GROUP 3: Covered page-operation call-site migration
Task 4: Route OpenAI/auth page operations through the delay gate
Files:
-
Modify:
content/auth-page-recovery.js -
Modify:
content/signup-page.js -
Modify:
content/phone-auth.js -
Test:
tests/auth-page-recovery.test.js -
Test:
tests/step5-direct-complete.test.js -
Test:
tests/step4-split-code-submit.test.js -
Test:
tests/step7-phone-login-entry.test.js -
Step 1: Write the failing tests
Extend the representative flow harnesses so they inject a fake CodexOperationDelay.performOperationWithDelay that records operation metadata, delegates to the wrapped operation, and records a delay:2000 event after the operation. Required assertions:
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\(/);
assert.deepStrictEqual(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(profileEvents.indexOf('operation:fill-name:start') < profileEvents.indexOf('delay:fill-name:2000'));
assert.deepStrictEqual(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(splitCodeEvents.filter((event) => event.startsWith('delay:split-code')).length, 1);
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));
- Step 2: Run the targeted tests and verify they fail
Run: node --test tests/auth-page-recovery.test.js tests/step5-direct-complete.test.js tests/step4-split-code-submit.test.js tests/step7-phone-login-entry.test.js
Expected: FAIL because the OpenAI/auth content scripts still call page operations directly and do not emit delay-gate events.
- Step 3: Write the minimal implementation
In content/auth-page-recovery.js, wrap only the retry button click and keep waitForRetryPageRecoveryAfterClick polling sleeps unwrapped:
const rootScope = typeof self !== 'undefined' ? self : globalThis;
const performOperationWithDelay = deps.performOperationWithDelay
|| rootScope.CodexOperationDelay?.performOperationWithDelay;
await performOperationWithDelay({ stepKey: options.stepKey || '', kind: 'click', label: 'auth-retry-click' }, async () => {
simulateClick(retryState.retryButton);
});
In content/signup-page.js and content/phone-auth.js, add a local wrapper alias and route covered operations through it:
const performOperationWithDelay = (...args) => window.CodexOperationDelay.performOperationWithDelay(...args);
await performOperationWithDelay({ stepKey: 'fill-profile', kind: 'fill', label: 'fill-name' }, async () => {
fillInput(nameInput, fullName);
});
await performOperationWithDelay({ stepKey: 'fill-profile', kind: 'hidden-sync', label: 'profile-dom-sync' }, async () => {
hiddenInput.value = value;
hiddenInput.dispatchEvent(new Event('input', { bubbles: true }));
hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
});
await performOperationWithDelay({ stepKey: 'fetch-signup-code', kind: 'grouped-code', label: 'split-code' }, async () => {
splitInputs.forEach((input, index) => fillInput(input, code[index] || ''));
});
await performOperationWithDelay({ stepKey: 'fetch-signup-code', kind: 'submit', label: 'submit-code' }, async () => {
simulateClick(submitButton);
});
Apply the same wrapper to visible input fills, hidden DOM sync writes, dropdown selections, simulateClick/submit/continue actions, and phone-auth submit/resend/verification operations that mutate the page. Do not wrap wait-only helpers such as waitForElement, network retry waits, or polling sleeps.
- Step 4: Run the targeted tests and verify they pass
Run: node --test tests/auth-page-recovery.test.js tests/step5-direct-complete.test.js tests/step4-split-code-submit.test.js tests/step7-phone-login-entry.test.js
Expected: PASS.
- Step 5: Commit
git add content/auth-page-recovery.js content/signup-page.js content/phone-auth.js tests/auth-page-recovery.test.js tests/step5-direct-complete.test.js tests/step4-split-code-submit.test.js tests/step7-phone-login-entry.test.js
git commit -m "feat: delay OpenAI page operations"
Task 5: Route checkout and payment page operations through the delay gate
Files:
-
Modify:
content/plus-checkout.js -
Modify:
content/paypal-flow.js -
Modify:
content/gopay-flow.js -
Test:
tests/plus-checkout-create-wait.test.js -
Test:
tests/paypal-approve-detection.test.js -
Test:
tests/gopay-flow-content.test.js -
Test:
tests/plus-checkout-billing-tab-resolution.test.js -
Step 1: Write the failing tests
Extend the existing checkout/payment harnesses with gate event assertions instead of regex-only checks:
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]);
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',
]);
assert.deepStrictEqual(gopayEvents.filter((event) => event.type === 'delay').map((event) => event.label), [
'submit-phone',
'submit-otp',
'submit-pin',
'click-continue',
'click-pay-now',
]);
assert.equal(gopayEvents.some((event) => event.type === 'delay' && event.ms !== 2000), false);
- Step 2: Run the targeted tests and verify they fail
Run: node --test tests/plus-checkout-create-wait.test.js tests/paypal-approve-detection.test.js tests/gopay-flow-content.test.js tests/plus-checkout-billing-tab-resolution.test.js
Expected: FAIL because checkout and payment page operations still bypass the shared gate.
- Step 3: Write the minimal implementation
Route each covered DOM write or click in content/plus-checkout.js, content/paypal-flow.js, and content/gopay-flow.js through window.CodexOperationDelay.performOperationWithDelay. Required wrapped operation labels include these page actions when present:
['select-payment-method', 'fill-billing-address', 'fill-address-query', 'select-address-suggestion', 'click-subscribe']
['paypal-email', 'paypal-password', 'paypal-dismiss-prompt', 'paypal-approve']
['submit-phone', 'submit-otp', 'submit-pin', 'click-continue', 'click-pay-now']
Keep terminal subscribe, approve, continue, and pay-now actions inside the wrapper so each receives a post-operation 2000 ms delay after the click completes. Do not wrap background polling waits, page-load waits, or network-only GPC helper API calls.
- Step 4: Run the targeted tests and verify they pass
Run: node --test tests/plus-checkout-create-wait.test.js tests/paypal-approve-detection.test.js tests/gopay-flow-content.test.js tests/plus-checkout-billing-tab-resolution.test.js
Expected: PASS.
- Step 5: Commit
git add content/plus-checkout.js content/paypal-flow.js content/gopay-flow.js tests/plus-checkout-create-wait.test.js tests/paypal-approve-detection.test.js tests/gopay-flow-content.test.js tests/plus-checkout-billing-tab-resolution.test.js
git commit -m "feat: delay checkout and payment page operations"
Task 6: Wrap non-polling provider UI operations and protect polling cadence
Files:
-
Modify:
content/duck-mail.js -
Modify:
content/mail-2925.js -
Test:
tests/provider-operation-delay.test.js -
Test:
tests/mail-polling-operation-delay-exclusion.test.js -
Step 1: Write the failing tests
Add tests/provider-operation-delay.test.js for non-polling provider actions:
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\(/);
});
Add tests/mail-polling-operation-delay-exclusion.test.js to preserve polling cadence:
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\(/);
});
- Step 2: Run the targeted tests and verify they fail
Run: node --test tests/provider-operation-delay.test.js tests/mail-polling-operation-delay-exclusion.test.js
Expected: FAIL in tests/provider-operation-delay.test.js because non-polling provider UI operations are not routed through the gate yet. Polling-exclusion assertions must pass or fail only if a prior task accidentally added delay to polling paths.
- Step 3: Write the minimal implementation
In content/duck-mail.js, wrap only the generator button click as one covered operation:
await window.CodexOperationDelay.performOperationWithDelay({ stepKey: 'fetch-signup-code', kind: 'click', label: 'duck-generate-address' }, async () => {
if (typeof simulateClick === 'function') {
simulateClick(generatorButton);
} else {
generatorButton.click();
}
});
In content/mail-2925.js, wrap page DOM writes/clicks that run under ENSURE_MAIL2925_SESSION. Do not wrap DELETE_ALL_EMAILS, POLL_EMAIL, or shared helpers when the same helper is called from those handlers.
Do not modify POLL_EMAIL handlers, DELETE_ALL_EMAILS, or their refresh/navigation/open/delete polling and cleanup loops in content/mail-163.js, content/qq-mail.js, content/icloud-mail.js, content/mail-2925.js, content/gmail-mail.js, or content/inbucket-mail.js.
- Step 4: Run the targeted tests and verify they pass
Run: node --test tests/provider-operation-delay.test.js tests/mail-polling-operation-delay-exclusion.test.js
Expected: PASS.
- Step 5: Commit
git add content/duck-mail.js content/mail-2925.js tests/provider-operation-delay.test.js tests/mail-polling-operation-delay-exclusion.test.js
git commit -m "feat: delay provider UI operations without slowing polling"
TASK_GROUP 4: Exclusion checks, docs, and final regression
Task 7: Lock excluded flows, update docs, and run full verification
Files:
-
Modify:
README.md -
Modify:
docs/使用教程/使用教程.md -
Test:
tests/background-operation-delay-exclusions.test.js -
Test:
tests/operation-delay-docs.test.js -
Test: all existing
tests/*.test.js -
Step 1: Write the failing tests
Add tests/background-operation-delay-exclusions.test.js:
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/);
});
Add tests/operation-delay-docs.test.js:
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/);
});
}
- Step 2: Run the targeted tests and verify they fail
Run: node --test tests/background-operation-delay-exclusions.test.js tests/operation-delay-docs.test.js
Expected: FAIL because docs do not mention the feature yet and the exclusion test file does not exist before this task.
- Step 3: Write the minimal implementation
Keep excluded flow code delay-free. Add a concise docs section to both README.md and docs/使用教程/使用教程.md:
### 操作间延迟
`操作间延迟` 默认开启。开启后,自动流程和手动单步在每个页面输入、选择、点击、提交、继续或授权操作完成后固定等待 2 秒;第一项页面操作不会提前等待。分格 OTP/验证码会先整组填完,然后只等待一次。
该开关不同于步间间隔,不影响邮箱轮询、短信/WhatsApp 轮询、后台 API、网络重试、后台定时器或存储持久化,也不影响 `confirm-oauth` 和 `platform-verify` 的交互节奏。
- Step 4: Run targeted and full verification
Run: node --test tests/background-operation-delay-exclusions.test.js tests/operation-delay-docs.test.js
Expected: PASS.
Run: npm test
Expected: PASS for the full node --test tests/*.test.js suite.
- Step 5: Commit
git add README.md docs/使用教程/使用教程.md tests/background-operation-delay-exclusions.test.js tests/operation-delay-docs.test.js
git commit -m "docs: document operation delay behavior"