diff --git a/background.js b/background.js index 48b997e..c6f1666 100644 --- a/background.js +++ b/background.js @@ -667,6 +667,9 @@ const PERSISTED_SETTING_DEFAULTS = { gopayHelperFailureStage: '', gopayHelperFailureDetail: '', gopayHelperTaskPayload: null, + gopayHelperTaskProgressSignature: '', + gopayHelperTaskProgressAt: 0, + gopayHelperTaskProgressTaskId: '', gopayHelperBalance: '', gopayHelperBalancePayload: null, gopayHelperBalanceUpdatedAt: 0, @@ -836,6 +839,9 @@ const DEFAULT_STATE = { gopayHelperFailureDetail: '', gopayHelperTaskPayload: null, gopayHelperOrderCreatedAt: 0, + gopayHelperTaskProgressSignature: '', + gopayHelperTaskProgressAt: 0, + gopayHelperTaskProgressTaskId: '', gopayHelperPinPayload: null, gopayHelperResolvedOtp: '', gopayHelperOtpRequestId: '', @@ -2939,7 +2945,10 @@ async function setEmailStateSilently(email) { async function setEmailState(email) { await setEmailStateSilently(email); if (email) { - await appendManualAccountRunRecordIfNeeded('step2_stopped', null, '步骤 2 已使用邮箱,流程尚未完成。'); + const latestState = await getState(); + const recordStatus = shouldMarkAccountRunRecordRunning(latestState) ? 'running' : 'step2_stopped'; + const recordReason = recordStatus === 'running' ? '正在运行' : '步骤 2 已使用邮箱,流程尚未完成。'; + await appendManualAccountRunRecordIfNeeded(recordStatus, latestState, recordReason); await resumeAutoRunIfWaitingForEmail(); } } @@ -2979,10 +2988,19 @@ async function setSignupPhoneStateSilently(phoneNumber) { async function setSignupPhoneState(phoneNumber) { await setSignupPhoneStateSilently(phoneNumber); if (String(phoneNumber || '').trim()) { - await appendManualAccountRunRecordIfNeeded('step2_stopped', null, '步骤 2 已使用手机号,流程尚未完成。'); + const latestState = await getState(); + const recordStatus = shouldMarkAccountRunRecordRunning(latestState) ? 'running' : 'step2_stopped'; + const recordReason = recordStatus === 'running' ? '正在运行' : '步骤 2 已使用手机号,流程尚未完成。'; + await appendManualAccountRunRecordIfNeeded(recordStatus, latestState, recordReason); } } +function shouldMarkAccountRunRecordRunning(state = {}) { + const phase = String(state.autoRunPhase || '').trim().toLowerCase(); + return Boolean(state.autoRunning) + && ['running', 'waiting_step', 'waiting_email', 'retrying'].includes(phase); +} + async function setPasswordState(password) { await setState({ password }); broadcastDataUpdate({ password }); @@ -7523,7 +7541,8 @@ function getErrorMessage(error) { return loggingStatus.getErrorMessage(error); } return String(typeof error === 'string' ? error : error?.message || '') - .replace(/^GPC_TASK_ENDED::/i, ''); + .replace(/^GPC_TASK_ENDED::/i, '') + .replace(/^AUTO_RUN_STEP_IDLE_RESTART::/i, ''); } function isCloudflareSecurityBlockedError(error) { @@ -7843,6 +7862,9 @@ function getDownstreamStateResets(step, state = {}) { gopayHelperFailureDetail: '', gopayHelperTaskPayload: null, gopayHelperOrderCreatedAt: 0, + gopayHelperTaskProgressSignature: '', + gopayHelperTaskProgressAt: 0, + gopayHelperTaskProgressTaskId: '', gopayHelperPinPayload: null, gopayHelperResolvedOtp: '', gopayHelperOtpRequestId: '', @@ -8839,6 +8861,10 @@ async function handleStepData(step, payload) { const stepWaiters = new Map(); let resumeWaiter = null; const AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS = 120000; +const AUTO_RUN_STEP_IDLE_LOG_TIMEOUT_MS = 5 * 60 * 1000; +const AUTO_RUN_STEP_IDLE_LOG_CHECK_INTERVAL_MS = 5000; +const AUTO_RUN_STEP_IDLE_RESTART_MAX_ATTEMPTS = 3; +const AUTO_RUN_STEP_IDLE_RESTART_ERROR_PREFIX = 'AUTO_RUN_STEP_IDLE_RESTART::'; const AUTO_RUN_BACKGROUND_COMPLETED_STEPS = new Set([1, 2, 4, 6, 7, 8, 9]); const STEP_COMPLETION_SIGNAL_STEPS = new Set([3, 5, 10, 12]); const AUTO_RUN_BACKGROUND_COMPLETED_STEP_KEYS = new Set([ @@ -9078,6 +9104,120 @@ async function executeStepViaCompletionSignal(step, timeoutMs = 0) { throw completionResult.error; } +function getLatestLogTimestamp(logs = [], fallback = 0) { + if (!Array.isArray(logs) || !logs.length) { + return Number.isFinite(Number(fallback)) ? Number(fallback) : 0; + } + return logs.reduce((latest, entry) => { + const timestamp = Number(entry?.timestamp); + return Number.isFinite(timestamp) && timestamp > latest ? timestamp : latest; + }, Number.isFinite(Number(fallback)) ? Number(fallback) : 0); +} + +function buildAutoRunStepIdleRestartError(step, idleMs = AUTO_RUN_STEP_IDLE_LOG_TIMEOUT_MS) { + const seconds = Math.max(1, Math.round((Number(idleMs) || AUTO_RUN_STEP_IDLE_LOG_TIMEOUT_MS) / 1000)); + const error = new Error(`${AUTO_RUN_STEP_IDLE_RESTART_ERROR_PREFIX}步骤 ${step} 已连续 ${seconds} 秒没有新日志,准备重新开始当前步骤。`); + error.autoRunStepIdleRestart = true; + error.failedStep = Math.floor(Number(step) || 0); + return error; +} + +function isAutoRunStepIdleRestartError(error) { + const message = String(typeof error === 'string' ? error : error?.message || ''); + return Boolean(error?.autoRunStepIdleRestart) || message.startsWith(AUTO_RUN_STEP_IDLE_RESTART_ERROR_PREFIX); +} + +function startAutoRunStepIdleLogWatchdog(step, options = {}) { + const idleTimeoutMs = Math.max(1000, Math.floor(Number(options.idleTimeoutMs) || AUTO_RUN_STEP_IDLE_LOG_TIMEOUT_MS)); + const checkIntervalMs = Math.max(250, Math.min(idleTimeoutMs, Math.floor(Number(options.checkIntervalMs) || AUTO_RUN_STEP_IDLE_LOG_CHECK_INTERVAL_MS))); + let cancelled = false; + let timer = null; + let lastActivityAt = Date.now(); + + const promise = new Promise((_, reject) => { + const schedule = () => { + if (cancelled) { + return; + } + const idleForMs = Math.max(0, Date.now() - lastActivityAt); + const delayMs = Math.max(50, Math.min(checkIntervalMs, idleTimeoutMs - idleForMs)); + timer = setTimeout(check, delayMs); + }; + + const check = async () => { + if (cancelled) { + return; + } + try { + const state = await getState(); + if (state?.plusManualConfirmationPending) { + lastActivityAt = Date.now(); + schedule(); + return; + } + + const latestLogAt = getLatestLogTimestamp(state?.logs || [], lastActivityAt); + if (latestLogAt > lastActivityAt) { + lastActivityAt = latestLogAt; + } + + const idleForMs = Date.now() - lastActivityAt; + if (idleForMs >= idleTimeoutMs) { + reject(buildAutoRunStepIdleRestartError(step, idleForMs)); + return; + } + } catch (_err) { + // Watchdog read failures should not break the real step; retry the check. + } + schedule(); + }; + + schedule(); + }); + + return { + promise, + cancel() { + cancelled = true; + if (timer) { + clearTimeout(timer); + } + }, + }; +} + +async function runAutoStepActionWithIdleLogWatchdog(step, action, options = {}) { + const executionPromise = Promise.resolve().then(action); + const watchdog = startAutoRunStepIdleLogWatchdog(step, options); + try { + return await Promise.race([ + executionPromise, + watchdog.promise, + ]); + } catch (error) { + if (isAutoRunStepIdleRestartError(error)) { + void executionPromise.catch((lateError) => { + const lateMessage = getErrorMessage(lateError); + if (!lateMessage || isStopError(lateError) || isAutoRunStepIdleRestartError(lateError)) { + return; + } + addLog(`步骤 ${step}:无日志重开后收到原执行失败:${lateMessage}`, 'warn').catch(() => {}); + }); + } + throw error; + } finally { + watchdog.cancel(); + } +} + +async function executeStepAndWaitWithAutoRunIdleLogWatchdog(step, delayAfter = 2000, options = {}) { + return runAutoStepActionWithIdleLogWatchdog( + step, + () => executeStepAndWait(step, delayAfter), + options + ); +} + async function waitForRunningStepsToFinish(payload = {}) { let currentState = await getState(); let runningSteps = getRunningSteps(currentState.stepStatuses, currentState); @@ -10451,6 +10591,7 @@ async function runAutoSequenceFromStep(startStep, context = {}) { let goPayCheckoutRestartCount = 0; let gpcCheckoutRestartCount = 0; let step4RestartCount = 0; + const stepIdleRestartCounts = new Map(); let currentStartStep = startStep; let continueCurrentAttempt = continued; const resolvedSignupMethod = await ensureResolvedSignupMethodForRun(); @@ -10476,6 +10617,39 @@ async function runAutoSequenceFromStep(startStep, context = {}) { return error; }; + const restartCurrentStepAfterIdle = async (step, error) => { + if (!isAutoRunStepIdleRestartError(error)) { + return false; + } + + const idleRestartCount = (stepIdleRestartCounts.get(step) || 0) + 1; + stepIdleRestartCounts.set(step, idleRestartCount); + if (idleRestartCount > AUTO_RUN_STEP_IDLE_RESTART_MAX_ATTEMPTS) { + await addLog( + `步骤 ${step}:已连续 ${AUTO_RUN_STEP_IDLE_RESTART_MAX_ATTEMPTS} 次因 5 分钟无新日志而重开,停止自动重试。原因:${getErrorMessage(error)}`, + 'error' + ); + throw error; + } + + const reason = getErrorMessage(error); + if (typeof cancelPendingCommands === 'function') { + cancelPendingCommands(`步骤 ${step} 5 分钟没有新日志,准备重开当前步骤。`); + } + if (typeof broadcastStopToContentScripts === 'function') { + await broadcastStopToContentScripts(); + } + await addLog( + `步骤 ${step}:5 分钟没有新日志,准备重新开始当前步骤(第 ${idleRestartCount}/${AUTO_RUN_STEP_IDLE_RESTART_MAX_ATTEMPTS} 次)。原因:${reason}`, + 'warn' + ); + await invalidateDownstreamAfterStepRestart(Math.max(0, step - 1), { + logLabel: `步骤 ${step} 因 5 分钟无新日志准备重开(第 ${idleRestartCount}/${AUTO_RUN_STEP_IDLE_RESTART_MAX_ATTEMPTS} 次)`, + }); + currentStartStep = step; + continueCurrentAttempt = true; + return true; + }; while (true) { @@ -10486,16 +10660,40 @@ async function runAutoSequenceFromStep(startStep, context = {}) { } if (currentStartStep <= 1) { - await executeStepAndWait(1, AUTO_STEP_DELAYS[1]); + try { + await executeStepAndWaitWithAutoRunIdleLogWatchdog(1, AUTO_STEP_DELAYS[1]); + } catch (err) { + attachFailedStep(err, 1); + if (isStopError(err)) { + throw err; + } + if (await restartCurrentStepAfterIdle(1, err)) { + continue; + } + throw err; + } } if (currentStartStep <= 2) { - if (resolvedSignupMethod === SIGNUP_METHOD_PHONE) { - await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:本轮注册方式为手机号注册,将跳过邮箱预获取 ===`, 'info'); - } else { - await ensureAutoEmailReady(targetRun, totalRuns, attemptRuns); + try { + await runAutoStepActionWithIdleLogWatchdog(2, async () => { + if (resolvedSignupMethod === SIGNUP_METHOD_PHONE) { + await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:本轮注册方式为手机号注册,将跳过邮箱预获取 ===`, 'info'); + } else { + await ensureAutoEmailReady(targetRun, totalRuns, attemptRuns); + } + await executeStepAndWait(2, AUTO_STEP_DELAYS[2]); + }); + } catch (err) { + attachFailedStep(err, 2); + if (isStopError(err)) { + throw err; + } + if (await restartCurrentStepAfterIdle(2, err)) { + continue; + } + throw err; } - await executeStepAndWait(2, AUTO_STEP_DELAYS[2]); } let restartFromStep1WithCurrentEmail = false; @@ -10513,12 +10711,15 @@ async function runAutoSequenceFromStep(startStep, context = {}) { await addLog(`自动运行:步骤 3 当前状态为 ${step3Status},将直接继续后续流程。`, 'info'); } else { try { - await executeStepAndWait(3, AUTO_STEP_DELAYS[3]); + await executeStepAndWaitWithAutoRunIdleLogWatchdog(3, AUTO_STEP_DELAYS[3]); } catch (err) { attachFailedStep(err, 3); if (isStopError(err)) { throw err; } + if (await restartCurrentStepAfterIdle(3, err)) { + continue; + } if (isSignupPhonePasswordMismatchFailure(err)) { step4RestartCount += 1; await restartSignupPhonePasswordMismatchAttemptFromStep(3, step4RestartCount, err); @@ -10559,7 +10760,7 @@ async function runAutoSequenceFromStep(startStep, context = {}) { continue; } try { - await executeStepAndWait(step, AUTO_STEP_DELAYS[step]); + await executeStepAndWaitWithAutoRunIdleLogWatchdog(step, AUTO_STEP_DELAYS[step]); step += 1; } catch (err) { attachFailedStep(err, step); @@ -10567,6 +10768,10 @@ async function runAutoSequenceFromStep(startStep, context = {}) { throw err; } + if (await restartCurrentStepAfterIdle(step, err)) { + continue; + } + const stepExecutionKey = typeof getStepExecutionKeyForState === 'function' ? getStepExecutionKeyForState(step, latestState) : ''; diff --git a/background/account-run-history.js b/background/account-run-history.js index e80908a..773fa4a 100644 --- a/background/account-run-history.js +++ b/background/account-run-history.js @@ -30,6 +30,9 @@ if (normalized === 'success') { return 'success'; } + if (normalized === 'running' || /_running$/.test(normalized)) { + return 'running'; + } if (normalized === 'failed' || /_failed$/.test(normalized)) { return 'failed'; } @@ -157,6 +160,9 @@ if (finalStatus === 'success') { return '流程完成'; } + if (finalStatus === 'running') { + return '正在运行'; + } if (finalStatus === 'stopped') { if (Number.isInteger(failedStep) && failedStep > 0) { return `步骤 ${failedStep} 停止`; @@ -519,6 +525,8 @@ summary.total += 1; if (record.finalStatus === 'success') { summary.success += 1; + } else if (record.finalStatus === 'running') { + summary.running += 1; } else if (record.finalStatus === 'failed') { summary.failed += 1; } else if (record.finalStatus === 'stopped') { @@ -529,6 +537,7 @@ }, { total: 0, success: 0, + running: 0, failed: 0, stopped: 0, retryTotal: 0, diff --git a/background/logging-status.js b/background/logging-status.js index 06a3145..b86afa8 100644 --- a/background/logging-status.js +++ b/background/logging-status.js @@ -73,7 +73,8 @@ function getErrorMessage(error) { return String(typeof error === 'string' ? error : error?.message || '') - .replace(/^GPC_TASK_ENDED::/i, ''); + .replace(/^GPC_TASK_ENDED::/i, '') + .replace(/^AUTO_RUN_STEP_IDLE_RESTART::/i, ''); } function isVerificationMailPollingError(error) { diff --git a/background/steps/create-plus-checkout.js b/background/steps/create-plus-checkout.js index a89dfc1..6e5d150 100644 --- a/background/steps/create-plus-checkout.js +++ b/background/steps/create-plus-checkout.js @@ -413,6 +413,9 @@ gopayHelperRemoteStage: result.remoteStage, gopayHelperPhoneMode: result.phoneMode || normalizeGpcHelperPhoneMode(state?.gopayHelperPhoneMode || state?.phoneMode), gopayHelperTaskPayload: result.responsePayload, + gopayHelperTaskProgressSignature: '', + gopayHelperTaskProgressAt: 0, + gopayHelperTaskProgressTaskId: result.taskId, gopayHelperReferenceId: '', gopayHelperGoPayGuid: '', gopayHelperRedirectUrl: '', diff --git a/background/steps/fill-plus-checkout.js b/background/steps/fill-plus-checkout.js index b69ef78..11f1f92 100644 --- a/background/steps/fill-plus-checkout.js +++ b/background/steps/fill-plus-checkout.js @@ -795,11 +795,9 @@ task?.api_waiting_for, task?.last_input_error, task?.otp_invalid_count, - task?.reference_id || task?.referenceId, - task?.redirect_url || task?.redirectUrl, - task?.flow_id || task?.flowId, - task?.challenge_id || task?.challengeId, - task?.gopay_guid || task?.gopayGuid, + task?.failure_stage || task?.failureStage, + task?.failure_detail || task?.failureDetail, + task?.error_message || task?.errorMessage, ].map((value) => String(value ?? '').trim()).join('|'); } @@ -898,6 +896,9 @@ gopayHelperFailureStage: '', gopayHelperFailureDetail: '', gopayHelperTaskPayload: null, + gopayHelperTaskProgressSignature: '', + gopayHelperTaskProgressAt: 0, + gopayHelperTaskProgressTaskId: '', gopayHelperPinPayload: null, gopayHelperResolvedOtp: '', gopayHelperOtpRequestId: '', @@ -971,8 +972,14 @@ let lastSubmittedOtp = ''; let pinSubmitted = false; let terminalReached = false; - let lastProgressSignature = ''; - let lastProgressAt = Date.now(); + let lastProgressSignature = String(state?.gopayHelperTaskProgressSignature || '').trim(); + let lastProgressAt = normalizeEpochMilliseconds(state?.gopayHelperTaskProgressAt || 0) || Date.now(); + let lastProgressTaskId = String(state?.gopayHelperTaskProgressTaskId || '').trim(); + if (lastProgressTaskId !== taskId) { + lastProgressSignature = ''; + lastProgressAt = Date.now(); + lastProgressTaskId = ''; + } const staleStatusTimeoutMs = getGpcTaskStaleStatusTimeoutMs(state); if (!taskId) { @@ -1025,15 +1032,27 @@ if (shouldWatchGpcTaskProgress(task, state)) { const progressSignature = buildGpcTaskProgressSignature(task); const now = Date.now(); - if (progressSignature && progressSignature !== lastProgressSignature) { + if (progressSignature && (progressSignature !== lastProgressSignature || lastProgressTaskId !== taskId)) { lastProgressSignature = progressSignature; lastProgressAt = now; + lastProgressTaskId = taskId; + await setState({ + gopayHelperTaskProgressSignature: progressSignature, + gopayHelperTaskProgressAt: now, + gopayHelperTaskProgressTaskId: taskId, + }); } else if (progressSignature && now - lastProgressAt >= staleStatusTimeoutMs) { throw buildGpcTaskStaleStatusError(task, staleStatusTimeoutMs); } } else { lastProgressSignature = ''; lastProgressAt = Date.now(); + lastProgressTaskId = ''; + await setState({ + gopayHelperTaskProgressSignature: '', + gopayHelperTaskProgressAt: 0, + gopayHelperTaskProgressTaskId: taskId, + }); } if (isGpcTaskOtpWait(task, state)) { diff --git a/sidepanel/account-records-manager.js b/sidepanel/account-records-manager.js index 0a5f476..33d1d92 100644 --- a/sidepanel/account-records-manager.js +++ b/sidepanel/account-records-manager.js @@ -21,19 +21,25 @@ success: { label: '成', className: 'is-success', - matches: (record) => record.finalStatus === 'success', + matches: (record) => getRecordDisplayStatus(record) === 'success', metaLabel: '成功', }, + running: { + label: '运行', + className: 'is-running', + matches: (record) => getRecordDisplayStatus(record) === 'running', + metaLabel: '运行中', + }, failed: { label: '失', className: 'is-failed', - matches: (record) => record.finalStatus === 'failed', + matches: (record) => getRecordDisplayStatus(record) === 'failed', metaLabel: '失败', }, stopped: { label: '停', className: 'is-stopped', - matches: (record) => record.finalStatus === 'stopped', + matches: (record) => getRecordDisplayStatus(record) === 'stopped', metaLabel: '停止', }, retry: { @@ -96,6 +102,58 @@ : identifier.toLowerCase(); } + function getRecordDisplayStatus(record = {}) { + return String(record.displayStatus || record.finalStatus || '').trim().toLowerCase(); + } + + function isAutoRunRecordDisplayRunning(currentState = {}) { + const phase = String(currentState.autoRunPhase || '').trim().toLowerCase(); + return Boolean(currentState.autoRunning) + && ['running', 'waiting_step', 'waiting_email', 'retrying'].includes(phase); + } + + function buildCurrentAccountRecordId(currentState = {}) { + const accountIdentifierType = String(currentState.accountIdentifierType || '').trim().toLowerCase(); + const email = String(currentState.email || '').trim(); + const phoneNumber = String( + currentState.signupPhoneNumber + || currentState.phoneNumber + || currentState.phone + || '' + ).trim(); + const accountIdentifier = String( + currentState.accountIdentifier + || (accountIdentifierType === 'phone' ? phoneNumber : email) + || '' + ).trim(); + return buildRecordId({ + accountIdentifierType, + accountIdentifier, + email, + phoneNumber, + }); + } + + function applyRunningDisplayState(record = {}, currentState = {}) { + if (!isAutoRunRecordDisplayRunning(currentState)) { + return record; + } + if (getRecordDisplayStatus(record) === 'success') { + return record; + } + + const currentRecordId = buildCurrentAccountRecordId(currentState); + if (!currentRecordId || buildRecordId(record) !== currentRecordId) { + return record; + } + + return { + ...record, + displayStatus: 'running', + displaySummary: '正在运行', + }; + } + function getRecordIdentifierType(record = {}) { const rawType = String(record.accountIdentifierType || '').trim().toLowerCase(); if (rawType === 'phone') { @@ -167,18 +225,22 @@ return (Array.isArray(currentState?.accountRunHistory) ? currentState.accountRunHistory : []) .filter((item) => item && typeof item === 'object') .slice() - .sort((left, right) => normalizeTimestamp(right.finishedAt) - normalizeTimestamp(left.finishedAt)); + .sort((left, right) => normalizeTimestamp(right.finishedAt) - normalizeTimestamp(left.finishedAt)) + .map((record) => applyRunningDisplayState(record, currentState)); } function summarizeAccountRunHistory(records = []) { return records.reduce((summary, record) => { const retryCount = normalizeRetryCount(record.retryCount); + const status = getRecordDisplayStatus(record); summary.total += 1; - if (record.finalStatus === 'success') { + if (status === 'success') { summary.success += 1; - } else if (record.finalStatus === 'failed') { + } else if (status === 'running') { + summary.running += 1; + } else if (status === 'failed') { summary.failed += 1; - } else if (record.finalStatus === 'stopped') { + } else if (status === 'stopped') { summary.stopped += 1; } if (retryCount > 0) { @@ -189,6 +251,7 @@ }, { total: 0, success: 0, + running: 0, failed: 0, stopped: 0, retryRecordCount: 0, @@ -227,21 +290,44 @@ } function getStatusMeta(record = {}) { - if (record.finalStatus === 'success') { + const status = getRecordDisplayStatus(record); + if (status === 'success') { return { kind: 'success', label: '成功' }; } - if (record.finalStatus === 'stopped') { + if (status === 'running') { + return { kind: 'running', label: '正在运行' }; + } + if (status === 'stopped') { return { kind: 'stopped', label: '停止' }; } return { kind: 'failed', label: '失败' }; } function getRecordSummaryText(record = {}) { - if (record.finalStatus === 'success') { + const status = getRecordDisplayStatus(record); + if (record.displaySummary) { + return String(record.displaySummary || '').trim(); + } + if (status === 'success') { return '流程完成'; } + if (status === 'running') { + return '正在运行'; + } - return String(record.failureLabel || '').trim() || '流程失败'; + return String(record.failureDetail || record.reason || '').trim() + || String(record.failureLabel || '').trim() + || '流程失败'; + } + + function getRecordTooltipText(record = {}, summaryText = '') { + const recordTitle = getRecordTitle(record); + const status = getRecordDisplayStatus(record); + const detail = String(record.displaySummary || record.failureDetail || record.reason || '').trim(); + if (status === 'success' || status === 'running' || !detail || detail === recordTitle) { + return recordTitle; + } + return `${recordTitle}\n${detail}`; } function getFilterConfig(filterKey = activeFilter) { @@ -378,6 +464,7 @@ const summary = summarizeAccountRunHistory(allRecords); dom.accountRecordsStats.innerHTML = [ createStatChip('all', summary.total), + createStatChip('running', summary.running), createStatChip('success', summary.success), createStatChip('failed', summary.failed), createStatChip('stopped', summary.stopped), @@ -449,9 +536,9 @@ const recordId = buildRecordId(record); const primaryIdentifier = getRecordPrimaryIdentifier(record) || '(空账号)'; const secondaryIdentifier = getRecordSecondaryIdentifier(record); - const recordTitle = getRecordTitle(record); const statusMeta = getStatusMeta(record); const summaryText = getRecordSummaryText(record); + const recordTitle = getRecordTooltipText(record, summaryText); const retryCount = normalizeRetryCount(record.retryCount); const isSelected = selectedRecordIds.has(recordId); const itemClassNames = [ diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index a661317..b677d63 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -2985,6 +2985,12 @@ header { border-color: color-mix(in srgb, var(--green) 16%, var(--border)); } +.account-records-stat.is-running { + color: var(--blue); + background: var(--blue-soft); + border-color: color-mix(in srgb, var(--blue) 16%, var(--border)); +} + .account-records-stat.is-failed { color: var(--red); background: var(--red-soft); @@ -3158,8 +3164,11 @@ header { min-width: 0; flex: 1; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow-wrap: anywhere; + white-space: normal; color: var(--text-secondary); font-size: 11px; line-height: 1.4; @@ -3186,6 +3195,15 @@ header { color: var(--green); } +.account-record-item.is-running { + border-color: color-mix(in srgb, var(--blue) 14%, var(--border-subtle)); +} + +.account-record-item.is-running .account-record-item-status { + background: var(--blue-soft); + color: var(--blue); +} + .account-record-item.is-failed { border-color: color-mix(in srgb, var(--red) 14%, var(--border-subtle)); } diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 5a1e377..2760ce9 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -2065,6 +2065,23 @@ function syncLatestState(nextState) { renderAccountRecords(latestState); } +let accountRunHistoryRefreshTimer = null; + +function scheduleAccountRunHistoryRefresh(delayMs = 150) { + if (accountRunHistoryRefreshTimer) { + clearTimeout(accountRunHistoryRefreshTimer); + } + accountRunHistoryRefreshTimer = setTimeout(() => { + accountRunHistoryRefreshTimer = null; + chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' }).then(state => { + syncLatestState(state); + syncAutoRunState(state); + updateStatusDisplay(latestState); + updateButtonStates(); + }).catch(() => { }); + }, Math.max(0, Number(delayMs) || 0)); +} + function normalizeOperationDelayEnabled(value) { return typeof value === 'boolean' ? value : true; } @@ -13380,6 +13397,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { appendLog(message.payload); if (message.payload.level === 'error') { showToast(message.payload.message, 'error'); + scheduleAccountRunHistoryRefresh(); } break; @@ -14135,6 +14153,9 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { applyAutoRunStatus(message.payload); updateStatusDisplay(latestState); updateButtonStates(); + if (!['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(message.payload.phase)) { + scheduleAccountRunHistoryRefresh(); + } break; } } diff --git a/tests/auto-run-step4-mail2925-thread-terminate.test.js b/tests/auto-run-step4-mail2925-thread-terminate.test.js index b1179f4..0e357a7 100644 --- a/tests/auto-run-step4-mail2925-thread-terminate.test.js +++ b/tests/auto-run-step4-mail2925-thread-terminate.test.js @@ -53,10 +53,20 @@ function extractFunction(name) { } const bundle = [ + 'const AUTO_RUN_STEP_IDLE_LOG_TIMEOUT_MS = 300000;', + 'const AUTO_RUN_STEP_IDLE_LOG_CHECK_INTERVAL_MS = 5000;', + 'const AUTO_RUN_STEP_IDLE_RESTART_MAX_ATTEMPTS = 3;', + "const AUTO_RUN_STEP_IDLE_RESTART_ERROR_PREFIX = 'AUTO_RUN_STEP_IDLE_RESTART::';", extractFunction('isAddPhoneAuthUrl'), extractFunction('isAddPhoneAuthState'), extractFunction('isMail2925ThreadTerminatedError'), extractFunction('isSignupUserAlreadyExistsFailure'), + extractFunction('getLatestLogTimestamp'), + extractFunction('buildAutoRunStepIdleRestartError'), + extractFunction('isAutoRunStepIdleRestartError'), + extractFunction('startAutoRunStepIdleLogWatchdog'), + extractFunction('runAutoStepActionWithIdleLogWatchdog'), + extractFunction('executeStepAndWaitWithAutoRunIdleLogWatchdog'), extractFunction('getPostStep6AutoRestartDecision'), extractFunction('runAutoSequenceFromStep'), ].join('\n'); diff --git a/tests/auto-run-step4-restart.test.js b/tests/auto-run-step4-restart.test.js index 823e00c..a4a52f2 100644 --- a/tests/auto-run-step4-restart.test.js +++ b/tests/auto-run-step4-restart.test.js @@ -53,6 +53,10 @@ function extractFunction(name) { } const bundle = [ + 'const AUTO_RUN_STEP_IDLE_LOG_TIMEOUT_MS = 300000;', + 'const AUTO_RUN_STEP_IDLE_LOG_CHECK_INTERVAL_MS = 5000;', + 'const AUTO_RUN_STEP_IDLE_RESTART_MAX_ATTEMPTS = 3;', + "const AUTO_RUN_STEP_IDLE_RESTART_ERROR_PREFIX = 'AUTO_RUN_STEP_IDLE_RESTART::';", extractFunction('isAddPhoneAuthUrl'), extractFunction('isAddPhoneAuthState'), extractFunction('isMail2925ThreadTerminatedError'), @@ -60,6 +64,12 @@ const bundle = [ extractFunction('getSignupPhonePasswordMismatchRestartPayload'), extractFunction('restartSignupPhonePasswordMismatchAttemptFromStep'), extractFunction('isSignupUserAlreadyExistsFailure'), + extractFunction('getLatestLogTimestamp'), + extractFunction('buildAutoRunStepIdleRestartError'), + extractFunction('isAutoRunStepIdleRestartError'), + extractFunction('startAutoRunStepIdleLogWatchdog'), + extractFunction('runAutoStepActionWithIdleLogWatchdog'), + extractFunction('executeStepAndWaitWithAutoRunIdleLogWatchdog'), extractFunction('getPostStep6AutoRestartDecision'), extractFunction('runAutoSequenceFromStep'), ].join('\n'); diff --git a/tests/auto-run-step6-restart.test.js b/tests/auto-run-step6-restart.test.js index 480db22..be48a38 100644 --- a/tests/auto-run-step6-restart.test.js +++ b/tests/auto-run-step6-restart.test.js @@ -57,6 +57,12 @@ const bundle = [ extractFunction('isAddPhoneAuthUrl'), extractFunction('isAddPhoneAuthState'), extractFunction('isGpcCheckoutRestartRequiredFailure'), + extractFunction('getLatestLogTimestamp'), + extractFunction('buildAutoRunStepIdleRestartError'), + extractFunction('isAutoRunStepIdleRestartError'), + extractFunction('startAutoRunStepIdleLogWatchdog'), + extractFunction('runAutoStepActionWithIdleLogWatchdog'), + extractFunction('executeStepAndWaitWithAutoRunIdleLogWatchdog'), extractFunction('getPostStep6AutoRestartDecision'), extractFunction('runAutoSequenceFromStep'), ].join('\n'); @@ -86,6 +92,10 @@ function createHarness(options = {}) { stepIds = Object.keys(stepDefinitions).map(Number).sort((a, b) => a - b), lastStepId = Math.max(...stepIds), finalOAuthChainStartStep = 7, + idleLogTimeoutMs = 300000, + idleLogCheckIntervalMs = 5000, + hangStep = 0, + hangBudget = 0, } = options; return new Function(` @@ -94,6 +104,10 @@ const LAST_STEP_ID = ${JSON.stringify(lastStepId)}; const FINAL_OAUTH_CHAIN_START_STEP = ${JSON.stringify(finalOAuthChainStartStep)}; const SIGNUP_METHOD_PHONE = 'phone'; const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper'; +const AUTO_RUN_STEP_IDLE_LOG_TIMEOUT_MS = ${JSON.stringify(idleLogTimeoutMs)}; +const AUTO_RUN_STEP_IDLE_LOG_CHECK_INTERVAL_MS = ${JSON.stringify(idleLogCheckIntervalMs)}; +const AUTO_RUN_STEP_IDLE_RESTART_MAX_ATTEMPTS = 3; +const AUTO_RUN_STEP_IDLE_RESTART_ERROR_PREFIX = 'AUTO_RUN_STEP_IDLE_RESTART::'; const LOG_PREFIX = '[test]'; const chrome = { tabs: { @@ -102,14 +116,17 @@ const chrome = { }; let remainingFailures = ${JSON.stringify(failureBudget)}; +let remainingHangs = ${JSON.stringify(hangBudget)}; const events = { steps: [], logs: [], invalidations: [], + cancellations: [], + stopBroadcasts: 0, }; async function addLog(message, level = 'info') { - events.logs.push({ message, level }); + events.logs.push({ message, level, timestamp: Date.now() }); } async function ensureAutoEmailReady() {} @@ -119,6 +136,7 @@ async function getState() { return { stepStatuses: { 3: 'completed' }, mailProvider: '163', + logs: events.logs, ...${JSON.stringify(customState)}, }; } @@ -140,6 +158,10 @@ function isStepDoneStatus(status) { } async function executeStepAndWait(step) { events.steps.push(step); + if (step === ${JSON.stringify(hangStep)} && remainingHangs > 0) { + remainingHangs -= 1; + return new Promise(() => {}); + } if (step === ${JSON.stringify(failureStep)} && remainingFailures > 0) { remainingFailures -= 1; throw new Error(${JSON.stringify(failureMessage)}); @@ -151,6 +173,12 @@ async function getTabId() { async function invalidateDownstreamAfterStepRestart(step, options = {}) { events.invalidations.push({ step, options }); } +function cancelPendingCommands(reason = '') { + events.cancellations.push(reason); +} +async function broadcastStopToContentScripts() { + events.stopBroadcasts += 1; +} function getLoginAuthStateLabel(state) { return state || 'unknown'; } @@ -224,6 +252,62 @@ test('auto-run keeps restarting from step 7 after post-login failures without a assert.ok(events.logs.some(({ message }) => /回到步骤 7 重新开始授权流程/.test(message))); }); +test('auto-run restarts the current step after five minutes without new logs', async () => { + const harness = createHarness({ + startStep: 10, + failureStep: 0, + hangStep: 10, + hangBudget: 1, + idleLogTimeoutMs: 20, + idleLogCheckIntervalMs: 5, + }); + + const events = await harness.run(); + + assert.deepStrictEqual(events.steps, [10, 10]); + assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [9]); + assert.equal(events.cancellations.length, 1); + assert.equal(events.stopBroadcasts, 1); + assert.ok(events.logs.some(({ message }) => /5 分钟没有新日志,准备重新开始当前步骤/.test(message))); +}); + +test('auto-run applies the idle-log restart watchdog to early steps too', async () => { + const harness = createHarness({ + startStep: 2, + failureStep: 0, + hangStep: 2, + hangBudget: 1, + idleLogTimeoutMs: 20, + idleLogCheckIntervalMs: 5, + }); + + const events = await harness.run(); + + assert.deepStrictEqual(events.steps, [2, 2, 4, 5, 6, 7, 8, 9, 10]); + assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [1]); + assert.equal(events.cancellations.length, 1); + assert.equal(events.stopBroadcasts, 1); +}); + +test('auto-run stops current-step idle restarts after the retry cap', async () => { + const harness = createHarness({ + startStep: 10, + failureStep: 0, + hangStep: 10, + hangBudget: 4, + idleLogTimeoutMs: 20, + idleLogCheckIntervalMs: 5, + }); + + const result = await harness.runAndCaptureError(); + + assert.ok(result?.error); + assert.match(result.error.message, /AUTO_RUN_STEP_IDLE_RESTART::步骤 10/); + assert.deepStrictEqual(result.events.steps, [10, 10, 10, 10]); + assert.deepStrictEqual(result.events.invalidations.map((entry) => entry.step), [9, 9, 9]); + assert.ok(result.events.logs.some(({ message }) => /已连续 3 次因 5 分钟无新日志而重开/.test(message))); +}); + test('auto-run stops restarting once add-phone is detected', async () => { const harness = createHarness({ failureStep: 7, @@ -429,6 +513,39 @@ test('auto-run restarts GPC checkout from step 6 when task status has no progres assert.ok(events.logs.some(({ message }) => /回到步骤 6 重新创建 GPC 任务/.test(message))); }); +test('auto-run keeps rebuilding GPC checkout beyond three failures', async () => { + const plusGpcSteps = { + 6: { key: 'plus-checkout-create' }, + 7: { key: 'plus-checkout-billing' }, + 10: { key: 'oauth-login' }, + 11: { key: 'fetch-login-code' }, + 12: { key: 'confirm-oauth' }, + 13: { key: 'platform-verify' }, + }; + const harness = createHarness({ + startStep: 6, + failureStep: 7, + failureBudget: 4, + failureMessage: 'GPC_TASK_ENDED::GPC task status stalled, recreate the task.', + stepDefinitions: plusGpcSteps, + finalOAuthChainStartStep: 10, + customState: { + stepStatuses: { 3: 'completed' }, + plusPaymentMethod: 'gpc-helper', + plusCheckoutSource: 'gpc-helper', + }, + }); + + const events = await harness.run(); + + assert.deepStrictEqual( + events.steps, + [6, 7, 6, 7, 6, 7, 6, 7, 6, 7, 10, 11, 12, 13] + ); + assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [5, 5, 5, 5]); + assert.ok(events.logs.some(({ message }) => /第 4 次/.test(message))); +}); + test('auto-run does not restart GPC checkout when Plus account has no free-trial eligibility', async () => { const plusGpcSteps = { 6: { key: 'plus-checkout-create' }, diff --git a/tests/background-account-run-history-module.test.js b/tests/background-account-run-history-module.test.js index c26d19c..dac9582 100644 --- a/tests/background-account-run-history-module.test.js +++ b/tests/background-account-run-history-module.test.js @@ -126,6 +126,20 @@ test('account run history helper upgrades old records, keeps stopped items and s assert.equal(genericStoppedRecord.failureLabel, '流程已停止'); assert.equal(genericStoppedRecord.failedStep, null); + const runningRecord = helpers.buildAccountRunHistoryRecord({ + email: 'run@b.com', + password: 'z', + autoRunning: true, + autoRunCurrentRun: 1, + autoRunTotalRuns: 2, + autoRunAttemptRun: 1, + }, 'running', '正在运行'); + assert.equal(runningRecord.finalStatus, 'running'); + assert.equal(runningRecord.failureLabel, '正在运行'); + assert.equal(runningRecord.failureDetail, ''); + assert.equal(runningRecord.failedStep, null); + assert.equal(runningRecord.source, 'auto'); + const normalizedStoppedRecord = helpers.normalizeAccountRunHistoryRecord({ recordId: 'legacy-stop@example.com', email: 'legacy-stop@example.com', @@ -471,6 +485,7 @@ test('account run history helper clears persisted records and syncs full snapsho assert.deepStrictEqual(payload.summary, { total: 1, success: 0, + running: 0, failed: 1, stopped: 0, retryTotal: 1, @@ -486,6 +501,7 @@ test('account run history helper clears persisted records and syncs full snapsho summary: { total: 0, success: 0, + running: 0, failed: 0, stopped: 0, retryTotal: 0, @@ -586,6 +602,7 @@ test('account run history helper deletes selected records and syncs remaining sn summary: { total: 1, success: 1, + running: 0, failed: 0, stopped: 0, retryTotal: 0, diff --git a/tests/plus-checkout-billing-tab-resolution.test.js b/tests/plus-checkout-billing-tab-resolution.test.js index c793b08..dd3995c 100644 --- a/tests/plus-checkout-billing-tab-resolution.test.js +++ b/tests/plus-checkout-billing-tab-resolution.test.js @@ -1081,6 +1081,75 @@ test('GPC billing fails repeated checkout stage as stale so auto-run can recreat assert.equal(events.logs.some((entry) => entry.message === '步骤 7:GPC 任务状态:创建订单'), true); }); +test('GPC billing fails unchanged visible created status even when hidden ids change', async () => { + const originalNow = Date.now; + let now = 1710000000000; + let queryCount = 0; + const fetchCalls = []; + const { events, executor } = createExecutorHarness({ + frames: [], + stateByFrame: {}, + sleepWithStop: async (ms) => { + events.sleeps.push(ms); + now += ms; + }, + fetchImpl: async (url, options = {}) => { + fetchCalls.push({ url, options }); + if (url === 'https://gpc.qlhazycoder.top/api/gp/tasks/task_created') { + queryCount += 1; + return { + ok: true, + status: 200, + json: async () => createGpcTaskResponse({ + task_id: 'task_created', + phone_mode: 'auto', + status: 'created', + status_text: '已创建', + remote_stage: '', + api_waiting_for: '', + reference_id: `ref_${queryCount}`, + flow_id: `flow_${queryCount}`, + }), + }; + } + if (url.endsWith('/api/gp/tasks/task_created/stop')) { + return { + ok: true, + status: 200, + json: async () => createGpcTaskResponse({ + task_id: 'task_created', + status: 'discarded', + status_text: '已停止', + }), + }; + } + throw new Error(`unexpected url: ${url}`); + }, + }); + + Date.now = () => now; + try { + await assert.rejects( + () => executor.executePlusCheckoutBilling({ + plusPaymentMethod: 'gpc-helper', + plusCheckoutSource: 'gpc-helper', + gopayHelperTaskId: 'task_created', + gopayHelperPhoneMode: 'auto', + gopayHelperApiUrl: 'https://gpc.qlhazycoder.top/', + gopayHelperApiKey: 'gpc_auto', + gopayHelperTaskStaleSeconds: 15, + }), + /GPC_TASK_ENDED::GPC 任务状态超过 15 秒无进展(已创建),请重新创建任务。/ + ); + } finally { + Date.now = originalNow; + } + + assert.equal(queryCount > 1, true); + assert.equal(fetchCalls.some((call) => call.url.endsWith('/api/gp/tasks/task_created/stop')), true); + assert.equal(events.logs.some((entry) => entry.message === '步骤 7:GPC 任务状态:已创建'), true); +}); + test('GPC billing reads SMS OTP from local helper for sms_otp_wait', async () => { const fetchCalls = []; let pollCount = 0; diff --git a/tests/sidepanel-account-records-manager.test.js b/tests/sidepanel-account-records-manager.test.js index cc8c21a..3280278 100644 --- a/tests/sidepanel-account-records-manager.test.js +++ b/tests/sidepanel-account-records-manager.test.js @@ -180,6 +180,112 @@ return { normalizeAccountRunHistoryHelperBaseUrlValue }; ); }); +test('account records manager shows current auto-run account as running', () => { + const source = fs.readFileSync('sidepanel/account-records-manager.js', 'utf8'); + const windowObject = {}; + const api = new Function('window', `${source}; return window.SidepanelAccountRecordsManager;`)(windowObject); + + const latestState = { + autoRunning: true, + autoRunPhase: 'running', + accountIdentifierType: 'email', + accountIdentifier: 'running@example.com', + email: 'running@example.com', + accountRunHistory: [ + { + recordId: 'running@example.com', + accountIdentifierType: 'email', + accountIdentifier: 'running@example.com', + email: 'running@example.com', + password: 'secret', + finalStatus: 'stopped', + finishedAt: '2026-04-17T04:32:00.000Z', + retryCount: 0, + failureLabel: '步骤 2 停止', + }, + ], + }; + + const list = createNode(); + const stats = createNode(); + const meta = createNode(); + const pageLabel = createNode(); + const manager = api.createAccountRecordsManager({ + state: { + getLatestState: () => latestState, + syncLatestState() {}, + }, + dom: { + accountRecordsList: list, + accountRecordsMeta: meta, + accountRecordsPageLabel: pageLabel, + accountRecordsStats: stats, + }, + helpers: { + escapeHtml: (value) => String(value || ''), + }, + runtime: { + sendMessage: async () => ({}), + }, + }); + + manager.render(); + + assert.match(list.innerHTML, /is-running/); + assert.match(list.innerHTML, /正在运行/); + assert.doesNotMatch(list.innerHTML, /步骤 2 停止/); + assert.match(stats.innerHTML, /data-account-record-filter="running"/); + assert.match(stats.innerHTML, /1<\/strong>运行/); +}); + +test('account records manager shows full failure detail before short label', () => { + const source = fs.readFileSync('sidepanel/account-records-manager.js', 'utf8'); + const windowObject = {}; + const api = new Function('window', `${source}; return window.SidepanelAccountRecordsManager;`)(windowObject); + + const latestState = { + accountRunHistory: [ + { + recordId: 'failed-detail@example.com', + accountIdentifierType: 'email', + accountIdentifier: 'failed-detail@example.com', + email: 'failed-detail@example.com', + finalStatus: 'failed', + finishedAt: '2026-04-17T04:32:00.000Z', + retryCount: 0, + failureLabel: '步骤 2 失败', + failureDetail: 'Duck 邮箱自动获取失败:未找到生成新 Duck 地址按钮。', + }, + ], + }; + + const list = createNode(); + const manager = api.createAccountRecordsManager({ + state: { + getLatestState: () => latestState, + syncLatestState() {}, + }, + dom: { + accountRecordsList: list, + accountRecordsMeta: createNode(), + accountRecordsPageLabel: createNode(), + accountRecordsStats: createNode(), + }, + helpers: { + escapeHtml: (value) => String(value || ''), + }, + runtime: { + sendMessage: async () => ({}), + }, + }); + + manager.render(); + + assert.match(list.innerHTML, /Duck 邮箱自动获取失败/); + assert.match(list.innerHTML, /title="failed-detail@example\.com\nDuck 邮箱自动获取失败/); + assert.doesNotMatch(list.innerHTML, /account-record-item-summary">步骤 2 失败/); +}); + test('account records manager supports filter chips and partial multi-select delete', async () => { const source = fs.readFileSync('sidepanel/account-records-manager.js', 'utf8'); const windowObject = {};