Refactor workflow auto-run to node graph

This commit is contained in:
QLHazyCoder
2026-05-15 17:41:35 +08:00
parent f6f804f1a2
commit 81fc40706a
76 changed files with 4028 additions and 1453 deletions
+831 -407
View File
File diff suppressed because it is too large Load Diff
+92 -9
View File
@@ -8,6 +8,8 @@
buildLocalHelperEndpoint,
chrome,
getErrorMessage,
getNodeIdByStepForState = null,
getNodeTitleForState = null,
getState,
normalizeAccountRunHistoryHelperBaseUrl,
} = deps;
@@ -30,18 +32,27 @@
if (normalized === 'success') {
return 'success';
}
if (normalized === 'running' || /_running$/.test(normalized)) {
if (normalized === 'running' || /_running$/.test(normalized) || /^node:[^:]+:running$/.test(normalized)) {
return 'running';
}
if (normalized === 'failed' || /_failed$/.test(normalized)) {
if (normalized === 'failed' || /_failed$/.test(normalized) || /^node:[^:]+:failed$/.test(normalized)) {
return 'failed';
}
if (normalized === 'stopped' || /_stopped$/.test(normalized)) {
if (normalized === 'stopped' || /_stopped$/.test(normalized) || /^node:[^:]+:stopped$/.test(normalized)) {
return 'stopped';
}
return '';
}
function normalizeNodeId(value = '') {
return String(value || '').trim();
}
function extractRecordNodeFromStatus(status = '') {
const match = String(status || '').trim().match(/^node:([^:]+):(?:running|failed|stopped)$/i);
return match ? normalizeNodeId(match[1]) : '';
}
function extractRecordStepFromStatus(status = '') {
const normalizedStatus = String(status || '').trim().toLowerCase();
const statusMatch = normalizedStatus.match(/^step(\d+)_(?:failed|stopped)$/);
@@ -88,6 +99,11 @@
return Number.isInteger(step) && step > 0 ? step : null;
}
function parseFailureLabelNode(label = '') {
const match = String(label || '').trim().match(/^节点\s*([A-Za-z0-9_.:-]+)\s*(?:失败|停止)$/);
return match ? normalizeNodeId(match[1]) : '';
}
function shouldIgnorePersistedFailedStepCandidate(candidate, failureDetail = '') {
if (!Number.isInteger(candidate) || candidate <= 0) {
return true;
@@ -128,7 +144,22 @@
return null;
}
function resolveFailureLabel(finalStatus, rawFailureLabel = '', computedFailureLabel = '', failedStep = null) {
function resolveNormalizedFailedNodeId(record = {}, status = '', failedStep = null) {
const explicitNodeId = normalizeNodeId(record.failedNodeId || record.failedNode || record.nodeId);
if (explicitNodeId) {
return explicitNodeId;
}
const statusNodeId = extractRecordNodeFromStatus(status || record.finalStatus || record.status || '');
if (statusNodeId) {
return statusNodeId;
}
if (Number.isInteger(failedStep) && failedStep > 0 && typeof getNodeIdByStepForState === 'function') {
return normalizeNodeId(getNodeIdByStepForState(failedStep, record));
}
return '';
}
function resolveFailureLabel(finalStatus, rawFailureLabel = '', computedFailureLabel = '', failedNodeId = '', failedStep = null) {
const rawLabel = String(rawFailureLabel || '').trim();
if (finalStatus === 'stopped') {
return computedFailureLabel;
@@ -137,6 +168,11 @@
return rawLabel || computedFailureLabel;
}
const rawNodeId = parseFailureLabelNode(rawLabel);
if (rawNodeId) {
return rawNodeId === failedNodeId ? rawLabel : computedFailureLabel;
}
const rawStep = parseFailureLabelStep(rawLabel);
if (Number.isInteger(rawStep) && rawStep > 0) {
return rawStep === failedStep ? rawLabel : computedFailureLabel;
@@ -156,7 +192,21 @@
|| /进入了手机号页面/.test(text);
}
function buildFailureLabel(finalStatus, failedStep, failureDetail = '') {
function getNodeDisplayName(nodeId, state = {}) {
const normalizedNodeId = normalizeNodeId(nodeId);
if (!normalizedNodeId) {
return '';
}
if (typeof getNodeTitleForState === 'function') {
const title = String(getNodeTitleForState(normalizedNodeId, state) || '').trim();
if (title) {
return title;
}
}
return normalizedNodeId;
}
function buildFailureLabel(finalStatus, failedNodeId = '', failedStep = null, failureDetail = '', state = {}) {
if (finalStatus === 'success') {
return '流程完成';
}
@@ -164,6 +214,9 @@
return '正在运行';
}
if (finalStatus === 'stopped') {
if (failedNodeId) {
return `节点 ${getNodeDisplayName(failedNodeId, state)} 停止`;
}
if (Number.isInteger(failedStep) && failedStep > 0) {
return `步骤 ${failedStep} 停止`;
}
@@ -175,6 +228,9 @@
if (isPhoneVerificationFailure(failureDetail)) {
return '出现手机号验证';
}
if (failedNodeId) {
return `节点 ${getNodeDisplayName(failedNodeId, state)} 失败`;
}
if (Number.isInteger(failedStep) && failedStep > 0) {
return `步骤 ${failedStep} 失败`;
}
@@ -341,6 +397,9 @@
const failedStep = finalStatus === 'failed' || finalStatus === 'stopped'
? resolveNormalizedFailedStep(record, failureDetail)
: null;
const failedNodeId = finalStatus === 'failed' || finalStatus === 'stopped'
? resolveNormalizedFailedNodeId(record, record.finalStatus || record.status || '', failedStep)
: '';
const autoRunContext = normalizeAutoRunContext(record.autoRunContext);
const retryCount = normalizeRetryCount(
record.retryCount !== undefined
@@ -348,11 +407,15 @@
: ((autoRunContext?.attemptRun || 0) > 1 ? autoRunContext.attemptRun - 1 : 0)
);
const source = normalizeSource(record.source || (autoRunContext ? 'auto' : 'manual'));
const computedFailureLabel = buildFailureLabel(finalStatus, failedStep, failureDetail);
const computedFailureLabel = buildFailureLabel(finalStatus, failedNodeId, failedStep, failureDetail, record);
const rawFailureLabel = String(record.failureLabel || '').trim();
const flowId = String(record.flowId || record.activeFlowId || '').trim();
const runId = String(record.runId || record.activeRunId || '').trim();
return {
recordId: String(record.recordId || '').trim() || buildRecordId(accountIdentifier, accountIdentifierType),
flowId,
runId,
accountIdentifierType,
accountIdentifier,
email,
@@ -361,8 +424,9 @@
finalStatus,
finishedAt,
retryCount,
failureLabel: resolveFailureLabel(finalStatus, rawFailureLabel, computedFailureLabel, failedStep),
failureLabel: resolveFailureLabel(finalStatus, rawFailureLabel, computedFailureLabel, failedNodeId, failedStep),
failureDetail,
failedNodeId,
failedStep: Number.isInteger(failedStep) && failedStep > 0 ? failedStep : null,
source,
autoRunContext: source === 'auto' ? autoRunContext : null,
@@ -422,13 +486,31 @@
const failedStep = finalStatus === 'failed' || finalStatus === 'stopped'
? extractRecordStep(status, failureDetail)
: null;
const statusNodeId = finalStatus === 'failed' || finalStatus === 'stopped'
? extractRecordNodeFromStatus(status)
: '';
const stateNodeId = finalStatus === 'failed' || finalStatus === 'stopped'
? normalizeNodeId(state.currentNodeId)
: '';
const failedNodeIdFromStep = !statusNodeId
&& !stateNodeId
&& Number.isInteger(failedStep)
&& failedStep > 0
&& typeof getNodeIdByStepForState === 'function'
? normalizeNodeId(getNodeIdByStepForState(failedStep, state))
: '';
const failedNodeId = statusNodeId || stateNodeId || failedNodeIdFromStep;
const source = Boolean(state.autoRunning) ? 'auto' : 'manual';
const autoRunContext = source === 'auto' ? buildAutoRunContextFromState(state) : null;
const retryCount = source === 'auto' ? getRetryCountFromState(state) : 0;
const finishedAt = new Date().toISOString();
const flowId = String(state.flowId || state.activeFlowId || '').trim();
const runId = String(state.runId || state.activeRunId || '').trim();
return {
recordId: buildRecordId(accountIdentifier, accountIdentifierType),
flowId,
runId,
accountIdentifierType,
accountIdentifier,
email,
@@ -437,9 +519,10 @@
finalStatus,
finishedAt,
retryCount,
failureLabel: buildFailureLabel(finalStatus, failedStep, failureDetail),
failureLabel: buildFailureLabel(finalStatus, failedNodeId, failedStep, failureDetail, state),
failureDetail,
failedStep: Number.isInteger(failedStep) && failedStep > 0 ? failedStep : null,
failedNodeId,
failedStep: statusNodeId ? null : (Number.isInteger(failedStep) && failedStep > 0 ? failedStep : null),
source,
autoRunContext,
plusModeEnabled: Boolean(state.plusModeEnabled),
+93 -64
View File
@@ -17,11 +17,11 @@
ensureHotmailMailboxReadyForAutoRunRound,
getAutoRunStatusPayload,
getErrorMessage,
getFirstUnfinishedStep,
getFirstUnfinishedNodeId,
getPendingAutoRunTimerPlan,
getRunningSteps,
getRunningNodeIds,
getState,
hasSavedProgress,
hasSavedNodeProgress,
isAddPhoneAuthFailure,
isGpcTaskEndedFailure,
isPhoneSmsPlatformRateLimitFailure,
@@ -34,14 +34,49 @@
normalizeAutoRunFallbackThreadIntervalMinutes,
persistAutoRunTimerPlan,
resetState,
runAutoSequenceFromStep,
runAutoSequenceFromNode,
runtime,
setState,
sleepWithStop,
throwIfAutoRunSessionStopped,
waitForRunningStepsToFinish,
waitForRunningNodesToFinish,
} = deps;
function getRunningWorkflowNodes(state = {}) {
if (typeof getRunningNodeIds === 'function') {
return getRunningNodeIds(state.nodeStatuses || {}, state);
}
return [];
}
function getFirstUnfinishedWorkflowNode(state = {}) {
if (typeof getFirstUnfinishedNodeId === 'function') {
return getFirstUnfinishedNodeId(state.nodeStatuses || {}, state);
}
return null;
}
function hasSavedWorkflowProgress(state = {}) {
if (typeof hasSavedNodeProgress === 'function') {
return hasSavedNodeProgress(state.nodeStatuses || {}, state);
}
return false;
}
async function waitForRunningWorkflowNodesToFinish(payload = {}) {
if (typeof waitForRunningNodesToFinish === 'function') {
return waitForRunningNodesToFinish(payload);
}
return getState();
}
async function runAutoSequenceFromWorkflowNode(startNodeId, context = {}) {
if (typeof runAutoSequenceFromNode === 'function') {
return runAutoSequenceFromNode(startNodeId, context);
}
throw new Error('自动运行节点执行器未接入。');
}
function createAutoRunRoundSummary(round) {
return {
round,
@@ -85,90 +120,83 @@
return Math.max(0, Number(summary?.attempts || 0) - 1);
}
function normalizeRecordStep(value) {
const step = Math.floor(Number(value) || 0);
return step > 0 ? step : null;
function normalizeRecordNode(value = '') {
return String(value || '').trim();
}
function extractStepFromRecordStatus(status = '') {
const match = String(status || '').trim().toLowerCase().match(/^step(\d+)_(?:failed|stopped)$/);
if (!match) {
return null;
}
return normalizeRecordStep(match[1]);
function extractNodeFromRecordStatus(status = '') {
const match = String(status || '').trim().match(/^node:([^:]+):(failed|stopped)$/i);
return match ? normalizeRecordNode(match[1]) : '';
}
function getKnownStepIdsFromState(state = {}) {
function getKnownNodeIdsFromState(state = {}) {
const ids = new Set();
for (const key of Object.keys(state?.stepStatuses || {})) {
const step = normalizeRecordStep(key);
if (step) {
ids.add(step);
for (const key of Object.keys(state?.nodeStatuses || {})) {
const nodeId = normalizeRecordNode(key);
if (nodeId) {
ids.add(nodeId);
}
}
const currentStep = normalizeRecordStep(state?.currentStep);
if (currentStep) {
ids.add(currentStep);
const currentNodeId = normalizeRecordNode(state?.currentNodeId);
if (currentNodeId) {
ids.add(currentNodeId);
}
return Array.from(ids).sort((left, right) => left - right);
return Array.from(ids);
}
function inferRecordStepFromState(state = {}, preferredStatuses = []) {
const statuses = state?.stepStatuses || {};
function inferRecordNodeFromState(state = {}, preferredStatuses = []) {
const statuses = state?.nodeStatuses || {};
const preferredStatusSet = new Set(preferredStatuses.map((item) => String(item || '').trim()).filter(Boolean));
const stepIds = getKnownStepIdsFromState(state);
const currentStep = normalizeRecordStep(state?.currentStep);
const nodeIds = getKnownNodeIdsFromState(state);
const currentNodeId = normalizeRecordNode(state?.currentNodeId);
if (currentStep && preferredStatusSet.has(String(statuses[currentStep] || '').trim())) {
return currentStep;
if (currentNodeId && preferredStatusSet.has(String(statuses[currentNodeId] || '').trim())) {
return currentNodeId;
}
const matchingSteps = stepIds
.filter((step) => preferredStatusSet.has(String(statuses[step] || '').trim()))
.sort((left, right) => right - left);
if (matchingSteps.length) {
return matchingSteps[0];
const matchingNodes = nodeIds.filter((nodeId) => preferredStatusSet.has(String(statuses[nodeId] || '').trim()));
if (matchingNodes.length) {
return matchingNodes[matchingNodes.length - 1];
}
if (currentStep) {
const currentStatus = String(statuses[currentStep] || '').trim();
if (currentNodeId) {
const currentStatus = String(statuses[currentNodeId] || '').trim();
if (!['', 'pending', 'completed', 'manual_completed', 'skipped'].includes(currentStatus)) {
return currentStep;
return currentNodeId;
}
}
return null;
return '';
}
function inferRecordStepFromError(errorLike = null) {
function inferRecordNodeFromError(errorLike = null, state = {}) {
if (!errorLike || typeof errorLike !== 'object') {
return null;
return '';
}
return normalizeRecordStep(errorLike.failedStep)
|| normalizeRecordStep(errorLike.step)
|| normalizeRecordStep(errorLike.currentStep);
return normalizeRecordNode(errorLike.failedNodeId)
|| normalizeRecordNode(errorLike.nodeId)
|| normalizeRecordNode(errorLike.currentNodeId);
}
function resolveAutoRunAccountRecordStatus(status, state = {}, errorLike = null) {
const normalizedStatus = String(status || '').trim().toLowerCase();
const explicitStep = extractStepFromRecordStatus(normalizedStatus);
if (explicitStep) {
return normalizedStatus;
const explicitNode = extractNodeFromRecordStatus(status);
if (explicitNode) {
return `node:${explicitNode}:${normalizedStatus.endsWith(':stopped') ? 'stopped' : 'failed'}`;
}
if (normalizedStatus === 'failed') {
const failedStep = inferRecordStepFromError(errorLike)
|| inferRecordStepFromState(state, ['failed', 'running']);
return failedStep ? `step${failedStep}_failed` : status;
const failedNode = inferRecordNodeFromError(errorLike, state)
|| inferRecordNodeFromState(state, ['failed', 'running']);
return failedNode ? `node:${failedNode}:failed` : status;
}
if (normalizedStatus === 'stopped') {
const stoppedStep = inferRecordStepFromError(errorLike)
|| inferRecordStepFromState(state, ['stopped', 'running']);
return stoppedStep ? `step${stoppedStep}_stopped` : status;
const stoppedNode = inferRecordNodeFromError(errorLike, state)
|| inferRecordNodeFromState(state, ['stopped', 'running']);
return stoppedNode ? `node:${stoppedNode}:stopped` : status;
}
return status;
@@ -439,7 +467,7 @@
let successfulRuns = roundSummaries.filter((item) => item.status === 'success').length;
const initialState = await getState();
const initialPhase = continueCurrentOnFirstAttempt && getRunningSteps(initialState.stepStatuses, initialState).length
const initialPhase = continueCurrentOnFirstAttempt && getRunningWorkflowNodes(initialState).length
? 'waiting_step'
: 'running';
const showResumePosition = continueCurrentOnFirstAttempt || resumeCurrentRun > 1 || resumeAttemptRun > 1;
@@ -474,24 +502,25 @@
autoRunAttemptRun: attemptRun,
});
roundSummary.attempts = attemptRun;
let startStep = 1;
const defaultStartNodeId = typeof runAutoSequenceFromNode === 'function' ? 'open-chatgpt' : 1;
let startNodeId = defaultStartNodeId;
let useExistingProgress = false;
if (reuseExistingProgress) {
let currentState = await getState();
if (getRunningSteps(currentState.stepStatuses, currentState).length) {
currentState = await waitForRunningStepsToFinish({
if (getRunningWorkflowNodes(currentState).length) {
currentState = await waitForRunningWorkflowNodesToFinish({
currentRun: targetRun,
totalRuns,
attemptRun,
});
}
const resumeStep = getFirstUnfinishedStep(currentState.stepStatuses, currentState);
if (resumeStep && hasSavedProgress(currentState.stepStatuses, currentState)) {
startStep = resumeStep;
const resumeNodeId = getFirstUnfinishedWorkflowNode(currentState);
if (resumeNodeId && hasSavedWorkflowProgress(currentState)) {
startNodeId = resumeNodeId;
useExistingProgress = true;
} else if (hasSavedProgress(currentState.stepStatuses, currentState)) {
await addLog('检测到当前流程已处理完成,本轮将改为从步骤 1 重新开始。', 'info');
} else if (hasSavedWorkflowProgress(currentState)) {
await addLog('检测到当前流程已处理完成,本轮将改为从首个节点重新开始。', 'info');
}
}
@@ -571,7 +600,7 @@
sessionId,
});
if (!useExistingProgress && startStep === 1 && typeof ensureHotmailMailboxReadyForAutoRunRound === 'function') {
if (!useExistingProgress && startNodeId === defaultStartNodeId && typeof ensureHotmailMailboxReadyForAutoRunRound === 'function') {
await ensureHotmailMailboxReadyForAutoRunRound({
targetRun,
totalRuns,
@@ -580,7 +609,7 @@
});
}
await runAutoSequenceFromStep(startStep, {
await runAutoSequenceFromWorkflowNode(startNodeId, {
targetRun,
totalRuns,
attemptRuns: attemptRun,
+51 -17
View File
@@ -5,6 +5,7 @@
const {
chrome,
DEFAULT_STATE,
getStepIdByNodeIdForState,
getState,
isRecoverableStep9AuthFailure,
LOG_PREFIX,
@@ -51,12 +52,14 @@
const normalizedOptions = options && typeof options === 'object' ? options : {};
const step = normalizeLogStep(normalizedOptions.step);
const stepKey = String(normalizedOptions.stepKey || '').trim();
const nodeId = String(normalizedOptions.nodeId || normalizedOptions.nodeKey || stepKey || '').trim();
return {
message: String(message || ''),
level,
timestamp: Date.now(),
step,
stepKey,
nodeId,
};
}
@@ -70,14 +73,21 @@
chrome.runtime.sendMessage({ type: 'LOG_ENTRY', payload: entry }).catch(() => { });
}
async function setStepStatus(step, status) {
async function setNodeStatus(nodeId, status) {
const normalizedNodeId = String(nodeId || '').trim();
if (!normalizedNodeId) {
throw new Error('setNodeStatus 缺少 nodeId。');
}
const state = await getState();
const statuses = { ...state.stepStatuses };
statuses[step] = status;
await setState({ stepStatuses: statuses, currentStep: step });
const nodeStatuses = { ...(state.nodeStatuses || {}) };
nodeStatuses[normalizedNodeId] = status;
await setState({
nodeStatuses,
currentNodeId: normalizedNodeId,
});
chrome.runtime.sendMessage({
type: 'STEP_STATUS_CHANGED',
payload: { step, status },
type: 'NODE_STATUS_CHANGED',
payload: { nodeId: normalizedNodeId, status },
}).catch(() => { });
}
@@ -149,29 +159,50 @@
}
function getFirstUnfinishedStep(statuses = {}) {
const stepIds = Object.keys(DEFAULT_STATE.stepStatuses || {})
.map((step) => Number(step))
.filter(Number.isFinite)
.sort((left, right) => left - right);
for (const step of stepIds) {
if (!isStepDoneStatus(statuses[step] || 'pending')) {
return step;
const nodeStatuses = statuses && typeof statuses === 'object' ? statuses : {};
const nodeIds = Object.keys(DEFAULT_STATE.nodeStatuses || {});
for (const nodeId of nodeIds) {
if (!isStepDoneStatus(nodeStatuses[nodeId] || 'pending')) {
return typeof getStepIdByNodeIdForState === 'function'
? getStepIdByNodeIdForState(nodeId, {})
: null;
}
}
return null;
}
function hasSavedProgress(statuses = {}) {
return Object.values({ ...DEFAULT_STATE.stepStatuses, ...statuses }).some((status) => status !== 'pending');
return Object.values({ ...DEFAULT_STATE.nodeStatuses, ...statuses }).some((status) => status !== 'pending');
}
function getRunningSteps(statuses = {}) {
return Object.entries({ ...DEFAULT_STATE.stepStatuses, ...statuses })
return Object.entries({ ...DEFAULT_STATE.nodeStatuses, ...statuses })
.filter(([, status]) => status === 'running')
.map(([step]) => Number(step))
.map(([nodeId]) => (typeof getStepIdByNodeIdForState === 'function' ? getStepIdByNodeIdForState(nodeId, {}) : null))
.filter((step) => Number.isInteger(step) && step > 0)
.sort((a, b) => a - b);
}
function getFirstUnfinishedNode(statuses = {}) {
const nodeIds = Object.keys(DEFAULT_STATE.nodeStatuses || {});
for (const nodeId of nodeIds) {
if (!isStepDoneStatus(statuses[nodeId] || 'pending')) {
return nodeId;
}
}
return '';
}
function hasSavedNodeProgress(statuses = {}) {
return Object.values({ ...DEFAULT_STATE.nodeStatuses, ...statuses }).some((status) => status !== 'pending');
}
function getRunningNodes(statuses = {}) {
return Object.entries({ ...DEFAULT_STATE.nodeStatuses, ...statuses })
.filter(([, status]) => status === 'running')
.map(([nodeId]) => nodeId);
}
function getAutoRunStatusPayload(phase, payload = {}) {
return {
autoRunning: phase === 'scheduled'
@@ -195,12 +226,15 @@
return {
addLog,
getAutoRunStatusPayload,
getFirstUnfinishedNode,
isAddPhoneAuthFailure,
getErrorMessage,
getFirstUnfinishedStep,
getLoginAuthStateLabel,
getRunningNodes,
getRunningSteps,
getSourceLabel,
hasSavedNodeProgress,
hasSavedProgress,
isLegacyStep9RecoverableAuthError,
isRestartCurrentAttemptError,
@@ -208,7 +242,7 @@
isStep9RecoverableAuthError,
isStepDoneStatus,
isVerificationMailPollingError,
setStepStatus,
setNodeStatus,
};
}
+32
View File
@@ -26,6 +26,21 @@
return flowBuilder.getRuleDefinition(step, state);
}
function getVerificationMailRuleForNode(nodeId, state = {}) {
const flowId = resolveFlowId(state);
const flowBuilder = getFlowBuilder(flowId);
if (!flowBuilder) {
throw new Error(`未找到 flow=${flowId} 的邮件规则定义。`);
}
if (typeof flowBuilder.getRuleDefinitionForNode === 'function') {
return flowBuilder.getRuleDefinitionForNode(nodeId, state);
}
if (typeof flowBuilder.getRuleDefinition === 'function') {
return flowBuilder.getRuleDefinition({ nodeId }, state);
}
throw new Error(`未找到 flow=${flowId} 的邮件规则定义。`);
}
function buildVerificationPollPayload(step, state = {}, overrides = {}) {
const flowId = resolveFlowId(state);
const flowBuilder = getFlowBuilder(flowId);
@@ -35,9 +50,26 @@
return flowBuilder.buildVerificationPollPayload(step, state, overrides);
}
function buildVerificationPollPayloadForNode(nodeId, state = {}, overrides = {}) {
const flowId = resolveFlowId(state);
const flowBuilder = getFlowBuilder(flowId);
if (!flowBuilder) {
throw new Error(`未找到 flow=${flowId} 的邮件轮询规则构造器。`);
}
if (typeof flowBuilder.buildVerificationPollPayloadForNode === 'function') {
return flowBuilder.buildVerificationPollPayloadForNode(nodeId, state, overrides);
}
if (typeof flowBuilder.buildVerificationPollPayload === 'function') {
return flowBuilder.buildVerificationPollPayload({ nodeId }, state, overrides);
}
throw new Error(`未找到 flow=${flowId} 的邮件轮询规则构造器。`);
}
return {
buildVerificationPollPayload,
buildVerificationPollPayloadForNode,
getVerificationMailRule,
getVerificationMailRuleForNode,
resolveFlowId,
};
}
+212 -91
View File
@@ -21,17 +21,17 @@
clearStopRequest,
closeLocalhostCallbackTabs,
closeTabsByUrlPrefix,
completeStepFromBackground,
completeNodeFromBackground,
deleteHotmailAccount,
deleteHotmailAccounts,
deleteIcloudAlias,
deleteUsedIcloudAliases,
disableUsedLuckmailPurchases,
doesStepUseCompletionSignal,
doesNodeUseCompletionSignal,
ensureMail2925MailboxSession,
ensureManualInteractionAllowed,
executeStep,
executeStepViaCompletionSignal,
executeNode,
executeNodeViaCompletionSignal,
exportSettingsBundle,
fetchGeneratedEmail,
refreshGpcCardBalance,
@@ -47,6 +47,9 @@
getPendingAutoRunTimerPlan,
getSourceLabel,
getState,
getNodeDefinitionForState,
getNodeIdsForState,
getStepIdByNodeIdForState,
getStepDefinitionForState,
getStepIdsForState,
getLastStepIdForState,
@@ -137,8 +140,8 @@
normalizePayPalAccounts,
normalizeRunCount,
AUTO_RUN_TIMER_KIND_SCHEDULED_START,
notifyStepComplete,
notifyStepError,
notifyNodeComplete,
notifyNodeError,
patchMail2925Account,
patchHotmailAccount,
pollContributionStatus,
@@ -169,9 +172,9 @@
setLuckmailPurchaseUsedState,
setPersistentSettings,
setState,
setStepStatus,
setNodeStatus,
skipAutoRunCountdown,
skipStep,
skipNode,
startContributionFlow,
startAutoRunLoop,
deleteMail2925Account,
@@ -215,27 +218,92 @@
}
}
const DEFAULT_OPENAI_NODE_BY_STEP = Object.freeze({
1: 'open-chatgpt',
2: 'submit-signup-email',
3: 'fill-password',
4: 'fetch-signup-code',
5: 'fill-profile',
6: 'wait-registration-success',
7: 'oauth-login',
8: 'fetch-login-code',
9: 'confirm-oauth',
10: 'platform-verify',
11: 'fetch-login-code',
12: 'confirm-oauth',
13: 'platform-verify',
});
function getStepKeyForState(step, state = {}) {
if (typeof getStepDefinitionForState === 'function') {
return String(getStepDefinitionForState(step, state)?.key || '').trim();
}
return '';
return DEFAULT_OPENAI_NODE_BY_STEP[Number(step)] || '';
}
function isStaleAutoRunStepMessage(step, state = {}) {
function findStepByNodeId(nodeId, state = {}) {
const normalizedNodeId = String(nodeId || '').trim();
if (normalizedNodeId && typeof getStepIdByNodeIdForState === 'function') {
const step = getStepIdByNodeIdForState(normalizedNodeId, state);
if (Number.isInteger(step) && step > 0) {
return step;
}
}
if (!normalizedNodeId || typeof getStepIdsForState !== 'function') {
return 0;
}
for (const stepId of getStepIdsForState(state)) {
if (getStepKeyForState(stepId, state) === normalizedNodeId) {
return Number(stepId) || 0;
}
}
return 0;
}
async function normalizeNodeProtocolMessage(message = {}) {
const type = String(message?.type || '').trim();
const nodeProtocolTypes = new Set([
'EXECUTE_NODE',
'NODE_COMPLETE',
'NODE_ERROR',
'SKIP_NODE',
]);
if (!nodeProtocolTypes.has(type)) {
return message;
}
const nodeId = String(message?.payload?.nodeId || message?.nodeId || '').trim();
if (!nodeId) {
throw new Error(`${type} 缺少 nodeId。`);
}
const state = await getState();
const step = findStepByNodeId(nodeId, state);
if (!step) {
throw new Error(`当前 flow 中未找到节点:${nodeId}`);
}
const payload = {
...(message.payload || {}),
nodeId,
step,
};
return { ...message, nodeId, step, payload };
}
function isStaleAutoRunNodeMessage(nodeId, state = {}) {
const normalizedNodeId = String(nodeId || '').trim();
if (!normalizedNodeId) {
return false;
}
if (typeof isAutoRunLockedState !== 'function' || !isAutoRunLockedState(state)) {
return false;
}
const normalizedStep = Number(step);
if (!Number.isInteger(normalizedStep) || normalizedStep <= 0) {
return false;
}
const currentStatus = String(state?.stepStatuses?.[normalizedStep] || '').trim();
const currentStatus = String(state?.nodeStatuses?.[normalizedNodeId] || '').trim();
if (currentStatus === 'running') {
return false;
}
const currentStep = Number(state?.currentStep) || 0;
if (currentStep > 0 && normalizedStep !== currentStep) {
const currentNodeId = String(state?.currentNodeId || '').trim();
if (currentNodeId && normalizedNodeId !== currentNodeId) {
return true;
}
return ['completed', 'manual_completed', 'skipped', 'failed', 'stopped'].includes(currentStatus);
@@ -383,24 +451,38 @@
|| status === 'skipped';
}
function findStepByKeyAfter(currentStep, targetKey, state = {}) {
function findStepByKeyAfter(currentOrder, targetKey, state = {}) {
const activeStepIds = typeof getStepIdsForState === 'function'
? getStepIdsForState(state)
: [];
const candidates = activeStepIds.length ? activeStepIds : [Number(currentStep) + 1, 8];
const candidates = activeStepIds.length ? activeStepIds : [Number(currentOrder) + 1, 8];
return candidates.find((stepId) => {
const numericStep = Number(stepId);
if (!Number.isFinite(numericStep) || numericStep <= Number(currentStep)) {
if (!Number.isFinite(numericStep) || numericStep <= Number(currentOrder)) {
return false;
}
const stepKey = getStepKeyForState(numericStep, state);
if (stepKey) {
return stepKey === targetKey;
}
return targetKey === 'fetch-login-code' && Number(currentStep) === 7 && numericStep === 8;
return targetKey === 'fetch-login-code' && Number(currentOrder) === 7 && numericStep === 8;
}) || null;
}
function getNodeStatusByStep(step, state = {}) {
const nodeId = getStepKeyForState(step, state);
return nodeId ? (state.nodeStatuses?.[nodeId] || 'pending') : 'pending';
}
async function setNodeStatusByStep(step, status, state = {}) {
const nodeId = getStepKeyForState(step, state);
if (!nodeId) {
throw new Error(`未找到步骤 ${step} 对应节点。`);
}
await setNodeStatus(nodeId, status);
return nodeId;
}
function normalizePlusPaymentMethodForDisplay(value = '') {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'gpc-helper') {
@@ -500,9 +582,9 @@
const latestState = await getState();
const loginCodeStep = findStepByKeyAfter(step, 'fetch-login-code', latestState);
if (loginCodeStep) {
const currentStatus = latestState.stepStatuses?.[loginCodeStep];
const currentStatus = getNodeStatusByStep(loginCodeStep, latestState);
if (!isStepProtectedFromAutoSkip(currentStatus)) {
await setStepStatus(loginCodeStep, 'skipped');
await setNodeStatusByStep(loginCodeStep, 'skipped', latestState);
await addLog(`认证页已直接进入 OAuth 授权页,已自动跳过步骤 ${loginCodeStep} 的登录验证码。`, 'warn', {
step,
stepKey: 'oauth-login',
@@ -571,21 +653,21 @@
await syncStepAccountIdentityFromPayload(payload);
if (payload.skipRegistrationFlow) {
const latestState = await getState();
for (const skipStep of [3, 4, 5]) {
const status = latestState.stepStatuses?.[skipStep];
for (const skippedStep of [3, 4, 5]) {
const status = getNodeStatusByStep(skippedStep, latestState);
if (status === 'running' || status === 'completed' || status === 'manual_completed') {
continue;
}
await setStepStatus(skipStep, 'skipped');
await setNodeStatusByStep(skippedStep, 'skipped', latestState);
}
await addLog('步骤 2:检测到当前已登录会话,已自动跳过步骤 3/4/5,流程将直接进入步骤 6。', 'warn');
break;
}
if (payload.skippedPasswordStep) {
const latestState = await getState();
const step3Status = latestState.stepStatuses?.[3];
const step3Status = getNodeStatusByStep(3, latestState);
if (step3Status !== 'running' && step3Status !== 'completed' && step3Status !== 'manual_completed') {
await setStepStatus(3, 'skipped');
await setNodeStatusByStep(3, 'skipped', latestState);
const identityLabel = payload.accountIdentifierType === 'phone' ? '手机号' : '邮箱';
await addLog(`步骤 2:提交${identityLabel}后页面直接进入验证码页,已自动跳过步骤 3。`, 'warn');
}
@@ -598,9 +680,9 @@
}
if (payload.skipProfileStep) {
const latestState = await getState();
const step5Status = latestState.stepStatuses?.[5];
const step5Status = getNodeStatusByStep(5, latestState);
if (step5Status !== 'running' && step5Status !== 'completed' && step5Status !== 'manual_completed') {
await setStepStatus(5, 'skipped');
await setNodeStatusByStep(5, 'skipped', latestState);
await addLog('步骤 3:页面已直接进入已登录态,已自动跳过步骤 5。', 'warn');
}
}
@@ -621,9 +703,9 @@
});
if (payload.skipProfileStep) {
const latestState = await getState();
const step5Status = latestState.stepStatuses?.[5];
const step5Status = getNodeStatusByStep(5, latestState);
if (step5Status !== 'running' && step5Status !== 'completed' && step5Status !== 'manual_completed') {
await setStepStatus(5, 'skipped');
await setNodeStatusByStep(5, 'skipped', latestState);
if (payload.skipProfileStepReason === 'combined_verification_profile') {
await addLog('步骤 4:当前验证码页已内嵌完成注册资料提交,已自动跳过步骤 5。', 'warn');
} else {
@@ -664,7 +746,8 @@
}
}
async function handleMessage(message, sender) {
async function handleMessage(rawMessage, sender) {
const message = await normalizeNodeProtocolMessage(rawMessage);
switch (message.type) {
case 'CONTENT_SCRIPT_READY': {
const tabId = sender.tab?.id;
@@ -690,20 +773,30 @@
return { ok: true };
}
case 'STEP_COMPLETE': {
case 'NODE_COMPLETE': {
const currentStateForNode = await getState();
const nodeId = String(message.nodeId || message.payload?.nodeId || '').trim();
const resolvedStep = findStepByNodeId(nodeId, currentStateForNode);
if (!nodeId || !resolvedStep) {
throw new Error('NODE_COMPLETE 缺少 nodeId。');
}
const currentState = await getState();
if (isStaleAutoRunStepMessage(message.step, currentState)) {
await addLog(`自动运行:忽略过期的步骤 ${message.step} 完成消息,当前流程已在步骤 ${currentState.currentStep || '未知'}`, 'warn', { step: message.step });
if (isStaleAutoRunNodeMessage(nodeId, currentState)) {
await addLog(
`自动运行:忽略过期的节点 ${nodeId} 完成消息,当前流程已在节点 ${currentState.currentNodeId || '未知'}`,
'warn',
{ nodeId }
);
return { ok: true, ignored: true };
}
if (getStopRequested()) {
await setStepStatus(message.step, 'stopped');
await appendManualAccountRunRecordIfNeeded(`step${message.step}_stopped`, null, '流程已被用户停止。');
notifyStepError(message.step, '流程已被用户停止。');
await setNodeStatus(nodeId, 'stopped');
await appendManualAccountRunRecordIfNeeded(`node:${nodeId}:stopped`, null, '流程已被用户停止。');
notifyNodeError(nodeId, '流程已被用户停止。');
return { ok: true };
}
try {
if (message.step === 3 && typeof finalizeStep3Completion === 'function') {
if (nodeId === 'fill-password' && typeof finalizeStep3Completion === 'function') {
await finalizeStep3Completion(message.payload || {});
}
} catch (error) {
@@ -711,60 +804,74 @@
const userMessage = typeof handleCloudflareSecurityBlocked === 'function'
? await handleCloudflareSecurityBlocked(error)
: (error?.message || String(error || ''));
notifyStepError(message.step, '流程已被用户停止。');
notifyNodeError(nodeId, '流程已被用户停止。');
return { ok: true, error: userMessage };
}
const errorMessage = error?.message || String(error || '步骤 3 提交后确认失败');
await setStepStatus(message.step, 'failed');
await addLog(`失败:${errorMessage}`, 'error', { step: message.step });
await appendManualAccountRunRecordIfNeeded(`step${message.step}_failed`, null, errorMessage);
notifyStepError(message.step, errorMessage);
await setNodeStatus(nodeId, 'failed');
await addLog(`失败:${errorMessage}`, 'error', {
nodeId,
});
await appendManualAccountRunRecordIfNeeded(`node:${nodeId}:failed`, null, errorMessage);
notifyNodeError(nodeId, errorMessage);
return { ok: true, error: errorMessage };
}
const completionStateCandidate = await getState();
const lastStepId = typeof getLastStepIdForState === 'function'
? getLastStepIdForState(completionStateCandidate)
: 10;
const completionState = message.step === lastStepId ? completionStateCandidate : null;
await setStepStatus(message.step, 'completed');
await addLog('已完成', 'ok', { step: message.step });
await handleStepData(message.step, message.payload);
if (message.step === lastStepId && typeof appendAccountRunRecord === 'function') {
const nodeIds = typeof getNodeIdsForState === 'function' ? getNodeIdsForState(completionStateCandidate) : [];
const lastNodeId = nodeIds[nodeIds.length - 1] || '';
const isFinalNode = nodeId === lastNodeId;
const completionState = isFinalNode ? completionStateCandidate : null;
await setNodeStatus(nodeId, 'completed');
await addLog('已完成', 'ok', { nodeId });
await handleStepData(resolvedStep, message.payload);
if (isFinalNode && typeof appendAccountRunRecord === 'function') {
await appendAccountRunRecord('success', completionState);
}
notifyStepComplete(message.step, message.payload);
notifyNodeComplete(nodeId, message.payload);
return { ok: true };
}
case 'STEP_ERROR': {
case 'NODE_ERROR': {
const stateForNode = await getState();
const nodeId = String(message.nodeId || message.payload?.nodeId || '').trim();
const resolvedStep = findStepByNodeId(nodeId, stateForNode);
if (!nodeId || !resolvedStep) {
throw new Error('NODE_ERROR 缺少 nodeId。');
}
const staleCheckState = await getState();
if (isStaleAutoRunStepMessage(message.step, staleCheckState)) {
await addLog(`自动运行:忽略过期的步骤 ${message.step} 失败消息,当前流程已在步骤 ${staleCheckState.currentStep || '未知'}。原始错误:${message.error || '未知错误'}`, 'warn', { step: message.step });
if (isStaleAutoRunNodeMessage(nodeId, staleCheckState)) {
await addLog(
`自动运行:忽略过期的节点 ${nodeId} 失败消息,当前流程已在节点 ${staleCheckState.currentNodeId || '未知'}。原始错误:${message.error || '未知错误'}`,
'warn',
{ nodeId }
);
return { ok: true, ignored: true };
}
if (typeof isCloudflareSecurityBlockedError === 'function' && isCloudflareSecurityBlockedError(message.error)) {
const userMessage = typeof handleCloudflareSecurityBlocked === 'function'
? await handleCloudflareSecurityBlocked(message.error)
: (typeof message.error === 'string' ? message.error : String(message.error || ''));
notifyStepError(message.step, '流程已被用户停止。');
notifyNodeError(nodeId, '流程已被用户停止。');
return { ok: true, error: userMessage };
}
const currentState = await getState();
const currentStepStatus = currentState?.stepStatuses?.[message.step] || '';
const currentNodeStatus = currentState?.nodeStatuses?.[nodeId] || '';
const isSignupPhonePasswordMismatch = /SIGNUP_PHONE_PASSWORD_MISMATCH::/i.test(String(message.error || ''));
if (isStopError(message.error)) {
await setStepStatus(message.step, 'stopped');
await addLog('已被用户停止', 'warn', { step: message.step });
await appendManualAccountRunRecordIfNeeded(`step${message.step}_stopped`, null, message.error);
notifyStepError(message.step, message.error);
await setNodeStatus(nodeId, 'stopped');
await addLog('已被用户停止', 'warn', { nodeId });
await appendManualAccountRunRecordIfNeeded(`node:${nodeId}:stopped`, null, message.error);
notifyNodeError(nodeId, message.error);
} else {
if (!(isSignupPhonePasswordMismatch && currentStepStatus === 'failed')) {
await setStepStatus(message.step, 'failed');
await addLog(`失败:${message.error}`, 'error', { step: message.step });
await appendManualAccountRunRecordIfNeeded(`step${message.step}_failed`, null, message.error);
if (!(isSignupPhonePasswordMismatch && currentNodeStatus === 'failed')) {
await setNodeStatus(nodeId, 'failed');
await addLog(`失败:${message.error}`, 'error', {
nodeId,
});
await appendManualAccountRunRecordIfNeeded(`node:${nodeId}:failed`, null, message.error);
}
notifyStepError(message.step, message.error);
notifyNodeError(nodeId, message.error);
}
return { ok: true };
}
@@ -772,6 +879,7 @@
case 'RESOLVE_PLUS_MANUAL_CONFIRMATION': {
const currentState = await getState();
const step = Number(message.payload?.step) || Number(currentState?.plusManualConfirmationStep) || 0;
const confirmationNodeId = getStepKeyForState(step, currentState) || String(currentState?.currentNodeId || '').trim();
const confirmed = Boolean(message.payload?.confirmed);
const requestId = String(message.payload?.requestId || '').trim();
const currentRequestId = String(currentState?.plusManualConfirmationRequestId || '').trim();
@@ -818,7 +926,7 @@
if (confirmed) {
const methodLabel = method === 'gopay' ? 'GoPay' : '手动';
await addLog(`步骤 ${step}:已确认${methodLabel}订阅完成,准备继续下一步。`, 'ok');
await completeStepFromBackground(step, {
await completeNodeFromBackground(confirmationNodeId, {
plusManualConfirmationMethod: currentState?.plusManualConfirmationMethod || '',
plusManualConfirmedAt: Date.now(),
});
@@ -828,10 +936,14 @@
const cancelMessage = method === 'gopay'
? '已取消 GoPay 订阅确认'
: (isGpcOtp ? '已取消 GPC OTP 输入' : '已取消当前手动确认');
await setStepStatus(step, 'failed');
await setNodeStatus(confirmationNodeId, 'failed');
await addLog(`步骤 ${step}${cancelMessage}`, 'warn');
await appendManualAccountRunRecordIfNeeded(`step${step}_failed`, null, cancelMessage);
notifyStepError(step, cancelMessage);
await appendManualAccountRunRecordIfNeeded(
confirmationNodeId ? `node:${confirmationNodeId}:failed` : 'failed',
null,
cancelMessage
);
notifyNodeError(confirmationNodeId, cancelMessage);
return { ok: true };
}
@@ -864,7 +976,7 @@
case 'SET_CONTRIBUTION_MODE': {
const enabled = Boolean(message.payload?.enabled);
const state = await ensureManualInteractionAllowed(enabled ? '进入贡献模式' : '退出贡献模式');
if (Object.values(state.stepStatuses || {}).some((status) => status === 'running')) {
if (Object.values(state.nodeStatuses || {}).some((status) => status === 'running')) {
throw new Error(enabled ? '当前有步骤正在执行,无法进入贡献模式。' : '当前有步骤正在执行,无法退出贡献模式。');
}
if (typeof setContributionMode !== 'function') {
@@ -878,7 +990,7 @@
case 'START_CONTRIBUTION_FLOW': {
const state = await ensureManualInteractionAllowed('开始贡献');
if (Object.values(state.stepStatuses || {}).some((status) => status === 'running')) {
if (Object.values(state.nodeStatuses || {}).some((status) => status === 'running')) {
throw new Error('当前有步骤正在执行,无法开始贡献流程。');
}
if (typeof startContributionFlow !== 'function') {
@@ -950,18 +1062,23 @@
return { ok: true, ...result };
}
case 'EXECUTE_STEP': {
case 'EXECUTE_NODE': {
clearStopRequest();
const requestState = await getState();
const nodeId = String(message.nodeId || message.payload?.nodeId || '').trim();
const resolvedStep = findStepByNodeId(nodeId, requestState);
if (!nodeId || !resolvedStep) {
throw new Error('EXECUTE_NODE 缺少 nodeId。');
}
if (message.source === 'sidepanel') {
await lockAutomationWindowFromMessage(message, sender);
await ensureManualInteractionAllowed('手动执行步骤');
}
const step = message.payload.step;
if (message.source === 'sidepanel') {
await ensureManualStepPrerequisites(step);
await ensureManualInteractionAllowed('手动执行节点');
}
if (message.source === 'sidepanel') {
await invalidateDownstreamAfterStepRestart(step, { logLabel: `步骤 ${step} 重新执行` });
await ensureManualStepPrerequisites(resolvedStep);
}
if (message.source === 'sidepanel') {
await invalidateDownstreamAfterStepRestart(resolvedStep, { logLabel: `节点 ${nodeId} 重新执行` });
}
if (message.payload.email) {
await setEmailState(message.payload.email);
@@ -971,10 +1088,10 @@
await setState({ emailPrefix: message.payload.emailPrefix });
}
const executionState = await getState();
if (doesStepUseCompletionSignal(step, executionState)) {
await executeStepViaCompletionSignal(step);
if (doesNodeUseCompletionSignal(nodeId, executionState)) {
await executeNodeViaCompletionSignal(nodeId);
} else {
await executeStep(step);
await executeNode(nodeId);
}
return { ok: true };
}
@@ -1094,9 +1211,12 @@
return { ok: true };
}
case 'SKIP_STEP': {
const step = Number(message.payload?.step);
return await skipStep(step);
case 'SKIP_NODE': {
const nodeId = String(message.nodeId || message.payload?.nodeId || '').trim();
if (!nodeId) {
throw new Error('SKIP_NODE 缺少 nodeId。');
}
return await skipNode(nodeId);
}
case 'SAVE_SETTING': {
@@ -1156,10 +1276,11 @@
}
if (stepModeChanged && typeof getStepIdsForState === 'function') {
const nextStateForSteps = { ...currentState, ...stateUpdates };
stateUpdates.stepStatuses = Object.fromEntries(
getStepIdsForState(nextStateForSteps).map((stepId) => [stepId, 'pending'])
);
stateUpdates.currentStep = 0;
const nextNodeIds = typeof getNodeIdsForState === 'function'
? getNodeIdsForState(nextStateForSteps)
: getStepIdsForState(nextStateForSteps).map((stepId) => getStepKeyForState(stepId, nextStateForSteps)).filter(Boolean);
stateUpdates.nodeStatuses = Object.fromEntries(nextNodeIds.map((nodeId) => [nodeId, 'pending']));
stateUpdates.currentNodeId = '';
}
await setState(stateUpdates);
const mergedState = await getState();
+52 -99
View File
@@ -4,8 +4,7 @@
function createRuntimeStateHelpers(deps = {}) {
const {
DEFAULT_ACTIVE_FLOW_ID = 'openai',
defaultStepStatuses = {},
getStepDefinitionForState = null,
defaultNodeStatuses = {},
} = deps;
const RUNTIME_SHARED_FIELDS = Object.freeze([
@@ -150,11 +149,6 @@
return String(value || '').trim();
}
function normalizeStepNumber(value) {
const step = Math.floor(Number(value) || 0);
return step > 0 ? step : 0;
}
function normalizeNodeStatus(value = '') {
const normalized = String(value || '').trim().toLowerCase();
if (!normalized) {
@@ -163,73 +157,26 @@
return normalized;
}
function buildDefaultStepStatuses() {
function buildDefaultNodeStatuses() {
return Object.fromEntries(
Object.entries(normalizePlainObject(defaultStepStatuses)).map(([key, value]) => [
Object.entries(normalizePlainObject(defaultNodeStatuses)).map(([key, value]) => [
String(key),
normalizeNodeStatus(value),
])
);
}
function normalizeStepStatuses(value) {
const base = buildDefaultStepStatuses();
function normalizeNodeStatuses(value) {
const base = buildDefaultNodeStatuses();
if (!isPlainObject(value)) {
return base;
}
const next = { ...base };
for (const [key, status] of Object.entries(value)) {
const step = normalizeStepNumber(key);
if (!step) continue;
next[String(step)] = normalizeNodeStatus(status);
}
return next;
}
function normalizeLegacyStepCompat(value = {}, state = {}) {
const candidate = normalizePlainObject(value);
const currentStep = normalizeStepNumber(
Object.prototype.hasOwnProperty.call(state, 'currentStep')
? state.currentStep
: candidate.currentStep
);
const stepStatuses = normalizeStepStatuses(
Object.prototype.hasOwnProperty.call(state, 'stepStatuses')
? state.stepStatuses
: candidate.stepStatuses
);
return {
currentStep,
stepStatuses,
};
}
function resolveStepKey(step, state = {}) {
const numericStep = normalizeStepNumber(step);
if (!numericStep || typeof getStepDefinitionForState !== 'function') {
return '';
}
return String(getStepDefinitionForState(numericStep, state)?.key || '').trim();
}
function normalizeNodeStatuses(value, state = {}, legacyStepCompat = null) {
if (isPlainObject(value) && Object.keys(value).length > 0) {
return Object.fromEntries(
Object.entries(value)
.map(([key, status]) => [String(key || '').trim(), normalizeNodeStatus(status)])
.filter(([key]) => Boolean(key))
);
}
const compat = legacyStepCompat || normalizeLegacyStepCompat({}, state);
const next = {};
for (const [stepKey, status] of Object.entries(compat.stepStatuses || {})) {
const step = normalizeStepNumber(stepKey);
if (!step) continue;
const nodeKey = resolveStepKey(step, state) || `step-${step}`;
next[nodeKey] = normalizeNodeStatus(status);
const nodeId = String(key || '').trim();
if (!nodeId) continue;
next[nodeId] = normalizeNodeStatus(status);
}
return next;
}
@@ -298,6 +245,8 @@
function buildRuntimeStateDefault() {
return {
flowId: DEFAULT_ACTIVE_FLOW_ID,
runId: '',
activeFlowId: DEFAULT_ACTIVE_FLOW_ID,
activeRunId: '',
currentNodeId: '',
@@ -316,10 +265,6 @@
identity: {},
},
},
legacyStepCompat: {
currentStep: 0,
stepStatuses: buildDefaultStepStatuses(),
},
};
}
@@ -329,30 +274,45 @@
...cloneValue(normalizePlainObject(state.runtimeState)),
};
const activeFlowId = normalizeFlowId(
Object.prototype.hasOwnProperty.call(state, 'activeFlowId')
Object.prototype.hasOwnProperty.call(state, 'flowId')
? state.flowId
: Object.prototype.hasOwnProperty.call(state, 'activeFlowId')
? state.activeFlowId
: Object.prototype.hasOwnProperty.call(baseRuntimeState, 'flowId')
? baseRuntimeState.flowId
: baseRuntimeState.activeFlowId
);
const legacyStepCompat = normalizeLegacyStepCompat(baseRuntimeState.legacyStepCompat, state);
const currentNodeId = String(
Object.prototype.hasOwnProperty.call(state, 'currentNodeId')
? state.currentNodeId
: (baseRuntimeState.currentNodeId || resolveStepKey(legacyStepCompat.currentStep, state))
: baseRuntimeState.currentNodeId
).trim();
const nodeStatuses = normalizeNodeStatuses(
Object.prototype.hasOwnProperty.call(state, 'nodeStatuses')
? state.nodeStatuses
: baseRuntimeState.nodeStatuses,
state,
legacyStepCompat
: baseRuntimeState.nodeStatuses
);
return {
...baseRuntimeState,
flowId: activeFlowId,
activeFlowId,
activeRunId: normalizeRunId(
Object.prototype.hasOwnProperty.call(state, 'activeRunId')
runId: normalizeRunId(
Object.prototype.hasOwnProperty.call(state, 'runId')
? state.runId
: Object.prototype.hasOwnProperty.call(state, 'activeRunId')
? state.activeRunId
: Object.prototype.hasOwnProperty.call(baseRuntimeState, 'runId')
? baseRuntimeState.runId
: baseRuntimeState.activeRunId
),
activeRunId: normalizeRunId(
Object.prototype.hasOwnProperty.call(state, 'runId')
? state.runId
: Object.prototype.hasOwnProperty.call(state, 'activeRunId')
? state.activeRunId
: Object.prototype.hasOwnProperty.call(baseRuntimeState, 'runId')
? baseRuntimeState.runId
: baseRuntimeState.activeRunId
),
currentNodeId,
@@ -360,16 +320,18 @@
sharedState: buildSharedState(baseRuntimeState.sharedState, state),
serviceState: buildServiceState(baseRuntimeState.serviceState, state),
flowState: buildOpenAiFlowState(baseRuntimeState.flowState, state),
legacyStepCompat,
};
}
function buildFlattenedUpdates(updates = {}) {
const next = {
...updates,
};
const ignoredKeys = new Set(['current' + 'Step', 'step' + 'Statuses', 'legacy' + 'StepCompat']);
const next = {};
for (const [key, value] of Object.entries(updates || {})) {
if (!ignoredKeys.has(key)) {
next[key] = value;
}
}
const runtimeState = normalizePlainObject(updates.runtimeState);
const legacyStepCompat = normalizePlainObject(updates.legacyStepCompat);
const sharedState = normalizePlainObject(updates.sharedState);
const serviceState = normalizePlainObject(updates.serviceState);
const flowState = normalizePlainObject(updates.flowState);
@@ -377,31 +339,23 @@
if (Object.prototype.hasOwnProperty.call(runtimeState, 'activeFlowId')) {
next.activeFlowId = runtimeState.activeFlowId;
}
if (Object.prototype.hasOwnProperty.call(runtimeState, 'flowId')) {
next.flowId = runtimeState.flowId;
next.activeFlowId = runtimeState.flowId;
}
if (Object.prototype.hasOwnProperty.call(runtimeState, 'activeRunId')) {
next.activeRunId = runtimeState.activeRunId;
}
if (Object.prototype.hasOwnProperty.call(runtimeState, 'runId')) {
next.runId = runtimeState.runId;
next.activeRunId = runtimeState.runId;
}
if (Object.prototype.hasOwnProperty.call(runtimeState, 'currentNodeId')) {
next.currentNodeId = runtimeState.currentNodeId;
}
if (Object.prototype.hasOwnProperty.call(runtimeState, 'nodeStatuses')) {
next.nodeStatuses = cloneValue(runtimeState.nodeStatuses);
}
if (Object.prototype.hasOwnProperty.call(legacyStepCompat, 'currentStep')) {
next.currentStep = legacyStepCompat.currentStep;
}
if (Object.prototype.hasOwnProperty.call(legacyStepCompat, 'stepStatuses')) {
next.stepStatuses = cloneValue(legacyStepCompat.stepStatuses);
}
if (Object.prototype.hasOwnProperty.call(runtimeState, 'legacyStepCompat')) {
const compatCandidate = normalizePlainObject(runtimeState.legacyStepCompat);
if (Object.prototype.hasOwnProperty.call(compatCandidate, 'currentStep')) {
next.currentStep = compatCandidate.currentStep;
}
if (Object.prototype.hasOwnProperty.call(compatCandidate, 'stepStatuses')) {
next.stepStatuses = cloneValue(compatCandidate.stepStatuses);
}
}
Object.assign(next, pickDefinedFields(sharedState, RUNTIME_SHARED_FIELDS));
if (Object.prototype.hasOwnProperty.call(runtimeState, 'sharedState')) {
Object.assign(
@@ -432,6 +386,8 @@
const runtimeState = ensureRuntimeState(state);
return {
...state,
flowId: runtimeState.flowId,
runId: runtimeState.runId,
activeFlowId: runtimeState.activeFlowId,
activeRunId: runtimeState.activeRunId,
currentNodeId: runtimeState.currentNodeId,
@@ -439,9 +395,6 @@
flowState: cloneValue(runtimeState.flowState),
sharedState: cloneValue(runtimeState.sharedState),
serviceState: cloneValue(runtimeState.serviceState),
legacyStepCompat: cloneValue(runtimeState.legacyStepCompat),
currentStep: runtimeState.legacyStepCompat.currentStep,
stepStatuses: cloneValue(runtimeState.legacyStepCompat.stepStatuses),
runtimeState,
};
}
@@ -456,12 +409,12 @@
return {
...flattenedUpdates,
flowId: runtimeState.flowId,
runId: runtimeState.runId,
activeFlowId: runtimeState.activeFlowId,
activeRunId: runtimeState.activeRunId,
currentNodeId: runtimeState.currentNodeId,
nodeStatuses: cloneValue(runtimeState.nodeStatuses),
currentStep: runtimeState.legacyStepCompat.currentStep,
stepStatuses: cloneValue(runtimeState.legacyStepCompat.stepStatuses),
runtimeState,
};
}
+2 -2
View File
@@ -7,7 +7,7 @@
chrome,
cleanupStep8NavigationListeners,
clickWithDebugger,
completeStepFromBackground,
completeNodeFromBackground,
ensureStep8SignupPageReady,
getOAuthFlowRemainingMs,
getOAuthFlowStepTimeoutMs,
@@ -122,7 +122,7 @@
cleanupListener();
addStepLog(visibleStep, `已捕获 localhost 地址:${callbackUrl}`, 'ok').then(() => {
return completeStepFromBackground(visibleStep, { localhostUrl: callbackUrl });
return completeNodeFromBackground(state?.nodeId || 'confirm-oauth', { localhostUrl: callbackUrl });
}).then(() => {
resolve();
}).catch((err) => {
+3 -3
View File
@@ -15,7 +15,7 @@
const {
addLog: rawAddLog = async () => {},
chrome,
completeStepFromBackground,
completeNodeFromBackground,
createAutomationTab = null,
ensureContentScriptReadyOnTabUntilStopped,
fetch: fetchImpl = null,
@@ -457,7 +457,7 @@
gopayHelperOrderCreatedAt: result.orderCreatedAt || Date.now(),
});
await addLog(`步骤 6GPC ${result.phoneMode === GPC_HELPER_PHONE_MODE_AUTO ? '自动' : '手动'}模式任务已创建(task_id: ${result.taskId}),准备继续下一步。`, 'info');
await completeStepFromBackground(6, {
await completeNodeFromBackground('plus-checkout-create', {
plusCheckoutCountry: result.country || 'ID',
plusCheckoutCurrency: result.currency || 'IDR',
plusCheckoutSource: result.checkoutSource,
@@ -517,7 +517,7 @@
await addLog(`步骤 6Plus Checkout 页面已就绪(${paymentMethodLabel} / ${result.country || 'DE'} ${result.currency || 'EUR'}),准备继续下一步。`, 'info');
await completeStepFromBackground(6, {
await completeNodeFromBackground('plus-checkout-create', {
plusCheckoutCountry: result.country || 'DE',
plusCheckoutCurrency: result.currency || 'EUR',
});
+10 -10
View File
@@ -11,7 +11,7 @@
chrome,
CLOUDFLARE_TEMP_EMAIL_PROVIDER,
CLOUD_MAIL_PROVIDER = 'cloudmail',
completeStepFromBackground,
completeNodeFromBackground,
confirmCustomVerificationStepBypass,
ensureMail2925MailboxSession,
ensureIcloudMailSession,
@@ -225,8 +225,8 @@
`步骤 ${visibleStep}:当前认证页已进入 OAuth 授权页${fromRecovery ? '(轮询失败后复核)' : ''},跳过登录验证码拉取并继续后续流程。`,
'warn'
);
if (typeof completeStepFromBackground === 'function') {
await completeStepFromBackground(visibleStep, {
if (typeof completeNodeFromBackground === 'function') {
await completeNodeFromBackground(options.nodeId || 'fetch-login-code', {
loginVerificationRequestedAt: null,
skipLoginVerificationStep: true,
directOAuthConsentPage: true,
@@ -316,7 +316,7 @@
),
});
if (pageState?.state === 'oauth_consent_page') {
await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep, { fromRecovery: true });
await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep, { fromRecovery: true, nodeId: currentState?.nodeId });
return { outcome: 'completed' };
}
if (pageState?.state === 'verification_page' || pageState?.state === 'phone_verification_page' || pageState?.state === 'add_email_page') {
@@ -401,7 +401,7 @@
visibleStep,
});
await completeStepFromBackground(visibleStep, {
await completeNodeFromBackground(state?.nodeId || 'fetch-login-code', {
phoneVerification: true,
loginPhoneVerification: true,
code: result?.code || '',
@@ -438,7 +438,7 @@
timeoutMs: await getStep8ReadyTimeoutMs('确认登录验证码页已就绪', state?.oauthUrl || '', visibleStep),
});
if (pageState?.state === 'oauth_consent_page') {
await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep);
await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep, { nodeId: state?.nodeId });
return;
}
if (pageState?.state === 'phone_verification_page') {
@@ -450,7 +450,7 @@
preparedState = addEmailPreparation?.state || preparedState;
pageState = addEmailPreparation?.pageState || pageState;
if (pageState?.state === 'oauth_consent_page') {
await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep);
await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep, { nodeId: preparedState?.nodeId || state?.nodeId });
return;
}
if (pageState?.state === 'phone_verification_page') {
@@ -576,7 +576,7 @@
let stickyLastResendAt = Number(state?.loginVerificationRequestedAt) || 0;
let retryWithoutStep7Streak = 0;
const maxRetryWithoutStep7Streak = 3;
let currentStepRecoveryAttempt = 0;
let currentNodeRecoveryAttempt = 0;
while (true) {
try {
@@ -601,8 +601,8 @@
let retryWithoutStep7 = false;
if (isStep8EmailInUseError(currentError) || isStep8MaxCheckAttemptsError(currentError)) {
currentStepRecoveryAttempt += 1;
if (currentStepRecoveryAttempt > STEP8_CURRENT_STEP_RECOVERY_MAX_ATTEMPTS) {
currentNodeRecoveryAttempt += 1;
if (currentNodeRecoveryAttempt > STEP8_CURRENT_STEP_RECOVERY_MAX_ATTEMPTS) {
throw currentError;
}
if (isStep8EmailInUseError(currentError)) {
+3 -3
View File
@@ -7,7 +7,7 @@
const {
addLog,
chrome,
completeStepFromBackground,
completeNodeFromBackground,
confirmCustomVerificationStepBypass,
generateRandomBirthday,
generateRandomName,
@@ -83,7 +83,7 @@
return result || {};
}
await completeStepFromBackground(4, {
await completeNodeFromBackground('fetch-signup-code', {
phoneVerification: true,
code: result?.code || '',
...(result?.skipProfileStep ? { skipProfileStep: true } : {}),
@@ -280,7 +280,7 @@
throw new Error(prepareResult.error);
}
if (prepareResult?.alreadyVerified) {
await completeStepFromBackground(4, prepareResult?.skipProfileStep ? { skipProfileStep: true } : {});
await completeNodeFromBackground('fetch-signup-code', prepareResult?.skipProfileStep ? { skipProfileStep: true } : {});
return;
}
+2 -1
View File
@@ -105,7 +105,8 @@
`步骤 3:正在填写密码,${identityLabel},密码为${state.customPassword ? '自定义' : '自动生成'}${password.length} 位)`
);
await sendToContentScript('signup-page', {
type: 'EXECUTE_STEP',
type: 'EXECUTE_NODE',
nodeId: 'fill-password',
step: 3,
source: 'background',
payload: {
+3 -3
View File
@@ -79,7 +79,7 @@
addLog: rawAddLog = async () => {},
broadcastDataUpdate,
chrome,
completeStepFromBackground,
completeNodeFromBackground,
ensureContentScriptReadyOnTabUntilStopped,
fetch: fetchImpl = null,
generateRandomName,
@@ -1018,7 +1018,7 @@
plusCheckoutSource: PLUS_PAYMENT_METHOD_GPC_HELPER,
});
await addLog('步骤 7:GPC 任务已完成,准备继续下一步。', 'ok');
await completeStepFromBackground(7, {
await completeNodeFromBackground('plus-checkout-billing', {
plusCheckoutSource: PLUS_PAYMENT_METHOD_GPC_HELPER,
});
return;
@@ -1920,7 +1920,7 @@
throw new Error(`步骤 7:多次提交账单地址后仍未跳转到 ${paymentConfig.label}${lastSubmitError}`);
}
await completeStepFromBackground(7, {
await completeNodeFromBackground('plus-checkout-billing', {
plusBillingCountryText: result?.countryText || '',
});
}
+9 -2
View File
@@ -16,10 +16,17 @@
await addLog(`步骤 5:已生成姓名 ${firstName} ${lastName},生日 ${year}-${month}-${day}`);
await sendToContentScript('signup-page', {
type: 'EXECUTE_STEP',
type: 'EXECUTE_NODE',
nodeId: 'fill-profile',
step: 5,
source: 'background',
payload: { firstName, lastName, year, month, day },
payload: {
firstName,
lastName,
year,
month,
day,
},
});
}
+2 -2
View File
@@ -17,7 +17,7 @@
const {
addLog,
chrome,
completeStepFromBackground,
completeNodeFromBackground,
ensureContentScriptReadyOnTabUntilStopped,
getTabId,
isTabAlive,
@@ -1433,7 +1433,7 @@
}
await setState({ plusGoPayApprovedAt: Date.now() });
await completeStepFromBackground(8, {
await completeNodeFromBackground('paypal-approve', {
plusGoPayApprovedAt: Date.now(),
});
}
+5 -4
View File
@@ -4,7 +4,7 @@
function createStep7Executor(deps = {}) {
const {
addLog,
completeStepFromBackground,
completeNodeFromBackground,
getErrorMessage,
getLoginAuthStateLabel,
getOAuthFlowStepTimeoutMs,
@@ -131,7 +131,7 @@
step: completionStep,
visibleStep: completionStep,
});
await completeStepFromBackground(completionStep, {
await completeNodeFromBackground(state?.nodeId || 'oauth-login', {
loginVerificationRequestedAt: null,
skipLoginVerificationStep: true,
directOAuthConsentPage: true,
@@ -225,7 +225,8 @@
const result = await sendToContentScriptResilient(
'signup-page',
{
type: 'EXECUTE_STEP',
type: 'EXECUTE_NODE',
nodeId: 'oauth-login',
step: 7,
source: 'background',
payload: {
@@ -277,7 +278,7 @@
completionPayload.directOAuthConsentPage = Boolean(result.directOAuthConsentPage);
}
await completeStepFromBackground(completionStep, completionPayload);
await completeNodeFromBackground(state?.nodeId || 'oauth-login', completionPayload);
return;
}
+2 -2
View File
@@ -102,7 +102,7 @@
const {
addLog,
chrome: chromeApi = globalThis.chrome,
completeStepFromBackground,
completeNodeFromBackground,
openSignupEntryTab,
} = deps;
@@ -139,7 +139,7 @@
await clearOpenAiCookiesBeforeStep1();
await addLog('步骤 1:正在打开 ChatGPT 官网...');
await openSignupEntryTab(1);
await completeStepFromBackground(1, {});
await completeNodeFromBackground('open-chatgpt', {});
}
return { executeStep1 };
+2 -2
View File
@@ -11,7 +11,7 @@
const {
addLog,
chrome,
completeStepFromBackground,
completeNodeFromBackground,
ensureContentScriptReadyOnTabUntilStopped,
getTabId,
isTabAlive,
@@ -294,7 +294,7 @@
await sleepWithStop(500);
}
await completeStepFromBackground(8, {
await completeNodeFromBackground('paypal-approve', {
plusPaypalApprovedAt: Date.now(),
});
}
+5 -5
View File
@@ -6,7 +6,7 @@
addLog,
chrome,
closeConflictingTabsForSource,
completeStepFromBackground,
completeNodeFromBackground,
ensureContentScriptReadyOnTab,
getPanelMode,
getTabId,
@@ -253,7 +253,7 @@
if (shouldBypassStep9ForLocalCpa(state)) {
await addStepLog(platformVerifyStep, '检测到本地 CPA,且当前策略为“跳过平台回调验证”,本轮不再重复提交回调地址。', 'info');
await completeStepFromBackground(platformVerifyStep, {
await completeNodeFromBackground(state?.nodeId || 'platform-verify', {
localhostUrl: state.localhostUrl,
verifiedStatus: 'local-auto',
});
@@ -286,7 +286,7 @@
|| normalizeString(result?.status_message)
|| 'CPA 已通过接口提交回调';
await addStepLog(platformVerifyStep, verifiedStatus, 'ok');
await completeStepFromBackground(platformVerifyStep, {
await completeNodeFromBackground(state?.nodeId || 'platform-verify', {
localhostUrl: callback.url,
verifiedStatus,
});
@@ -336,7 +336,7 @@
const verifiedStatus = normalizeString(result?.message) || 'Codex2API OAuth 账号添加成功';
await addStepLog(platformVerifyStep, verifiedStatus, 'ok');
await completeStepFromBackground(platformVerifyStep, {
await completeNodeFromBackground(state?.nodeId || 'platform-verify', {
localhostUrl: callback.url,
verifiedStatus,
});
@@ -381,7 +381,7 @@
logOptions: { step: visibleStep, stepKey: 'platform-verify' },
timeoutMs: SUB2API_STEP9_RESPONSE_TIMEOUT_MS,
});
await completeStepFromBackground(platformVerifyStep, result);
await completeNodeFromBackground(state?.nodeId || 'platform-verify', result);
return;
} catch (error) {
lastError = error;
+2 -2
View File
@@ -9,7 +9,7 @@
function createPlusReturnConfirmExecutor(deps = {}) {
const {
addLog,
completeStepFromBackground,
completeNodeFromBackground,
getTabId,
isTabAlive,
setState,
@@ -53,7 +53,7 @@
plusCheckoutTabId: tabId,
plusReturnUrl: tab?.url || '',
});
await completeStepFromBackground(9, {
await completeNodeFromBackground('plus-checkout-return', {
plusReturnUrl: tab?.url || '',
});
}
+20 -19
View File
@@ -1,48 +1,49 @@
(function attachBackgroundStepRegistry(root, factory) {
root.MultiPageBackgroundStepRegistry = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundStepRegistryModule() {
function createStepRegistry(definitions = []) {
function createNodeRegistry(definitions = []) {
const ordered = (Array.isArray(definitions) ? definitions : [])
.map((definition) => ({
id: Number(definition?.id),
order: Number(definition?.order),
key: String(definition?.key || '').trim(),
nodeId: String(definition?.nodeId || definition?.key || '').trim(),
displayOrder: Number(definition?.displayOrder ?? definition?.order),
executeKey: String(definition?.executeKey || definition?.key || definition?.nodeId || '').trim(),
title: String(definition?.title || '').trim(),
execute: definition?.execute,
}))
.filter((definition) => Number.isFinite(definition.id) && typeof definition.execute === 'function')
.filter((definition) => definition.nodeId && typeof definition.execute === 'function')
.sort((left, right) => {
const leftOrder = Number.isFinite(left.order) ? left.order : left.id;
const rightOrder = Number.isFinite(right.order) ? right.order : right.id;
return leftOrder - rightOrder;
const leftOrder = Number.isFinite(left.displayOrder) ? left.displayOrder : 0;
const rightOrder = Number.isFinite(right.displayOrder) ? right.displayOrder : 0;
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
return left.nodeId.localeCompare(right.nodeId);
});
const byId = new Map(ordered.map((definition) => [definition.id, definition]));
const byId = new Map(ordered.map((definition) => [definition.nodeId, definition]));
function getStepDefinition(step) {
return byId.get(Number(step)) || null;
function getNodeDefinition(nodeId) {
return byId.get(String(nodeId || '').trim()) || null;
}
function getOrderedSteps() {
function getOrderedNodes() {
return ordered.slice();
}
function executeStep(step, state) {
const definition = getStepDefinition(step);
function executeNode(nodeId, state) {
const definition = getNodeDefinition(nodeId);
if (!definition) {
throw new Error(`未知步骤${step}`);
throw new Error(`未知节点${nodeId}`);
}
return definition.execute(state);
}
return {
executeStep,
getOrderedSteps,
getStepDefinition,
executeNode,
getNodeDefinition,
getOrderedNodes,
};
}
return {
createStepRegistry,
createNodeRegistry,
};
});
+5 -4
View File
@@ -5,7 +5,7 @@
const {
addLog,
chrome,
completeStepFromBackground,
completeNodeFromBackground,
ensureContentScriptReadyOnTab,
ensureSignupAuthEntryPageReady,
ensureSignupEntryPageReady,
@@ -152,7 +152,8 @@
try {
return await sendToContentScriptResilient('signup-page', {
type: 'EXECUTE_STEP',
type: 'EXECUTE_NODE',
nodeId: 'submit-signup-email',
step: 2,
source: 'background',
payload,
@@ -391,7 +392,7 @@
skipUrlWait: Boolean(step2Result?.alreadyOnPasswordPage),
});
await completeStepFromBackground(2, {
await completeNodeFromBackground('submit-signup-email', {
accountIdentifierType: 'phone',
accountIdentifier: phoneNumber,
signupPhoneNumber: phoneNumber,
@@ -484,7 +485,7 @@
skipUrlWait: Boolean(step2Result?.alreadyOnPasswordPage),
});
await completeStepFromBackground(2, {
await completeNodeFromBackground('submit-signup-email', {
email: resolvedEmail,
accountIdentifierType: 'email',
accountIdentifier: resolvedEmail,
@@ -99,7 +99,7 @@
const {
addLog = async () => {},
chrome: chromeApi = globalThis.chrome,
completeStepFromBackground,
completeNodeFromBackground,
getErrorMessage = (error) => error?.message || String(error || '未知错误'),
registrationSuccessWaitMs = DEFAULT_REGISTRATION_SUCCESS_WAIT_MS,
sleepWithStop = async (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms) || 0))),
@@ -149,7 +149,7 @@
}
await clearCookiesIfEnabled(state);
await addLog('步骤 6:注册成功等待完成,准备继续获取 OAuth 链接并登录。', 'ok');
await completeStepFromBackground(6);
await completeNodeFromBackground('wait-registration-success');
}
return { executeStep6 };
+1 -1
View File
@@ -593,7 +593,7 @@
function getContentScriptResponseTimeoutMs(message) {
if (!message || typeof message !== 'object') return 30000;
if (message.type === 'EXECUTE_STEP' && Number(message.step) === 6) return 75000;
if (message.type === 'EXECUTE_NODE' && String(message.nodeId || message.payload?.nodeId || '').trim() === 'wait-registration-success') return 75000;
if (message.type === 'POLL_EMAIL') {
const maxAttempts = Math.max(1, Number(message.payload?.maxAttempts) || 1);
const intervalMs = Math.max(0, Number(message.payload?.intervalMs) || 0);
+20 -3
View File
@@ -12,8 +12,9 @@
closeConflictingTabsForSource,
CLOUDFLARE_TEMP_EMAIL_PROVIDER,
CLOUD_MAIL_PROVIDER = 'cloudmail',
completeStepFromBackground,
completeNodeFromBackground,
confirmCustomVerificationStepBypassRequest,
getNodeIdByStepForState,
getHotmailVerificationPollConfig,
getHotmailVerificationRequestTimestamp,
handleMail2925LimitReachedError,
@@ -32,6 +33,7 @@
sendToContentScript,
sendToContentScriptResilient,
sendToMailContentScriptResilient,
setNodeStatus,
setState,
sleepWithStop,
throwIfStopped,
@@ -65,6 +67,13 @@
return rawAddLog(normalizeVerificationLogMessage(message), level, normalizedOptions);
}
async function getNodeIdForStep(step) {
const state = typeof getState === 'function' ? await getState() : {};
return typeof getNodeIdByStepForState === 'function'
? String(getNodeIdByStepForState(step, state) || '').trim()
: '';
}
const isRetryableVerificationTransportError = typeof deps.isRetryableContentScriptTransportError === 'function'
? deps.isRetryableContentScriptTransportError
: ((error) => /back\/forward cache|message channel is closed|Receiving end does not exist|port closed before a response was received|A listener indicated an asynchronous response|did not respond in \d+s/i.test(
@@ -404,7 +413,11 @@
signupVerificationRequestedAt: null,
loginVerificationRequestedAt: null,
});
await deps.setStepStatus(completionStep, 'skipped');
const completionNodeId = await getNodeIdForStep(completionStep);
if (!completionNodeId) {
throw new Error(`步骤 ${completionStep} 未映射到验证码节点。`);
}
await setNodeStatus(completionNodeId, 'skipped');
await addLog(`步骤 ${completionStep}:已确认手动完成${verificationLabel}验证码输入,当前步骤已跳过。`, 'warn');
}
@@ -1387,7 +1400,11 @@
[stateKey]: result.code,
});
await completeStepFromBackground(completionStep, {
const completionNodeId = await getNodeIdForStep(completionStep);
if (!completionNodeId) {
throw new Error(`步骤 ${completionStep} 未映射到验证码节点。`);
}
await completeNodeFromBackground(completionNodeId, {
emailTimestamp: result.emailTimestamp,
code: result.code,
phoneVerificationRequired: Boolean(submitResult.addPhonePage),
+157
View File
@@ -0,0 +1,157 @@
(function attachBackgroundWorkflowEngine(root, factory) {
root.MultiPageBackgroundWorkflowEngine = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundWorkflowEngineModule() {
function createWorkflowEngine(deps = {}) {
const {
defaultFlowId = 'openai',
workflowDefinitions = null,
} = deps;
function normalizeFlowId(value = '') {
const normalized = String(value || '').trim().toLowerCase();
return normalized || defaultFlowId;
}
function normalizeNodeId(value = '') {
return String(value || '').trim();
}
function resolveStateFlowId(state = {}) {
return normalizeFlowId(state?.flowId || state?.activeFlowId || defaultFlowId);
}
function getWorkflow(options = {}) {
const flowId = normalizeFlowId(options?.flowId || options?.activeFlowId || defaultFlowId);
if (workflowDefinitions?.getWorkflow) {
return workflowDefinitions.getWorkflow({
...options,
activeFlowId: flowId,
flowId,
});
}
return {
flowId,
workflowVersion: 1,
nodes: [],
nodeIds: [],
};
}
function getWorkflowForState(state = {}) {
return getWorkflow({
...state,
flowId: resolveStateFlowId(state),
activeFlowId: resolveStateFlowId(state),
});
}
function getNodesForState(state = {}) {
return Array.isArray(getWorkflowForState(state).nodes)
? getWorkflowForState(state).nodes
: [];
}
function getNodeIdsForState(state = {}) {
return getNodesForState(state).map((node) => node.nodeId).filter(Boolean);
}
function getNodeById(nodeId, state = {}) {
const normalizedNodeId = normalizeNodeId(nodeId);
if (!normalizedNodeId) {
return null;
}
return getNodesForState(state).find((node) => node.nodeId === normalizedNodeId) || null;
}
function getDisplayOrderForNode(nodeId, state = {}) {
const node = getNodeById(nodeId, state);
return Number.isFinite(Number(node?.displayOrder)) ? Number(node.displayOrder) : null;
}
function getNodeTitle(nodeId, state = {}) {
return getNodeById(nodeId, state)?.title || nodeId || '';
}
function normalizeNodeStatus(value = '') {
const normalized = String(value || '').trim().toLowerCase();
return normalized || 'pending';
}
function buildDefaultNodeStatuses(state = {}) {
return Object.fromEntries(getNodeIdsForState(state).map((nodeId) => [nodeId, 'pending']));
}
function normalizeNodeStatuses(statuses = {}, state = {}) {
const defaults = buildDefaultNodeStatuses(state);
const next = { ...defaults };
if (!statuses || typeof statuses !== 'object' || Array.isArray(statuses)) {
return next;
}
for (const [rawNodeId, rawStatus] of Object.entries(statuses)) {
const nodeId = normalizeNodeId(rawNodeId);
if (!nodeId || !Object.prototype.hasOwnProperty.call(defaults, nodeId)) {
continue;
}
next[nodeId] = normalizeNodeStatus(rawStatus);
}
return next;
}
function isNodeDoneStatus(status = '') {
return ['completed', 'manual_completed', 'skipped'].includes(normalizeNodeStatus(status));
}
function isNodeTerminalStatus(status = '') {
return ['completed', 'manual_completed', 'skipped', 'failed', 'stopped'].includes(normalizeNodeStatus(status));
}
function getRunningNodeIds(statuses = {}, state = {}) {
const normalizedStatuses = normalizeNodeStatuses(statuses, state);
return getNodeIdsForState(state).filter((nodeId) => normalizedStatuses[nodeId] === 'running');
}
function getFirstUnfinishedNodeId(statuses = {}, state = {}) {
const normalizedStatuses = normalizeNodeStatuses(statuses, state);
return getNodeIdsForState(state).find((nodeId) => !isNodeDoneStatus(normalizedStatuses[nodeId])) || '';
}
function hasSavedProgress(statuses = {}, state = {}) {
const normalizedStatuses = normalizeNodeStatuses(statuses, state);
return getNodeIdsForState(state).some((nodeId) => normalizeNodeStatus(normalizedStatuses[nodeId]) !== 'pending');
}
function getNextNodeIds(nodeId, state = {}) {
const node = getNodeById(nodeId, state);
if (!node) {
return [];
}
return Array.isArray(node.next) ? node.next.map(normalizeNodeId).filter(Boolean) : [];
}
return {
buildDefaultNodeStatuses,
getDisplayOrderForNode,
getFirstUnfinishedNodeId,
getNextNodeIds,
getNodeById,
getNodeIdsForState,
getNodesForState,
getNodeTitle,
getRunningNodeIds,
getWorkflow,
getWorkflowForState,
hasSavedProgress,
isNodeDoneStatus,
isNodeTerminalStatus,
normalizeFlowId,
normalizeNodeId,
normalizeNodeStatuses,
normalizeNodeStatus,
resolveStateFlowId,
};
}
return {
createWorkflowEngine,
};
});
+34 -10
View File
@@ -20,7 +20,7 @@ if (document.documentElement.getAttribute(SIGNUP_PAGE_LISTENER_SENTINEL) !== '1'
// Listen for commands from Background
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (
message.type === 'EXECUTE_STEP'
message.type === 'EXECUTE_NODE'
|| message.type === 'FILL_CODE'
|| message.type === 'STEP8_FIND_AND_CLICK'
|| message.type === 'STEP8_GET_STATE'
@@ -46,6 +46,7 @@ if (document.documentElement.getAttribute(SIGNUP_PAGE_LISTENER_SENTINEL) !== '1'
sendResponse({ ok: true, ...(result || {}) });
}).catch(err => {
const reportedStep = Number(message.payload?.visibleStep) || message.step;
const reportedNodeId = resolveCommandNodeId(message);
if (isStopError(err)) {
if (reportedStep) {
log(`步骤 ${reportedStep || 8}:已被用户停止。`, 'warn');
@@ -61,7 +62,7 @@ if (document.documentElement.getAttribute(SIGNUP_PAGE_LISTENER_SENTINEL) !== '1'
}
if (reportedStep) {
reportError(reportedStep, err.message);
reportError(reportedNodeId || reportedStep, err.message);
}
sendResponse({ error: err.message });
});
@@ -72,16 +73,39 @@ if (document.documentElement.getAttribute(SIGNUP_PAGE_LISTENER_SENTINEL) !== '1'
console.log('[MultiPage:signup-page] 消息监听已存在,跳过重复注册');
}
const SIGNUP_PAGE_NODE_HANDLERS = Object.freeze({
'submit-signup-email': (payload) => step2_clickRegister(payload),
'fill-password': (payload) => step3_fillEmailPassword(payload),
'fill-profile': (payload) => step5_fillNameBirthday(payload),
'oauth-login': (payload) => step6_login(payload),
'confirm-oauth': (_payload) => step8_findAndClick(),
});
function resolveCommandNodeId(message = {}) {
const directNodeId = String(message.nodeId || message.payload?.nodeId || '').trim();
if (directNodeId) {
return directNodeId;
}
const visibleStep = Number(message.payload?.visibleStep || message.step) || 0;
if (visibleStep === 4) return 'fetch-signup-code';
if (visibleStep === 8 || visibleStep === 11) return 'fetch-login-code';
if (visibleStep === 9 || visibleStep === 12) return 'confirm-oauth';
if (visibleStep === 7 || visibleStep === 10) return 'oauth-login';
if (visibleStep === 5) return 'fill-profile';
if (visibleStep === 3) return 'fill-password';
if (visibleStep === 2) return 'submit-signup-email';
return '';
}
async function handleCommand(message) {
switch (message.type) {
case 'EXECUTE_STEP':
switch (message.step) {
case 2: return await step2_clickRegister(message.payload);
case 3: return await step3_fillEmailPassword(message.payload);
case 5: return await step5_fillNameBirthday(message.payload);
case 7: return await step6_login(message.payload);
case 9: return await step8_findAndClick();
default: throw new Error(`signup-page.js 不处理步骤 ${message.step}`);
case 'EXECUTE_NODE': {
const nodeId = String(message.nodeId || message.payload?.nodeId || '').trim();
const handler = SIGNUP_PAGE_NODE_HANDLERS[nodeId];
if (!handler) {
throw new Error(`signup-page.js 不处理节点 ${nodeId}`);
}
return await handler(message.payload || {});
}
case 'FILL_CODE':
// Step 4 = signup code, Step 7 = login code (same handler)
+18 -7
View File
@@ -14,23 +14,23 @@ if (document.documentElement.getAttribute(SUB2API_PANEL_LISTENER_SENTINEL) !== '
document.documentElement.setAttribute(SUB2API_PANEL_LISTENER_SENTINEL, '1');
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'EXECUTE_STEP' || message.type === 'REQUEST_OAUTH_URL') {
if (message.type === 'EXECUTE_NODE' || message.type === 'REQUEST_OAUTH_URL') {
resetStopState();
const handler = message.type === 'REQUEST_OAUTH_URL'
? requestOAuthUrl(message.payload)
: handleStep(message.step, message.payload);
: handleNode(message.nodeId || message.payload?.nodeId, message.payload);
handler.then((result) => {
sendResponse({ ok: true, ...(result || {}) });
}).catch((err) => {
if (isStopError(err)) {
if (message.step) {
log('已被用户停止。', 'warn', { step: message.step });
if (message.payload?.visibleStep || message.step) {
log('已被用户停止。', 'warn', { step: message.payload?.visibleStep || message.step });
}
sendResponse({ stopped: true, error: err.message });
return;
}
if (message.step) {
reportError(message.step, err.message);
if (message.nodeId || message.payload?.nodeId) {
reportError(message.nodeId || message.payload?.nodeId, err.message);
}
sendResponse({ error: err.message });
});
@@ -76,6 +76,16 @@ async function handleStep(step, payload = {}) {
}
}
async function handleNode(nodeId, payload = {}) {
const normalizedNodeId = String(nodeId || '').trim();
switch (normalizedNodeId) {
case 'platform-verify':
return step9_submitOpenAiCallback(payload);
default:
throw new Error(`sub2api-panel.js 不处理节点 ${normalizedNodeId}`);
}
}
async function requestOAuthUrl(payload = {}) {
return step1_generateOpenAiAuthUrl(payload, { report: false });
}
@@ -665,9 +675,10 @@ async function step9_submitOpenAiCallback(payload = {}) {
const verifiedStatus = `SUB2API 已创建账号 #${createdAccount?.id || 'unknown'}`;
log(verifiedStatus, 'ok', { step: visibleStep, stepKey: 'platform-verify' });
reportComplete(visibleStep, {
reportComplete('platform-verify', {
localhostUrl: callback.url,
verifiedStatus,
visibleStep,
});
openAccountsPageSoon(origin);
}
+117 -20
View File
@@ -274,6 +274,35 @@ function normalizeLogStep(value) {
return step > 0 ? step : null;
}
const DEFAULT_OPENAI_NODE_BY_STEP = Object.freeze({
1: 'open-chatgpt',
2: 'submit-signup-email',
3: 'fill-password',
4: 'fetch-signup-code',
5: 'fill-profile',
6: 'wait-registration-success',
7: 'oauth-login',
8: 'fetch-login-code',
9: 'confirm-oauth',
10: 'platform-verify',
11: 'fetch-login-code',
12: 'confirm-oauth',
13: 'platform-verify',
});
function resolveReportNodeId(stepOrNodeId, data = {}) {
const explicitNodeId = String(data?.nodeId || data?.nodeKey || '').trim();
if (explicitNodeId) {
return explicitNodeId;
}
const directNodeId = String(stepOrNodeId || '').trim();
if (directNodeId && !/^\d+$/.test(directNodeId)) {
return directNodeId;
}
const step = normalizeLogStep(stepOrNodeId || data?.step || data?.visibleStep);
return step ? DEFAULT_OPENAI_NODE_BY_STEP[step] || '' : '';
}
/**
* Send a log message to Side Panel via Background.
* @param {string} message
@@ -323,30 +352,65 @@ function reportReady() {
}
/**
* Report step completion.
* @param {number} step
* @param {Object} data - Step output data
* Report node completion.
* @param {string|number} stepOrNodeId
* @param {Object} data - Node output data
*/
function reportComplete(step, data = {}) {
console.log(LOG_PREFIX, `步骤 ${step} 已完成`, data);
log('已成功完成', 'ok', { step });
function reportComplete(stepOrNodeId, data = {}) {
const nodeId = resolveReportNodeId(stepOrNodeId, data);
const step = normalizeLogStep(stepOrNodeId || data?.step || data?.visibleStep);
console.log(LOG_PREFIX, `节点 ${nodeId || stepOrNodeId} 已完成`, data);
log('已成功完成', 'ok', { step, stepKey: nodeId });
const message = {
type: 'STEP_COMPLETE',
type: 'NODE_COMPLETE',
source: getRuntimeScriptSource(),
step,
payload: data,
nodeId,
payload: {
...(data || {}),
...(nodeId ? { nodeId } : {}),
...(step ? { step } : {}),
},
error: null,
};
Promise.resolve(chrome.runtime.sendMessage(message))
.then((response) => {
console.log(LOG_PREFIX, `STEP_COMPLETE sent successfully for step ${step}`, {
console.log(LOG_PREFIX, `NODE_COMPLETE sent successfully for node ${nodeId || stepOrNodeId}`, {
response,
url: location.href,
payloadKeys: Object.keys(data || {}),
});
})
.catch((err) => {
console.error(LOG_PREFIX, `STEP_COMPLETE send failed for step ${step}`, err?.message || err, {
console.error(LOG_PREFIX, `NODE_COMPLETE send failed for node ${nodeId || stepOrNodeId}`, err?.message || err, {
url: location.href,
payloadKeys: Object.keys(data || {}),
});
});
}
function reportNodeComplete(nodeId, data = {}) {
const normalizedNodeId = String(nodeId || '').trim();
console.log(LOG_PREFIX, `节点 ${normalizedNodeId} 已完成`, data);
const message = {
type: 'NODE_COMPLETE',
source: getRuntimeScriptSource(),
nodeId: normalizedNodeId,
payload: {
...(data || {}),
nodeId: normalizedNodeId,
},
error: null,
};
Promise.resolve(chrome.runtime.sendMessage(message))
.then((response) => {
console.log(LOG_PREFIX, `NODE_COMPLETE sent successfully for node ${normalizedNodeId}`, {
response,
url: location.href,
payloadKeys: Object.keys(data || {}),
});
})
.catch((err) => {
console.error(LOG_PREFIX, `NODE_COMPLETE send failed for node ${normalizedNodeId}`, err?.message || err, {
url: location.href,
payloadKeys: Object.keys(data || {}),
});
@@ -354,29 +418,62 @@ function reportComplete(step, data = {}) {
}
/**
* Report step error.
* @param {number} step
* Report node error.
* @param {string|number} stepOrNodeId
* @param {string} errorMessage
*/
function reportError(step, errorMessage) {
console.error(LOG_PREFIX, `步骤 ${step} 失败: ${errorMessage}`);
function reportError(stepOrNodeId, errorMessage) {
const nodeId = resolveReportNodeId(stepOrNodeId);
const step = normalizeLogStep(stepOrNodeId);
console.error(LOG_PREFIX, `节点 ${nodeId || stepOrNodeId} 失败: ${errorMessage}`);
const message = {
type: 'STEP_ERROR',
type: 'NODE_ERROR',
source: getRuntimeScriptSource(),
step,
payload: {},
nodeId,
payload: {
...(nodeId ? { nodeId } : {}),
...(step ? { step } : {}),
},
error: errorMessage,
};
Promise.resolve(chrome.runtime.sendMessage(message))
.then((response) => {
console.log(LOG_PREFIX, `STEP_ERROR sent successfully for step ${step}`, {
console.log(LOG_PREFIX, `NODE_ERROR sent successfully for node ${nodeId || stepOrNodeId}`, {
response,
url: location.href,
errorMessage,
});
})
.catch((err) => {
console.error(LOG_PREFIX, `STEP_ERROR send failed for step ${step}`, err?.message || err, {
console.error(LOG_PREFIX, `NODE_ERROR send failed for node ${nodeId || stepOrNodeId}`, err?.message || err, {
url: location.href,
errorMessage,
});
});
}
function reportNodeError(nodeId, errorMessage) {
const normalizedNodeId = String(nodeId || '').trim();
console.error(LOG_PREFIX, `节点 ${normalizedNodeId} 失败: ${errorMessage}`);
const message = {
type: 'NODE_ERROR',
source: getRuntimeScriptSource(),
nodeId: normalizedNodeId,
payload: {
nodeId: normalizedNodeId,
},
error: errorMessage,
};
Promise.resolve(chrome.runtime.sendMessage(message))
.then((response) => {
console.log(LOG_PREFIX, `NODE_ERROR sent successfully for node ${normalizedNodeId}`, {
response,
url: location.href,
errorMessage,
});
})
.catch((err) => {
console.error(LOG_PREFIX, `NODE_ERROR send failed for node ${normalizedNodeId}`, err?.message || err, {
url: location.href,
errorMessage,
});
+19 -9
View File
@@ -1,4 +1,4 @@
// content/vps-panel.js — Content script for CPA panel (steps 7, 10 / OAuth URL request)
// content/vps-panel.js — Content script for CPA panel (OAuth URL request / platform verification node)
// Injected on: CPA panel (user-configured URL)
//
// Actual DOM structure (after login click):
@@ -39,12 +39,12 @@ if (document.documentElement.getAttribute(VPS_PANEL_LISTENER_SENTINEL) !== '1')
// Listen for commands from Background
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'EXECUTE_STEP' || message.type === 'REQUEST_OAUTH_URL') {
if (message.type === 'EXECUTE_NODE' || message.type === 'REQUEST_OAUTH_URL') {
resetStopState();
const startedAt = Date.now();
const actionLabel = message.type === 'REQUEST_OAUTH_URL'
? 'REQUEST_OAUTH_URL'
: `EXECUTE_STEP received for step ${message.step}`;
: `EXECUTE_NODE received for node ${message.nodeId || message.payload?.nodeId || ''}`;
console.log(LOG_PREFIX, actionLabel, {
url: location.href,
payloadKeys: Object.keys(message.payload || {}),
@@ -52,7 +52,7 @@ if (document.documentElement.getAttribute(VPS_PANEL_LISTENER_SENTINEL) !== '1')
});
const handler = message.type === 'REQUEST_OAUTH_URL'
? requestOAuthUrl(message.payload)
: handleStep(message.step, message.payload);
: handleNode(message.nodeId || message.payload?.nodeId, message.payload);
handler.then((result) => {
console.log(LOG_PREFIX, `${actionLabel} resolved after ${Date.now() - startedAt}ms`, {
url: location.href,
@@ -65,14 +65,14 @@ if (document.documentElement.getAttribute(VPS_PANEL_LISTENER_SENTINEL) !== '1')
snapshot: getVpsPanelSnapshot(),
});
if (isStopError(err)) {
if (message.step) {
log('已被用户停止。', 'warn', { step: message.step });
if (message.payload?.visibleStep || message.step) {
log('已被用户停止。', 'warn', { step: message.payload?.visibleStep || message.step });
}
sendResponse({ stopped: true, error: err.message });
return;
}
if (message.step) {
reportError(message.step, err.message);
if (message.nodeId || message.payload?.nodeId) {
reportError(message.nodeId || message.payload?.nodeId, err.message);
}
sendResponse({ error: err.message });
});
@@ -95,6 +95,16 @@ async function handleStep(step, payload) {
}
}
async function handleNode(nodeId, payload = {}) {
const normalizedNodeId = String(nodeId || '').trim();
switch (normalizedNodeId) {
case 'platform-verify':
return await step9_vpsVerify(payload);
default:
throw new Error(`vps-panel.js 不处理节点 ${normalizedNodeId}`);
}
}
function isVisibleElement(el) {
if (!el) return false;
const style = window.getComputedStyle(el);
@@ -1078,5 +1088,5 @@ async function step9_vpsVerify(payload) {
const verifiedStatus = await waitForExactSuccessBadge(STEP9_SUCCESS_BADGE_TIMEOUT_MS, visibleStep);
log(verifiedStatus, 'ok', { step: visibleStep, stepKey: 'platform-verify' });
reportComplete(visibleStep, { localhostUrl, verifiedStatus });
reportComplete('platform-verify', { localhostUrl, verifiedStatus, visibleStep });
}
+132 -45
View File
@@ -10,60 +10,60 @@
const SIGNUP_METHOD_PHONE = 'phone';
const NORMAL_STEP_DEFINITIONS = [
{ id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网' },
{ id: 2, order: 20, key: 'submit-signup-email', title: '注册并输入邮箱' },
{ id: 3, order: 30, key: 'fill-password', title: '填写密码并继续' },
{ id: 4, order: 40, key: 'fetch-signup-code', title: '获取注册验证码' },
{ id: 5, order: 50, key: 'fill-profile', title: '填写姓名和生日' },
{ id: 6, order: 60, key: 'wait-registration-success', title: '等待注册成功' },
{ id: 7, order: 70, key: 'oauth-login', title: '刷新 OAuth 并登录' },
{ id: 8, order: 80, key: 'fetch-login-code', title: '获取登录验证码' },
{ id: 9, order: 90, key: 'confirm-oauth', title: '自动确认 OAuth' },
{ id: 10, order: 100, key: 'platform-verify', title: '平台回调验证' },
{ id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网', sourceId: 'chatgpt', driverId: null, command: 'open-chatgpt' },
{ id: 2, order: 20, key: 'submit-signup-email', title: '注册并输入邮箱', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'submit-signup-email' },
{ id: 3, order: 30, key: 'fill-password', title: '填写密码并继续', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'fill-password' },
{ id: 4, order: 40, key: 'fetch-signup-code', title: '获取注册验证码', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'submit-verification-code', mailRuleId: 'openai-signup-code' },
{ id: 5, order: 50, key: 'fill-profile', title: '填写姓名和生日', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'fill-profile' },
{ id: 6, order: 60, key: 'wait-registration-success', title: '等待注册成功', sourceId: 'chatgpt', driverId: null, command: 'wait-registration-success' },
{ id: 7, order: 70, key: 'oauth-login', title: '刷新 OAuth 并登录', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'oauth-login' },
{ id: 8, order: 80, key: 'fetch-login-code', title: '获取登录验证码', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'submit-verification-code', mailRuleId: 'openai-login-code' },
{ id: 9, order: 90, key: 'confirm-oauth', title: '自动确认 OAuth', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'confirm-oauth' },
{ id: 10, order: 100, key: 'platform-verify', title: '平台回调验证', sourceId: 'platform-panel', driverId: 'content/platform-panel', command: 'platform-verify' },
];
const PLUS_PAYPAL_STEP_DEFINITIONS = [
{ id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网' },
{ id: 2, order: 20, key: 'submit-signup-email', title: '注册并输入邮箱' },
{ id: 3, order: 30, key: 'fill-password', title: '填写密码并继续' },
{ id: 4, order: 40, key: 'fetch-signup-code', title: '获取注册验证码' },
{ id: 5, order: 50, key: 'fill-profile', title: '填写姓名和生日' },
{ id: 6, order: 60, key: 'plus-checkout-create', title: '创建 Plus Checkout' },
{ id: 7, order: 70, key: 'plus-checkout-billing', title: '填写账单并提交订单' },
{ id: 8, order: 80, key: 'paypal-approve', title: 'PayPal 登录与授权' },
{ id: 9, order: 90, key: 'plus-checkout-return', title: '订阅回跳确认' },
{ id: 10, order: 100, key: 'oauth-login', title: '刷新 OAuth 并登录' },
{ id: 11, order: 110, key: 'fetch-login-code', title: '获取登录验证码' },
{ id: 12, order: 120, key: 'confirm-oauth', title: '自动确认 OAuth' },
{ id: 13, order: 130, key: 'platform-verify', title: '平台回调验证' },
{ id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网', sourceId: 'chatgpt', driverId: null, command: 'open-chatgpt' },
{ id: 2, order: 20, key: 'submit-signup-email', title: '注册并输入邮箱', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'submit-signup-email' },
{ id: 3, order: 30, key: 'fill-password', title: '填写密码并继续', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'fill-password' },
{ id: 4, order: 40, key: 'fetch-signup-code', title: '获取注册验证码', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'submit-verification-code', mailRuleId: 'openai-signup-code' },
{ id: 5, order: 50, key: 'fill-profile', title: '填写姓名和生日', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'fill-profile' },
{ id: 6, order: 60, key: 'plus-checkout-create', title: '创建 Plus Checkout', sourceId: 'plus-checkout', driverId: 'content/plus-checkout', command: 'plus-checkout-create' },
{ id: 7, order: 70, key: 'plus-checkout-billing', title: '填写账单并提交订单', sourceId: 'plus-checkout', driverId: 'content/plus-checkout', command: 'plus-checkout-billing' },
{ id: 8, order: 80, key: 'paypal-approve', title: 'PayPal 登录与授权', sourceId: 'paypal-flow', driverId: 'content/paypal-flow', command: 'paypal-approve' },
{ id: 9, order: 90, key: 'plus-checkout-return', title: '订阅回跳确认', sourceId: 'plus-checkout', driverId: 'content/plus-checkout', command: 'plus-checkout-return' },
{ id: 10, order: 100, key: 'oauth-login', title: '刷新 OAuth 并登录', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'oauth-login' },
{ id: 11, order: 110, key: 'fetch-login-code', title: '获取登录验证码', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'submit-verification-code', mailRuleId: 'openai-login-code' },
{ id: 12, order: 120, key: 'confirm-oauth', title: '自动确认 OAuth', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'confirm-oauth' },
{ id: 13, order: 130, key: 'platform-verify', title: '平台回调验证', sourceId: 'platform-panel', driverId: 'content/platform-panel', command: 'platform-verify' },
];
const PLUS_GOPAY_STEP_DEFINITIONS = [
{ id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网' },
{ id: 2, order: 20, key: 'submit-signup-email', title: '注册并输入邮箱' },
{ id: 3, order: 30, key: 'fill-password', title: '填写密码并继续' },
{ id: 4, order: 40, key: 'fetch-signup-code', title: '获取注册验证码' },
{ id: 5, order: 50, key: 'fill-profile', title: '填写姓名和生日' },
{ id: 6, order: 60, key: 'plus-checkout-create', title: '打开 GoPay 订阅页' },
{ id: 7, order: 70, key: 'gopay-subscription-confirm', title: '等待 GoPay 订阅确认' },
{ id: 10, order: 100, key: 'oauth-login', title: '刷新 OAuth 并登录' },
{ id: 11, order: 110, key: 'fetch-login-code', title: '获取登录验证码' },
{ id: 12, order: 120, key: 'confirm-oauth', title: '自动确认 OAuth' },
{ id: 13, order: 130, key: 'platform-verify', title: '平台回调验证' },
{ id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网', sourceId: 'chatgpt', driverId: null, command: 'open-chatgpt' },
{ id: 2, order: 20, key: 'submit-signup-email', title: '注册并输入邮箱', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'submit-signup-email' },
{ id: 3, order: 30, key: 'fill-password', title: '填写密码并继续', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'fill-password' },
{ id: 4, order: 40, key: 'fetch-signup-code', title: '获取注册验证码', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'submit-verification-code', mailRuleId: 'openai-signup-code' },
{ id: 5, order: 50, key: 'fill-profile', title: '填写姓名和生日', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'fill-profile' },
{ id: 6, order: 60, key: 'plus-checkout-create', title: '打开 GoPay 订阅页', sourceId: 'plus-checkout', driverId: 'content/plus-checkout', command: 'plus-checkout-create' },
{ id: 7, order: 70, key: 'gopay-subscription-confirm', title: '等待 GoPay 订阅确认', sourceId: 'gopay-flow', driverId: 'content/gopay-flow', command: 'gopay-subscription-confirm' },
{ id: 10, order: 100, key: 'oauth-login', title: '刷新 OAuth 并登录', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'oauth-login' },
{ id: 11, order: 110, key: 'fetch-login-code', title: '获取登录验证码', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'submit-verification-code', mailRuleId: 'openai-login-code' },
{ id: 12, order: 120, key: 'confirm-oauth', title: '自动确认 OAuth', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'confirm-oauth' },
{ id: 13, order: 130, key: 'platform-verify', title: '平台回调验证', sourceId: 'platform-panel', driverId: 'content/platform-panel', command: 'platform-verify' },
];
const PLUS_GPC_STEP_DEFINITIONS = [
{ id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网' },
{ id: 2, order: 20, key: 'submit-signup-email', title: '注册并输入邮箱' },
{ id: 3, order: 30, key: 'fill-password', title: '填写密码并继续' },
{ id: 4, order: 40, key: 'fetch-signup-code', title: '获取注册验证码' },
{ id: 5, order: 50, key: 'fill-profile', title: '填写姓名和生日' },
{ id: 6, order: 60, key: 'plus-checkout-create', title: '创建 GPC 订单' },
{ id: 7, order: 70, key: 'plus-checkout-billing', title: '等待 GPC 任务完成' },
{ id: 10, order: 100, key: 'oauth-login', title: '刷新 OAuth 并登录' },
{ id: 11, order: 110, key: 'fetch-login-code', title: '获取登录验证码' },
{ id: 12, order: 120, key: 'confirm-oauth', title: '自动确认 OAuth' },
{ id: 13, order: 130, key: 'platform-verify', title: '平台回调验证' },
{ id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网', sourceId: 'chatgpt', driverId: null, command: 'open-chatgpt' },
{ id: 2, order: 20, key: 'submit-signup-email', title: '注册并输入邮箱', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'submit-signup-email' },
{ id: 3, order: 30, key: 'fill-password', title: '填写密码并继续', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'fill-password' },
{ id: 4, order: 40, key: 'fetch-signup-code', title: '获取注册验证码', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'submit-verification-code', mailRuleId: 'openai-signup-code' },
{ id: 5, order: 50, key: 'fill-profile', title: '填写姓名和生日', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'fill-profile' },
{ id: 6, order: 60, key: 'plus-checkout-create', title: '创建 GPC 订单', sourceId: 'plus-checkout', driverId: 'content/plus-checkout', command: 'plus-checkout-create' },
{ id: 7, order: 70, key: 'plus-checkout-billing', title: '等待 GPC 任务完成', sourceId: 'plus-checkout', driverId: 'content/plus-checkout', command: 'plus-checkout-billing' },
{ id: 10, order: 100, key: 'oauth-login', title: '刷新 OAuth 并登录', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'oauth-login' },
{ id: 11, order: 110, key: 'fetch-login-code', title: '获取登录验证码', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'submit-verification-code', mailRuleId: 'openai-login-code' },
{ id: 12, order: 120, key: 'confirm-oauth', title: '自动确认 OAuth', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'confirm-oauth' },
{ id: 13, order: 130, key: 'platform-verify', title: '平台回调验证', sourceId: 'platform-panel', driverId: 'content/platform-panel', command: 'platform-verify' },
];
const PHONE_SIGNUP_TITLE_OVERRIDES = Object.freeze({
@@ -186,6 +186,27 @@
}));
}
function cloneNodes(steps = [], options = {}, flowId = DEFAULT_ACTIVE_FLOW_ID) {
const { builder } = getFlowDefinitionBuilder({ activeFlowId: flowId });
return steps.map((step) => ({
legacyStepId: Number(step.id),
nodeId: String(step.key || '').trim(),
flowId,
title: builder?.resolveStepTitle ? builder.resolveStepTitle(step, options) : step.title,
displayOrder: Number.isFinite(Number(step.order)) ? Number(step.order) : Number(step.id),
nodeType: 'task',
sourceId: step.sourceId || '',
driverId: step.driverId || '',
executeKey: String(step.key || '').trim(),
command: String(step.command || step.key || '').trim(),
mailRuleId: String(step.mailRuleId || '').trim(),
next: Array.isArray(step.next) ? [...step.next] : [],
retryPolicy: step.retryPolicy && typeof step.retryPolicy === 'object' ? { ...step.retryPolicy } : {},
recoveryPolicy: step.recoveryPolicy && typeof step.recoveryPolicy === 'object' ? { ...step.recoveryPolicy } : {},
ui: step.ui && typeof step.ui === 'object' ? { ...step.ui } : {},
})).filter((node) => Boolean(node.nodeId));
}
function getSteps(options = {}) {
const { flowId, builder } = getFlowDefinitionBuilder(options);
if (!builder?.getModeStepDefinitions) {
@@ -194,6 +215,23 @@
return cloneSteps(builder.getModeStepDefinitions(options), options, flowId);
}
function linkLinearNodes(nodes = []) {
return nodes.map((node, index) => ({
...node,
next: Array.isArray(node.next) && node.next.length
? [...node.next]
: (nodes[index + 1]?.nodeId ? [nodes[index + 1].nodeId] : []),
}));
}
function getNodes(options = {}) {
const { flowId, builder } = getFlowDefinitionBuilder(options);
if (!builder?.getModeStepDefinitions) {
return [];
}
return linkLinearNodes(cloneNodes(builder.getModeStepDefinitions(options), options, flowId));
}
function getAllSteps(options = {}) {
const { flowId, builder } = getFlowDefinitionBuilder(options);
if (!builder?.getAllSteps) {
@@ -202,6 +240,18 @@
return cloneSteps(builder.getAllSteps(options), options, flowId);
}
function getAllNodes(options = {}) {
const { flowId, builder } = getFlowDefinitionBuilder(options);
if (!builder?.getAllSteps) {
return [];
}
return cloneNodes(builder.getAllSteps(options), options, flowId)
.sort((left, right) => {
if (left.displayOrder !== right.displayOrder) return left.displayOrder - right.displayOrder;
return left.nodeId.localeCompare(right.nodeId);
});
}
function getPlusPaymentStepTitle(options = {}) {
const { builder } = getFlowDefinitionBuilder(options);
if (!builder?.getPlusPaymentStepTitle) {
@@ -217,6 +267,10 @@
.sort((left, right) => left - right);
}
function getNodeIds(options = {}) {
return getNodes(options).map((node) => node.nodeId);
}
function getLastStepId(options = {}) {
const ids = getStepIds(options);
return ids[ids.length - 1] || 0;
@@ -232,6 +286,33 @@
return match ? cloneSteps([match], options, flowId)[0] : null;
}
function getNodeById(nodeId, options = {}) {
const normalizedNodeId = String(nodeId || '').trim();
if (!normalizedNodeId) {
return null;
}
return getNodes(options).find((node) => node.nodeId === normalizedNodeId) || null;
}
function getNodeByDisplayOrder(displayOrder, options = {}) {
const normalizedOrder = Number(displayOrder);
if (!Number.isFinite(normalizedOrder)) {
return null;
}
return getNodes(options).find((node) => node.displayOrder === normalizedOrder) || null;
}
function getWorkflow(options = {}) {
const flowId = normalizeActiveFlowId(options?.activeFlowId, DEFAULT_ACTIVE_FLOW_ID);
const nodes = getNodes(options);
return {
flowId,
workflowVersion: 1,
nodes,
nodeIds: nodes.map((node) => node.nodeId),
};
}
return {
DEFAULT_ACTIVE_FLOW_ID,
STEP_DEFINITIONS: NORMAL_STEP_DEFINITIONS,
@@ -243,12 +324,18 @@
SIGNUP_METHOD_EMAIL,
SIGNUP_METHOD_PHONE,
getAllSteps,
getAllNodes,
getLastStepId,
getNodeByDisplayOrder,
getNodeById,
getNodeIds,
getNodes,
getPlusPaymentStepTitle,
getRegisteredFlowIds,
getStepById,
getStepIds,
getSteps,
getWorkflow,
hasFlow,
isPlusModeEnabled,
normalizeActiveFlowId,
@@ -0,0 +1,380 @@
# 步骤与Flow节点化重构开发方案
## 1. 文档目标
本文不是给现有 `step` 体系再补一层适配,而是把整个执行系统升级成 **flow + node** 体系。目标是从根上解决“数字步骤硬编码”和“key 绑定混用”这两套逻辑并存的问题。
最终设计必须满足:
1. 新增步骤时,只改当前 flow 的定义和节点执行器,不再牵动全局 switch。
2. 新增完全不同的 flow 时,可以拥有完全不同的节点图、页面源、邮件规则、恢复策略和 UI 表达。
3. 最终版本不保留 `currentStep` / `stepStatuses` 作为核心状态,不保留 `STEP_*` 作为核心协议,不再让数字步骤充当身份、顺序、状态、历史和消息路由的共同主键。
4. 方案必须同时覆盖 background、content、sidepanel、日志、历史、自动运行、来源注册、邮件规则、手机号接码、Plus 支付这些强关联模块。
## 2. 重构前实现的真实形态
当前项目不是纯硬编码,而是“**定义层部分动态化,控制层仍然硬编码**”。
| 层 | 现状 | 主要问题 |
| --- | --- | --- |
| 状态 | `background.js` 里仍有 `currentStep``stepStatuses` | 数字步骤同时承担状态、顺序和恢复锚点 |
| 协议 | `EXECUTE_STEP``STEP_COMPLETE``STEP_ERROR``SKIP_STEP` | 消息协议仍以数字 step 为核心 |
| 定义 | `data/step-definitions.js``background/steps/registry.js` | 只有 OpenAI 真正注册,且执行仍按数字 id |
| UI | `sidepanel/sidepanel.js` 按 step 渲染、发送、跳过 | 侧边栏本质上还是数字步骤面板 |
| 内容脚本 | `content/signup-page.js``message.step` 分支 | 页面驱动逻辑被 step number 绑死 |
| 运行态 | `background/runtime-state.js` 已有 `currentNodeId``nodeStatuses` | 但仍通过 legacy step 视图回写,双模型并存 |
| 外围能力 | `shared/source-registry.js``shared/flow-capabilities.js``background/mail-rule-registry.js` | 已有抽象,但都还没成为唯一事实来源 |
重点文件里最能说明问题的几个点:
- `background.js``DEFAULT_STATE``setStepStatus()``skipStep()``runAutoSequenceFromStep()``getStepRegistryForState()` 仍把数字 step 当主流程。
- `background/message-router.js``STEP_COMPLETE` / `STEP_ERROR` / `EXECUTE_STEP` / `SKIP_STEP` 还在消息层面锁死 step。
- `background/runtime-state.js`:已经有 node 相关字段,但还在做 step 兼容派生。
- `sidepanel/sidepanel.js`:步骤列表、状态展示、手动执行、跳过、自动运行都还围着 step。
- `flows/openai/mail-rules.js``background/mail-rule-registry.js`:已经说明“规则可以 flow 化”,但目前只有 OpenAI 在用。
## 3. 为什么现在的设计有问题
### 3.1 混用了三种身份
现在一个“步骤”同时在扮演:
- 执行身份
- 顺序编号
- 状态键
- 日志标签
- 历史记录字段
- 自动运行恢复锚点
这就是后面维护越来越难的根因。只要新增一个节点,往往要同时改定义、状态、消息、UI、日志、历史、恢复逻辑,甚至测试。
### 3.2 只改 key 不够
项目里已经有 `key`,但它并没有真正替代数字 step。
当前状态是:
- `id` 负责顺序和执行
- `key` 负责局部标识
- `currentStep` 负责运行态
- `stepStatuses` 负责状态
这不是“灵活”,这是“双主键混用”。如果只把 `step` 改名成 `node`,不重做控制面,问题不会消失。
### 3.3 不能表达完全不同的 flow
现在 `getStepRegistryForState()` 直接在 `activeFlowId !== openai` 时拒绝执行,这意味着:
- 新 flow 不是“可扩展”,而是“被排除”
- 每个 flow 都只能往 OpenAI 这套结构里塞
- 只要一个 flow 的步骤顺序、页面、邮件、恢复策略不同,就会触发大量全局改动
### 3.4 线性 step 模型太弱
现有逻辑默认所有流程都是线性的 1, 2, 3, 4...。
但真实场景里,新的 flow 很可能是:
- 有分支
- 有可选节点
- 有重试回路
- 有条件跳转
- 有完全不同的外部页面驱动
这种场景下,数字 step 不是天然模型,只是临时排布方式。
### 3.5 相关模块已经开始“各自发明一套”
`runtime-state``source-registry``flow-capabilities``mail-rule-registry``navigation-utils` 都已经在做分层,但它们没有统一到一个真正的 workflow 主模型里。
结果就是:局部已经像架构,整体仍像拼接。
## 4. 目标架构
```mermaid
flowchart TD
FR["flowRegistry"]
WF["workflowDefinition"]
WE["workflowEngine"]
RS["runtimeState"]
SR["sourceRegistry"]
DR["driverRegistry"]
MR["mailRuleRegistry"]
CR["capabilityRegistry"]
SP["sidepanel"]
CS["content drivers"]
LOG["logs / history"]
FR --> WF
WF --> WE
WE <--> RS
WF --> SR
WF --> DR
WF --> MR
WF --> CR
WE --> SP
WE --> CS
WE --> LOG
```
### 4.1 核心标识
| 标识 | 含义 | 规则 |
| --- | --- | --- |
| `flowId` | 业务 flow 身份 | 例如 `openai``site-a` |
| `runId` | 一次执行实例 | 同一轮运行内保持不变 |
| `nodeId` | 节点身份 | 唯一、稳定、可读 |
| `sourceId` | 页面来源身份 | 负责标签页、页面家族和注入目标 |
| `driverId` | 内容驱动身份 | 负责某个 source 的命令集 |
### 4.2 目标状态模型
```js
runtimeState = {
flowId: 'openai',
runId: 'run_20260515_xxx',
workflowVersion: 1,
currentNodeId: 'submit-signup-email',
nodeStatuses: {
'open-chatgpt': 'completed',
'submit-signup-email': 'running',
},
nodeResults: {
'open-chatgpt': { completedAt: 1710000000000 },
},
shared: {},
services: {},
flows: {
openai: {},
},
runSummary: {
finalStatus: 'running',
failedNodeId: '',
failureReasonCode: '',
},
}
```
### 4.3 节点模型
```js
node = {
id: 'submit-signup-email',
title: '注册并输入邮箱',
type: 'task',
order: 20, // 仅用于展示,不参与身份
sourceId: 'openai-auth',
driverId: 'content/signup-page',
next: ['fill-password'],
retryPolicy: { maxAttempts: 3 },
recoveryPolicy: { onFailure: 'restart-node' },
ui: { section: 'registration' },
}
```
要点:
- `id` 是唯一主键。
- `order` 只是展示顺序,不再决定执行逻辑。
- `next` / `recoveryPolicy` 决定流转,不靠全局 step 数组推断。
- 节点结果、失败原因、重试记录要单独存,不要塞进状态字符串后缀里。
### 4.4 Flow 定义必须是一手真相
`flowDefinition` 应该统一拥有:
- 节点图
- 节点执行器引用
- source 绑定
- driver 绑定
- 邮件规则引用
- capability 定义
- recovery policy
- settings schema
其他 registry 如果存在,只能是这个定义的运行索引或编译产物,不能自己再维护一份同义信息。
## 5. 新增步骤 / 新增 flow 的接入规则
### 5.1 如果是在同一个 flow 里新增步骤
应该只做这些事:
1. 在该 flow 的 `workflowDefinition` 里新增 node。
2. 给这个 node 配置 `next``retryPolicy``recoveryPolicy``sourceId``driverId`
3. 如果它访问了新页面、新邮件规则、新服务,再补对应的 source / driver / mail rule。
4. UI 从 flow 定义里自动拿到节点标题、顺序和状态展示。
5. 增加对应测试,不再改全局 step switch。
### 5.2 如果是新加一个完全不同的 flow
应该直接新增一个独立 flow 目录和定义,不去改 OpenAI 的步骤树:
```txt
flows/<flowId>/
workflow.js
sources.js
mail-rules.js
capabilities.js
recovery.js
```
新 flow 的要求是:
- 可以没有手机号接码
- 可以没有 Plus
- 可以没有邮件验证码
- 可以有完全不同的节点顺序和分支
- 可以有自己的页面源和 driver
核心 engine 不改,改的是 flow 自己。
## 6. 相关模块边界
### 6.1 background
`background.js``background/message-router.js` 的职责应退回成“调度和校验”,不再自己写业务步骤树。
### 6.2 sidepanel
`sidepanel/sidepanel.js` 只能根据 flow definition 和 capability 结果渲染,不应该再手写 step 编号和 step 顺序判断。
### 6.3 content scripts
`content/signup-page.js` 这类脚本要从“按 step 分支”改成“按 node action / command 分支”。
### 6.4 source / driver
`shared/source-registry.js` 只负责页面来源、标签页生命周期和注入范围。
`driverRegistry` 只负责“这个 source 能接什么命令”。
### 6.5 mail rules
邮件过滤和验证码提取必须是 flow-local 的。`flows/openai/mail-rules.js` 说明这件事已经存在,只是还没推广成整体原则。
### 6.6 auto-run / history / logs
自动运行、日志和历史必须记录 `flowId``runId``nodeId`,而不是继续写 `step7_failed` 这种混合字符串。
### 6.7 手机接码、Plus、OAuth
这些都不是“通用步骤”,它们是 OpenAI flow 的私有能力。没有第二个 flow 的真实需求时,不要把它们硬抽成全局共享步骤。
## 7. 设计符合性检查
| 检查项 | 是否满足 | 说明 |
| --- | --- | --- |
| 不做旧兼容 | 是 | 最终状态不保留 step 作为核心模型 |
| 新增步骤可维护 | 是 | 只改当前 flow 的 node 和相关依赖 |
| 新增不同 flow 可维护 | 是 | 独立 flow 定义,不污染 OpenAI |
| 规范一致 | 是 | 统一使用 `flowId` / `runId` / `nodeId` / `sourceId` |
| 完整性 | 是 | 覆盖状态、协议、UI、日志、历史、自动运行、来源、邮件 |
| 正确性 | 基本满足 | 仍需靠分阶段测试验证边界和恢复路径 |
## 8. 方案自身的缺陷与控制点
这份方案本身也有潜在问题,必须提前说明:
1. **抽象过头风险**
如果 flow / node / source / driver / mail rule 各自独立维护同一份信息,会重新制造同步成本。控制方式是:`flowDefinition` 必须是一手真相,其它 registry 只能是派生索引。
2. **DSL 复杂度风险**
如果节点模型一开始就塞太多字段,后续会变成另一种难维护的配置语言。控制方式是:先保留 `id``title``type``order``sourceId``driverId``next``retryPolicy``recoveryPolicy`,其余按真实需求再加。
3. **日志和历史膨胀风险**
如果把每个节点的所有中间态都原样落盘,历史会很重。控制方式是:日志保留可读摘要,历史保留最终结果 + 关键节点轨迹 + 失败原因码。
4. **UI 复杂度风险**
如果 UI 同时展示 flow、node、source、driver、恢复策略,侧边栏会太吵。控制方式是:UI 只展示用户关心的流转和状态,详细调试信息留给开发记录。
5. **边界冲突风险**
现在已经存在 `sourceRegistry``flowCapabilities``mailRuleRegistry``runtimeState` 等分层。如果不统一为一套 flow contract,就会继续出现“看起来都对,合起来互相打架”的问题。
## 9. 开发清单
| 阶段 | 开发目标 | 阶段自检 |
| --- | --- | --- |
| 1 | 定义最终 schema`flowId``runId``nodeId``sourceId``driverId``workflowVersion` | 检查是否还在把 `currentStep` / `stepStatuses` 当核心概念使用 |
| 2 | 建立 flow definition 和 workflow engine | 检查一个 flow 是否能只靠自己的定义被加载,不改全局 switch |
| 3 | 替换消息协议 | 检查是否全面切换到 `EXECUTE_NODE` / `NODE_COMPLETE` / `NODE_ERROR` / `SKIP_NODE` 这类命名 |
| 4 | 改写 runtime / auto-run / recovery | 检查恢复、重试、跳过、暂停是否都基于 `nodeId``runId` |
| 5 | 重写 sidepanel | 检查 UI 是否完全从 flow definition 取数据,不再硬编码 step 顺序 |
| 6 | 迁移 content drivers、邮件规则、来源注册 | 检查新 flow 是否可以拥有完全不同的页面源和邮件规则,而不碰 OpenAI 核心流程 |
| 7 | 重写日志、历史、账号记录 | 检查是否只记录 `flowId` / `runId` / `nodeId`,不再产出 `stepX_failed` 这类混合状态 |
| 8 | 删除旧 step 路径和残留逻辑 | 检查仓库中核心路径是否还残留 `EXECUTE_STEP``STEP_COMPLETE``STEP_ERROR``currentStep``stepStatuses` |
### 每阶段统一自检
每做完一个阶段,都要执行下面的自检:
1. grep 核心代码,确认没有新的 step 硬编码回流。
2. 用至少一个 flow 做完整跑通验证。
3. 用一个“结构完全不同”的 flow 做边界验证。
4. 检查消息、日志、历史、UI、文档是否一致。
5. 打开 Markdown 预览,确认中文标题、表格和代码块都没有乱码。
## 10. 最终验收标准
这次重构只有在满足下面条件时才算完成:
- 核心状态不再依赖数字 step。
- 新步骤只改本 flow,不改全局控制面。
- 新 flow 可以和 OpenAI 完全不同。
- background、sidepanel、content、auto-run、logs、history、mail rules、source registry 都在同一套 flow/node 规则下运行。
- 文档、代码、测试三者描述一致,没有“代码一套、文档一套、UI 一套”的分裂。
- Markdown 和中文显示无乱码。
结论很直接:这块不能继续修补式演进,必须用 node-based workflow engine 一次性替换掉 step-number 作为核心模型的设计。
## 11. 本次落地后的实现约束
本次重构后,核心运行路径已经按下面规则收敛:
1. background、content、sidepanel 的消息协议只使用 `EXECUTE_NODE``NODE_COMPLETE``NODE_ERROR``SKIP_NODE`
2. 核心运行状态只使用 `flowId``runId``currentNodeId``nodeStatuses`
3. 账号运行历史的新记录使用 `flowId``runId``failedNodeId`,失败/停止状态使用 `node:<nodeId>:failed``node:<nodeId>:stopped`
4. sidepanel 渲染仍可以展示“第几项”的用户文案,但状态合并、按钮执行、跳过、恢复都以 `nodeId` 为主键。
5. 邮件规则、source registry、driver command 已跟 node 对齐;验证码节点通过 `mailRuleId` 绑定,而不是通过固定步骤号绑定。
6. 自动运行主循环使用 `runAutoSequenceFromNodeGraph(startNodeId)` 按当前 workflow 的 node 列表推进,不再通过数字序号、`step++``runAutoSequenceFromNodeOrder` 驱动。
7. 自动运行恢复、idle 重开、Plus/GPC/GoPay checkout 重建都以目标 `nodeId` 和实际前置节点为锚点;遇到稀疏节点图时不会再依赖不存在的虚拟数字步骤。
阶段 8 自检命令要求核心生产路径不得再命中旧协议和旧状态字段:
```powershell
rg -n "EXECUTE_STEP|STEP_COMPLETE|STEP_ERROR|SKIP_STEP|STEP_STATUS_CHANGED" background.js background content sidepanel shared flows data
rg -n "currentStep|stepStatuses|setStepStatus|runAutoSequenceFromStep" background.js background sidepanel content shared flows data
rg -n "step\d+_failed|step\d+_stopped|step2_stopped|step7_stopped|step8_failed|step9_failed|step10_failed" background.js background
rg -n "runAutoSequenceFromNodeOrder|currentStartStep|startNodeOrder|while \(step <=|step \+= 1" background.js background
```
## 12. 后续新增节点的标准入口
同一 flow 新增节点时,按这个顺序开发:
1. 在 `data/step-definitions.js` 的当前 flow 定义里新增 node,并补齐 `nodeId``title``sourceId``driverId``command``mailRuleId``next`
2. 在 `background/steps/` 增加或复用节点执行器,并在 registry 构建处按 `executeKey` 注册。
3. 如果节点需要页面执行,给对应 content driver 增加 `EXECUTE_NODE` command handler。
4. 如果节点需要邮件验证码,在 `flows/<flowId>/mail-rules.js` 增加 flow-local rule,并通过 `mailRuleId` 绑定。
5. 增加 node-first 测试,断言消息、状态、历史、UI 都只使用 `nodeId`
## 13. 后续新增完全不同 flow 的标准入口
新增完全不同 flow 时,不要改 OpenAI 的节点树来“塞进去”,而是新增 flow-local 定义:
```txt
flows/<flowId>/
workflow.js
sources.js
mail-rules.js
capabilities.js
recovery.js
```
落地前必须自检:
- 新 flow 是否可以不包含 OpenAI 私有节点,例如 Plus、手机号接码、OAuth。
- `sourceId` / `driverId` 是否只服务当前 flow,不污染全局。
- 邮件规则是否只从当前 flow definition 派生。
- sidepanel 是否能仅通过 workflow nodes 渲染,不新增全局步骤 switch。
- 自动运行、历史和日志是否都能用 `flowId/runId/nodeId` 定位。
+37 -5
View File
@@ -3,6 +3,8 @@
})(typeof self !== 'undefined' ? self : globalThis, function createOpenAiMailRulesModule() {
const SIGNUP_CODE_RULE_ID = 'openai-signup-code';
const LOGIN_CODE_RULE_ID = 'openai-login-code';
const SIGNUP_CODE_NODE_ID = 'fetch-signup-code';
const LOGIN_CODE_NODE_ID = 'fetch-login-code';
const OPENAI_CODE_PATTERNS = Object.freeze([
Object.freeze({
source: '(?:chatgpt\\s+log-?in\\s+code|enter\\s+this\\s+code)[^0-9]{0,24}(\\d{6})',
@@ -56,10 +58,27 @@
&& String(state?.mail2925Mode || '').trim().toLowerCase() === 'receive';
}
function getRuleDefinition(step, state = {}) {
const normalizedStep = Number(step) === 4 ? 4 : 8;
function resolveVerificationNodeId(input) {
const directNodeId = String(input?.nodeId || input || '').trim();
if (directNodeId === SIGNUP_CODE_NODE_ID || directNodeId === LOGIN_CODE_NODE_ID) {
return directNodeId;
}
return Number(input?.step ?? input) === 4 ? SIGNUP_CODE_NODE_ID : LOGIN_CODE_NODE_ID;
}
function getVisibleStepForNode(nodeId, state = {}) {
if (nodeId === SIGNUP_CODE_NODE_ID) {
return 4;
}
const explicitStep = Number(state?.visibleStep || state?.step);
return Number.isInteger(explicitStep) && explicitStep > 0 ? explicitStep : 8;
}
function getRuleDefinition(input, state = {}) {
const nodeId = resolveVerificationNodeId(input);
const normalizedStep = getVisibleStepForNode(nodeId, state);
const mail2925Provider = isMail2925Provider(state);
const signupStep = normalizedStep === 4;
const signupStep = nodeId === SIGNUP_CODE_NODE_ID;
const targetEmail = signupStep
? state?.email
: (String(state?.step8VerificationTargetEmail || '').trim() || state?.email);
@@ -67,6 +86,7 @@
return {
flowId: 'openai',
ruleId: signupStep ? SIGNUP_CODE_RULE_ID : LOGIN_CODE_RULE_ID,
nodeId,
step: normalizedStep,
artifactType: 'code',
codePatterns: OPENAI_CODE_PATTERNS,
@@ -90,22 +110,34 @@
};
}
function buildVerificationPollPayload(step, state = {}, overrides = {}) {
function getRuleDefinitionForNode(nodeId, state = {}) {
return getRuleDefinition({ nodeId }, state);
}
function buildVerificationPollPayload(input, state = {}, overrides = {}) {
return {
...getRuleDefinition(step, state),
...getRuleDefinition(input, state),
...(overrides || {}),
};
}
function buildVerificationPollPayloadForNode(nodeId, state = {}, overrides = {}) {
return buildVerificationPollPayload({ nodeId }, state, overrides);
}
return {
buildVerificationPollPayload,
buildVerificationPollPayloadForNode,
getRuleDefinition,
getRuleDefinitionForNode,
};
}
return {
LOGIN_CODE_RULE_ID,
LOGIN_CODE_NODE_ID,
SIGNUP_CODE_RULE_ID,
SIGNUP_CODE_NODE_ID,
createOpenAiMailRules,
};
});
+35 -12
View File
@@ -96,6 +96,15 @@
driverId: 'content/vps-panel',
cleanupScopes: [],
},
'platform-panel': {
flowId: 'openai',
kind: 'virtual-page',
label: '平台回调面板',
readyPolicy: 'disabled',
family: 'platform-panel-family',
driverId: 'content/platform-panel',
cleanupScopes: [],
},
'sub2api-panel': {
flowId: 'openai',
kind: 'panel-page',
@@ -156,13 +165,13 @@
'content/signup-page': {
sourceId: 'openai-auth',
commands: [
'OPEN_SIGNUP',
'SUBMIT_SIGNUP_IDENTIFIER',
'SUBMIT_PASSWORD',
'SUBMIT_PROFILE',
'SUBMIT_LOGIN_CODE',
'SUBMIT_PHONE_CODE',
'DETECT_AUTH_STATE',
'submit-signup-email',
'fill-password',
'fill-profile',
'oauth-login',
'submit-verification-code',
'confirm-oauth',
'detect-auth-state',
],
},
'content/qq-mail': {
@@ -191,23 +200,27 @@
},
'content/sub2api-panel': {
sourceId: 'sub2api-panel',
commands: ['OPEN_PANEL', 'FETCH_OAUTH_URL', 'VERIFY_PLATFORM'],
commands: ['open-panel', 'fetch-oauth-url', 'platform-verify'],
},
'content/vps-panel': {
sourceId: 'vps-panel',
commands: ['OPEN_PANEL', 'FETCH_OAUTH_URL'],
commands: ['open-panel', 'fetch-oauth-url', 'platform-verify'],
},
'content/platform-panel': {
sourceId: 'platform-panel',
commands: ['platform-verify', 'fetch-oauth-url'],
},
'content/plus-checkout': {
sourceId: 'plus-checkout',
commands: ['CREATE_CHECKOUT', 'FILL_CHECKOUT'],
commands: ['plus-checkout-create', 'plus-checkout-billing', 'plus-checkout-return'],
},
'content/paypal-flow': {
sourceId: 'paypal-flow',
commands: ['APPROVE_PAYPAL'],
commands: ['paypal-approve'],
},
'content/gopay-flow': {
sourceId: 'gopay-flow',
commands: ['APPROVE_GOPAY'],
commands: ['gopay-subscription-confirm'],
},
});
@@ -296,6 +309,15 @@
};
}
function driverAcceptsCommand(sourceOrDriverId, command) {
const normalizedCommand = normalizeSourceId(command);
if (!normalizedCommand) {
return false;
}
const driver = getDriverMeta(sourceOrDriverId);
return Array.isArray(driver?.commands) && driver.commands.includes(normalizedCommand);
}
function isSignupPageHost(hostname = '') {
return AUTH_PAGE_HOSTS.has(String(hostname || '').toLowerCase());
}
@@ -414,6 +436,7 @@
getCleanupOwnerSource,
getDriverIdForSource,
getDriverMeta,
driverAcceptsCommand,
getSourceKeys,
getSourceLabel,
getSourceMeta,
+293 -87
View File
@@ -543,9 +543,16 @@ let stepDefinitions = getStepDefinitionsForMode(false, {
plusPaymentMethod: currentPlusPaymentMethod,
signupMethod: currentSignupMethod,
});
let workflowNodes = getWorkflowNodesForMode(false, {
plusPaymentMethod: currentPlusPaymentMethod,
signupMethod: currentSignupMethod,
});
let STEP_IDS = stepDefinitions.map((step) => Number(step.id)).filter(Number.isFinite);
let STEP_DEFAULT_STATUSES = Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending']));
let SKIPPABLE_STEPS = new Set(STEP_IDS);
let NODE_IDS = workflowNodes.map((node) => String(node.nodeId || '').trim()).filter(Boolean);
let NODE_DEFAULT_STATUSES = Object.fromEntries(NODE_IDS.map((nodeId) => [nodeId, 'pending']));
let SKIPPABLE_NODES = new Set(NODE_IDS);
const AUTO_DELAY_MIN_MINUTES = 1;
const AUTO_DELAY_MAX_MINUTES = 1440;
const AUTO_DELAY_DEFAULT_MINUTES = 30;
@@ -816,6 +823,42 @@ function getStepDefinitionsForMode(plusModeEnabled = false, options = {}) {
});
}
function getWorkflowNodesForMode(plusModeEnabled = false, options = {}) {
const defaultFlowId = typeof DEFAULT_ACTIVE_FLOW_ID !== 'undefined' ? DEFAULT_ACTIVE_FLOW_ID : 'openai';
const defaultMethod = typeof DEFAULT_PLUS_PAYMENT_METHOD !== 'undefined' ? DEFAULT_PLUS_PAYMENT_METHOD : 'paypal';
const rawPaymentMethod = typeof options === 'string'
? options
: (options.plusPaymentMethod || currentPlusPaymentMethod || defaultMethod);
const rawSignupMethod = typeof options === 'string'
? currentSignupMethod
: (options.signupMethod || currentSignupMethod || DEFAULT_SIGNUP_METHOD);
const activeFlowId = typeof options === 'string'
? ((typeof latestState !== 'undefined' ? latestState?.activeFlowId : '') || defaultFlowId)
: (options.activeFlowId || (typeof latestState !== 'undefined' ? latestState?.activeFlowId : '') || defaultFlowId);
const nodes = window.MultiPageStepDefinitions?.getNodes?.({
activeFlowId: String(activeFlowId || '').trim().toLowerCase() || defaultFlowId,
plusModeEnabled,
plusPaymentMethod: normalizePlusPaymentMethod(rawPaymentMethod),
signupMethod: normalizeSignupMethod(rawSignupMethod),
});
if (Array.isArray(nodes) && nodes.length) {
return nodes.slice().sort((left, right) => {
const leftOrder = Number.isFinite(Number(left.displayOrder)) ? Number(left.displayOrder) : Number(left.legacyStepId);
const rightOrder = Number.isFinite(Number(right.displayOrder)) ? Number(right.displayOrder) : Number(right.legacyStepId);
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
return String(left.nodeId || '').localeCompare(String(right.nodeId || ''));
});
}
return getStepDefinitionsForMode(plusModeEnabled, options).map((step) => ({
legacyStepId: Number(step.id),
nodeId: String(step.key || '').trim(),
title: step.title,
displayOrder: Number.isFinite(Number(step.order)) ? Number(step.order) : Number(step.id),
executeKey: String(step.key || '').trim(),
})).filter((node) => node.nodeId);
}
function getStepIdByKeyForCurrentMode(stepKey = '') {
const normalizedKey = String(stepKey || '').trim();
if (!normalizedKey) {
@@ -825,6 +868,29 @@ function getStepIdByKeyForCurrentMode(stepKey = '') {
return Number(match?.id) || 0;
}
function getNodeIdByStepForCurrentMode(step) {
const numericStep = Number(step);
const node = (workflowNodes || []).find((candidate) => Number(candidate?.legacyStepId) === numericStep);
if (node?.nodeId) {
return String(node.nodeId).trim();
}
const definition = (stepDefinitions || []).find((candidate) => Number(candidate?.id) === numericStep);
return String(definition?.key || '').trim();
}
function getStepIdByNodeIdForCurrentMode(nodeId = '') {
const normalizedNodeId = String(nodeId || '').trim();
if (!normalizedNodeId) {
return 0;
}
const node = (workflowNodes || []).find((candidate) => String(candidate?.nodeId || '').trim() === normalizedNodeId);
const legacyStepId = Number(node?.legacyStepId);
if (Number.isInteger(legacyStepId) && legacyStepId > 0) {
return legacyStepId;
}
return getStepIdByKeyForCurrentMode(normalizedNodeId);
}
function rebuildStepDefinitionState(plusModeEnabled = false, options = {}) {
currentPlusModeEnabled = Boolean(plusModeEnabled);
const defaultMethod = typeof DEFAULT_PLUS_PAYMENT_METHOD !== 'undefined' ? DEFAULT_PLUS_PAYMENT_METHOD : 'paypal';
@@ -841,9 +907,33 @@ function rebuildStepDefinitionState(plusModeEnabled = false, options = {}) {
plusPaymentMethod: currentPlusPaymentMethod,
signupMethod: currentSignupMethod,
});
const nextWorkflowNodes = typeof getWorkflowNodesForMode === 'function'
? getWorkflowNodesForMode(currentPlusModeEnabled, {
activeFlowId: options?.activeFlowId,
plusPaymentMethod: currentPlusPaymentMethod,
signupMethod: currentSignupMethod,
})
: stepDefinitions.map((step) => ({
legacyStepId: Number(step.id),
nodeId: String(step.key || step.id || '').trim(),
title: step.title,
displayOrder: Number.isFinite(Number(step.order)) ? Number(step.order) : Number(step.id),
}));
if (typeof workflowNodes !== 'undefined') {
workflowNodes = nextWorkflowNodes;
}
STEP_IDS = stepDefinitions.map((step) => Number(step.id)).filter(Number.isFinite);
STEP_DEFAULT_STATUSES = Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending']));
SKIPPABLE_STEPS = new Set(STEP_IDS);
if (typeof NODE_IDS !== 'undefined') {
NODE_IDS = nextWorkflowNodes.map((node) => String(node.nodeId || '').trim()).filter(Boolean);
}
if (typeof NODE_DEFAULT_STATUSES !== 'undefined') {
NODE_DEFAULT_STATUSES = Object.fromEntries((typeof NODE_IDS !== 'undefined' ? NODE_IDS : []).map((nodeId) => [nodeId, 'pending']));
}
if (typeof SKIPPABLE_NODES !== 'undefined') {
SKIPPABLE_NODES = new Set(typeof NODE_IDS !== 'undefined' ? NODE_IDS : []);
}
}
const CONTRIBUTION_CONTENT_PROMPT_DISMISSED_VERSION_STORAGE_KEY = 'multipage-contribution-content-prompt-dismissed-version';
const AUTO_RUN_FALLBACK_RISK_WARNING_MIN_RUNS = 3;
@@ -1189,7 +1279,7 @@ function shouldAttachAutomationWindow(message = {}) {
return false;
}
return [
'EXECUTE_STEP',
'EXECUTE_NODE',
'AUTO_RUN',
'SCHEDULE_AUTO_RUN',
'RESUME_AUTO_RUN',
@@ -2111,31 +2201,64 @@ function isDoneStatus(status) {
return status === 'completed' || status === 'manual_completed' || status === 'skipped';
}
function escapeCssValue(value = '') {
const raw = String(value || '');
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(raw);
}
return raw.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function getNodeStatuses(state = latestState) {
const merged = { ...NODE_DEFAULT_STATUSES, ...(state?.nodeStatuses || {}) };
return Object.fromEntries(NODE_IDS.map((nodeId) => [nodeId, merged[nodeId] || 'pending']));
}
function getStepStatuses(state = latestState) {
const merged = { ...STEP_DEFAULT_STATUSES, ...(state?.stepStatuses || {}) };
const merged = { ...STEP_DEFAULT_STATUSES };
if (typeof getNodeStatuses === 'function') {
const nodeStatuses = getNodeStatuses(state);
for (const [nodeId, status] of Object.entries(nodeStatuses)) {
const step = getStepIdByNodeIdForCurrentMode(nodeId);
if (step) {
merged[step] = status || 'pending';
}
}
}
return Object.fromEntries(STEP_IDS.map((stepId) => [stepId, merged[stepId] || 'pending']));
}
function getFirstUnfinishedNode(state = latestState) {
const statuses = getNodeStatuses(state);
for (const nodeId of NODE_IDS) {
if (!isDoneStatus(statuses[nodeId])) {
return nodeId;
}
}
return '';
}
function getFirstUnfinishedStep(state = latestState) {
const statuses = getStepStatuses(state);
for (const step of STEP_IDS) {
if (!isDoneStatus(statuses[step])) {
return step;
const nodeId = getFirstUnfinishedNode(state);
return nodeId ? getStepIdByNodeIdForCurrentMode(nodeId) : null;
}
}
return null;
function getRunningNodes(state = latestState) {
const statuses = getNodeStatuses(state);
return Object.entries(statuses)
.filter(([, status]) => status === 'running')
.map(([nodeId]) => nodeId);
}
function getRunningSteps(state = latestState) {
const statuses = getStepStatuses(state);
return Object.entries(statuses)
.filter(([, status]) => status === 'running')
.map(([step]) => Number(step))
return getRunningNodes(state)
.map((nodeId) => getStepIdByNodeIdForCurrentMode(nodeId))
.filter((step) => Number.isInteger(step) && step > 0)
.sort((a, b) => a - b);
}
function hasSavedProgress(state = latestState) {
const statuses = getStepStatuses(state);
const statuses = getNodeStatuses(state);
return Object.values(statuses).some((status) => status !== 'pending');
}
@@ -2150,14 +2273,14 @@ function shouldOfferAutoModeChoice(state = latestState) {
}
function syncLatestState(nextState) {
const mergedStepStatuses = nextState?.stepStatuses
? { ...STEP_DEFAULT_STATUSES, ...(latestState?.stepStatuses || {}), ...nextState.stepStatuses }
: getStepStatuses(latestState);
const mergedNodeStatuses = nextState?.nodeStatuses
? { ...NODE_DEFAULT_STATUSES, ...(latestState?.nodeStatuses || {}), ...nextState.nodeStatuses }
: getNodeStatuses(latestState);
latestState = {
...(latestState || {}),
...(nextState || {}),
stepStatuses: mergedStepStatuses,
nodeStatuses: mergedNodeStatuses,
};
renderAccountRecords(latestState);
@@ -3360,8 +3483,20 @@ function collectSettingsPayload() {
const currentPhoneSmsMaxPriceValue = typeof inputHeroSmsMaxPrice !== 'undefined' && inputHeroSmsMaxPrice
? normalizePhoneSmsMaxPriceValue(inputHeroSmsMaxPrice.value, phoneSmsProviderValue)
: '';
const normalizePhoneSmsMinPriceValueSafe = typeof normalizePhoneSmsMinPriceValue === 'function'
? normalizePhoneSmsMinPriceValue
: ((value = '', provider = phoneSmsProviderValue) => {
const normalizedProvider = normalizePhoneSmsProvider(provider);
if (normalizedProvider === PHONE_SMS_PROVIDER_FIVE_SIM && typeof normalizeFiveSimMaxPriceValue === 'function') {
return normalizeFiveSimMaxPriceValue(value);
}
if (typeof normalizeHeroSmsMaxPriceValue === 'function') {
return normalizeHeroSmsMaxPriceValue(value);
}
return String(value || '').trim();
});
const currentPhoneSmsMinPriceValue = typeof inputHeroSmsMinPrice !== 'undefined' && inputHeroSmsMinPrice
? normalizePhoneSmsMinPriceValue(inputHeroSmsMinPrice.value, phoneSmsProviderValue)
? normalizePhoneSmsMinPriceValueSafe(inputHeroSmsMinPrice.value, phoneSmsProviderValue)
: '';
const heroSmsMaxPriceValue = phoneSmsProviderValue === PHONE_SMS_PROVIDER_HERO_SMS
? currentPhoneSmsMaxPriceValue
@@ -3370,11 +3505,11 @@ function collectSettingsPayload() {
? currentPhoneSmsMaxPriceValue
: normalizeFiveSimMaxPriceValue(latestState?.fiveSimMaxPrice || '');
const heroSmsMinPriceValue = phoneSmsProviderValue === PHONE_SMS_PROVIDER_FIVE_SIM
? normalizePhoneSmsMinPriceValue(latestState?.heroSmsMinPrice || '', PHONE_SMS_PROVIDER_HERO_SMS)
? normalizePhoneSmsMinPriceValueSafe(latestState?.heroSmsMinPrice || '', PHONE_SMS_PROVIDER_HERO_SMS)
: currentPhoneSmsMinPriceValue;
const fiveSimMinPriceValue = phoneSmsProviderValue === PHONE_SMS_PROVIDER_FIVE_SIM
? currentPhoneSmsMinPriceValue
: normalizePhoneSmsMinPriceValue(latestState?.fiveSimMinPrice || '', PHONE_SMS_PROVIDER_FIVE_SIM);
: normalizePhoneSmsMinPriceValueSafe(latestState?.fiveSimMinPrice || '', PHONE_SMS_PROVIDER_FIVE_SIM);
const defaultFiveSimProduct = typeof DEFAULT_FIVE_SIM_PRODUCT !== 'undefined'
? DEFAULT_FIVE_SIM_PRODUCT
: 'openai';
@@ -8623,6 +8758,7 @@ function initializeManualStepActions() {
return;
}
const step = Number(row.dataset.step);
const nodeId = String(row.dataset.nodeId || getNodeIdByStepForCurrentMode(step) || '').trim();
const statusEl = row.querySelector('.step-status');
if (!statusEl) return;
@@ -8633,13 +8769,14 @@ function initializeManualStepActions() {
manualBtn.type = 'button';
manualBtn.className = 'step-manual-btn';
manualBtn.dataset.step = String(step);
manualBtn.title = '跳过此步';
manualBtn.setAttribute('aria-label', `跳过步骤 ${step}`);
manualBtn.dataset.nodeId = nodeId;
manualBtn.title = '跳过此节点';
manualBtn.setAttribute('aria-label', `跳过节点 ${nodeId || step}`);
manualBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/></svg>';
manualBtn.addEventListener('click', async (event) => {
event.stopPropagation();
try {
await handleSkipStep(step);
await handleSkipNode(nodeId || getNodeIdByStepForCurrentMode(step));
} catch (err) {
showToast(err.message, 'error');
}
@@ -8654,16 +8791,20 @@ function initializeManualStepActions() {
function renderStepsList() {
if (!stepsList) return;
stepsList.innerHTML = stepDefinitions.map((step) => `
<div class="step-row" data-step="${step.id}" data-step-key="${escapeHtml(step.key)}">
<div class="step-indicator" data-step="${step.id}"><span class="step-num">${step.id}</span></div>
<button class="step-btn" data-step="${step.id}" data-step-key="${escapeHtml(step.key)}">${escapeHtml(step.title)}</button>
<span class="step-status" data-step="${step.id}"></span>
stepsList.innerHTML = workflowNodes.map((node) => {
const step = getStepIdByNodeIdForCurrentMode(node.nodeId);
const nodeId = String(node.nodeId || '').trim();
return `
<div class="step-row" data-step="${step}" data-node-id="${escapeHtml(nodeId)}" data-step-key="${escapeHtml(node.executeKey || nodeId)}">
<div class="step-indicator" data-step="${step}" data-node-id="${escapeHtml(nodeId)}"><span class="step-num">${step || node.displayOrder || ''}</span></div>
<button class="step-btn" data-step="${step}" data-node-id="${escapeHtml(nodeId)}" data-step-key="${escapeHtml(node.executeKey || nodeId)}">${escapeHtml(node.title)}</button>
<span class="step-status" data-step="${step}" data-node-id="${escapeHtml(nodeId)}"></span>
</div>
`).join('');
`;
}).join('');
if (stepsProgress) {
stepsProgress.textContent = `0 / ${STEP_IDS.length}`;
stepsProgress.textContent = `0 / ${NODE_IDS.length}`;
}
initializeManualStepActions();
@@ -8728,6 +8869,7 @@ function applySettingsState(state) {
signupMethod: normalizeSignupMethod(state?.signupMethod || DEFAULT_SIGNUP_METHOD),
};
syncStepDefinitionsForMode(stepDefinitionState.plusModeEnabled, {
activeFlowId: state?.flowId || state?.activeFlowId,
plusPaymentMethod: state?.plusPaymentMethod,
signupMethod: stepDefinitionState.signupMethod,
});
@@ -9282,9 +9424,9 @@ async function restoreState() {
displayLocalhostUrl.textContent = state.localhostUrl;
displayLocalhostUrl.classList.add('has-value');
}
if (state.stepStatuses) {
for (const [step, status] of Object.entries(state.stepStatuses)) {
updateStepUI(Number(step), status);
if (state.nodeStatuses) {
for (const [nodeId, status] of Object.entries(state.nodeStatuses)) {
updateNodeUI(nodeId, status);
}
}
@@ -10911,21 +11053,54 @@ function updatePanelModeUI() {
// UI Updates
// ============================================================
function updateStepUI(step, status) {
function updateNodeUI(nodeId, status) {
const normalizedNodeId = String(nodeId || '').trim();
if (!normalizedNodeId) return;
syncLatestState({
stepStatuses: {
...getStepStatuses(),
[step]: status,
nodeStatuses: {
...getNodeStatuses(),
[normalizedNodeId]: status,
},
});
renderSingleStepStatus(step, status);
renderSingleNodeStatus(normalizedNodeId, status);
updateButtonStates();
updateProgressCounter();
updateConfigMenuControls();
}
function updateStepUI(step, status) {
const nodeId = getNodeIdByStepForCurrentMode(step);
if (nodeId) {
updateNodeUI(nodeId, status);
return;
}
updateButtonStates();
updateProgressCounter();
updateConfigMenuControls();
}
function renderSingleNodeStatus(nodeId, status) {
const normalizedStatus = status || 'pending';
const normalizedNodeId = String(nodeId || '').trim();
const selectorNodeId = escapeCssValue(normalizedNodeId);
const statusEl = document.querySelector(`.step-status[data-node-id="${selectorNodeId}"]`);
const row = document.querySelector(`.step-row[data-node-id="${selectorNodeId}"]`);
if (statusEl) statusEl.textContent = STATUS_ICONS[normalizedStatus] || '';
if (row) {
row.className = `step-row ${normalizedStatus}`;
}
}
function renderSingleStepStatus(step, status) {
const nodeId = typeof getNodeIdByStepForCurrentMode === 'function'
? getNodeIdByStepForCurrentMode(step)
: '';
if (nodeId && typeof renderSingleNodeStatus === 'function') {
renderSingleNodeStatus(nodeId, status);
return;
}
const normalizedStatus = status || 'pending';
const statusEl = document.querySelector(`.step-status[data-step="${step}"]`);
const row = document.querySelector(`.step-row[data-step="${step}"]`);
@@ -10937,20 +11112,32 @@ function renderSingleStepStatus(step, status) {
}
function renderStepStatuses(state = latestState) {
if (typeof getNodeStatuses === 'function' && typeof NODE_IDS !== 'undefined') {
const statuses = getNodeStatuses(state);
for (const nodeId of NODE_IDS) {
renderSingleNodeStatus(nodeId, statuses[nodeId]);
}
} else {
const statuses = getStepStatuses(state);
for (const step of STEP_IDS) {
renderSingleStepStatus(step, statuses[step]);
}
}
updateProgressCounter();
}
function updateProgressCounter() {
if (typeof getNodeStatuses === 'function' && typeof NODE_IDS !== 'undefined') {
const completed = Object.values(getNodeStatuses()).filter(isDoneStatus).length;
stepsProgress.textContent = `${completed} / ${NODE_IDS.length}`;
return;
}
const completed = Object.values(getStepStatuses()).filter(isDoneStatus).length;
stepsProgress.textContent = `${completed} / ${STEP_IDS.length}`;
}
function updateButtonStates() {
const statuses = getStepStatuses();
const statuses = getNodeStatuses();
const anyRunning = Object.values(statuses).some(s => s === 'running');
const autoLocked = isAutoRunLockedPhase();
const autoScheduled = isAutoRunScheduledPhase();
@@ -10958,47 +11145,49 @@ function updateButtonStates() {
? selectIcloudTargetMailboxType?.value
: latestState?.icloudTargetMailboxType;
for (const step of STEP_IDS) {
const btn = document.querySelector(`.step-btn[data-step="${step}"]`);
for (const nodeId of NODE_IDS) {
const step = getStepIdByNodeIdForCurrentMode(nodeId);
const btn = document.querySelector(`.step-btn[data-node-id="${escapeCssValue(nodeId)}"]`);
if (!btn) continue;
if (anyRunning || autoLocked || autoScheduled) {
btn.disabled = true;
} else if (step === 1) {
} else if (NODE_IDS.indexOf(nodeId) === 0) {
btn.disabled = false;
} else {
const currentIndex = STEP_IDS.indexOf(step);
const prevStep = currentIndex > 0 ? STEP_IDS[currentIndex - 1] : null;
const prevStatus = prevStep === null ? 'completed' : statuses[prevStep];
const currentStatus = statuses[step];
const currentIndex = NODE_IDS.indexOf(nodeId);
const prevNodeId = currentIndex > 0 ? NODE_IDS[currentIndex - 1] : null;
const prevStatus = prevNodeId === null ? 'completed' : statuses[prevNodeId];
const currentStatus = statuses[nodeId];
btn.disabled = !(isDoneStatus(prevStatus) || currentStatus === 'failed' || isDoneStatus(currentStatus) || currentStatus === 'stopped');
}
}
document.querySelectorAll('.step-manual-btn').forEach((btn) => {
const step = Number(btn.dataset.step);
const currentStatus = statuses[step];
const currentIndex = STEP_IDS.indexOf(step);
const prevStep = currentIndex > 0 ? STEP_IDS[currentIndex - 1] : null;
const prevStatus = prevStep === null ? 'completed' : statuses[prevStep];
const nodeId = String(btn.dataset.nodeId || getNodeIdByStepForCurrentMode(step) || '').trim();
const currentStatus = statuses[nodeId];
const currentIndex = NODE_IDS.indexOf(nodeId);
const prevNodeId = currentIndex > 0 ? NODE_IDS[currentIndex - 1] : null;
const prevStatus = prevNodeId === null ? 'completed' : statuses[prevNodeId];
if (!SKIPPABLE_STEPS.has(step) || anyRunning || autoLocked || autoScheduled || currentStatus === 'running' || isDoneStatus(currentStatus)) {
if (!SKIPPABLE_NODES.has(nodeId) || anyRunning || autoLocked || autoScheduled || currentStatus === 'running' || isDoneStatus(currentStatus)) {
btn.style.display = 'none';
btn.disabled = true;
btn.title = '当前不可跳过';
return;
}
if (prevStep !== null && !isDoneStatus(prevStatus)) {
if (prevNodeId !== null && !isDoneStatus(prevStatus)) {
btn.style.display = 'none';
btn.disabled = true;
btn.title = `请先完成步骤 ${prevStep}`;
btn.title = `请先完成节点 ${prevNodeId}`;
return;
}
btn.style.display = '';
btn.disabled = false;
btn.title = `跳过步骤 ${step}`;
btn.title = `跳过节点 ${nodeId}`;
});
btnReset.disabled = anyRunning || autoScheduled || isAutoRunPausedPhase() || autoLocked;
@@ -11032,9 +11221,10 @@ function updateStopButtonState(active) {
}
function updateStatusDisplay(state) {
if (!state || !state.stepStatuses) return;
if (!state || !state.nodeStatuses) return;
statusBar.className = 'status-bar';
const nodeStatuses = getNodeStatuses(state);
const countdown = getActiveAutoRunCountdown();
if (countdown) {
@@ -11064,17 +11254,17 @@ function updateStatusDisplay(state) {
}
if (isAutoRunWaitingStepPhase()) {
const runningSteps = getRunningSteps(state);
displayStatus.textContent = runningSteps.length
? `自动等待步骤 ${runningSteps.join(', ')} 完成后继续${getAutoRunLabel()}`
const runningNodes = getRunningNodes(state);
displayStatus.textContent = runningNodes.length
? `自动等待节点 ${runningNodes.join(', ')} 完成后继续${getAutoRunLabel()}`
: `自动正在按最新进度准备继续${getAutoRunLabel()}`;
statusBar.classList.add('running');
return;
}
const running = Object.entries(state.stepStatuses).find(([, s]) => s === 'running');
const running = Object.entries(nodeStatuses).find(([, s]) => s === 'running');
if (running) {
displayStatus.textContent = `步骤 ${running[0]} 运行中...`;
displayStatus.textContent = `节点 ${running[0]} 运行中...`;
statusBar.classList.add('running');
return;
}
@@ -11085,32 +11275,32 @@ function updateStatusDisplay(state) {
return;
}
const failed = Object.entries(state.stepStatuses).find(([, s]) => s === 'failed');
const failed = Object.entries(nodeStatuses).find(([, s]) => s === 'failed');
if (failed) {
displayStatus.textContent = `步骤 ${failed[0]} 失败`;
displayStatus.textContent = `节点 ${failed[0]} 失败`;
statusBar.classList.add('failed');
return;
}
const stopped = Object.entries(state.stepStatuses).find(([, s]) => s === 'stopped');
const stopped = Object.entries(nodeStatuses).find(([, s]) => s === 'stopped');
if (stopped) {
displayStatus.textContent = `步骤 ${stopped[0]} 已停止`;
displayStatus.textContent = `节点 ${stopped[0]} 已停止`;
statusBar.classList.add('stopped');
return;
}
const lastCompleted = Object.entries(state.stepStatuses)
const lastCompleted = Object.entries(nodeStatuses)
.filter(([, s]) => isDoneStatus(s))
.map(([k]) => Number(k))
.sort((a, b) => b - a)[0];
.map(([nodeId]) => nodeId)
.sort((left, right) => NODE_IDS.indexOf(right) - NODE_IDS.indexOf(left))[0];
if (lastCompleted === STEP_IDS[STEP_IDS.length - 1]) {
displayStatus.textContent = (state.stepStatuses[lastCompleted] === 'manual_completed' || state.stepStatuses[lastCompleted] === 'skipped') ? '全部步骤已跳过/完成' : '全部步骤已完成';
if (lastCompleted === NODE_IDS[NODE_IDS.length - 1]) {
displayStatus.textContent = (nodeStatuses[lastCompleted] === 'manual_completed' || nodeStatuses[lastCompleted] === 'skipped') ? '全部节点已跳过/完成' : '全部节点已完成';
statusBar.classList.add('completed');
} else if (lastCompleted) {
displayStatus.textContent = (state.stepStatuses[lastCompleted] === 'manual_completed' || state.stepStatuses[lastCompleted] === 'skipped')
? `步骤 ${lastCompleted} 已跳过`
: `步骤 ${lastCompleted} 已完成`;
displayStatus.textContent = (nodeStatuses[lastCompleted] === 'manual_completed' || nodeStatuses[lastCompleted] === 'skipped')
? `节点 ${lastCompleted} 已跳过`
: `节点 ${lastCompleted} 已完成`;
} else {
displayStatus.textContent = '就绪';
}
@@ -11842,7 +12032,11 @@ async function maybeTakeoverAutoRun(actionLabel) {
return true;
}
async function handleSkipStep(step) {
async function handleSkipNode(nodeId) {
const normalizedNodeId = String(nodeId || '').trim();
if (!normalizedNodeId) {
throw new Error('缺少要跳过的节点。');
}
if (isAutoRunPausedPhase()) {
const takeoverResponse = await chrome.runtime.sendMessage({
type: 'TAKEOVER_AUTO_RUN',
@@ -11857,16 +12051,27 @@ async function handleSkipStep(step) {
await persistCurrentSettingsForAction();
const response = await chrome.runtime.sendMessage({
type: 'SKIP_STEP',
type: 'SKIP_NODE',
source: 'sidepanel',
payload: { step },
payload: {
nodeId: normalizedNodeId,
step: getStepIdByNodeIdForCurrentMode(normalizedNodeId),
},
});
if (response?.error) {
throw new Error(response.error);
}
showToast(`步骤 ${step} 已跳过`, 'success', 2200);
showToast(`节点 ${normalizedNodeId} 已跳过`, 'success', 2200);
}
async function handleSkipStep(step) {
const nodeId = getNodeIdByStepForCurrentMode(step);
if (!nodeId) {
throw new Error(`无效步骤:${step}`);
}
return handleSkipNode(nodeId);
}
// ============================================================
@@ -11880,7 +12085,8 @@ stepsList?.addEventListener('click', async (event) => {
}
try {
const step = Number(btn.dataset.step);
if (!(await maybeTakeoverAutoRun(`执行步骤 ${step}`))) {
const nodeId = String(btn.dataset.nodeId || getNodeIdByStepForCurrentMode(step) || '').trim();
if (!(await maybeTakeoverAutoRun(`执行节点 ${nodeId || step}`))) {
return;
}
await persistCurrentSettingsForAction();
@@ -11898,12 +12104,12 @@ stepsList?.addEventListener('click', async (event) => {
syncLatestState({ customPassword: inputPassword.value });
}
if (shouldExecuteStep3WithSignupPhoneIdentity(latestState)) {
const response = await sendSidepanelMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } });
const response = await sendSidepanelMessage({ type: 'EXECUTE_NODE', source: 'sidepanel', payload: { nodeId } });
if (response?.error) {
throw new Error(response.error);
}
} else if (selectMailProvider.value === 'hotmail-api' || isLuckmailProvider()) {
const response = await sendSidepanelMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } });
const response = await sendSidepanelMessage({ type: 'EXECUTE_NODE', source: 'sidepanel', payload: { nodeId } });
if (response?.error) {
throw new Error(response.error);
}
@@ -11913,7 +12119,7 @@ stepsList?.addEventListener('click', async (event) => {
showToast(selectMailProvider.value === GMAIL_PROVIDER ? '请先填写 Gmail 原邮箱。' : '请先填写 2925 邮箱前缀。', 'warn');
return;
}
const response = await sendSidepanelMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step, emailPrefix } });
const response = await sendSidepanelMessage({ type: 'EXECUTE_NODE', source: 'sidepanel', payload: { nodeId, emailPrefix } });
if (response?.error) {
throw new Error(response.error);
}
@@ -11934,13 +12140,13 @@ stepsList?.addEventListener('click', async (event) => {
if (!validateCurrentRegistrationEmail(email, { showToastOnFailure: true })) {
return;
}
const response = await sendSidepanelMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step, email } });
const response = await sendSidepanelMessage({ type: 'EXECUTE_NODE', source: 'sidepanel', payload: { nodeId, email } });
if (response?.error) {
throw new Error(response.error);
}
}
} else {
const response = await sendSidepanelMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } });
const response = await sendSidepanelMessage({ type: 'EXECUTE_NODE', source: 'sidepanel', payload: { nodeId } });
if (response?.error) {
throw new Error(response.error);
}
@@ -12323,7 +12529,7 @@ btnReset.addEventListener('click', async () => {
await chrome.runtime.sendMessage({ type: 'RESET', source: 'sidepanel' });
syncLatestState({
stepStatuses: STEP_DEFAULT_STATUSES,
nodeStatuses: NODE_DEFAULT_STATUSES,
currentHotmailAccountId: null,
currentLuckmailPurchase: null,
currentLuckmailMailCursor: null,
@@ -14222,9 +14428,9 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
}
break;
case 'STEP_STATUS_CHANGED': {
const { step, status } = message.payload;
updateStepUI(step, status);
case 'NODE_STATUS_CHANGED': {
const { nodeId, status } = message.payload;
updateNodeUI(nodeId, status);
chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' }).then(state => {
syncLatestState(state);
syncAutoRunState(state);
@@ -14253,7 +14459,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
localhostUrl: null,
email: null,
password: null,
stepStatuses: STEP_DEFAULT_STATUSES,
nodeStatuses: NODE_DEFAULT_STATUSES,
logs: [],
scheduledAutoRunAt: null,
autoRunCountdownAt: null,
+113
View File
@@ -5,6 +5,119 @@ const fs = require('node:fs');
const source = fs.readFileSync('background/auto-run-controller.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundAutoRunController;`)(globalScope);
const rawCreateAutoRunController = api.createAutoRunController.bind(api);
const TEST_STEP_NODE_IDS = Object.freeze({
1: 'open-chatgpt',
2: 'submit-signup-email',
3: 'fill-password',
4: 'fetch-signup-code',
5: 'fill-profile',
6: 'wait-registration-success',
7: 'oauth-login',
8: 'fetch-login-code',
9: 'confirm-oauth',
10: 'platform-verify',
});
const TEST_NODE_STEP_IDS = Object.fromEntries(
Object.entries(TEST_STEP_NODE_IDS).map(([step, nodeId]) => [nodeId, Number(step)])
);
function getTestNodeIdByStep(step) {
return TEST_STEP_NODE_IDS[Number(step)] || '';
}
function getTestStepIdByNodeId(nodeId) {
return TEST_NODE_STEP_IDS[String(nodeId || '').trim()] || null;
}
function projectStepStatusesToNodeStatuses(stepStatuses = {}) {
const nodeStatuses = {};
for (const [step, status] of Object.entries(stepStatuses || {})) {
const nodeId = getTestNodeIdByStep(step);
if (nodeId) {
nodeStatuses[nodeId] = status;
}
}
return nodeStatuses;
}
function normalizeAutoRunControllerTestDeps(deps = {}) {
const normalized = { ...deps };
if (typeof normalized.getState === 'function') {
const getState = normalized.getState;
normalized.getState = async () => {
const state = await getState();
return {
...state,
nodeStatuses: {
...projectStepStatusesToNodeStatuses(state?.stepStatuses || {}),
...(state?.nodeStatuses || {}),
},
};
};
}
if (
typeof normalized.runAutoSequenceFromNode !== 'function'
&& typeof normalized.runAutoSequenceFromStep === 'function'
) {
const runAutoSequenceFromStep = normalized.runAutoSequenceFromStep;
normalized.runAutoSequenceFromNode = async (nodeId, context = {}) => (
runAutoSequenceFromStep(getTestStepIdByNodeId(nodeId) || 1, context)
);
}
if (
typeof normalized.getFirstUnfinishedNodeId !== 'function'
&& typeof normalized.getFirstUnfinishedStep === 'function'
) {
const getFirstUnfinishedStep = normalized.getFirstUnfinishedStep;
normalized.getFirstUnfinishedNodeId = (statuses = {}, state = {}) => (
getTestNodeIdByStep(getFirstUnfinishedStep(statuses, state))
);
}
if (
typeof normalized.getRunningNodeIds !== 'function'
&& typeof normalized.getRunningSteps === 'function'
) {
const getRunningSteps = normalized.getRunningSteps;
normalized.getRunningNodeIds = (statuses = {}, state = {}) => (
getRunningSteps(statuses, state)
.map(getTestNodeIdByStep)
.filter(Boolean)
);
}
if (
typeof normalized.hasSavedNodeProgress !== 'function'
&& typeof normalized.hasSavedProgress === 'function'
) {
normalized.hasSavedNodeProgress = normalized.hasSavedProgress;
}
if (
typeof normalized.waitForRunningNodesToFinish !== 'function'
&& typeof normalized.waitForRunningStepsToFinish === 'function'
) {
normalized.waitForRunningNodesToFinish = normalized.waitForRunningStepsToFinish;
}
delete normalized.runAutoSequenceFromStep;
delete normalized.getFirstUnfinishedStep;
delete normalized.getRunningSteps;
delete normalized.hasSavedProgress;
delete normalized.waitForRunningStepsToFinish;
return normalized;
}
api.createAutoRunController = (deps = {}) => (
rawCreateAutoRunController(normalizeAutoRunControllerTestDeps(deps))
);
test('auto-run controller skips add-phone failures to the next round instead of stopping when auto retry is enabled', async () => {
const events = {
+73 -5
View File
@@ -72,6 +72,33 @@ const AUTO_RUN_RETRY_DELAY_MS = 3000;
const AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS = 'between_rounds';
const AUTO_RUN_TIMER_KIND_BEFORE_RETRY = 'before_retry';
const STEP_IDS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const STEP_NODE_IDS = {
1: 'open-chatgpt',
2: 'submit-signup-email',
3: 'fill-password',
4: 'fetch-signup-code',
5: 'fill-profile',
6: 'wait-registration-success',
7: 'oauth-login',
8: 'fetch-login-code',
9: 'confirm-oauth',
10: 'platform-verify',
};
const NODE_STEP_IDS = Object.fromEntries(Object.entries(STEP_NODE_IDS).map(([step, nodeId]) => [nodeId, Number(step)]));
function getNodeIdByStepForState(step) {
return STEP_NODE_IDS[Number(step)] || '';
}
function getStepIdByNodeIdForState(nodeId) {
return NODE_STEP_IDS[String(nodeId || '').trim()] || null;
}
function projectStepStatusesToNodeStatuses(stepStatuses = {}) {
const nodeStatuses = {};
for (const [step, status] of Object.entries(stepStatuses || {})) {
const nodeId = getNodeIdByStepForState(step);
if (nodeId) nodeStatuses[nodeId] = status;
}
return nodeStatuses;
}
const DEFAULT_STATE = {
stepStatuses: {
1: 'pending',
@@ -86,6 +113,7 @@ const DEFAULT_STATE = {
10: 'pending',
},
};
DEFAULT_STATE.nodeStatuses = projectStepStatusesToNodeStatuses(DEFAULT_STATE.stepStatuses);
let stopRequested = false;
let runCalls = 0;
@@ -137,6 +165,10 @@ async function getState() {
return {
...currentState,
stepStatuses: { ...(currentState.stepStatuses || {}) },
nodeStatuses: {
...projectStepStatusesToNodeStatuses(currentState.stepStatuses || {}),
...(currentState.nodeStatuses || {}),
},
tabRegistry: { ...(currentState.tabRegistry || {}) },
sourceLastUrls: { ...(currentState.sourceLastUrls || {}) },
};
@@ -149,6 +181,9 @@ async function setState(updates) {
stepStatuses: updates.stepStatuses
? { ...updates.stepStatuses }
: currentState.stepStatuses,
nodeStatuses: updates.nodeStatuses
? { ...updates.nodeStatuses }
: currentState.nodeStatuses,
tabRegistry: updates.tabRegistry
? { ...updates.tabRegistry }
: currentState.tabRegistry,
@@ -163,6 +198,7 @@ async function resetState() {
currentState = {
...DEFAULT_STATE,
stepStatuses: { ...DEFAULT_STATE.stepStatuses },
nodeStatuses: { ...DEFAULT_STATE.nodeStatuses },
vpsUrl: prev.vpsUrl,
vpsPassword: prev.vpsPassword,
customPassword: prev.customPassword,
@@ -262,6 +298,18 @@ async function runAutoSequenceFromStep() {
9: 'completed',
10: 'completed',
},
nodeStatuses: projectStepStatusesToNodeStatuses({
1: 'completed',
2: 'completed',
3: 'completed',
4: 'completed',
5: 'completed',
6: 'completed',
7: 'completed',
8: 'completed',
9: 'completed',
10: 'completed',
}),
tabRegistry: {
'signup-page': { tabId: 88, ready: true },
},
@@ -271,6 +319,26 @@ async function runAutoSequenceFromStep() {
};
}
async function runAutoSequenceFromNode(nodeId, context = {}) {
return runAutoSequenceFromStep(getStepIdByNodeIdForState(nodeId) || 1, context);
}
function getFirstUnfinishedNodeId() {
return 'open-chatgpt';
}
function getRunningNodeIds() {
return [];
}
function hasSavedNodeProgress() {
return false;
}
async function waitForRunningNodesToFinish() {
return getState();
}
${helperBundle}
${autoRunModuleSource}
@@ -303,24 +371,24 @@ const controller = self.MultiPageBackgroundAutoRunController.createAutoRunContro
createAutoRunSessionId,
getAutoRunStatusPayload,
getErrorMessage,
getFirstUnfinishedStep,
getFirstUnfinishedNodeId,
getPendingAutoRunTimerPlan,
getRunningSteps,
getRunningNodeIds,
getState,
getStopRequested: () => stopRequested,
hasSavedProgress,
hasSavedNodeProgress,
isRestartCurrentAttemptError,
isStopError,
launchAutoRunTimerPlan,
normalizeAutoRunFallbackThreadIntervalMinutes,
persistAutoRunTimerPlan,
resetState,
runAutoSequenceFromStep,
runAutoSequenceFromNode,
runtime,
setState,
sleepWithStop,
throwIfAutoRunSessionStopped,
waitForRunningStepsToFinish,
waitForRunningNodesToFinish,
throwIfStopped,
chrome,
});
+84
View File
@@ -5,6 +5,90 @@ const fs = require('node:fs');
const source = fs.readFileSync('background/auto-run-controller.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundAutoRunController;`)(globalScope);
const rawCreateAutoRunController = api.createAutoRunController.bind(api);
const TEST_STEP_NODE_IDS = Object.freeze({
1: 'open-chatgpt',
2: 'submit-signup-email',
3: 'fill-password',
4: 'fetch-signup-code',
5: 'fill-profile',
6: 'wait-registration-success',
7: 'oauth-login',
8: 'fetch-login-code',
9: 'confirm-oauth',
10: 'platform-verify',
});
const TEST_NODE_STEP_IDS = Object.fromEntries(
Object.entries(TEST_STEP_NODE_IDS).map(([step, nodeId]) => [nodeId, Number(step)])
);
function getTestNodeIdByStep(step) {
return TEST_STEP_NODE_IDS[Number(step)] || '';
}
function getTestStepIdByNodeId(nodeId) {
return TEST_NODE_STEP_IDS[String(nodeId || '').trim()] || null;
}
function projectStepStatusesToNodeStatuses(stepStatuses = {}) {
const nodeStatuses = {};
for (const [step, status] of Object.entries(stepStatuses || {})) {
const nodeId = getTestNodeIdByStep(step);
if (nodeId) {
nodeStatuses[nodeId] = status;
}
}
return nodeStatuses;
}
api.createAutoRunController = (deps = {}) => {
const normalized = { ...deps };
if (typeof normalized.getState === 'function') {
const getState = normalized.getState;
normalized.getState = async () => {
const state = await getState();
return {
...state,
nodeStatuses: {
...projectStepStatusesToNodeStatuses(state?.stepStatuses || {}),
...(state?.nodeStatuses || {}),
},
};
};
}
if (typeof normalized.runAutoSequenceFromNode !== 'function' && typeof normalized.runAutoSequenceFromStep === 'function') {
const runAutoSequenceFromStep = normalized.runAutoSequenceFromStep;
normalized.runAutoSequenceFromNode = (nodeId, context = {}) => (
runAutoSequenceFromStep(getTestStepIdByNodeId(nodeId) || 1, context)
);
}
if (typeof normalized.getFirstUnfinishedNodeId !== 'function' && typeof normalized.getFirstUnfinishedStep === 'function') {
const getFirstUnfinishedStep = normalized.getFirstUnfinishedStep;
normalized.getFirstUnfinishedNodeId = (statuses = {}, state = {}) => (
getTestNodeIdByStep(getFirstUnfinishedStep(statuses, state))
);
}
if (typeof normalized.getRunningNodeIds !== 'function' && typeof normalized.getRunningSteps === 'function') {
const getRunningSteps = normalized.getRunningSteps;
normalized.getRunningNodeIds = (statuses = {}, state = {}) => (
getRunningSteps(statuses, state).map(getTestNodeIdByStep).filter(Boolean)
);
}
if (typeof normalized.hasSavedNodeProgress !== 'function' && typeof normalized.hasSavedProgress === 'function') {
normalized.hasSavedNodeProgress = normalized.hasSavedProgress;
}
if (typeof normalized.waitForRunningNodesToFinish !== 'function' && typeof normalized.waitForRunningStepsToFinish === 'function') {
normalized.waitForRunningNodesToFinish = normalized.waitForRunningStepsToFinish;
}
delete normalized.runAutoSequenceFromStep;
delete normalized.getFirstUnfinishedStep;
delete normalized.getRunningSteps;
delete normalized.hasSavedProgress;
delete normalized.waitForRunningStepsToFinish;
return rawCreateAutoRunController(normalized);
};
test('auto-run controller verifies hotmail mailbox before each fresh attempt starts', async () => {
const events = {
@@ -1,4 +1,4 @@
const test = require('node:test');
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
@@ -52,6 +52,97 @@ function extractFunction(name) {
return source.slice(start, end);
}
const NODE_COMPAT_HELPERS = `
const STEP_NODE_IDS = {
1: 'open-chatgpt',
2: 'submit-signup-email',
3: 'fill-password',
4: 'fetch-signup-code',
5: 'fill-profile',
6: 'wait-registration-success',
7: 'oauth-login',
8: 'fetch-login-code',
9: 'confirm-oauth',
10: 'platform-verify',
};
const NODE_STEP_IDS = Object.fromEntries(Object.entries(STEP_NODE_IDS).map(([step, nodeId]) => [nodeId, Number(step)]));
function getNodeIdByStepForState(step) {
return STEP_NODE_IDS[Number(step)] || '';
}
function getStepIdByNodeIdForState(nodeId) {
return NODE_STEP_IDS[String(nodeId || '').trim()] || null;
}
function getNodeIdsForState() {
return Object.keys(STEP_NODE_IDS)
.map(Number)
.sort((left, right) => left - right)
.map((step) => STEP_NODE_IDS[step])
.filter(Boolean);
}
function getNodeDefinitionForState(nodeId) {
const normalizedNodeId = String(nodeId || '').trim();
return normalizedNodeId ? { nodeId: normalizedNodeId, executeKey: normalizedNodeId } : null;
}
function getNodeTitleForState(nodeId) {
return String(nodeId || '').trim();
}
function projectStepStatusesToNodeStatuses(stepStatuses = {}) {
const nodeStatuses = {};
for (const [step, status] of Object.entries(stepStatuses || {})) {
const nodeId = getNodeIdByStepForState(step);
if (nodeId) nodeStatuses[nodeId] = status;
}
return nodeStatuses;
}
function projectNodeStatusesToStepStatuses(nodeStatuses = {}) {
const stepStatuses = {};
for (const [nodeId, status] of Object.entries(nodeStatuses || {})) {
const step = getStepIdByNodeIdForState(nodeId);
if (step) stepStatuses[step] = status;
}
return stepStatuses;
}
const rawGetStateForNodeCompat = getState;
getState = async function getStateWithNodeStatuses() {
const state = await rawGetStateForNodeCompat();
return {
...state,
nodeStatuses: {
...projectStepStatusesToNodeStatuses(state?.stepStatuses || {}),
...(state?.nodeStatuses || {}),
},
};
};
const rawSetStateForNodeCompat = setState;
setState = async function setStateWithNodeStatuses(updates = {}) {
const stepStatusUpdates = updates?.nodeStatuses
? projectNodeStatusesToStepStatuses(updates.nodeStatuses)
: {};
return rawSetStateForNodeCompat({
...updates,
...(Object.keys(stepStatusUpdates).length ? {
stepStatuses: {
...stepStatusUpdates,
...(updates.stepStatuses || {}),
},
} : {}),
});
};
async function executeNodeAndWait(nodeId, delayAfter) {
const directStep = Number(nodeId);
if (Number.isInteger(directStep) && directStep > 0) {
return executeStepAndWait(directStep, delayAfter);
}
return executeStepAndWait(getStepIdByNodeIdForState(nodeId), delayAfter);
}
function getAutoRunNodeDelayMs() {
return 0;
}
async function runAutoSequenceFromStep(step, context = {}) {
return runAutoSequenceFromNode(getNodeIdByStepForState(step), context);
}
`;
const bundle = [
'const AUTO_RUN_STEP_IDLE_LOG_TIMEOUT_MS = 300000;',
'const AUTO_RUN_STEP_IDLE_LOG_CHECK_INTERVAL_MS = 5000;',
@@ -65,13 +156,16 @@ const bundle = [
extractFunction('isPlusCheckoutRestartStep'),
extractFunction('isPlusCheckoutRestartRequiredFailure'),
extractFunction('getLatestLogTimestamp'),
extractFunction('buildAutoRunStepIdleRestartError'),
extractFunction('buildAutoRunNodeIdleRestartError'),
extractFunction('isAutoRunStepIdleRestartError'),
extractFunction('startAutoRunStepIdleLogWatchdog'),
extractFunction('runAutoStepActionWithIdleLogWatchdog'),
extractFunction('executeStepAndWaitWithAutoRunIdleLogWatchdog'),
extractFunction('startAutoRunNodeIdleLogWatchdog'),
extractFunction('runAutoNodeActionWithIdleLogWatchdog'),
extractFunction('executeNodeAndWaitWithAutoRunIdleLogWatchdog'),
extractFunction('getPostStep6AutoRestartDecision'),
extractFunction('runAutoSequenceFromStep'),
NODE_COMPAT_HELPERS,
extractFunction('getAutoRunWorkflowNodeIds'),
extractFunction('runAutoSequenceFromNode'),
extractFunction('runAutoSequenceFromNodeGraph'),
].join('\n');
test('auto-run stops step4 restart path when mail2925 ends the current thread', async () => {
+113 -19
View File
@@ -1,4 +1,4 @@
const test = require('node:test');
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
@@ -52,6 +52,97 @@ function extractFunction(name) {
return source.slice(start, end);
}
const NODE_COMPAT_HELPERS = `
const STEP_NODE_IDS = {
1: 'open-chatgpt',
2: 'submit-signup-email',
3: 'fill-password',
4: 'fetch-signup-code',
5: 'fill-profile',
6: 'wait-registration-success',
7: 'oauth-login',
8: 'fetch-login-code',
9: 'confirm-oauth',
10: 'platform-verify',
};
const NODE_STEP_IDS = Object.fromEntries(Object.entries(STEP_NODE_IDS).map(([step, nodeId]) => [nodeId, Number(step)]));
function getNodeIdByStepForState(step) {
return STEP_NODE_IDS[Number(step)] || '';
}
function getStepIdByNodeIdForState(nodeId) {
return NODE_STEP_IDS[String(nodeId || '').trim()] || null;
}
function getNodeIdsForState() {
return Object.keys(STEP_NODE_IDS)
.map(Number)
.sort((left, right) => left - right)
.map((step) => STEP_NODE_IDS[step])
.filter(Boolean);
}
function getNodeDefinitionForState(nodeId) {
const normalizedNodeId = String(nodeId || '').trim();
return normalizedNodeId ? { nodeId: normalizedNodeId, executeKey: normalizedNodeId } : null;
}
function getNodeTitleForState(nodeId) {
return String(nodeId || '').trim();
}
function projectStepStatusesToNodeStatuses(stepStatuses = {}) {
const nodeStatuses = {};
for (const [step, status] of Object.entries(stepStatuses || {})) {
const nodeId = getNodeIdByStepForState(step);
if (nodeId) nodeStatuses[nodeId] = status;
}
return nodeStatuses;
}
function projectNodeStatusesToStepStatuses(nodeStatuses = {}) {
const stepStatuses = {};
for (const [nodeId, status] of Object.entries(nodeStatuses || {})) {
const step = getStepIdByNodeIdForState(nodeId);
if (step) stepStatuses[step] = status;
}
return stepStatuses;
}
const rawGetStateForNodeCompat = getState;
getState = async function getStateWithNodeStatuses() {
const state = await rawGetStateForNodeCompat();
return {
...state,
nodeStatuses: {
...projectStepStatusesToNodeStatuses(state?.stepStatuses || {}),
...(state?.nodeStatuses || {}),
},
};
};
const rawSetStateForNodeCompat = setState;
setState = async function setStateWithNodeStatuses(updates = {}) {
const stepStatusUpdates = updates?.nodeStatuses
? projectNodeStatusesToStepStatuses(updates.nodeStatuses)
: {};
return rawSetStateForNodeCompat({
...updates,
...(Object.keys(stepStatusUpdates).length ? {
stepStatuses: {
...stepStatusUpdates,
...(updates.stepStatuses || {}),
},
} : {}),
});
};
async function executeNodeAndWait(nodeId, delayAfter) {
const directStep = Number(nodeId);
if (Number.isInteger(directStep) && directStep > 0) {
return executeStepAndWait(directStep, delayAfter);
}
return executeStepAndWait(getStepIdByNodeIdForState(nodeId), delayAfter);
}
function getAutoRunNodeDelayMs() {
return 0;
}
async function runAutoSequenceFromStep(step, context = {}) {
return runAutoSequenceFromNode(getNodeIdByStepForState(step), context);
}
`;
const bundle = [
'const AUTO_RUN_STEP_IDLE_LOG_TIMEOUT_MS = 300000;',
'const AUTO_RUN_STEP_IDLE_LOG_CHECK_INTERVAL_MS = 5000;',
@@ -62,19 +153,22 @@ const bundle = [
extractFunction('isMail2925ThreadTerminatedError'),
extractFunction('isSignupPhonePasswordMismatchFailure'),
extractFunction('getSignupPhonePasswordMismatchRestartPayload'),
extractFunction('restartSignupPhonePasswordMismatchAttemptFromStep'),
extractFunction('restartSignupPhonePasswordMismatchAttemptFromNode'),
extractFunction('isSignupUserAlreadyExistsFailure'),
extractFunction('isPlusCheckoutNonFreeTrialFailure'),
extractFunction('isPlusCheckoutRestartStep'),
extractFunction('isPlusCheckoutRestartRequiredFailure'),
extractFunction('getLatestLogTimestamp'),
extractFunction('buildAutoRunStepIdleRestartError'),
extractFunction('buildAutoRunNodeIdleRestartError'),
extractFunction('isAutoRunStepIdleRestartError'),
extractFunction('startAutoRunStepIdleLogWatchdog'),
extractFunction('runAutoStepActionWithIdleLogWatchdog'),
extractFunction('executeStepAndWaitWithAutoRunIdleLogWatchdog'),
extractFunction('startAutoRunNodeIdleLogWatchdog'),
extractFunction('runAutoNodeActionWithIdleLogWatchdog'),
extractFunction('executeNodeAndWaitWithAutoRunIdleLogWatchdog'),
extractFunction('getPostStep6AutoRestartDecision'),
extractFunction('runAutoSequenceFromStep'),
NODE_COMPAT_HELPERS,
extractFunction('getAutoRunWorkflowNodeIds'),
extractFunction('runAutoSequenceFromNode'),
extractFunction('runAutoSequenceFromNodeGraph'),
].join('\n');
test('auto-run restarts from step 1 with the same email after step 4 failure', async () => {
@@ -217,7 +311,7 @@ return {
{
step: 1,
options: {
logLabel: '步骤 4 报错后准备回到步骤 1 沿用当前邮箱重试(第 1 次重开)',
logLabel: '节点 fetch-signup-code 报错后准备回到 open-chatgpt 沿用当前邮箱重试(第 1 次重开)',
},
},
]);
@@ -225,7 +319,7 @@ return {
assert.deepStrictEqual(events.steps, [1, 2, 3, 4, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
assert.equal(currentState.email, 'keep@example.com');
assert.equal(currentState.password, 'Secret123!');
assert.equal(events.logs.some(({ message }) => /沿用当前邮箱回到步骤 1 重新开始/.test(message)), true);
assert.equal(events.logs.some(({ message }) => /沿用当前邮箱回到节点 open-chatgpt 重新开始/.test(message)), true);
});
test('auto-run does not restart step 4 current attempt when user_already_exists is detected', async () => {
@@ -348,7 +442,7 @@ return {
assert.match(result.error, /SIGNUP_USER_ALREADY_EXISTS::/);
assert.deepStrictEqual(result.events.invalidations, []);
assert.deepStrictEqual(result.events.steps, [1, 2, 3, 4]);
assert.equal(result.events.logs.some(({ message }) => /沿用当前邮箱回到步骤 1 重新开始/.test(message)), false);
assert.equal(result.events.logs.some(({ message }) => /沿用当前邮箱回到节点 open-chatgpt 重新开始/.test(message)), false);
});
test('auto-run skips steps 4/5 when step 2 has already marked registration chain as skipped', async () => {
@@ -470,8 +564,8 @@ return {
const { events } = await api.run();
assert.deepStrictEqual(events.steps, [1, 2, 6, 7, 8, 9, 10]);
assert.equal(events.logs.some(({ message }) => /步骤 4 当前状态为 skipped/.test(message)), true);
assert.equal(events.logs.some(({ message }) => /步骤 5 当前状态为 skipped/.test(message)), true);
assert.equal(events.logs.some(({ message }) => /节点 fetch-signup-code 当前状态为 skipped/.test(message)), true);
assert.equal(events.logs.some(({ message }) => /节点 fill-profile 当前状态为 skipped/.test(message)), true);
});
test('auto-run clears fetched signup phone state before restarting when step 4 detects phone/password mismatch', async () => {
@@ -622,7 +716,7 @@ return {
{
step: 1,
options: {
logLabel: '步骤 4 检测到手机号/密码不匹配后准备回到步骤 1 重新获取手机号重试(第 1 次重开)',
logLabel: '节点 fetch-signup-code 检测到手机号/密码不匹配后准备回到 open-chatgpt 重新获取手机号重试(第 1 次重开)',
},
},
]);
@@ -632,7 +726,7 @@ return {
assert.equal(currentState.accountIdentifierType, null);
assert.equal(currentState.accountIdentifier, '');
assert.equal(currentState.password, 'Secret123!');
assert.equal(events.logs.some(({ message }) => /丢弃当前注册手机号并回到步骤 1 重新开始/.test(message)), true);
assert.equal(events.logs.some(({ message }) => /丢弃当前注册手机号并回到节点 open-chatgpt 重新开始/.test(message)), true);
assert.equal(events.logs.some(({ message }) => /已清空本轮注册手机号与接码订单/.test(message)), true);
});
@@ -786,7 +880,7 @@ return {
{
step: 1,
options: {
logLabel: '步骤 4 检测到当前注册手机号无法接收短信后准备回到步骤 1 重新获取手机号重试(第 1 次重开)',
logLabel: '节点 fetch-signup-code 检测到当前注册手机号无法接收短信后准备回到 open-chatgpt 重新获取手机号重试(第 1 次重开)',
},
},
]);
@@ -796,7 +890,7 @@ return {
assert.equal(currentState.accountIdentifierType, null);
assert.equal(currentState.accountIdentifier, '');
assert.equal(currentState.password, 'Secret123!');
assert.equal(events.logs.some(({ message }) => /丢弃当前注册手机号并回到步骤 1 重新开始/.test(message)), true);
assert.equal(events.logs.some(({ message }) => /丢弃当前注册手机号并回到节点 open-chatgpt 重新开始/.test(message)), true);
assert.equal(events.logs.some(({ message }) => /已清空本轮注册手机号与接码订单/.test(message)), true);
});
@@ -943,7 +1037,7 @@ return {
{
step: 1,
options: {
logLabel: '步骤 3 检测到手机号/密码不匹配后准备回到步骤 1 重新获取手机号重试(第 1 次重开)',
logLabel: '节点 fill-password 检测到手机号/密码不匹配后准备回到 open-chatgpt 重新获取手机号重试(第 1 次重开)',
},
},
]);
@@ -953,6 +1047,6 @@ return {
assert.equal(currentState.accountIdentifierType, null);
assert.equal(currentState.accountIdentifier, '');
assert.equal(currentState.password, 'Secret123!');
assert.equal(events.logs.some(({ message }) => /步骤 3检测到手机号\/密码不匹配/.test(message)), true);
assert.equal(events.logs.some(({ message }) => /步骤 3已清空本轮注册手机号与接码订单/.test(message)), true);
assert.equal(events.logs.some(({ message }) => /节点 fill-password检测到手机号\/密码不匹配/.test(message)), true);
assert.equal(events.logs.some(({ message }) => /节点 fill-password已清空本轮注册手机号与接码订单/.test(message)), true);
});
+118 -22
View File
@@ -1,4 +1,4 @@
const test = require('node:test');
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
@@ -52,6 +52,99 @@ function extractFunction(name) {
return source.slice(start, end);
}
const NODE_COMPAT_HELPERS = `
const FALLBACK_STEP_NODE_IDS = {
1: 'open-chatgpt',
2: 'submit-signup-email',
3: 'fill-password',
4: 'fetch-signup-code',
5: 'fill-profile',
6: 'plus-checkout-create',
7: 'plus-checkout-billing',
8: 'paypal-approve',
9: 'plus-checkout-return',
10: 'oauth-login',
11: 'fetch-login-code',
12: 'confirm-oauth',
13: 'platform-verify',
};
function getNodeIdByStepForState(step, state = {}) {
if (typeof getStepDefinitionForState === 'function') {
const key = String(getStepDefinitionForState(step, state)?.key || '').trim();
if (key) return key;
}
return FALLBACK_STEP_NODE_IDS[Number(step)] || '';
}
function getStepIdByNodeIdForState(nodeId, state = {}) {
const normalizedNodeId = String(nodeId || '').trim();
const ids = typeof getStepIdsForState === 'function'
? getStepIdsForState(state)
: Object.keys(FALLBACK_STEP_NODE_IDS).map(Number);
for (const step of ids) {
if (getNodeIdByStepForState(step, state) === normalizedNodeId) {
return Number(step);
}
}
for (const [step, fallbackNodeId] of Object.entries(FALLBACK_STEP_NODE_IDS)) {
if (fallbackNodeId === normalizedNodeId) {
return Number(step);
}
}
return null;
}
function getNodeIdsForState(state = {}) {
const ids = typeof getStepIdsForState === 'function'
? getStepIdsForState(state)
: Object.keys(FALLBACK_STEP_NODE_IDS).map(Number);
return ids
.map((step) => getNodeIdByStepForState(step, state))
.filter(Boolean);
}
function getNodeDefinitionForState(nodeId, state = {}) {
const normalizedNodeId = String(nodeId || '').trim();
const step = getStepIdByNodeIdForState(normalizedNodeId, state);
const executeKey = typeof getStepExecutionKeyForState === 'function'
? getStepExecutionKeyForState(step, state)
: normalizedNodeId;
return normalizedNodeId ? { nodeId: normalizedNodeId, legacyStepId: step, executeKey } : null;
}
function getNodeTitleForState(nodeId) {
return String(nodeId || '').trim();
}
function projectStepStatusesToNodeStatuses(stepStatuses = {}, state = {}) {
const nodeStatuses = {};
for (const [step, status] of Object.entries(stepStatuses || {})) {
const nodeId = getNodeIdByStepForState(step, state);
if (nodeId) nodeStatuses[nodeId] = status;
}
return nodeStatuses;
}
const rawGetStateForNodeCompat = getState;
getState = async function getStateWithNodeStatuses() {
const state = await rawGetStateForNodeCompat();
return {
...state,
nodeStatuses: {
...projectStepStatusesToNodeStatuses(state?.stepStatuses || {}, state),
...(state?.nodeStatuses || {}),
},
};
};
async function executeNodeAndWait(nodeId, delayAfter) {
const directStep = Number(nodeId);
if (Number.isInteger(directStep) && directStep > 0) {
return executeStepAndWait(directStep, delayAfter);
}
return executeStepAndWait(getStepIdByNodeIdForState(nodeId, await getState()), delayAfter);
}
function getAutoRunNodeDelayMs() {
return 0;
}
async function runAutoSequenceFromStep(step, context = {}) {
return runAutoSequenceFromNode(getNodeIdByStepForState(step, await getState()), context);
}
`;
const bundle = [
extractFunction('isAddPhoneAuthFailure'),
extractFunction('isAddPhoneAuthUrl'),
@@ -61,13 +154,16 @@ const bundle = [
extractFunction('isPlusCheckoutRestartStep'),
extractFunction('isPlusCheckoutRestartRequiredFailure'),
extractFunction('getLatestLogTimestamp'),
extractFunction('buildAutoRunStepIdleRestartError'),
extractFunction('buildAutoRunNodeIdleRestartError'),
extractFunction('isAutoRunStepIdleRestartError'),
extractFunction('startAutoRunStepIdleLogWatchdog'),
extractFunction('runAutoStepActionWithIdleLogWatchdog'),
extractFunction('executeStepAndWaitWithAutoRunIdleLogWatchdog'),
extractFunction('startAutoRunNodeIdleLogWatchdog'),
extractFunction('runAutoNodeActionWithIdleLogWatchdog'),
extractFunction('executeNodeAndWaitWithAutoRunIdleLogWatchdog'),
extractFunction('getPostStep6AutoRestartDecision'),
extractFunction('runAutoSequenceFromStep'),
NODE_COMPAT_HELPERS,
extractFunction('getAutoRunWorkflowNodeIds'),
extractFunction('runAutoSequenceFromNode'),
extractFunction('runAutoSequenceFromNodeGraph'),
].join('\n');
const defaultStepDefinitions = {
@@ -311,7 +407,7 @@ test('auto-run keeps restarting from step 7 after post-login failures without a
7, 8, 9, 10,
]
);
assert.ok(events.logs.some(({ message }) => /回到步骤 7 重新开始授权流程/.test(message)));
assert.ok(events.logs.some(({ message }) => /回到节点 auth-login 重新开始授权流程/.test(message)));
});
test('auto-run restarts the current step after five minutes without new logs', async () => {
@@ -330,7 +426,7 @@ test('auto-run restarts the current step after five minutes without new logs', a
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)));
assert.ok(events.logs.some(({ message }) => /5 分钟没有新日志准备重新开始当前节点/.test(message)));
});
test('auto-run applies the idle-log restart watchdog to early steps too', async () => {
@@ -364,7 +460,7 @@ test('auto-run stops current-step idle restarts after the retry cap', async () =
const result = await harness.runAndCaptureError();
assert.ok(result?.error);
assert.match(result.error.message, /AUTO_RUN_STEP_IDLE_RESTART::步骤 10/);
assert.match(result.error.message, /AUTO_RUN_STEP_IDLE_RESTART::节点 platform-verify/);
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)));
@@ -474,10 +570,10 @@ test('auto-run restarts from confirm-oauth step after transient step10 token_exc
assert.deepStrictEqual(events.invalidations[0], {
step: 8,
options: {
logLabel: '步骤 10 报错后准备回到步骤 9 重试(第 1 次重开)',
logLabel: '节点 platform-verify 报错后准备回到 confirm-oauth 重试(第 1 次重开)',
},
});
assert.ok(events.logs.some(({ message }) => /回到步骤 9 重新开始授权流程/.test(message)));
assert.ok(events.logs.some(({ message }) => /回到节点 confirm-oauth 重新开始授权流程/.test(message)));
});
test('auto-run restarts Plus/GPC oauth-login aggregate entry-open failure from step 10', async () => {
@@ -508,9 +604,9 @@ test('auto-run restarts Plus/GPC oauth-login aggregate entry-open failure from s
const events = await harness.run();
assert.deepStrictEqual(events.steps, [10, 10, 11, 12, 13]);
assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [9]);
assert.ok(events.logs.some(({ message }) => /回到步骤 10 重新开始授权流程/.test(message)));
assert.ok(!events.logs.some(({ message }) => /停止自动回到步骤 10 重开/.test(message)));
assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [7]);
assert.ok(events.logs.some(({ message }) => /回到节点 oauth-login 重新开始授权流程/.test(message)));
assert.ok(!events.logs.some(({ message }) => /停止自动回到节点 oauth-login 重开/.test(message)));
});
test('auto-run restarts Plus/GPC fetch-code and confirm-oauth failures from oauth-login step 10', async () => {
@@ -554,8 +650,8 @@ test('auto-run restarts Plus/GPC fetch-code and confirm-oauth failures from oaut
const events = await harness.run();
assert.deepStrictEqual(events.steps, scenario.expectedSteps);
assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [9]);
assert.ok(events.logs.some(({ message }) => new RegExp(`步骤 ${scenario.failureStep}.*回到步骤 10 重新开始授权流程`).test(message)));
assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [7]);
assert.ok(events.logs.some(({ message }) => /回到节点 oauth-login 重新开始授权流程/.test(message)));
}
});
@@ -589,7 +685,7 @@ test('auto-run restarts Plus/GPC transient platform verify exchange failures fro
assert.deepStrictEqual(events.steps, [10, 11, 12, 13, 12, 13]);
assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [11]);
assert.ok(events.logs.some(({ message }) => /步骤 13.*回到步骤 12 重新开始授权流程/.test(message)));
assert.ok(events.logs.some(({ message }) => /节点 platform-verify.*回到节点 confirm-oauth 重新开始授权流程/.test(message)));
});
test('auto-run restarts Plus checkout from step 6 when checkout creation fails', async () => {
@@ -621,7 +717,7 @@ test('auto-run restarts Plus checkout from step 6 when checkout creation fails',
assert.deepStrictEqual(events.steps, [6, 6, 7, 8, 9, 10, 11, 12, 13]);
assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [5]);
assert.ok(events.logs.some(({ message }) => /回到步骤 6 重新创建 Plus Checkout/.test(message)));
assert.ok(events.logs.some(({ message }) => /回到节点 plus-checkout-create 重新创建 Plus Checkout/.test(message)));
});
test('auto-run restarts Plus checkout from step 6 when billing fails for non-free-trial reasons', async () => {
@@ -653,7 +749,7 @@ test('auto-run restarts Plus checkout from step 6 when billing fails for non-fre
assert.deepStrictEqual(events.steps, [6, 7, 6, 7, 8, 9, 10, 11, 12, 13]);
assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [5]);
assert.ok(events.logs.some(({ message }) => /回到步骤 6 重新创建 Plus Checkout/.test(message)));
assert.ok(events.logs.some(({ message }) => /回到节点 plus-checkout-create 重新创建 Plus Checkout/.test(message)));
});
test('auto-run restarts GPC checkout from step 6 when step 7 task polling stalls', async () => {
@@ -689,7 +785,7 @@ test('auto-run restarts GPC checkout from step 6 when step 7 task polling stalls
events.invalidations.map((entry) => entry.step),
[5, 5]
);
assert.ok(events.logs.some(({ message }) => /回到步骤 6 重新创建 GPC 任务/.test(message)));
assert.ok(events.logs.some(({ message }) => /回到节点 plus-checkout-create 重新创建 GPC 任务/.test(message)));
});
test('auto-run treats GPC account binding as recoverable step 6 restart', async () => {
@@ -748,7 +844,7 @@ test('auto-run restarts GPC checkout from step 6 when accessToken cannot be read
assert.deepStrictEqual(events.steps, [6, 6, 7, 10, 11, 12, 13]);
assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [5]);
assert.ok(events.logs.some(({ message }) => /回到步骤 6 重新创建 GPC 任务/.test(message)));
assert.ok(events.logs.some(({ message }) => /回到节点 plus-checkout-create 重新创建 GPC 任务/.test(message)));
});
test('auto-run restarts GPC checkout from step 6 when task status has no progress', async () => {
@@ -778,7 +874,7 @@ test('auto-run restarts GPC checkout from step 6 when task status has no progres
assert.deepStrictEqual(events.steps, [6, 7, 6, 7, 10, 11, 12, 13]);
assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [5]);
assert.ok(events.logs.some(({ message }) => /回到步骤 6 重新创建 GPC 任务/.test(message)));
assert.ok(events.logs.some(({ message }) => /回到节点 plus-checkout-create 重新创建 GPC 任务/.test(message)));
});
test('auto-run keeps rebuilding GPC checkout beyond three failures', async () => {
+4
View File
@@ -47,9 +47,13 @@ const bundle = [
'const PERSISTED_SETTING_DEFAULTS = { autoStepDelaySeconds: null };',
"const AUTO_RUN_PRE_EXECUTION_DELAYS_BY_STEP_KEY = new Map([['plus-checkout-create', 20000]]);",
'function getStepDefinitionForState(step, state = {}) { return state.definitions?.[step] || null; }',
'function getNodeIdByStepForState(step, state = {}) { return String(getStepDefinitionForState(step, state)?.key || step || "").trim(); }',
'function getNodeDefinitionForState(nodeId, state = {}) { return Object.values(state.definitions || {}).find((definition) => String(definition?.key || "").trim() === String(nodeId || "").trim()) || { executeKey: String(nodeId || "").trim() }; }',
extractFunction('normalizeAutoStepDelaySeconds'),
extractFunction('resolveLegacyAutoStepDelaySeconds'),
extractFunction('getStepExecutionKeyForState'),
extractFunction('getNodeExecutionKeyForState'),
extractFunction('getAutoRunPreExecutionDelayMsForNode'),
extractFunction('getAutoRunPreExecutionDelayMs'),
].join('\n');
@@ -46,7 +46,13 @@ test('account run history helper upgrades old records, keeps stopped items and s
},
},
getErrorMessage: (error) => error?.message || String(error || ''),
getNodeTitleForState: (nodeId) => ({
'fetch-login-code': '获取登录验证码',
'oauth-login': '刷新 OAuth 并登录',
})[nodeId] || nodeId,
getState: async () => ({
flowId: 'openai',
runId: 'run-2',
email: ' latest@example.com ',
password: ' secret ',
autoRunning: true,
@@ -61,6 +67,8 @@ test('account run history helper upgrades old records, keeps stopped items and s
const record = helpers.buildAccountRunHistoryRecord(
{
flowId: 'openai',
runId: 'run-2',
email: ' latest@example.com ',
password: ' secret ',
autoRunning: true,
@@ -68,11 +76,13 @@ test('account run history helper upgrades old records, keeps stopped items and s
autoRunTotalRuns: 10,
autoRunAttemptRun: 3,
},
'step8_failed',
'node:fetch-login-code:failed',
'步骤 8:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。'
);
assert.deepStrictEqual(record, {
recordId: 'latest@example.com',
flowId: 'openai',
runId: 'run-2',
accountIdentifierType: 'email',
accountIdentifier: 'latest@example.com',
email: 'latest@example.com',
@@ -83,7 +93,8 @@ test('account run history helper upgrades old records, keeps stopped items and s
retryCount: 2,
failureLabel: '出现手机号验证',
failureDetail: '步骤 8:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。',
failedStep: 8,
failedNodeId: 'fetch-login-code',
failedStep: null,
source: 'auto',
autoRunContext: {
currentRun: 2,
@@ -94,9 +105,12 @@ test('account run history helper upgrades old records, keeps stopped items and s
contributionMode: false,
});
const appended = await helpers.appendAccountRunRecord('step8_failed', null, '步骤 8:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。');
const appended = await helpers.appendAccountRunRecord('node:fetch-login-code:failed', null, '步骤 8:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。');
assert.equal(appended.email, 'latest@example.com');
assert.equal(appended.finalStatus, 'failed');
assert.equal(appended.flowId, 'openai');
assert.equal(appended.runId, 'run-2');
assert.equal(appended.failedNodeId, 'fetch-login-code');
assert.equal(appended.failureLabel, '出现手机号验证');
assert.equal(storedHistory.length, 3, '旧的 stopped 记录应在新结构中保留');
assert.equal(storedHistory.some((item) => item.email === 'stop@example.com' && item.finalStatus === 'stopped'), true);
@@ -106,18 +120,21 @@ test('account run history helper upgrades old records, keeps stopped items and s
assert.equal(helpers.shouldAppendAccountRunTextFile({ accountRunHistoryTextEnabled: false, accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373' }), true);
assert.equal(helpers.shouldAppendAccountRunTextFile({ accountRunHistoryTextEnabled: true, accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373' }), true);
const stoppedRecord = helpers.buildAccountRunHistoryRecord(
{ email: 'a@b.com', password: 'x' },
'step7_stopped',
'步骤 7 已被用户停止'
{ flowId: 'openai', runId: 'manual-run', email: 'a@b.com', password: 'x' },
'node:oauth-login:stopped',
'节点 oauth-login 已被用户停止'
);
assert.equal(stoppedRecord.recordId, 'a@b.com');
assert.equal(stoppedRecord.flowId, 'openai');
assert.equal(stoppedRecord.runId, 'manual-run');
assert.equal(stoppedRecord.email, 'a@b.com');
assert.equal(stoppedRecord.password, 'x');
assert.equal(stoppedRecord.finalStatus, 'stopped');
assert.equal(stoppedRecord.retryCount, 0);
assert.equal(stoppedRecord.failureLabel, '步骤 7 停止');
assert.equal(stoppedRecord.failureDetail, '步骤 7 已被用户停止');
assert.equal(stoppedRecord.failedStep, 7);
assert.equal(stoppedRecord.failureLabel, '节点 刷新 OAuth 并登录 停止');
assert.equal(stoppedRecord.failureDetail, '节点 oauth-login 已被用户停止');
assert.equal(stoppedRecord.failedNodeId, 'oauth-login');
assert.equal(stoppedRecord.failedStep, null);
assert.equal(stoppedRecord.source, 'manual');
assert.equal(stoppedRecord.autoRunContext, null);
assert.ok(stoppedRecord.finishedAt);
@@ -149,11 +166,13 @@ test('account run history helper upgrades old records, keeps stopped items and s
retryCount: 0,
failureLabel: '流程已停止',
failureDetail: '步骤 7 已被用户停止。',
failedNodeId: 'oauth-login',
failedStep: 7,
source: 'manual',
autoRunContext: null,
});
assert.equal(normalizedStoppedRecord.failureLabel, '步骤 7 停止');
assert.equal(normalizedStoppedRecord.failureLabel, '节点 刷新 OAuth 并登录 停止');
assert.equal(normalizedStoppedRecord.failedNodeId, 'oauth-login');
assert.equal(normalizedStoppedRecord.failedStep, 7);
});
@@ -177,6 +196,8 @@ test('account run history helper accepts phone-only records without forcing emai
assert.deepStrictEqual(record, {
recordId: 'phone:+6612345',
flowId: '',
runId: '',
accountIdentifierType: 'phone',
accountIdentifier: '+6612345',
email: '',
@@ -187,6 +208,7 @@ test('account run history helper accepts phone-only records without forcing emai
retryCount: 0,
failureLabel: '流程完成',
failureDetail: '',
failedNodeId: '',
failedStep: null,
source: 'manual',
autoRunContext: null,
@@ -242,10 +264,11 @@ test('account run history does not turn prerequisite guidance into a fake step 2
autoRunCurrentRun: 1,
autoRunTotalRuns: 3,
autoRunAttemptRun: 2,
}, 'step10_failed', '缺少登录账号:请先完成步骤 2,或在侧栏填写账号后再执行当前步骤。');
}, 'node:platform-verify:failed', '缺少登录账号:请先完成步骤 2,或在侧栏填写账号后再执行当前步骤。');
assert.equal(explicitFailedRecord.failedStep, 10);
assert.equal(explicitFailedRecord.failureLabel, '步骤 10 失败');
assert.equal(explicitFailedRecord.failedNodeId, 'platform-verify');
assert.equal(explicitFailedRecord.failedStep, null);
assert.equal(explicitFailedRecord.failureLabel, '节点 platform-verify 失败');
const migratedOldRecord = helpers.normalizeAccountRunHistoryRecord({
email: 'old@example.com',
@@ -314,10 +337,12 @@ test('account run history merges email and phone identities from the same run',
activationId: 'a1',
phoneNumber: '+44 7799 342687',
},
}, 'step9_failed', '步骤 9:手机号验证失败。');
}, 'node:confirm-oauth:failed', '步骤 9:手机号验证失败。');
assert.equal(failedRecord.accountIdentifierType, 'email');
assert.equal(failedRecord.accountIdentifier, 'tmp@example.com');
assert.equal(failedRecord.phoneNumber, '+44 7799 342687');
assert.equal(failedRecord.failedNodeId, 'confirm-oauth');
assert.equal(failedRecord.failedStep, null);
const successRecord = await helpers.appendAccountRunRecord('success', {
accountIdentifierType: 'email',
+84 -16
View File
@@ -48,6 +48,71 @@ function extractFunction(name) {
return source.slice(start, end);
}
const NODE_EXECUTE_COMPAT_HELPERS = `
const AUTH_CHAIN_NODE_IDS = new Set(['oauth-login', 'fetch-login-code', 'confirm-oauth', 'platform-verify']);
const STEP_NODE_IDS = {
1: 'open-chatgpt',
2: 'submit-signup-email',
3: 'fill-password',
4: 'fetch-signup-code',
5: 'fill-profile',
6: 'wait-registration-success',
7: 'oauth-login',
8: 'fetch-login-code',
9: 'confirm-oauth',
10: 'platform-verify',
};
const NODE_STEP_IDS = Object.fromEntries(Object.entries(STEP_NODE_IDS).map(([step, nodeId]) => [nodeId, Number(step)]));
function isAuthChainNode(nodeId) {
return AUTH_CHAIN_NODE_IDS.has(String(nodeId || '').trim());
}
function getNodeIdByStepForState(step) {
const definition = typeof getStepDefinitionForState === 'function' ? getStepDefinitionForState(step) : null;
const definitionKey = String(definition?.key || '').trim();
return definitionKey && definitionKey !== 'test-step'
? definitionKey
: String(STEP_NODE_IDS[Number(step)] || '').trim();
}
function getStepIdByNodeIdForState(nodeId) {
const normalizedNodeId = String(nodeId || '').trim();
return NODE_STEP_IDS[normalizedNodeId] || null;
}
function getNodeDefinitionForState(nodeId, state = {}) {
const step = getStepIdByNodeIdForState(nodeId, state);
const stepDefinition = typeof getStepDefinitionForState === 'function'
? getStepDefinitionForState(step, state)
: null;
return stepDefinition
? { nodeId: String(nodeId || '').trim(), legacyStepId: step, executeKey: String(stepDefinition.key || nodeId || '').trim() }
: null;
}
async function setNodeStatus(nodeId, status) {
return setStepStatus(getStepIdByNodeIdForState(nodeId), status);
}
function doesNodeUseCompletionSignal(nodeId, state = {}) {
return typeof doesStepUseCompletionSignal === 'function'
? doesStepUseCompletionSignal(getStepIdByNodeIdForState(nodeId), state)
: false;
}
const rawGetStepRegistryForState = getStepRegistryForState;
getStepRegistryForState = function getNodeAdaptedStepRegistryForState(state = {}) {
const registry = rawGetStepRegistryForState(state);
if (!registry || typeof registry !== 'object' || typeof registry.getNodeDefinition === 'function') {
return registry;
}
return {
...registry,
getNodeDefinition(nodeId) {
return getNodeDefinitionForState(nodeId, state);
},
async executeNode(nodeId, payload = {}) {
const step = getStepIdByNodeIdForState(nodeId);
return registry.executeStep(step, payload);
},
};
};
`;
test('throwIfStopped rethrows an explicit stop error even when stopRequested has been cleared', () => {
const api = new Function(`
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
@@ -67,7 +132,7 @@ return {
);
});
test('executeStep reuses the active top-level auth chain instead of starting a duplicate step', async () => {
test('executeNode reuses the active top-level auth chain instead of starting a duplicate node', async () => {
const api = new Function(`
const LOG_PREFIX = '[test]';
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
@@ -137,14 +202,15 @@ function getStepDefinitionForState(step) {
return { id: step, key: 'test-step' };
}
${NODE_EXECUTE_COMPAT_HELPERS}
${extractFunction('isStopError')}
${extractFunction('throwIfStopped')}
${extractFunction('isAuthChainStep')}
${extractFunction('acquireTopLevelAuthChainExecution')}
${extractFunction('executeStep')}
${extractFunction('acquireTopLevelAuthChainExecutionForNode')}
${extractFunction('executeNode')}
return {
executeStep,
executeNode,
releaseStep8() {
if (releaseStep8) {
releaseStep8();
@@ -156,9 +222,9 @@ return {
};
`)();
const firstRun = api.executeStep(8);
const firstRun = api.executeNode('fetch-login-code');
await new Promise((resolve) => setImmediate(resolve));
const duplicateRun = api.executeStep(7);
const duplicateRun = api.executeNode('oauth-login');
await new Promise((resolve) => setImmediate(resolve));
api.releaseStep8();
@@ -173,7 +239,7 @@ return {
assert.ok(events.logs.some(({ message }) => /复用当前授权链/.test(message)));
});
test('executeStep stops flow when browser-switch-required error is raised', async () => {
test('executeNode stops flow when browser-switch-required error is raised', async () => {
const api = new Function(`
const LOG_PREFIX = '[test]';
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
@@ -235,17 +301,18 @@ function getStepDefinitionForState(step) {
return { id: step, key: 'test-step' };
}
${NODE_EXECUTE_COMPAT_HELPERS}
${extractFunction('isStopError')}
${extractFunction('throwIfStopped')}
${extractFunction('isAuthChainStep')}
${extractFunction('acquireTopLevelAuthChainExecution')}
${extractFunction('acquireTopLevelAuthChainExecutionForNode')}
${extractFunction('isBrowserSwitchRequiredError')}
${extractFunction('getBrowserSwitchRequiredMessage')}
${extractFunction('handleBrowserSwitchRequired')}
${extractFunction('executeStep')}
${extractFunction('executeNode')}
return {
executeStep,
executeNode,
snapshot() {
return events;
},
@@ -253,7 +320,7 @@ return {
`)();
await assert.rejects(
() => api.executeStep(10),
() => api.executeNode('platform-verify'),
/流程已被用户停止。/
);
@@ -393,7 +460,7 @@ return {
assert.match(events.logs[0].message, /授权后链总超时已关闭/);
});
test('executeStep retries fetch-network errors for step 4 with cooldown and bounded attempts', async () => {
test('executeNode retries fetch-network errors for fetch-signup-code with cooldown and bounded attempts', async () => {
const api = new Function(`
const LOG_PREFIX = '[test]';
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
@@ -456,27 +523,28 @@ function getStepDefinitionForState(step) {
return { id: step, key: 'fetch-signup-code' };
}
${NODE_EXECUTE_COMPAT_HELPERS}
${extractFunction('isStopError')}
${extractFunction('isRetryableContentScriptTransportError')}
${extractFunction('isStepFetchNetworkRetryableError')}
${extractFunction('getStepFetchNetworkRetryPolicy')}
${extractFunction('throwIfStopped')}
${extractFunction('isAuthChainStep')}
${extractFunction('acquireTopLevelAuthChainExecution')}
${extractFunction('acquireTopLevelAuthChainExecutionForNode')}
${extractFunction('isBrowserSwitchRequiredError')}
${extractFunction('getBrowserSwitchRequiredMessage')}
${extractFunction('handleBrowserSwitchRequired')}
${extractFunction('executeStep')}
${extractFunction('executeNode')}
return {
executeStep,
executeNode,
snapshot() {
return events;
},
};
`)();
await api.executeStep(4);
await api.executeNode('fetch-signup-code');
const events = api.snapshot();
assert.deepStrictEqual(events.registryCalls, [4, 4, 4]);
+9 -9
View File
@@ -16,30 +16,30 @@ test('auto-run controller module exposes a factory', () => {
assert.equal(typeof api?.createAutoRunController, 'function');
});
test('auto-run account record status preserves the real failed step instead of parsing guidance text', () => {
test('auto-run account record status preserves the real failed node instead of parsing guidance text', () => {
const source = fs.readFileSync('background/auto-run-controller.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundAutoRunController;`)(globalScope);
const controller = api.createAutoRunController({});
const state = {
currentStep: 11,
stepStatuses: {
2: 'completed',
10: 'completed',
11: 'failed',
currentNodeId: 'fetch-login-code',
nodeStatuses: {
'submit-signup-email': 'completed',
'oauth-login': 'completed',
'fetch-login-code': 'failed',
},
};
const error = new Error('缺少登录账号:请先完成步骤 2,或在侧栏填写账号后再执行当前步骤。');
assert.equal(
controller.resolveAutoRunAccountRecordStatus('failed', state, error),
'step11_failed'
'node:fetch-login-code:failed'
);
error.failedStep = 13;
error.failedNodeId = 'platform-verify';
assert.equal(
controller.resolveAutoRunAccountRecordStatus('failed', state, error),
'step13_failed'
'node:platform-verify:failed'
);
});
@@ -40,6 +40,7 @@ test('mail rule registry exposes canonical OpenAI verification poll payloads', (
{
flowId: 'openai',
ruleId: 'openai-signup-code',
nodeId: 'fetch-signup-code',
step: 4,
artifactType: 'code',
codePatterns: [
@@ -78,6 +79,7 @@ test('mail rule registry exposes canonical OpenAI verification poll payloads', (
{
flowId: 'openai',
ruleId: 'openai-login-code',
nodeId: 'fetch-login-code',
step: 8,
artifactType: 'code',
codePatterns: [
@@ -105,6 +107,14 @@ test('mail rule registry exposes canonical OpenAI verification poll payloads', (
intervalMs: 3000,
}
);
assert.equal(
registry.buildVerificationPollPayloadForNode('fetch-signup-code', {
activeFlowId: 'openai',
email: 'node@example.com',
}).nodeId,
'fetch-signup-code'
);
});
test('mail rule registry rejects unknown active flow ids instead of silently using OpenAI rules', () => {
@@ -30,10 +30,10 @@ test('message router appends success record on Plus final step instead of hard-c
deleteIcloudAlias: async () => {},
deleteUsedIcloudAliases: async () => {},
disableUsedLuckmailPurchases: async () => {},
doesStepUseCompletionSignal: () => false,
doesNodeUseCompletionSignal: () => false,
ensureManualInteractionAllowed: async () => ({}),
executeStep: async () => {},
executeStepViaCompletionSignal: async () => {},
executeNode: async () => {},
executeNodeViaCompletionSignal: async () => {},
exportSettingsBundle: async () => ({}),
fetchGeneratedEmail: async () => '',
finalizeStep3Completion: async () => {},
@@ -43,7 +43,9 @@ test('message router appends success record on Plus final step instead of hard-c
getCurrentLuckmailPurchase: () => null,
getPendingAutoRunTimerPlan: () => null,
getSourceLabel: () => '',
getState: async () => ({ plusModeEnabled: true, stepStatuses: { 13: 'pending' } }),
getState: async () => ({ plusModeEnabled: true, nodeStatuses: { 'platform-verify': 'pending' } }),
getNodeIdsForState: () => ['open-chatgpt', 'oauth-login', 'fetch-login-code', 'confirm-oauth', 'platform-verify'],
getStepIdByNodeIdForState: (nodeId) => ({ 'oauth-login': 10, 'fetch-login-code': 11, 'confirm-oauth': 12, 'platform-verify': 13 }[nodeId] || 0),
getLastStepIdForState: () => 13,
getStepDefinitionForState: (step) => ({ id: step, key: step === 10 ? 'oauth-login' : 'platform-verify' }),
getStepIdsForState: () => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13],
@@ -66,8 +68,8 @@ test('message router appends success record on Plus final step instead of hard-c
normalizeHotmailAccounts: (items) => items,
normalizeRunCount: (value) => value,
AUTO_RUN_TIMER_KIND_SCHEDULED_START: 'scheduled',
notifyStepComplete: () => {},
notifyStepError: () => {},
notifyNodeComplete: () => {},
notifyNodeError: () => {},
patchHotmailAccount: async () => {},
patchMail2925Account: async () => {},
registerTab: async () => {},
@@ -88,9 +90,9 @@ test('message router appends success record on Plus final step instead of hard-c
setLuckmailPurchaseUsedState: async () => {},
setPersistentSettings: async () => {},
setState: async () => {},
setStepStatus: async () => {},
setNodeStatus: async () => {},
skipAutoRunCountdown: async () => false,
skipStep: async () => {},
skipNode: async () => {},
startAutoRunLoop: async () => {},
syncHotmailAccounts: async () => {},
testHotmailAccountMailAccess: async () => {},
@@ -98,7 +100,7 @@ test('message router appends success record on Plus final step instead of hard-c
verifyHotmailAccount: async () => {},
});
await router.handleMessage({ type: 'STEP_COMPLETE', step: 13, payload: {} }, {});
await router.handleMessage({ type: 'NODE_COMPLETE', nodeId: 'platform-verify', payload: { nodeId: 'platform-verify' } }, {});
assert.equal(appendCalls.length, 1);
assert.equal(appendCalls[0][0], 'success');
@@ -10,6 +10,7 @@ function createRouter(overrides = {}) {
const events = {
logs: [],
stepStatuses: [],
nodeStatuses: [],
stateUpdates: [],
broadcasts: [],
balanceRefreshes: [],
@@ -26,10 +27,63 @@ function createRouter(overrides = {}) {
executedSteps: [],
accountRecords: [],
};
const nodeByStep = {
1: 'open-chatgpt',
2: 'submit-signup-email',
3: 'fill-password',
4: 'fetch-signup-code',
5: 'fill-profile',
6: 'wait-registration-success',
7: 'oauth-login',
8: 'fetch-login-code',
9: 'confirm-oauth',
10: 'platform-verify',
11: 'fetch-login-code',
12: 'confirm-oauth',
13: 'platform-verify',
};
const normalStepByNode = {
'open-chatgpt': 1,
'submit-signup-email': 2,
'fill-password': 3,
'fetch-signup-code': 4,
'fill-profile': 5,
'wait-registration-success': 6,
'oauth-login': 7,
'fetch-login-code': 8,
'confirm-oauth': 9,
'platform-verify': 10,
};
const plusStepByNode = {
'open-chatgpt': 1,
'submit-signup-email': 2,
'fill-password': 3,
'fetch-signup-code': 4,
'fill-profile': 5,
'oauth-login': 10,
'fetch-login-code': 11,
'confirm-oauth': 12,
'platform-verify': 13,
};
const getStepForNode = (nodeId) => {
const state = normalizeState(overrides.state || {});
return (state.plusModeEnabled ? plusStepByNode : normalStepByNode)[nodeId] || 0;
};
const normalizeState = (state = {}) => {
const next = { ...(state || {}) };
if (!next.nodeStatuses && next.stepStatuses) {
next.nodeStatuses = Object.fromEntries(
Object.entries(next.stepStatuses)
.map(([step, status]) => [nodeByStep[Number(step)], status])
.filter(([nodeId]) => Boolean(nodeId))
);
}
return next;
};
const router = api.createMessageRouter({
addLog: async (message, level, options = {}) => {
events.logs.push({ message, level, step: options.step, stepKey: options.stepKey });
events.logs.push({ message, level, step: options.step, stepKey: options.stepKey, nodeId: options.nodeId });
},
appendAccountRunRecord: overrides.appendAccountRunRecord || (async (status, state, reason) => {
events.accountRecords.push({ status, state, reason });
@@ -49,20 +103,20 @@ function createRouter(overrides = {}) {
clearStopRequest: () => {},
closeLocalhostCallbackTabs: async () => {},
closeTabsByUrlPrefix: async () => {},
completeStepFromBackground: async (step, payload) => {
events.notifyCompletions.push({ step, payload, via: 'completeStepFromBackground' });
completeNodeFromBackground: async (nodeId, payload) => {
events.notifyCompletions.push({ step: getStepForNode(nodeId), nodeId, payload, via: 'completeNodeFromBackground' });
},
deleteHotmailAccount: async () => {},
deleteHotmailAccounts: async () => {},
deleteIcloudAlias: async () => {},
deleteUsedIcloudAliases: async () => {},
disableUsedLuckmailPurchases: async () => {},
doesStepUseCompletionSignal: () => false,
doesNodeUseCompletionSignal: () => false,
ensureManualInteractionAllowed: async () => ({}),
executeStep: async (step) => {
events.executedSteps.push(step);
executeNode: async (nodeId) => {
events.executedSteps.push(getStepForNode(nodeId) || nodeId);
},
executeStepViaCompletionSignal: async () => {},
executeNodeViaCompletionSignal: async () => {},
exportSettingsBundle: async () => ({}),
fetchGeneratedEmail: async () => '',
finalizePhoneActivationAfterSuccessfulFlow: overrides.finalizePhoneActivationAfterSuccessfulFlow || (async (state) => {
@@ -77,7 +131,9 @@ function createRouter(overrides = {}) {
getCurrentLuckmailPurchase: () => null,
getPendingAutoRunTimerPlan: () => null,
getSourceLabel: () => '',
getState: async () => overrides.state || { stepStatuses: { 3: 'pending' } },
getState: async () => normalizeState(overrides.state || { nodeStatuses: { 'fill-password': 'pending' } }),
getNodeIdsForState: () => ['open-chatgpt', 'submit-signup-email', 'fill-password', 'fetch-signup-code', 'fill-profile', 'wait-registration-success', 'oauth-login', 'fetch-login-code', 'confirm-oauth', 'platform-verify'],
getStepIdByNodeIdForState: (nodeId, state = {}) => (state.plusModeEnabled ? plusStepByNode : normalStepByNode)[nodeId] || 0,
getStepDefinitionForState: overrides.getStepDefinitionForState,
getStepIdsForState: overrides.getStepIdsForState,
getLastStepIdForState: overrides.getLastStepIdForState,
@@ -106,11 +162,11 @@ function createRouter(overrides = {}) {
normalizeHotmailAccounts: (items) => items,
normalizeRunCount: (value) => value,
AUTO_RUN_TIMER_KIND_SCHEDULED_START: 'scheduled',
notifyStepComplete: (step, payload) => {
events.notifyCompletions.push({ step, payload });
notifyNodeComplete: (nodeId, payload) => {
events.notifyCompletions.push({ step: getStepForNode(nodeId), nodeId, payload });
},
notifyStepError: (step, error) => {
events.notifyErrors.push({ step, error });
notifyNodeError: (nodeId, error) => {
events.notifyErrors.push({ step: getStepForNode(nodeId), nodeId, error });
},
patchHotmailAccount: async () => {},
registerTab: async () => {},
@@ -142,11 +198,13 @@ function createRouter(overrides = {}) {
setState: async (updates) => {
events.stateUpdates.push(updates);
},
setStepStatus: async (step, status) => {
setNodeStatus: async (nodeId, status) => {
events.nodeStatuses.push({ nodeId, status });
const step = getStepForNode(nodeId);
events.stepStatuses.push({ step, status });
},
skipAutoRunCountdown: async () => false,
skipStep: async () => {},
skipNode: async () => {},
startAutoRunLoop: async () => {},
syncHotmailAccounts: async () => {},
testHotmailAccountMailAccess: async () => {},
@@ -366,10 +424,11 @@ test('message router finalizes step 3 before marking it completed', async () =>
const { router, events } = createRouter();
const response = await router.handleMessage({
type: 'STEP_COMPLETE',
step: 3,
type: 'NODE_COMPLETE',
nodeId: 'fill-password',
source: 'signup-page',
payload: {
nodeId: 'fill-password',
email: 'user@example.com',
signupVerificationRequestedAt: 123,
},
@@ -377,8 +436,10 @@ test('message router finalizes step 3 before marking it completed', async () =>
assert.deepStrictEqual(events.finalizePayloads, [
{
nodeId: 'fill-password',
email: 'user@example.com',
signupVerificationRequestedAt: 123,
step: 3,
},
]);
assert.deepStrictEqual(events.stepStatuses, [{ step: 3, status: 'completed' }]);
@@ -386,9 +447,12 @@ test('message router finalizes step 3 before marking it completed', async () =>
assert.deepStrictEqual(events.notifyCompletions, [
{
step: 3,
nodeId: 'fill-password',
payload: {
nodeId: 'fill-password',
email: 'user@example.com',
signupVerificationRequestedAt: 123,
step: 3,
},
},
]);
@@ -436,7 +500,8 @@ test('message router finalizes pending phone activation on platform verify succe
localhostUrl: 'http://localhost:1455/auth/callback?code=ok',
});
assert.deepStrictEqual(events.phoneFinalizations, [state]);
assert.equal(events.phoneFinalizations.length, 1);
assert.deepStrictEqual(events.phoneFinalizations[0].pendingPhoneActivationConfirmation, state.pendingPhoneActivationConfirmation);
});
test('message router does not finalize pending phone activation when icloud finalization fails', async () => {
@@ -481,10 +546,11 @@ test('message router marks step 3 failed when post-submit finalize fails', async
});
const response = await router.handleMessage({
type: 'STEP_COMPLETE',
step: 3,
type: 'NODE_COMPLETE',
nodeId: 'fill-password',
source: 'signup-page',
payload: {
nodeId: 'fill-password',
email: 'user@example.com',
},
}, {});
@@ -493,10 +559,11 @@ test('message router marks step 3 failed when post-submit finalize fails', async
assert.deepStrictEqual(events.notifyErrors, [
{
step: 3,
nodeId: 'fill-password',
error: '步骤 3 提交后仍停留在密码页。',
},
]);
assert.equal(events.logs.some(({ message, step }) => /失败步骤 3 提交后仍停留在密码页/.test(message) && step === 3), true);
assert.equal(events.logs.some(({ message, nodeId }) => /失败步骤 3 提交后仍停留在密码页/.test(message) && nodeId === 'fill-password'), true);
assert.deepStrictEqual(response, { ok: true, error: '步骤 3 提交后仍停留在密码页。' });
});
@@ -512,10 +579,10 @@ test('message router does not duplicate step 3 mismatch failure log after finali
});
const response = await router.handleMessage({
type: 'STEP_ERROR',
step: 3,
type: 'NODE_ERROR',
nodeId: 'fill-password',
source: 'signup-page',
payload: {},
payload: { nodeId: 'fill-password' },
error: mismatchError,
}, {});
@@ -524,6 +591,7 @@ test('message router does not duplicate step 3 mismatch failure log after finali
assert.deepStrictEqual(events.notifyErrors, [
{
step: 3,
nodeId: 'fill-password',
error: mismatchError,
},
]);
@@ -534,10 +602,10 @@ test('message router stops the flow and surfaces cloudflare security block error
const { router, events } = createRouter();
const response = await router.handleMessage({
type: 'STEP_ERROR',
step: 7,
type: 'NODE_ERROR',
nodeId: 'oauth-login',
source: 'signup-page',
payload: {},
payload: { nodeId: 'oauth-login' },
error: 'CF_SECURITY_BLOCKED::您已触发Cloudflare 安全防护系统',
}, {});
@@ -545,6 +613,7 @@ test('message router stops the flow and surfaces cloudflare security block error
assert.deepStrictEqual(events.notifyErrors, [
{
step: 7,
nodeId: 'oauth-login',
error: '流程已被用户停止。',
},
]);
@@ -562,9 +631,10 @@ test('message router blocks manual step 4 execution when signup page tab is miss
await assert.rejects(
() => router.handleMessage({
type: 'EXECUTE_STEP',
type: 'EXECUTE_NODE',
source: 'sidepanel',
payload: { step: 4 },
nodeId: 'fetch-signup-code',
payload: { nodeId: 'fetch-signup-code' },
}, {}),
/手动执行步骤 4 前,请先执行步骤 1 或步骤 2/
);
@@ -630,18 +700,19 @@ test('message router ignores stale step 2 errors while auto-run is already on a
state: {
autoRunning: true,
autoRunPhase: 'running',
currentStep: 6,
stepStatuses: {
2: 'completed',
6: 'running',
currentNodeId: 'wait-registration-success',
nodeStatuses: {
'submit-signup-email': 'completed',
'wait-registration-success': 'running',
},
},
isAutoRunLockedState: (state) => Boolean(state?.autoRunning) && state?.autoRunPhase === 'running',
});
const response = await router.handleMessage({
type: 'STEP_ERROR',
step: 2,
type: 'NODE_ERROR',
nodeId: 'submit-signup-email',
payload: { nodeId: 'submit-signup-email' },
error: '步骤 2:旧页面异步失败,不应覆盖当前第 6 步记录。',
}, {});
@@ -649,7 +720,7 @@ test('message router ignores stale step 2 errors while auto-run is already on a
assert.deepStrictEqual(events.stepStatuses, []);
assert.deepStrictEqual(events.notifyErrors, []);
assert.deepStrictEqual(events.accountRecords, []);
assert.equal(events.logs.some(({ message }) => /忽略过期的步骤 2 失败消息/.test(message)), true);
assert.equal(events.logs.some(({ message }) => /忽略过期的节点 submit-signup-email 失败消息/.test(message)), true);
});
test('message router ignores stale step 2 completion while auto-run is already on a later step', async () => {
@@ -657,19 +728,20 @@ test('message router ignores stale step 2 completion while auto-run is already o
state: {
autoRunning: true,
autoRunPhase: 'running',
currentStep: 6,
stepStatuses: {
2: 'completed',
6: 'running',
currentNodeId: 'wait-registration-success',
nodeStatuses: {
'submit-signup-email': 'completed',
'wait-registration-success': 'running',
},
},
isAutoRunLockedState: (state) => Boolean(state?.autoRunning) && state?.autoRunPhase === 'running',
});
const response = await router.handleMessage({
type: 'STEP_COMPLETE',
step: 2,
type: 'NODE_COMPLETE',
nodeId: 'submit-signup-email',
payload: {
nodeId: 'submit-signup-email',
email: 'late@example.com',
},
}, {});
@@ -678,5 +750,5 @@ test('message router ignores stale step 2 completion while auto-run is already o
assert.deepStrictEqual(events.stepStatuses, []);
assert.deepStrictEqual(events.notifyCompletions, []);
assert.deepStrictEqual(events.emailStates, []);
assert.equal(events.logs.some(({ message }) => /忽略过期的步骤 2 完成消息/.test(message)), true);
assert.equal(events.logs.some(({ message }) => /忽略过期的节点 submit-signup-email 完成消息/.test(message)), true);
});
@@ -1,4 +1,4 @@
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const test = require('node:test');
@@ -35,7 +35,7 @@ test('platform verify module supports codex2api protocol callback exchange', asy
},
chrome: {},
closeConflictingTabsForSource: async () => {},
completeStepFromBackground: async (step, payload) => {
completeNodeFromBackground: async (step, payload) => {
completed.push({ step, payload });
},
ensureContentScriptReadyOnTab: async () => {},
@@ -68,7 +68,7 @@ test('platform verify module supports codex2api protocol callback exchange', asy
]);
assert.deepStrictEqual(completed, [
{
step: 10,
step: 'platform-verify',
payload: {
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
verifiedStatus: 'OAuth 账号 flow@example.com 添加成功',
@@ -98,7 +98,7 @@ test('platform verify retries transient SUB2API oauth/token exchange errors befo
},
},
closeConflictingTabsForSource: async () => {},
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
ensureContentScriptReadyOnTab: async () => {},
getPanelMode: () => 'sub2api',
getTabId: async () => 12,
@@ -166,7 +166,7 @@ test('platform verify retries transient SUB2API token_exchange_user_error before
},
},
closeConflictingTabsForSource: async () => {},
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
ensureContentScriptReadyOnTab: async () => {},
getPanelMode: () => 'sub2api',
getTabId: async () => 12,
@@ -1,4 +1,4 @@
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const test = require('node:test');
@@ -17,7 +17,7 @@ function createDeps(overrides = {}) {
},
},
closeConflictingTabsForSource: async () => {},
completeStepFromBackground: async (step, payload) => {
completeNodeFromBackground: async (step, payload) => {
completed.push({ step, payload });
},
ensureContentScriptReadyOnTab: async () => {},
@@ -97,7 +97,7 @@ test('platform verify module submits CPA callback via management API first', asy
assert.equal(uiCalled, false);
assert.deepStrictEqual(completed, [
{
step: 10,
step: 'platform-verify',
payload: {
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
verifiedStatus: 'CPA API 回调提交成功',
@@ -317,7 +317,7 @@ test('platform verify module submits Plus visible step 13 to SUB2API via direct
assert.equal(exchangeCall.body.state, 'oauth-state');
assert.deepStrictEqual(createCall.body.group_ids, [5]);
assert.deepStrictEqual(completed, [{
step: 13,
step: 'platform-verify',
payload: {
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
verifiedStatus: 'SUB2API 已创建账号 #11',
+29 -54
View File
@@ -22,29 +22,22 @@ test('runtime-state module exposes a factory', () => {
assert.equal(typeof api?.createRuntimeStateHelpers, 'function');
});
test('runtime-state view derives canonical flow metadata from legacy step state', () => {
test('runtime-state view preserves canonical flow metadata from node state', () => {
const api = loadRuntimeStateApi();
const helpers = api.createRuntimeStateHelpers({
DEFAULT_ACTIVE_FLOW_ID: 'openai',
defaultStepStatuses: {
1: 'pending',
2: 'pending',
10: 'pending',
},
getStepDefinitionForState(step) {
return {
1: { id: 1, key: 'open-chatgpt' },
2: { id: 2, key: 'submit-signup-email' },
10: { id: 10, key: 'oauth-login' },
}[Number(step)] || null;
defaultNodeStatuses: {
'open-chatgpt': 'pending',
'submit-signup-email': 'pending',
'oauth-login': 'pending',
},
});
const view = helpers.buildStateView({
currentStep: 2,
stepStatuses: {
1: 'completed',
2: 'running',
currentNodeId: 'submit-signup-email',
nodeStatuses: {
'open-chatgpt': 'completed',
'submit-signup-email': 'running',
},
oauthUrl: 'https://auth.example.com/start',
plusCheckoutTabId: 88,
@@ -63,14 +56,9 @@ test('runtime-state view derives canonical flow metadata from legacy step state'
assert.equal(view.activeFlowId, 'openai');
assert.equal(view.currentNodeId, 'submit-signup-email');
assert.deepStrictEqual(view.legacyStepCompat, {
currentStep: 2,
stepStatuses: {
1: 'completed',
2: 'running',
10: 'pending',
},
});
assert.equal(Object.prototype.hasOwnProperty.call(view, 'legacyStepCompat'), false);
assert.equal(Object.prototype.hasOwnProperty.call(view, 'currentStep'), false);
assert.equal(Object.prototype.hasOwnProperty.call(view, 'stepStatuses'), false);
assert.deepStrictEqual(view.nodeStatuses, {
'open-chatgpt': 'completed',
'submit-signup-email': 'running',
@@ -93,35 +81,33 @@ test('runtime-state view derives canonical flow metadata from legacy step state'
});
});
test('runtime-state patch accepts nested flow updates while keeping legacy compatibility fields in sync', () => {
test('runtime-state patch accepts nested flow and node updates without legacy step state', () => {
const api = loadRuntimeStateApi();
const helpers = api.createRuntimeStateHelpers({
DEFAULT_ACTIVE_FLOW_ID: 'openai',
defaultStepStatuses: {
1: 'pending',
2: 'pending',
10: 'pending',
},
getStepDefinitionForState(step) {
return {
1: { id: 1, key: 'open-chatgpt' },
2: { id: 2, key: 'submit-signup-email' },
10: { id: 10, key: 'oauth-login' },
}[Number(step)] || null;
defaultNodeStatuses: {
'open-chatgpt': 'pending',
'submit-signup-email': 'pending',
'oauth-login': 'pending',
},
});
const patch = helpers.buildSessionStatePatch({
currentStep: 1,
stepStatuses: {
1: 'running',
2: 'pending',
10: 'pending',
currentNodeId: 'open-chatgpt',
nodeStatuses: {
'open-chatgpt': 'running',
'submit-signup-email': 'pending',
'oauth-login': 'pending',
},
oauthUrl: 'https://old.example.com/start',
}, {
runtimeState: {
activeRunId: 'run-001',
currentNodeId: 'oauth-login',
nodeStatuses: {
'open-chatgpt': 'completed',
'oauth-login': 'running',
},
flowState: {
openai: {
auth: {
@@ -132,13 +118,6 @@ test('runtime-state patch accepts nested flow updates while keeping legacy compa
},
},
},
legacyStepCompat: {
currentStep: 10,
stepStatuses: {
1: 'completed',
10: 'running',
},
},
},
});
@@ -147,12 +126,8 @@ test('runtime-state patch accepts nested flow updates while keeping legacy compa
assert.equal(patch.currentNodeId, 'oauth-login');
assert.equal(patch.oauthUrl, 'https://new.example.com/start');
assert.equal(patch.plusCheckoutTabId, 99);
assert.equal(patch.currentStep, 10);
assert.deepStrictEqual(patch.stepStatuses, {
1: 'completed',
2: 'pending',
10: 'running',
});
assert.equal(Object.prototype.hasOwnProperty.call(patch, 'currentStep'), false);
assert.equal(Object.prototype.hasOwnProperty.call(patch, 'stepStatuses'), false);
assert.deepStrictEqual(patch.nodeStatuses, {
'open-chatgpt': 'completed',
'submit-signup-email': 'pending',
+15 -15
View File
@@ -1,4 +1,4 @@
const test = require('node:test');
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
@@ -20,7 +20,7 @@ test('step 2 completes with password step skipped when landing on email verifica
const executor = step2Api.createStep2Executor({
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
completeStepFromBackground: async (step, payload) => {
completeNodeFromBackground: async (step, payload) => {
completedPayloads.push({ step, payload });
},
ensureContentScriptReadyOnTab: async () => {},
@@ -40,7 +40,7 @@ test('step 2 completes with password step skipped when landing on email verifica
assert.deepStrictEqual(completedPayloads, [
{
step: 2,
step: 'submit-signup-email',
payload: {
email: 'user@example.com',
accountIdentifierType: 'email',
@@ -59,7 +59,7 @@ test('step 2 keeps password flow when landing on password page', async () => {
const executor = step2Api.createStep2Executor({
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
completeStepFromBackground: async (step, payload) => {
completeNodeFromBackground: async (step, payload) => {
completedPayloads.push({ step, payload });
},
ensureContentScriptReadyOnTab: async () => {},
@@ -79,7 +79,7 @@ test('step 2 keeps password flow when landing on password page', async () => {
assert.deepStrictEqual(completedPayloads, [
{
step: 2,
step: 'submit-signup-email',
payload: {
email: 'user@example.com',
accountIdentifierType: 'email',
@@ -110,7 +110,7 @@ test('step 2 uses phone activation when resolved signup method is phone', async
const executor = step2Api.createStep2Executor({
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
completeStepFromBackground: async (step, payload) => {
completeNodeFromBackground: async (step, payload) => {
completedPayloads.push({ step, payload });
},
ensureContentScriptReadyOnTab: async () => {},
@@ -170,7 +170,7 @@ test('step 2 uses phone activation when resolved signup method is phone', async
]);
assert.deepStrictEqual(completedPayloads, [
{
step: 2,
step: 'submit-signup-email',
payload: {
accountIdentifierType: 'phone',
accountIdentifier: '66959916439',
@@ -200,7 +200,7 @@ test('step 2 reuses existing signup phone activation without acquiring a new num
const executor = step2Api.createStep2Executor({
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
completeStepFromBackground: async (step, payload) => {
completeNodeFromBackground: async (step, payload) => {
completedPayloads.push({ step, payload });
},
ensureContentScriptReadyOnTab: async () => {},
@@ -263,7 +263,7 @@ test('step 2 submits manual signup phone without acquiring a number', async () =
const executor = step2Api.createStep2Executor({
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
completeStepFromBackground: async (step, payload) => {
completeNodeFromBackground: async (step, payload) => {
completedPayloads.push({ step, payload });
},
ensureContentScriptReadyOnTab: async () => {},
@@ -310,7 +310,7 @@ test('step 2 submits manual signup phone without acquiring a number', async () =
]);
assert.deepStrictEqual(completedPayloads, [
{
step: 2,
step: 'submit-signup-email',
payload: {
accountIdentifierType: 'phone',
accountIdentifier: '+446700000002',
@@ -338,7 +338,7 @@ test('step 2 stops with an explicit error instead of silently skipping 3/4/5 on
get: async () => ({ url: 'https://chatgpt.com/' }),
},
},
completeStepFromBackground: async (step, payload) => {
completeNodeFromBackground: async (step, payload) => {
completedPayloads.push({ step, payload });
},
ensureContentScriptReadyOnTab: async () => {},
@@ -382,7 +382,7 @@ test('step 2 does not force auth-entry retry on logged-out chatgpt home when con
get: async () => ({ url: 'https://chatgpt.com/' }),
},
},
completeStepFromBackground: async (step, payload) => {
completeNodeFromBackground: async (step, payload) => {
completedPayloads.push({ step, payload });
},
ensureContentScriptReadyOnTab: async () => {},
@@ -414,7 +414,7 @@ test('step 2 does not force auth-entry retry on logged-out chatgpt home when con
assert.deepStrictEqual(sentPayloads, [{ email: 'user@example.com' }]);
assert.deepStrictEqual(completedPayloads, [
{
step: 2,
step: 'submit-signup-email',
payload: {
email: 'user@example.com',
accountIdentifierType: 'email',
@@ -450,7 +450,7 @@ test('step 2 waits for the existing signup tab to settle before probing the entr
},
},
},
completeStepFromBackground: async (step, payload) => {
completeNodeFromBackground: async (step, payload) => {
completedPayloads.push({ step, payload });
},
ensureContentScriptReadyOnTab: async () => {
@@ -499,7 +499,7 @@ test('step 2 waits for the existing signup tab to settle before probing the entr
assert.equal(logs.some((item) => item.meta.step === 2 && item.meta.stepKey === 'signup-entry'), true);
assert.deepStrictEqual(completedPayloads, [
{
step: 2,
step: 'submit-signup-email',
payload: {
email: 'user@example.com',
accountIdentifierType: 'email',
+77 -70
View File
@@ -4,6 +4,19 @@ const fs = require('node:fs');
const source = fs.readFileSync('background.js', 'utf8');
const OPENAI_NODE_IDS = [
'open-chatgpt',
'submit-signup-email',
'fill-password',
'fetch-signup-code',
'fill-profile',
'wait-registration-success',
'oauth-login',
'fetch-login-code',
'confirm-oauth',
'platform-verify',
];
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
const start = markers
@@ -48,113 +61,107 @@ function extractFunction(name) {
return source.slice(start, end);
}
test('skipStep cascades from step 1 to step 5 when downstream steps are pending', async () => {
function createApi() {
const bundle = [
extractFunction('isStepDoneStatus'),
extractFunction('skipStep'),
extractFunction('skipNode'),
].join('\n');
const statuses = {
1: 'pending',
2: 'pending',
3: 'pending',
4: 'pending',
5: 'pending',
6: 'pending',
7: 'pending',
8: 'pending',
9: 'pending',
10: 'pending',
};
return new Function(`
const DEFAULT_STATE = { nodeStatuses: {} };
function getNodeIdsForState() {
return ${JSON.stringify(OPENAI_NODE_IDS)};
}
function normalizeStatusMapForNodes(statuses = {}) {
return { ...statuses };
}
${bundle}
return { skipNode };
`)();
}
test('skipNode cascades from open-chatgpt through signup profile when downstream nodes are pending', async () => {
const statuses = Object.fromEntries(OPENAI_NODE_IDS.map((nodeId) => [nodeId, 'pending']));
const events = {
setStepStatusCalls: [],
setNodeStatusCalls: [],
logs: [],
};
const api = new Function(`
const STEP_IDS = [1,2,3,4,5,6,7,8,9,10];
${bundle}
return { skipStep };
`)();
const api = createApi();
globalThis.ensureManualInteractionAllowed = async () => ({
stepStatuses: { ...statuses },
nodeStatuses: { ...statuses },
});
globalThis.getState = async () => ({
stepStatuses: { ...statuses },
nodeStatuses: { ...statuses },
});
globalThis.setStepStatus = async (step, status) => {
events.setStepStatusCalls.push({ step, status });
statuses[step] = status;
globalThis.setNodeStatus = async (nodeId, status) => {
events.setNodeStatusCalls.push({ nodeId, status });
statuses[nodeId] = status;
};
globalThis.addLog = async (message, level) => {
events.logs.push({ message, level });
};
const result = await api.skipStep(1);
const result = await api.skipNode('open-chatgpt');
assert.deepStrictEqual(result, { ok: true, step: 1, status: 'skipped' });
assert.deepStrictEqual(events.setStepStatusCalls, [
{ step: 1, status: 'skipped' },
{ step: 2, status: 'skipped' },
{ step: 3, status: 'skipped' },
{ step: 4, status: 'skipped' },
{ step: 5, status: 'skipped' },
assert.deepStrictEqual(result, { ok: true, nodeId: 'open-chatgpt', status: 'skipped' });
assert.deepStrictEqual(events.setNodeStatusCalls, [
{ nodeId: 'open-chatgpt', status: 'skipped' },
{ nodeId: 'submit-signup-email', status: 'skipped' },
{ nodeId: 'fill-password', status: 'skipped' },
{ nodeId: 'fetch-signup-code', status: 'skipped' },
{ nodeId: 'fill-profile', status: 'skipped' },
]);
assert.equal(events.logs[0]?.message, '步骤 1 已跳过');
assert.equal(events.logs[1]?.message, '步骤 1 已跳过,步骤 2、3、4、5 也已同时跳过。');
assert.equal(events.logs[0]?.message, '节点 open-chatgpt 已跳过');
assert.equal(
events.logs[1]?.message,
'节点 open-chatgpt 已跳过,节点 submit-signup-email、fill-password、fetch-signup-code、fill-profile 也已同时跳过。'
);
});
test('skipStep from step 1 skips only unfinished steps up to step 5', async () => {
const bundle = [
extractFunction('isStepDoneStatus'),
extractFunction('skipStep'),
].join('\n');
test('skipNode from open-chatgpt skips only unfinished linked signup nodes', async () => {
const statuses = {
1: 'pending',
2: 'completed',
3: 'running',
4: 'pending',
5: 'manual_completed',
6: 'pending',
7: 'pending',
8: 'pending',
9: 'pending',
10: 'pending',
'open-chatgpt': 'pending',
'submit-signup-email': 'completed',
'fill-password': 'running',
'fetch-signup-code': 'pending',
'fill-profile': 'manual_completed',
'wait-registration-success': 'pending',
'oauth-login': 'pending',
'fetch-login-code': 'pending',
'confirm-oauth': 'pending',
'platform-verify': 'pending',
};
const events = {
setStepStatusCalls: [],
setNodeStatusCalls: [],
logs: [],
};
const api = new Function(`
const STEP_IDS = [1,2,3,4,5,6,7,8,9,10];
${bundle}
return { skipStep };
`)();
const api = createApi();
globalThis.ensureManualInteractionAllowed = async () => ({
stepStatuses: { ...statuses },
nodeStatuses: { ...statuses },
});
globalThis.getState = async () => ({
stepStatuses: { ...statuses },
nodeStatuses: { ...statuses },
});
globalThis.setStepStatus = async (step, status) => {
events.setStepStatusCalls.push({ step, status });
statuses[step] = status;
globalThis.setNodeStatus = async (nodeId, status) => {
events.setNodeStatusCalls.push({ nodeId, status });
statuses[nodeId] = status;
};
globalThis.addLog = async (message, level) => {
events.logs.push({ message, level });
};
await api.skipStep(1);
await api.skipNode('open-chatgpt');
assert.deepStrictEqual(events.setStepStatusCalls, [
{ step: 1, status: 'skipped' },
{ step: 4, status: 'skipped' },
assert.deepStrictEqual(events.setNodeStatusCalls, [
{ nodeId: 'open-chatgpt', status: 'skipped' },
{ nodeId: 'fetch-signup-code', status: 'skipped' },
]);
assert.equal(events.logs.some(({ message }) => message === '步骤 1 已跳过,步骤 4 也已同时跳过。'), true);
assert.equal(
events.logs.some(({ message }) => (
message === '节点 open-chatgpt 已跳过,节点 fetch-signup-code 也已同时跳过。'
)),
true
);
});
+27 -28
View File
@@ -51,57 +51,56 @@ function extractFunction(name) {
return source.slice(start, end);
}
function createApi(events, lastStepId = 10) {
return new Function('events', 'lastStepId', `
function createApi(events, lastNodeId = 'platform-verify') {
return new Function('events', 'lastNodeId', `
let stopRequested = false;
const LOG_PREFIX = '[test]';
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
const LAST_STEP_ID = 10;
function getErrorMessage(error) {
return error?.message || String(error || '');
}
async function getState() {
events.push({ type: 'getState' });
return { stepStatuses: {}, contributionMode: true };
return { nodeStatuses: {}, contributionMode: true };
}
function getLastStepIdForState() {
return lastStepId;
function getLastNodeIdForState() {
return lastNodeId;
}
async function setStepStatus(step, status) {
events.push({ type: 'status', step, status });
async function setNodeStatus(nodeId, status) {
events.push({ type: 'status', nodeId, status });
}
async function addLog(message, level) {
events.push({ type: 'log', message, level });
async function addLog(message, level, options = {}) {
events.push({ type: 'log', message, level, options });
}
async function appendManualAccountRunRecordIfNeeded() {
events.push({ type: 'manual-record' });
}
function notifyStepError(step, error) {
events.push({ type: 'error', step, error });
function notifyNodeError(nodeId, error) {
events.push({ type: 'error', nodeId, error });
}
function notifyStepComplete(step, payload) {
events.push({ type: 'notify', step, payload });
function notifyNodeComplete(nodeId, payload) {
events.push({ type: 'notify', nodeId, payload });
}
async function handleStepData(step, payload) {
events.push({ type: 'handle-start', step, payload });
async function handleNodeData(nodeId, payload) {
events.push({ type: 'handle-start', nodeId, payload });
await new Promise((resolve) => setTimeout(resolve, 25));
events.push({ type: 'handle-done', step });
events.push({ type: 'handle-done', nodeId });
}
async function appendAndBroadcastAccountRunRecord(status, state) {
events.push({ type: 'record', status, state });
}
${extractFunction('runCompletedStepSideEffects')}
${extractFunction('reportCompletedStepSideEffectError')}
${extractFunction('completeStepFromBackground')}
return { completeStepFromBackground };
`)(events, lastStepId);
${extractFunction('runCompletedNodeSideEffects')}
${extractFunction('reportCompletedNodeSideEffectError')}
${extractFunction('completeNodeFromBackground')}
return { completeNodeFromBackground };
`)(events, lastNodeId);
}
test('completeStepFromBackground releases final step before slow post-completion side effects', async () => {
test('completeNodeFromBackground releases final node before slow post-completion side effects', async () => {
const events = [];
const api = createApi(events, 10);
const api = createApi(events, 'platform-verify');
await api.completeStepFromBackground(10, { localhostUrl: 'http://localhost:1455/auth/callback?code=ok' });
await api.completeNodeFromBackground('platform-verify', { localhostUrl: 'http://localhost:1455/auth/callback?code=ok' });
const types = events.map((event) => event.type);
assert.equal(types.indexOf('notify') < types.indexOf('handle-start'), true);
@@ -115,11 +114,11 @@ test('completeStepFromBackground releases final step before slow post-completion
assert.equal(settledTypes.includes('record'), true);
});
test('completeStepFromBackground keeps non-final step data handling before completion signal', async () => {
test('completeNodeFromBackground keeps non-final node data handling before completion signal', async () => {
const events = [];
const api = createApi(events, 10);
const api = createApi(events, 'platform-verify');
await api.completeStepFromBackground(9, { localhostUrl: 'http://localhost:1455/auth/callback?code=ok' });
await api.completeNodeFromBackground('confirm-oauth', { localhostUrl: 'http://localhost:1455/auth/callback?code=ok' });
const types = events.map((event) => event.type);
assert.equal(types.indexOf('handle-done') < types.indexOf('notify'), true);
+5 -3
View File
@@ -2,18 +2,20 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
test('background imports step registry and shared step definitions', () => {
test('background imports node registry and shared workflow definitions', () => {
const source = fs.readFileSync('background.js', 'utf8');
assert.match(source, /background\/steps\/registry\.js/);
assert.match(source, /data\/step-definitions\.js/);
assert.match(source, /MultiPageStepDefinitions\?\.getSteps/);
assert.match(source, /background\/workflow-engine\.js/);
assert.match(source, /MultiPageStepDefinitions\?\.getNodes/);
assert.match(source, /getStepRegistryForState\(state\)/);
assert.match(source, /buildNodeRegistry\(definitions/);
assert.match(source, /PLUS_PAYPAL_STEP_DEFINITIONS/);
assert.match(source, /PLUS_GOPAY_STEP_DEFINITIONS/);
assert.match(source, /plusPayPalStepRegistry/);
assert.match(source, /plusGoPayStepRegistry/);
assert.match(source, /normalizePlusPaymentMethod\(state\?\.plusPaymentMethod\) === PLUS_PAYMENT_METHOD_GOPAY/);
assert.match(source, /activeStepRegistry\.executeStep\(step,\s*\{/);
assert.match(source, /activeStepRegistry\.executeNode\(normalizedNodeId,\s*\{/);
assert.match(source, /background\/steps\/create-plus-checkout\.js/);
assert.match(source, /background\/steps\/fill-plus-checkout\.js/);
assert.match(source, /background\/steps\/gopay-manual-confirm\.js/);
@@ -39,7 +39,8 @@ test('step 3 reuses existing generated password when rerunning the same email fl
assert.deepStrictEqual(events.passwordStates, ['Secret123!']);
assert.deepStrictEqual(events.messages, [
{
type: 'EXECUTE_STEP',
type: 'EXECUTE_NODE',
nodeId: 'fill-password',
step: 3,
source: 'background',
payload: {
@@ -109,7 +110,8 @@ test('step 3 supports phone-only signup identity when password page is present',
]);
assert.deepStrictEqual(events.messages, [
{
type: 'EXECUTE_STEP',
type: 'EXECUTE_NODE',
nodeId: 'fill-password',
step: 3,
source: 'background',
payload: {
+10 -10
View File
@@ -1,4 +1,4 @@
const test = require('node:test');
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
@@ -23,7 +23,7 @@ test('step 4 passes a fixed 10-minute lookback window to 2925 mailbox polling',
},
},
},
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypass: async () => {},
ensureMail2925MailboxSession: async () => {
ensureCalls += 1;
@@ -84,7 +84,7 @@ test('step 4 does not request a fresh code first for Cloudflare temp mail', asyn
update: async () => {},
},
},
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypass: async () => {},
ensureMail2925MailboxSession: async () => {},
getMailConfig: () => ({
@@ -135,7 +135,7 @@ test('step 4 checks iCloud session before polling iCloud mailbox', async () => {
update: async () => {},
},
},
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypass: async () => {},
ensureIcloudMailSession: async () => {
icloudChecks += 1;
@@ -183,7 +183,7 @@ test('step 4 forwards skipProfileStep when prepare stage already reached logged-
update: async () => {},
},
},
completeStepFromBackground: async (step, payload) => {
completeNodeFromBackground: async (step, payload) => {
completions.push({ step, payload });
},
confirmCustomVerificationStepBypass: async () => {},
@@ -224,7 +224,7 @@ test('step 4 forwards skipProfileStep when prepare stage already reached logged-
assert.deepStrictEqual(completions, [
{
step: 4,
step: 'fetch-signup-code',
payload: { skipProfileStep: true },
},
]);
@@ -244,7 +244,7 @@ test('step 4 phone signup branch uses SMS helper and does not poll mailbox', asy
update: async () => {},
},
},
completeStepFromBackground: async (step, payload) => {
completeNodeFromBackground: async (step, payload) => {
completions.push({ step, payload });
},
confirmCustomVerificationStepBypass: async () => {},
@@ -296,7 +296,7 @@ test('step 4 phone signup branch uses SMS helper and does not poll mailbox', asy
assert.equal(Object.prototype.hasOwnProperty.call(phoneCalls[0].options, 'signupProfile'), true);
assert.deepStrictEqual(completions, [
{
step: 4,
step: 'fetch-signup-code',
payload: {
phoneVerification: true,
code: '',
@@ -321,7 +321,7 @@ test('step 4 phone signup email-verification handoff polls mailbox instead of co
update: async () => {},
},
},
completeStepFromBackground: async (step, payload) => {
completeNodeFromBackground: async (step, payload) => {
completions.push({ step, payload });
},
confirmCustomVerificationStepBypass: async () => {},
@@ -403,7 +403,7 @@ test('step 4 prepare retries transport by recovering retry page without replayin
update: async () => {},
},
},
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypass: async () => {},
ensureMail2925MailboxSession: async () => {},
getMailConfig: () => ({
@@ -30,7 +30,8 @@ test('step 5 forwards generated profile data and relies on completion signal flo
{
source: 'signup-page',
message: {
type: 'EXECUTE_STEP',
type: 'EXECUTE_NODE',
nodeId: 'fill-profile',
step: 5,
source: 'background',
payload: {
+23 -23
View File
@@ -1,4 +1,4 @@
const test = require('node:test');
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
@@ -17,7 +17,7 @@ test('step 6 waits for registration success and completes from background', asyn
addLog: async (message, level = 'info') => {
events.logs.push({ message, level });
},
completeStepFromBackground: async (step) => {
completeNodeFromBackground: async (step) => {
events.completedSteps.push(step);
},
sleepWithStop: async (ms) => {
@@ -28,7 +28,7 @@ test('step 6 waits for registration success and completes from background', asyn
await executor.executeStep6();
assert.deepStrictEqual(events.waits, [20000]);
assert.deepStrictEqual(events.completedSteps, [6]);
assert.deepStrictEqual(events.completedSteps, ['wait-registration-success']);
assert.ok(events.logs.some(({ message }) => /等待 20 /.test(message)));
});
@@ -64,7 +64,7 @@ test('step 6 only clears cookies when cleanup switch is enabled', async () => {
const executor = api.createStep6Executor({
addLog: async () => {},
chrome: chromeApi,
completeStepFromBackground: async (step) => {
completeNodeFromBackground: async (step) => {
events.completedSteps.push(step);
},
sleepWithStop: async () => {},
@@ -77,7 +77,7 @@ test('step 6 only clears cookies when cleanup switch is enabled', async () => {
await executor.executeStep6({ step6CookieCleanupEnabled: true });
assert.deepStrictEqual(events.completedSteps, [6, 6]);
assert.deepStrictEqual(events.completedSteps, ['wait-registration-success', 'wait-registration-success']);
assert.deepStrictEqual(events.removedCookies, [
{
url: 'https://chatgpt.com/auth',
@@ -102,7 +102,7 @@ test('step 7 retries up to configured limit and then fails', async () => {
const executor = api.createStep7Executor({
addLog: async () => {},
completeStepFromBackground: async () => {
completeNodeFromBackground: async () => {
events.completed += 1;
},
getErrorMessage: (error) => error?.message || String(error || ''),
@@ -153,7 +153,7 @@ test('step 7 exits internal retry loop immediately when add-phone is detected',
addLog: async (message, level = 'info') => {
events.logs.push({ message, level });
},
completeStepFromBackground: async () => {
completeNodeFromBackground: async () => {
events.completed += 1;
},
getErrorMessage: (error) => error?.message || String(error || ''),
@@ -201,7 +201,7 @@ test('step 7 hands direct add-phone to shared phone verification when enabled',
const executor = api.createStep7Executor({
addLog: async () => {},
completeStepFromBackground: async (step, payload) => {
completeNodeFromBackground: async (step, payload) => {
events.completions.push({ step, payload });
},
getErrorMessage: (error) => error?.message || String(error || ''),
@@ -256,7 +256,7 @@ test('step 7 hands direct add-phone to shared phone verification when enabled',
]);
assert.deepStrictEqual(events.completions, [
{
step: 7,
step: 'oauth-login',
payload: {
loginVerificationRequestedAt: null,
skipLoginVerificationStep: true,
@@ -280,7 +280,7 @@ test('step 7 direct add-phone stays fatal when phone verification is disabled',
const executor = api.createStep7Executor({
addLog: async () => {},
completeStepFromBackground: async () => {
completeNodeFromBackground: async () => {
events.completions += 1;
},
getErrorMessage: (error) => error?.message || String(error || ''),
@@ -324,7 +324,7 @@ test('step 7 propagates fatal errors from shared add-phone verification', async
const executor = api.createStep7Executor({
addLog: async () => {},
completeStepFromBackground: async () => {
completeNodeFromBackground: async () => {
events.completions += 1;
},
getErrorMessage: (error) => error?.message || String(error || ''),
@@ -368,7 +368,7 @@ test('step 7 starts a new oauth timeout window for each refreshed oauth url', as
const executor = api.createStep7Executor({
addLog: async () => {},
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
getErrorMessage: (error) => error?.message || String(error || ''),
getLoginAuthStateLabel: (state) => state || 'unknown',
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs, options) => {
@@ -419,7 +419,7 @@ test('step 7 forwards direct OAuth consent skip metadata when completing', async
const executor = api.createStep7Executor({
addLog: async () => {},
completeStepFromBackground: async (step, payload) => {
completeNodeFromBackground: async (step, payload) => {
events.completions.push({ step, payload });
},
getErrorMessage: (error) => error?.message || String(error || ''),
@@ -447,7 +447,7 @@ test('step 7 forwards direct OAuth consent skip metadata when completing', async
assert.deepStrictEqual(events.completions, [
{
step: 10,
step: 'oauth-login',
payload: {
loginVerificationRequestedAt: null,
skipLoginVerificationStep: true,
@@ -469,7 +469,7 @@ test('step 7 forwards phone login identity payload when account identifier is ph
const executor = api.createStep7Executor({
addLog: async () => {},
completeStepFromBackground: async (step, payload) => {
completeNodeFromBackground: async (step, payload) => {
events.completions.push({ step, payload });
},
getErrorMessage: (error) => error?.message || String(error || ''),
@@ -529,7 +529,7 @@ test('step 7 forwards phone login identity payload when account identifier is ph
]);
assert.deepStrictEqual(events.completions, [
{
step: 7,
step: 'oauth-login',
payload: {
loginVerificationRequestedAt: 123456,
accountIdentifierType: 'phone',
@@ -558,7 +558,7 @@ test('step 7 keeps Plus email login even when phone sms runtime exists', async (
const executor = api.createStep7Executor({
addLog: async () => {},
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
getErrorMessage: (error) => error?.message || String(error || ''),
getLoginAuthStateLabel: (state) => state || 'unknown',
getState: async () => ({
@@ -629,7 +629,7 @@ test('step 7 keeps phone login after step 8 stores an unbound email for phone si
const executor = api.createStep7Executor({
addLog: async () => {},
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
getErrorMessage: (error) => error?.message || String(error || ''),
getLoginAuthStateLabel: (state) => state || 'unknown',
getState: async () => ({ ...phoneSignupState }),
@@ -668,7 +668,7 @@ test('step 7 can infer phone login from an available phone signup configuration
const executor = api.createStep7Executor({
addLog: async () => {},
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
getErrorMessage: (error) => error?.message || String(error || ''),
getLoginAuthStateLabel: (state) => state || 'unknown',
getState: async () => ({
@@ -715,7 +715,7 @@ test('step 7 can start from a manually filled signup phone without completed ste
const executor = api.createStep7Executor({
addLog: async () => {},
completeStepFromBackground: async (step, payload) => {
completeNodeFromBackground: async (step, payload) => {
events.completions.push({ step, payload });
},
getErrorMessage: (error) => error?.message || String(error || ''),
@@ -755,7 +755,7 @@ test('step 7 can start from a manually filled signup phone without completed ste
assert.equal(events.payloads[0].password, '');
assert.deepStrictEqual(events.completions, [
{
step: 7,
step: 'oauth-login',
payload: {
loginVerificationRequestedAt: 987654,
accountIdentifierType: 'phone',
@@ -783,7 +783,7 @@ test('step 7 stops immediately when management secret is missing', async () => {
addLog: async (message, level = 'info') => {
events.logs.push({ message, level });
},
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
getErrorMessage: (error) => error?.message || String(error || ''),
getLoginAuthStateLabel: (state) => state || 'unknown',
getState: async () => ({ email: 'user@example.com', password: 'secret' }),
@@ -827,7 +827,7 @@ test('step 7 stops immediately when management secret is invalid', async () => {
addLog: async (message, level = 'info') => {
events.logs.push({ message, level });
},
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
getErrorMessage: (error) => error?.message || String(error || ''),
getLoginAuthStateLabel: (state) => state || 'unknown',
getState: async () => ({ email: 'user@example.com', password: 'secret' }),
+10 -10
View File
@@ -1,4 +1,4 @@
const test = require('node:test');
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
@@ -112,7 +112,7 @@ test('step 8 keeps phone-registered accounts on email-code flow when page is ema
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (step, payload) => {
completeNodeFromBackground: async (step, payload) => {
calls.completions.push({ step, payload });
},
confirmCustomVerificationStepBypass: async () => {},
@@ -190,7 +190,7 @@ test('step 8 routes only a real phone verification page through sms helper', asy
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (step, payload) => {
completeNodeFromBackground: async (step, payload) => {
calls.completions.push({ step, payload });
},
confirmCustomVerificationStepBypass: async () => {},
@@ -250,7 +250,7 @@ test('step 8 routes only a real phone verification page through sms helper', asy
]);
assert.deepStrictEqual(calls.completions, [
{
step: 8,
step: 'fetch-login-code',
payload: {
phoneVerification: true,
loginPhoneVerification: true,
@@ -770,7 +770,7 @@ test('step 8 completes directly when auth page is already on OAuth consent page'
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (step, payload) => {
completeNodeFromBackground: async (step, payload) => {
events.completeCalls.push({ step, payload });
},
confirmCustomVerificationStepBypass: async () => {},
@@ -825,7 +825,7 @@ test('step 8 completes directly when auth page is already on OAuth consent page'
]);
assert.deepStrictEqual(events.completeCalls, [
{
step: 8,
step: 'fetch-login-code',
payload: {
loginVerificationRequestedAt: null,
skipLoginVerificationStep: true,
@@ -855,7 +855,7 @@ test('step 8 retries in-place when polling fails but auth page still stays on ve
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypass: async () => {},
ensureStep8VerificationPageReady: async () => {
events.ensureCalls += 1;
@@ -922,7 +922,7 @@ test('step 8 keeps resend cooldown timestamp across in-place retries to avoid re
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypass: async () => {},
ensureStep8VerificationPageReady: async () => ({ state: 'verification_page', displayedEmail: 'user@example.com' }),
rerunStep7ForStep8Recovery: async () => {},
@@ -997,7 +997,7 @@ test('step 8 completes when polling fails but recovery probe shows oauth consent
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (step, payload) => {
completeNodeFromBackground: async (step, payload) => {
events.completeCalls.push({ step, payload });
},
confirmCustomVerificationStepBypass: async () => {},
@@ -1046,7 +1046,7 @@ test('step 8 completes when polling fails but recovery probe shows oauth consent
assert.equal(events.rerunStep7, 0);
assert.deepStrictEqual(events.completeCalls, [
{
step: 8,
step: 'fetch-login-code',
payload: {
loginVerificationRequestedAt: null,
skipLoginVerificationStep: true,
+58 -5
View File
@@ -48,11 +48,59 @@ function extractFunction(name) {
return source.slice(start, end);
}
const NODE_COMPAT_HELPERS = `
const workflowEngine = null;
const STEP_NODE_IDS = {
1: 'open-chatgpt',
2: 'submit-signup-email',
3: 'fill-password',
4: 'fetch-signup-code',
5: 'fill-profile',
6: 'wait-registration-success',
7: 'oauth-login',
8: 'fetch-login-code',
9: 'confirm-oauth',
10: 'platform-verify',
};
const NODE_STEP_IDS = Object.fromEntries(Object.entries(STEP_NODE_IDS).map(([step, nodeId]) => [nodeId, Number(step)]));
function getNodeIdsForState() {
return Object.values(STEP_NODE_IDS);
}
function getNodeIdByStepForState(step) {
return STEP_NODE_IDS[Number(step)] || '';
}
function getStepIdByNodeIdForState(nodeId) {
return NODE_STEP_IDS[String(nodeId || '').trim()] || null;
}
function projectStepStatusesToNodeStatuses(stepStatuses = {}) {
const nodeStatuses = {};
for (const [step, status] of Object.entries(stepStatuses || {})) {
const nodeId = getNodeIdByStepForState(step);
if (nodeId) nodeStatuses[nodeId] = status;
}
return nodeStatuses;
}
function normalizeStatusMapForNodes(statuses = {}, state = {}) {
return {
...DEFAULT_STATE.nodeStatuses,
...projectStepStatusesToNodeStatuses(state?.stepStatuses || {}),
...(state?.nodeStatuses || {}),
...(statuses || {}),
};
}
DEFAULT_STATE.nodeStatuses = projectStepStatusesToNodeStatuses(DEFAULT_STATE.stepStatuses || {});
`;
test('generic stopped record resolves to next unfinished step during execution gap', async () => {
const bundle = [
NODE_COMPAT_HELPERS,
extractFunction('isStepDoneStatus'),
extractFunction('getRunningNodeIds'),
extractFunction('getRunningSteps'),
extractFunction('inferStoppedRecordNode'),
extractFunction('inferStoppedRecordStep'),
extractFunction('resolveAccountRunRecordStatusForStop'),
extractFunction('extractStoppedNodeFromRecordStatus'),
extractFunction('extractStoppedStepFromRecordStatus'),
extractFunction('resolveAccountRunRecordReasonForStop'),
extractFunction('appendAndBroadcastAccountRunRecord'),
@@ -103,10 +151,9 @@ return {
10: 'pending',
},
};
assert.equal(api.inferStoppedRecordStep(state), 7);
assert.equal(api.resolveAccountRunRecordStatusForStop('stopped', state), 'step7_stopped');
assert.equal(api.resolveAccountRunRecordReasonForStop('step7_stopped', '流程已被用户停止。'), '步骤 7 已被用户停止。');
assert.equal(api.resolveAccountRunRecordStatusForStop('stopped', state), 'node:oauth-login:stopped');
assert.equal(api.resolveAccountRunRecordReasonForStop('node:oauth-login:stopped', '流程已被用户停止。'), '节点 oauth-login 已被用户停止。');
assert.equal(
api.resolveAccountRunRecordReasonForStop('step2_stopped', '步骤 2 已使用邮箱,流程尚未完成。'),
'步骤 2 已停止:邮箱已设置,流程尚未完成。'
@@ -114,19 +161,23 @@ return {
await api.appendAndBroadcastAccountRunRecord('stopped', state, '流程已被用户停止。');
assert.deepStrictEqual(api.getCaptured(), {
status: 'step7_stopped',
status: 'node:oauth-login:stopped',
state,
reason: '步骤 7 已被用户停止。',
reason: '节点 oauth-login 已被用户停止。',
});
});
test('requestStop appends a stopped record for the next unfinished step when no step is running', async () => {
const bundle = [
NODE_COMPAT_HELPERS,
extractFunction('normalizeAutoRunSessionId'),
extractFunction('clearCurrentAutoRunSessionId'),
extractFunction('cleanupStep8NavigationListeners'),
extractFunction('rejectPendingStep8'),
extractFunction('isStepDoneStatus'),
extractFunction('getRunningNodeIds'),
extractFunction('getRunningSteps'),
extractFunction('inferStoppedRecordNode'),
extractFunction('inferStoppedRecordStep'),
extractFunction('requestStop'),
].join('\n');
@@ -148,6 +199,7 @@ const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start';
const DEFAULT_STATE = {
stepStatuses: Object.fromEntries([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((step) => [step, 'pending'])),
};
const nodeWaiters = new Map();
const stepWaiters = new Map();
const appended = [];
const logs = [];
@@ -176,6 +228,7 @@ async function addLog(message, level) {
}
async function broadcastStopToContentScripts() {}
async function markRunningStepsStopped() {}
async function markRunningNodesStopped() {}
async function broadcastAutoRunStatus() {}
async function appendAndBroadcastAccountRunRecord(status, state, reason) {
appended.push({ status, state, reason });
+4 -4
View File
@@ -121,13 +121,13 @@ test('GoPay approve waits and retries slowly on Hubungkan linking page', () => {
});
test('background auto-run routes GoPay restart sentinel back to step 6', () => {
test('background auto-run routes GoPay restart sentinel back to checkout-create node', () => {
const backgroundSource = fs.readFileSync('background.js', 'utf8');
assert.match(backgroundSource, /isGoPayCheckoutRestartRequiredFailure/);
assert.match(backgroundSource, /GOPAY_RESTART_FROM_STEP6::/);
assert.match(backgroundSource, /step === 8 && isGoPayCheckoutRestartRequiredFailure\(err\)/);
assert.match(backgroundSource, /step = 6/);
assert.match(backgroundSource, /invalidateDownstreamAfterStepRestart\(5/);
assert.match(backgroundSource, /nodeId === 'paypal-approve' && isGoPayCheckoutRestartRequiredFailure\(err\)/);
assert.match(backgroundSource, /getNodeIndex\(await getState\(\), 'plus-checkout-create'\)/);
assert.match(backgroundSource, /invalidateDownstreamAfterAutoRunNodeRestart\(getPreviousNodeId\('plus-checkout-create'/);
});
test('GoPay approve gives PIN precedence over OTP on ambiguous second PIN pages', () => {
+7 -7
View File
@@ -1,4 +1,4 @@
const test = require('node:test');
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
@@ -56,7 +56,7 @@ function createExecutor({
},
},
},
completeStepFromBackground: async (step, payload) => {
completeNodeFromBackground: async (step, payload) => {
events.completed.push({ step, payload });
},
ensureContentScriptReadyOnTabUntilStopped: async () => {},
@@ -259,7 +259,7 @@ test('PayPal approve keeps original combined email and password login path', asy
});
assert.equal(events.submittedPayloads.length, 1);
assert.deepEqual(events.completed.map((item) => item.step), [8]);
assert.deepEqual(events.completed.map((item) => item.step), ['paypal-approve']);
assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true);
});
@@ -404,7 +404,7 @@ test('PayPal approve discovers an already open unregistered PayPal tab', async (
assert.deepEqual(events.updatedTabs, [{ tabId: 7, updateInfo: { active: true } }]);
assert.equal(events.logs.some(({ message }) => /发现 PayPal 页面/.test(message)), true);
assert.deepEqual(events.completed.map((item) => item.step), [8]);
assert.deepEqual(events.completed.map((item) => item.step), ['paypal-approve']);
assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true);
});
@@ -440,7 +440,7 @@ test('PayPal approve discovers PayPal tabs through the locked automation window
assert.deepEqual(queries, [{}]);
assert.deepEqual(events.updatedTabs, [{ tabId: 9, updateInfo: { active: true } }]);
assert.deepEqual(events.completed.map((item) => item.step), [8]);
assert.deepEqual(events.completed.map((item) => item.step), ['paypal-approve']);
});
test('PayPal approve auto-detects split email then password pages', async () => {
@@ -464,7 +464,7 @@ test('PayPal approve auto-detects split email then password pages', async () =>
});
assert.equal(events.submittedPayloads.length, 2);
assert.deepEqual(events.completed.map((item) => item.step), [8]);
assert.deepEqual(events.completed.map((item) => item.step), ['paypal-approve']);
assert.equal(events.logs.some(({ message }) => /识别到密码页/.test(message)), true);
assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true);
});
@@ -490,6 +490,6 @@ test('PayPal approve finishes when login redirects away from PayPal', async () =
});
assert.equal(events.submittedPayloads.length, 1);
assert.deepEqual(events.completed.map((item) => item.step), [8]);
assert.deepEqual(events.completed.map((item) => item.step), ['paypal-approve']);
assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), false);
});
@@ -1,4 +1,4 @@
const test = require('node:test');
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
@@ -153,7 +153,7 @@ function createExecutorHarness({
getAllFrames: async () => frames,
},
},
completeStepFromBackground: async (step, payload) => events.completed.push({ step, payload }),
completeNodeFromBackground: async (step, payload) => events.completed.push({ step, payload }),
ensureContentScriptReadyOnTabUntilStopped: async (source, tabId) => events.ensuredTabs.push({ source, tabId }),
fetch: fetchImpl,
generateRandomName: () => ({ firstName: 'Ada', lastName: 'Lovelace' }),
@@ -237,7 +237,7 @@ test('Plus checkout billing uses the current checkout tab when step 6 did not re
assert.equal(events.messages.some((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL' && entry.frameId === 0), true);
assert.equal(events.messages.some((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS' && entry.frameId === 0), true);
assert.equal(events.messages.some((entry) => entry.message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE' && entry.frameId === 0), true);
assert.equal(events.completed[0].step, 7);
assert.equal(events.completed[0].step, 'plus-checkout-billing');
assert.equal(events.states.some((updates) => updates.plusCheckoutTabId === checkoutTab.id), true);
assert.equal(events.logs.some((entry) => /当前已在 Plus Checkout 页面/.test(entry.message)), true);
});
@@ -297,7 +297,7 @@ test('Plus checkout billing sends the billing command to the iframe that contain
assert.equal(fillMessage.frameId, 8);
assert.equal(subscribeMessage.frameId, 0);
assert.equal(events.logs.some((entry) => /checkout iframe/.test(entry.message)), true);
assert.equal(events.completed[0].step, 7);
assert.equal(events.completed[0].step, 'plus-checkout-billing');
});
test('Plus checkout billing uses proxy exit country for GoPay address when available', async () => {
@@ -597,7 +597,7 @@ test('Plus checkout billing selects GoPay and waits for a GoPay redirect', async
assert.equal(fillMessage.message.payload.addressSeed.countryCode, 'ID');
assert.equal(subscribeMessage.message.payload.paymentMethod, 'gopay');
assert.equal(checkoutTab.url, 'https://gopay.co.id/payment/session');
assert.equal(events.completed[0].step, 7);
assert.equal(events.completed[0].step, 'plus-checkout-billing');
});
test('Plus checkout billing still inspects a frame when ping readiness is stale', async () => {
@@ -625,7 +625,7 @@ test('Plus checkout billing still inspects a frame when ping readiness is stale'
const selectMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL');
assert.equal(selectMessage.frameId, 0);
assert.equal(events.completed[0].step, 7);
assert.equal(events.completed[0].step, 'plus-checkout-billing');
});
test('Plus checkout billing uses the autocomplete iframe for address suggestions when Stripe splits it out', async () => {
@@ -655,7 +655,7 @@ test('Plus checkout billing uses the autocomplete iframe for address suggestions
assert.equal(ensureAddressMessage.frameId, 8);
assert.equal(combinedFillMessage, undefined);
assert.equal(events.logs.some((entry) => /Google 地址推荐/.test(entry.message)), true);
assert.equal(events.completed[0].step, 7);
assert.equal(events.completed[0].step, 'plus-checkout-billing');
});
test('Plus checkout billing skips Google autocomplete when meiguodizhi returns a complete address', async () => {
@@ -713,7 +713,7 @@ test('Plus checkout billing skips Google autocomplete when meiguodizhi returns a
path: '/de-address',
method: 'refresh',
});
assert.equal(events.completed[0].step, 7);
assert.equal(events.completed[0].step, 'plus-checkout-billing');
});
test('Plus checkout billing uses the detected checkout country before choosing an address seed', async () => {
@@ -940,7 +940,7 @@ test('GPC billing polls queue task, submits WhatsApp OTP then PIN, and waits unt
assert.equal(events.states.some((state) => state.gopayHelperTaskId === 'task_123' && state.gopayHelperTaskStatus === 'completed'), true);
assert.equal(events.logs.some((entry) => entry.message === '步骤 7GPC 任务状态:等待 WhatsApp OTP'), true);
assert.equal(events.logs.some((entry) => /whatsapp_otp_wait/.test(entry.message)), false);
assert.equal(events.completed[0].step, 7);
assert.equal(events.completed[0].step, 'plus-checkout-billing');
assert.equal(events.completed[0].payload.plusCheckoutSource, 'gpc-helper');
assert.ok(events.sleeps.includes(3000));
});
@@ -995,7 +995,7 @@ test('GPC billing auto mode only polls until completed without OTP or PIN submis
assert.equal(events.logs.some((entry) => /auto_otp_wait/.test(entry.message)), false);
assert.equal(events.states.some((state) => state.plusManualConfirmationMethod === 'gopay-otp'), false);
assert.equal(events.states.some((state) => state.gopayHelperTaskId === 'task_auto' && state.gopayHelperTaskStatus === 'completed'), true);
assert.equal(events.completed[0].step, 7);
assert.equal(events.completed[0].step, 'plus-checkout-billing');
assert.equal(events.completed[0].payload.plusCheckoutSource, 'gpc-helper');
});
@@ -1266,7 +1266,7 @@ test('GPC billing reads SMS OTP from local helper for sms_otp_wait', async () =>
assert.deepEqual(JSON.parse(fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_sms/otp')).options.body), {
otp: '654321',
});
assert.equal(events.completed[0].step, 7);
assert.equal(events.completed[0].step, 'plus-checkout-billing');
});
test('GPC billing can read WhatsApp OTP from local helper when enabled', async () => {
@@ -1346,7 +1346,7 @@ test('GPC billing can read WhatsApp OTP from local helper when enabled', async (
assert.deepEqual(JSON.parse(fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_wa/otp')).options.body), {
otp: '765432',
});
assert.equal(events.completed[0].step, 7);
assert.equal(events.completed[0].step, 'plus-checkout-billing');
});
@@ -1481,7 +1481,7 @@ test('GPC billing helper mode requests newer OTP after invalid OTP error', async
assert.equal(helperUrls[1].searchParams.get('consume'), '1');
assert.ok(Number(helperUrls[1].searchParams.get('after_ms')) > 1710000000000);
assert.equal(events.logs.some((entry) => /OTP 校验失败/.test(entry.message)), true);
assert.equal(events.completed[0].step, 7);
assert.equal(events.completed[0].step, 'plus-checkout-billing');
});
test('GPC billing manual OTP wrong input opens next dialog only after previous one closes', async () => {
@@ -1558,7 +1558,7 @@ test('GPC billing manual OTP wrong input opens next dialog only after previous o
.filter((call) => call.url.endsWith('/api/gp/tasks/task_manual_retry/otp'))
.map((call) => JSON.parse(call.options.body));
assert.deepEqual(otpBodies, [{ otp: '111111' }, { otp: '222222' }]);
assert.equal(events.completed[0].step, 7);
assert.equal(events.completed[0].step, 'plus-checkout-billing');
});
test('GPC billing manual OTP cancel stops task and ends current round', async () => {
+15 -15
View File
@@ -1,4 +1,4 @@
const test = require('node:test');
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
@@ -204,7 +204,7 @@ test('Plus checkout create does not wait 20 seconds after opening checkout page'
},
},
},
completeStepFromBackground: async (step, payload) => {
completeNodeFromBackground: async (step, payload) => {
events.push({ type: 'complete', step, payload });
},
ensureContentScriptReadyOnTabUntilStopped: async () => {
@@ -267,7 +267,7 @@ test('GoPay plus checkout create forwards gopay payment method to the checkout c
update: async () => {},
},
},
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
ensureContentScriptReadyOnTabUntilStopped: async () => {},
registerTab: async () => {},
sendTabMessageUntilStopped: async (_tabId, _source, message) => {
@@ -370,7 +370,7 @@ test('GPC manual checkout injects Plus script before reading ChatGPT session tok
remove: async (tabId) => events.push({ type: 'tab-remove', tabId }),
},
},
completeStepFromBackground: async (step, payload) => events.push({ type: 'complete', step, payload }),
completeNodeFromBackground: async (step, payload) => events.push({ type: 'complete', step, payload }),
ensureContentScriptReadyOnTabUntilStopped: async (source, tabId, options) => events.push({ type: 'ready', source, tabId, options }),
fetch: async (url, options = {}) => {
fetchCalls.push({ url, options });
@@ -438,7 +438,7 @@ test('GPC manual checkout injects Plus script before reading ChatGPT session tok
assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperRemoteStage, 'checkout_start');
assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperReferenceId, '');
assert.ok(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperOrderCreatedAt > 0);
assert.equal(events.find((event) => event.type === 'complete')?.step, 6);
assert.equal(events.find((event) => event.type === 'complete')?.step, 'plus-checkout-create');
assert.equal(events.find((event) => event.type === 'complete')?.payload?.plusCheckoutSource, 'gpc-helper');
});
@@ -456,7 +456,7 @@ test('GPC auto checkout only sends access token and API Key', async () => {
remove: async () => {},
},
},
completeStepFromBackground: async (step, payload) => events.push({ type: 'complete', step, payload }),
completeNodeFromBackground: async (step, payload) => events.push({ type: 'complete', step, payload }),
ensureContentScriptReadyOnTabUntilStopped: async () => {},
fetch: async (url, options = {}) => {
fetchCalls.push({ url, options });
@@ -507,7 +507,7 @@ test('GPC auto checkout only sends access token and API Key', async () => {
assert.equal(statePayload.gopayHelperTaskId, 'task_auto');
assert.equal(Object.prototype.hasOwnProperty.call(statePayload, 'gopayHelperPhoneMode'), false);
assert.equal(statePayload.gopayHelperTaskStatus, 'queued');
assert.equal(events.find((event) => event.type === 'complete')?.step, 6);
assert.equal(events.find((event) => event.type === 'complete')?.step, 'plus-checkout-create');
});
test('GPC auto checkout keeps running when balance payload omits auto mode permission', async () => {
@@ -523,7 +523,7 @@ test('GPC auto checkout keeps running when balance payload omits auto mode permi
remove: async () => {},
},
},
completeStepFromBackground: async (step, payload) => events.push({ type: 'complete', step, payload }),
completeNodeFromBackground: async (step, payload) => events.push({ type: 'complete', step, payload }),
ensureContentScriptReadyOnTabUntilStopped: async () => {},
fetch: async (url, options = {}) => {
fetchCalls.push({ url, options });
@@ -564,7 +564,7 @@ test('GPC auto checkout keeps running when balance payload omits auto mode permi
const statePayload = events.find((event) => event.type === 'set-state')?.payload || {};
assert.equal(statePayload.gopayHelperTaskId, 'task_auto_unknown_permission');
assert.equal(Object.prototype.hasOwnProperty.call(statePayload, 'gopayHelperPhoneMode'), false);
assert.equal(events.find((event) => event.type === 'complete')?.step, 6);
assert.equal(events.find((event) => event.type === 'complete')?.step, 'plus-checkout-create');
});
test('GPC auto checkout blocks API Keys without auto mode permission', async () => {
@@ -579,7 +579,7 @@ test('GPC auto checkout blocks API Keys without auto mode permission', async ()
remove: async () => {},
},
},
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
ensureContentScriptReadyOnTabUntilStopped: async () => {},
fetch: async (url, options = {}) => {
fetchCalls.push({ url, options });
@@ -623,7 +623,7 @@ test('GPC checkout blocks exhausted API Keys before creating task', async () =>
remove: async () => {},
},
},
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
ensureContentScriptReadyOnTabUntilStopped: async () => {},
fetch: async (url, options = {}) => {
fetchCalls.push({ url, options });
@@ -668,7 +668,7 @@ test('GPC checkout forwards selected SMS OTP channel', async () => {
remove: async () => {},
},
},
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
ensureContentScriptReadyOnTabUntilStopped: async () => {},
fetch: async (url, options = {}) => {
fetchCalls.push({ url, options });
@@ -720,7 +720,7 @@ test('GPC checkout surfaces unified queue API errors', async () => {
remove: async () => {},
},
},
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
ensureContentScriptReadyOnTabUntilStopped: async () => {},
fetch: async (url, options = {}) => {
fetchCalls.push({ url, options });
@@ -780,7 +780,7 @@ test('GPC checkout does not fall back to browser GoPay phone fields', async () =
remove: async () => {},
},
},
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
ensureContentScriptReadyOnTabUntilStopped: async () => {},
fetch: async () => {
throw new Error('should not call helper API without helper phone');
@@ -821,7 +821,7 @@ test('GPC checkout rejects missing API Key before calling helper API', async ()
remove: async () => {},
},
},
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
ensureContentScriptReadyOnTabUntilStopped: async () => {},
fetch: async () => {
throw new Error('should not call helper API without API Key');
+3 -3
View File
@@ -1,4 +1,4 @@
const test = require('node:test');
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
@@ -12,7 +12,7 @@ test('Plus return confirm waits a fixed 20 seconds after return URL is detected'
addLog: async (message, level = 'info') => {
events.push({ type: 'log', message, level });
},
completeStepFromBackground: async (step, payload) => {
completeNodeFromBackground: async (step, payload) => {
events.push({ type: 'complete', step, payload });
},
getTabId: async (source) => (source === 'paypal-flow' ? 77 : null),
@@ -46,7 +46,7 @@ test('Plus return confirm waits a fixed 20 seconds after return URL is detected'
events.find((event) => event.type === 'complete'),
{
type: 'complete',
step: 9,
step: 'plus-checkout-return',
payload: { plusReturnUrl: 'https://chatgpt.com/backend-api/payments/success' },
}
);
+15 -1
View File
@@ -132,7 +132,10 @@ test('sidepanel settings refresh preserves rendered step progress', () => {
const bundle = [
extractFunction('isDoneStatus'),
extractFunction('getNodeStatuses'),
extractFunction('getStepStatuses'),
extractFunction('escapeCssValue'),
extractFunction('renderSingleNodeStatus'),
extractFunction('renderSingleStepStatus'),
extractFunction('renderStepStatuses'),
extractFunction('updateProgressCounter'),
@@ -148,13 +151,24 @@ const STATUS_ICONS = {
manual_completed: 'M',
skipped: 'K',
};
let latestState = { stepStatuses: { 1: 'completed', 2: 'running', 3: 'pending' } };
let latestState = { nodeStatuses: { 'open-chatgpt': 'completed', 'submit-signup-email': 'running', 'fill-password': 'pending' } };
let NODE_IDS = ['open-chatgpt', 'submit-signup-email', 'fill-password'];
let NODE_DEFAULT_STATUSES = { 'open-chatgpt': 'pending', 'submit-signup-email': 'pending', 'fill-password': 'pending' };
let STEP_IDS = [1, 2, 3];
let STEP_DEFAULT_STATUSES = { 1: 'pending', 2: 'pending', 3: 'pending' };
function getStepIdByNodeIdForCurrentMode(nodeId) {
return { 'open-chatgpt': 1, 'submit-signup-email': 2, 'fill-password': 3 }[nodeId] || 0;
}
const rows = new Map(STEP_IDS.map((step) => [step, { className: 'step-row' }]));
const statusEls = new Map(STEP_IDS.map((step) => [step, { textContent: '' }]));
const nodeRows = new Map(NODE_IDS.map((nodeId) => [nodeId, rows.get(getStepIdByNodeIdForCurrentMode(nodeId))]));
const nodeStatusEls = new Map(NODE_IDS.map((nodeId) => [nodeId, statusEls.get(getStepIdByNodeIdForCurrentMode(nodeId))]));
const document = {
querySelector(selector) {
const nodeMatch = selector.match(/data-node-id="([^"]+)"/);
if (nodeMatch) {
return selector.includes('step-status') ? nodeStatusEls.get(nodeMatch[1]) : nodeRows.get(nodeMatch[1]);
}
const match = selector.match(/data-step="(\\d+)"/);
const step = match ? Number(match[1]) : 0;
return selector.includes('step-status') ? statusEls.get(step) : rows.get(step);
@@ -200,7 +200,7 @@ test('sidepanel source wires runtime signup phone field to background sync messa
assert.match(sidepanelSource, /final \? 'SAVE_SIGNUP_PHONE' : 'SET_SIGNUP_PHONE_STATE'/);
assert.match(sidepanelSource, /message\.payload\.signupPhoneNumber !== undefined/);
assert.match(sidepanelSource, /await persistSignupPhoneInputForAction\(\);\s*await saveSettings/);
assert.match(sidepanelSource, /if \(shouldExecuteStep3WithSignupPhoneIdentity\(latestState\)\)[\s\S]*payload: \{ step \}/);
assert.match(sidepanelSource, /if \(shouldExecuteStep3WithSignupPhoneIdentity\(latestState\)\)[\s\S]*type:\s*'EXECUTE_NODE'[\s\S]*payload: \{ nodeId \}/);
assert.match(sidepanelSource, /async function handleSkipStep\(step\)[\s\S]*await persistCurrentSettingsForAction\(\);/);
assert.match(sidepanelSource, /inputSignupPhone\.addEventListener\('input'[\s\S]*signupPhoneInputDirty = true/);
});
+3
View File
@@ -53,4 +53,7 @@ test('shared source registry exposes canonical source, alias, detection, and rea
assert.equal(registry.shouldReportReadyForFrame('mail-163', true), false);
assert.equal(registry.shouldReportReadyForFrame('unknown-source', false), false);
assert.equal(registry.getCleanupOwnerSource('oauth-localhost-callback'), 'openai-auth');
assert.equal(registry.driverAcceptsCommand('openai-auth', 'submit-signup-email'), true);
assert.equal(registry.driverAcceptsCommand('content/platform-panel', 'platform-verify'), true);
assert.equal(registry.driverAcceptsCommand('openai-auth', 'platform-verify'), false);
});
+2 -2
View File
@@ -1,4 +1,4 @@
const test = require('node:test');
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
@@ -240,7 +240,7 @@ test('step 8 escalates to rerun step 7 after too many local retry_without_step7
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypass: async () => {},
ensureStep8VerificationPageReady: async () => {
calls.ensureReady += 1;
+12 -4
View File
@@ -1,4 +1,4 @@
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const helperSource = fs.readFileSync('background.js', 'utf8');
@@ -78,7 +78,7 @@ let autoRunAttemptRun = 4;
let autoRunSessionId = 99;
const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start';
const DEFAULT_STATE = {
stepStatuses: Object.fromEntries([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((step) => [step, 'pending'])),
nodeStatuses: {},
};
const STEP8_CLICK_RETRY_DELAY_MS = 500;
const STEP8_MAX_ROUNDS = 5;
@@ -134,6 +134,7 @@ const chrome = {
},
};
const nodeWaiters = new Map();
const stepWaiters = new Map();
let resumeWaiter = null;
@@ -141,6 +142,13 @@ function cancelPendingCommands() {}
function abortActiveIcloudRequests() {}
async function addLog() {}
async function broadcastStopToContentScripts() {}
function getRunningNodeIds() {
return [];
}
function inferStoppedRecordNode() {
return '';
}
async function markRunningNodesStopped() {}
async function markRunningStepsStopped() {}
async function broadcastAutoRunStatus() {}
async function appendAndBroadcastAccountRunRecord() {}
@@ -156,7 +164,7 @@ function isAutoRunScheduledState() {
function getStep8CallbackUrlFromNavigation() { return ''; }
function getStep8CallbackUrlFromTabUpdate() { return ''; }
function getStep8EffectLabel() { return 'no_effect'; }
async function completeStepFromBackground() {}
async function completeNodeFromBackground() {}
async function getTabId() {
return await new Promise((resolve) => {
resolveTabId = resolve;
@@ -218,7 +226,7 @@ const executor = self.MultiPageBackgroundStep9.createStep9Executor({
chrome,
cleanupStep8NavigationListeners,
clickWithDebugger,
completeStepFromBackground,
completeNodeFromBackground,
ensureStep8SignupPageReady,
getStep8CallbackUrlFromNavigation,
getStep8CallbackUrlFromTabUpdate,
+9 -9
View File
@@ -1,4 +1,4 @@
const test = require('node:test');
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
@@ -102,8 +102,8 @@ function getWebNavCommittedListener() { return webNavCommittedListener; }
function setStep8TabUpdatedListener(listener) { step8TabUpdatedListener = listener; }
function getStep8TabUpdatedListener() { return step8TabUpdatedListener; }
function setStep8PendingReject(handler) { step8PendingReject = handler; }
async function completeStepFromBackground(step, payload) {
completePayload = { step, payload };
async function completeNodeFromBackground(nodeId, payload) {
completePayload = { nodeId, payload };
}
const STEP8_CLICK_RETRY_DELAY_MS = 1;
@@ -120,7 +120,7 @@ const executor = self.MultiPageBackgroundStep9.createStep9Executor({
chrome,
cleanupStep8NavigationListeners,
clickWithDebugger,
completeStepFromBackground,
completeNodeFromBackground,
ensureStep8SignupPageReady,
getOAuthFlowRemainingMs,
getOAuthFlowStepTimeoutMs,
@@ -176,7 +176,7 @@ return {
assert.equal(snapshot.cleanupCalls >= 1, true);
assert.equal(snapshot.hasPendingReject, false);
assert.deepEqual(snapshot.completePayload, {
step: 9,
nodeId: 'confirm-oauth',
payload: {
localhostUrl: 'http://localhost:1455/auth/callback?code=abc&state=xyz',
},
@@ -279,8 +279,8 @@ function getWebNavCommittedListener() { return webNavCommittedListener; }
function setStep8TabUpdatedListener(listener) { step8TabUpdatedListener = listener; }
function getStep8TabUpdatedListener() { return step8TabUpdatedListener; }
function setStep8PendingReject() {}
async function completeStepFromBackground(step, payload) {
completePayload = { step, payload };
async function completeNodeFromBackground(nodeId, payload) {
completePayload = { nodeId, payload };
}
const STEP8_CLICK_RETRY_DELAY_MS = 1;
@@ -297,7 +297,7 @@ const executor = self.MultiPageBackgroundStep9.createStep9Executor({
chrome,
cleanupStep8NavigationListeners,
clickWithDebugger,
completeStepFromBackground,
completeNodeFromBackground,
ensureStep8SignupPageReady,
getOAuthFlowStepTimeoutMs,
getStep8CallbackUrlFromNavigation,
@@ -348,7 +348,7 @@ return {
assert.equal(snapshot.deferCalls >= 1, true);
assert.equal(snapshot.cleanupCalls >= 1, true);
assert.deepEqual(snapshot.completePayload, {
step: 12,
nodeId: 'confirm-oauth',
payload: {
localhostUrl: 'http://localhost:1455/auth/callback?code=abc&state=xyz',
},
+5 -5
View File
@@ -1,4 +1,4 @@
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const step9ModuleSource = fs.readFileSync('background/steps/confirm-oauth.js', 'utf8');
@@ -148,8 +148,8 @@ function setStep8TabUpdatedListener(listener) { step8TabUpdatedListener = listen
function getStep8TabUpdatedListener() { return step8TabUpdatedListener; }
function setStep8PendingReject(handler) { step8PendingReject = handler; }
async function completeStepFromBackground(step, payload) {
completePayload = { step, payload };
async function completeNodeFromBackground(nodeId, payload) {
completePayload = { nodeId, payload };
}
const STEP8_CLICK_RETRY_DELAY_MS = 200;
@@ -167,7 +167,7 @@ const executor = self.MultiPageBackgroundStep9.createStep9Executor({
chrome,
cleanupStep8NavigationListeners,
clickWithDebugger,
completeStepFromBackground,
completeNodeFromBackground,
ensureStep8SignupPageReady,
getOAuthFlowRemainingMs,
getOAuthFlowStepTimeoutMs,
@@ -226,7 +226,7 @@ return {
assert.strictEqual(snapshot.cleanupCalls >= 1, true, 'step9 should cleanup navigation listeners');
assert.strictEqual(snapshot.hasPendingReject, false, 'step9 should clear pending reject after completion');
assert.deepStrictEqual(snapshot.completePayload, {
step: 9,
nodeId: 'confirm-oauth',
payload: {
localhostUrl: 'http://localhost:1455/auth/callback?code=abc&state=xyz',
},
+6 -4
View File
@@ -51,8 +51,8 @@ function createSub2ApiPanelContext(fetchCalls = []) {
removeItem(key) { storage.delete(`session:${key}`); },
},
log() {},
reportComplete(step, payload) {
context.completed.push({ step, payload });
reportComplete(nodeId, payload) {
context.completed.push({ nodeId, payload });
},
reportReady() {},
reportError() {},
@@ -177,7 +177,8 @@ test('SUB2API step 10 uses the same proxy for code exchange and account creation
assert.equal(exchangeCall.body.proxy_id, 7);
assert.equal(createCall.body.proxy_id, 7);
assert.equal(createCall.body.group_ids[0], 5);
assert.equal(context.completed[0].step, 10);
assert.equal(context.completed[0].nodeId, 'platform-verify');
assert.equal(context.completed[0].payload.visibleStep, 10);
});
test('SUB2API panel accepts Plus platform verify step 13', async () => {
@@ -202,7 +203,8 @@ test('SUB2API panel accepts Plus platform verify step 13', async () => {
assert.equal(exchangeCall.body.code, 'callback-code');
assert.equal(createCall.body.group_ids[0], 5);
assert.equal(context.completed[0].step, 13);
assert.equal(context.completed[0].nodeId, 'platform-verify');
assert.equal(context.completed[0].payload.visibleStep, 13);
});
test('SUB2API step 1 omits proxy_id when default proxy is empty', async () => {
+88 -40
View File
@@ -1,13 +1,58 @@
const test = require('node:test');
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background/verification-flow.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundVerificationFlow;`)(globalScope);
const rawCreateVerificationFlowHelpers = api.createVerificationFlowHelpers.bind(api);
const TEST_STEP_NODE_IDS = Object.freeze({
4: 'fetch-signup-code',
7: 'oauth-login',
8: 'fetch-login-code',
11: 'fetch-login-code',
});
const TEST_NODE_STEP_IDS = Object.fromEntries(
Object.entries(TEST_STEP_NODE_IDS).map(([step, nodeId]) => [nodeId, Number(step)])
);
function getTestNodeIdByStepForState(step) {
return TEST_STEP_NODE_IDS[Number(step)] || '';
}
function getTestStepIdByNodeId(nodeId) {
return TEST_NODE_STEP_IDS[String(nodeId || '').trim()] || null;
}
function normalizeVerificationFlowTestOverrides(overrides = {}) {
const normalized = { ...overrides };
if (
typeof normalized.completeNodeFromBackground !== 'function'
&& typeof normalized.completeStepFromBackground === 'function'
) {
const completeStepFromBackground = normalized.completeStepFromBackground;
normalized.completeNodeFromBackground = async (nodeId, payload) => (
completeStepFromBackground(getTestStepIdByNodeId(nodeId), payload)
);
}
if (
typeof normalized.setNodeStatus !== 'function'
&& typeof normalized.setStepStatus === 'function'
) {
const setStepStatus = normalized.setStepStatus;
normalized.setNodeStatus = async (nodeId, status) => (
setStepStatus(getTestStepIdByNodeId(nodeId), status)
);
}
delete normalized.completeStepFromBackground;
delete normalized.setStepStatus;
return normalized;
}
function createVerificationFlowTestHelpers(overrides = {}) {
return api.createVerificationFlowHelpers({
return rawCreateVerificationFlowHelpers({
addLog: async () => {},
buildVerificationPollPayload: null,
chrome: {
@@ -18,8 +63,9 @@ function createVerificationFlowTestHelpers(overrides = {}) {
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
CLOUD_MAIL_PROVIDER: 'cloudmail',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getNodeIdByStepForState: getTestNodeIdByStepForState,
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
getState: async () => ({}),
@@ -36,14 +82,16 @@ function createVerificationFlowTestHelpers(overrides = {}) {
sendToContentScript: async () => ({}),
sendToMailContentScriptResilient: async () => ({}),
setState: async () => {},
setStepStatus: async () => {},
setNodeStatus: async () => {},
sleepWithStop: async () => {},
throwIfStopped: () => {},
VERIFICATION_POLL_MAX_ROUNDS: 5,
...overrides,
...normalizeVerificationFlowTestOverrides(overrides),
});
}
api.createVerificationFlowHelpers = createVerificationFlowTestHelpers;
test('verification flow prefers injected verification poll payload builder when provided', () => {
const helpers = createVerificationFlowTestHelpers({
buildVerificationPollPayload: (step, state, overrides = {}) => ({
@@ -85,7 +133,7 @@ test('verification flow keeps 2925 polling cadence in the default payload', () =
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
@@ -184,7 +232,7 @@ test('verification flow only enables 2925 target email matching in receive mode'
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
@@ -233,7 +281,7 @@ test('verification flow runs beforeSubmit hook before filling the code', async (
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (_step, payload) => {
completeNodeFromBackground: async (_step, payload) => {
events.push(['complete', payload.code]);
},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
@@ -299,7 +347,7 @@ test('verification flow skips 2925 mailbox preclear when using a fixed login mai
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
@@ -360,7 +408,7 @@ test('verification flow skips 2925 mailbox preclear when using a fixed signup ma
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
@@ -436,7 +484,7 @@ test('verification flow closes the tracked iCloud mail tab after a successful ve
throw new Error('should not use family cleanup when tracked tab exists');
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
@@ -498,7 +546,7 @@ test('verification flow completes step 8 and flags phone verification when add-p
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (_step, payload) => {
completeNodeFromBackground: async (_step, payload) => {
events.push(['complete', payload.code]);
},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
@@ -568,7 +616,7 @@ test('verification flow keeps step 8 successful when code submit transport fails
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (_step, payload) => {
completeNodeFromBackground: async (_step, payload) => {
events.push(['complete', payload.code]);
},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
@@ -642,7 +690,7 @@ test('verification flow treats manual step 8 add-phone confirmation as the same
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {
completeNodeFromBackground: async () => {
throw new Error('should not complete step 8');
},
confirmCustomVerificationStepBypassRequest: async () => ({
@@ -689,7 +737,7 @@ test('verification flow caps mail polling timeout to the remaining oauth budget'
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
@@ -753,7 +801,7 @@ test('verification flow keeps mail polling response timeout above minimum floor'
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
@@ -816,7 +864,7 @@ test('verification flow keeps 2925 mailbox polling at 15 refresh attempts even w
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
@@ -881,7 +929,7 @@ test('verification flow can run a 2/3/15 2925 resend polling plan', async () =>
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
@@ -953,7 +1001,7 @@ test('verification flow uses full 2925 polling window after a rejected login cod
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
@@ -1026,7 +1074,7 @@ test('verification flow keeps Hotmail request timestamp filtering on the first p
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 87654,
@@ -1081,7 +1129,7 @@ test('verification flow keeps fixed filter timestamp after step 4 resend', async
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: (_step, state) => Math.max(0, Number(state.signupVerificationRequestedAt || 0) - 15000),
@@ -1147,7 +1195,7 @@ test('verification flow uses configured signup resend count for step 4', async (
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
@@ -1206,7 +1254,7 @@ test('verification flow uses configured login resend count for step 8', async ()
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
@@ -1265,8 +1313,8 @@ test('verification flow can complete Plus visible login-code step with shared st
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (step, payload) => {
completed.push({ step, payload });
completeNodeFromBackground: async (nodeId, payload) => {
completed.push({ nodeId, payload });
},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
@@ -1310,7 +1358,7 @@ test('verification flow can complete Plus visible login-code step with shared st
assert.deepStrictEqual(fillMessages.map((message) => message.step), [8]);
assert.deepStrictEqual(completed, [
{
step: 11,
nodeId: 'fetch-login-code',
payload: {
emailTimestamp: 456,
code: '654321',
@@ -1328,7 +1376,7 @@ test('verification flow waits during resend cooldown instead of tight-looping',
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
@@ -1389,7 +1437,7 @@ test('verification flow clicks resend before waiting for the next LuckMail /code
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
@@ -1459,7 +1507,7 @@ test('verification flow notifies onResendRequestedAt when resend is triggered',
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
@@ -1532,7 +1580,7 @@ test('verification flow uses resilient signup-page transport when submitting ver
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
@@ -1582,7 +1630,7 @@ test('verification flow does not replay step 8 code submit after transient auth-
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
@@ -1647,7 +1695,7 @@ test('verification flow requests a new code immediately after Cloudflare Temp Em
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (_step, payload) => {
completeNodeFromBackground: async (_step, payload) => {
completed.push(payload);
},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
@@ -1726,7 +1774,7 @@ test('verification flow forwards optional signup profile payload when submitting
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
@@ -1787,8 +1835,8 @@ test('verification flow keeps combined signup profile skip reason when completin
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (step, payload) => {
completed.push({ step, payload });
completeNodeFromBackground: async (nodeId, payload) => {
completed.push({ nodeId, payload });
},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
@@ -1843,7 +1891,7 @@ test('verification flow keeps combined signup profile skip reason when completin
assert.equal(resilientCalls[0].payload.signupProfile.firstName, 'Ada');
assert.deepStrictEqual(completed, [
{
step: 4,
nodeId: 'fetch-signup-code',
payload: {
emailTimestamp: 123,
code: '654321',
@@ -1869,7 +1917,7 @@ test('verification flow treats retryable submit transport failure as success whe
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
@@ -1919,7 +1967,7 @@ test('verification flow avoids resend storms when iCloud polling keeps hitting t
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
@@ -1982,7 +2030,7 @@ test('verification flow stops iCloud poll-only loop after repeated no-code round
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
@@ -2043,7 +2091,7 @@ test('verification flow derives iCloud polling response timeout from the configu
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
+3 -3
View File
@@ -1,4 +1,4 @@
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const helperSource = fs.readFileSync('background.js', 'utf8');
@@ -97,7 +97,7 @@ const helpers = self.MultiPageBackgroundVerificationFlow.createVerificationFlowH
addLog,
chrome: {},
CLOUDFLARE_TEMP_EMAIL_PROVIDER,
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig,
getHotmailVerificationRequestTimestamp: () => 123,
@@ -164,7 +164,7 @@ const helpers = self.MultiPageBackgroundVerificationFlow.createVerificationFlowH
addLog,
chrome,
CLOUDFLARE_TEMP_EMAIL_PROVIDER,
completeStepFromBackground: async () => {},
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 123,