feat: add support for running status in account run history and UI

- Enhanced account run history to include 'running' status with appropriate translations and summaries.
- Updated logging status to handle new error message format.
- Introduced new properties for task progress in create-plus-checkout step.
- Modified fill-plus-checkout step to handle additional error details.
- Updated account records manager to display running status and summary correctly.
- Added CSS styles for running status in account records.
- Implemented logic to refresh account run history based on auto-run state changes.
- Added tests for running status and idle log restart functionality in auto-run steps.
- Improved account records manager tests to validate running state display and failure details.
This commit is contained in:
QLHazyCoder
2026-05-10 18:36:34 +08:00
parent 6bd00dbd5b
commit 48d3d5fc12
14 changed files with 727 additions and 35 deletions
+216 -11
View File
@@ -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)
: '';
+9
View File
@@ -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,
+2 -1
View File
@@ -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) {
+3
View File
@@ -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: '',
+27 -8
View File
@@ -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)) {
+99 -12
View File
@@ -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 = [
+20 -2
View File
@@ -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));
}
+21
View File
@@ -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;
}
}
@@ -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');
+10
View File
@@ -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');
+118 -1
View File
@@ -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' },
@@ -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,
@@ -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 === '步骤 7GPC 任务状态:创建订单'), 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 === '步骤 7GPC 任务状态:已创建'), true);
});
test('GPC billing reads SMS OTP from local helper for sms_otp_wait', async () => {
const fetchCalls = [];
let pollCount = 0;
@@ -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, /<strong>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 = {};