From 73f9deb53eac155b78732f76c44223873fed6799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A5=81=E8=BF=9E=E6=B5=B7?= <2825305047@qq.com> Date: Fri, 17 Apr 2026 23:29:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8D=E5=81=9C=E6=AD=A2?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 110 ++++++++++++++- background/auto-run-controller.js | 41 +++++- background/logging-status.js | 1 + background/tab-runtime.js | 113 +++++++++------ content/signup-page.js | 11 +- tests/auto-run-fresh-attempt-reset.test.js | 18 +++ tests/auto-run-timer-session-guard.test.js | 147 ++++++++++++++++++++ tests/background-tab-runtime-module.test.js | 41 ++++++ tests/step3-direct-complete.test.js | 4 + tests/step8-stop-cleanup.test.js | 5 + 项目完整链路说明.md | 21 +-- 项目文件结构说明.md | 11 +- 12 files changed, 460 insertions(+), 63 deletions(-) create mode 100644 tests/auto-run-timer-session-guard.test.js diff --git a/background.js b/background.js index cd53f5c..e52b7d4 100644 --- a/background.js +++ b/background.js @@ -308,6 +308,7 @@ const DEFAULT_STATE = { autoRunCurrentRun: 0, // 自动运行当前执行到第几轮。 autoRunTotalRuns: 1, // 自动运行计划总轮数。 autoRunAttemptRun: 0, // 当前轮次的重试序号。 + autoRunSessionId: 0, autoRunRoundSummaries: [], // 自动运行轮次摘要。 scheduledAutoRunAt: null, // 自动运行计划启动时间戳。 autoRunTimerPlan: null, // 自动运行可恢复计时计划快照。 @@ -426,6 +427,48 @@ function normalizeAutoRunTimerKind(value = '') { return ''; } +function normalizeAutoRunSessionId(value) { + const numeric = Math.floor(Number(value) || 0); + return numeric > 0 ? numeric : 0; +} + +function createAutoRunSessionId() { + autoRunSessionSeed = Math.max(autoRunSessionSeed + 1, Date.now()); + autoRunSessionId = autoRunSessionSeed; + return autoRunSessionId; +} + +function setCurrentAutoRunSessionId(value) { + autoRunSessionId = normalizeAutoRunSessionId(value); + return autoRunSessionId; +} + +function clearCurrentAutoRunSessionId(expectedSessionId = null) { + if (expectedSessionId === null) { + autoRunSessionId = 0; + return autoRunSessionId; + } + + const normalizedExpected = normalizeAutoRunSessionId(expectedSessionId); + if (!normalizedExpected || normalizedExpected === autoRunSessionId) { + autoRunSessionId = 0; + } + return autoRunSessionId; +} + +function isCurrentAutoRunSessionId(value) { + const normalized = normalizeAutoRunSessionId(value); + return normalized > 0 && normalized === autoRunSessionId; +} + +function throwIfAutoRunSessionStopped(sessionId) { + const normalizedSessionId = normalizeAutoRunSessionId(sessionId); + if (normalizedSessionId && !isCurrentAutoRunSessionId(normalizedSessionId)) { + throw new Error(STOP_ERROR_MESSAGE); + } + throwIfStopped(); +} + function normalizeAutoRunTimerPlan(plan) { if (!plan || typeof plan !== 'object' || Array.isArray(plan)) { return null; @@ -449,6 +492,7 @@ function normalizeAutoRunTimerPlan(plan) { 0, Math.min(AUTO_RUN_MAX_RETRIES_PER_ROUND + 1, Math.floor(Number(plan.attemptRun) || 0)) ); + const autoRunSessionId = normalizeAutoRunSessionId(plan.autoRunSessionId ?? plan.sessionId); const roundSummaries = serializeAutoRunRoundSummaries(totalRuns, plan.roundSummaries); const countdownTitle = String(plan.countdownTitle || '').trim(); const countdownNote = String(plan.countdownNote || '').trim(); @@ -462,6 +506,7 @@ function normalizeAutoRunTimerPlan(plan) { mode, currentRun: 0, attemptRun: 0, + autoRunSessionId, roundSummaries: [], countdownTitle: countdownTitle || '已计划自动运行', countdownNote: countdownNote || `计划于 ${formatAutoRunScheduleTime(fireAt)} 开始`, @@ -479,6 +524,7 @@ function normalizeAutoRunTimerPlan(plan) { mode: 'restart', currentRun: normalizedCurrentRun, attemptRun: normalizedAttemptRun, + autoRunSessionId, roundSummaries, countdownTitle: countdownTitle || '线程间隔中', countdownNote: countdownNote || `第 ${Math.min(normalizedCurrentRun + 1, totalRuns)}/${totalRuns} 轮即将开始`, @@ -495,6 +541,7 @@ function normalizeAutoRunTimerPlan(plan) { mode: 'restart', currentRun: normalizedCurrentRun, attemptRun: normalizedAttemptRun, + autoRunSessionId, roundSummaries, countdownTitle: countdownTitle || '线程间隔中', countdownNote: countdownNote || `第 ${normalizedCurrentRun}/${totalRuns} 轮第 ${normalizedAttemptRun} 次尝试即将开始`, @@ -521,6 +568,7 @@ function normalizeAutoRunTimerPlanFromState(state = {}) { fireAt: legacyScheduledAt, totalRuns: state.scheduledAutoRunPlan?.totalRuns ?? state.autoRunTotalRuns, autoRunSkipFailures: state.scheduledAutoRunPlan?.autoRunSkipFailures ?? state.autoRunSkipFailures, + autoRunSessionId: state.autoRunSessionId, mode: state.scheduledAutoRunPlan?.mode, }); } @@ -541,6 +589,7 @@ function getAutoRunTimerStatusPayload(plan) { currentRun: normalizedPlan.currentRun, totalRuns: normalizedPlan.totalRuns, attemptRun: normalizedPlan.attemptRun, + sessionId: normalizedPlan.autoRunSessionId, scheduledAt: phase === 'scheduled' ? normalizedPlan.fireAt : null, countdownAt: normalizedPlan.fireAt, countdownTitle: normalizedPlan.countdownTitle, @@ -3794,6 +3843,7 @@ const tabRuntime = self.MultiPageBackgroundTabRuntime?.createTabRuntime({ LOG_PREFIX, matchesSourceUrlFamily, setState, + sleepWithStop, STOP_ERROR_MESSAGE, throwIfStopped, }); @@ -3978,6 +4028,7 @@ function getAutoRunStatusPayload(phase, payload = {}) { currentRun: payload.currentRun ?? autoRunCurrentRun, totalRuns: payload.totalRuns ?? autoRunTotalRuns, attemptRun: payload.attemptRun ?? autoRunAttemptRun, + sessionId: payload.sessionId ?? payload.autoRunSessionId ?? autoRunSessionId, }; if (typeof loggingStatus !== 'undefined' && loggingStatus?.getAutoRunStatusPayload) { return loggingStatus.getAutoRunStatusPayload(phase, normalizedPayload); @@ -3993,6 +4044,7 @@ function getAutoRunStatusPayload(phase, payload = {}) { autoRunCurrentRun: normalizedPayload.currentRun ?? 0, autoRunTotalRuns: normalizedPayload.totalRuns ?? 1, autoRunAttemptRun: normalizedPayload.attemptRun ?? 0, + autoRunSessionId: normalizeAutoRunSessionId(normalizedPayload.sessionId), scheduledAutoRunAt: Number.isFinite(Number(normalizedPayload.scheduledAt)) ? Number(normalizedPayload.scheduledAt) : null, autoRunCountdownAt: Number.isFinite(Number(normalizedPayload.countdownAt)) ? Number(normalizedPayload.countdownAt) : null, autoRunCountdownTitle: normalizedPayload.countdownTitle === undefined ? '' : String(normalizedPayload.countdownTitle || ''), @@ -4010,6 +4062,7 @@ async function broadcastAutoRunStatus(phase, payload = {}, extraState = {}) { currentRun: payload.currentRun ?? autoRunCurrentRun, totalRuns: payload.totalRuns ?? autoRunTotalRuns, attemptRun: payload.attemptRun ?? autoRunAttemptRun, + sessionId: payload.sessionId ?? payload.autoRunSessionId ?? autoRunSessionId, scheduledAt: rawScheduledAt === null ? null : Number(rawScheduledAt), countdownAt: rawCountdownAt === null ? null : Number(rawCountdownAt), countdownTitle: payload.countdownTitle === undefined ? '' : String(payload.countdownTitle || ''), @@ -4119,6 +4172,7 @@ function getAutoRunTimerResumeOptions(plan) { if (normalizedPlan.kind === AUTO_RUN_TIMER_KIND_SCHEDULED_START) { return { loopOptions: { + autoRunSessionId: normalizedPlan.autoRunSessionId, autoRunSkipFailures: normalizedPlan.autoRunSkipFailures, mode: normalizedPlan.mode, }, @@ -4126,6 +4180,7 @@ function getAutoRunTimerResumeOptions(plan) { currentRun: 0, totalRuns: normalizedPlan.totalRuns, attemptRun: 0, + sessionId: normalizedPlan.autoRunSessionId, }, }; } @@ -4134,6 +4189,7 @@ function getAutoRunTimerResumeOptions(plan) { const nextRun = Math.min(normalizedPlan.currentRun + 1, normalizedPlan.totalRuns); return { loopOptions: { + autoRunSessionId: normalizedPlan.autoRunSessionId, autoRunSkipFailures: normalizedPlan.autoRunSkipFailures, mode: 'restart', resumeCurrentRun: nextRun, @@ -4144,12 +4200,14 @@ function getAutoRunTimerResumeOptions(plan) { currentRun: nextRun, totalRuns: normalizedPlan.totalRuns, attemptRun: 1, + sessionId: normalizedPlan.autoRunSessionId, }, }; } return { loopOptions: { + autoRunSessionId: normalizedPlan.autoRunSessionId, autoRunSkipFailures: normalizedPlan.autoRunSkipFailures, mode: 'restart', resumeCurrentRun: normalizedPlan.currentRun, @@ -4160,6 +4218,7 @@ function getAutoRunTimerResumeOptions(plan) { currentRun: normalizedPlan.currentRun, totalRuns: normalizedPlan.totalRuns, attemptRun: normalizedPlan.attemptRun, + sessionId: normalizedPlan.autoRunSessionId, }, }; } @@ -4185,6 +4244,9 @@ async function launchAutoRunTimerPlan(trigger = 'alarm', options = {}) { if (autoRunActive) { return false; } + if (plan.autoRunSessionId && !isCurrentAutoRunSessionId(plan.autoRunSessionId)) { + return false; + } const resumeOptions = getAutoRunTimerResumeOptions(plan); if (!resumeOptions) { @@ -4202,9 +4264,13 @@ async function launchAutoRunTimerPlan(trigger = 'alarm', options = {}) { } await clearAutoRunTimerAlarm(); + if (plan.autoRunSessionId && !isCurrentAutoRunSessionId(plan.autoRunSessionId)) { + return false; + } autoRunCurrentRun = resumeOptions.statusPayload.currentRun; autoRunTotalRuns = plan.totalRuns; autoRunAttemptRun = resumeOptions.statusPayload.attemptRun; + autoRunSessionId = normalizeAutoRunSessionId(plan.autoRunSessionId); if (plan.kind === AUTO_RUN_TIMER_KIND_SCHEDULED_START && trigger !== 'manual' && state.autoRunDelayEnabled) { await setAutoRunDelayEnabledState(false); } @@ -4219,6 +4285,9 @@ async function launchAutoRunTimerPlan(trigger = 'alarm', options = {}) { } ); + if (plan.autoRunSessionId && !isCurrentAutoRunSessionId(plan.autoRunSessionId)) { + return false; + } clearStopRequest(); let logMessage = '倒计时结束,自动运行开始执行。'; if (plan.kind === AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS) { @@ -4233,6 +4302,9 @@ async function launchAutoRunTimerPlan(trigger = 'alarm', options = {}) { logMessage = '已手动跳过倒计时,自动运行立即开始。'; } await addLog(logMessage, 'info'); + if (plan.autoRunSessionId && !isCurrentAutoRunSessionId(plan.autoRunSessionId)) { + return false; + } startAutoRunLoop(plan.totalRuns, resumeOptions.loopOptions); return true; @@ -4251,17 +4323,20 @@ async function scheduleAutoRun(totalRuns, options = {}) { } const delayMinutes = normalizeAutoRunDelayMinutes(options.delayMinutes); + const sessionId = createAutoRunSessionId(); const timerPlan = normalizeAutoRunTimerPlan({ kind: AUTO_RUN_TIMER_KIND_SCHEDULED_START, fireAt: Date.now() + delayMinutes * 60 * 1000, totalRuns, autoRunSkipFailures: options.autoRunSkipFailures, + autoRunSessionId: sessionId, mode: options.mode, }); autoRunCurrentRun = 0; autoRunTotalRuns = timerPlan.totalRuns; autoRunAttemptRun = 0; + autoRunSessionId = sessionId; await persistAutoRunTimerPlan(timerPlan, { autoRunSkipFailures: timerPlan.autoRunSkipFailures, @@ -4284,14 +4359,17 @@ async function cancelScheduledAutoRun(options = {}) { autoRunCurrentRun = 0; autoRunTotalRuns = plan.totalRuns; autoRunAttemptRun = 0; + clearCurrentAutoRunSessionId(plan.autoRunSessionId); await broadcastAutoRunStatus( 'idle', { currentRun: 0, totalRuns: plan.totalRuns, attemptRun: 0, + sessionId: 0, }, { + autoRunSessionId: 0, autoRunRoundSummaries: [], autoRunTimerPlan: null, scheduledAutoRunPlan: null, @@ -4306,15 +4384,18 @@ async function cancelScheduledAutoRun(options = {}) { async function restoreAutoRunTimerIfNeeded() { const state = await getState(); - const plan = getPendingAutoRunTimerPlan(state); + let plan = getPendingAutoRunTimerPlan(state); if (!plan) { + clearCurrentAutoRunSessionId(); if (state.autoRunPhase === 'scheduled' || state.autoRunPhase === 'waiting_interval') { await clearAutoRunTimerAlarm(); await broadcastAutoRunStatus('idle', { currentRun: 0, totalRuns: 1, attemptRun: 0, + sessionId: 0, }, { + autoRunSessionId: 0, autoRunRoundSummaries: [], autoRunTimerPlan: null, scheduledAutoRunPlan: null, @@ -4323,6 +4404,19 @@ async function restoreAutoRunTimerIfNeeded() { return; } + if (!plan.autoRunSessionId) { + const restoredSessionId = createAutoRunSessionId(); + plan = await persistAutoRunTimerPlan({ + ...plan, + autoRunSessionId: restoredSessionId, + }, { + autoRunSkipFailures: plan.autoRunSkipFailures, + autoRunRoundSummaries: serializeAutoRunRoundSummaries(plan.totalRuns, plan.roundSummaries), + }); + } else { + setCurrentAutoRunSessionId(plan.autoRunSessionId); + } + if (plan.fireAt <= Date.now()) { await launchAutoRunTimerPlan('restore'); return; @@ -4333,6 +4427,7 @@ async function restoreAutoRunTimerIfNeeded() { statusPayload.phase, statusPayload, { + autoRunSessionId: plan.autoRunSessionId, autoRunSkipFailures: plan.autoRunSkipFailures, autoRunRoundSummaries: serializeAutoRunRoundSummaries(plan.totalRuns, plan.roundSummaries), autoRunTimerPlan: plan, @@ -4809,6 +4904,7 @@ async function requestStop(options = {}) { autoRunCurrentRun = timerPlan.currentRun; autoRunTotalRuns = timerPlan.totalRuns; autoRunAttemptRun = timerPlan.attemptRun; + clearCurrentAutoRunSessionId(timerPlan.autoRunSessionId); if (options.logMessage !== false) { await addLog(options.logMessage || '已停止等待中的自动流程。', 'warn'); } @@ -4816,7 +4912,9 @@ async function requestStop(options = {}) { currentRun: timerPlan.currentRun, totalRuns: timerPlan.totalRuns, attemptRun: timerPlan.attemptRun, + sessionId: 0, }, { + autoRunSessionId: 0, autoRunSkipFailures: timerPlan.autoRunSkipFailures, autoRunRoundSummaries: serializeAutoRunRoundSummaries(timerPlan.totalRuns, timerPlan.roundSummaries), autoRunTimerPlan: null, @@ -4830,6 +4928,7 @@ async function requestStop(options = {}) { if (stopRequested) return; stopRequested = true; + clearCurrentAutoRunSessionId(); cancelPendingCommands(); cleanupStep8NavigationListeners(); rejectPendingStep8(new Error(STOP_ERROR_MESSAGE)); @@ -4853,7 +4952,9 @@ async function requestStop(options = {}) { currentRun: autoRunCurrentRun, totalRuns: autoRunTotalRuns, attemptRun: autoRunAttemptRun, + sessionId: 0, }, { + autoRunSessionId: 0, autoRunTimerPlan: null, scheduledAutoRunPlan: null, }); @@ -5017,6 +5118,8 @@ let autoRunActive = false; let autoRunCurrentRun = 0; let autoRunTotalRuns = 1; let autoRunAttemptRun = 0; +let autoRunSessionId = 0; +let autoRunSessionSeed = 0; const EMAIL_FETCH_MAX_ATTEMPTS = 5; const VERIFICATION_POLL_MAX_ROUNDS = 5; const STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS = 25000; @@ -5088,6 +5191,7 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR broadcastStopToContentScripts, cancelPendingCommands, clearStopRequest: () => clearStopRequest(), + createAutoRunSessionId: () => createAutoRunSessionId(), getAutoRunStatusPayload, getErrorMessage, getFirstUnfinishedStep, @@ -5109,16 +5213,19 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR autoRunCurrentRun, autoRunTotalRuns, autoRunAttemptRun, + autoRunSessionId, }), set: (updates = {}) => { if (updates.autoRunActive !== undefined) autoRunActive = Boolean(updates.autoRunActive); if (updates.autoRunCurrentRun !== undefined) autoRunCurrentRun = Number(updates.autoRunCurrentRun) || 0; if (updates.autoRunTotalRuns !== undefined) autoRunTotalRuns = Number(updates.autoRunTotalRuns) || 0; if (updates.autoRunAttemptRun !== undefined) autoRunAttemptRun = Number(updates.autoRunAttemptRun) || 0; + if (updates.autoRunSessionId !== undefined) autoRunSessionId = normalizeAutoRunSessionId(updates.autoRunSessionId); }, }, setState, sleepWithStop, + throwIfAutoRunSessionStopped: (sessionId) => throwIfAutoRunSessionStopped(sessionId), waitForRunningStepsToFinish, throwIfStopped: () => throwIfStopped(), chrome, @@ -5558,6 +5665,7 @@ async function resumeAutoRun() { await addLog('检测到自动流程暂停上下文已丢失,正在从当前进度恢复自动运行...', 'warn'); startAutoRunLoop(totalRuns, { + autoRunSessionId: normalizeAutoRunSessionId(state.autoRunSessionId), autoRunSkipFailures: Boolean(state.autoRunSkipFailures), mode: 'continue', resumeCurrentRun: currentRun, diff --git a/background/auto-run-controller.js b/background/auto-run-controller.js index a7d663e..e7ccff8 100644 --- a/background/auto-run-controller.js +++ b/background/auto-run-controller.js @@ -13,6 +13,7 @@ broadcastStopToContentScripts, cancelPendingCommands, clearStopRequest, + createAutoRunSessionId, getAutoRunStatusPayload, getErrorMessage, getFirstUnfinishedStep, @@ -30,6 +31,7 @@ runtime, setState, sleepWithStop, + throwIfAutoRunSessionStopped, waitForRunningStepsToFinish, } = deps; @@ -175,6 +177,7 @@ currentRun: targetRun, totalRuns, attemptRun: currentRuntime.autoRunAttemptRun, + autoRunSessionId: currentRuntime.autoRunSessionId, autoRunSkipFailures, roundSummaries, countdownTitle: '线程间隔中', @@ -206,6 +209,7 @@ currentRun: targetRun, totalRuns, attemptRun: nextAttemptRun, + autoRunSessionId: runtime.get().autoRunSessionId, autoRunSkipFailures, roundSummaries, countdownTitle: '线程间隔中', @@ -225,12 +229,14 @@ await addLog(`自动运行异常终止:${getErrorMessage(error) || '未知错误'}`, 'error'); } - runtime.set({ autoRunActive: false }); + runtime.set({ autoRunActive: false, autoRunSessionId: 0 }); await broadcastAutoRunStatus('stopped', { currentRun: currentRuntime.autoRunCurrentRun, totalRuns: currentRuntime.autoRunTotalRuns, attemptRun: currentRuntime.autoRunAttemptRun, + sessionId: 0, }, { + autoRunSessionId: 0, autoRunTimerPlan: null, scheduledAutoRunPlan: null, }); @@ -250,12 +256,22 @@ return; } + let sessionId = Number.isInteger(options.autoRunSessionId) && options.autoRunSessionId > 0 + ? options.autoRunSessionId + : 0; + if (sessionId) { + throwIfAutoRunSessionStopped(sessionId); + } else { + sessionId = createAutoRunSessionId(); + } + clearStopRequest(); runtime.set({ autoRunActive: true, autoRunTotalRuns: totalRuns, autoRunCurrentRun: 0, autoRunAttemptRun: 0, + autoRunSessionId: sessionId, }); currentRuntime = runtime.get(); @@ -293,12 +309,14 @@ const showResumePosition = continueCurrentOnFirstAttempt || resumeCurrentRun > 1 || resumeAttemptRun > 1; await setState({ + autoRunSessionId: sessionId, autoRunSkipFailures, autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), ...getAutoRunStatusPayload(initialPhase, { currentRun: showResumePosition ? resumeCurrentRun : 0, totalRuns, attemptRun: showResumePosition ? resumeAttemptRun : 0, + sessionId, }), }); @@ -360,9 +378,10 @@ cloudflareDomain: prevState.cloudflareDomain, cloudflareDomains: prevState.cloudflareDomains, autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), + autoRunSessionId: sessionId, tabRegistry: {}, sourceLastUrls: {}, - ...getAutoRunStatusPayload('running', { currentRun: targetRun, totalRuns, attemptRun }), + ...getAutoRunStatusPayload('running', { currentRun: targetRun, totalRuns, attemptRun, sessionId }), }; await resetState(); await setState(keepSettings); @@ -370,9 +389,10 @@ await sleepWithStop(500); } else { await setState({ + autoRunSessionId: sessionId, autoRunSkipFailures, autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), - ...getAutoRunStatusPayload('running', { currentRun: targetRun, totalRuns, attemptRun }), + ...getAutoRunStatusPayload('running', { currentRun: targetRun, totalRuns, attemptRun, sessionId }), }); } @@ -397,11 +417,12 @@ }; try { - deps.throwIfStopped(); + throwIfAutoRunSessionStopped(sessionId); await broadcastAutoRunStatus('running', { currentRun: targetRun, totalRuns, attemptRun, + sessionId, }); await runAutoSequenceFromStep(startStep, { @@ -428,6 +449,7 @@ currentRun: targetRun, totalRuns, attemptRun, + sessionId: 0, }); break; } @@ -453,6 +475,7 @@ currentRun: targetRun, totalRuns, attemptRun, + sessionId, }); forceFreshTabsNextRun = true; await addLog( @@ -470,6 +493,7 @@ currentRun: targetRun, totalRuns, attemptRun, + sessionId: 0, }); break; } @@ -493,6 +517,7 @@ currentRun: targetRun, totalRuns, attemptRun, + sessionId: 0, }); break; } @@ -518,6 +543,7 @@ currentRun: targetRun, totalRuns, attemptRun, + sessionId: 0, }); break; } @@ -559,6 +585,7 @@ currentRun: targetRun, totalRuns, attemptRun: runtime.get().autoRunAttemptRun, + sessionId: 0, }); break; } @@ -584,6 +611,7 @@ currentRun: finalRuntime.autoRunCurrentRun, totalRuns: finalRuntime.autoRunTotalRuns, attemptRun: finalRuntime.autoRunAttemptRun, + sessionId: 0, }); } else { await addLog(`=== 全部 ${finalRuntime.autoRunTotalRuns} 轮已执行完成,成功 ${successfulRuns} 轮 ===`, 'ok'); @@ -591,11 +619,13 @@ currentRun: finalRuntime.autoRunTotalRuns, totalRuns: finalRuntime.autoRunTotalRuns, attemptRun: finalRuntime.autoRunAttemptRun, + sessionId: 0, }); } - runtime.set({ autoRunActive: false }); + runtime.set({ autoRunActive: false, autoRunSessionId: 0 }); const afterRuntime = runtime.get(); await setState({ + autoRunSessionId: 0, autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), autoRunTimerPlan: null, scheduledAutoRunPlan: null, @@ -603,6 +633,7 @@ currentRun: deps.getStopRequested() || stoppedEarly ? afterRuntime.autoRunCurrentRun : afterRuntime.autoRunTotalRuns, totalRuns: afterRuntime.autoRunTotalRuns, attemptRun: afterRuntime.autoRunAttemptRun, + sessionId: 0, }), }); clearStopRequest(); diff --git a/background/logging-status.js b/background/logging-status.js index 9b98972..83a0ea7 100644 --- a/background/logging-status.js +++ b/background/logging-status.js @@ -137,6 +137,7 @@ autoRunCurrentRun: payload.currentRun ?? 0, autoRunTotalRuns: payload.totalRuns ?? 1, autoRunAttemptRun: payload.attemptRun ?? 0, + autoRunSessionId: Math.max(0, Math.floor(Number(payload.sessionId ?? payload.autoRunSessionId) || 0)), scheduledAutoRunAt: Number.isFinite(Number(payload.scheduledAt)) ? Number(payload.scheduledAt) : null, autoRunCountdownAt: Number.isFinite(Number(payload.countdownAt)) ? Number(payload.countdownAt) : null, autoRunCountdownTitle: payload.countdownTitle === undefined ? '' : String(payload.countdownTitle || ''), diff --git a/background/tab-runtime.js b/background/tab-runtime.js index ea1650c..54f6dfc 100644 --- a/background/tab-runtime.js +++ b/background/tab-runtime.js @@ -12,12 +12,73 @@ LOG_PREFIX, matchesSourceUrlFamily, setState, + sleepWithStop, STOP_ERROR_MESSAGE, throwIfStopped, } = deps; const pendingCommands = new Map(); + async function sleepOrStop(ms) { + if (typeof sleepWithStop === 'function') { + await sleepWithStop(ms); + return; + } + + const start = Date.now(); + while (Date.now() - start < ms) { + throwIfStopped(); + await new Promise((resolve) => setTimeout(resolve, Math.min(100, ms - (Date.now() - start)))); + } + } + + function waitForTabUpdateComplete(tabId, timeoutMs = 30000) { + return new Promise((resolve, reject) => { + let settled = false; + let stopTimer = null; + + const cleanup = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + clearTimeout(stopTimer); + chrome.tabs.onUpdated.removeListener(listener); + }; + + const resolveSafely = () => { + cleanup(); + resolve(); + }; + + const rejectSafely = (error) => { + cleanup(); + reject(error); + }; + + const listener = (updatedTabId, info) => { + if (updatedTabId === tabId && info.status === 'complete') { + resolveSafely(); + } + }; + + const timer = setTimeout(resolveSafely, timeoutMs); + chrome.tabs.onUpdated.addListener(listener); + + const pollStop = () => { + if (settled) return; + try { + throwIfStopped(); + } catch (error) { + rejectSafely(error); + return; + } + stopTimer = setTimeout(pollStop, 100); + }; + + pollStop(); + }); + } + async function getTabRegistry() { const state = await getState(); return state.tabRegistry || {}; @@ -179,7 +240,7 @@ } catch { return null; } - await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + await sleepOrStop(retryDelayMs); } return null; } @@ -197,7 +258,7 @@ } catch { return null; } - await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + await sleepOrStop(retryDelayMs); } return null; } @@ -215,7 +276,7 @@ } catch { return null; } - await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + await sleepOrStop(retryDelayMs); } try { @@ -296,7 +357,7 @@ logged = true; } - await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + await sleepOrStop(retryDelayMs); } throw lastError || new Error(`${getSourceLabel(source)} 内容脚本长时间未就绪。`); @@ -418,17 +479,7 @@ if (registry[source]) registry[source].ready = false; await setState({ tabRegistry: registry }); await chrome.tabs.reload(tabId); - await new Promise((resolve) => { - const timer = setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, 30000); - const listener = (tid, info) => { - if (tid === tabId && info.status === 'complete') { - chrome.tabs.onUpdated.removeListener(listener); - clearTimeout(timer); - resolve(); - } - }; - chrome.tabs.onUpdated.addListener(listener); - }); + await waitForTabUpdateComplete(tabId); } if (options.inject) { @@ -447,7 +498,7 @@ target: { tabId }, files: options.inject, }); - await new Promise((resolve) => setTimeout(resolve, 500)); + await sleepOrStop(500); } await rememberSourceLastUrl(source, url); @@ -458,17 +509,7 @@ await setState({ tabRegistry: registry }); await chrome.tabs.update(tabId, { url, active: true }); - await new Promise((resolve) => { - const timer = setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, 30000); - const listener = (tid, info) => { - if (tid === tabId && info.status === 'complete') { - chrome.tabs.onUpdated.removeListener(listener); - clearTimeout(timer); - resolve(); - } - }; - chrome.tabs.onUpdated.addListener(listener); - }); + await waitForTabUpdateComplete(tabId); if (options.inject) { if (options.injectSource) { @@ -486,7 +527,7 @@ }); } - await new Promise((resolve) => setTimeout(resolve, 500)); + await sleepOrStop(500); await rememberSourceLastUrl(source, url); return tabId; } @@ -495,17 +536,7 @@ const tab = await chrome.tabs.create({ url, active: true }); if (options.inject) { - await new Promise((resolve) => { - const timer = setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, 30000); - const listener = (tabId, info) => { - if (tabId === tab.id && info.status === 'complete') { - chrome.tabs.onUpdated.removeListener(listener); - clearTimeout(timer); - resolve(); - } - }; - chrome.tabs.onUpdated.addListener(listener); - }); + await waitForTabUpdateComplete(tab.id); if (options.injectSource) { await chrome.scripting.executeScript({ target: { tabId: tab.id }, @@ -580,7 +611,7 @@ logged = true; } - await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + await sleepOrStop(retryDelayMs); } } @@ -628,7 +659,7 @@ injectSource: mail.injectSource, reloadIfSameUrl: true, }); - await new Promise((resolve) => setTimeout(resolve, 800)); + await sleepOrStop(800); } } diff --git a/content/signup-page.js b/content/signup-page.js index 958d99b..bfe73e9 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -523,9 +523,12 @@ async function fillSignupEmailAndContinue(email, step) { log(`步骤 ${step}:邮箱已准备提交,正在前往密码页...`); window.setTimeout(() => { try { + throwIfStopped(); simulateClick(continueButton); } catch (error) { - console.error('[MultiPage:signup-page] deferred signup email submit failed:', error?.message || error); + if (!isStopError(error)) { + console.error('[MultiPage:signup-page] deferred signup email submit failed:', error?.message || error); + } } }, 120); @@ -601,12 +604,15 @@ async function step3_fillEmailPassword(payload) { if (submitBtn) { window.setTimeout(async () => { try { + throwIfStopped(); await sleep(500); await humanPause(500, 1300); simulateClick(submitBtn); log('步骤 3:表单已提交'); } catch (error) { - console.error('[MultiPage:signup-page] deferred step 3 submit failed:', error?.message || error); + if (!isStopError(error)) { + console.error('[MultiPage:signup-page] deferred step 3 submit failed:', error?.message || error); + } } }, 120); } @@ -2344,4 +2350,3 @@ async function step5_fillNameBirthday(payload) { log('步骤 5:已点击“完成帐户创建”,当前步骤直接完成,不再等待页面结果。'); return completionPayload; } - diff --git a/tests/auto-run-fresh-attempt-reset.test.js b/tests/auto-run-fresh-attempt-reset.test.js index 7a77f0c..8627a15 100644 --- a/tests/auto-run-fresh-attempt-reset.test.js +++ b/tests/auto-run-fresh-attempt-reset.test.js @@ -53,6 +53,7 @@ function extractFunction(source, name) { const helperBundle = [ extractFunction(helperSource, 'clearStopRequest'), + extractFunction(helperSource, 'normalizeAutoRunSessionId'), extractFunction(helperSource, 'throwIfStopped'), extractFunction(helperSource, 'isStopError'), extractFunction(helperSource, 'isStepDoneStatus'), @@ -88,6 +89,8 @@ const DEFAULT_STATE = { let stopRequested = false; let runCalls = 0; +let autoRunSessionId = 0; +let autoRunSessionSeed = 1000; const logs = []; const broadcasts = []; @@ -191,6 +194,17 @@ async function persistAutoRunTimerPlan() {} async function launchAutoRunTimerPlan() { return false; } function getPendingAutoRunTimerPlan() { return null; } function getErrorMessage(error) { return error?.message || String(error || ''); } +function createAutoRunSessionId() { + autoRunSessionSeed += 1; + autoRunSessionId = autoRunSessionSeed; + return autoRunSessionId; +} +function throwIfAutoRunSessionStopped(sessionId) { + if (sessionId && sessionId !== autoRunSessionId) { + throw new Error(STOP_ERROR_MESSAGE); + } + throwIfStopped(); +} const chrome = { runtime: { sendMessage() { @@ -242,6 +256,7 @@ const runtime = { autoRunCurrentRun: 0, autoRunTotalRuns: 1, autoRunAttemptRun: 0, + autoRunSessionId: 0, }, get() { return { ...this.state }; @@ -261,6 +276,7 @@ const controller = self.MultiPageBackgroundAutoRunController.createAutoRunContro broadcastStopToContentScripts, cancelPendingCommands, clearStopRequest, + createAutoRunSessionId, getAutoRunStatusPayload, getErrorMessage, getFirstUnfinishedStep, @@ -279,6 +295,7 @@ const controller = self.MultiPageBackgroundAutoRunController.createAutoRunContro runtime, setState, sleepWithStop, + throwIfAutoRunSessionStopped, waitForRunningStepsToFinish, throwIfStopped, chrome, @@ -309,6 +326,7 @@ return { assert.strictEqual(snapshot.currentState.autoRunPhase, 'complete', 'both runs should complete after reset'); assert.strictEqual(snapshot.currentState.autoRunCurrentRun, 2, 'final run index should be recorded'); assert.strictEqual(snapshot.autoRunActive, false, 'auto-run should exit active state after completion'); + assert.strictEqual(snapshot.currentState.autoRunSessionId, 0, 'session id should be cleared after completion'); assert.strictEqual(snapshot.currentState.gmailBaseEmail, 'demo@gmail.com', 'gmail base email should survive fresh-attempt reset'); assert.strictEqual(snapshot.currentState.mail2925BaseEmail, 'demo@2925.com', '2925 base email should survive fresh-attempt reset'); diff --git a/tests/auto-run-timer-session-guard.test.js b/tests/auto-run-timer-session-guard.test.js new file mode 100644 index 0000000..1f74bf2 --- /dev/null +++ b/tests/auto-run-timer-session-guard.test.js @@ -0,0 +1,147 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const helperSource = fs.readFileSync('background.js', 'utf8'); + +function extractFunction(source, name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map((marker) => source.indexOf(marker)) + .find((index) => index >= 0); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -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) { + braceStart = i; + break; + } + } + if (braceStart < 0) { + throw new Error(`missing body for function ${name}`); + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +const helperBundle = [ + extractFunction(helperSource, 'normalizeRunCount'), + extractFunction(helperSource, 'normalizeAutoRunTimerKind'), + extractFunction(helperSource, 'normalizeAutoRunSessionId'), + extractFunction(helperSource, 'isCurrentAutoRunSessionId'), + extractFunction(helperSource, 'normalizeAutoRunTimerPlan'), + extractFunction(helperSource, 'getAutoRunTimerResumeOptions'), + extractFunction(helperSource, 'launchAutoRunTimerPlan'), +].join('\n'); + +test('launchAutoRunTimerPlan ignores stale timer plans after stop invalidates the session', async () => { + const api = new Function(` +const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start'; +const AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS = 'between_rounds'; +const AUTO_RUN_TIMER_KIND_BEFORE_RETRY = 'before_retry'; +const AUTO_RUN_MAX_RETRIES_PER_ROUND = 3; + +let autoRunTimerLaunching = false; +let autoRunActive = false; +let autoRunCurrentRun = 0; +let autoRunTotalRuns = 1; +let autoRunAttemptRun = 0; +let autoRunSessionId = 0; + +const state = { + autoRunDelayEnabled: false, + autoRunTimerPlan: { + kind: AUTO_RUN_TIMER_KIND_SCHEDULED_START, + fireAt: Date.now() + 60_000, + totalRuns: 2, + autoRunSkipFailures: false, + autoRunSessionId: 42, + countdownTitle: '已计划自动运行', + countdownNote: '等待启动', + }, +}; + +let startCalls = 0; +let clearStopCalls = 0; +let clearAlarmCalls = 0; + +async function getState() { + return { ...state }; +} + +function getPendingAutoRunTimerPlan() { + return state.autoRunTimerPlan; +} + +async function clearAutoRunTimerAlarm() { + clearAlarmCalls += 1; +} + +async function broadcastAutoRunStatus() {} +async function addLog() {} +async function setAutoRunDelayEnabledState() {} +function serializeAutoRunRoundSummaries(totalRuns, summaries = []) { + return Array.isArray(summaries) ? summaries : []; +} +function clearStopRequest() { + clearStopCalls += 1; +} +function startAutoRunLoop() { + startCalls += 1; +} + +${helperBundle} + +return { + launchAutoRunTimerPlan, + snapshot() { + return { + startCalls, + clearStopCalls, + clearAlarmCalls, + autoRunCurrentRun, + autoRunTotalRuns, + autoRunAttemptRun, + }; + }, +}; +`)(); + + const started = await api.launchAutoRunTimerPlan('alarm'); + const snapshot = api.snapshot(); + + assert.equal(started, false); + assert.equal(snapshot.startCalls, 0, 'stale timer plan should not restart auto-run'); + assert.equal(snapshot.clearStopCalls, 0, 'stale timer plan should not clear the stop flag for a cancelled run'); + assert.equal(snapshot.clearAlarmCalls, 0, 'stale timer plan should not clear a potentially newer alarm'); + assert.equal(snapshot.autoRunCurrentRun, 0); + assert.equal(snapshot.autoRunTotalRuns, 1); + assert.equal(snapshot.autoRunAttemptRun, 0); +}); diff --git a/tests/background-tab-runtime-module.test.js b/tests/background-tab-runtime-module.test.js index e57a555..2398c5d 100644 --- a/tests/background-tab-runtime-module.test.js +++ b/tests/background-tab-runtime-module.test.js @@ -47,6 +47,7 @@ test('tab runtime waitForTabComplete waits until tab status becomes complete', a registerTab: async () => {}, setState: async () => {}, shouldBypassStep9ForLocalCpa: () => false, + throwIfStopped: () => {}, }); const result = await runtime.waitForTabComplete(9, { @@ -57,3 +58,43 @@ test('tab runtime waitForTabComplete waits until tab status becomes complete', a assert.equal(result?.status, 'complete'); assert.equal(getCalls, 3); }); + +test('tab runtime waitForTabComplete aborts promptly when stop is requested', async () => { + const source = fs.readFileSync('background/tab-runtime.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundTabRuntime;`)(globalScope); + + let throwCalls = 0; + const runtime = api.createTabRuntime({ + LOG_PREFIX: '[test]', + addLog: async () => {}, + chrome: { + tabs: { + get: async () => ({ + id: 9, + url: 'https://example.com', + status: 'loading', + }), + query: async () => [], + }, + }, + getSourceLabel: (sourceName) => sourceName || 'unknown', + getState: async () => ({ tabRegistry: {}, sourceLastUrls: {} }), + matchesSourceUrlFamily: () => false, + setState: async () => {}, + throwIfStopped: () => { + throwCalls += 1; + if (throwCalls >= 2) { + throw new Error('Flow stopped.'); + } + }, + }); + + await assert.rejects( + runtime.waitForTabComplete(9, { + timeoutMs: 2000, + retryDelayMs: 1, + }), + /Flow stopped\./ + ); +}); diff --git a/tests/step3-direct-complete.test.js b/tests/step3-direct-complete.test.js index 0ce56e9..141ce18 100644 --- a/tests/step3-direct-complete.test.js +++ b/tests/step3-direct-complete.test.js @@ -98,6 +98,10 @@ function fillInput(input, value) { async function humanPause() {} async function sleep() {} +function throwIfStopped() {} +function isStopError() { + return false; +} function log(message, level = 'info') { logs.push({ message, level }); diff --git a/tests/step8-stop-cleanup.test.js b/tests/step8-stop-cleanup.test.js index b43fcd5..e49ca4e 100644 --- a/tests/step8-stop-cleanup.test.js +++ b/tests/step8-stop-cleanup.test.js @@ -52,6 +52,8 @@ function extractFunction(source, name) { } const helperBundle = [ + extractFunction(helperSource, 'normalizeAutoRunSessionId'), + extractFunction(helperSource, 'clearCurrentAutoRunSessionId'), extractFunction(helperSource, 'throwIfStopped'), extractFunction(helperSource, 'cleanupStep8NavigationListeners'), extractFunction(helperSource, 'rejectPendingStep8'), @@ -71,6 +73,7 @@ let autoRunActive = true; let autoRunCurrentRun = 2; let autoRunTotalRuns = 3; let autoRunAttemptRun = 4; +let autoRunSessionId = 99; const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start'; const STEP8_CLICK_RETRY_DELAY_MS = 500; const STEP8_MAX_ROUNDS = 5; @@ -257,6 +260,7 @@ return { sentMessages, clickCount, autoRunActive, + autoRunSessionId, }; }, }; @@ -288,6 +292,7 @@ return { assert.strictEqual(state.webNavCommittedListener, null, 'Stop 后 onCommitted 引用应为空'); assert.strictEqual(state.step8TabUpdatedListener, null, 'Stop 后 tabs.onUpdated 引用应为空'); assert.strictEqual(state.step8PendingReject, null, 'Stop 后不应保留 Step 8 挂起 reject'); + assert.strictEqual(state.autoRunSessionId, 0, 'Stop 后自动运行 session 应失效'); console.log('step8 stop cleanup tests passed'); })().catch((error) => { diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 8f5f03a..37a3dc7 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -121,6 +121,7 @@ - 当前邮箱 / 密码 - localhost 回调地址 - 自动运行轮次信息 +- 当前自动运行 session 标识 `autoRunSessionId` - 标签注册表 - 最近打开的来源地址 - LuckMail 当前运行时选择 @@ -179,6 +180,7 @@ - 页面切换导致脚本暂时失联时,后台不会立刻误判彻底失败 - 邮箱页或注册页能在注入恢复后继续执行 +- 用户点击 Stop 后,标签完成等待、注入后的短暂延迟和内容脚本重试等待会尽快中断,不会因为底层轮询还在 sleep 而继续往下恢复 ## 6. 手动步骤完整链路 @@ -230,7 +232,8 @@ 4. 让内容脚本填写密码 5. 内容脚本先上报 Step 3 完成信号 6. 上报完成后再异步点击提交,避免页面跳转打断响应通道 -7. 后台在真正确认 Step 3 完成前,会额外检查提交后是否切换页面;如果出现认证页 `Try again / 重试` 页面,会先自动点击 `重试` 恢复,再继续后续链路 +7. 延迟提交真正触发前会再次检查 Stop 状态,避免用户已停止时页面仍继续自动提交 +8. 后台在真正确认 Step 3 完成前,会额外检查提交后是否切换页面;如果出现认证页 `Try again / 重试` 页面,会先自动点击 `重试` 恢复,再继续后续链路 ### Step 4 / Step 7 @@ -440,20 +443,22 @@ 流程: 1. 读取总轮数与模式 -2. 计算是否从中断点继续 -3. 每轮执行前重置必要运行态 -4. 执行 `runAutoSequenceFromStep` +2. 为本轮自动流程分配唯一 `autoRunSessionId` +3. 计算是否从中断点继续 +4. 每轮执行前重置必要运行态 +5. 执行 `runAutoSequenceFromStep` - 步骤 7 内部仍保留登录态恢复的有限重试 - 一旦进入步骤 7~10,遇到普通报错且认证流程未进入 `add-phone`,则自动回到步骤 7 无限重开 - 如果是手动停止,则立即退出自动流程,不会再触发“回到步骤 7 重开” -5. 如果失败,根据设置决定: +6. 如果失败,根据设置决定: - 立即停止 - 当前轮重试 - 下一轮继续 -6. 当前轮最终失败时,写入邮箱记录,并在自动重试链路中累计重试次数 +7. 当前轮最终失败时,写入邮箱记录,并在自动重试链路中累计重试次数 - 手动停止与自动停止会写入“停止”状态(若后续同邮箱成功/失败,会被覆盖为最新状态) -7. 如果配置了线程间隔,则挂计时计划 -8. 所有轮次结束后输出汇总 +8. 如果配置了线程间隔,则挂计时计划;计时计划会带上当前 `autoRunSessionId` +9. 旧 timer / 旧 alarm / 旧恢复入口只有在 session 仍有效时才允许恢复执行;Stop 会立即使当前 session 失效,防止“停止后旧倒计时又把流程重新拉起” +10. 所有轮次结束后输出汇总,并清空当前 session 标识 ## 9. 新增功能时最容易漏掉的地方 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index 50352f1..f13b7ab 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -41,14 +41,14 @@ ## `background/` - `background/account-run-history.js`:邮箱记录模块,负责以邮箱为主键维护最新记录(同邮箱后续状态覆盖),在步骤 2 设定邮箱后支持先写入“停止/未完成”占位状态,统一归一化成功/失败/停止三态并落地到 `chrome.storage.local`,支持清理记录,并在启用独立本地同步配置后把完整快照同步到本地 helper。 -- `background/auto-run-controller.js`:自动运行主控制器,封装多轮执行、重试、轮次摘要、线程间隔与倒计时恢复逻辑;fresh-attempt reset 时会额外保留 `gmailBaseEmail` 与 `mail2925BaseEmail`,避免自动流程重置后丢失别名基邮箱配置。 +- `background/auto-run-controller.js`:自动运行主控制器,封装多轮执行、重试、轮次摘要、线程间隔与倒计时恢复逻辑;当前自动流程会绑定 `autoRunSessionId`,手动停止后旧的倒计时计划、旧重试链路和旧恢复入口不会再复活已失效的自动运行;fresh-attempt reset 时会额外保留 `gmailBaseEmail` 与 `mail2925BaseEmail`,避免自动流程重置后丢失别名基邮箱配置。 - `background/generated-email-helpers.js`:生成邮箱辅助层,除了 Duck / Cloudflare / iCloud / Cloudflare Temp Email,也统一承接 Gmail / 2925 的别名邮箱生成入口。 - `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层。 - `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息。 - `background/navigation-utils.js`:导航与 URL 判断工具层,负责 callback、入口页、CPA / SUB2API 地址、步骤跳转相关判断。 - `background/panel-bridge.js`:CPA / SUB2API 面板桥接层,封装 OAuth 地址获取所需的页面打开、脚本注入和通信。 - `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、等待密码页以及解析当前流程所用邮箱;在 Gmail / 2925 模式下会优先复用已经存在且仍兼容的完整注册邮箱,只有不兼容或为空时才重新生成。 -- `background/tab-runtime.js`:标签页与内容脚本运行时基础设施,封装标签注册、冲突清理、消息超时、注入重试与队列。 +- `background/tab-runtime.js`:标签页与内容脚本运行时基础设施,封装标签注册、冲突清理、消息超时、注入重试与队列;当前等待标签完成、注入后的短暂延迟和内容脚本重试等待都已做 Stop 感知,避免用户停止后后台还继续等待并恢复执行。 - `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退、自定义邮箱跳过、共享验证码自动重发次数配置以及 2925 长轮询参数。 ## `background/steps/` @@ -76,7 +76,7 @@ - `content/mail-163.js`:163 / 163 VIP 邮箱轮询脚本,负责网页邮箱验证码读取和邮件清理。 - `content/mail-2925.js`:2925 邮箱页面脚本,负责 2925 邮箱收件轮询与验证码匹配。 - `content/qq-mail.js`:QQ 邮箱轮询脚本,负责网页邮箱验证码读取。 -- `content/signup-page.js`:注册、登录、授权主内容脚本,负责 OpenAI / ChatGPT 页面上的步骤执行。 +- `content/signup-page.js`:注册、登录、授权主内容脚本,负责 OpenAI / ChatGPT 页面上的步骤执行;其中 Step 2 / 3 的延迟点击与延迟提交在真正触发前会先检查 Stop 状态,避免停止后页面继续自动点击。 - `content/sub2api-panel.js`:SUB2API 后台内容脚本,负责获取 OAuth 地址和提交 localhost 回调;当前承接步骤 10。 - `content/utils.js`:内容脚本公共工具层,负责日志、READY / COMPLETE / ERROR 上报、元素等待、输入与点击。 - `content/vps-panel.js`:CPA 面板内容脚本,负责获取 OAuth 地址、提交回调 URL,并基于精确成功徽标与错误态做步骤 10 判定。 @@ -123,6 +123,7 @@ - `tests/activation-utils.test.js`:测试内容脚本激活策略与平台回调可恢复错误判断。 - `tests/auth-page-recovery.test.js`:测试认证页共享恢复层的重试页识别与恢复行为。 - `tests/auto-run-fresh-attempt-reset.test.js`:测试自动运行在新一轮开始前会重置旧运行时上下文,并补充 `gmailBaseEmail` / `mail2925BaseEmail` 在 fresh reset 后仍被保留的回归验证。 +- `tests/auto-run-timer-session-guard.test.js`:测试自动运行的旧计时计划在 Stop 使 session 失效后,不能再把已停止的自动流程重新拉起。 - `tests/auto-run-step6-restart.test.js`:测试自动运行在后半段授权链路遇错时会回到步骤 7 重开,并在命中 add-phone 时停止重开。 - `tests/auto-step-random-delay.test.js`:测试自动运行步间延迟与旧配置键兼容解析。 - `tests/background-auto-run-module.test.js`:测试自动运行控制器模块已接入且导出工厂。 @@ -145,7 +146,7 @@ - `tests/background-step6-retry-limit.test.js`:测试步骤 6 的 Cookie 清理,以及步骤 7 的有限重试上限。 - `tests/background-step7-recovery.test.js`:测试步骤 8 获取登录验证码后直接提交(不再回放步骤 7),并为 2925 关闭重发间隔。 - `tests/background-step-registry.test.js`:测试后台步骤注册表和共享步骤定义已接入。 -- `tests/background-tab-runtime-module.test.js`:测试标签运行时模块已接入且导出工厂。 +- `tests/background-tab-runtime-module.test.js`:测试标签运行时模块已接入且导出工厂,并覆盖等待标签完成时的 Stop 中断行为。 - `tests/background-verification-flow-module.test.js`:测试验证码流程模块已接入且导出工厂。 - `tests/cloudflare-temp-email-provider.test.js`:测试 Cloudflare Temp Email provider 的轮询与目标邮箱选择逻辑。 - `tests/cloudflare-temp-email-utils.test.js`:测试 Cloudflare Temp Email 工具层的 URL、域名、邮件解析逻辑。 @@ -165,7 +166,7 @@ - `tests/signup-entry-diagnostics.test.js`:测试注册入口诊断快照输出。 - `tests/signup-page-tab-cleanup.test.js`:测试注册页来源标签的冲突清理逻辑。 - `tests/step-definitions-module.test.js`:测试共享步骤定义模块及侧边栏脚本加载顺序。 -- `tests/step3-direct-complete.test.js`:测试步骤 3 在提交前先完成上报。 +- `tests/step3-direct-complete.test.js`:测试步骤 3 在提交前先完成上报,并保留延迟提交回调的可执行性验证。 - `tests/step5-direct-complete.test.js`:测试步骤 5 在资料页点击提交后立即完成当前步骤。 - `tests/step6-login-state.test.js`:测试 OAuth 登录状态识别逻辑,当前对应步骤 7 链路。 - `tests/step6-timeout-recovery.test.js`:测试 OAuth 登录超时报错页的可恢复结果构造,当前对应步骤 7 链路。