feat: add step definitions module and integrate with sidepanel

- Introduced a new module for step definitions in `data/step-definitions.js` to manage shared step metadata.
- Updated `sidepanel.html` to dynamically render steps based on the new step definitions module.
- Refactored `sidepanel.js` to utilize the step definitions for rendering and managing step statuses.
- Enhanced tests to validate the step definitions module and its integration with the sidepanel.
- Cleaned up existing tests to ensure they align with the new structure and functionality.
This commit is contained in:
QLHazyCoder
2026-04-17 01:39:46 +08:00
parent a0d6d1f050
commit 3071f94f7c
19 changed files with 2877 additions and 1850 deletions
+608
View File
@@ -0,0 +1,608 @@
(function attachBackgroundAutoRunController(root, factory) {
root.MultiPageBackgroundAutoRunController = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundAutoRunControllerModule() {
function createAutoRunController(deps = {}) {
const {
addLog,
AUTO_RUN_MAX_RETRIES_PER_ROUND,
AUTO_RUN_RETRY_DELAY_MS,
AUTO_RUN_TIMER_KIND_BEFORE_RETRY,
AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS,
broadcastAutoRunStatus,
broadcastStopToContentScripts,
cancelPendingCommands,
clearStopRequest,
getAutoRunStatusPayload,
getErrorMessage,
getFirstUnfinishedStep,
getPendingAutoRunTimerPlan,
getRunningSteps,
getState,
hasSavedProgress,
isRestartCurrentAttemptError,
isStopError,
launchAutoRunTimerPlan,
normalizeAutoRunFallbackThreadIntervalMinutes,
persistAutoRunTimerPlan,
resetState,
runAutoSequenceFromStep,
runtime,
setState,
sleepWithStop,
waitForRunningStepsToFinish,
} = deps;
function createAutoRunRoundSummary(round) {
return {
round,
status: 'pending',
attempts: 0,
failureReasons: [],
finalFailureReason: '',
};
}
function normalizeAutoRunRoundSummary(summary, round) {
const base = createAutoRunRoundSummary(round);
if (!summary || typeof summary !== 'object') {
return base;
}
const status = String(summary.status || '').trim().toLowerCase();
return {
round,
status: ['pending', 'success', 'failed'].includes(status) ? status : base.status,
attempts: Math.max(0, Math.floor(Number(summary.attempts) || 0)),
failureReasons: Array.isArray(summary.failureReasons)
? summary.failureReasons.map((item) => String(item || '').trim()).filter(Boolean)
: [],
finalFailureReason: String(summary.finalFailureReason || '').trim(),
};
}
function buildAutoRunRoundSummaries(totalRuns, rawSummaries = []) {
return Array.from({ length: totalRuns }, (_, index) => normalizeAutoRunRoundSummary(rawSummaries[index], index + 1));
}
function serializeAutoRunRoundSummaries(totalRuns, roundSummaries = []) {
return buildAutoRunRoundSummaries(totalRuns, roundSummaries).map((summary) => ({
...summary,
failureReasons: [...summary.failureReasons],
}));
}
function getAutoRunRoundRetryCount(summary) {
return Math.max(0, Number(summary?.attempts || 0) - 1);
}
function formatAutoRunFailureReasons(reasons = []) {
if (!Array.isArray(reasons) || !reasons.length) {
return '未知错误';
}
const counts = new Map();
for (const reason of reasons) {
const normalized = String(reason || '').trim() || '未知错误';
counts.set(normalized, (counts.get(normalized) || 0) + 1);
}
return Array.from(counts.entries())
.map(([reason, count]) => (count > 1 ? `${reason}${count}次)` : reason))
.join('');
}
async function logAutoRunFinalSummary(totalRuns, roundSummaries = []) {
const summaries = buildAutoRunRoundSummaries(totalRuns, roundSummaries);
const successRounds = summaries.filter((item) => item.status === 'success');
const failedRounds = summaries.filter((item) => item.status === 'failed');
const pendingRounds = summaries.filter((item) => item.status === 'pending');
await addLog('=== 自动运行汇总 ===', failedRounds.length ? 'warn' : 'ok');
await addLog(
`总轮数:${totalRuns};成功:${successRounds.length};失败:${failedRounds.length};未完成:${pendingRounds.length}`,
failedRounds.length ? 'warn' : 'ok'
);
if (successRounds.length) {
await addLog(
`成功轮次:${successRounds
.map((item) => `${item.round} 轮(重试 ${getAutoRunRoundRetryCount(item)} 次)`)
.join('')}`,
'ok'
);
}
if (failedRounds.length) {
await addLog(
`失败轮次:${failedRounds
.map((item) => {
const retryCount = getAutoRunRoundRetryCount(item);
const finalReason = item.finalFailureReason || item.failureReasons[item.failureReasons.length - 1] || '未知错误';
const reasonSummary = formatAutoRunFailureReasons(item.failureReasons);
return `${item.round} 轮(重试 ${retryCount} 次,最终原因:${finalReason};失败记录:${reasonSummary}`;
})
.join('')}`,
'error'
);
}
if (pendingRounds.length) {
await addLog(
`未完成轮次:${pendingRounds.map((item) => `${item.round}`).join('')}`,
'warn'
);
}
}
async function skipAutoRunCountdown() {
const state = await getState();
const plan = getPendingAutoRunTimerPlan(state);
if (!plan || state.autoRunPhase !== 'waiting_interval') {
return false;
}
return launchAutoRunTimerPlan('manual', {
expectedKinds: [
AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS,
AUTO_RUN_TIMER_KIND_BEFORE_RETRY,
],
});
}
async function waitBetweenAutoRunRounds(targetRun, totalRuns, roundSummary, options = {}) {
const { autoRunSkipFailures = false, roundSummaries = [] } = options;
if (totalRuns <= 1 || targetRun >= totalRuns) {
return false;
}
const fallbackThreadIntervalMinutes = normalizeAutoRunFallbackThreadIntervalMinutes(
(await getState()).autoRunFallbackThreadIntervalMinutes
);
if (fallbackThreadIntervalMinutes <= 0) {
return false;
}
const currentRuntime = runtime.get();
const statusLabel = roundSummary?.status === 'failed' ? '失败' : '完成';
await addLog(
`线程间隔:第 ${targetRun}/${totalRuns} 轮已${statusLabel},等待 ${fallbackThreadIntervalMinutes} 分钟后开始下一轮。`,
'info'
);
await persistAutoRunTimerPlan({
kind: AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS,
fireAt: Date.now() + fallbackThreadIntervalMinutes * 60 * 1000,
currentRun: targetRun,
totalRuns,
attemptRun: currentRuntime.autoRunAttemptRun,
autoRunSkipFailures,
roundSummaries,
countdownTitle: '线程间隔中',
countdownNote: `${Math.min(targetRun + 1, totalRuns)}/${totalRuns} 轮即将开始`,
}, {
autoRunSkipFailures,
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
});
runtime.set({ autoRunActive: false });
return true;
}
async function waitBeforeAutoRunRetry(targetRun, totalRuns, nextAttemptRun, options = {}) {
const { autoRunSkipFailures = false, roundSummaries = [] } = options;
const fallbackThreadIntervalMinutes = normalizeAutoRunFallbackThreadIntervalMinutes(
(await getState()).autoRunFallbackThreadIntervalMinutes
);
if (fallbackThreadIntervalMinutes <= 0) {
return false;
}
await addLog(
`线程间隔:等待 ${fallbackThreadIntervalMinutes} 分钟后开始第 ${targetRun}/${totalRuns} 轮第 ${nextAttemptRun} 次尝试。`,
'info'
);
await persistAutoRunTimerPlan({
kind: AUTO_RUN_TIMER_KIND_BEFORE_RETRY,
fireAt: Date.now() + fallbackThreadIntervalMinutes * 60 * 1000,
currentRun: targetRun,
totalRuns,
attemptRun: nextAttemptRun,
autoRunSkipFailures,
roundSummaries,
countdownTitle: '线程间隔中',
countdownNote: `${targetRun}/${totalRuns} 轮第 ${nextAttemptRun} 次尝试即将开始`,
}, {
autoRunSkipFailures,
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
});
runtime.set({ autoRunActive: false });
return true;
}
async function handleAutoRunLoopUnhandledError(error) {
const currentRuntime = runtime.get();
console.error('Auto run loop crashed:', error);
if (!isStopError(error)) {
await addLog(`自动运行异常终止:${getErrorMessage(error) || '未知错误'}`, 'error');
}
runtime.set({ autoRunActive: false });
await broadcastAutoRunStatus('stopped', {
currentRun: currentRuntime.autoRunCurrentRun,
totalRuns: currentRuntime.autoRunTotalRuns,
attemptRun: currentRuntime.autoRunAttemptRun,
}, {
autoRunTimerPlan: null,
scheduledAutoRunPlan: null,
});
clearStopRequest();
}
function startAutoRunLoop(totalRuns, options = {}) {
autoRunLoop(totalRuns, options).catch((error) => {
handleAutoRunLoopUnhandledError(error).catch(() => {});
});
}
async function autoRunLoop(totalRuns, options = {}) {
let currentRuntime = runtime.get();
if (currentRuntime.autoRunActive) {
await addLog('自动运行已在进行中', 'warn');
return;
}
clearStopRequest();
runtime.set({
autoRunActive: true,
autoRunTotalRuns: totalRuns,
autoRunCurrentRun: 0,
autoRunAttemptRun: 0,
});
currentRuntime = runtime.get();
const autoRunSkipFailures = Boolean(options.autoRunSkipFailures);
const initialMode = options.mode === 'continue' ? 'continue' : 'restart';
const resumeCurrentRun = Number.isInteger(options.resumeCurrentRun) && options.resumeCurrentRun > 0
? Math.min(totalRuns, options.resumeCurrentRun)
: 1;
const resumeAttemptRun = Number.isInteger(options.resumeAttemptRun) && options.resumeAttemptRun > 0
? Math.min(AUTO_RUN_MAX_RETRIES_PER_ROUND + 1, options.resumeAttemptRun)
: 1;
let continueCurrentOnFirstAttempt = initialMode === 'continue';
let forceFreshTabsNextRun = false;
let stoppedEarly = false;
let parkedByTimer = false;
const roundSummaries = buildAutoRunRoundSummaries(totalRuns, options.resumeRoundSummaries);
if (continueCurrentOnFirstAttempt && resumeCurrentRun > 1) {
for (let round = 1; round < resumeCurrentRun; round += 1) {
const summary = roundSummaries[round - 1];
if (summary.status === 'pending') {
summary.status = 'success';
if (!summary.attempts) {
summary.attempts = 1;
}
}
}
}
let successfulRuns = roundSummaries.filter((item) => item.status === 'success').length;
const initialState = await getState();
const initialPhase = continueCurrentOnFirstAttempt && getRunningSteps(initialState.stepStatuses).length
? 'waiting_step'
: 'running';
const showResumePosition = continueCurrentOnFirstAttempt || resumeCurrentRun > 1 || resumeAttemptRun > 1;
await setState({
autoRunSkipFailures,
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
...getAutoRunStatusPayload(initialPhase, {
currentRun: showResumePosition ? resumeCurrentRun : 0,
totalRuns,
attemptRun: showResumePosition ? resumeAttemptRun : 0,
}),
});
for (let targetRun = resumeCurrentRun; targetRun <= totalRuns; targetRun += 1) {
const roundSummary = roundSummaries[targetRun - 1];
const resumingCurrentRound = continueCurrentOnFirstAttempt && targetRun === resumeCurrentRun;
let attemptRun = resumingCurrentRound ? resumeAttemptRun : 1;
let reuseExistingProgress = resumingCurrentRound;
const maxAttemptsForRound = autoRunSkipFailures
? AUTO_RUN_MAX_RETRIES_PER_ROUND + 1
: Math.max(1, attemptRun);
while (attemptRun <= maxAttemptsForRound) {
runtime.set({
autoRunCurrentRun: targetRun,
autoRunAttemptRun: attemptRun,
});
roundSummary.attempts = attemptRun;
let startStep = 1;
let useExistingProgress = false;
if (reuseExistingProgress) {
let currentState = await getState();
if (getRunningSteps(currentState.stepStatuses).length) {
currentState = await waitForRunningStepsToFinish({
currentRun: targetRun,
totalRuns,
attemptRun,
});
}
const resumeStep = getFirstUnfinishedStep(currentState.stepStatuses);
if (resumeStep && hasSavedProgress(currentState.stepStatuses)) {
startStep = resumeStep;
useExistingProgress = true;
} else if (hasSavedProgress(currentState.stepStatuses)) {
await addLog('检测到当前流程已处理完成,本轮将改为从步骤 1 重新开始。', 'info');
}
}
if (!useExistingProgress) {
const prevState = await getState();
const keepSettings = {
vpsUrl: prevState.vpsUrl,
vpsPassword: prevState.vpsPassword,
customPassword: prevState.customPassword,
autoRunSkipFailures: prevState.autoRunSkipFailures,
autoRunFallbackThreadIntervalMinutes: prevState.autoRunFallbackThreadIntervalMinutes,
autoRunDelayEnabled: prevState.autoRunDelayEnabled,
autoRunDelayMinutes: prevState.autoRunDelayMinutes,
autoStepDelaySeconds: prevState.autoStepDelaySeconds,
mailProvider: prevState.mailProvider,
emailGenerator: prevState.emailGenerator,
emailPrefix: prevState.emailPrefix,
inbucketHost: prevState.inbucketHost,
inbucketMailbox: prevState.inbucketMailbox,
cloudflareDomain: prevState.cloudflareDomain,
cloudflareDomains: prevState.cloudflareDomains,
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
tabRegistry: {},
sourceLastUrls: {},
...getAutoRunStatusPayload('running', { currentRun: targetRun, totalRuns, attemptRun }),
};
await resetState();
await setState(keepSettings);
deps.chrome.runtime.sendMessage({ type: 'AUTO_RUN_RESET' }).catch(() => { });
await sleepWithStop(500);
} else {
await setState({
autoRunSkipFailures,
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
...getAutoRunStatusPayload('running', { currentRun: targetRun, totalRuns, attemptRun }),
});
}
if (forceFreshTabsNextRun) {
await addLog(`上一轮尝试已放弃,当前开始第 ${targetRun}/${totalRuns} 轮第 ${attemptRun} 次尝试。`, 'warn');
forceFreshTabsNextRun = false;
}
try {
deps.throwIfStopped();
await broadcastAutoRunStatus('running', {
currentRun: targetRun,
totalRuns,
attemptRun,
});
await runAutoSequenceFromStep(startStep, {
targetRun,
totalRuns,
attemptRuns: attemptRun,
continued: useExistingProgress,
});
roundSummary.status = 'success';
roundSummary.finalFailureReason = '';
successfulRuns += 1;
await setState({
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
});
await addLog(`=== 第 ${targetRun}/${totalRuns} 轮完成(第 ${attemptRun} 次尝试成功)===`, 'ok');
break;
} catch (err) {
if (isStopError(err)) {
stoppedEarly = true;
await addLog(`${targetRun}/${totalRuns} 轮已被用户停止`, 'warn');
await broadcastAutoRunStatus('stopped', {
currentRun: targetRun,
totalRuns,
attemptRun,
});
break;
}
const reason = getErrorMessage(err);
roundSummary.failureReasons.push(reason);
const canRetry = autoRunSkipFailures && attemptRun < maxAttemptsForRound;
await setState({
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
});
if (canRetry) {
const retryIndex = attemptRun;
if (isRestartCurrentAttemptError(err)) {
await addLog(`${targetRun}/${totalRuns} 轮第 ${attemptRun} 次尝试需要整轮重开:${reason}`, 'warn');
} else {
await addLog(`${targetRun}/${totalRuns} 轮第 ${attemptRun} 次尝试失败:${reason}`, 'error');
}
cancelPendingCommands('当前尝试已放弃。');
await broadcastStopToContentScripts();
await broadcastAutoRunStatus('retrying', {
currentRun: targetRun,
totalRuns,
attemptRun,
});
forceFreshTabsNextRun = true;
await addLog(
`自动重试:${Math.round(AUTO_RUN_RETRY_DELAY_MS / 1000)} 秒后开始第 ${targetRun}/${totalRuns} 轮第 ${attemptRun + 1} 次尝试(第 ${retryIndex}/${AUTO_RUN_MAX_RETRIES_PER_ROUND} 次重试)。`,
'warn'
);
try {
await sleepWithStop(AUTO_RUN_RETRY_DELAY_MS);
} catch (sleepError) {
if (isStopError(sleepError)) {
stoppedEarly = true;
await addLog(`${targetRun}/${totalRuns} 轮已被用户停止`, 'warn');
await broadcastAutoRunStatus('stopped', {
currentRun: targetRun,
totalRuns,
attemptRun,
});
break;
}
throw sleepError;
}
try {
const parkedForRetry = await waitBeforeAutoRunRetry(targetRun, totalRuns, attemptRun + 1, {
autoRunSkipFailures,
roundSummaries,
});
if (parkedForRetry) {
parkedByTimer = true;
break;
}
} catch (sleepError) {
if (isStopError(sleepError)) {
stoppedEarly = true;
await addLog(`${targetRun}/${totalRuns} 轮已被用户停止`, 'warn');
await broadcastAutoRunStatus('stopped', {
currentRun: targetRun,
totalRuns,
attemptRun,
});
break;
}
throw sleepError;
}
attemptRun += 1;
reuseExistingProgress = false;
continue;
}
roundSummary.status = 'failed';
roundSummary.finalFailureReason = reason;
await setState({
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
});
if (!autoRunSkipFailures) {
cancelPendingCommands('当前轮执行失败。');
await broadcastStopToContentScripts();
await addLog('自动重试未开启,自动运行将在当前失败后停止。', 'warn');
stoppedEarly = true;
await broadcastAutoRunStatus('stopped', {
currentRun: targetRun,
totalRuns,
attemptRun,
});
break;
}
await addLog(`${targetRun}/${totalRuns} 轮最终失败:${reason}`, 'error');
await addLog(
targetRun < totalRuns
? `${targetRun}/${totalRuns} 轮已达到 ${AUTO_RUN_MAX_RETRIES_PER_ROUND} 次重试上限,继续下一轮。`
: `${targetRun}/${totalRuns} 轮已达到 ${AUTO_RUN_MAX_RETRIES_PER_ROUND} 次重试上限,本次自动运行结束。`,
'warn'
);
cancelPendingCommands('当前轮已达到重试上限。');
await broadcastStopToContentScripts();
forceFreshTabsNextRun = true;
break;
} finally {
reuseExistingProgress = false;
continueCurrentOnFirstAttempt = false;
}
}
if (stoppedEarly || parkedByTimer) {
break;
}
try {
const parkedForNextRound = await waitBetweenAutoRunRounds(targetRun, totalRuns, roundSummary, {
autoRunSkipFailures,
roundSummaries,
});
if (parkedForNextRound) {
parkedByTimer = true;
break;
}
} catch (sleepError) {
if (isStopError(sleepError)) {
stoppedEarly = true;
await addLog(`${targetRun}/${totalRuns} 轮已被用户停止`, 'warn');
await broadcastAutoRunStatus('stopped', {
currentRun: targetRun,
totalRuns,
attemptRun: runtime.get().autoRunAttemptRun,
});
break;
}
throw sleepError;
}
}
if (parkedByTimer) {
runtime.set({ autoRunActive: false });
clearStopRequest();
return;
}
await setState({
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
});
await logAutoRunFinalSummary(totalRuns, roundSummaries);
const finalRuntime = runtime.get();
if (deps.getStopRequested() || stoppedEarly) {
await addLog(`=== 已停止,完成 ${successfulRuns}/${finalRuntime.autoRunTotalRuns} 轮 ===`, 'warn');
await broadcastAutoRunStatus('stopped', {
currentRun: finalRuntime.autoRunCurrentRun,
totalRuns: finalRuntime.autoRunTotalRuns,
attemptRun: finalRuntime.autoRunAttemptRun,
});
} else {
await addLog(`=== 全部 ${finalRuntime.autoRunTotalRuns} 轮已执行完成,成功 ${successfulRuns} 轮 ===`, 'ok');
await broadcastAutoRunStatus('complete', {
currentRun: finalRuntime.autoRunTotalRuns,
totalRuns: finalRuntime.autoRunTotalRuns,
attemptRun: finalRuntime.autoRunAttemptRun,
});
}
runtime.set({ autoRunActive: false });
const afterRuntime = runtime.get();
await setState({
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
autoRunTimerPlan: null,
scheduledAutoRunPlan: null,
...getAutoRunStatusPayload(deps.getStopRequested() || stoppedEarly ? 'stopped' : 'complete', {
currentRun: deps.getStopRequested() || stoppedEarly ? afterRuntime.autoRunCurrentRun : afterRuntime.autoRunTotalRuns,
totalRuns: afterRuntime.autoRunTotalRuns,
attemptRun: afterRuntime.autoRunAttemptRun,
}),
});
clearStopRequest();
}
return {
autoRunLoop,
buildAutoRunRoundSummaries,
createAutoRunRoundSummary,
formatAutoRunFailureReasons,
getAutoRunRoundRetryCount,
handleAutoRunLoopUnhandledError,
logAutoRunFinalSummary,
normalizeAutoRunRoundSummary,
serializeAutoRunRoundSummaries,
skipAutoRunCountdown,
startAutoRunLoop,
waitBetweenAutoRunRounds,
waitBeforeAutoRunRetry,
};
}
return {
createAutoRunController,
};
});