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
+839 -415
View File
File diff suppressed because it is too large Load Diff
+92 -9
View File
@@ -8,6 +8,8 @@
buildLocalHelperEndpoint, buildLocalHelperEndpoint,
chrome, chrome,
getErrorMessage, getErrorMessage,
getNodeIdByStepForState = null,
getNodeTitleForState = null,
getState, getState,
normalizeAccountRunHistoryHelperBaseUrl, normalizeAccountRunHistoryHelperBaseUrl,
} = deps; } = deps;
@@ -30,18 +32,27 @@
if (normalized === 'success') { if (normalized === 'success') {
return 'success'; return 'success';
} }
if (normalized === 'running' || /_running$/.test(normalized)) { if (normalized === 'running' || /_running$/.test(normalized) || /^node:[^:]+:running$/.test(normalized)) {
return 'running'; return 'running';
} }
if (normalized === 'failed' || /_failed$/.test(normalized)) { if (normalized === 'failed' || /_failed$/.test(normalized) || /^node:[^:]+:failed$/.test(normalized)) {
return 'failed'; return 'failed';
} }
if (normalized === 'stopped' || /_stopped$/.test(normalized)) { if (normalized === 'stopped' || /_stopped$/.test(normalized) || /^node:[^:]+:stopped$/.test(normalized)) {
return 'stopped'; return 'stopped';
} }
return ''; 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 = '') { function extractRecordStepFromStatus(status = '') {
const normalizedStatus = String(status || '').trim().toLowerCase(); const normalizedStatus = String(status || '').trim().toLowerCase();
const statusMatch = normalizedStatus.match(/^step(\d+)_(?:failed|stopped)$/); const statusMatch = normalizedStatus.match(/^step(\d+)_(?:failed|stopped)$/);
@@ -88,6 +99,11 @@
return Number.isInteger(step) && step > 0 ? step : null; 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 = '') { function shouldIgnorePersistedFailedStepCandidate(candidate, failureDetail = '') {
if (!Number.isInteger(candidate) || candidate <= 0) { if (!Number.isInteger(candidate) || candidate <= 0) {
return true; return true;
@@ -128,7 +144,22 @@
return null; 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(); const rawLabel = String(rawFailureLabel || '').trim();
if (finalStatus === 'stopped') { if (finalStatus === 'stopped') {
return computedFailureLabel; return computedFailureLabel;
@@ -137,6 +168,11 @@
return rawLabel || computedFailureLabel; return rawLabel || computedFailureLabel;
} }
const rawNodeId = parseFailureLabelNode(rawLabel);
if (rawNodeId) {
return rawNodeId === failedNodeId ? rawLabel : computedFailureLabel;
}
const rawStep = parseFailureLabelStep(rawLabel); const rawStep = parseFailureLabelStep(rawLabel);
if (Number.isInteger(rawStep) && rawStep > 0) { if (Number.isInteger(rawStep) && rawStep > 0) {
return rawStep === failedStep ? rawLabel : computedFailureLabel; return rawStep === failedStep ? rawLabel : computedFailureLabel;
@@ -156,7 +192,21 @@
|| /进入了手机号页面/.test(text); || /进入了手机号页面/.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') { if (finalStatus === 'success') {
return '流程完成'; return '流程完成';
} }
@@ -164,6 +214,9 @@
return '正在运行'; return '正在运行';
} }
if (finalStatus === 'stopped') { if (finalStatus === 'stopped') {
if (failedNodeId) {
return `节点 ${getNodeDisplayName(failedNodeId, state)} 停止`;
}
if (Number.isInteger(failedStep) && failedStep > 0) { if (Number.isInteger(failedStep) && failedStep > 0) {
return `步骤 ${failedStep} 停止`; return `步骤 ${failedStep} 停止`;
} }
@@ -175,6 +228,9 @@
if (isPhoneVerificationFailure(failureDetail)) { if (isPhoneVerificationFailure(failureDetail)) {
return '出现手机号验证'; return '出现手机号验证';
} }
if (failedNodeId) {
return `节点 ${getNodeDisplayName(failedNodeId, state)} 失败`;
}
if (Number.isInteger(failedStep) && failedStep > 0) { if (Number.isInteger(failedStep) && failedStep > 0) {
return `步骤 ${failedStep} 失败`; return `步骤 ${failedStep} 失败`;
} }
@@ -341,6 +397,9 @@
const failedStep = finalStatus === 'failed' || finalStatus === 'stopped' const failedStep = finalStatus === 'failed' || finalStatus === 'stopped'
? resolveNormalizedFailedStep(record, failureDetail) ? resolveNormalizedFailedStep(record, failureDetail)
: null; : null;
const failedNodeId = finalStatus === 'failed' || finalStatus === 'stopped'
? resolveNormalizedFailedNodeId(record, record.finalStatus || record.status || '', failedStep)
: '';
const autoRunContext = normalizeAutoRunContext(record.autoRunContext); const autoRunContext = normalizeAutoRunContext(record.autoRunContext);
const retryCount = normalizeRetryCount( const retryCount = normalizeRetryCount(
record.retryCount !== undefined record.retryCount !== undefined
@@ -348,11 +407,15 @@
: ((autoRunContext?.attemptRun || 0) > 1 ? autoRunContext.attemptRun - 1 : 0) : ((autoRunContext?.attemptRun || 0) > 1 ? autoRunContext.attemptRun - 1 : 0)
); );
const source = normalizeSource(record.source || (autoRunContext ? 'auto' : 'manual')); 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 rawFailureLabel = String(record.failureLabel || '').trim();
const flowId = String(record.flowId || record.activeFlowId || '').trim();
const runId = String(record.runId || record.activeRunId || '').trim();
return { return {
recordId: String(record.recordId || '').trim() || buildRecordId(accountIdentifier, accountIdentifierType), recordId: String(record.recordId || '').trim() || buildRecordId(accountIdentifier, accountIdentifierType),
flowId,
runId,
accountIdentifierType, accountIdentifierType,
accountIdentifier, accountIdentifier,
email, email,
@@ -361,8 +424,9 @@
finalStatus, finalStatus,
finishedAt, finishedAt,
retryCount, retryCount,
failureLabel: resolveFailureLabel(finalStatus, rawFailureLabel, computedFailureLabel, failedStep), failureLabel: resolveFailureLabel(finalStatus, rawFailureLabel, computedFailureLabel, failedNodeId, failedStep),
failureDetail, failureDetail,
failedNodeId,
failedStep: Number.isInteger(failedStep) && failedStep > 0 ? failedStep : null, failedStep: Number.isInteger(failedStep) && failedStep > 0 ? failedStep : null,
source, source,
autoRunContext: source === 'auto' ? autoRunContext : null, autoRunContext: source === 'auto' ? autoRunContext : null,
@@ -422,13 +486,31 @@
const failedStep = finalStatus === 'failed' || finalStatus === 'stopped' const failedStep = finalStatus === 'failed' || finalStatus === 'stopped'
? extractRecordStep(status, failureDetail) ? extractRecordStep(status, failureDetail)
: null; : 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 source = Boolean(state.autoRunning) ? 'auto' : 'manual';
const autoRunContext = source === 'auto' ? buildAutoRunContextFromState(state) : null; const autoRunContext = source === 'auto' ? buildAutoRunContextFromState(state) : null;
const retryCount = source === 'auto' ? getRetryCountFromState(state) : 0; const retryCount = source === 'auto' ? getRetryCountFromState(state) : 0;
const finishedAt = new Date().toISOString(); const finishedAt = new Date().toISOString();
const flowId = String(state.flowId || state.activeFlowId || '').trim();
const runId = String(state.runId || state.activeRunId || '').trim();
return { return {
recordId: buildRecordId(accountIdentifier, accountIdentifierType), recordId: buildRecordId(accountIdentifier, accountIdentifierType),
flowId,
runId,
accountIdentifierType, accountIdentifierType,
accountIdentifier, accountIdentifier,
email, email,
@@ -437,9 +519,10 @@
finalStatus, finalStatus,
finishedAt, finishedAt,
retryCount, retryCount,
failureLabel: buildFailureLabel(finalStatus, failedStep, failureDetail), failureLabel: buildFailureLabel(finalStatus, failedNodeId, failedStep, failureDetail, state),
failureDetail, failureDetail,
failedStep: Number.isInteger(failedStep) && failedStep > 0 ? failedStep : null, failedNodeId,
failedStep: statusNodeId ? null : (Number.isInteger(failedStep) && failedStep > 0 ? failedStep : null),
source, source,
autoRunContext, autoRunContext,
plusModeEnabled: Boolean(state.plusModeEnabled), plusModeEnabled: Boolean(state.plusModeEnabled),
+93 -64
View File
@@ -17,11 +17,11 @@
ensureHotmailMailboxReadyForAutoRunRound, ensureHotmailMailboxReadyForAutoRunRound,
getAutoRunStatusPayload, getAutoRunStatusPayload,
getErrorMessage, getErrorMessage,
getFirstUnfinishedStep, getFirstUnfinishedNodeId,
getPendingAutoRunTimerPlan, getPendingAutoRunTimerPlan,
getRunningSteps, getRunningNodeIds,
getState, getState,
hasSavedProgress, hasSavedNodeProgress,
isAddPhoneAuthFailure, isAddPhoneAuthFailure,
isGpcTaskEndedFailure, isGpcTaskEndedFailure,
isPhoneSmsPlatformRateLimitFailure, isPhoneSmsPlatformRateLimitFailure,
@@ -34,14 +34,49 @@
normalizeAutoRunFallbackThreadIntervalMinutes, normalizeAutoRunFallbackThreadIntervalMinutes,
persistAutoRunTimerPlan, persistAutoRunTimerPlan,
resetState, resetState,
runAutoSequenceFromStep, runAutoSequenceFromNode,
runtime, runtime,
setState, setState,
sleepWithStop, sleepWithStop,
throwIfAutoRunSessionStopped, throwIfAutoRunSessionStopped,
waitForRunningStepsToFinish, waitForRunningNodesToFinish,
} = deps; } = 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) { function createAutoRunRoundSummary(round) {
return { return {
round, round,
@@ -85,90 +120,83 @@
return Math.max(0, Number(summary?.attempts || 0) - 1); return Math.max(0, Number(summary?.attempts || 0) - 1);
} }
function normalizeRecordStep(value) { function normalizeRecordNode(value = '') {
const step = Math.floor(Number(value) || 0); return String(value || '').trim();
return step > 0 ? step : null;
} }
function extractStepFromRecordStatus(status = '') { function extractNodeFromRecordStatus(status = '') {
const match = String(status || '').trim().toLowerCase().match(/^step(\d+)_(?:failed|stopped)$/); const match = String(status || '').trim().match(/^node:([^:]+):(failed|stopped)$/i);
if (!match) { return match ? normalizeRecordNode(match[1]) : '';
return null;
}
return normalizeRecordStep(match[1]);
} }
function getKnownStepIdsFromState(state = {}) { function getKnownNodeIdsFromState(state = {}) {
const ids = new Set(); const ids = new Set();
for (const key of Object.keys(state?.stepStatuses || {})) { for (const key of Object.keys(state?.nodeStatuses || {})) {
const step = normalizeRecordStep(key); const nodeId = normalizeRecordNode(key);
if (step) { if (nodeId) {
ids.add(step); ids.add(nodeId);
} }
} }
const currentStep = normalizeRecordStep(state?.currentStep); const currentNodeId = normalizeRecordNode(state?.currentNodeId);
if (currentStep) { if (currentNodeId) {
ids.add(currentStep); ids.add(currentNodeId);
} }
return Array.from(ids).sort((left, right) => left - right); return Array.from(ids);
} }
function inferRecordStepFromState(state = {}, preferredStatuses = []) { function inferRecordNodeFromState(state = {}, preferredStatuses = []) {
const statuses = state?.stepStatuses || {}; const statuses = state?.nodeStatuses || {};
const preferredStatusSet = new Set(preferredStatuses.map((item) => String(item || '').trim()).filter(Boolean)); const preferredStatusSet = new Set(preferredStatuses.map((item) => String(item || '').trim()).filter(Boolean));
const stepIds = getKnownStepIdsFromState(state); const nodeIds = getKnownNodeIdsFromState(state);
const currentStep = normalizeRecordStep(state?.currentStep); const currentNodeId = normalizeRecordNode(state?.currentNodeId);
if (currentStep && preferredStatusSet.has(String(statuses[currentStep] || '').trim())) { if (currentNodeId && preferredStatusSet.has(String(statuses[currentNodeId] || '').trim())) {
return currentStep; return currentNodeId;
} }
const matchingSteps = stepIds const matchingNodes = nodeIds.filter((nodeId) => preferredStatusSet.has(String(statuses[nodeId] || '').trim()));
.filter((step) => preferredStatusSet.has(String(statuses[step] || '').trim())) if (matchingNodes.length) {
.sort((left, right) => right - left); return matchingNodes[matchingNodes.length - 1];
if (matchingSteps.length) {
return matchingSteps[0];
} }
if (currentStep) { if (currentNodeId) {
const currentStatus = String(statuses[currentStep] || '').trim(); const currentStatus = String(statuses[currentNodeId] || '').trim();
if (!['', 'pending', 'completed', 'manual_completed', 'skipped'].includes(currentStatus)) { 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') { if (!errorLike || typeof errorLike !== 'object') {
return null; return '';
} }
return normalizeRecordStep(errorLike.failedStep) return normalizeRecordNode(errorLike.failedNodeId)
|| normalizeRecordStep(errorLike.step) || normalizeRecordNode(errorLike.nodeId)
|| normalizeRecordStep(errorLike.currentStep); || normalizeRecordNode(errorLike.currentNodeId);
} }
function resolveAutoRunAccountRecordStatus(status, state = {}, errorLike = null) { function resolveAutoRunAccountRecordStatus(status, state = {}, errorLike = null) {
const normalizedStatus = String(status || '').trim().toLowerCase(); const normalizedStatus = String(status || '').trim().toLowerCase();
const explicitStep = extractStepFromRecordStatus(normalizedStatus); const explicitNode = extractNodeFromRecordStatus(status);
if (explicitStep) { if (explicitNode) {
return normalizedStatus; return `node:${explicitNode}:${normalizedStatus.endsWith(':stopped') ? 'stopped' : 'failed'}`;
} }
if (normalizedStatus === 'failed') { if (normalizedStatus === 'failed') {
const failedStep = inferRecordStepFromError(errorLike) const failedNode = inferRecordNodeFromError(errorLike, state)
|| inferRecordStepFromState(state, ['failed', 'running']); || inferRecordNodeFromState(state, ['failed', 'running']);
return failedStep ? `step${failedStep}_failed` : status; return failedNode ? `node:${failedNode}:failed` : status;
} }
if (normalizedStatus === 'stopped') { if (normalizedStatus === 'stopped') {
const stoppedStep = inferRecordStepFromError(errorLike) const stoppedNode = inferRecordNodeFromError(errorLike, state)
|| inferRecordStepFromState(state, ['stopped', 'running']); || inferRecordNodeFromState(state, ['stopped', 'running']);
return stoppedStep ? `step${stoppedStep}_stopped` : status; return stoppedNode ? `node:${stoppedNode}:stopped` : status;
} }
return status; return status;
@@ -439,7 +467,7 @@
let successfulRuns = roundSummaries.filter((item) => item.status === 'success').length; let successfulRuns = roundSummaries.filter((item) => item.status === 'success').length;
const initialState = await getState(); const initialState = await getState();
const initialPhase = continueCurrentOnFirstAttempt && getRunningSteps(initialState.stepStatuses, initialState).length const initialPhase = continueCurrentOnFirstAttempt && getRunningWorkflowNodes(initialState).length
? 'waiting_step' ? 'waiting_step'
: 'running'; : 'running';
const showResumePosition = continueCurrentOnFirstAttempt || resumeCurrentRun > 1 || resumeAttemptRun > 1; const showResumePosition = continueCurrentOnFirstAttempt || resumeCurrentRun > 1 || resumeAttemptRun > 1;
@@ -474,24 +502,25 @@
autoRunAttemptRun: attemptRun, autoRunAttemptRun: attemptRun,
}); });
roundSummary.attempts = attemptRun; roundSummary.attempts = attemptRun;
let startStep = 1; const defaultStartNodeId = typeof runAutoSequenceFromNode === 'function' ? 'open-chatgpt' : 1;
let startNodeId = defaultStartNodeId;
let useExistingProgress = false; let useExistingProgress = false;
if (reuseExistingProgress) { if (reuseExistingProgress) {
let currentState = await getState(); let currentState = await getState();
if (getRunningSteps(currentState.stepStatuses, currentState).length) { if (getRunningWorkflowNodes(currentState).length) {
currentState = await waitForRunningStepsToFinish({ currentState = await waitForRunningWorkflowNodesToFinish({
currentRun: targetRun, currentRun: targetRun,
totalRuns, totalRuns,
attemptRun, attemptRun,
}); });
} }
const resumeStep = getFirstUnfinishedStep(currentState.stepStatuses, currentState); const resumeNodeId = getFirstUnfinishedWorkflowNode(currentState);
if (resumeStep && hasSavedProgress(currentState.stepStatuses, currentState)) { if (resumeNodeId && hasSavedWorkflowProgress(currentState)) {
startStep = resumeStep; startNodeId = resumeNodeId;
useExistingProgress = true; useExistingProgress = true;
} else if (hasSavedProgress(currentState.stepStatuses, currentState)) { } else if (hasSavedWorkflowProgress(currentState)) {
await addLog('检测到当前流程已处理完成,本轮将改为从步骤 1 重新开始。', 'info'); await addLog('检测到当前流程已处理完成,本轮将改为从首个节点重新开始。', 'info');
} }
} }
@@ -571,7 +600,7 @@
sessionId, sessionId,
}); });
if (!useExistingProgress && startStep === 1 && typeof ensureHotmailMailboxReadyForAutoRunRound === 'function') { if (!useExistingProgress && startNodeId === defaultStartNodeId && typeof ensureHotmailMailboxReadyForAutoRunRound === 'function') {
await ensureHotmailMailboxReadyForAutoRunRound({ await ensureHotmailMailboxReadyForAutoRunRound({
targetRun, targetRun,
totalRuns, totalRuns,
@@ -580,7 +609,7 @@
}); });
} }
await runAutoSequenceFromStep(startStep, { await runAutoSequenceFromWorkflowNode(startNodeId, {
targetRun, targetRun,
totalRuns, totalRuns,
attemptRuns: attemptRun, attemptRuns: attemptRun,
+51 -17
View File
@@ -5,6 +5,7 @@
const { const {
chrome, chrome,
DEFAULT_STATE, DEFAULT_STATE,
getStepIdByNodeIdForState,
getState, getState,
isRecoverableStep9AuthFailure, isRecoverableStep9AuthFailure,
LOG_PREFIX, LOG_PREFIX,
@@ -51,12 +52,14 @@
const normalizedOptions = options && typeof options === 'object' ? options : {}; const normalizedOptions = options && typeof options === 'object' ? options : {};
const step = normalizeLogStep(normalizedOptions.step); const step = normalizeLogStep(normalizedOptions.step);
const stepKey = String(normalizedOptions.stepKey || '').trim(); const stepKey = String(normalizedOptions.stepKey || '').trim();
const nodeId = String(normalizedOptions.nodeId || normalizedOptions.nodeKey || stepKey || '').trim();
return { return {
message: String(message || ''), message: String(message || ''),
level, level,
timestamp: Date.now(), timestamp: Date.now(),
step, step,
stepKey, stepKey,
nodeId,
}; };
} }
@@ -70,14 +73,21 @@
chrome.runtime.sendMessage({ type: 'LOG_ENTRY', payload: entry }).catch(() => { }); 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 state = await getState();
const statuses = { ...state.stepStatuses }; const nodeStatuses = { ...(state.nodeStatuses || {}) };
statuses[step] = status; nodeStatuses[normalizedNodeId] = status;
await setState({ stepStatuses: statuses, currentStep: step }); await setState({
nodeStatuses,
currentNodeId: normalizedNodeId,
});
chrome.runtime.sendMessage({ chrome.runtime.sendMessage({
type: 'STEP_STATUS_CHANGED', type: 'NODE_STATUS_CHANGED',
payload: { step, status }, payload: { nodeId: normalizedNodeId, status },
}).catch(() => { }); }).catch(() => { });
} }
@@ -149,29 +159,50 @@
} }
function getFirstUnfinishedStep(statuses = {}) { function getFirstUnfinishedStep(statuses = {}) {
const stepIds = Object.keys(DEFAULT_STATE.stepStatuses || {}) const nodeStatuses = statuses && typeof statuses === 'object' ? statuses : {};
.map((step) => Number(step)) const nodeIds = Object.keys(DEFAULT_STATE.nodeStatuses || {});
.filter(Number.isFinite) for (const nodeId of nodeIds) {
.sort((left, right) => left - right); if (!isStepDoneStatus(nodeStatuses[nodeId] || 'pending')) {
for (const step of stepIds) { return typeof getStepIdByNodeIdForState === 'function'
if (!isStepDoneStatus(statuses[step] || 'pending')) { ? getStepIdByNodeIdForState(nodeId, {})
return step; : null;
} }
} }
return null; return null;
} }
function hasSavedProgress(statuses = {}) { 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 = {}) { function getRunningSteps(statuses = {}) {
return Object.entries({ ...DEFAULT_STATE.stepStatuses, ...statuses }) return Object.entries({ ...DEFAULT_STATE.nodeStatuses, ...statuses })
.filter(([, status]) => status === 'running') .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); .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 = {}) { function getAutoRunStatusPayload(phase, payload = {}) {
return { return {
autoRunning: phase === 'scheduled' autoRunning: phase === 'scheduled'
@@ -195,12 +226,15 @@
return { return {
addLog, addLog,
getAutoRunStatusPayload, getAutoRunStatusPayload,
getFirstUnfinishedNode,
isAddPhoneAuthFailure, isAddPhoneAuthFailure,
getErrorMessage, getErrorMessage,
getFirstUnfinishedStep, getFirstUnfinishedStep,
getLoginAuthStateLabel, getLoginAuthStateLabel,
getRunningNodes,
getRunningSteps, getRunningSteps,
getSourceLabel, getSourceLabel,
hasSavedNodeProgress,
hasSavedProgress, hasSavedProgress,
isLegacyStep9RecoverableAuthError, isLegacyStep9RecoverableAuthError,
isRestartCurrentAttemptError, isRestartCurrentAttemptError,
@@ -208,7 +242,7 @@
isStep9RecoverableAuthError, isStep9RecoverableAuthError,
isStepDoneStatus, isStepDoneStatus,
isVerificationMailPollingError, isVerificationMailPollingError,
setStepStatus, setNodeStatus,
}; };
} }
+32
View File
@@ -26,6 +26,21 @@
return flowBuilder.getRuleDefinition(step, state); 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 = {}) { function buildVerificationPollPayload(step, state = {}, overrides = {}) {
const flowId = resolveFlowId(state); const flowId = resolveFlowId(state);
const flowBuilder = getFlowBuilder(flowId); const flowBuilder = getFlowBuilder(flowId);
@@ -35,9 +50,26 @@
return flowBuilder.buildVerificationPollPayload(step, state, overrides); 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 { return {
buildVerificationPollPayload, buildVerificationPollPayload,
buildVerificationPollPayloadForNode,
getVerificationMailRule, getVerificationMailRule,
getVerificationMailRuleForNode,
resolveFlowId, resolveFlowId,
}; };
} }
+212 -91
View File
@@ -21,17 +21,17 @@
clearStopRequest, clearStopRequest,
closeLocalhostCallbackTabs, closeLocalhostCallbackTabs,
closeTabsByUrlPrefix, closeTabsByUrlPrefix,
completeStepFromBackground, completeNodeFromBackground,
deleteHotmailAccount, deleteHotmailAccount,
deleteHotmailAccounts, deleteHotmailAccounts,
deleteIcloudAlias, deleteIcloudAlias,
deleteUsedIcloudAliases, deleteUsedIcloudAliases,
disableUsedLuckmailPurchases, disableUsedLuckmailPurchases,
doesStepUseCompletionSignal, doesNodeUseCompletionSignal,
ensureMail2925MailboxSession, ensureMail2925MailboxSession,
ensureManualInteractionAllowed, ensureManualInteractionAllowed,
executeStep, executeNode,
executeStepViaCompletionSignal, executeNodeViaCompletionSignal,
exportSettingsBundle, exportSettingsBundle,
fetchGeneratedEmail, fetchGeneratedEmail,
refreshGpcCardBalance, refreshGpcCardBalance,
@@ -47,6 +47,9 @@
getPendingAutoRunTimerPlan, getPendingAutoRunTimerPlan,
getSourceLabel, getSourceLabel,
getState, getState,
getNodeDefinitionForState,
getNodeIdsForState,
getStepIdByNodeIdForState,
getStepDefinitionForState, getStepDefinitionForState,
getStepIdsForState, getStepIdsForState,
getLastStepIdForState, getLastStepIdForState,
@@ -137,8 +140,8 @@
normalizePayPalAccounts, normalizePayPalAccounts,
normalizeRunCount, normalizeRunCount,
AUTO_RUN_TIMER_KIND_SCHEDULED_START, AUTO_RUN_TIMER_KIND_SCHEDULED_START,
notifyStepComplete, notifyNodeComplete,
notifyStepError, notifyNodeError,
patchMail2925Account, patchMail2925Account,
patchHotmailAccount, patchHotmailAccount,
pollContributionStatus, pollContributionStatus,
@@ -169,9 +172,9 @@
setLuckmailPurchaseUsedState, setLuckmailPurchaseUsedState,
setPersistentSettings, setPersistentSettings,
setState, setState,
setStepStatus, setNodeStatus,
skipAutoRunCountdown, skipAutoRunCountdown,
skipStep, skipNode,
startContributionFlow, startContributionFlow,
startAutoRunLoop, startAutoRunLoop,
deleteMail2925Account, 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 = {}) { function getStepKeyForState(step, state = {}) {
if (typeof getStepDefinitionForState === 'function') { if (typeof getStepDefinitionForState === 'function') {
return String(getStepDefinitionForState(step, state)?.key || '').trim(); 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)) { if (typeof isAutoRunLockedState !== 'function' || !isAutoRunLockedState(state)) {
return false; return false;
} }
const normalizedStep = Number(step); const currentStatus = String(state?.nodeStatuses?.[normalizedNodeId] || '').trim();
if (!Number.isInteger(normalizedStep) || normalizedStep <= 0) {
return false;
}
const currentStatus = String(state?.stepStatuses?.[normalizedStep] || '').trim();
if (currentStatus === 'running') { if (currentStatus === 'running') {
return false; return false;
} }
const currentStep = Number(state?.currentStep) || 0; const currentNodeId = String(state?.currentNodeId || '').trim();
if (currentStep > 0 && normalizedStep !== currentStep) { if (currentNodeId && normalizedNodeId !== currentNodeId) {
return true; return true;
} }
return ['completed', 'manual_completed', 'skipped', 'failed', 'stopped'].includes(currentStatus); return ['completed', 'manual_completed', 'skipped', 'failed', 'stopped'].includes(currentStatus);
@@ -383,24 +451,38 @@
|| status === 'skipped'; || status === 'skipped';
} }
function findStepByKeyAfter(currentStep, targetKey, state = {}) { function findStepByKeyAfter(currentOrder, targetKey, state = {}) {
const activeStepIds = typeof getStepIdsForState === 'function' const activeStepIds = typeof getStepIdsForState === 'function'
? getStepIdsForState(state) ? getStepIdsForState(state)
: []; : [];
const candidates = activeStepIds.length ? activeStepIds : [Number(currentStep) + 1, 8]; const candidates = activeStepIds.length ? activeStepIds : [Number(currentOrder) + 1, 8];
return candidates.find((stepId) => { return candidates.find((stepId) => {
const numericStep = Number(stepId); const numericStep = Number(stepId);
if (!Number.isFinite(numericStep) || numericStep <= Number(currentStep)) { if (!Number.isFinite(numericStep) || numericStep <= Number(currentOrder)) {
return false; return false;
} }
const stepKey = getStepKeyForState(numericStep, state); const stepKey = getStepKeyForState(numericStep, state);
if (stepKey) { if (stepKey) {
return stepKey === targetKey; return stepKey === targetKey;
} }
return targetKey === 'fetch-login-code' && Number(currentStep) === 7 && numericStep === 8; return targetKey === 'fetch-login-code' && Number(currentOrder) === 7 && numericStep === 8;
}) || null; }) || 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 = '') { function normalizePlusPaymentMethodForDisplay(value = '') {
const normalized = String(value || '').trim().toLowerCase(); const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'gpc-helper') { if (normalized === 'gpc-helper') {
@@ -500,9 +582,9 @@
const latestState = await getState(); const latestState = await getState();
const loginCodeStep = findStepByKeyAfter(step, 'fetch-login-code', latestState); const loginCodeStep = findStepByKeyAfter(step, 'fetch-login-code', latestState);
if (loginCodeStep) { if (loginCodeStep) {
const currentStatus = latestState.stepStatuses?.[loginCodeStep]; const currentStatus = getNodeStatusByStep(loginCodeStep, latestState);
if (!isStepProtectedFromAutoSkip(currentStatus)) { if (!isStepProtectedFromAutoSkip(currentStatus)) {
await setStepStatus(loginCodeStep, 'skipped'); await setNodeStatusByStep(loginCodeStep, 'skipped', latestState);
await addLog(`认证页已直接进入 OAuth 授权页,已自动跳过步骤 ${loginCodeStep} 的登录验证码。`, 'warn', { await addLog(`认证页已直接进入 OAuth 授权页,已自动跳过步骤 ${loginCodeStep} 的登录验证码。`, 'warn', {
step, step,
stepKey: 'oauth-login', stepKey: 'oauth-login',
@@ -571,21 +653,21 @@
await syncStepAccountIdentityFromPayload(payload); await syncStepAccountIdentityFromPayload(payload);
if (payload.skipRegistrationFlow) { if (payload.skipRegistrationFlow) {
const latestState = await getState(); const latestState = await getState();
for (const skipStep of [3, 4, 5]) { for (const skippedStep of [3, 4, 5]) {
const status = latestState.stepStatuses?.[skipStep]; const status = getNodeStatusByStep(skippedStep, latestState);
if (status === 'running' || status === 'completed' || status === 'manual_completed') { if (status === 'running' || status === 'completed' || status === 'manual_completed') {
continue; continue;
} }
await setStepStatus(skipStep, 'skipped'); await setNodeStatusByStep(skippedStep, 'skipped', latestState);
} }
await addLog('步骤 2:检测到当前已登录会话,已自动跳过步骤 3/4/5,流程将直接进入步骤 6。', 'warn'); await addLog('步骤 2:检测到当前已登录会话,已自动跳过步骤 3/4/5,流程将直接进入步骤 6。', 'warn');
break; break;
} }
if (payload.skippedPasswordStep) { if (payload.skippedPasswordStep) {
const latestState = await getState(); const latestState = await getState();
const step3Status = latestState.stepStatuses?.[3]; const step3Status = getNodeStatusByStep(3, latestState);
if (step3Status !== 'running' && step3Status !== 'completed' && step3Status !== 'manual_completed') { if (step3Status !== 'running' && step3Status !== 'completed' && step3Status !== 'manual_completed') {
await setStepStatus(3, 'skipped'); await setNodeStatusByStep(3, 'skipped', latestState);
const identityLabel = payload.accountIdentifierType === 'phone' ? '手机号' : '邮箱'; const identityLabel = payload.accountIdentifierType === 'phone' ? '手机号' : '邮箱';
await addLog(`步骤 2:提交${identityLabel}后页面直接进入验证码页,已自动跳过步骤 3。`, 'warn'); await addLog(`步骤 2:提交${identityLabel}后页面直接进入验证码页,已自动跳过步骤 3。`, 'warn');
} }
@@ -598,9 +680,9 @@
} }
if (payload.skipProfileStep) { if (payload.skipProfileStep) {
const latestState = await getState(); const latestState = await getState();
const step5Status = latestState.stepStatuses?.[5]; const step5Status = getNodeStatusByStep(5, latestState);
if (step5Status !== 'running' && step5Status !== 'completed' && step5Status !== 'manual_completed') { if (step5Status !== 'running' && step5Status !== 'completed' && step5Status !== 'manual_completed') {
await setStepStatus(5, 'skipped'); await setNodeStatusByStep(5, 'skipped', latestState);
await addLog('步骤 3:页面已直接进入已登录态,已自动跳过步骤 5。', 'warn'); await addLog('步骤 3:页面已直接进入已登录态,已自动跳过步骤 5。', 'warn');
} }
} }
@@ -621,9 +703,9 @@
}); });
if (payload.skipProfileStep) { if (payload.skipProfileStep) {
const latestState = await getState(); const latestState = await getState();
const step5Status = latestState.stepStatuses?.[5]; const step5Status = getNodeStatusByStep(5, latestState);
if (step5Status !== 'running' && step5Status !== 'completed' && step5Status !== 'manual_completed') { if (step5Status !== 'running' && step5Status !== 'completed' && step5Status !== 'manual_completed') {
await setStepStatus(5, 'skipped'); await setNodeStatusByStep(5, 'skipped', latestState);
if (payload.skipProfileStepReason === 'combined_verification_profile') { if (payload.skipProfileStepReason === 'combined_verification_profile') {
await addLog('步骤 4:当前验证码页已内嵌完成注册资料提交,已自动跳过步骤 5。', 'warn'); await addLog('步骤 4:当前验证码页已内嵌完成注册资料提交,已自动跳过步骤 5。', 'warn');
} else { } else {
@@ -664,7 +746,8 @@
} }
} }
async function handleMessage(message, sender) { async function handleMessage(rawMessage, sender) {
const message = await normalizeNodeProtocolMessage(rawMessage);
switch (message.type) { switch (message.type) {
case 'CONTENT_SCRIPT_READY': { case 'CONTENT_SCRIPT_READY': {
const tabId = sender.tab?.id; const tabId = sender.tab?.id;
@@ -690,20 +773,30 @@
return { ok: true }; 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(); const currentState = await getState();
if (isStaleAutoRunStepMessage(message.step, currentState)) { if (isStaleAutoRunNodeMessage(nodeId, currentState)) {
await addLog(`自动运行:忽略过期的步骤 ${message.step} 完成消息,当前流程已在步骤 ${currentState.currentStep || '未知'}`, 'warn', { step: message.step }); await addLog(
`自动运行:忽略过期的节点 ${nodeId} 完成消息,当前流程已在节点 ${currentState.currentNodeId || '未知'}`,
'warn',
{ nodeId }
);
return { ok: true, ignored: true }; return { ok: true, ignored: true };
} }
if (getStopRequested()) { if (getStopRequested()) {
await setStepStatus(message.step, 'stopped'); await setNodeStatus(nodeId, 'stopped');
await appendManualAccountRunRecordIfNeeded(`step${message.step}_stopped`, null, '流程已被用户停止。'); await appendManualAccountRunRecordIfNeeded(`node:${nodeId}:stopped`, null, '流程已被用户停止。');
notifyStepError(message.step, '流程已被用户停止。'); notifyNodeError(nodeId, '流程已被用户停止。');
return { ok: true }; return { ok: true };
} }
try { try {
if (message.step === 3 && typeof finalizeStep3Completion === 'function') { if (nodeId === 'fill-password' && typeof finalizeStep3Completion === 'function') {
await finalizeStep3Completion(message.payload || {}); await finalizeStep3Completion(message.payload || {});
} }
} catch (error) { } catch (error) {
@@ -711,60 +804,74 @@
const userMessage = typeof handleCloudflareSecurityBlocked === 'function' const userMessage = typeof handleCloudflareSecurityBlocked === 'function'
? await handleCloudflareSecurityBlocked(error) ? await handleCloudflareSecurityBlocked(error)
: (error?.message || String(error || '')); : (error?.message || String(error || ''));
notifyStepError(message.step, '流程已被用户停止。'); notifyNodeError(nodeId, '流程已被用户停止。');
return { ok: true, error: userMessage }; return { ok: true, error: userMessage };
} }
const errorMessage = error?.message || String(error || '步骤 3 提交后确认失败'); const errorMessage = error?.message || String(error || '步骤 3 提交后确认失败');
await setStepStatus(message.step, 'failed'); await setNodeStatus(nodeId, 'failed');
await addLog(`失败:${errorMessage}`, 'error', { step: message.step }); await addLog(`失败:${errorMessage}`, 'error', {
await appendManualAccountRunRecordIfNeeded(`step${message.step}_failed`, null, errorMessage); nodeId,
notifyStepError(message.step, errorMessage); });
await appendManualAccountRunRecordIfNeeded(`node:${nodeId}:failed`, null, errorMessage);
notifyNodeError(nodeId, errorMessage);
return { ok: true, error: errorMessage }; return { ok: true, error: errorMessage };
} }
const completionStateCandidate = await getState(); const completionStateCandidate = await getState();
const lastStepId = typeof getLastStepIdForState === 'function' const nodeIds = typeof getNodeIdsForState === 'function' ? getNodeIdsForState(completionStateCandidate) : [];
? getLastStepIdForState(completionStateCandidate) const lastNodeId = nodeIds[nodeIds.length - 1] || '';
: 10; const isFinalNode = nodeId === lastNodeId;
const completionState = message.step === lastStepId ? completionStateCandidate : null; const completionState = isFinalNode ? completionStateCandidate : null;
await setStepStatus(message.step, 'completed'); await setNodeStatus(nodeId, 'completed');
await addLog('已完成', 'ok', { step: message.step }); await addLog('已完成', 'ok', { nodeId });
await handleStepData(message.step, message.payload); await handleStepData(resolvedStep, message.payload);
if (message.step === lastStepId && typeof appendAccountRunRecord === 'function') { if (isFinalNode && typeof appendAccountRunRecord === 'function') {
await appendAccountRunRecord('success', completionState); await appendAccountRunRecord('success', completionState);
} }
notifyStepComplete(message.step, message.payload); notifyNodeComplete(nodeId, message.payload);
return { ok: true }; 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(); const staleCheckState = await getState();
if (isStaleAutoRunStepMessage(message.step, staleCheckState)) { if (isStaleAutoRunNodeMessage(nodeId, staleCheckState)) {
await addLog(`自动运行:忽略过期的步骤 ${message.step} 失败消息,当前流程已在步骤 ${staleCheckState.currentStep || '未知'}。原始错误:${message.error || '未知错误'}`, 'warn', { step: message.step }); await addLog(
`自动运行:忽略过期的节点 ${nodeId} 失败消息,当前流程已在节点 ${staleCheckState.currentNodeId || '未知'}。原始错误:${message.error || '未知错误'}`,
'warn',
{ nodeId }
);
return { ok: true, ignored: true }; return { ok: true, ignored: true };
} }
if (typeof isCloudflareSecurityBlockedError === 'function' && isCloudflareSecurityBlockedError(message.error)) { if (typeof isCloudflareSecurityBlockedError === 'function' && isCloudflareSecurityBlockedError(message.error)) {
const userMessage = typeof handleCloudflareSecurityBlocked === 'function' const userMessage = typeof handleCloudflareSecurityBlocked === 'function'
? await handleCloudflareSecurityBlocked(message.error) ? await handleCloudflareSecurityBlocked(message.error)
: (typeof message.error === 'string' ? message.error : String(message.error || '')); : (typeof message.error === 'string' ? message.error : String(message.error || ''));
notifyStepError(message.step, '流程已被用户停止。'); notifyNodeError(nodeId, '流程已被用户停止。');
return { ok: true, error: userMessage }; return { ok: true, error: userMessage };
} }
const currentState = await getState(); 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 || '')); const isSignupPhonePasswordMismatch = /SIGNUP_PHONE_PASSWORD_MISMATCH::/i.test(String(message.error || ''));
if (isStopError(message.error)) { if (isStopError(message.error)) {
await setStepStatus(message.step, 'stopped'); await setNodeStatus(nodeId, 'stopped');
await addLog('已被用户停止', 'warn', { step: message.step }); await addLog('已被用户停止', 'warn', { nodeId });
await appendManualAccountRunRecordIfNeeded(`step${message.step}_stopped`, null, message.error); await appendManualAccountRunRecordIfNeeded(`node:${nodeId}:stopped`, null, message.error);
notifyStepError(message.step, message.error); notifyNodeError(nodeId, message.error);
} else { } else {
if (!(isSignupPhonePasswordMismatch && currentStepStatus === 'failed')) { if (!(isSignupPhonePasswordMismatch && currentNodeStatus === 'failed')) {
await setStepStatus(message.step, 'failed'); await setNodeStatus(nodeId, 'failed');
await addLog(`失败:${message.error}`, 'error', { step: message.step }); await addLog(`失败:${message.error}`, 'error', {
await appendManualAccountRunRecordIfNeeded(`step${message.step}_failed`, null, message.error); nodeId,
});
await appendManualAccountRunRecordIfNeeded(`node:${nodeId}:failed`, null, message.error);
} }
notifyStepError(message.step, message.error); notifyNodeError(nodeId, message.error);
} }
return { ok: true }; return { ok: true };
} }
@@ -772,6 +879,7 @@
case 'RESOLVE_PLUS_MANUAL_CONFIRMATION': { case 'RESOLVE_PLUS_MANUAL_CONFIRMATION': {
const currentState = await getState(); const currentState = await getState();
const step = Number(message.payload?.step) || Number(currentState?.plusManualConfirmationStep) || 0; 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 confirmed = Boolean(message.payload?.confirmed);
const requestId = String(message.payload?.requestId || '').trim(); const requestId = String(message.payload?.requestId || '').trim();
const currentRequestId = String(currentState?.plusManualConfirmationRequestId || '').trim(); const currentRequestId = String(currentState?.plusManualConfirmationRequestId || '').trim();
@@ -818,7 +926,7 @@
if (confirmed) { if (confirmed) {
const methodLabel = method === 'gopay' ? 'GoPay' : '手动'; const methodLabel = method === 'gopay' ? 'GoPay' : '手动';
await addLog(`步骤 ${step}:已确认${methodLabel}订阅完成,准备继续下一步。`, 'ok'); await addLog(`步骤 ${step}:已确认${methodLabel}订阅完成,准备继续下一步。`, 'ok');
await completeStepFromBackground(step, { await completeNodeFromBackground(confirmationNodeId, {
plusManualConfirmationMethod: currentState?.plusManualConfirmationMethod || '', plusManualConfirmationMethod: currentState?.plusManualConfirmationMethod || '',
plusManualConfirmedAt: Date.now(), plusManualConfirmedAt: Date.now(),
}); });
@@ -828,10 +936,14 @@
const cancelMessage = method === 'gopay' const cancelMessage = method === 'gopay'
? '已取消 GoPay 订阅确认' ? '已取消 GoPay 订阅确认'
: (isGpcOtp ? '已取消 GPC OTP 输入' : '已取消当前手动确认'); : (isGpcOtp ? '已取消 GPC OTP 输入' : '已取消当前手动确认');
await setStepStatus(step, 'failed'); await setNodeStatus(confirmationNodeId, 'failed');
await addLog(`步骤 ${step}${cancelMessage}`, 'warn'); await addLog(`步骤 ${step}${cancelMessage}`, 'warn');
await appendManualAccountRunRecordIfNeeded(`step${step}_failed`, null, cancelMessage); await appendManualAccountRunRecordIfNeeded(
notifyStepError(step, cancelMessage); confirmationNodeId ? `node:${confirmationNodeId}:failed` : 'failed',
null,
cancelMessage
);
notifyNodeError(confirmationNodeId, cancelMessage);
return { ok: true }; return { ok: true };
} }
@@ -864,7 +976,7 @@
case 'SET_CONTRIBUTION_MODE': { case 'SET_CONTRIBUTION_MODE': {
const enabled = Boolean(message.payload?.enabled); const enabled = Boolean(message.payload?.enabled);
const state = await ensureManualInteractionAllowed(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 ? '当前有步骤正在执行,无法进入贡献模式。' : '当前有步骤正在执行,无法退出贡献模式。'); throw new Error(enabled ? '当前有步骤正在执行,无法进入贡献模式。' : '当前有步骤正在执行,无法退出贡献模式。');
} }
if (typeof setContributionMode !== 'function') { if (typeof setContributionMode !== 'function') {
@@ -878,7 +990,7 @@
case 'START_CONTRIBUTION_FLOW': { case 'START_CONTRIBUTION_FLOW': {
const state = await ensureManualInteractionAllowed('开始贡献'); 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('当前有步骤正在执行,无法开始贡献流程。'); throw new Error('当前有步骤正在执行,无法开始贡献流程。');
} }
if (typeof startContributionFlow !== 'function') { if (typeof startContributionFlow !== 'function') {
@@ -950,18 +1062,23 @@
return { ok: true, ...result }; return { ok: true, ...result };
} }
case 'EXECUTE_STEP': { case 'EXECUTE_NODE': {
clearStopRequest(); 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') { if (message.source === 'sidepanel') {
await lockAutomationWindowFromMessage(message, sender); await lockAutomationWindowFromMessage(message, sender);
await ensureManualInteractionAllowed('手动执行步骤'); await ensureManualInteractionAllowed('手动执行节点');
}
const step = message.payload.step;
if (message.source === 'sidepanel') {
await ensureManualStepPrerequisites(step);
} }
if (message.source === 'sidepanel') { 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) { if (message.payload.email) {
await setEmailState(message.payload.email); await setEmailState(message.payload.email);
@@ -971,10 +1088,10 @@
await setState({ emailPrefix: message.payload.emailPrefix }); await setState({ emailPrefix: message.payload.emailPrefix });
} }
const executionState = await getState(); const executionState = await getState();
if (doesStepUseCompletionSignal(step, executionState)) { if (doesNodeUseCompletionSignal(nodeId, executionState)) {
await executeStepViaCompletionSignal(step); await executeNodeViaCompletionSignal(nodeId);
} else { } else {
await executeStep(step); await executeNode(nodeId);
} }
return { ok: true }; return { ok: true };
} }
@@ -1094,9 +1211,12 @@
return { ok: true }; return { ok: true };
} }
case 'SKIP_STEP': { case 'SKIP_NODE': {
const step = Number(message.payload?.step); const nodeId = String(message.nodeId || message.payload?.nodeId || '').trim();
return await skipStep(step); if (!nodeId) {
throw new Error('SKIP_NODE 缺少 nodeId。');
}
return await skipNode(nodeId);
} }
case 'SAVE_SETTING': { case 'SAVE_SETTING': {
@@ -1156,10 +1276,11 @@
} }
if (stepModeChanged && typeof getStepIdsForState === 'function') { if (stepModeChanged && typeof getStepIdsForState === 'function') {
const nextStateForSteps = { ...currentState, ...stateUpdates }; const nextStateForSteps = { ...currentState, ...stateUpdates };
stateUpdates.stepStatuses = Object.fromEntries( const nextNodeIds = typeof getNodeIdsForState === 'function'
getStepIdsForState(nextStateForSteps).map((stepId) => [stepId, 'pending']) ? getNodeIdsForState(nextStateForSteps)
); : getStepIdsForState(nextStateForSteps).map((stepId) => getStepKeyForState(stepId, nextStateForSteps)).filter(Boolean);
stateUpdates.currentStep = 0; stateUpdates.nodeStatuses = Object.fromEntries(nextNodeIds.map((nodeId) => [nodeId, 'pending']));
stateUpdates.currentNodeId = '';
} }
await setState(stateUpdates); await setState(stateUpdates);
const mergedState = await getState(); const mergedState = await getState();
+53 -100
View File
@@ -4,8 +4,7 @@
function createRuntimeStateHelpers(deps = {}) { function createRuntimeStateHelpers(deps = {}) {
const { const {
DEFAULT_ACTIVE_FLOW_ID = 'openai', DEFAULT_ACTIVE_FLOW_ID = 'openai',
defaultStepStatuses = {}, defaultNodeStatuses = {},
getStepDefinitionForState = null,
} = deps; } = deps;
const RUNTIME_SHARED_FIELDS = Object.freeze([ const RUNTIME_SHARED_FIELDS = Object.freeze([
@@ -150,11 +149,6 @@
return String(value || '').trim(); return String(value || '').trim();
} }
function normalizeStepNumber(value) {
const step = Math.floor(Number(value) || 0);
return step > 0 ? step : 0;
}
function normalizeNodeStatus(value = '') { function normalizeNodeStatus(value = '') {
const normalized = String(value || '').trim().toLowerCase(); const normalized = String(value || '').trim().toLowerCase();
if (!normalized) { if (!normalized) {
@@ -163,73 +157,26 @@
return normalized; return normalized;
} }
function buildDefaultStepStatuses() { function buildDefaultNodeStatuses() {
return Object.fromEntries( return Object.fromEntries(
Object.entries(normalizePlainObject(defaultStepStatuses)).map(([key, value]) => [ Object.entries(normalizePlainObject(defaultNodeStatuses)).map(([key, value]) => [
String(key), String(key),
normalizeNodeStatus(value), normalizeNodeStatus(value),
]) ])
); );
} }
function normalizeStepStatuses(value) { function normalizeNodeStatuses(value) {
const base = buildDefaultStepStatuses(); const base = buildDefaultNodeStatuses();
if (!isPlainObject(value)) { if (!isPlainObject(value)) {
return base; return base;
} }
const next = { ...base }; const next = { ...base };
for (const [key, status] of Object.entries(value)) { for (const [key, status] of Object.entries(value)) {
const step = normalizeStepNumber(key); const nodeId = String(key || '').trim();
if (!step) continue; if (!nodeId) continue;
next[String(step)] = normalizeNodeStatus(status); next[nodeId] = 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);
} }
return next; return next;
} }
@@ -298,6 +245,8 @@
function buildRuntimeStateDefault() { function buildRuntimeStateDefault() {
return { return {
flowId: DEFAULT_ACTIVE_FLOW_ID,
runId: '',
activeFlowId: DEFAULT_ACTIVE_FLOW_ID, activeFlowId: DEFAULT_ACTIVE_FLOW_ID,
activeRunId: '', activeRunId: '',
currentNodeId: '', currentNodeId: '',
@@ -316,10 +265,6 @@
identity: {}, identity: {},
}, },
}, },
legacyStepCompat: {
currentStep: 0,
stepStatuses: buildDefaultStepStatuses(),
},
}; };
} }
@@ -329,47 +274,64 @@
...cloneValue(normalizePlainObject(state.runtimeState)), ...cloneValue(normalizePlainObject(state.runtimeState)),
}; };
const activeFlowId = normalizeFlowId( 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 ? state.activeFlowId
: Object.prototype.hasOwnProperty.call(baseRuntimeState, 'flowId')
? baseRuntimeState.flowId
: baseRuntimeState.activeFlowId : baseRuntimeState.activeFlowId
); );
const legacyStepCompat = normalizeLegacyStepCompat(baseRuntimeState.legacyStepCompat, state);
const currentNodeId = String( const currentNodeId = String(
Object.prototype.hasOwnProperty.call(state, 'currentNodeId') Object.prototype.hasOwnProperty.call(state, 'currentNodeId')
? state.currentNodeId ? state.currentNodeId
: (baseRuntimeState.currentNodeId || resolveStepKey(legacyStepCompat.currentStep, state)) : baseRuntimeState.currentNodeId
).trim(); ).trim();
const nodeStatuses = normalizeNodeStatuses( const nodeStatuses = normalizeNodeStatuses(
Object.prototype.hasOwnProperty.call(state, 'nodeStatuses') Object.prototype.hasOwnProperty.call(state, 'nodeStatuses')
? state.nodeStatuses ? state.nodeStatuses
: baseRuntimeState.nodeStatuses, : baseRuntimeState.nodeStatuses
state,
legacyStepCompat
); );
return { return {
...baseRuntimeState, ...baseRuntimeState,
flowId: activeFlowId,
activeFlowId, activeFlowId,
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( activeRunId: normalizeRunId(
Object.prototype.hasOwnProperty.call(state, 'activeRunId') Object.prototype.hasOwnProperty.call(state, 'runId')
? state.activeRunId ? state.runId
: baseRuntimeState.activeRunId : Object.prototype.hasOwnProperty.call(state, 'activeRunId')
? state.activeRunId
: Object.prototype.hasOwnProperty.call(baseRuntimeState, 'runId')
? baseRuntimeState.runId
: baseRuntimeState.activeRunId
), ),
currentNodeId, currentNodeId,
nodeStatuses, nodeStatuses,
sharedState: buildSharedState(baseRuntimeState.sharedState, state), sharedState: buildSharedState(baseRuntimeState.sharedState, state),
serviceState: buildServiceState(baseRuntimeState.serviceState, state), serviceState: buildServiceState(baseRuntimeState.serviceState, state),
flowState: buildOpenAiFlowState(baseRuntimeState.flowState, state), flowState: buildOpenAiFlowState(baseRuntimeState.flowState, state),
legacyStepCompat,
}; };
} }
function buildFlattenedUpdates(updates = {}) { function buildFlattenedUpdates(updates = {}) {
const next = { const ignoredKeys = new Set(['current' + 'Step', 'step' + 'Statuses', 'legacy' + 'StepCompat']);
...updates, const next = {};
}; for (const [key, value] of Object.entries(updates || {})) {
if (!ignoredKeys.has(key)) {
next[key] = value;
}
}
const runtimeState = normalizePlainObject(updates.runtimeState); const runtimeState = normalizePlainObject(updates.runtimeState);
const legacyStepCompat = normalizePlainObject(updates.legacyStepCompat);
const sharedState = normalizePlainObject(updates.sharedState); const sharedState = normalizePlainObject(updates.sharedState);
const serviceState = normalizePlainObject(updates.serviceState); const serviceState = normalizePlainObject(updates.serviceState);
const flowState = normalizePlainObject(updates.flowState); const flowState = normalizePlainObject(updates.flowState);
@@ -377,31 +339,23 @@
if (Object.prototype.hasOwnProperty.call(runtimeState, 'activeFlowId')) { if (Object.prototype.hasOwnProperty.call(runtimeState, 'activeFlowId')) {
next.activeFlowId = 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')) { if (Object.prototype.hasOwnProperty.call(runtimeState, 'activeRunId')) {
next.activeRunId = 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')) { if (Object.prototype.hasOwnProperty.call(runtimeState, 'currentNodeId')) {
next.currentNodeId = runtimeState.currentNodeId; next.currentNodeId = runtimeState.currentNodeId;
} }
if (Object.prototype.hasOwnProperty.call(runtimeState, 'nodeStatuses')) { if (Object.prototype.hasOwnProperty.call(runtimeState, 'nodeStatuses')) {
next.nodeStatuses = cloneValue(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)); Object.assign(next, pickDefinedFields(sharedState, RUNTIME_SHARED_FIELDS));
if (Object.prototype.hasOwnProperty.call(runtimeState, 'sharedState')) { if (Object.prototype.hasOwnProperty.call(runtimeState, 'sharedState')) {
Object.assign( Object.assign(
@@ -432,6 +386,8 @@
const runtimeState = ensureRuntimeState(state); const runtimeState = ensureRuntimeState(state);
return { return {
...state, ...state,
flowId: runtimeState.flowId,
runId: runtimeState.runId,
activeFlowId: runtimeState.activeFlowId, activeFlowId: runtimeState.activeFlowId,
activeRunId: runtimeState.activeRunId, activeRunId: runtimeState.activeRunId,
currentNodeId: runtimeState.currentNodeId, currentNodeId: runtimeState.currentNodeId,
@@ -439,9 +395,6 @@
flowState: cloneValue(runtimeState.flowState), flowState: cloneValue(runtimeState.flowState),
sharedState: cloneValue(runtimeState.sharedState), sharedState: cloneValue(runtimeState.sharedState),
serviceState: cloneValue(runtimeState.serviceState), serviceState: cloneValue(runtimeState.serviceState),
legacyStepCompat: cloneValue(runtimeState.legacyStepCompat),
currentStep: runtimeState.legacyStepCompat.currentStep,
stepStatuses: cloneValue(runtimeState.legacyStepCompat.stepStatuses),
runtimeState, runtimeState,
}; };
} }
@@ -456,12 +409,12 @@
return { return {
...flattenedUpdates, ...flattenedUpdates,
flowId: runtimeState.flowId,
runId: runtimeState.runId,
activeFlowId: runtimeState.activeFlowId, activeFlowId: runtimeState.activeFlowId,
activeRunId: runtimeState.activeRunId, activeRunId: runtimeState.activeRunId,
currentNodeId: runtimeState.currentNodeId, currentNodeId: runtimeState.currentNodeId,
nodeStatuses: cloneValue(runtimeState.nodeStatuses), nodeStatuses: cloneValue(runtimeState.nodeStatuses),
currentStep: runtimeState.legacyStepCompat.currentStep,
stepStatuses: cloneValue(runtimeState.legacyStepCompat.stepStatuses),
runtimeState, runtimeState,
}; };
} }
+2 -2
View File
@@ -7,7 +7,7 @@
chrome, chrome,
cleanupStep8NavigationListeners, cleanupStep8NavigationListeners,
clickWithDebugger, clickWithDebugger,
completeStepFromBackground, completeNodeFromBackground,
ensureStep8SignupPageReady, ensureStep8SignupPageReady,
getOAuthFlowRemainingMs, getOAuthFlowRemainingMs,
getOAuthFlowStepTimeoutMs, getOAuthFlowStepTimeoutMs,
@@ -122,7 +122,7 @@
cleanupListener(); cleanupListener();
addStepLog(visibleStep, `已捕获 localhost 地址:${callbackUrl}`, 'ok').then(() => { addStepLog(visibleStep, `已捕获 localhost 地址:${callbackUrl}`, 'ok').then(() => {
return completeStepFromBackground(visibleStep, { localhostUrl: callbackUrl }); return completeNodeFromBackground(state?.nodeId || 'confirm-oauth', { localhostUrl: callbackUrl });
}).then(() => { }).then(() => {
resolve(); resolve();
}).catch((err) => { }).catch((err) => {
+3 -3
View File
@@ -15,7 +15,7 @@
const { const {
addLog: rawAddLog = async () => {}, addLog: rawAddLog = async () => {},
chrome, chrome,
completeStepFromBackground, completeNodeFromBackground,
createAutomationTab = null, createAutomationTab = null,
ensureContentScriptReadyOnTabUntilStopped, ensureContentScriptReadyOnTabUntilStopped,
fetch: fetchImpl = null, fetch: fetchImpl = null,
@@ -457,7 +457,7 @@
gopayHelperOrderCreatedAt: result.orderCreatedAt || Date.now(), gopayHelperOrderCreatedAt: result.orderCreatedAt || Date.now(),
}); });
await addLog(`步骤 6GPC ${result.phoneMode === GPC_HELPER_PHONE_MODE_AUTO ? '自动' : '手动'}模式任务已创建(task_id: ${result.taskId}),准备继续下一步。`, 'info'); 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', plusCheckoutCountry: result.country || 'ID',
plusCheckoutCurrency: result.currency || 'IDR', plusCheckoutCurrency: result.currency || 'IDR',
plusCheckoutSource: result.checkoutSource, plusCheckoutSource: result.checkoutSource,
@@ -517,7 +517,7 @@
await addLog(`步骤 6Plus Checkout 页面已就绪(${paymentMethodLabel} / ${result.country || 'DE'} ${result.currency || 'EUR'}),准备继续下一步。`, 'info'); await addLog(`步骤 6Plus Checkout 页面已就绪(${paymentMethodLabel} / ${result.country || 'DE'} ${result.currency || 'EUR'}),准备继续下一步。`, 'info');
await completeStepFromBackground(6, { await completeNodeFromBackground('plus-checkout-create', {
plusCheckoutCountry: result.country || 'DE', plusCheckoutCountry: result.country || 'DE',
plusCheckoutCurrency: result.currency || 'EUR', plusCheckoutCurrency: result.currency || 'EUR',
}); });
+10 -10
View File
@@ -11,7 +11,7 @@
chrome, chrome,
CLOUDFLARE_TEMP_EMAIL_PROVIDER, CLOUDFLARE_TEMP_EMAIL_PROVIDER,
CLOUD_MAIL_PROVIDER = 'cloudmail', CLOUD_MAIL_PROVIDER = 'cloudmail',
completeStepFromBackground, completeNodeFromBackground,
confirmCustomVerificationStepBypass, confirmCustomVerificationStepBypass,
ensureMail2925MailboxSession, ensureMail2925MailboxSession,
ensureIcloudMailSession, ensureIcloudMailSession,
@@ -225,8 +225,8 @@
`步骤 ${visibleStep}:当前认证页已进入 OAuth 授权页${fromRecovery ? '(轮询失败后复核)' : ''},跳过登录验证码拉取并继续后续流程。`, `步骤 ${visibleStep}:当前认证页已进入 OAuth 授权页${fromRecovery ? '(轮询失败后复核)' : ''},跳过登录验证码拉取并继续后续流程。`,
'warn' 'warn'
); );
if (typeof completeStepFromBackground === 'function') { if (typeof completeNodeFromBackground === 'function') {
await completeStepFromBackground(visibleStep, { await completeNodeFromBackground(options.nodeId || 'fetch-login-code', {
loginVerificationRequestedAt: null, loginVerificationRequestedAt: null,
skipLoginVerificationStep: true, skipLoginVerificationStep: true,
directOAuthConsentPage: true, directOAuthConsentPage: true,
@@ -316,7 +316,7 @@
), ),
}); });
if (pageState?.state === 'oauth_consent_page') { if (pageState?.state === 'oauth_consent_page') {
await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep, { fromRecovery: true }); await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep, { fromRecovery: true, nodeId: currentState?.nodeId });
return { outcome: 'completed' }; return { outcome: 'completed' };
} }
if (pageState?.state === 'verification_page' || pageState?.state === 'phone_verification_page' || pageState?.state === 'add_email_page') { if (pageState?.state === 'verification_page' || pageState?.state === 'phone_verification_page' || pageState?.state === 'add_email_page') {
@@ -401,7 +401,7 @@
visibleStep, visibleStep,
}); });
await completeStepFromBackground(visibleStep, { await completeNodeFromBackground(state?.nodeId || 'fetch-login-code', {
phoneVerification: true, phoneVerification: true,
loginPhoneVerification: true, loginPhoneVerification: true,
code: result?.code || '', code: result?.code || '',
@@ -438,7 +438,7 @@
timeoutMs: await getStep8ReadyTimeoutMs('确认登录验证码页已就绪', state?.oauthUrl || '', visibleStep), timeoutMs: await getStep8ReadyTimeoutMs('确认登录验证码页已就绪', state?.oauthUrl || '', visibleStep),
}); });
if (pageState?.state === 'oauth_consent_page') { if (pageState?.state === 'oauth_consent_page') {
await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep); await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep, { nodeId: state?.nodeId });
return; return;
} }
if (pageState?.state === 'phone_verification_page') { if (pageState?.state === 'phone_verification_page') {
@@ -450,7 +450,7 @@
preparedState = addEmailPreparation?.state || preparedState; preparedState = addEmailPreparation?.state || preparedState;
pageState = addEmailPreparation?.pageState || pageState; pageState = addEmailPreparation?.pageState || pageState;
if (pageState?.state === 'oauth_consent_page') { if (pageState?.state === 'oauth_consent_page') {
await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep); await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep, { nodeId: preparedState?.nodeId || state?.nodeId });
return; return;
} }
if (pageState?.state === 'phone_verification_page') { if (pageState?.state === 'phone_verification_page') {
@@ -576,7 +576,7 @@
let stickyLastResendAt = Number(state?.loginVerificationRequestedAt) || 0; let stickyLastResendAt = Number(state?.loginVerificationRequestedAt) || 0;
let retryWithoutStep7Streak = 0; let retryWithoutStep7Streak = 0;
const maxRetryWithoutStep7Streak = 3; const maxRetryWithoutStep7Streak = 3;
let currentStepRecoveryAttempt = 0; let currentNodeRecoveryAttempt = 0;
while (true) { while (true) {
try { try {
@@ -601,8 +601,8 @@
let retryWithoutStep7 = false; let retryWithoutStep7 = false;
if (isStep8EmailInUseError(currentError) || isStep8MaxCheckAttemptsError(currentError)) { if (isStep8EmailInUseError(currentError) || isStep8MaxCheckAttemptsError(currentError)) {
currentStepRecoveryAttempt += 1; currentNodeRecoveryAttempt += 1;
if (currentStepRecoveryAttempt > STEP8_CURRENT_STEP_RECOVERY_MAX_ATTEMPTS) { if (currentNodeRecoveryAttempt > STEP8_CURRENT_STEP_RECOVERY_MAX_ATTEMPTS) {
throw currentError; throw currentError;
} }
if (isStep8EmailInUseError(currentError)) { if (isStep8EmailInUseError(currentError)) {
+3 -3
View File
@@ -7,7 +7,7 @@
const { const {
addLog, addLog,
chrome, chrome,
completeStepFromBackground, completeNodeFromBackground,
confirmCustomVerificationStepBypass, confirmCustomVerificationStepBypass,
generateRandomBirthday, generateRandomBirthday,
generateRandomName, generateRandomName,
@@ -83,7 +83,7 @@
return result || {}; return result || {};
} }
await completeStepFromBackground(4, { await completeNodeFromBackground('fetch-signup-code', {
phoneVerification: true, phoneVerification: true,
code: result?.code || '', code: result?.code || '',
...(result?.skipProfileStep ? { skipProfileStep: true } : {}), ...(result?.skipProfileStep ? { skipProfileStep: true } : {}),
@@ -280,7 +280,7 @@
throw new Error(prepareResult.error); throw new Error(prepareResult.error);
} }
if (prepareResult?.alreadyVerified) { if (prepareResult?.alreadyVerified) {
await completeStepFromBackground(4, prepareResult?.skipProfileStep ? { skipProfileStep: true } : {}); await completeNodeFromBackground('fetch-signup-code', prepareResult?.skipProfileStep ? { skipProfileStep: true } : {});
return; return;
} }
+2 -1
View File
@@ -105,7 +105,8 @@
`步骤 3:正在填写密码,${identityLabel},密码为${state.customPassword ? '自定义' : '自动生成'}${password.length} 位)` `步骤 3:正在填写密码,${identityLabel},密码为${state.customPassword ? '自定义' : '自动生成'}${password.length} 位)`
); );
await sendToContentScript('signup-page', { await sendToContentScript('signup-page', {
type: 'EXECUTE_STEP', type: 'EXECUTE_NODE',
nodeId: 'fill-password',
step: 3, step: 3,
source: 'background', source: 'background',
payload: { payload: {
+3 -3
View File
@@ -79,7 +79,7 @@
addLog: rawAddLog = async () => {}, addLog: rawAddLog = async () => {},
broadcastDataUpdate, broadcastDataUpdate,
chrome, chrome,
completeStepFromBackground, completeNodeFromBackground,
ensureContentScriptReadyOnTabUntilStopped, ensureContentScriptReadyOnTabUntilStopped,
fetch: fetchImpl = null, fetch: fetchImpl = null,
generateRandomName, generateRandomName,
@@ -1018,7 +1018,7 @@
plusCheckoutSource: PLUS_PAYMENT_METHOD_GPC_HELPER, plusCheckoutSource: PLUS_PAYMENT_METHOD_GPC_HELPER,
}); });
await addLog('步骤 7:GPC 任务已完成,准备继续下一步。', 'ok'); await addLog('步骤 7:GPC 任务已完成,准备继续下一步。', 'ok');
await completeStepFromBackground(7, { await completeNodeFromBackground('plus-checkout-billing', {
plusCheckoutSource: PLUS_PAYMENT_METHOD_GPC_HELPER, plusCheckoutSource: PLUS_PAYMENT_METHOD_GPC_HELPER,
}); });
return; return;
@@ -1920,7 +1920,7 @@
throw new Error(`步骤 7:多次提交账单地址后仍未跳转到 ${paymentConfig.label}${lastSubmitError}`); throw new Error(`步骤 7:多次提交账单地址后仍未跳转到 ${paymentConfig.label}${lastSubmitError}`);
} }
await completeStepFromBackground(7, { await completeNodeFromBackground('plus-checkout-billing', {
plusBillingCountryText: result?.countryText || '', plusBillingCountryText: result?.countryText || '',
}); });
} }
+9 -2
View File
@@ -16,10 +16,17 @@
await addLog(`步骤 5:已生成姓名 ${firstName} ${lastName},生日 ${year}-${month}-${day}`); await addLog(`步骤 5:已生成姓名 ${firstName} ${lastName},生日 ${year}-${month}-${day}`);
await sendToContentScript('signup-page', { await sendToContentScript('signup-page', {
type: 'EXECUTE_STEP', type: 'EXECUTE_NODE',
nodeId: 'fill-profile',
step: 5, step: 5,
source: 'background', source: 'background',
payload: { firstName, lastName, year, month, day }, payload: {
firstName,
lastName,
year,
month,
day,
},
}); });
} }
+2 -2
View File
@@ -17,7 +17,7 @@
const { const {
addLog, addLog,
chrome, chrome,
completeStepFromBackground, completeNodeFromBackground,
ensureContentScriptReadyOnTabUntilStopped, ensureContentScriptReadyOnTabUntilStopped,
getTabId, getTabId,
isTabAlive, isTabAlive,
@@ -1433,7 +1433,7 @@
} }
await setState({ plusGoPayApprovedAt: Date.now() }); await setState({ plusGoPayApprovedAt: Date.now() });
await completeStepFromBackground(8, { await completeNodeFromBackground('paypal-approve', {
plusGoPayApprovedAt: Date.now(), plusGoPayApprovedAt: Date.now(),
}); });
} }
+5 -4
View File
@@ -4,7 +4,7 @@
function createStep7Executor(deps = {}) { function createStep7Executor(deps = {}) {
const { const {
addLog, addLog,
completeStepFromBackground, completeNodeFromBackground,
getErrorMessage, getErrorMessage,
getLoginAuthStateLabel, getLoginAuthStateLabel,
getOAuthFlowStepTimeoutMs, getOAuthFlowStepTimeoutMs,
@@ -131,7 +131,7 @@
step: completionStep, step: completionStep,
visibleStep: completionStep, visibleStep: completionStep,
}); });
await completeStepFromBackground(completionStep, { await completeNodeFromBackground(state?.nodeId || 'oauth-login', {
loginVerificationRequestedAt: null, loginVerificationRequestedAt: null,
skipLoginVerificationStep: true, skipLoginVerificationStep: true,
directOAuthConsentPage: true, directOAuthConsentPage: true,
@@ -225,7 +225,8 @@
const result = await sendToContentScriptResilient( const result = await sendToContentScriptResilient(
'signup-page', 'signup-page',
{ {
type: 'EXECUTE_STEP', type: 'EXECUTE_NODE',
nodeId: 'oauth-login',
step: 7, step: 7,
source: 'background', source: 'background',
payload: { payload: {
@@ -277,7 +278,7 @@
completionPayload.directOAuthConsentPage = Boolean(result.directOAuthConsentPage); completionPayload.directOAuthConsentPage = Boolean(result.directOAuthConsentPage);
} }
await completeStepFromBackground(completionStep, completionPayload); await completeNodeFromBackground(state?.nodeId || 'oauth-login', completionPayload);
return; return;
} }
+2 -2
View File
@@ -102,7 +102,7 @@
const { const {
addLog, addLog,
chrome: chromeApi = globalThis.chrome, chrome: chromeApi = globalThis.chrome,
completeStepFromBackground, completeNodeFromBackground,
openSignupEntryTab, openSignupEntryTab,
} = deps; } = deps;
@@ -139,7 +139,7 @@
await clearOpenAiCookiesBeforeStep1(); await clearOpenAiCookiesBeforeStep1();
await addLog('步骤 1:正在打开 ChatGPT 官网...'); await addLog('步骤 1:正在打开 ChatGPT 官网...');
await openSignupEntryTab(1); await openSignupEntryTab(1);
await completeStepFromBackground(1, {}); await completeNodeFromBackground('open-chatgpt', {});
} }
return { executeStep1 }; return { executeStep1 };
+2 -2
View File
@@ -11,7 +11,7 @@
const { const {
addLog, addLog,
chrome, chrome,
completeStepFromBackground, completeNodeFromBackground,
ensureContentScriptReadyOnTabUntilStopped, ensureContentScriptReadyOnTabUntilStopped,
getTabId, getTabId,
isTabAlive, isTabAlive,
@@ -294,7 +294,7 @@
await sleepWithStop(500); await sleepWithStop(500);
} }
await completeStepFromBackground(8, { await completeNodeFromBackground('paypal-approve', {
plusPaypalApprovedAt: Date.now(), plusPaypalApprovedAt: Date.now(),
}); });
} }
+5 -5
View File
@@ -6,7 +6,7 @@
addLog, addLog,
chrome, chrome,
closeConflictingTabsForSource, closeConflictingTabsForSource,
completeStepFromBackground, completeNodeFromBackground,
ensureContentScriptReadyOnTab, ensureContentScriptReadyOnTab,
getPanelMode, getPanelMode,
getTabId, getTabId,
@@ -253,7 +253,7 @@
if (shouldBypassStep9ForLocalCpa(state)) { if (shouldBypassStep9ForLocalCpa(state)) {
await addStepLog(platformVerifyStep, '检测到本地 CPA,且当前策略为“跳过平台回调验证”,本轮不再重复提交回调地址。', 'info'); await addStepLog(platformVerifyStep, '检测到本地 CPA,且当前策略为“跳过平台回调验证”,本轮不再重复提交回调地址。', 'info');
await completeStepFromBackground(platformVerifyStep, { await completeNodeFromBackground(state?.nodeId || 'platform-verify', {
localhostUrl: state.localhostUrl, localhostUrl: state.localhostUrl,
verifiedStatus: 'local-auto', verifiedStatus: 'local-auto',
}); });
@@ -286,7 +286,7 @@
|| normalizeString(result?.status_message) || normalizeString(result?.status_message)
|| 'CPA 已通过接口提交回调'; || 'CPA 已通过接口提交回调';
await addStepLog(platformVerifyStep, verifiedStatus, 'ok'); await addStepLog(platformVerifyStep, verifiedStatus, 'ok');
await completeStepFromBackground(platformVerifyStep, { await completeNodeFromBackground(state?.nodeId || 'platform-verify', {
localhostUrl: callback.url, localhostUrl: callback.url,
verifiedStatus, verifiedStatus,
}); });
@@ -336,7 +336,7 @@
const verifiedStatus = normalizeString(result?.message) || 'Codex2API OAuth 账号添加成功'; const verifiedStatus = normalizeString(result?.message) || 'Codex2API OAuth 账号添加成功';
await addStepLog(platformVerifyStep, verifiedStatus, 'ok'); await addStepLog(platformVerifyStep, verifiedStatus, 'ok');
await completeStepFromBackground(platformVerifyStep, { await completeNodeFromBackground(state?.nodeId || 'platform-verify', {
localhostUrl: callback.url, localhostUrl: callback.url,
verifiedStatus, verifiedStatus,
}); });
@@ -381,7 +381,7 @@
logOptions: { step: visibleStep, stepKey: 'platform-verify' }, logOptions: { step: visibleStep, stepKey: 'platform-verify' },
timeoutMs: SUB2API_STEP9_RESPONSE_TIMEOUT_MS, timeoutMs: SUB2API_STEP9_RESPONSE_TIMEOUT_MS,
}); });
await completeStepFromBackground(platformVerifyStep, result); await completeNodeFromBackground(state?.nodeId || 'platform-verify', result);
return; return;
} catch (error) { } catch (error) {
lastError = error; lastError = error;
+2 -2
View File
@@ -9,7 +9,7 @@
function createPlusReturnConfirmExecutor(deps = {}) { function createPlusReturnConfirmExecutor(deps = {}) {
const { const {
addLog, addLog,
completeStepFromBackground, completeNodeFromBackground,
getTabId, getTabId,
isTabAlive, isTabAlive,
setState, setState,
@@ -53,7 +53,7 @@
plusCheckoutTabId: tabId, plusCheckoutTabId: tabId,
plusReturnUrl: tab?.url || '', plusReturnUrl: tab?.url || '',
}); });
await completeStepFromBackground(9, { await completeNodeFromBackground('plus-checkout-return', {
plusReturnUrl: tab?.url || '', plusReturnUrl: tab?.url || '',
}); });
} }
+20 -19
View File
@@ -1,48 +1,49 @@
(function attachBackgroundStepRegistry(root, factory) { (function attachBackgroundStepRegistry(root, factory) {
root.MultiPageBackgroundStepRegistry = factory(); root.MultiPageBackgroundStepRegistry = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundStepRegistryModule() { })(typeof self !== 'undefined' ? self : globalThis, function createBackgroundStepRegistryModule() {
function createStepRegistry(definitions = []) { function createNodeRegistry(definitions = []) {
const ordered = (Array.isArray(definitions) ? definitions : []) const ordered = (Array.isArray(definitions) ? definitions : [])
.map((definition) => ({ .map((definition) => ({
id: Number(definition?.id), nodeId: String(definition?.nodeId || definition?.key || '').trim(),
order: Number(definition?.order), displayOrder: Number(definition?.displayOrder ?? definition?.order),
key: String(definition?.key || '').trim(), executeKey: String(definition?.executeKey || definition?.key || definition?.nodeId || '').trim(),
title: String(definition?.title || '').trim(), title: String(definition?.title || '').trim(),
execute: definition?.execute, execute: definition?.execute,
})) }))
.filter((definition) => Number.isFinite(definition.id) && typeof definition.execute === 'function') .filter((definition) => definition.nodeId && typeof definition.execute === 'function')
.sort((left, right) => { .sort((left, right) => {
const leftOrder = Number.isFinite(left.order) ? left.order : left.id; const leftOrder = Number.isFinite(left.displayOrder) ? left.displayOrder : 0;
const rightOrder = Number.isFinite(right.order) ? right.order : right.id; const rightOrder = Number.isFinite(right.displayOrder) ? right.displayOrder : 0;
return leftOrder - rightOrder; 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) { function getNodeDefinition(nodeId) {
return byId.get(Number(step)) || null; return byId.get(String(nodeId || '').trim()) || null;
} }
function getOrderedSteps() { function getOrderedNodes() {
return ordered.slice(); return ordered.slice();
} }
function executeStep(step, state) { function executeNode(nodeId, state) {
const definition = getStepDefinition(step); const definition = getNodeDefinition(nodeId);
if (!definition) { if (!definition) {
throw new Error(`未知步骤${step}`); throw new Error(`未知节点${nodeId}`);
} }
return definition.execute(state); return definition.execute(state);
} }
return { return {
executeStep, executeNode,
getOrderedSteps, getNodeDefinition,
getStepDefinition, getOrderedNodes,
}; };
} }
return { return {
createStepRegistry, createNodeRegistry,
}; };
}); });
+5 -4
View File
@@ -5,7 +5,7 @@
const { const {
addLog, addLog,
chrome, chrome,
completeStepFromBackground, completeNodeFromBackground,
ensureContentScriptReadyOnTab, ensureContentScriptReadyOnTab,
ensureSignupAuthEntryPageReady, ensureSignupAuthEntryPageReady,
ensureSignupEntryPageReady, ensureSignupEntryPageReady,
@@ -152,7 +152,8 @@
try { try {
return await sendToContentScriptResilient('signup-page', { return await sendToContentScriptResilient('signup-page', {
type: 'EXECUTE_STEP', type: 'EXECUTE_NODE',
nodeId: 'submit-signup-email',
step: 2, step: 2,
source: 'background', source: 'background',
payload, payload,
@@ -391,7 +392,7 @@
skipUrlWait: Boolean(step2Result?.alreadyOnPasswordPage), skipUrlWait: Boolean(step2Result?.alreadyOnPasswordPage),
}); });
await completeStepFromBackground(2, { await completeNodeFromBackground('submit-signup-email', {
accountIdentifierType: 'phone', accountIdentifierType: 'phone',
accountIdentifier: phoneNumber, accountIdentifier: phoneNumber,
signupPhoneNumber: phoneNumber, signupPhoneNumber: phoneNumber,
@@ -484,7 +485,7 @@
skipUrlWait: Boolean(step2Result?.alreadyOnPasswordPage), skipUrlWait: Boolean(step2Result?.alreadyOnPasswordPage),
}); });
await completeStepFromBackground(2, { await completeNodeFromBackground('submit-signup-email', {
email: resolvedEmail, email: resolvedEmail,
accountIdentifierType: 'email', accountIdentifierType: 'email',
accountIdentifier: resolvedEmail, accountIdentifier: resolvedEmail,
@@ -99,7 +99,7 @@
const { const {
addLog = async () => {}, addLog = async () => {},
chrome: chromeApi = globalThis.chrome, chrome: chromeApi = globalThis.chrome,
completeStepFromBackground, completeNodeFromBackground,
getErrorMessage = (error) => error?.message || String(error || '未知错误'), getErrorMessage = (error) => error?.message || String(error || '未知错误'),
registrationSuccessWaitMs = DEFAULT_REGISTRATION_SUCCESS_WAIT_MS, registrationSuccessWaitMs = DEFAULT_REGISTRATION_SUCCESS_WAIT_MS,
sleepWithStop = async (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms) || 0))), sleepWithStop = async (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms) || 0))),
@@ -149,7 +149,7 @@
} }
await clearCookiesIfEnabled(state); await clearCookiesIfEnabled(state);
await addLog('步骤 6:注册成功等待完成,准备继续获取 OAuth 链接并登录。', 'ok'); await addLog('步骤 6:注册成功等待完成,准备继续获取 OAuth 链接并登录。', 'ok');
await completeStepFromBackground(6); await completeNodeFromBackground('wait-registration-success');
} }
return { executeStep6 }; return { executeStep6 };
+1 -1
View File
@@ -593,7 +593,7 @@
function getContentScriptResponseTimeoutMs(message) { function getContentScriptResponseTimeoutMs(message) {
if (!message || typeof message !== 'object') return 30000; 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') { if (message.type === 'POLL_EMAIL') {
const maxAttempts = Math.max(1, Number(message.payload?.maxAttempts) || 1); const maxAttempts = Math.max(1, Number(message.payload?.maxAttempts) || 1);
const intervalMs = Math.max(0, Number(message.payload?.intervalMs) || 0); const intervalMs = Math.max(0, Number(message.payload?.intervalMs) || 0);
+20 -3
View File
@@ -12,8 +12,9 @@
closeConflictingTabsForSource, closeConflictingTabsForSource,
CLOUDFLARE_TEMP_EMAIL_PROVIDER, CLOUDFLARE_TEMP_EMAIL_PROVIDER,
CLOUD_MAIL_PROVIDER = 'cloudmail', CLOUD_MAIL_PROVIDER = 'cloudmail',
completeStepFromBackground, completeNodeFromBackground,
confirmCustomVerificationStepBypassRequest, confirmCustomVerificationStepBypassRequest,
getNodeIdByStepForState,
getHotmailVerificationPollConfig, getHotmailVerificationPollConfig,
getHotmailVerificationRequestTimestamp, getHotmailVerificationRequestTimestamp,
handleMail2925LimitReachedError, handleMail2925LimitReachedError,
@@ -32,6 +33,7 @@
sendToContentScript, sendToContentScript,
sendToContentScriptResilient, sendToContentScriptResilient,
sendToMailContentScriptResilient, sendToMailContentScriptResilient,
setNodeStatus,
setState, setState,
sleepWithStop, sleepWithStop,
throwIfStopped, throwIfStopped,
@@ -65,6 +67,13 @@
return rawAddLog(normalizeVerificationLogMessage(message), level, normalizedOptions); 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' const isRetryableVerificationTransportError = typeof deps.isRetryableContentScriptTransportError === 'function'
? deps.isRetryableContentScriptTransportError ? 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( : ((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, signupVerificationRequestedAt: null,
loginVerificationRequestedAt: 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'); await addLog(`步骤 ${completionStep}:已确认手动完成${verificationLabel}验证码输入,当前步骤已跳过。`, 'warn');
} }
@@ -1387,7 +1400,11 @@
[stateKey]: result.code, [stateKey]: result.code,
}); });
await completeStepFromBackground(completionStep, { const completionNodeId = await getNodeIdForStep(completionStep);
if (!completionNodeId) {
throw new Error(`步骤 ${completionStep} 未映射到验证码节点。`);
}
await completeNodeFromBackground(completionNodeId, {
emailTimestamp: result.emailTimestamp, emailTimestamp: result.emailTimestamp,
code: result.code, code: result.code,
phoneVerificationRequired: Boolean(submitResult.addPhonePage), 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 // Listen for commands from Background
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if ( if (
message.type === 'EXECUTE_STEP' message.type === 'EXECUTE_NODE'
|| message.type === 'FILL_CODE' || message.type === 'FILL_CODE'
|| message.type === 'STEP8_FIND_AND_CLICK' || message.type === 'STEP8_FIND_AND_CLICK'
|| message.type === 'STEP8_GET_STATE' || message.type === 'STEP8_GET_STATE'
@@ -46,6 +46,7 @@ if (document.documentElement.getAttribute(SIGNUP_PAGE_LISTENER_SENTINEL) !== '1'
sendResponse({ ok: true, ...(result || {}) }); sendResponse({ ok: true, ...(result || {}) });
}).catch(err => { }).catch(err => {
const reportedStep = Number(message.payload?.visibleStep) || message.step; const reportedStep = Number(message.payload?.visibleStep) || message.step;
const reportedNodeId = resolveCommandNodeId(message);
if (isStopError(err)) { if (isStopError(err)) {
if (reportedStep) { if (reportedStep) {
log(`步骤 ${reportedStep || 8}:已被用户停止。`, 'warn'); log(`步骤 ${reportedStep || 8}:已被用户停止。`, 'warn');
@@ -61,7 +62,7 @@ if (document.documentElement.getAttribute(SIGNUP_PAGE_LISTENER_SENTINEL) !== '1'
} }
if (reportedStep) { if (reportedStep) {
reportError(reportedStep, err.message); reportError(reportedNodeId || reportedStep, err.message);
} }
sendResponse({ error: err.message }); sendResponse({ error: err.message });
}); });
@@ -72,17 +73,40 @@ if (document.documentElement.getAttribute(SIGNUP_PAGE_LISTENER_SENTINEL) !== '1'
console.log('[MultiPage:signup-page] 消息监听已存在,跳过重复注册'); 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) { async function handleCommand(message) {
switch (message.type) { switch (message.type) {
case 'EXECUTE_STEP': case 'EXECUTE_NODE': {
switch (message.step) { const nodeId = String(message.nodeId || message.payload?.nodeId || '').trim();
case 2: return await step2_clickRegister(message.payload); const handler = SIGNUP_PAGE_NODE_HANDLERS[nodeId];
case 3: return await step3_fillEmailPassword(message.payload); if (!handler) {
case 5: return await step5_fillNameBirthday(message.payload); throw new Error(`signup-page.js 不处理节点 ${nodeId}`);
case 7: return await step6_login(message.payload);
case 9: return await step8_findAndClick();
default: throw new Error(`signup-page.js 不处理步骤 ${message.step}`);
} }
return await handler(message.payload || {});
}
case 'FILL_CODE': case 'FILL_CODE':
// Step 4 = signup code, Step 7 = login code (same handler) // Step 4 = signup code, Step 7 = login code (same handler)
return await fillVerificationCode(message.step, message.payload); return await fillVerificationCode(message.step, message.payload);
+18 -7
View File
@@ -14,23 +14,23 @@ if (document.documentElement.getAttribute(SUB2API_PANEL_LISTENER_SENTINEL) !== '
document.documentElement.setAttribute(SUB2API_PANEL_LISTENER_SENTINEL, '1'); document.documentElement.setAttribute(SUB2API_PANEL_LISTENER_SENTINEL, '1');
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 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(); resetStopState();
const handler = message.type === 'REQUEST_OAUTH_URL' const handler = message.type === 'REQUEST_OAUTH_URL'
? requestOAuthUrl(message.payload) ? requestOAuthUrl(message.payload)
: handleStep(message.step, message.payload); : handleNode(message.nodeId || message.payload?.nodeId, message.payload);
handler.then((result) => { handler.then((result) => {
sendResponse({ ok: true, ...(result || {}) }); sendResponse({ ok: true, ...(result || {}) });
}).catch((err) => { }).catch((err) => {
if (isStopError(err)) { if (isStopError(err)) {
if (message.step) { if (message.payload?.visibleStep || message.step) {
log('已被用户停止。', 'warn', { step: message.step }); log('已被用户停止。', 'warn', { step: message.payload?.visibleStep || message.step });
} }
sendResponse({ stopped: true, error: err.message }); sendResponse({ stopped: true, error: err.message });
return; return;
} }
if (message.step) { if (message.nodeId || message.payload?.nodeId) {
reportError(message.step, err.message); reportError(message.nodeId || message.payload?.nodeId, err.message);
} }
sendResponse({ error: 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 = {}) { async function requestOAuthUrl(payload = {}) {
return step1_generateOpenAiAuthUrl(payload, { report: false }); return step1_generateOpenAiAuthUrl(payload, { report: false });
} }
@@ -665,9 +675,10 @@ async function step9_submitOpenAiCallback(payload = {}) {
const verifiedStatus = `SUB2API 已创建账号 #${createdAccount?.id || 'unknown'}`; const verifiedStatus = `SUB2API 已创建账号 #${createdAccount?.id || 'unknown'}`;
log(verifiedStatus, 'ok', { step: visibleStep, stepKey: 'platform-verify' }); log(verifiedStatus, 'ok', { step: visibleStep, stepKey: 'platform-verify' });
reportComplete(visibleStep, { reportComplete('platform-verify', {
localhostUrl: callback.url, localhostUrl: callback.url,
verifiedStatus, verifiedStatus,
visibleStep,
}); });
openAccountsPageSoon(origin); openAccountsPageSoon(origin);
} }
+117 -20
View File
@@ -274,6 +274,35 @@ function normalizeLogStep(value) {
return step > 0 ? step : null; 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. * Send a log message to Side Panel via Background.
* @param {string} message * @param {string} message
@@ -323,30 +352,65 @@ function reportReady() {
} }
/** /**
* Report step completion. * Report node completion.
* @param {number} step * @param {string|number} stepOrNodeId
* @param {Object} data - Step output data * @param {Object} data - Node output data
*/ */
function reportComplete(step, data = {}) { function reportComplete(stepOrNodeId, data = {}) {
console.log(LOG_PREFIX, `步骤 ${step} 已完成`, data); const nodeId = resolveReportNodeId(stepOrNodeId, data);
log('已成功完成', 'ok', { step }); const step = normalizeLogStep(stepOrNodeId || data?.step || data?.visibleStep);
console.log(LOG_PREFIX, `节点 ${nodeId || stepOrNodeId} 已完成`, data);
log('已成功完成', 'ok', { step, stepKey: nodeId });
const message = { const message = {
type: 'STEP_COMPLETE', type: 'NODE_COMPLETE',
source: getRuntimeScriptSource(), source: getRuntimeScriptSource(),
step, nodeId,
payload: data, payload: {
...(data || {}),
...(nodeId ? { nodeId } : {}),
...(step ? { step } : {}),
},
error: null, error: null,
}; };
Promise.resolve(chrome.runtime.sendMessage(message)) Promise.resolve(chrome.runtime.sendMessage(message))
.then((response) => { .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, response,
url: location.href, url: location.href,
payloadKeys: Object.keys(data || {}), payloadKeys: Object.keys(data || {}),
}); });
}) })
.catch((err) => { .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, url: location.href,
payloadKeys: Object.keys(data || {}), payloadKeys: Object.keys(data || {}),
}); });
@@ -354,29 +418,62 @@ function reportComplete(step, data = {}) {
} }
/** /**
* Report step error. * Report node error.
* @param {number} step * @param {string|number} stepOrNodeId
* @param {string} errorMessage * @param {string} errorMessage
*/ */
function reportError(step, errorMessage) { function reportError(stepOrNodeId, errorMessage) {
console.error(LOG_PREFIX, `步骤 ${step} 失败: ${errorMessage}`); const nodeId = resolveReportNodeId(stepOrNodeId);
const step = normalizeLogStep(stepOrNodeId);
console.error(LOG_PREFIX, `节点 ${nodeId || stepOrNodeId} 失败: ${errorMessage}`);
const message = { const message = {
type: 'STEP_ERROR', type: 'NODE_ERROR',
source: getRuntimeScriptSource(), source: getRuntimeScriptSource(),
step, nodeId,
payload: {}, payload: {
...(nodeId ? { nodeId } : {}),
...(step ? { step } : {}),
},
error: errorMessage, error: errorMessage,
}; };
Promise.resolve(chrome.runtime.sendMessage(message)) Promise.resolve(chrome.runtime.sendMessage(message))
.then((response) => { .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, response,
url: location.href, url: location.href,
errorMessage, errorMessage,
}); });
}) })
.catch((err) => { .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, url: location.href,
errorMessage, 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) // Injected on: CPA panel (user-configured URL)
// //
// Actual DOM structure (after login click): // Actual DOM structure (after login click):
@@ -39,12 +39,12 @@ if (document.documentElement.getAttribute(VPS_PANEL_LISTENER_SENTINEL) !== '1')
// Listen for commands from Background // Listen for commands from Background
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 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(); resetStopState();
const startedAt = Date.now(); const startedAt = Date.now();
const actionLabel = message.type === 'REQUEST_OAUTH_URL' const actionLabel = message.type === 'REQUEST_OAUTH_URL'
? '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, { console.log(LOG_PREFIX, actionLabel, {
url: location.href, url: location.href,
payloadKeys: Object.keys(message.payload || {}), 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' const handler = message.type === 'REQUEST_OAUTH_URL'
? requestOAuthUrl(message.payload) ? requestOAuthUrl(message.payload)
: handleStep(message.step, message.payload); : handleNode(message.nodeId || message.payload?.nodeId, message.payload);
handler.then((result) => { handler.then((result) => {
console.log(LOG_PREFIX, `${actionLabel} resolved after ${Date.now() - startedAt}ms`, { console.log(LOG_PREFIX, `${actionLabel} resolved after ${Date.now() - startedAt}ms`, {
url: location.href, url: location.href,
@@ -65,14 +65,14 @@ if (document.documentElement.getAttribute(VPS_PANEL_LISTENER_SENTINEL) !== '1')
snapshot: getVpsPanelSnapshot(), snapshot: getVpsPanelSnapshot(),
}); });
if (isStopError(err)) { if (isStopError(err)) {
if (message.step) { if (message.payload?.visibleStep || message.step) {
log('已被用户停止。', 'warn', { step: message.step }); log('已被用户停止。', 'warn', { step: message.payload?.visibleStep || message.step });
} }
sendResponse({ stopped: true, error: err.message }); sendResponse({ stopped: true, error: err.message });
return; return;
} }
if (message.step) { if (message.nodeId || message.payload?.nodeId) {
reportError(message.step, err.message); reportError(message.nodeId || message.payload?.nodeId, err.message);
} }
sendResponse({ error: 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) { function isVisibleElement(el) {
if (!el) return false; if (!el) return false;
const style = window.getComputedStyle(el); const style = window.getComputedStyle(el);
@@ -1078,5 +1088,5 @@ async function step9_vpsVerify(payload) {
const verifiedStatus = await waitForExactSuccessBadge(STEP9_SUCCESS_BADGE_TIMEOUT_MS, visibleStep); const verifiedStatus = await waitForExactSuccessBadge(STEP9_SUCCESS_BADGE_TIMEOUT_MS, visibleStep);
log(verifiedStatus, 'ok', { step: visibleStep, stepKey: 'platform-verify' }); 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 SIGNUP_METHOD_PHONE = 'phone';
const NORMAL_STEP_DEFINITIONS = [ const NORMAL_STEP_DEFINITIONS = [
{ id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网' }, { 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: '注册并输入邮箱' }, { 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: '填写密码并继续' }, { 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: '获取注册验证码' }, { 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: '填写姓名和生日' }, { 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: '等待注册成功' }, { 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 并登录' }, { 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: '获取登录验证码' }, { 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' }, { 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: '平台回调验证' }, { id: 10, order: 100, key: 'platform-verify', title: '平台回调验证', sourceId: 'platform-panel', driverId: 'content/platform-panel', command: 'platform-verify' },
]; ];
const PLUS_PAYPAL_STEP_DEFINITIONS = [ const PLUS_PAYPAL_STEP_DEFINITIONS = [
{ id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网' }, { 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: '注册并输入邮箱' }, { 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: '填写密码并继续' }, { 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: '获取注册验证码' }, { 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: '填写姓名和生日' }, { 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' }, { 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: '填写账单并提交订单' }, { 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 登录与授权' }, { 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: '订阅回跳确认' }, { 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 并登录' }, { 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: '获取登录验证码' }, { 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' }, { 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: '平台回调验证' }, { id: 13, order: 130, key: 'platform-verify', title: '平台回调验证', sourceId: 'platform-panel', driverId: 'content/platform-panel', command: 'platform-verify' },
]; ];
const PLUS_GOPAY_STEP_DEFINITIONS = [ const PLUS_GOPAY_STEP_DEFINITIONS = [
{ id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网' }, { 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: '注册并输入邮箱' }, { 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: '填写密码并继续' }, { 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: '获取注册验证码' }, { 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: '填写姓名和生日' }, { 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 订阅页' }, { 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 订阅确认' }, { 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 并登录' }, { 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: '获取登录验证码' }, { 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' }, { 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: '平台回调验证' }, { id: 13, order: 130, key: 'platform-verify', title: '平台回调验证', sourceId: 'platform-panel', driverId: 'content/platform-panel', command: 'platform-verify' },
]; ];
const PLUS_GPC_STEP_DEFINITIONS = [ const PLUS_GPC_STEP_DEFINITIONS = [
{ id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网' }, { 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: '注册并输入邮箱' }, { 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: '填写密码并继续' }, { 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: '获取注册验证码' }, { 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: '填写姓名和生日' }, { 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 订单' }, { 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 任务完成' }, { 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 并登录' }, { 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: '获取登录验证码' }, { 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' }, { 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: '平台回调验证' }, { 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({ 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 = {}) { function getSteps(options = {}) {
const { flowId, builder } = getFlowDefinitionBuilder(options); const { flowId, builder } = getFlowDefinitionBuilder(options);
if (!builder?.getModeStepDefinitions) { if (!builder?.getModeStepDefinitions) {
@@ -194,6 +215,23 @@
return cloneSteps(builder.getModeStepDefinitions(options), options, flowId); 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 = {}) { function getAllSteps(options = {}) {
const { flowId, builder } = getFlowDefinitionBuilder(options); const { flowId, builder } = getFlowDefinitionBuilder(options);
if (!builder?.getAllSteps) { if (!builder?.getAllSteps) {
@@ -202,6 +240,18 @@
return cloneSteps(builder.getAllSteps(options), options, flowId); 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 = {}) { function getPlusPaymentStepTitle(options = {}) {
const { builder } = getFlowDefinitionBuilder(options); const { builder } = getFlowDefinitionBuilder(options);
if (!builder?.getPlusPaymentStepTitle) { if (!builder?.getPlusPaymentStepTitle) {
@@ -217,6 +267,10 @@
.sort((left, right) => left - right); .sort((left, right) => left - right);
} }
function getNodeIds(options = {}) {
return getNodes(options).map((node) => node.nodeId);
}
function getLastStepId(options = {}) { function getLastStepId(options = {}) {
const ids = getStepIds(options); const ids = getStepIds(options);
return ids[ids.length - 1] || 0; return ids[ids.length - 1] || 0;
@@ -232,6 +286,33 @@
return match ? cloneSteps([match], options, flowId)[0] : null; 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 { return {
DEFAULT_ACTIVE_FLOW_ID, DEFAULT_ACTIVE_FLOW_ID,
STEP_DEFINITIONS: NORMAL_STEP_DEFINITIONS, STEP_DEFINITIONS: NORMAL_STEP_DEFINITIONS,
@@ -243,12 +324,18 @@
SIGNUP_METHOD_EMAIL, SIGNUP_METHOD_EMAIL,
SIGNUP_METHOD_PHONE, SIGNUP_METHOD_PHONE,
getAllSteps, getAllSteps,
getAllNodes,
getLastStepId, getLastStepId,
getNodeByDisplayOrder,
getNodeById,
getNodeIds,
getNodes,
getPlusPaymentStepTitle, getPlusPaymentStepTitle,
getRegisteredFlowIds, getRegisteredFlowIds,
getStepById, getStepById,
getStepIds, getStepIds,
getSteps, getSteps,
getWorkflow,
hasFlow, hasFlow,
isPlusModeEnabled, isPlusModeEnabled,
normalizeActiveFlowId, 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() { })(typeof self !== 'undefined' ? self : globalThis, function createOpenAiMailRulesModule() {
const SIGNUP_CODE_RULE_ID = 'openai-signup-code'; const SIGNUP_CODE_RULE_ID = 'openai-signup-code';
const LOGIN_CODE_RULE_ID = 'openai-login-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([ const OPENAI_CODE_PATTERNS = Object.freeze([
Object.freeze({ Object.freeze({
source: '(?:chatgpt\\s+log-?in\\s+code|enter\\s+this\\s+code)[^0-9]{0,24}(\\d{6})', 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'; && String(state?.mail2925Mode || '').trim().toLowerCase() === 'receive';
} }
function getRuleDefinition(step, state = {}) { function resolveVerificationNodeId(input) {
const normalizedStep = Number(step) === 4 ? 4 : 8; 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 mail2925Provider = isMail2925Provider(state);
const signupStep = normalizedStep === 4; const signupStep = nodeId === SIGNUP_CODE_NODE_ID;
const targetEmail = signupStep const targetEmail = signupStep
? state?.email ? state?.email
: (String(state?.step8VerificationTargetEmail || '').trim() || state?.email); : (String(state?.step8VerificationTargetEmail || '').trim() || state?.email);
@@ -67,6 +86,7 @@
return { return {
flowId: 'openai', flowId: 'openai',
ruleId: signupStep ? SIGNUP_CODE_RULE_ID : LOGIN_CODE_RULE_ID, ruleId: signupStep ? SIGNUP_CODE_RULE_ID : LOGIN_CODE_RULE_ID,
nodeId,
step: normalizedStep, step: normalizedStep,
artifactType: 'code', artifactType: 'code',
codePatterns: OPENAI_CODE_PATTERNS, 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 { return {
...getRuleDefinition(step, state), ...getRuleDefinition(input, state),
...(overrides || {}), ...(overrides || {}),
}; };
} }
function buildVerificationPollPayloadForNode(nodeId, state = {}, overrides = {}) {
return buildVerificationPollPayload({ nodeId }, state, overrides);
}
return { return {
buildVerificationPollPayload, buildVerificationPollPayload,
buildVerificationPollPayloadForNode,
getRuleDefinition, getRuleDefinition,
getRuleDefinitionForNode,
}; };
} }
return { return {
LOGIN_CODE_RULE_ID, LOGIN_CODE_RULE_ID,
LOGIN_CODE_NODE_ID,
SIGNUP_CODE_RULE_ID, SIGNUP_CODE_RULE_ID,
SIGNUP_CODE_NODE_ID,
createOpenAiMailRules, createOpenAiMailRules,
}; };
}); });
+35 -12
View File
@@ -96,6 +96,15 @@
driverId: 'content/vps-panel', driverId: 'content/vps-panel',
cleanupScopes: [], cleanupScopes: [],
}, },
'platform-panel': {
flowId: 'openai',
kind: 'virtual-page',
label: '平台回调面板',
readyPolicy: 'disabled',
family: 'platform-panel-family',
driverId: 'content/platform-panel',
cleanupScopes: [],
},
'sub2api-panel': { 'sub2api-panel': {
flowId: 'openai', flowId: 'openai',
kind: 'panel-page', kind: 'panel-page',
@@ -156,13 +165,13 @@
'content/signup-page': { 'content/signup-page': {
sourceId: 'openai-auth', sourceId: 'openai-auth',
commands: [ commands: [
'OPEN_SIGNUP', 'submit-signup-email',
'SUBMIT_SIGNUP_IDENTIFIER', 'fill-password',
'SUBMIT_PASSWORD', 'fill-profile',
'SUBMIT_PROFILE', 'oauth-login',
'SUBMIT_LOGIN_CODE', 'submit-verification-code',
'SUBMIT_PHONE_CODE', 'confirm-oauth',
'DETECT_AUTH_STATE', 'detect-auth-state',
], ],
}, },
'content/qq-mail': { 'content/qq-mail': {
@@ -191,23 +200,27 @@
}, },
'content/sub2api-panel': { 'content/sub2api-panel': {
sourceId: 'sub2api-panel', sourceId: 'sub2api-panel',
commands: ['OPEN_PANEL', 'FETCH_OAUTH_URL', 'VERIFY_PLATFORM'], commands: ['open-panel', 'fetch-oauth-url', 'platform-verify'],
}, },
'content/vps-panel': { 'content/vps-panel': {
sourceId: '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': { 'content/plus-checkout': {
sourceId: 'plus-checkout', sourceId: 'plus-checkout',
commands: ['CREATE_CHECKOUT', 'FILL_CHECKOUT'], commands: ['plus-checkout-create', 'plus-checkout-billing', 'plus-checkout-return'],
}, },
'content/paypal-flow': { 'content/paypal-flow': {
sourceId: 'paypal-flow', sourceId: 'paypal-flow',
commands: ['APPROVE_PAYPAL'], commands: ['paypal-approve'],
}, },
'content/gopay-flow': { 'content/gopay-flow': {
sourceId: '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 = '') { function isSignupPageHost(hostname = '') {
return AUTH_PAGE_HOSTS.has(String(hostname || '').toLowerCase()); return AUTH_PAGE_HOSTS.has(String(hostname || '').toLowerCase());
} }
@@ -414,6 +436,7 @@
getCleanupOwnerSource, getCleanupOwnerSource,
getDriverIdForSource, getDriverIdForSource,
getDriverMeta, getDriverMeta,
driverAcceptsCommand,
getSourceKeys, getSourceKeys,
getSourceLabel, getSourceLabel,
getSourceMeta, getSourceMeta,
+296 -90
View File
@@ -543,9 +543,16 @@ let stepDefinitions = getStepDefinitionsForMode(false, {
plusPaymentMethod: currentPlusPaymentMethod, plusPaymentMethod: currentPlusPaymentMethod,
signupMethod: currentSignupMethod, signupMethod: currentSignupMethod,
}); });
let workflowNodes = getWorkflowNodesForMode(false, {
plusPaymentMethod: currentPlusPaymentMethod,
signupMethod: currentSignupMethod,
});
let STEP_IDS = stepDefinitions.map((step) => Number(step.id)).filter(Number.isFinite); 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 STEP_DEFAULT_STATUSES = Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending']));
let SKIPPABLE_STEPS = new Set(STEP_IDS); 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_MIN_MINUTES = 1;
const AUTO_DELAY_MAX_MINUTES = 1440; const AUTO_DELAY_MAX_MINUTES = 1440;
const AUTO_DELAY_DEFAULT_MINUTES = 30; 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 = '') { function getStepIdByKeyForCurrentMode(stepKey = '') {
const normalizedKey = String(stepKey || '').trim(); const normalizedKey = String(stepKey || '').trim();
if (!normalizedKey) { if (!normalizedKey) {
@@ -825,6 +868,29 @@ function getStepIdByKeyForCurrentMode(stepKey = '') {
return Number(match?.id) || 0; 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 = {}) { function rebuildStepDefinitionState(plusModeEnabled = false, options = {}) {
currentPlusModeEnabled = Boolean(plusModeEnabled); currentPlusModeEnabled = Boolean(plusModeEnabled);
const defaultMethod = typeof DEFAULT_PLUS_PAYMENT_METHOD !== 'undefined' ? DEFAULT_PLUS_PAYMENT_METHOD : 'paypal'; const defaultMethod = typeof DEFAULT_PLUS_PAYMENT_METHOD !== 'undefined' ? DEFAULT_PLUS_PAYMENT_METHOD : 'paypal';
@@ -841,9 +907,33 @@ function rebuildStepDefinitionState(plusModeEnabled = false, options = {}) {
plusPaymentMethod: currentPlusPaymentMethod, plusPaymentMethod: currentPlusPaymentMethod,
signupMethod: currentSignupMethod, 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_IDS = stepDefinitions.map((step) => Number(step.id)).filter(Number.isFinite);
STEP_DEFAULT_STATUSES = Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending'])); STEP_DEFAULT_STATUSES = Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending']));
SKIPPABLE_STEPS = new Set(STEP_IDS); 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 CONTRIBUTION_CONTENT_PROMPT_DISMISSED_VERSION_STORAGE_KEY = 'multipage-contribution-content-prompt-dismissed-version';
const AUTO_RUN_FALLBACK_RISK_WARNING_MIN_RUNS = 3; const AUTO_RUN_FALLBACK_RISK_WARNING_MIN_RUNS = 3;
@@ -1189,7 +1279,7 @@ function shouldAttachAutomationWindow(message = {}) {
return false; return false;
} }
return [ return [
'EXECUTE_STEP', 'EXECUTE_NODE',
'AUTO_RUN', 'AUTO_RUN',
'SCHEDULE_AUTO_RUN', 'SCHEDULE_AUTO_RUN',
'RESUME_AUTO_RUN', 'RESUME_AUTO_RUN',
@@ -2111,31 +2201,64 @@ function isDoneStatus(status) {
return status === 'completed' || status === 'manual_completed' || status === 'skipped'; 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) { 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'])); return Object.fromEntries(STEP_IDS.map((stepId) => [stepId, merged[stepId] || 'pending']));
} }
function getFirstUnfinishedStep(state = latestState) { function getFirstUnfinishedNode(state = latestState) {
const statuses = getStepStatuses(state); const statuses = getNodeStatuses(state);
for (const step of STEP_IDS) { for (const nodeId of NODE_IDS) {
if (!isDoneStatus(statuses[step])) { if (!isDoneStatus(statuses[nodeId])) {
return step; return nodeId;
} }
} }
return null; return '';
}
function getFirstUnfinishedStep(state = latestState) {
const nodeId = getFirstUnfinishedNode(state);
return nodeId ? getStepIdByNodeIdForCurrentMode(nodeId) : null;
}
function getRunningNodes(state = latestState) {
const statuses = getNodeStatuses(state);
return Object.entries(statuses)
.filter(([, status]) => status === 'running')
.map(([nodeId]) => nodeId);
} }
function getRunningSteps(state = latestState) { function getRunningSteps(state = latestState) {
const statuses = getStepStatuses(state); return getRunningNodes(state)
return Object.entries(statuses) .map((nodeId) => getStepIdByNodeIdForCurrentMode(nodeId))
.filter(([, status]) => status === 'running') .filter((step) => Number.isInteger(step) && step > 0)
.map(([step]) => Number(step))
.sort((a, b) => a - b); .sort((a, b) => a - b);
} }
function hasSavedProgress(state = latestState) { function hasSavedProgress(state = latestState) {
const statuses = getStepStatuses(state); const statuses = getNodeStatuses(state);
return Object.values(statuses).some((status) => status !== 'pending'); return Object.values(statuses).some((status) => status !== 'pending');
} }
@@ -2150,14 +2273,14 @@ function shouldOfferAutoModeChoice(state = latestState) {
} }
function syncLatestState(nextState) { function syncLatestState(nextState) {
const mergedStepStatuses = nextState?.stepStatuses const mergedNodeStatuses = nextState?.nodeStatuses
? { ...STEP_DEFAULT_STATUSES, ...(latestState?.stepStatuses || {}), ...nextState.stepStatuses } ? { ...NODE_DEFAULT_STATUSES, ...(latestState?.nodeStatuses || {}), ...nextState.nodeStatuses }
: getStepStatuses(latestState); : getNodeStatuses(latestState);
latestState = { latestState = {
...(latestState || {}), ...(latestState || {}),
...(nextState || {}), ...(nextState || {}),
stepStatuses: mergedStepStatuses, nodeStatuses: mergedNodeStatuses,
}; };
renderAccountRecords(latestState); renderAccountRecords(latestState);
@@ -3360,8 +3483,20 @@ function collectSettingsPayload() {
const currentPhoneSmsMaxPriceValue = typeof inputHeroSmsMaxPrice !== 'undefined' && inputHeroSmsMaxPrice const currentPhoneSmsMaxPriceValue = typeof inputHeroSmsMaxPrice !== 'undefined' && inputHeroSmsMaxPrice
? normalizePhoneSmsMaxPriceValue(inputHeroSmsMaxPrice.value, phoneSmsProviderValue) ? 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 const currentPhoneSmsMinPriceValue = typeof inputHeroSmsMinPrice !== 'undefined' && inputHeroSmsMinPrice
? normalizePhoneSmsMinPriceValue(inputHeroSmsMinPrice.value, phoneSmsProviderValue) ? normalizePhoneSmsMinPriceValueSafe(inputHeroSmsMinPrice.value, phoneSmsProviderValue)
: ''; : '';
const heroSmsMaxPriceValue = phoneSmsProviderValue === PHONE_SMS_PROVIDER_HERO_SMS const heroSmsMaxPriceValue = phoneSmsProviderValue === PHONE_SMS_PROVIDER_HERO_SMS
? currentPhoneSmsMaxPriceValue ? currentPhoneSmsMaxPriceValue
@@ -3370,11 +3505,11 @@ function collectSettingsPayload() {
? currentPhoneSmsMaxPriceValue ? currentPhoneSmsMaxPriceValue
: normalizeFiveSimMaxPriceValue(latestState?.fiveSimMaxPrice || ''); : normalizeFiveSimMaxPriceValue(latestState?.fiveSimMaxPrice || '');
const heroSmsMinPriceValue = phoneSmsProviderValue === PHONE_SMS_PROVIDER_FIVE_SIM 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; : currentPhoneSmsMinPriceValue;
const fiveSimMinPriceValue = phoneSmsProviderValue === PHONE_SMS_PROVIDER_FIVE_SIM const fiveSimMinPriceValue = phoneSmsProviderValue === PHONE_SMS_PROVIDER_FIVE_SIM
? currentPhoneSmsMinPriceValue ? 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' const defaultFiveSimProduct = typeof DEFAULT_FIVE_SIM_PRODUCT !== 'undefined'
? DEFAULT_FIVE_SIM_PRODUCT ? DEFAULT_FIVE_SIM_PRODUCT
: 'openai'; : 'openai';
@@ -8623,6 +8758,7 @@ function initializeManualStepActions() {
return; return;
} }
const step = Number(row.dataset.step); const step = Number(row.dataset.step);
const nodeId = String(row.dataset.nodeId || getNodeIdByStepForCurrentMode(step) || '').trim();
const statusEl = row.querySelector('.step-status'); const statusEl = row.querySelector('.step-status');
if (!statusEl) return; if (!statusEl) return;
@@ -8633,13 +8769,14 @@ function initializeManualStepActions() {
manualBtn.type = 'button'; manualBtn.type = 'button';
manualBtn.className = 'step-manual-btn'; manualBtn.className = 'step-manual-btn';
manualBtn.dataset.step = String(step); manualBtn.dataset.step = String(step);
manualBtn.title = '跳过此步'; manualBtn.dataset.nodeId = nodeId;
manualBtn.setAttribute('aria-label', `跳过步骤 ${step}`); 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.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) => { manualBtn.addEventListener('click', async (event) => {
event.stopPropagation(); event.stopPropagation();
try { try {
await handleSkipStep(step); await handleSkipNode(nodeId || getNodeIdByStepForCurrentMode(step));
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
} }
@@ -8654,16 +8791,20 @@ function initializeManualStepActions() {
function renderStepsList() { function renderStepsList() {
if (!stepsList) return; if (!stepsList) return;
stepsList.innerHTML = stepDefinitions.map((step) => ` stepsList.innerHTML = workflowNodes.map((node) => {
<div class="step-row" data-step="${step.id}" data-step-key="${escapeHtml(step.key)}"> const step = getStepIdByNodeIdForCurrentMode(node.nodeId);
<div class="step-indicator" data-step="${step.id}"><span class="step-num">${step.id}</span></div> const nodeId = String(node.nodeId || '').trim();
<button class="step-btn" data-step="${step.id}" data-step-key="${escapeHtml(step.key)}">${escapeHtml(step.title)}</button> return `
<span class="step-status" data-step="${step.id}"></span> <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> </div>
`).join(''); `;
}).join('');
if (stepsProgress) { if (stepsProgress) {
stepsProgress.textContent = `0 / ${STEP_IDS.length}`; stepsProgress.textContent = `0 / ${NODE_IDS.length}`;
} }
initializeManualStepActions(); initializeManualStepActions();
@@ -8728,6 +8869,7 @@ function applySettingsState(state) {
signupMethod: normalizeSignupMethod(state?.signupMethod || DEFAULT_SIGNUP_METHOD), signupMethod: normalizeSignupMethod(state?.signupMethod || DEFAULT_SIGNUP_METHOD),
}; };
syncStepDefinitionsForMode(stepDefinitionState.plusModeEnabled, { syncStepDefinitionsForMode(stepDefinitionState.plusModeEnabled, {
activeFlowId: state?.flowId || state?.activeFlowId,
plusPaymentMethod: state?.plusPaymentMethod, plusPaymentMethod: state?.plusPaymentMethod,
signupMethod: stepDefinitionState.signupMethod, signupMethod: stepDefinitionState.signupMethod,
}); });
@@ -9282,9 +9424,9 @@ async function restoreState() {
displayLocalhostUrl.textContent = state.localhostUrl; displayLocalhostUrl.textContent = state.localhostUrl;
displayLocalhostUrl.classList.add('has-value'); displayLocalhostUrl.classList.add('has-value');
} }
if (state.stepStatuses) { if (state.nodeStatuses) {
for (const [step, status] of Object.entries(state.stepStatuses)) { for (const [nodeId, status] of Object.entries(state.nodeStatuses)) {
updateStepUI(Number(step), status); updateNodeUI(nodeId, status);
} }
} }
@@ -10911,21 +11053,54 @@ function updatePanelModeUI() {
// UI Updates // UI Updates
// ============================================================ // ============================================================
function updateStepUI(step, status) { function updateNodeUI(nodeId, status) {
const normalizedNodeId = String(nodeId || '').trim();
if (!normalizedNodeId) return;
syncLatestState({ syncLatestState({
stepStatuses: { nodeStatuses: {
...getStepStatuses(), ...getNodeStatuses(),
[step]: status, [normalizedNodeId]: status,
}, },
}); });
renderSingleStepStatus(step, status); renderSingleNodeStatus(normalizedNodeId, status);
updateButtonStates(); updateButtonStates();
updateProgressCounter(); updateProgressCounter();
updateConfigMenuControls(); 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) { 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 normalizedStatus = status || 'pending';
const statusEl = document.querySelector(`.step-status[data-step="${step}"]`); const statusEl = document.querySelector(`.step-status[data-step="${step}"]`);
const row = document.querySelector(`.step-row[data-step="${step}"]`); const row = document.querySelector(`.step-row[data-step="${step}"]`);
@@ -10937,20 +11112,32 @@ function renderSingleStepStatus(step, status) {
} }
function renderStepStatuses(state = latestState) { function renderStepStatuses(state = latestState) {
const statuses = getStepStatuses(state); if (typeof getNodeStatuses === 'function' && typeof NODE_IDS !== 'undefined') {
for (const step of STEP_IDS) { const statuses = getNodeStatuses(state);
renderSingleStepStatus(step, statuses[step]); 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(); updateProgressCounter();
} }
function 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; const completed = Object.values(getStepStatuses()).filter(isDoneStatus).length;
stepsProgress.textContent = `${completed} / ${STEP_IDS.length}`; stepsProgress.textContent = `${completed} / ${STEP_IDS.length}`;
} }
function updateButtonStates() { function updateButtonStates() {
const statuses = getStepStatuses(); const statuses = getNodeStatuses();
const anyRunning = Object.values(statuses).some(s => s === 'running'); const anyRunning = Object.values(statuses).some(s => s === 'running');
const autoLocked = isAutoRunLockedPhase(); const autoLocked = isAutoRunLockedPhase();
const autoScheduled = isAutoRunScheduledPhase(); const autoScheduled = isAutoRunScheduledPhase();
@@ -10958,47 +11145,49 @@ function updateButtonStates() {
? selectIcloudTargetMailboxType?.value ? selectIcloudTargetMailboxType?.value
: latestState?.icloudTargetMailboxType; : latestState?.icloudTargetMailboxType;
for (const step of STEP_IDS) { for (const nodeId of NODE_IDS) {
const btn = document.querySelector(`.step-btn[data-step="${step}"]`); const step = getStepIdByNodeIdForCurrentMode(nodeId);
const btn = document.querySelector(`.step-btn[data-node-id="${escapeCssValue(nodeId)}"]`);
if (!btn) continue; if (!btn) continue;
if (anyRunning || autoLocked || autoScheduled) { if (anyRunning || autoLocked || autoScheduled) {
btn.disabled = true; btn.disabled = true;
} else if (step === 1) { } else if (NODE_IDS.indexOf(nodeId) === 0) {
btn.disabled = false; btn.disabled = false;
} else { } else {
const currentIndex = STEP_IDS.indexOf(step); const currentIndex = NODE_IDS.indexOf(nodeId);
const prevStep = currentIndex > 0 ? STEP_IDS[currentIndex - 1] : null; const prevNodeId = currentIndex > 0 ? NODE_IDS[currentIndex - 1] : null;
const prevStatus = prevStep === null ? 'completed' : statuses[prevStep]; const prevStatus = prevNodeId === null ? 'completed' : statuses[prevNodeId];
const currentStatus = statuses[step]; const currentStatus = statuses[nodeId];
btn.disabled = !(isDoneStatus(prevStatus) || currentStatus === 'failed' || isDoneStatus(currentStatus) || currentStatus === 'stopped'); btn.disabled = !(isDoneStatus(prevStatus) || currentStatus === 'failed' || isDoneStatus(currentStatus) || currentStatus === 'stopped');
} }
} }
document.querySelectorAll('.step-manual-btn').forEach((btn) => { document.querySelectorAll('.step-manual-btn').forEach((btn) => {
const step = Number(btn.dataset.step); const step = Number(btn.dataset.step);
const currentStatus = statuses[step]; const nodeId = String(btn.dataset.nodeId || getNodeIdByStepForCurrentMode(step) || '').trim();
const currentIndex = STEP_IDS.indexOf(step); const currentStatus = statuses[nodeId];
const prevStep = currentIndex > 0 ? STEP_IDS[currentIndex - 1] : null; const currentIndex = NODE_IDS.indexOf(nodeId);
const prevStatus = prevStep === null ? 'completed' : statuses[prevStep]; 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.style.display = 'none';
btn.disabled = true; btn.disabled = true;
btn.title = '当前不可跳过'; btn.title = '当前不可跳过';
return; return;
} }
if (prevStep !== null && !isDoneStatus(prevStatus)) { if (prevNodeId !== null && !isDoneStatus(prevStatus)) {
btn.style.display = 'none'; btn.style.display = 'none';
btn.disabled = true; btn.disabled = true;
btn.title = `请先完成步骤 ${prevStep}`; btn.title = `请先完成节点 ${prevNodeId}`;
return; return;
} }
btn.style.display = ''; btn.style.display = '';
btn.disabled = false; btn.disabled = false;
btn.title = `跳过步骤 ${step}`; btn.title = `跳过节点 ${nodeId}`;
}); });
btnReset.disabled = anyRunning || autoScheduled || isAutoRunPausedPhase() || autoLocked; btnReset.disabled = anyRunning || autoScheduled || isAutoRunPausedPhase() || autoLocked;
@@ -11032,9 +11221,10 @@ function updateStopButtonState(active) {
} }
function updateStatusDisplay(state) { function updateStatusDisplay(state) {
if (!state || !state.stepStatuses) return; if (!state || !state.nodeStatuses) return;
statusBar.className = 'status-bar'; statusBar.className = 'status-bar';
const nodeStatuses = getNodeStatuses(state);
const countdown = getActiveAutoRunCountdown(); const countdown = getActiveAutoRunCountdown();
if (countdown) { if (countdown) {
@@ -11064,17 +11254,17 @@ function updateStatusDisplay(state) {
} }
if (isAutoRunWaitingStepPhase()) { if (isAutoRunWaitingStepPhase()) {
const runningSteps = getRunningSteps(state); const runningNodes = getRunningNodes(state);
displayStatus.textContent = runningSteps.length displayStatus.textContent = runningNodes.length
? `自动等待步骤 ${runningSteps.join(', ')} 完成后继续${getAutoRunLabel()}` ? `自动等待节点 ${runningNodes.join(', ')} 完成后继续${getAutoRunLabel()}`
: `自动正在按最新进度准备继续${getAutoRunLabel()}`; : `自动正在按最新进度准备继续${getAutoRunLabel()}`;
statusBar.classList.add('running'); statusBar.classList.add('running');
return; return;
} }
const running = Object.entries(state.stepStatuses).find(([, s]) => s === 'running'); const running = Object.entries(nodeStatuses).find(([, s]) => s === 'running');
if (running) { if (running) {
displayStatus.textContent = `步骤 ${running[0]} 运行中...`; displayStatus.textContent = `节点 ${running[0]} 运行中...`;
statusBar.classList.add('running'); statusBar.classList.add('running');
return; return;
} }
@@ -11085,32 +11275,32 @@ function updateStatusDisplay(state) {
return; return;
} }
const failed = Object.entries(state.stepStatuses).find(([, s]) => s === 'failed'); const failed = Object.entries(nodeStatuses).find(([, s]) => s === 'failed');
if (failed) { if (failed) {
displayStatus.textContent = `步骤 ${failed[0]} 失败`; displayStatus.textContent = `节点 ${failed[0]} 失败`;
statusBar.classList.add('failed'); statusBar.classList.add('failed');
return; return;
} }
const stopped = Object.entries(state.stepStatuses).find(([, s]) => s === 'stopped'); const stopped = Object.entries(nodeStatuses).find(([, s]) => s === 'stopped');
if (stopped) { if (stopped) {
displayStatus.textContent = `步骤 ${stopped[0]} 已停止`; displayStatus.textContent = `节点 ${stopped[0]} 已停止`;
statusBar.classList.add('stopped'); statusBar.classList.add('stopped');
return; return;
} }
const lastCompleted = Object.entries(state.stepStatuses) const lastCompleted = Object.entries(nodeStatuses)
.filter(([, s]) => isDoneStatus(s)) .filter(([, s]) => isDoneStatus(s))
.map(([k]) => Number(k)) .map(([nodeId]) => nodeId)
.sort((a, b) => b - a)[0]; .sort((left, right) => NODE_IDS.indexOf(right) - NODE_IDS.indexOf(left))[0];
if (lastCompleted === STEP_IDS[STEP_IDS.length - 1]) { if (lastCompleted === NODE_IDS[NODE_IDS.length - 1]) {
displayStatus.textContent = (state.stepStatuses[lastCompleted] === 'manual_completed' || state.stepStatuses[lastCompleted] === 'skipped') ? '全部步骤已跳过/完成' : '全部步骤已完成'; displayStatus.textContent = (nodeStatuses[lastCompleted] === 'manual_completed' || nodeStatuses[lastCompleted] === 'skipped') ? '全部节点已跳过/完成' : '全部节点已完成';
statusBar.classList.add('completed'); statusBar.classList.add('completed');
} else if (lastCompleted) { } else if (lastCompleted) {
displayStatus.textContent = (state.stepStatuses[lastCompleted] === 'manual_completed' || state.stepStatuses[lastCompleted] === 'skipped') displayStatus.textContent = (nodeStatuses[lastCompleted] === 'manual_completed' || nodeStatuses[lastCompleted] === 'skipped')
? `步骤 ${lastCompleted} 已跳过` ? `节点 ${lastCompleted} 已跳过`
: `步骤 ${lastCompleted} 已完成`; : `节点 ${lastCompleted} 已完成`;
} else { } else {
displayStatus.textContent = '就绪'; displayStatus.textContent = '就绪';
} }
@@ -11842,7 +12032,11 @@ async function maybeTakeoverAutoRun(actionLabel) {
return true; return true;
} }
async function handleSkipStep(step) { async function handleSkipNode(nodeId) {
const normalizedNodeId = String(nodeId || '').trim();
if (!normalizedNodeId) {
throw new Error('缺少要跳过的节点。');
}
if (isAutoRunPausedPhase()) { if (isAutoRunPausedPhase()) {
const takeoverResponse = await chrome.runtime.sendMessage({ const takeoverResponse = await chrome.runtime.sendMessage({
type: 'TAKEOVER_AUTO_RUN', type: 'TAKEOVER_AUTO_RUN',
@@ -11857,16 +12051,27 @@ async function handleSkipStep(step) {
await persistCurrentSettingsForAction(); await persistCurrentSettingsForAction();
const response = await chrome.runtime.sendMessage({ const response = await chrome.runtime.sendMessage({
type: 'SKIP_STEP', type: 'SKIP_NODE',
source: 'sidepanel', source: 'sidepanel',
payload: { step }, payload: {
nodeId: normalizedNodeId,
step: getStepIdByNodeIdForCurrentMode(normalizedNodeId),
},
}); });
if (response?.error) { if (response?.error) {
throw new Error(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 { try {
const step = Number(btn.dataset.step); 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; return;
} }
await persistCurrentSettingsForAction(); await persistCurrentSettingsForAction();
@@ -11898,12 +12104,12 @@ stepsList?.addEventListener('click', async (event) => {
syncLatestState({ customPassword: inputPassword.value }); syncLatestState({ customPassword: inputPassword.value });
} }
if (shouldExecuteStep3WithSignupPhoneIdentity(latestState)) { 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) { if (response?.error) {
throw new Error(response.error); throw new Error(response.error);
} }
} else if (selectMailProvider.value === 'hotmail-api' || isLuckmailProvider()) { } 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) { if (response?.error) {
throw new Error(response.error); throw new Error(response.error);
} }
@@ -11913,7 +12119,7 @@ stepsList?.addEventListener('click', async (event) => {
showToast(selectMailProvider.value === GMAIL_PROVIDER ? '请先填写 Gmail 原邮箱。' : '请先填写 2925 邮箱前缀。', 'warn'); showToast(selectMailProvider.value === GMAIL_PROVIDER ? '请先填写 Gmail 原邮箱。' : '请先填写 2925 邮箱前缀。', 'warn');
return; 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) { if (response?.error) {
throw new Error(response.error); throw new Error(response.error);
} }
@@ -11934,13 +12140,13 @@ stepsList?.addEventListener('click', async (event) => {
if (!validateCurrentRegistrationEmail(email, { showToastOnFailure: true })) { if (!validateCurrentRegistrationEmail(email, { showToastOnFailure: true })) {
return; 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) { if (response?.error) {
throw new Error(response.error); throw new Error(response.error);
} }
} }
} else { } 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) { if (response?.error) {
throw new Error(response.error); throw new Error(response.error);
} }
@@ -12323,7 +12529,7 @@ btnReset.addEventListener('click', async () => {
await chrome.runtime.sendMessage({ type: 'RESET', source: 'sidepanel' }); await chrome.runtime.sendMessage({ type: 'RESET', source: 'sidepanel' });
syncLatestState({ syncLatestState({
stepStatuses: STEP_DEFAULT_STATUSES, nodeStatuses: NODE_DEFAULT_STATUSES,
currentHotmailAccountId: null, currentHotmailAccountId: null,
currentLuckmailPurchase: null, currentLuckmailPurchase: null,
currentLuckmailMailCursor: null, currentLuckmailMailCursor: null,
@@ -14222,9 +14428,9 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
} }
break; break;
case 'STEP_STATUS_CHANGED': { case 'NODE_STATUS_CHANGED': {
const { step, status } = message.payload; const { nodeId, status } = message.payload;
updateStepUI(step, status); updateNodeUI(nodeId, status);
chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' }).then(state => { chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' }).then(state => {
syncLatestState(state); syncLatestState(state);
syncAutoRunState(state); syncAutoRunState(state);
@@ -14253,7 +14459,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
localhostUrl: null, localhostUrl: null,
email: null, email: null,
password: null, password: null,
stepStatuses: STEP_DEFAULT_STATUSES, nodeStatuses: NODE_DEFAULT_STATUSES,
logs: [], logs: [],
scheduledAutoRunAt: null, scheduledAutoRunAt: null,
autoRunCountdownAt: 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 source = fs.readFileSync('background/auto-run-controller.js', 'utf8');
const globalScope = {}; const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundAutoRunController;`)(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 () => { test('auto-run controller skips add-phone failures to the next round instead of stopping when auto retry is enabled', async () => {
const events = { 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_BETWEEN_ROUNDS = 'between_rounds';
const AUTO_RUN_TIMER_KIND_BEFORE_RETRY = 'before_retry'; const AUTO_RUN_TIMER_KIND_BEFORE_RETRY = 'before_retry';
const STEP_IDS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; 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 = { const DEFAULT_STATE = {
stepStatuses: { stepStatuses: {
1: 'pending', 1: 'pending',
@@ -86,6 +113,7 @@ const DEFAULT_STATE = {
10: 'pending', 10: 'pending',
}, },
}; };
DEFAULT_STATE.nodeStatuses = projectStepStatusesToNodeStatuses(DEFAULT_STATE.stepStatuses);
let stopRequested = false; let stopRequested = false;
let runCalls = 0; let runCalls = 0;
@@ -137,6 +165,10 @@ async function getState() {
return { return {
...currentState, ...currentState,
stepStatuses: { ...(currentState.stepStatuses || {}) }, stepStatuses: { ...(currentState.stepStatuses || {}) },
nodeStatuses: {
...projectStepStatusesToNodeStatuses(currentState.stepStatuses || {}),
...(currentState.nodeStatuses || {}),
},
tabRegistry: { ...(currentState.tabRegistry || {}) }, tabRegistry: { ...(currentState.tabRegistry || {}) },
sourceLastUrls: { ...(currentState.sourceLastUrls || {}) }, sourceLastUrls: { ...(currentState.sourceLastUrls || {}) },
}; };
@@ -149,6 +181,9 @@ async function setState(updates) {
stepStatuses: updates.stepStatuses stepStatuses: updates.stepStatuses
? { ...updates.stepStatuses } ? { ...updates.stepStatuses }
: currentState.stepStatuses, : currentState.stepStatuses,
nodeStatuses: updates.nodeStatuses
? { ...updates.nodeStatuses }
: currentState.nodeStatuses,
tabRegistry: updates.tabRegistry tabRegistry: updates.tabRegistry
? { ...updates.tabRegistry } ? { ...updates.tabRegistry }
: currentState.tabRegistry, : currentState.tabRegistry,
@@ -163,6 +198,7 @@ async function resetState() {
currentState = { currentState = {
...DEFAULT_STATE, ...DEFAULT_STATE,
stepStatuses: { ...DEFAULT_STATE.stepStatuses }, stepStatuses: { ...DEFAULT_STATE.stepStatuses },
nodeStatuses: { ...DEFAULT_STATE.nodeStatuses },
vpsUrl: prev.vpsUrl, vpsUrl: prev.vpsUrl,
vpsPassword: prev.vpsPassword, vpsPassword: prev.vpsPassword,
customPassword: prev.customPassword, customPassword: prev.customPassword,
@@ -262,6 +298,18 @@ async function runAutoSequenceFromStep() {
9: 'completed', 9: 'completed',
10: '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: { tabRegistry: {
'signup-page': { tabId: 88, ready: true }, '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} ${helperBundle}
${autoRunModuleSource} ${autoRunModuleSource}
@@ -303,24 +371,24 @@ const controller = self.MultiPageBackgroundAutoRunController.createAutoRunContro
createAutoRunSessionId, createAutoRunSessionId,
getAutoRunStatusPayload, getAutoRunStatusPayload,
getErrorMessage, getErrorMessage,
getFirstUnfinishedStep, getFirstUnfinishedNodeId,
getPendingAutoRunTimerPlan, getPendingAutoRunTimerPlan,
getRunningSteps, getRunningNodeIds,
getState, getState,
getStopRequested: () => stopRequested, getStopRequested: () => stopRequested,
hasSavedProgress, hasSavedNodeProgress,
isRestartCurrentAttemptError, isRestartCurrentAttemptError,
isStopError, isStopError,
launchAutoRunTimerPlan, launchAutoRunTimerPlan,
normalizeAutoRunFallbackThreadIntervalMinutes, normalizeAutoRunFallbackThreadIntervalMinutes,
persistAutoRunTimerPlan, persistAutoRunTimerPlan,
resetState, resetState,
runAutoSequenceFromStep, runAutoSequenceFromNode,
runtime, runtime,
setState, setState,
sleepWithStop, sleepWithStop,
throwIfAutoRunSessionStopped, throwIfAutoRunSessionStopped,
waitForRunningStepsToFinish, waitForRunningNodesToFinish,
throwIfStopped, throwIfStopped,
chrome, chrome,
}); });
+84
View File
@@ -5,6 +5,90 @@ const fs = require('node:fs');
const source = fs.readFileSync('background/auto-run-controller.js', 'utf8'); const source = fs.readFileSync('background/auto-run-controller.js', 'utf8');
const globalScope = {}; const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundAutoRunController;`)(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 () => { test('auto-run controller verifies hotmail mailbox before each fresh attempt starts', async () => {
const events = { const events = {
@@ -1,4 +1,4 @@
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
@@ -52,6 +52,97 @@ function extractFunction(name) {
return source.slice(start, end); 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 bundle = [
'const AUTO_RUN_STEP_IDLE_LOG_TIMEOUT_MS = 300000;', 'const AUTO_RUN_STEP_IDLE_LOG_TIMEOUT_MS = 300000;',
'const AUTO_RUN_STEP_IDLE_LOG_CHECK_INTERVAL_MS = 5000;', 'const AUTO_RUN_STEP_IDLE_LOG_CHECK_INTERVAL_MS = 5000;',
@@ -65,13 +156,16 @@ const bundle = [
extractFunction('isPlusCheckoutRestartStep'), extractFunction('isPlusCheckoutRestartStep'),
extractFunction('isPlusCheckoutRestartRequiredFailure'), extractFunction('isPlusCheckoutRestartRequiredFailure'),
extractFunction('getLatestLogTimestamp'), extractFunction('getLatestLogTimestamp'),
extractFunction('buildAutoRunStepIdleRestartError'), extractFunction('buildAutoRunNodeIdleRestartError'),
extractFunction('isAutoRunStepIdleRestartError'), extractFunction('isAutoRunStepIdleRestartError'),
extractFunction('startAutoRunStepIdleLogWatchdog'), extractFunction('startAutoRunNodeIdleLogWatchdog'),
extractFunction('runAutoStepActionWithIdleLogWatchdog'), extractFunction('runAutoNodeActionWithIdleLogWatchdog'),
extractFunction('executeStepAndWaitWithAutoRunIdleLogWatchdog'), extractFunction('executeNodeAndWaitWithAutoRunIdleLogWatchdog'),
extractFunction('getPostStep6AutoRestartDecision'), extractFunction('getPostStep6AutoRestartDecision'),
extractFunction('runAutoSequenceFromStep'), NODE_COMPAT_HELPERS,
extractFunction('getAutoRunWorkflowNodeIds'),
extractFunction('runAutoSequenceFromNode'),
extractFunction('runAutoSequenceFromNodeGraph'),
].join('\n'); ].join('\n');
test('auto-run stops step4 restart path when mail2925 ends the current thread', async () => { 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 assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
@@ -52,6 +52,97 @@ function extractFunction(name) {
return source.slice(start, end); 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 bundle = [
'const AUTO_RUN_STEP_IDLE_LOG_TIMEOUT_MS = 300000;', 'const AUTO_RUN_STEP_IDLE_LOG_TIMEOUT_MS = 300000;',
'const AUTO_RUN_STEP_IDLE_LOG_CHECK_INTERVAL_MS = 5000;', 'const AUTO_RUN_STEP_IDLE_LOG_CHECK_INTERVAL_MS = 5000;',
@@ -62,19 +153,22 @@ const bundle = [
extractFunction('isMail2925ThreadTerminatedError'), extractFunction('isMail2925ThreadTerminatedError'),
extractFunction('isSignupPhonePasswordMismatchFailure'), extractFunction('isSignupPhonePasswordMismatchFailure'),
extractFunction('getSignupPhonePasswordMismatchRestartPayload'), extractFunction('getSignupPhonePasswordMismatchRestartPayload'),
extractFunction('restartSignupPhonePasswordMismatchAttemptFromStep'), extractFunction('restartSignupPhonePasswordMismatchAttemptFromNode'),
extractFunction('isSignupUserAlreadyExistsFailure'), extractFunction('isSignupUserAlreadyExistsFailure'),
extractFunction('isPlusCheckoutNonFreeTrialFailure'), extractFunction('isPlusCheckoutNonFreeTrialFailure'),
extractFunction('isPlusCheckoutRestartStep'), extractFunction('isPlusCheckoutRestartStep'),
extractFunction('isPlusCheckoutRestartRequiredFailure'), extractFunction('isPlusCheckoutRestartRequiredFailure'),
extractFunction('getLatestLogTimestamp'), extractFunction('getLatestLogTimestamp'),
extractFunction('buildAutoRunStepIdleRestartError'), extractFunction('buildAutoRunNodeIdleRestartError'),
extractFunction('isAutoRunStepIdleRestartError'), extractFunction('isAutoRunStepIdleRestartError'),
extractFunction('startAutoRunStepIdleLogWatchdog'), extractFunction('startAutoRunNodeIdleLogWatchdog'),
extractFunction('runAutoStepActionWithIdleLogWatchdog'), extractFunction('runAutoNodeActionWithIdleLogWatchdog'),
extractFunction('executeStepAndWaitWithAutoRunIdleLogWatchdog'), extractFunction('executeNodeAndWaitWithAutoRunIdleLogWatchdog'),
extractFunction('getPostStep6AutoRestartDecision'), extractFunction('getPostStep6AutoRestartDecision'),
extractFunction('runAutoSequenceFromStep'), NODE_COMPAT_HELPERS,
extractFunction('getAutoRunWorkflowNodeIds'),
extractFunction('runAutoSequenceFromNode'),
extractFunction('runAutoSequenceFromNodeGraph'),
].join('\n'); ].join('\n');
test('auto-run restarts from step 1 with the same email after step 4 failure', async () => { test('auto-run restarts from step 1 with the same email after step 4 failure', async () => {
@@ -217,7 +311,7 @@ return {
{ {
step: 1, step: 1,
options: { 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.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.email, 'keep@example.com');
assert.equal(currentState.password, 'Secret123!'); 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 () => { 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.match(result.error, /SIGNUP_USER_ALREADY_EXISTS::/);
assert.deepStrictEqual(result.events.invalidations, []); assert.deepStrictEqual(result.events.invalidations, []);
assert.deepStrictEqual(result.events.steps, [1, 2, 3, 4]); 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 () => { 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(); const { events } = await api.run();
assert.deepStrictEqual(events.steps, [1, 2, 6, 7, 8, 9, 10]); 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 }) => /节点 fetch-signup-code 当前状态为 skipped/.test(message)), true);
assert.equal(events.logs.some(({ message }) => /步骤 5 当前状态为 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 () => { 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, step: 1,
options: { 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.accountIdentifierType, null);
assert.equal(currentState.accountIdentifier, ''); assert.equal(currentState.accountIdentifier, '');
assert.equal(currentState.password, 'Secret123!'); 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); assert.equal(events.logs.some(({ message }) => /已清空本轮注册手机号与接码订单/.test(message)), true);
}); });
@@ -786,7 +880,7 @@ return {
{ {
step: 1, step: 1,
options: { 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.accountIdentifierType, null);
assert.equal(currentState.accountIdentifier, ''); assert.equal(currentState.accountIdentifier, '');
assert.equal(currentState.password, 'Secret123!'); 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); assert.equal(events.logs.some(({ message }) => /已清空本轮注册手机号与接码订单/.test(message)), true);
}); });
@@ -943,7 +1037,7 @@ return {
{ {
step: 1, step: 1,
options: { options: {
logLabel: '步骤 3 检测到手机号/密码不匹配后准备回到步骤 1 重新获取手机号重试(第 1 次重开)', logLabel: '节点 fill-password 检测到手机号/密码不匹配后准备回到 open-chatgpt 重新获取手机号重试(第 1 次重开)',
}, },
}, },
]); ]);
@@ -953,6 +1047,6 @@ return {
assert.equal(currentState.accountIdentifierType, null); assert.equal(currentState.accountIdentifierType, null);
assert.equal(currentState.accountIdentifier, ''); assert.equal(currentState.accountIdentifier, '');
assert.equal(currentState.password, 'Secret123!'); assert.equal(currentState.password, 'Secret123!');
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 }) => /步骤 3已清空本轮注册手机号与接码订单/.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 assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
@@ -52,6 +52,99 @@ function extractFunction(name) {
return source.slice(start, end); 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 = [ const bundle = [
extractFunction('isAddPhoneAuthFailure'), extractFunction('isAddPhoneAuthFailure'),
extractFunction('isAddPhoneAuthUrl'), extractFunction('isAddPhoneAuthUrl'),
@@ -61,13 +154,16 @@ const bundle = [
extractFunction('isPlusCheckoutRestartStep'), extractFunction('isPlusCheckoutRestartStep'),
extractFunction('isPlusCheckoutRestartRequiredFailure'), extractFunction('isPlusCheckoutRestartRequiredFailure'),
extractFunction('getLatestLogTimestamp'), extractFunction('getLatestLogTimestamp'),
extractFunction('buildAutoRunStepIdleRestartError'), extractFunction('buildAutoRunNodeIdleRestartError'),
extractFunction('isAutoRunStepIdleRestartError'), extractFunction('isAutoRunStepIdleRestartError'),
extractFunction('startAutoRunStepIdleLogWatchdog'), extractFunction('startAutoRunNodeIdleLogWatchdog'),
extractFunction('runAutoStepActionWithIdleLogWatchdog'), extractFunction('runAutoNodeActionWithIdleLogWatchdog'),
extractFunction('executeStepAndWaitWithAutoRunIdleLogWatchdog'), extractFunction('executeNodeAndWaitWithAutoRunIdleLogWatchdog'),
extractFunction('getPostStep6AutoRestartDecision'), extractFunction('getPostStep6AutoRestartDecision'),
extractFunction('runAutoSequenceFromStep'), NODE_COMPAT_HELPERS,
extractFunction('getAutoRunWorkflowNodeIds'),
extractFunction('runAutoSequenceFromNode'),
extractFunction('runAutoSequenceFromNodeGraph'),
].join('\n'); ].join('\n');
const defaultStepDefinitions = { const defaultStepDefinitions = {
@@ -311,7 +407,7 @@ test('auto-run keeps restarting from step 7 after post-login failures without a
7, 8, 9, 10, 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 () => { 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.deepStrictEqual(events.invalidations.map((entry) => entry.step), [9]);
assert.equal(events.cancellations.length, 1); assert.equal(events.cancellations.length, 1);
assert.equal(events.stopBroadcasts, 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 () => { 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(); const result = await harness.runAndCaptureError();
assert.ok(result?.error); 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.steps, [10, 10, 10, 10]);
assert.deepStrictEqual(result.events.invalidations.map((entry) => entry.step), [9, 9, 9]); assert.deepStrictEqual(result.events.invalidations.map((entry) => entry.step), [9, 9, 9]);
assert.ok(result.events.logs.some(({ message }) => /已连续 3 次因 5 分钟无新日志而重开/.test(message))); 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], { assert.deepStrictEqual(events.invalidations[0], {
step: 8, step: 8,
options: { 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 () => { 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(); const events = await harness.run();
assert.deepStrictEqual(events.steps, [10, 10, 11, 12, 13]); assert.deepStrictEqual(events.steps, [10, 10, 11, 12, 13]);
assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [9]); assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [7]);
assert.ok(events.logs.some(({ message }) => /回到步骤 10 重新开始授权流程/.test(message))); assert.ok(events.logs.some(({ message }) => /回到节点 oauth-login 重新开始授权流程/.test(message)));
assert.ok(!events.logs.some(({ message }) => /停止自动回到步骤 10 重开/.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 () => { 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(); const events = await harness.run();
assert.deepStrictEqual(events.steps, scenario.expectedSteps); assert.deepStrictEqual(events.steps, scenario.expectedSteps);
assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [9]); assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [7]);
assert.ok(events.logs.some(({ message }) => new RegExp(`步骤 ${scenario.failureStep}.*回到步骤 10 重新开始授权流程`).test(message))); 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.steps, [10, 11, 12, 13, 12, 13]);
assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [11]); 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 () => { 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.steps, [6, 6, 7, 8, 9, 10, 11, 12, 13]);
assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [5]); 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 () => { 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.steps, [6, 7, 6, 7, 8, 9, 10, 11, 12, 13]);
assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [5]); 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 () => { 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), events.invalidations.map((entry) => entry.step),
[5, 5] [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 () => { 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.steps, [6, 6, 7, 10, 11, 12, 13]);
assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [5]); 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 () => { 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.steps, [6, 7, 6, 7, 10, 11, 12, 13]);
assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [5]); 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 () => { 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 PERSISTED_SETTING_DEFAULTS = { autoStepDelaySeconds: null };',
"const AUTO_RUN_PRE_EXECUTION_DELAYS_BY_STEP_KEY = new Map([['plus-checkout-create', 20000]]);", "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 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('normalizeAutoStepDelaySeconds'),
extractFunction('resolveLegacyAutoStepDelaySeconds'), extractFunction('resolveLegacyAutoStepDelaySeconds'),
extractFunction('getStepExecutionKeyForState'), extractFunction('getStepExecutionKeyForState'),
extractFunction('getNodeExecutionKeyForState'),
extractFunction('getAutoRunPreExecutionDelayMsForNode'),
extractFunction('getAutoRunPreExecutionDelayMs'), extractFunction('getAutoRunPreExecutionDelayMs'),
].join('\n'); ].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 || ''), getErrorMessage: (error) => error?.message || String(error || ''),
getNodeTitleForState: (nodeId) => ({
'fetch-login-code': '获取登录验证码',
'oauth-login': '刷新 OAuth 并登录',
})[nodeId] || nodeId,
getState: async () => ({ getState: async () => ({
flowId: 'openai',
runId: 'run-2',
email: ' latest@example.com ', email: ' latest@example.com ',
password: ' secret ', password: ' secret ',
autoRunning: true, autoRunning: true,
@@ -61,6 +67,8 @@ test('account run history helper upgrades old records, keeps stopped items and s
const record = helpers.buildAccountRunHistoryRecord( const record = helpers.buildAccountRunHistoryRecord(
{ {
flowId: 'openai',
runId: 'run-2',
email: ' latest@example.com ', email: ' latest@example.com ',
password: ' secret ', password: ' secret ',
autoRunning: true, autoRunning: true,
@@ -68,11 +76,13 @@ test('account run history helper upgrades old records, keeps stopped items and s
autoRunTotalRuns: 10, autoRunTotalRuns: 10,
autoRunAttemptRun: 3, autoRunAttemptRun: 3,
}, },
'step8_failed', 'node:fetch-login-code:failed',
'步骤 8:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。' '步骤 8:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。'
); );
assert.deepStrictEqual(record, { assert.deepStrictEqual(record, {
recordId: 'latest@example.com', recordId: 'latest@example.com',
flowId: 'openai',
runId: 'run-2',
accountIdentifierType: 'email', accountIdentifierType: 'email',
accountIdentifier: 'latest@example.com', accountIdentifier: 'latest@example.com',
email: '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, retryCount: 2,
failureLabel: '出现手机号验证', failureLabel: '出现手机号验证',
failureDetail: '步骤 8:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。', failureDetail: '步骤 8:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。',
failedStep: 8, failedNodeId: 'fetch-login-code',
failedStep: null,
source: 'auto', source: 'auto',
autoRunContext: { autoRunContext: {
currentRun: 2, currentRun: 2,
@@ -94,9 +105,12 @@ test('account run history helper upgrades old records, keeps stopped items and s
contributionMode: false, 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.email, 'latest@example.com');
assert.equal(appended.finalStatus, 'failed'); 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(appended.failureLabel, '出现手机号验证');
assert.equal(storedHistory.length, 3, '旧的 stopped 记录应在新结构中保留'); assert.equal(storedHistory.length, 3, '旧的 stopped 记录应在新结构中保留');
assert.equal(storedHistory.some((item) => item.email === 'stop@example.com' && item.finalStatus === 'stopped'), true); 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: false, accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373' }), true);
assert.equal(helpers.shouldAppendAccountRunTextFile({ accountRunHistoryTextEnabled: true, 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( const stoppedRecord = helpers.buildAccountRunHistoryRecord(
{ email: 'a@b.com', password: 'x' }, { flowId: 'openai', runId: 'manual-run', email: 'a@b.com', password: 'x' },
'step7_stopped', 'node:oauth-login:stopped',
'步骤 7 已被用户停止' '节点 oauth-login 已被用户停止'
); );
assert.equal(stoppedRecord.recordId, 'a@b.com'); 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.email, 'a@b.com');
assert.equal(stoppedRecord.password, 'x'); assert.equal(stoppedRecord.password, 'x');
assert.equal(stoppedRecord.finalStatus, 'stopped'); assert.equal(stoppedRecord.finalStatus, 'stopped');
assert.equal(stoppedRecord.retryCount, 0); assert.equal(stoppedRecord.retryCount, 0);
assert.equal(stoppedRecord.failureLabel, '步骤 7 停止'); assert.equal(stoppedRecord.failureLabel, '节点 刷新 OAuth 并登录 停止');
assert.equal(stoppedRecord.failureDetail, '步骤 7 已被用户停止'); assert.equal(stoppedRecord.failureDetail, '节点 oauth-login 已被用户停止');
assert.equal(stoppedRecord.failedStep, 7); assert.equal(stoppedRecord.failedNodeId, 'oauth-login');
assert.equal(stoppedRecord.failedStep, null);
assert.equal(stoppedRecord.source, 'manual'); assert.equal(stoppedRecord.source, 'manual');
assert.equal(stoppedRecord.autoRunContext, null); assert.equal(stoppedRecord.autoRunContext, null);
assert.ok(stoppedRecord.finishedAt); assert.ok(stoppedRecord.finishedAt);
@@ -149,11 +166,13 @@ test('account run history helper upgrades old records, keeps stopped items and s
retryCount: 0, retryCount: 0,
failureLabel: '流程已停止', failureLabel: '流程已停止',
failureDetail: '步骤 7 已被用户停止。', failureDetail: '步骤 7 已被用户停止。',
failedNodeId: 'oauth-login',
failedStep: 7, failedStep: 7,
source: 'manual', source: 'manual',
autoRunContext: null, autoRunContext: null,
}); });
assert.equal(normalizedStoppedRecord.failureLabel, '步骤 7 停止'); assert.equal(normalizedStoppedRecord.failureLabel, '节点 刷新 OAuth 并登录 停止');
assert.equal(normalizedStoppedRecord.failedNodeId, 'oauth-login');
assert.equal(normalizedStoppedRecord.failedStep, 7); assert.equal(normalizedStoppedRecord.failedStep, 7);
}); });
@@ -177,6 +196,8 @@ test('account run history helper accepts phone-only records without forcing emai
assert.deepStrictEqual(record, { assert.deepStrictEqual(record, {
recordId: 'phone:+6612345', recordId: 'phone:+6612345',
flowId: '',
runId: '',
accountIdentifierType: 'phone', accountIdentifierType: 'phone',
accountIdentifier: '+6612345', accountIdentifier: '+6612345',
email: '', email: '',
@@ -187,6 +208,7 @@ test('account run history helper accepts phone-only records without forcing emai
retryCount: 0, retryCount: 0,
failureLabel: '流程完成', failureLabel: '流程完成',
failureDetail: '', failureDetail: '',
failedNodeId: '',
failedStep: null, failedStep: null,
source: 'manual', source: 'manual',
autoRunContext: null, autoRunContext: null,
@@ -242,10 +264,11 @@ test('account run history does not turn prerequisite guidance into a fake step 2
autoRunCurrentRun: 1, autoRunCurrentRun: 1,
autoRunTotalRuns: 3, autoRunTotalRuns: 3,
autoRunAttemptRun: 2, autoRunAttemptRun: 2,
}, 'step10_failed', '缺少登录账号:请先完成步骤 2,或在侧栏填写账号后再执行当前步骤。'); }, 'node:platform-verify:failed', '缺少登录账号:请先完成步骤 2,或在侧栏填写账号后再执行当前步骤。');
assert.equal(explicitFailedRecord.failedStep, 10); assert.equal(explicitFailedRecord.failedNodeId, 'platform-verify');
assert.equal(explicitFailedRecord.failureLabel, '步骤 10 失败'); assert.equal(explicitFailedRecord.failedStep, null);
assert.equal(explicitFailedRecord.failureLabel, '节点 platform-verify 失败');
const migratedOldRecord = helpers.normalizeAccountRunHistoryRecord({ const migratedOldRecord = helpers.normalizeAccountRunHistoryRecord({
email: 'old@example.com', email: 'old@example.com',
@@ -314,10 +337,12 @@ test('account run history merges email and phone identities from the same run',
activationId: 'a1', activationId: 'a1',
phoneNumber: '+44 7799 342687', phoneNumber: '+44 7799 342687',
}, },
}, 'step9_failed', '步骤 9:手机号验证失败。'); }, 'node:confirm-oauth:failed', '步骤 9:手机号验证失败。');
assert.equal(failedRecord.accountIdentifierType, 'email'); assert.equal(failedRecord.accountIdentifierType, 'email');
assert.equal(failedRecord.accountIdentifier, 'tmp@example.com'); assert.equal(failedRecord.accountIdentifier, 'tmp@example.com');
assert.equal(failedRecord.phoneNumber, '+44 7799 342687'); assert.equal(failedRecord.phoneNumber, '+44 7799 342687');
assert.equal(failedRecord.failedNodeId, 'confirm-oauth');
assert.equal(failedRecord.failedStep, null);
const successRecord = await helpers.appendAccountRunRecord('success', { const successRecord = await helpers.appendAccountRunRecord('success', {
accountIdentifierType: 'email', accountIdentifierType: 'email',
+84 -16
View File
@@ -48,6 +48,71 @@ function extractFunction(name) {
return source.slice(start, end); 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', () => { test('throwIfStopped rethrows an explicit stop error even when stopRequested has been cleared', () => {
const api = new Function(` const api = new Function(`
const STOP_ERROR_MESSAGE = '流程已被用户停止。'; 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 api = new Function(`
const LOG_PREFIX = '[test]'; const LOG_PREFIX = '[test]';
const STOP_ERROR_MESSAGE = '流程已被用户停止。'; const STOP_ERROR_MESSAGE = '流程已被用户停止。';
@@ -137,14 +202,15 @@ function getStepDefinitionForState(step) {
return { id: step, key: 'test-step' }; return { id: step, key: 'test-step' };
} }
${NODE_EXECUTE_COMPAT_HELPERS}
${extractFunction('isStopError')} ${extractFunction('isStopError')}
${extractFunction('throwIfStopped')} ${extractFunction('throwIfStopped')}
${extractFunction('isAuthChainStep')} ${extractFunction('isAuthChainStep')}
${extractFunction('acquireTopLevelAuthChainExecution')} ${extractFunction('acquireTopLevelAuthChainExecutionForNode')}
${extractFunction('executeStep')} ${extractFunction('executeNode')}
return { return {
executeStep, executeNode,
releaseStep8() { releaseStep8() {
if (releaseStep8) { if (releaseStep8) {
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)); await new Promise((resolve) => setImmediate(resolve));
const duplicateRun = api.executeStep(7); const duplicateRun = api.executeNode('oauth-login');
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
api.releaseStep8(); api.releaseStep8();
@@ -173,7 +239,7 @@ return {
assert.ok(events.logs.some(({ message }) => /复用当前授权链/.test(message))); 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 api = new Function(`
const LOG_PREFIX = '[test]'; const LOG_PREFIX = '[test]';
const STOP_ERROR_MESSAGE = '流程已被用户停止。'; const STOP_ERROR_MESSAGE = '流程已被用户停止。';
@@ -235,17 +301,18 @@ function getStepDefinitionForState(step) {
return { id: step, key: 'test-step' }; return { id: step, key: 'test-step' };
} }
${NODE_EXECUTE_COMPAT_HELPERS}
${extractFunction('isStopError')} ${extractFunction('isStopError')}
${extractFunction('throwIfStopped')} ${extractFunction('throwIfStopped')}
${extractFunction('isAuthChainStep')} ${extractFunction('isAuthChainStep')}
${extractFunction('acquireTopLevelAuthChainExecution')} ${extractFunction('acquireTopLevelAuthChainExecutionForNode')}
${extractFunction('isBrowserSwitchRequiredError')} ${extractFunction('isBrowserSwitchRequiredError')}
${extractFunction('getBrowserSwitchRequiredMessage')} ${extractFunction('getBrowserSwitchRequiredMessage')}
${extractFunction('handleBrowserSwitchRequired')} ${extractFunction('handleBrowserSwitchRequired')}
${extractFunction('executeStep')} ${extractFunction('executeNode')}
return { return {
executeStep, executeNode,
snapshot() { snapshot() {
return events; return events;
}, },
@@ -253,7 +320,7 @@ return {
`)(); `)();
await assert.rejects( await assert.rejects(
() => api.executeStep(10), () => api.executeNode('platform-verify'),
/流程已被用户停止。/ /流程已被用户停止。/
); );
@@ -393,7 +460,7 @@ return {
assert.match(events.logs[0].message, /授权后链总超时已关闭/); 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 api = new Function(`
const LOG_PREFIX = '[test]'; const LOG_PREFIX = '[test]';
const STOP_ERROR_MESSAGE = '流程已被用户停止。'; const STOP_ERROR_MESSAGE = '流程已被用户停止。';
@@ -456,27 +523,28 @@ function getStepDefinitionForState(step) {
return { id: step, key: 'fetch-signup-code' }; return { id: step, key: 'fetch-signup-code' };
} }
${NODE_EXECUTE_COMPAT_HELPERS}
${extractFunction('isStopError')} ${extractFunction('isStopError')}
${extractFunction('isRetryableContentScriptTransportError')} ${extractFunction('isRetryableContentScriptTransportError')}
${extractFunction('isStepFetchNetworkRetryableError')} ${extractFunction('isStepFetchNetworkRetryableError')}
${extractFunction('getStepFetchNetworkRetryPolicy')} ${extractFunction('getStepFetchNetworkRetryPolicy')}
${extractFunction('throwIfStopped')} ${extractFunction('throwIfStopped')}
${extractFunction('isAuthChainStep')} ${extractFunction('isAuthChainStep')}
${extractFunction('acquireTopLevelAuthChainExecution')} ${extractFunction('acquireTopLevelAuthChainExecutionForNode')}
${extractFunction('isBrowserSwitchRequiredError')} ${extractFunction('isBrowserSwitchRequiredError')}
${extractFunction('getBrowserSwitchRequiredMessage')} ${extractFunction('getBrowserSwitchRequiredMessage')}
${extractFunction('handleBrowserSwitchRequired')} ${extractFunction('handleBrowserSwitchRequired')}
${extractFunction('executeStep')} ${extractFunction('executeNode')}
return { return {
executeStep, executeNode,
snapshot() { snapshot() {
return events; return events;
}, },
}; };
`)(); `)();
await api.executeStep(4); await api.executeNode('fetch-signup-code');
const events = api.snapshot(); const events = api.snapshot();
assert.deepStrictEqual(events.registryCalls, [4, 4, 4]); 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'); 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 source = fs.readFileSync('background/auto-run-controller.js', 'utf8');
const globalScope = {}; const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundAutoRunController;`)(globalScope); const api = new Function('self', `${source}; return self.MultiPageBackgroundAutoRunController;`)(globalScope);
const controller = api.createAutoRunController({}); const controller = api.createAutoRunController({});
const state = { const state = {
currentStep: 11, currentNodeId: 'fetch-login-code',
stepStatuses: { nodeStatuses: {
2: 'completed', 'submit-signup-email': 'completed',
10: 'completed', 'oauth-login': 'completed',
11: 'failed', 'fetch-login-code': 'failed',
}, },
}; };
const error = new Error('缺少登录账号:请先完成步骤 2,或在侧栏填写账号后再执行当前步骤。'); const error = new Error('缺少登录账号:请先完成步骤 2,或在侧栏填写账号后再执行当前步骤。');
assert.equal( assert.equal(
controller.resolveAutoRunAccountRecordStatus('failed', state, error), controller.resolveAutoRunAccountRecordStatus('failed', state, error),
'step11_failed' 'node:fetch-login-code:failed'
); );
error.failedStep = 13; error.failedNodeId = 'platform-verify';
assert.equal( assert.equal(
controller.resolveAutoRunAccountRecordStatus('failed', state, error), 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', flowId: 'openai',
ruleId: 'openai-signup-code', ruleId: 'openai-signup-code',
nodeId: 'fetch-signup-code',
step: 4, step: 4,
artifactType: 'code', artifactType: 'code',
codePatterns: [ codePatterns: [
@@ -78,6 +79,7 @@ test('mail rule registry exposes canonical OpenAI verification poll payloads', (
{ {
flowId: 'openai', flowId: 'openai',
ruleId: 'openai-login-code', ruleId: 'openai-login-code',
nodeId: 'fetch-login-code',
step: 8, step: 8,
artifactType: 'code', artifactType: 'code',
codePatterns: [ codePatterns: [
@@ -105,6 +107,14 @@ test('mail rule registry exposes canonical OpenAI verification poll payloads', (
intervalMs: 3000, 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', () => { 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 () => {}, deleteIcloudAlias: async () => {},
deleteUsedIcloudAliases: async () => {}, deleteUsedIcloudAliases: async () => {},
disableUsedLuckmailPurchases: async () => {}, disableUsedLuckmailPurchases: async () => {},
doesStepUseCompletionSignal: () => false, doesNodeUseCompletionSignal: () => false,
ensureManualInteractionAllowed: async () => ({}), ensureManualInteractionAllowed: async () => ({}),
executeStep: async () => {}, executeNode: async () => {},
executeStepViaCompletionSignal: async () => {}, executeNodeViaCompletionSignal: async () => {},
exportSettingsBundle: async () => ({}), exportSettingsBundle: async () => ({}),
fetchGeneratedEmail: async () => '', fetchGeneratedEmail: async () => '',
finalizeStep3Completion: async () => {}, finalizeStep3Completion: async () => {},
@@ -43,7 +43,9 @@ test('message router appends success record on Plus final step instead of hard-c
getCurrentLuckmailPurchase: () => null, getCurrentLuckmailPurchase: () => null,
getPendingAutoRunTimerPlan: () => null, getPendingAutoRunTimerPlan: () => null,
getSourceLabel: () => '', 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, getLastStepIdForState: () => 13,
getStepDefinitionForState: (step) => ({ id: step, key: step === 10 ? 'oauth-login' : 'platform-verify' }), 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], 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, normalizeHotmailAccounts: (items) => items,
normalizeRunCount: (value) => value, normalizeRunCount: (value) => value,
AUTO_RUN_TIMER_KIND_SCHEDULED_START: 'scheduled', AUTO_RUN_TIMER_KIND_SCHEDULED_START: 'scheduled',
notifyStepComplete: () => {}, notifyNodeComplete: () => {},
notifyStepError: () => {}, notifyNodeError: () => {},
patchHotmailAccount: async () => {}, patchHotmailAccount: async () => {},
patchMail2925Account: async () => {}, patchMail2925Account: async () => {},
registerTab: async () => {}, registerTab: async () => {},
@@ -88,9 +90,9 @@ test('message router appends success record on Plus final step instead of hard-c
setLuckmailPurchaseUsedState: async () => {}, setLuckmailPurchaseUsedState: async () => {},
setPersistentSettings: async () => {}, setPersistentSettings: async () => {},
setState: async () => {}, setState: async () => {},
setStepStatus: async () => {}, setNodeStatus: async () => {},
skipAutoRunCountdown: async () => false, skipAutoRunCountdown: async () => false,
skipStep: async () => {}, skipNode: async () => {},
startAutoRunLoop: async () => {}, startAutoRunLoop: async () => {},
syncHotmailAccounts: async () => {}, syncHotmailAccounts: async () => {},
testHotmailAccountMailAccess: async () => {}, testHotmailAccountMailAccess: async () => {},
@@ -98,7 +100,7 @@ test('message router appends success record on Plus final step instead of hard-c
verifyHotmailAccount: async () => {}, 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.length, 1);
assert.equal(appendCalls[0][0], 'success'); assert.equal(appendCalls[0][0], 'success');
@@ -10,6 +10,7 @@ function createRouter(overrides = {}) {
const events = { const events = {
logs: [], logs: [],
stepStatuses: [], stepStatuses: [],
nodeStatuses: [],
stateUpdates: [], stateUpdates: [],
broadcasts: [], broadcasts: [],
balanceRefreshes: [], balanceRefreshes: [],
@@ -26,10 +27,63 @@ function createRouter(overrides = {}) {
executedSteps: [], executedSteps: [],
accountRecords: [], 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({ const router = api.createMessageRouter({
addLog: async (message, level, options = {}) => { 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) => { appendAccountRunRecord: overrides.appendAccountRunRecord || (async (status, state, reason) => {
events.accountRecords.push({ status, state, reason }); events.accountRecords.push({ status, state, reason });
@@ -49,20 +103,20 @@ function createRouter(overrides = {}) {
clearStopRequest: () => {}, clearStopRequest: () => {},
closeLocalhostCallbackTabs: async () => {}, closeLocalhostCallbackTabs: async () => {},
closeTabsByUrlPrefix: async () => {}, closeTabsByUrlPrefix: async () => {},
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (nodeId, payload) => {
events.notifyCompletions.push({ step, payload, via: 'completeStepFromBackground' }); events.notifyCompletions.push({ step: getStepForNode(nodeId), nodeId, payload, via: 'completeNodeFromBackground' });
}, },
deleteHotmailAccount: async () => {}, deleteHotmailAccount: async () => {},
deleteHotmailAccounts: async () => {}, deleteHotmailAccounts: async () => {},
deleteIcloudAlias: async () => {}, deleteIcloudAlias: async () => {},
deleteUsedIcloudAliases: async () => {}, deleteUsedIcloudAliases: async () => {},
disableUsedLuckmailPurchases: async () => {}, disableUsedLuckmailPurchases: async () => {},
doesStepUseCompletionSignal: () => false, doesNodeUseCompletionSignal: () => false,
ensureManualInteractionAllowed: async () => ({}), ensureManualInteractionAllowed: async () => ({}),
executeStep: async (step) => { executeNode: async (nodeId) => {
events.executedSteps.push(step); events.executedSteps.push(getStepForNode(nodeId) || nodeId);
}, },
executeStepViaCompletionSignal: async () => {}, executeNodeViaCompletionSignal: async () => {},
exportSettingsBundle: async () => ({}), exportSettingsBundle: async () => ({}),
fetchGeneratedEmail: async () => '', fetchGeneratedEmail: async () => '',
finalizePhoneActivationAfterSuccessfulFlow: overrides.finalizePhoneActivationAfterSuccessfulFlow || (async (state) => { finalizePhoneActivationAfterSuccessfulFlow: overrides.finalizePhoneActivationAfterSuccessfulFlow || (async (state) => {
@@ -77,7 +131,9 @@ function createRouter(overrides = {}) {
getCurrentLuckmailPurchase: () => null, getCurrentLuckmailPurchase: () => null,
getPendingAutoRunTimerPlan: () => null, getPendingAutoRunTimerPlan: () => null,
getSourceLabel: () => '', 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, getStepDefinitionForState: overrides.getStepDefinitionForState,
getStepIdsForState: overrides.getStepIdsForState, getStepIdsForState: overrides.getStepIdsForState,
getLastStepIdForState: overrides.getLastStepIdForState, getLastStepIdForState: overrides.getLastStepIdForState,
@@ -106,11 +162,11 @@ function createRouter(overrides = {}) {
normalizeHotmailAccounts: (items) => items, normalizeHotmailAccounts: (items) => items,
normalizeRunCount: (value) => value, normalizeRunCount: (value) => value,
AUTO_RUN_TIMER_KIND_SCHEDULED_START: 'scheduled', AUTO_RUN_TIMER_KIND_SCHEDULED_START: 'scheduled',
notifyStepComplete: (step, payload) => { notifyNodeComplete: (nodeId, payload) => {
events.notifyCompletions.push({ step, payload }); events.notifyCompletions.push({ step: getStepForNode(nodeId), nodeId, payload });
}, },
notifyStepError: (step, error) => { notifyNodeError: (nodeId, error) => {
events.notifyErrors.push({ step, error }); events.notifyErrors.push({ step: getStepForNode(nodeId), nodeId, error });
}, },
patchHotmailAccount: async () => {}, patchHotmailAccount: async () => {},
registerTab: async () => {}, registerTab: async () => {},
@@ -142,11 +198,13 @@ function createRouter(overrides = {}) {
setState: async (updates) => { setState: async (updates) => {
events.stateUpdates.push(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 }); events.stepStatuses.push({ step, status });
}, },
skipAutoRunCountdown: async () => false, skipAutoRunCountdown: async () => false,
skipStep: async () => {}, skipNode: async () => {},
startAutoRunLoop: async () => {}, startAutoRunLoop: async () => {},
syncHotmailAccounts: async () => {}, syncHotmailAccounts: async () => {},
testHotmailAccountMailAccess: async () => {}, testHotmailAccountMailAccess: async () => {},
@@ -366,10 +424,11 @@ test('message router finalizes step 3 before marking it completed', async () =>
const { router, events } = createRouter(); const { router, events } = createRouter();
const response = await router.handleMessage({ const response = await router.handleMessage({
type: 'STEP_COMPLETE', type: 'NODE_COMPLETE',
step: 3, nodeId: 'fill-password',
source: 'signup-page', source: 'signup-page',
payload: { payload: {
nodeId: 'fill-password',
email: 'user@example.com', email: 'user@example.com',
signupVerificationRequestedAt: 123, signupVerificationRequestedAt: 123,
}, },
@@ -377,8 +436,10 @@ test('message router finalizes step 3 before marking it completed', async () =>
assert.deepStrictEqual(events.finalizePayloads, [ assert.deepStrictEqual(events.finalizePayloads, [
{ {
nodeId: 'fill-password',
email: 'user@example.com', email: 'user@example.com',
signupVerificationRequestedAt: 123, signupVerificationRequestedAt: 123,
step: 3,
}, },
]); ]);
assert.deepStrictEqual(events.stepStatuses, [{ step: 3, status: 'completed' }]); 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, [ assert.deepStrictEqual(events.notifyCompletions, [
{ {
step: 3, step: 3,
nodeId: 'fill-password',
payload: { payload: {
nodeId: 'fill-password',
email: 'user@example.com', email: 'user@example.com',
signupVerificationRequestedAt: 123, 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', 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 () => { 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({ const response = await router.handleMessage({
type: 'STEP_COMPLETE', type: 'NODE_COMPLETE',
step: 3, nodeId: 'fill-password',
source: 'signup-page', source: 'signup-page',
payload: { payload: {
nodeId: 'fill-password',
email: 'user@example.com', 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, [ assert.deepStrictEqual(events.notifyErrors, [
{ {
step: 3, step: 3,
nodeId: 'fill-password',
error: '步骤 3 提交后仍停留在密码页。', 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 提交后仍停留在密码页。' }); 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({ const response = await router.handleMessage({
type: 'STEP_ERROR', type: 'NODE_ERROR',
step: 3, nodeId: 'fill-password',
source: 'signup-page', source: 'signup-page',
payload: {}, payload: { nodeId: 'fill-password' },
error: mismatchError, error: mismatchError,
}, {}); }, {});
@@ -524,6 +591,7 @@ test('message router does not duplicate step 3 mismatch failure log after finali
assert.deepStrictEqual(events.notifyErrors, [ assert.deepStrictEqual(events.notifyErrors, [
{ {
step: 3, step: 3,
nodeId: 'fill-password',
error: mismatchError, error: mismatchError,
}, },
]); ]);
@@ -534,10 +602,10 @@ test('message router stops the flow and surfaces cloudflare security block error
const { router, events } = createRouter(); const { router, events } = createRouter();
const response = await router.handleMessage({ const response = await router.handleMessage({
type: 'STEP_ERROR', type: 'NODE_ERROR',
step: 7, nodeId: 'oauth-login',
source: 'signup-page', source: 'signup-page',
payload: {}, payload: { nodeId: 'oauth-login' },
error: 'CF_SECURITY_BLOCKED::您已触发Cloudflare 安全防护系统', 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, [ assert.deepStrictEqual(events.notifyErrors, [
{ {
step: 7, step: 7,
nodeId: 'oauth-login',
error: '流程已被用户停止。', error: '流程已被用户停止。',
}, },
]); ]);
@@ -562,9 +631,10 @@ test('message router blocks manual step 4 execution when signup page tab is miss
await assert.rejects( await assert.rejects(
() => router.handleMessage({ () => router.handleMessage({
type: 'EXECUTE_STEP', type: 'EXECUTE_NODE',
source: 'sidepanel', source: 'sidepanel',
payload: { step: 4 }, nodeId: 'fetch-signup-code',
payload: { nodeId: 'fetch-signup-code' },
}, {}), }, {}),
/手动执行步骤 4 前,请先执行步骤 1 或步骤 2/ /手动执行步骤 4 前,请先执行步骤 1 或步骤 2/
); );
@@ -630,18 +700,19 @@ test('message router ignores stale step 2 errors while auto-run is already on a
state: { state: {
autoRunning: true, autoRunning: true,
autoRunPhase: 'running', autoRunPhase: 'running',
currentStep: 6, currentNodeId: 'wait-registration-success',
stepStatuses: { nodeStatuses: {
2: 'completed', 'submit-signup-email': 'completed',
6: 'running', 'wait-registration-success': 'running',
}, },
}, },
isAutoRunLockedState: (state) => Boolean(state?.autoRunning) && state?.autoRunPhase === 'running', isAutoRunLockedState: (state) => Boolean(state?.autoRunning) && state?.autoRunPhase === 'running',
}); });
const response = await router.handleMessage({ const response = await router.handleMessage({
type: 'STEP_ERROR', type: 'NODE_ERROR',
step: 2, nodeId: 'submit-signup-email',
payload: { nodeId: 'submit-signup-email' },
error: '步骤 2:旧页面异步失败,不应覆盖当前第 6 步记录。', 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.stepStatuses, []);
assert.deepStrictEqual(events.notifyErrors, []); assert.deepStrictEqual(events.notifyErrors, []);
assert.deepStrictEqual(events.accountRecords, []); 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 () => { 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: { state: {
autoRunning: true, autoRunning: true,
autoRunPhase: 'running', autoRunPhase: 'running',
currentStep: 6, currentNodeId: 'wait-registration-success',
stepStatuses: { nodeStatuses: {
2: 'completed', 'submit-signup-email': 'completed',
6: 'running', 'wait-registration-success': 'running',
}, },
}, },
isAutoRunLockedState: (state) => Boolean(state?.autoRunning) && state?.autoRunPhase === 'running', isAutoRunLockedState: (state) => Boolean(state?.autoRunning) && state?.autoRunPhase === 'running',
}); });
const response = await router.handleMessage({ const response = await router.handleMessage({
type: 'STEP_COMPLETE', type: 'NODE_COMPLETE',
step: 2, nodeId: 'submit-signup-email',
payload: { payload: {
nodeId: 'submit-signup-email',
email: 'late@example.com', 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.stepStatuses, []);
assert.deepStrictEqual(events.notifyCompletions, []); assert.deepStrictEqual(events.notifyCompletions, []);
assert.deepStrictEqual(events.emailStates, []); 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 fs = require('node:fs');
const test = require('node:test'); const test = require('node:test');
@@ -35,7 +35,7 @@ test('platform verify module supports codex2api protocol callback exchange', asy
}, },
chrome: {}, chrome: {},
closeConflictingTabsForSource: async () => {}, closeConflictingTabsForSource: async () => {},
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (step, payload) => {
completed.push({ step, payload }); completed.push({ step, payload });
}, },
ensureContentScriptReadyOnTab: async () => {}, ensureContentScriptReadyOnTab: async () => {},
@@ -68,7 +68,7 @@ test('platform verify module supports codex2api protocol callback exchange', asy
]); ]);
assert.deepStrictEqual(completed, [ assert.deepStrictEqual(completed, [
{ {
step: 10, step: 'platform-verify',
payload: { payload: {
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
verifiedStatus: 'OAuth 账号 flow@example.com 添加成功', verifiedStatus: 'OAuth 账号 flow@example.com 添加成功',
@@ -98,7 +98,7 @@ test('platform verify retries transient SUB2API oauth/token exchange errors befo
}, },
}, },
closeConflictingTabsForSource: async () => {}, closeConflictingTabsForSource: async () => {},
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
ensureContentScriptReadyOnTab: async () => {}, ensureContentScriptReadyOnTab: async () => {},
getPanelMode: () => 'sub2api', getPanelMode: () => 'sub2api',
getTabId: async () => 12, getTabId: async () => 12,
@@ -166,7 +166,7 @@ test('platform verify retries transient SUB2API token_exchange_user_error before
}, },
}, },
closeConflictingTabsForSource: async () => {}, closeConflictingTabsForSource: async () => {},
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
ensureContentScriptReadyOnTab: async () => {}, ensureContentScriptReadyOnTab: async () => {},
getPanelMode: () => 'sub2api', getPanelMode: () => 'sub2api',
getTabId: async () => 12, 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 fs = require('node:fs');
const test = require('node:test'); const test = require('node:test');
@@ -17,7 +17,7 @@ function createDeps(overrides = {}) {
}, },
}, },
closeConflictingTabsForSource: async () => {}, closeConflictingTabsForSource: async () => {},
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (step, payload) => {
completed.push({ step, payload }); completed.push({ step, payload });
}, },
ensureContentScriptReadyOnTab: async () => {}, ensureContentScriptReadyOnTab: async () => {},
@@ -97,7 +97,7 @@ test('platform verify module submits CPA callback via management API first', asy
assert.equal(uiCalled, false); assert.equal(uiCalled, false);
assert.deepStrictEqual(completed, [ assert.deepStrictEqual(completed, [
{ {
step: 10, step: 'platform-verify',
payload: { payload: {
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
verifiedStatus: 'CPA API 回调提交成功', 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.equal(exchangeCall.body.state, 'oauth-state');
assert.deepStrictEqual(createCall.body.group_ids, [5]); assert.deepStrictEqual(createCall.body.group_ids, [5]);
assert.deepStrictEqual(completed, [{ assert.deepStrictEqual(completed, [{
step: 13, step: 'platform-verify',
payload: { payload: {
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
verifiedStatus: 'SUB2API 已创建账号 #11', verifiedStatus: 'SUB2API 已创建账号 #11',
+29 -54
View File
@@ -22,29 +22,22 @@ test('runtime-state module exposes a factory', () => {
assert.equal(typeof api?.createRuntimeStateHelpers, 'function'); 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 api = loadRuntimeStateApi();
const helpers = api.createRuntimeStateHelpers({ const helpers = api.createRuntimeStateHelpers({
DEFAULT_ACTIVE_FLOW_ID: 'openai', DEFAULT_ACTIVE_FLOW_ID: 'openai',
defaultStepStatuses: { defaultNodeStatuses: {
1: 'pending', 'open-chatgpt': 'pending',
2: 'pending', 'submit-signup-email': 'pending',
10: 'pending', 'oauth-login': '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;
}, },
}); });
const view = helpers.buildStateView({ const view = helpers.buildStateView({
currentStep: 2, currentNodeId: 'submit-signup-email',
stepStatuses: { nodeStatuses: {
1: 'completed', 'open-chatgpt': 'completed',
2: 'running', 'submit-signup-email': 'running',
}, },
oauthUrl: 'https://auth.example.com/start', oauthUrl: 'https://auth.example.com/start',
plusCheckoutTabId: 88, 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.activeFlowId, 'openai');
assert.equal(view.currentNodeId, 'submit-signup-email'); assert.equal(view.currentNodeId, 'submit-signup-email');
assert.deepStrictEqual(view.legacyStepCompat, { assert.equal(Object.prototype.hasOwnProperty.call(view, 'legacyStepCompat'), false);
currentStep: 2, assert.equal(Object.prototype.hasOwnProperty.call(view, 'currentStep'), false);
stepStatuses: { assert.equal(Object.prototype.hasOwnProperty.call(view, 'stepStatuses'), false);
1: 'completed',
2: 'running',
10: 'pending',
},
});
assert.deepStrictEqual(view.nodeStatuses, { assert.deepStrictEqual(view.nodeStatuses, {
'open-chatgpt': 'completed', 'open-chatgpt': 'completed',
'submit-signup-email': 'running', '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 api = loadRuntimeStateApi();
const helpers = api.createRuntimeStateHelpers({ const helpers = api.createRuntimeStateHelpers({
DEFAULT_ACTIVE_FLOW_ID: 'openai', DEFAULT_ACTIVE_FLOW_ID: 'openai',
defaultStepStatuses: { defaultNodeStatuses: {
1: 'pending', 'open-chatgpt': 'pending',
2: 'pending', 'submit-signup-email': 'pending',
10: 'pending', 'oauth-login': '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;
}, },
}); });
const patch = helpers.buildSessionStatePatch({ const patch = helpers.buildSessionStatePatch({
currentStep: 1, currentNodeId: 'open-chatgpt',
stepStatuses: { nodeStatuses: {
1: 'running', 'open-chatgpt': 'running',
2: 'pending', 'submit-signup-email': 'pending',
10: 'pending', 'oauth-login': 'pending',
}, },
oauthUrl: 'https://old.example.com/start', oauthUrl: 'https://old.example.com/start',
}, { }, {
runtimeState: { runtimeState: {
activeRunId: 'run-001', activeRunId: 'run-001',
currentNodeId: 'oauth-login',
nodeStatuses: {
'open-chatgpt': 'completed',
'oauth-login': 'running',
},
flowState: { flowState: {
openai: { openai: {
auth: { 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.currentNodeId, 'oauth-login');
assert.equal(patch.oauthUrl, 'https://new.example.com/start'); assert.equal(patch.oauthUrl, 'https://new.example.com/start');
assert.equal(patch.plusCheckoutTabId, 99); assert.equal(patch.plusCheckoutTabId, 99);
assert.equal(patch.currentStep, 10); assert.equal(Object.prototype.hasOwnProperty.call(patch, 'currentStep'), false);
assert.deepStrictEqual(patch.stepStatuses, { assert.equal(Object.prototype.hasOwnProperty.call(patch, 'stepStatuses'), false);
1: 'completed',
2: 'pending',
10: 'running',
});
assert.deepStrictEqual(patch.nodeStatuses, { assert.deepStrictEqual(patch.nodeStatuses, {
'open-chatgpt': 'completed', 'open-chatgpt': 'completed',
'submit-signup-email': 'pending', '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 assert = require('node:assert/strict');
const fs = require('node:fs'); 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({ const executor = step2Api.createStep2Executor({
addLog: async () => {}, addLog: async () => {},
chrome: { tabs: { update: async () => {} } }, chrome: { tabs: { update: async () => {} } },
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (step, payload) => {
completedPayloads.push({ step, payload }); completedPayloads.push({ step, payload });
}, },
ensureContentScriptReadyOnTab: async () => {}, ensureContentScriptReadyOnTab: async () => {},
@@ -40,7 +40,7 @@ test('step 2 completes with password step skipped when landing on email verifica
assert.deepStrictEqual(completedPayloads, [ assert.deepStrictEqual(completedPayloads, [
{ {
step: 2, step: 'submit-signup-email',
payload: { payload: {
email: 'user@example.com', email: 'user@example.com',
accountIdentifierType: 'email', accountIdentifierType: 'email',
@@ -59,7 +59,7 @@ test('step 2 keeps password flow when landing on password page', async () => {
const executor = step2Api.createStep2Executor({ const executor = step2Api.createStep2Executor({
addLog: async () => {}, addLog: async () => {},
chrome: { tabs: { update: async () => {} } }, chrome: { tabs: { update: async () => {} } },
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (step, payload) => {
completedPayloads.push({ step, payload }); completedPayloads.push({ step, payload });
}, },
ensureContentScriptReadyOnTab: async () => {}, ensureContentScriptReadyOnTab: async () => {},
@@ -79,7 +79,7 @@ test('step 2 keeps password flow when landing on password page', async () => {
assert.deepStrictEqual(completedPayloads, [ assert.deepStrictEqual(completedPayloads, [
{ {
step: 2, step: 'submit-signup-email',
payload: { payload: {
email: 'user@example.com', email: 'user@example.com',
accountIdentifierType: 'email', accountIdentifierType: 'email',
@@ -110,7 +110,7 @@ test('step 2 uses phone activation when resolved signup method is phone', async
const executor = step2Api.createStep2Executor({ const executor = step2Api.createStep2Executor({
addLog: async () => {}, addLog: async () => {},
chrome: { tabs: { update: async () => {} } }, chrome: { tabs: { update: async () => {} } },
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (step, payload) => {
completedPayloads.push({ step, payload }); completedPayloads.push({ step, payload });
}, },
ensureContentScriptReadyOnTab: async () => {}, ensureContentScriptReadyOnTab: async () => {},
@@ -170,7 +170,7 @@ test('step 2 uses phone activation when resolved signup method is phone', async
]); ]);
assert.deepStrictEqual(completedPayloads, [ assert.deepStrictEqual(completedPayloads, [
{ {
step: 2, step: 'submit-signup-email',
payload: { payload: {
accountIdentifierType: 'phone', accountIdentifierType: 'phone',
accountIdentifier: '66959916439', accountIdentifier: '66959916439',
@@ -200,7 +200,7 @@ test('step 2 reuses existing signup phone activation without acquiring a new num
const executor = step2Api.createStep2Executor({ const executor = step2Api.createStep2Executor({
addLog: async () => {}, addLog: async () => {},
chrome: { tabs: { update: async () => {} } }, chrome: { tabs: { update: async () => {} } },
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (step, payload) => {
completedPayloads.push({ step, payload }); completedPayloads.push({ step, payload });
}, },
ensureContentScriptReadyOnTab: async () => {}, ensureContentScriptReadyOnTab: async () => {},
@@ -263,7 +263,7 @@ test('step 2 submits manual signup phone without acquiring a number', async () =
const executor = step2Api.createStep2Executor({ const executor = step2Api.createStep2Executor({
addLog: async () => {}, addLog: async () => {},
chrome: { tabs: { update: async () => {} } }, chrome: { tabs: { update: async () => {} } },
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (step, payload) => {
completedPayloads.push({ step, payload }); completedPayloads.push({ step, payload });
}, },
ensureContentScriptReadyOnTab: async () => {}, ensureContentScriptReadyOnTab: async () => {},
@@ -310,7 +310,7 @@ test('step 2 submits manual signup phone without acquiring a number', async () =
]); ]);
assert.deepStrictEqual(completedPayloads, [ assert.deepStrictEqual(completedPayloads, [
{ {
step: 2, step: 'submit-signup-email',
payload: { payload: {
accountIdentifierType: 'phone', accountIdentifierType: 'phone',
accountIdentifier: '+446700000002', 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/' }), get: async () => ({ url: 'https://chatgpt.com/' }),
}, },
}, },
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (step, payload) => {
completedPayloads.push({ step, payload }); completedPayloads.push({ step, payload });
}, },
ensureContentScriptReadyOnTab: async () => {}, 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/' }), get: async () => ({ url: 'https://chatgpt.com/' }),
}, },
}, },
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (step, payload) => {
completedPayloads.push({ step, payload }); completedPayloads.push({ step, payload });
}, },
ensureContentScriptReadyOnTab: async () => {}, 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(sentPayloads, [{ email: 'user@example.com' }]);
assert.deepStrictEqual(completedPayloads, [ assert.deepStrictEqual(completedPayloads, [
{ {
step: 2, step: 'submit-signup-email',
payload: { payload: {
email: 'user@example.com', email: 'user@example.com',
accountIdentifierType: 'email', 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 }); completedPayloads.push({ step, payload });
}, },
ensureContentScriptReadyOnTab: async () => { 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.equal(logs.some((item) => item.meta.step === 2 && item.meta.stepKey === 'signup-entry'), true);
assert.deepStrictEqual(completedPayloads, [ assert.deepStrictEqual(completedPayloads, [
{ {
step: 2, step: 'submit-signup-email',
payload: { payload: {
email: 'user@example.com', email: 'user@example.com',
accountIdentifierType: 'email', accountIdentifierType: 'email',
+77 -70
View File
@@ -4,6 +4,19 @@ const fs = require('node:fs');
const source = fs.readFileSync('background.js', 'utf8'); 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) { function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`]; const markers = [`async function ${name}(`, `function ${name}(`];
const start = markers const start = markers
@@ -48,113 +61,107 @@ function extractFunction(name) {
return source.slice(start, end); return source.slice(start, end);
} }
test('skipStep cascades from step 1 to step 5 when downstream steps are pending', async () => { function createApi() {
const bundle = [ const bundle = [
extractFunction('isStepDoneStatus'), extractFunction('isStepDoneStatus'),
extractFunction('skipStep'), extractFunction('skipNode'),
].join('\n'); ].join('\n');
const statuses = { return new Function(`
1: 'pending', const DEFAULT_STATE = { nodeStatuses: {} };
2: 'pending', function getNodeIdsForState() {
3: 'pending', return ${JSON.stringify(OPENAI_NODE_IDS)};
4: 'pending', }
5: 'pending', function normalizeStatusMapForNodes(statuses = {}) {
6: 'pending', return { ...statuses };
7: 'pending', }
8: 'pending', ${bundle}
9: 'pending', return { skipNode };
10: 'pending', `)();
}; }
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 = { const events = {
setStepStatusCalls: [], setNodeStatusCalls: [],
logs: [], logs: [],
}; };
const api = createApi();
const api = new Function(`
const STEP_IDS = [1,2,3,4,5,6,7,8,9,10];
${bundle}
return { skipStep };
`)();
globalThis.ensureManualInteractionAllowed = async () => ({ globalThis.ensureManualInteractionAllowed = async () => ({
stepStatuses: { ...statuses }, nodeStatuses: { ...statuses },
}); });
globalThis.getState = async () => ({ globalThis.getState = async () => ({
stepStatuses: { ...statuses }, nodeStatuses: { ...statuses },
}); });
globalThis.setStepStatus = async (step, status) => { globalThis.setNodeStatus = async (nodeId, status) => {
events.setStepStatusCalls.push({ step, status }); events.setNodeStatusCalls.push({ nodeId, status });
statuses[step] = status; statuses[nodeId] = status;
}; };
globalThis.addLog = async (message, level) => { globalThis.addLog = async (message, level) => {
events.logs.push({ 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(result, { ok: true, nodeId: 'open-chatgpt', status: 'skipped' });
assert.deepStrictEqual(events.setStepStatusCalls, [ assert.deepStrictEqual(events.setNodeStatusCalls, [
{ step: 1, status: 'skipped' }, { nodeId: 'open-chatgpt', status: 'skipped' },
{ step: 2, status: 'skipped' }, { nodeId: 'submit-signup-email', status: 'skipped' },
{ step: 3, status: 'skipped' }, { nodeId: 'fill-password', status: 'skipped' },
{ step: 4, status: 'skipped' }, { nodeId: 'fetch-signup-code', status: 'skipped' },
{ step: 5, status: 'skipped' }, { nodeId: 'fill-profile', status: 'skipped' },
]); ]);
assert.equal(events.logs[0]?.message, '步骤 1 已跳过'); assert.equal(events.logs[0]?.message, '节点 open-chatgpt 已跳过');
assert.equal(events.logs[1]?.message, '步骤 1 已跳过,步骤 2、3、4、5 也已同时跳过。'); 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 () => { test('skipNode from open-chatgpt skips only unfinished linked signup nodes', async () => {
const bundle = [
extractFunction('isStepDoneStatus'),
extractFunction('skipStep'),
].join('\n');
const statuses = { const statuses = {
1: 'pending', 'open-chatgpt': 'pending',
2: 'completed', 'submit-signup-email': 'completed',
3: 'running', 'fill-password': 'running',
4: 'pending', 'fetch-signup-code': 'pending',
5: 'manual_completed', 'fill-profile': 'manual_completed',
6: 'pending', 'wait-registration-success': 'pending',
7: 'pending', 'oauth-login': 'pending',
8: 'pending', 'fetch-login-code': 'pending',
9: 'pending', 'confirm-oauth': 'pending',
10: 'pending', 'platform-verify': 'pending',
}; };
const events = { const events = {
setStepStatusCalls: [], setNodeStatusCalls: [],
logs: [], logs: [],
}; };
const api = createApi();
const api = new Function(`
const STEP_IDS = [1,2,3,4,5,6,7,8,9,10];
${bundle}
return { skipStep };
`)();
globalThis.ensureManualInteractionAllowed = async () => ({ globalThis.ensureManualInteractionAllowed = async () => ({
stepStatuses: { ...statuses }, nodeStatuses: { ...statuses },
}); });
globalThis.getState = async () => ({ globalThis.getState = async () => ({
stepStatuses: { ...statuses }, nodeStatuses: { ...statuses },
}); });
globalThis.setStepStatus = async (step, status) => { globalThis.setNodeStatus = async (nodeId, status) => {
events.setStepStatusCalls.push({ step, status }); events.setNodeStatusCalls.push({ nodeId, status });
statuses[step] = status; statuses[nodeId] = status;
}; };
globalThis.addLog = async (message, level) => { globalThis.addLog = async (message, level) => {
events.logs.push({ message, level }); events.logs.push({ message, level });
}; };
await api.skipStep(1); await api.skipNode('open-chatgpt');
assert.deepStrictEqual(events.setStepStatusCalls, [ assert.deepStrictEqual(events.setNodeStatusCalls, [
{ step: 1, status: 'skipped' }, { nodeId: 'open-chatgpt', status: 'skipped' },
{ step: 4, 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); return source.slice(start, end);
} }
function createApi(events, lastStepId = 10) { function createApi(events, lastNodeId = 'platform-verify') {
return new Function('events', 'lastStepId', ` return new Function('events', 'lastNodeId', `
let stopRequested = false; let stopRequested = false;
const LOG_PREFIX = '[test]'; const LOG_PREFIX = '[test]';
const STOP_ERROR_MESSAGE = '流程已被用户停止。'; const STOP_ERROR_MESSAGE = '流程已被用户停止。';
const LAST_STEP_ID = 10;
function getErrorMessage(error) { function getErrorMessage(error) {
return error?.message || String(error || ''); return error?.message || String(error || '');
} }
async function getState() { async function getState() {
events.push({ type: 'getState' }); events.push({ type: 'getState' });
return { stepStatuses: {}, contributionMode: true }; return { nodeStatuses: {}, contributionMode: true };
} }
function getLastStepIdForState() { function getLastNodeIdForState() {
return lastStepId; return lastNodeId;
} }
async function setStepStatus(step, status) { async function setNodeStatus(nodeId, status) {
events.push({ type: 'status', step, status }); events.push({ type: 'status', nodeId, status });
} }
async function addLog(message, level) { async function addLog(message, level, options = {}) {
events.push({ type: 'log', message, level }); events.push({ type: 'log', message, level, options });
} }
async function appendManualAccountRunRecordIfNeeded() { async function appendManualAccountRunRecordIfNeeded() {
events.push({ type: 'manual-record' }); events.push({ type: 'manual-record' });
} }
function notifyStepError(step, error) { function notifyNodeError(nodeId, error) {
events.push({ type: 'error', step, error }); events.push({ type: 'error', nodeId, error });
} }
function notifyStepComplete(step, payload) { function notifyNodeComplete(nodeId, payload) {
events.push({ type: 'notify', step, payload }); events.push({ type: 'notify', nodeId, payload });
} }
async function handleStepData(step, payload) { async function handleNodeData(nodeId, payload) {
events.push({ type: 'handle-start', step, payload }); events.push({ type: 'handle-start', nodeId, payload });
await new Promise((resolve) => setTimeout(resolve, 25)); await new Promise((resolve) => setTimeout(resolve, 25));
events.push({ type: 'handle-done', step }); events.push({ type: 'handle-done', nodeId });
} }
async function appendAndBroadcastAccountRunRecord(status, state) { async function appendAndBroadcastAccountRunRecord(status, state) {
events.push({ type: 'record', status, state }); events.push({ type: 'record', status, state });
} }
${extractFunction('runCompletedStepSideEffects')} ${extractFunction('runCompletedNodeSideEffects')}
${extractFunction('reportCompletedStepSideEffectError')} ${extractFunction('reportCompletedNodeSideEffectError')}
${extractFunction('completeStepFromBackground')} ${extractFunction('completeNodeFromBackground')}
return { completeStepFromBackground }; return { completeNodeFromBackground };
`)(events, lastStepId); `)(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 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); const types = events.map((event) => event.type);
assert.equal(types.indexOf('notify') < types.indexOf('handle-start'), true); 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); 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 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); const types = events.map((event) => event.type);
assert.equal(types.indexOf('handle-done') < types.indexOf('notify'), true); assert.equal(types.indexOf('handle-done') < types.indexOf('notify'), true);
+10 -8
View File
@@ -2,18 +2,20 @@ const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const fs = require('node:fs'); 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'); const source = fs.readFileSync('background.js', 'utf8');
assert.match(source, /background\/steps\/registry\.js/); assert.match(source, /background\/steps\/registry\.js/);
assert.match(source, /data\/step-definitions\.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, /getStepRegistryForState\(state\)/);
assert.match(source, /PLUS_PAYPAL_STEP_DEFINITIONS/); assert.match(source, /buildNodeRegistry\(definitions/);
assert.match(source, /PLUS_GOPAY_STEP_DEFINITIONS/); assert.match(source, /PLUS_PAYPAL_STEP_DEFINITIONS/);
assert.match(source, /plusPayPalStepRegistry/); assert.match(source, /PLUS_GOPAY_STEP_DEFINITIONS/);
assert.match(source, /plusGoPayStepRegistry/); assert.match(source, /plusPayPalStepRegistry/);
assert.match(source, /normalizePlusPaymentMethod\(state\?\.plusPaymentMethod\) === PLUS_PAYMENT_METHOD_GOPAY/); assert.match(source, /plusGoPayStepRegistry/);
assert.match(source, /activeStepRegistry\.executeStep\(step,\s*\{/); assert.match(source, /normalizePlusPaymentMethod\(state\?\.plusPaymentMethod\) === PLUS_PAYMENT_METHOD_GOPAY/);
assert.match(source, /activeStepRegistry\.executeNode\(normalizedNodeId,\s*\{/);
assert.match(source, /background\/steps\/create-plus-checkout\.js/); assert.match(source, /background\/steps\/create-plus-checkout\.js/);
assert.match(source, /background\/steps\/fill-plus-checkout\.js/); assert.match(source, /background\/steps\/fill-plus-checkout\.js/);
assert.match(source, /background\/steps\/gopay-manual-confirm\.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.passwordStates, ['Secret123!']);
assert.deepStrictEqual(events.messages, [ assert.deepStrictEqual(events.messages, [
{ {
type: 'EXECUTE_STEP', type: 'EXECUTE_NODE',
nodeId: 'fill-password',
step: 3, step: 3,
source: 'background', source: 'background',
payload: { payload: {
@@ -109,7 +110,8 @@ test('step 3 supports phone-only signup identity when password page is present',
]); ]);
assert.deepStrictEqual(events.messages, [ assert.deepStrictEqual(events.messages, [
{ {
type: 'EXECUTE_STEP', type: 'EXECUTE_NODE',
nodeId: 'fill-password',
step: 3, step: 3,
source: 'background', source: 'background',
payload: { 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 assert = require('node:assert/strict');
const fs = require('node:fs'); 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 () => {}, confirmCustomVerificationStepBypass: async () => {},
ensureMail2925MailboxSession: async () => { ensureMail2925MailboxSession: async () => {
ensureCalls += 1; ensureCalls += 1;
@@ -84,7 +84,7 @@ test('step 4 does not request a fresh code first for Cloudflare temp mail', asyn
update: async () => {}, update: async () => {},
}, },
}, },
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypass: async () => {}, confirmCustomVerificationStepBypass: async () => {},
ensureMail2925MailboxSession: async () => {}, ensureMail2925MailboxSession: async () => {},
getMailConfig: () => ({ getMailConfig: () => ({
@@ -135,7 +135,7 @@ test('step 4 checks iCloud session before polling iCloud mailbox', async () => {
update: async () => {}, update: async () => {},
}, },
}, },
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypass: async () => {}, confirmCustomVerificationStepBypass: async () => {},
ensureIcloudMailSession: async () => { ensureIcloudMailSession: async () => {
icloudChecks += 1; icloudChecks += 1;
@@ -183,7 +183,7 @@ test('step 4 forwards skipProfileStep when prepare stage already reached logged-
update: async () => {}, update: async () => {},
}, },
}, },
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (step, payload) => {
completions.push({ step, payload }); completions.push({ step, payload });
}, },
confirmCustomVerificationStepBypass: async () => {}, confirmCustomVerificationStepBypass: async () => {},
@@ -224,7 +224,7 @@ test('step 4 forwards skipProfileStep when prepare stage already reached logged-
assert.deepStrictEqual(completions, [ assert.deepStrictEqual(completions, [
{ {
step: 4, step: 'fetch-signup-code',
payload: { skipProfileStep: true }, payload: { skipProfileStep: true },
}, },
]); ]);
@@ -244,7 +244,7 @@ test('step 4 phone signup branch uses SMS helper and does not poll mailbox', asy
update: async () => {}, update: async () => {},
}, },
}, },
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (step, payload) => {
completions.push({ step, payload }); completions.push({ step, payload });
}, },
confirmCustomVerificationStepBypass: async () => {}, 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.equal(Object.prototype.hasOwnProperty.call(phoneCalls[0].options, 'signupProfile'), true);
assert.deepStrictEqual(completions, [ assert.deepStrictEqual(completions, [
{ {
step: 4, step: 'fetch-signup-code',
payload: { payload: {
phoneVerification: true, phoneVerification: true,
code: '', code: '',
@@ -321,7 +321,7 @@ test('step 4 phone signup email-verification handoff polls mailbox instead of co
update: async () => {}, update: async () => {},
}, },
}, },
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (step, payload) => {
completions.push({ step, payload }); completions.push({ step, payload });
}, },
confirmCustomVerificationStepBypass: async () => {}, confirmCustomVerificationStepBypass: async () => {},
@@ -403,7 +403,7 @@ test('step 4 prepare retries transport by recovering retry page without replayin
update: async () => {}, update: async () => {},
}, },
}, },
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypass: async () => {}, confirmCustomVerificationStepBypass: async () => {},
ensureMail2925MailboxSession: async () => {}, ensureMail2925MailboxSession: async () => {},
getMailConfig: () => ({ getMailConfig: () => ({
@@ -30,7 +30,8 @@ test('step 5 forwards generated profile data and relies on completion signal flo
{ {
source: 'signup-page', source: 'signup-page',
message: { message: {
type: 'EXECUTE_STEP', type: 'EXECUTE_NODE',
nodeId: 'fill-profile',
step: 5, step: 5,
source: 'background', source: 'background',
payload: { 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 assert = require('node:assert/strict');
const fs = require('node:fs'); 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') => { addLog: async (message, level = 'info') => {
events.logs.push({ message, level }); events.logs.push({ message, level });
}, },
completeStepFromBackground: async (step) => { completeNodeFromBackground: async (step) => {
events.completedSteps.push(step); events.completedSteps.push(step);
}, },
sleepWithStop: async (ms) => { sleepWithStop: async (ms) => {
@@ -28,7 +28,7 @@ test('step 6 waits for registration success and completes from background', asyn
await executor.executeStep6(); await executor.executeStep6();
assert.deepStrictEqual(events.waits, [20000]); 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))); 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({ const executor = api.createStep6Executor({
addLog: async () => {}, addLog: async () => {},
chrome: chromeApi, chrome: chromeApi,
completeStepFromBackground: async (step) => { completeNodeFromBackground: async (step) => {
events.completedSteps.push(step); events.completedSteps.push(step);
}, },
sleepWithStop: async () => {}, sleepWithStop: async () => {},
@@ -77,7 +77,7 @@ test('step 6 only clears cookies when cleanup switch is enabled', async () => {
await executor.executeStep6({ step6CookieCleanupEnabled: true }); 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, [ assert.deepStrictEqual(events.removedCookies, [
{ {
url: 'https://chatgpt.com/auth', 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({ const executor = api.createStep7Executor({
addLog: async () => {}, addLog: async () => {},
completeStepFromBackground: async () => { completeNodeFromBackground: async () => {
events.completed += 1; events.completed += 1;
}, },
getErrorMessage: (error) => error?.message || String(error || ''), 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') => { addLog: async (message, level = 'info') => {
events.logs.push({ message, level }); events.logs.push({ message, level });
}, },
completeStepFromBackground: async () => { completeNodeFromBackground: async () => {
events.completed += 1; events.completed += 1;
}, },
getErrorMessage: (error) => error?.message || String(error || ''), 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({ const executor = api.createStep7Executor({
addLog: async () => {}, addLog: async () => {},
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (step, payload) => {
events.completions.push({ step, payload }); events.completions.push({ step, payload });
}, },
getErrorMessage: (error) => error?.message || String(error || ''), 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, [ assert.deepStrictEqual(events.completions, [
{ {
step: 7, step: 'oauth-login',
payload: { payload: {
loginVerificationRequestedAt: null, loginVerificationRequestedAt: null,
skipLoginVerificationStep: true, skipLoginVerificationStep: true,
@@ -280,7 +280,7 @@ test('step 7 direct add-phone stays fatal when phone verification is disabled',
const executor = api.createStep7Executor({ const executor = api.createStep7Executor({
addLog: async () => {}, addLog: async () => {},
completeStepFromBackground: async () => { completeNodeFromBackground: async () => {
events.completions += 1; events.completions += 1;
}, },
getErrorMessage: (error) => error?.message || String(error || ''), 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({ const executor = api.createStep7Executor({
addLog: async () => {}, addLog: async () => {},
completeStepFromBackground: async () => { completeNodeFromBackground: async () => {
events.completions += 1; events.completions += 1;
}, },
getErrorMessage: (error) => error?.message || String(error || ''), 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({ const executor = api.createStep7Executor({
addLog: async () => {}, addLog: async () => {},
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
getErrorMessage: (error) => error?.message || String(error || ''), getErrorMessage: (error) => error?.message || String(error || ''),
getLoginAuthStateLabel: (state) => state || 'unknown', getLoginAuthStateLabel: (state) => state || 'unknown',
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs, options) => { getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs, options) => {
@@ -419,7 +419,7 @@ test('step 7 forwards direct OAuth consent skip metadata when completing', async
const executor = api.createStep7Executor({ const executor = api.createStep7Executor({
addLog: async () => {}, addLog: async () => {},
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (step, payload) => {
events.completions.push({ step, payload }); events.completions.push({ step, payload });
}, },
getErrorMessage: (error) => error?.message || String(error || ''), 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, [ assert.deepStrictEqual(events.completions, [
{ {
step: 10, step: 'oauth-login',
payload: { payload: {
loginVerificationRequestedAt: null, loginVerificationRequestedAt: null,
skipLoginVerificationStep: true, skipLoginVerificationStep: true,
@@ -469,7 +469,7 @@ test('step 7 forwards phone login identity payload when account identifier is ph
const executor = api.createStep7Executor({ const executor = api.createStep7Executor({
addLog: async () => {}, addLog: async () => {},
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (step, payload) => {
events.completions.push({ step, payload }); events.completions.push({ step, payload });
}, },
getErrorMessage: (error) => error?.message || String(error || ''), 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, [ assert.deepStrictEqual(events.completions, [
{ {
step: 7, step: 'oauth-login',
payload: { payload: {
loginVerificationRequestedAt: 123456, loginVerificationRequestedAt: 123456,
accountIdentifierType: 'phone', accountIdentifierType: 'phone',
@@ -558,7 +558,7 @@ test('step 7 keeps Plus email login even when phone sms runtime exists', async (
const executor = api.createStep7Executor({ const executor = api.createStep7Executor({
addLog: async () => {}, addLog: async () => {},
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
getErrorMessage: (error) => error?.message || String(error || ''), getErrorMessage: (error) => error?.message || String(error || ''),
getLoginAuthStateLabel: (state) => state || 'unknown', getLoginAuthStateLabel: (state) => state || 'unknown',
getState: async () => ({ 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({ const executor = api.createStep7Executor({
addLog: async () => {}, addLog: async () => {},
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
getErrorMessage: (error) => error?.message || String(error || ''), getErrorMessage: (error) => error?.message || String(error || ''),
getLoginAuthStateLabel: (state) => state || 'unknown', getLoginAuthStateLabel: (state) => state || 'unknown',
getState: async () => ({ ...phoneSignupState }), getState: async () => ({ ...phoneSignupState }),
@@ -668,7 +668,7 @@ test('step 7 can infer phone login from an available phone signup configuration
const executor = api.createStep7Executor({ const executor = api.createStep7Executor({
addLog: async () => {}, addLog: async () => {},
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
getErrorMessage: (error) => error?.message || String(error || ''), getErrorMessage: (error) => error?.message || String(error || ''),
getLoginAuthStateLabel: (state) => state || 'unknown', getLoginAuthStateLabel: (state) => state || 'unknown',
getState: async () => ({ getState: async () => ({
@@ -715,7 +715,7 @@ test('step 7 can start from a manually filled signup phone without completed ste
const executor = api.createStep7Executor({ const executor = api.createStep7Executor({
addLog: async () => {}, addLog: async () => {},
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (step, payload) => {
events.completions.push({ step, payload }); events.completions.push({ step, payload });
}, },
getErrorMessage: (error) => error?.message || String(error || ''), 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.equal(events.payloads[0].password, '');
assert.deepStrictEqual(events.completions, [ assert.deepStrictEqual(events.completions, [
{ {
step: 7, step: 'oauth-login',
payload: { payload: {
loginVerificationRequestedAt: 987654, loginVerificationRequestedAt: 987654,
accountIdentifierType: 'phone', accountIdentifierType: 'phone',
@@ -783,7 +783,7 @@ test('step 7 stops immediately when management secret is missing', async () => {
addLog: async (message, level = 'info') => { addLog: async (message, level = 'info') => {
events.logs.push({ message, level }); events.logs.push({ message, level });
}, },
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
getErrorMessage: (error) => error?.message || String(error || ''), getErrorMessage: (error) => error?.message || String(error || ''),
getLoginAuthStateLabel: (state) => state || 'unknown', getLoginAuthStateLabel: (state) => state || 'unknown',
getState: async () => ({ email: 'user@example.com', password: 'secret' }), 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') => { addLog: async (message, level = 'info') => {
events.logs.push({ message, level }); events.logs.push({ message, level });
}, },
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
getErrorMessage: (error) => error?.message || String(error || ''), getErrorMessage: (error) => error?.message || String(error || ''),
getLoginAuthStateLabel: (state) => state || 'unknown', getLoginAuthStateLabel: (state) => state || 'unknown',
getState: async () => ({ email: 'user@example.com', password: 'secret' }), 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 assert = require('node:assert/strict');
const fs = require('node:fs'); 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', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (step, payload) => {
calls.completions.push({ step, payload }); calls.completions.push({ step, payload });
}, },
confirmCustomVerificationStepBypass: async () => {}, 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', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (step, payload) => {
calls.completions.push({ step, payload }); calls.completions.push({ step, payload });
}, },
confirmCustomVerificationStepBypass: async () => {}, confirmCustomVerificationStepBypass: async () => {},
@@ -250,7 +250,7 @@ test('step 8 routes only a real phone verification page through sms helper', asy
]); ]);
assert.deepStrictEqual(calls.completions, [ assert.deepStrictEqual(calls.completions, [
{ {
step: 8, step: 'fetch-login-code',
payload: { payload: {
phoneVerification: true, phoneVerification: true,
loginPhoneVerification: 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', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (step, payload) => {
events.completeCalls.push({ step, payload }); events.completeCalls.push({ step, payload });
}, },
confirmCustomVerificationStepBypass: async () => {}, confirmCustomVerificationStepBypass: async () => {},
@@ -825,7 +825,7 @@ test('step 8 completes directly when auth page is already on OAuth consent page'
]); ]);
assert.deepStrictEqual(events.completeCalls, [ assert.deepStrictEqual(events.completeCalls, [
{ {
step: 8, step: 'fetch-login-code',
payload: { payload: {
loginVerificationRequestedAt: null, loginVerificationRequestedAt: null,
skipLoginVerificationStep: true, 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', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypass: async () => {}, confirmCustomVerificationStepBypass: async () => {},
ensureStep8VerificationPageReady: async () => { ensureStep8VerificationPageReady: async () => {
events.ensureCalls += 1; 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', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypass: async () => {}, confirmCustomVerificationStepBypass: async () => {},
ensureStep8VerificationPageReady: async () => ({ state: 'verification_page', displayedEmail: 'user@example.com' }), ensureStep8VerificationPageReady: async () => ({ state: 'verification_page', displayedEmail: 'user@example.com' }),
rerunStep7ForStep8Recovery: async () => {}, 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', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (step, payload) => {
events.completeCalls.push({ step, payload }); events.completeCalls.push({ step, payload });
}, },
confirmCustomVerificationStepBypass: async () => {}, 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.equal(events.rerunStep7, 0);
assert.deepStrictEqual(events.completeCalls, [ assert.deepStrictEqual(events.completeCalls, [
{ {
step: 8, step: 'fetch-login-code',
payload: { payload: {
loginVerificationRequestedAt: null, loginVerificationRequestedAt: null,
skipLoginVerificationStep: true, skipLoginVerificationStep: true,
+58 -5
View File
@@ -48,11 +48,59 @@ function extractFunction(name) {
return source.slice(start, end); 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 () => { test('generic stopped record resolves to next unfinished step during execution gap', async () => {
const bundle = [ const bundle = [
NODE_COMPAT_HELPERS,
extractFunction('isStepDoneStatus'),
extractFunction('getRunningNodeIds'),
extractFunction('getRunningSteps'), extractFunction('getRunningSteps'),
extractFunction('inferStoppedRecordNode'),
extractFunction('inferStoppedRecordStep'), extractFunction('inferStoppedRecordStep'),
extractFunction('resolveAccountRunRecordStatusForStop'), extractFunction('resolveAccountRunRecordStatusForStop'),
extractFunction('extractStoppedNodeFromRecordStatus'),
extractFunction('extractStoppedStepFromRecordStatus'), extractFunction('extractStoppedStepFromRecordStatus'),
extractFunction('resolveAccountRunRecordReasonForStop'), extractFunction('resolveAccountRunRecordReasonForStop'),
extractFunction('appendAndBroadcastAccountRunRecord'), extractFunction('appendAndBroadcastAccountRunRecord'),
@@ -103,10 +151,9 @@ return {
10: 'pending', 10: 'pending',
}, },
}; };
assert.equal(api.inferStoppedRecordStep(state), 7); assert.equal(api.inferStoppedRecordStep(state), 7);
assert.equal(api.resolveAccountRunRecordStatusForStop('stopped', state), 'step7_stopped'); assert.equal(api.resolveAccountRunRecordStatusForStop('stopped', state), 'node:oauth-login:stopped');
assert.equal(api.resolveAccountRunRecordReasonForStop('step7_stopped', '流程已被用户停止。'), '步骤 7 已被用户停止。'); assert.equal(api.resolveAccountRunRecordReasonForStop('node:oauth-login:stopped', '流程已被用户停止。'), '节点 oauth-login 已被用户停止。');
assert.equal( assert.equal(
api.resolveAccountRunRecordReasonForStop('step2_stopped', '步骤 2 已使用邮箱,流程尚未完成。'), api.resolveAccountRunRecordReasonForStop('step2_stopped', '步骤 2 已使用邮箱,流程尚未完成。'),
'步骤 2 已停止:邮箱已设置,流程尚未完成。' '步骤 2 已停止:邮箱已设置,流程尚未完成。'
@@ -114,19 +161,23 @@ return {
await api.appendAndBroadcastAccountRunRecord('stopped', state, '流程已被用户停止。'); await api.appendAndBroadcastAccountRunRecord('stopped', state, '流程已被用户停止。');
assert.deepStrictEqual(api.getCaptured(), { assert.deepStrictEqual(api.getCaptured(), {
status: 'step7_stopped', status: 'node:oauth-login:stopped',
state, state,
reason: '步骤 7 已被用户停止。', reason: '节点 oauth-login 已被用户停止。',
}); });
}); });
test('requestStop appends a stopped record for the next unfinished step when no step is running', async () => { test('requestStop appends a stopped record for the next unfinished step when no step is running', async () => {
const bundle = [ const bundle = [
NODE_COMPAT_HELPERS,
extractFunction('normalizeAutoRunSessionId'), extractFunction('normalizeAutoRunSessionId'),
extractFunction('clearCurrentAutoRunSessionId'), extractFunction('clearCurrentAutoRunSessionId'),
extractFunction('cleanupStep8NavigationListeners'), extractFunction('cleanupStep8NavigationListeners'),
extractFunction('rejectPendingStep8'), extractFunction('rejectPendingStep8'),
extractFunction('isStepDoneStatus'),
extractFunction('getRunningNodeIds'),
extractFunction('getRunningSteps'), extractFunction('getRunningSteps'),
extractFunction('inferStoppedRecordNode'),
extractFunction('inferStoppedRecordStep'), extractFunction('inferStoppedRecordStep'),
extractFunction('requestStop'), extractFunction('requestStop'),
].join('\n'); ].join('\n');
@@ -148,6 +199,7 @@ const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start';
const DEFAULT_STATE = { const DEFAULT_STATE = {
stepStatuses: Object.fromEntries([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((step) => [step, 'pending'])), 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 stepWaiters = new Map();
const appended = []; const appended = [];
const logs = []; const logs = [];
@@ -176,6 +228,7 @@ async function addLog(message, level) {
} }
async function broadcastStopToContentScripts() {} async function broadcastStopToContentScripts() {}
async function markRunningStepsStopped() {} async function markRunningStepsStopped() {}
async function markRunningNodesStopped() {}
async function broadcastAutoRunStatus() {} async function broadcastAutoRunStatus() {}
async function appendAndBroadcastAccountRunRecord(status, state, reason) { async function appendAndBroadcastAccountRunRecord(status, state, reason) {
appended.push({ 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'); const backgroundSource = fs.readFileSync('background.js', 'utf8');
assert.match(backgroundSource, /isGoPayCheckoutRestartRequiredFailure/); assert.match(backgroundSource, /isGoPayCheckoutRestartRequiredFailure/);
assert.match(backgroundSource, /GOPAY_RESTART_FROM_STEP6::/); assert.match(backgroundSource, /GOPAY_RESTART_FROM_STEP6::/);
assert.match(backgroundSource, /step === 8 && isGoPayCheckoutRestartRequiredFailure\(err\)/); assert.match(backgroundSource, /nodeId === 'paypal-approve' && isGoPayCheckoutRestartRequiredFailure\(err\)/);
assert.match(backgroundSource, /step = 6/); assert.match(backgroundSource, /getNodeIndex\(await getState\(\), 'plus-checkout-create'\)/);
assert.match(backgroundSource, /invalidateDownstreamAfterStepRestart\(5/); assert.match(backgroundSource, /invalidateDownstreamAfterAutoRunNodeRestart\(getPreviousNodeId\('plus-checkout-create'/);
}); });
test('GoPay approve gives PIN precedence over OTP on ambiguous second PIN pages', () => { 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 assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
const vm = require('node:vm'); const vm = require('node:vm');
@@ -56,7 +56,7 @@ function createExecutor({
}, },
}, },
}, },
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (step, payload) => {
events.completed.push({ step, payload }); events.completed.push({ step, payload });
}, },
ensureContentScriptReadyOnTabUntilStopped: async () => {}, 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.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); 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.deepEqual(events.updatedTabs, [{ tabId: 7, updateInfo: { active: true } }]);
assert.equal(events.logs.some(({ message }) => /发现 PayPal 页面/.test(message)), 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); 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(queries, [{}]);
assert.deepEqual(events.updatedTabs, [{ tabId: 9, updateInfo: { active: true } }]); 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 () => { 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.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.logs.some(({ message }) => /识别到密码页/.test(message)), true);
assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), 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.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); 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 assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
@@ -153,7 +153,7 @@ function createExecutorHarness({
getAllFrames: async () => frames, 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 }), ensureContentScriptReadyOnTabUntilStopped: async (source, tabId) => events.ensuredTabs.push({ source, tabId }),
fetch: fetchImpl, fetch: fetchImpl,
generateRandomName: () => ({ firstName: 'Ada', lastName: 'Lovelace' }), 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_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_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.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.states.some((updates) => updates.plusCheckoutTabId === checkoutTab.id), true);
assert.equal(events.logs.some((entry) => /当前已在 Plus Checkout 页面/.test(entry.message)), 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(fillMessage.frameId, 8);
assert.equal(subscribeMessage.frameId, 0); assert.equal(subscribeMessage.frameId, 0);
assert.equal(events.logs.some((entry) => /checkout iframe/.test(entry.message)), true); 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 () => { 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(fillMessage.message.payload.addressSeed.countryCode, 'ID');
assert.equal(subscribeMessage.message.payload.paymentMethod, 'gopay'); assert.equal(subscribeMessage.message.payload.paymentMethod, 'gopay');
assert.equal(checkoutTab.url, 'https://gopay.co.id/payment/session'); 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 () => { 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'); const selectMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL');
assert.equal(selectMessage.frameId, 0); 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 () => { 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(ensureAddressMessage.frameId, 8);
assert.equal(combinedFillMessage, undefined); assert.equal(combinedFillMessage, undefined);
assert.equal(events.logs.some((entry) => /Google 地址推荐/.test(entry.message)), true); 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 () => { 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', path: '/de-address',
method: 'refresh', 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 () => { 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.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) => entry.message === '步骤 7GPC 任务状态:等待 WhatsApp OTP'), true);
assert.equal(events.logs.some((entry) => /whatsapp_otp_wait/.test(entry.message)), false); 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.equal(events.completed[0].payload.plusCheckoutSource, 'gpc-helper');
assert.ok(events.sleeps.includes(3000)); 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.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.plusManualConfirmationMethod === 'gopay-otp'), false);
assert.equal(events.states.some((state) => state.gopayHelperTaskId === 'task_auto' && state.gopayHelperTaskStatus === 'completed'), true); 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'); 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), { assert.deepEqual(JSON.parse(fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_sms/otp')).options.body), {
otp: '654321', 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 () => { 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), { assert.deepEqual(JSON.parse(fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_wa/otp')).options.body), {
otp: '765432', 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.equal(helperUrls[1].searchParams.get('consume'), '1');
assert.ok(Number(helperUrls[1].searchParams.get('after_ms')) > 1710000000000); assert.ok(Number(helperUrls[1].searchParams.get('after_ms')) > 1710000000000);
assert.equal(events.logs.some((entry) => /OTP 校验失败/.test(entry.message)), true); 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 () => { 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')) .filter((call) => call.url.endsWith('/api/gp/tasks/task_manual_retry/otp'))
.map((call) => JSON.parse(call.options.body)); .map((call) => JSON.parse(call.options.body));
assert.deepEqual(otpBodies, [{ otp: '111111' }, { otp: '222222' }]); 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 () => { 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 assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
const vm = require('node:vm'); 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 }); events.push({ type: 'complete', step, payload });
}, },
ensureContentScriptReadyOnTabUntilStopped: async () => { ensureContentScriptReadyOnTabUntilStopped: async () => {
@@ -267,7 +267,7 @@ test('GoPay plus checkout create forwards gopay payment method to the checkout c
update: async () => {}, update: async () => {},
}, },
}, },
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
ensureContentScriptReadyOnTabUntilStopped: async () => {}, ensureContentScriptReadyOnTabUntilStopped: async () => {},
registerTab: async () => {}, registerTab: async () => {},
sendTabMessageUntilStopped: async (_tabId, _source, message) => { 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 }), 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 }), ensureContentScriptReadyOnTabUntilStopped: async (source, tabId, options) => events.push({ type: 'ready', source, tabId, options }),
fetch: async (url, options = {}) => { fetch: async (url, options = {}) => {
fetchCalls.push({ 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?.gopayHelperRemoteStage, 'checkout_start');
assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperReferenceId, ''); assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperReferenceId, '');
assert.ok(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperOrderCreatedAt > 0); 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'); 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 () => {}, remove: async () => {},
}, },
}, },
completeStepFromBackground: async (step, payload) => events.push({ type: 'complete', step, payload }), completeNodeFromBackground: async (step, payload) => events.push({ type: 'complete', step, payload }),
ensureContentScriptReadyOnTabUntilStopped: async () => {}, ensureContentScriptReadyOnTabUntilStopped: async () => {},
fetch: async (url, options = {}) => { fetch: async (url, options = {}) => {
fetchCalls.push({ 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(statePayload.gopayHelperTaskId, 'task_auto');
assert.equal(Object.prototype.hasOwnProperty.call(statePayload, 'gopayHelperPhoneMode'), false); assert.equal(Object.prototype.hasOwnProperty.call(statePayload, 'gopayHelperPhoneMode'), false);
assert.equal(statePayload.gopayHelperTaskStatus, 'queued'); 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 () => { 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 () => {}, remove: async () => {},
}, },
}, },
completeStepFromBackground: async (step, payload) => events.push({ type: 'complete', step, payload }), completeNodeFromBackground: async (step, payload) => events.push({ type: 'complete', step, payload }),
ensureContentScriptReadyOnTabUntilStopped: async () => {}, ensureContentScriptReadyOnTabUntilStopped: async () => {},
fetch: async (url, options = {}) => { fetch: async (url, options = {}) => {
fetchCalls.push({ 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 || {}; const statePayload = events.find((event) => event.type === 'set-state')?.payload || {};
assert.equal(statePayload.gopayHelperTaskId, 'task_auto_unknown_permission'); assert.equal(statePayload.gopayHelperTaskId, 'task_auto_unknown_permission');
assert.equal(Object.prototype.hasOwnProperty.call(statePayload, 'gopayHelperPhoneMode'), false); 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 () => { 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 () => {}, remove: async () => {},
}, },
}, },
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
ensureContentScriptReadyOnTabUntilStopped: async () => {}, ensureContentScriptReadyOnTabUntilStopped: async () => {},
fetch: async (url, options = {}) => { fetch: async (url, options = {}) => {
fetchCalls.push({ url, options }); fetchCalls.push({ url, options });
@@ -623,7 +623,7 @@ test('GPC checkout blocks exhausted API Keys before creating task', async () =>
remove: async () => {}, remove: async () => {},
}, },
}, },
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
ensureContentScriptReadyOnTabUntilStopped: async () => {}, ensureContentScriptReadyOnTabUntilStopped: async () => {},
fetch: async (url, options = {}) => { fetch: async (url, options = {}) => {
fetchCalls.push({ url, options }); fetchCalls.push({ url, options });
@@ -668,7 +668,7 @@ test('GPC checkout forwards selected SMS OTP channel', async () => {
remove: async () => {}, remove: async () => {},
}, },
}, },
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
ensureContentScriptReadyOnTabUntilStopped: async () => {}, ensureContentScriptReadyOnTabUntilStopped: async () => {},
fetch: async (url, options = {}) => { fetch: async (url, options = {}) => {
fetchCalls.push({ url, options }); fetchCalls.push({ url, options });
@@ -720,7 +720,7 @@ test('GPC checkout surfaces unified queue API errors', async () => {
remove: async () => {}, remove: async () => {},
}, },
}, },
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
ensureContentScriptReadyOnTabUntilStopped: async () => {}, ensureContentScriptReadyOnTabUntilStopped: async () => {},
fetch: async (url, options = {}) => { fetch: async (url, options = {}) => {
fetchCalls.push({ 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 () => {}, remove: async () => {},
}, },
}, },
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
ensureContentScriptReadyOnTabUntilStopped: async () => {}, ensureContentScriptReadyOnTabUntilStopped: async () => {},
fetch: async () => { fetch: async () => {
throw new Error('should not call helper API without helper phone'); 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 () => {}, remove: async () => {},
}, },
}, },
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
ensureContentScriptReadyOnTabUntilStopped: async () => {}, ensureContentScriptReadyOnTabUntilStopped: async () => {},
fetch: async () => { fetch: async () => {
throw new Error('should not call helper API without API Key'); 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 assert = require('node:assert/strict');
const fs = require('node:fs'); 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') => { addLog: async (message, level = 'info') => {
events.push({ type: 'log', message, level }); events.push({ type: 'log', message, level });
}, },
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (step, payload) => {
events.push({ type: 'complete', step, payload }); events.push({ type: 'complete', step, payload });
}, },
getTabId: async (source) => (source === 'paypal-flow' ? 77 : null), 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'), events.find((event) => event.type === 'complete'),
{ {
type: 'complete', type: 'complete',
step: 9, step: 'plus-checkout-return',
payload: { plusReturnUrl: 'https://chatgpt.com/backend-api/payments/success' }, 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 = [ const bundle = [
extractFunction('isDoneStatus'), extractFunction('isDoneStatus'),
extractFunction('getNodeStatuses'),
extractFunction('getStepStatuses'), extractFunction('getStepStatuses'),
extractFunction('escapeCssValue'),
extractFunction('renderSingleNodeStatus'),
extractFunction('renderSingleStepStatus'), extractFunction('renderSingleStepStatus'),
extractFunction('renderStepStatuses'), extractFunction('renderStepStatuses'),
extractFunction('updateProgressCounter'), extractFunction('updateProgressCounter'),
@@ -148,13 +151,24 @@ const STATUS_ICONS = {
manual_completed: 'M', manual_completed: 'M',
skipped: 'K', 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_IDS = [1, 2, 3];
let STEP_DEFAULT_STATUSES = { 1: 'pending', 2: 'pending', 3: 'pending' }; 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 rows = new Map(STEP_IDS.map((step) => [step, { className: 'step-row' }]));
const statusEls = new Map(STEP_IDS.map((step) => [step, { textContent: '' }])); 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 = { const document = {
querySelector(selector) { 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 match = selector.match(/data-step="(\\d+)"/);
const step = match ? Number(match[1]) : 0; const step = match ? Number(match[1]) : 0;
return selector.includes('step-status') ? statusEls.get(step) : rows.get(step); 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, /final \? 'SAVE_SIGNUP_PHONE' : 'SET_SIGNUP_PHONE_STATE'/);
assert.match(sidepanelSource, /message\.payload\.signupPhoneNumber !== undefined/); assert.match(sidepanelSource, /message\.payload\.signupPhoneNumber !== undefined/);
assert.match(sidepanelSource, /await persistSignupPhoneInputForAction\(\);\s*await saveSettings/); 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, /async function handleSkipStep\(step\)[\s\S]*await persistCurrentSettingsForAction\(\);/);
assert.match(sidepanelSource, /inputSignupPhone\.addEventListener\('input'[\s\S]*signupPhoneInputDirty = true/); 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('mail-163', true), false);
assert.equal(registry.shouldReportReadyForFrame('unknown-source', false), false); assert.equal(registry.shouldReportReadyForFrame('unknown-source', false), false);
assert.equal(registry.getCleanupOwnerSource('oauth-localhost-callback'), 'openai-auth'); 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 assert = require('node:assert/strict');
const fs = require('node:fs'); 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', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypass: async () => {}, confirmCustomVerificationStepBypass: async () => {},
ensureStep8VerificationPageReady: async () => { ensureStep8VerificationPageReady: async () => {
calls.ensureReady += 1; calls.ensureReady += 1;
+12 -4
View File
@@ -1,4 +1,4 @@
const assert = require('assert'); const assert = require('assert');
const fs = require('fs'); const fs = require('fs');
const helperSource = fs.readFileSync('background.js', 'utf8'); const helperSource = fs.readFileSync('background.js', 'utf8');
@@ -78,7 +78,7 @@ let autoRunAttemptRun = 4;
let autoRunSessionId = 99; let autoRunSessionId = 99;
const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start'; const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start';
const DEFAULT_STATE = { 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_CLICK_RETRY_DELAY_MS = 500;
const STEP8_MAX_ROUNDS = 5; const STEP8_MAX_ROUNDS = 5;
@@ -134,6 +134,7 @@ const chrome = {
}, },
}; };
const nodeWaiters = new Map();
const stepWaiters = new Map(); const stepWaiters = new Map();
let resumeWaiter = null; let resumeWaiter = null;
@@ -141,6 +142,13 @@ function cancelPendingCommands() {}
function abortActiveIcloudRequests() {} function abortActiveIcloudRequests() {}
async function addLog() {} async function addLog() {}
async function broadcastStopToContentScripts() {} async function broadcastStopToContentScripts() {}
function getRunningNodeIds() {
return [];
}
function inferStoppedRecordNode() {
return '';
}
async function markRunningNodesStopped() {}
async function markRunningStepsStopped() {} async function markRunningStepsStopped() {}
async function broadcastAutoRunStatus() {} async function broadcastAutoRunStatus() {}
async function appendAndBroadcastAccountRunRecord() {} async function appendAndBroadcastAccountRunRecord() {}
@@ -156,7 +164,7 @@ function isAutoRunScheduledState() {
function getStep8CallbackUrlFromNavigation() { return ''; } function getStep8CallbackUrlFromNavigation() { return ''; }
function getStep8CallbackUrlFromTabUpdate() { return ''; } function getStep8CallbackUrlFromTabUpdate() { return ''; }
function getStep8EffectLabel() { return 'no_effect'; } function getStep8EffectLabel() { return 'no_effect'; }
async function completeStepFromBackground() {} async function completeNodeFromBackground() {}
async function getTabId() { async function getTabId() {
return await new Promise((resolve) => { return await new Promise((resolve) => {
resolveTabId = resolve; resolveTabId = resolve;
@@ -218,7 +226,7 @@ const executor = self.MultiPageBackgroundStep9.createStep9Executor({
chrome, chrome,
cleanupStep8NavigationListeners, cleanupStep8NavigationListeners,
clickWithDebugger, clickWithDebugger,
completeStepFromBackground, completeNodeFromBackground,
ensureStep8SignupPageReady, ensureStep8SignupPageReady,
getStep8CallbackUrlFromNavigation, getStep8CallbackUrlFromNavigation,
getStep8CallbackUrlFromTabUpdate, 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 assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
@@ -102,8 +102,8 @@ function getWebNavCommittedListener() { return webNavCommittedListener; }
function setStep8TabUpdatedListener(listener) { step8TabUpdatedListener = listener; } function setStep8TabUpdatedListener(listener) { step8TabUpdatedListener = listener; }
function getStep8TabUpdatedListener() { return step8TabUpdatedListener; } function getStep8TabUpdatedListener() { return step8TabUpdatedListener; }
function setStep8PendingReject(handler) { step8PendingReject = handler; } function setStep8PendingReject(handler) { step8PendingReject = handler; }
async function completeStepFromBackground(step, payload) { async function completeNodeFromBackground(nodeId, payload) {
completePayload = { step, payload }; completePayload = { nodeId, payload };
} }
const STEP8_CLICK_RETRY_DELAY_MS = 1; const STEP8_CLICK_RETRY_DELAY_MS = 1;
@@ -120,7 +120,7 @@ const executor = self.MultiPageBackgroundStep9.createStep9Executor({
chrome, chrome,
cleanupStep8NavigationListeners, cleanupStep8NavigationListeners,
clickWithDebugger, clickWithDebugger,
completeStepFromBackground, completeNodeFromBackground,
ensureStep8SignupPageReady, ensureStep8SignupPageReady,
getOAuthFlowRemainingMs, getOAuthFlowRemainingMs,
getOAuthFlowStepTimeoutMs, getOAuthFlowStepTimeoutMs,
@@ -176,7 +176,7 @@ return {
assert.equal(snapshot.cleanupCalls >= 1, true); assert.equal(snapshot.cleanupCalls >= 1, true);
assert.equal(snapshot.hasPendingReject, false); assert.equal(snapshot.hasPendingReject, false);
assert.deepEqual(snapshot.completePayload, { assert.deepEqual(snapshot.completePayload, {
step: 9, nodeId: 'confirm-oauth',
payload: { payload: {
localhostUrl: 'http://localhost:1455/auth/callback?code=abc&state=xyz', localhostUrl: 'http://localhost:1455/auth/callback?code=abc&state=xyz',
}, },
@@ -279,8 +279,8 @@ function getWebNavCommittedListener() { return webNavCommittedListener; }
function setStep8TabUpdatedListener(listener) { step8TabUpdatedListener = listener; } function setStep8TabUpdatedListener(listener) { step8TabUpdatedListener = listener; }
function getStep8TabUpdatedListener() { return step8TabUpdatedListener; } function getStep8TabUpdatedListener() { return step8TabUpdatedListener; }
function setStep8PendingReject() {} function setStep8PendingReject() {}
async function completeStepFromBackground(step, payload) { async function completeNodeFromBackground(nodeId, payload) {
completePayload = { step, payload }; completePayload = { nodeId, payload };
} }
const STEP8_CLICK_RETRY_DELAY_MS = 1; const STEP8_CLICK_RETRY_DELAY_MS = 1;
@@ -297,7 +297,7 @@ const executor = self.MultiPageBackgroundStep9.createStep9Executor({
chrome, chrome,
cleanupStep8NavigationListeners, cleanupStep8NavigationListeners,
clickWithDebugger, clickWithDebugger,
completeStepFromBackground, completeNodeFromBackground,
ensureStep8SignupPageReady, ensureStep8SignupPageReady,
getOAuthFlowStepTimeoutMs, getOAuthFlowStepTimeoutMs,
getStep8CallbackUrlFromNavigation, getStep8CallbackUrlFromNavigation,
@@ -348,7 +348,7 @@ return {
assert.equal(snapshot.deferCalls >= 1, true); assert.equal(snapshot.deferCalls >= 1, true);
assert.equal(snapshot.cleanupCalls >= 1, true); assert.equal(snapshot.cleanupCalls >= 1, true);
assert.deepEqual(snapshot.completePayload, { assert.deepEqual(snapshot.completePayload, {
step: 12, nodeId: 'confirm-oauth',
payload: { payload: {
localhostUrl: 'http://localhost:1455/auth/callback?code=abc&state=xyz', 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 fs = require('fs');
const step9ModuleSource = fs.readFileSync('background/steps/confirm-oauth.js', 'utf8'); const step9ModuleSource = fs.readFileSync('background/steps/confirm-oauth.js', 'utf8');
@@ -148,8 +148,8 @@ function setStep8TabUpdatedListener(listener) { step8TabUpdatedListener = listen
function getStep8TabUpdatedListener() { return step8TabUpdatedListener; } function getStep8TabUpdatedListener() { return step8TabUpdatedListener; }
function setStep8PendingReject(handler) { step8PendingReject = handler; } function setStep8PendingReject(handler) { step8PendingReject = handler; }
async function completeStepFromBackground(step, payload) { async function completeNodeFromBackground(nodeId, payload) {
completePayload = { step, payload }; completePayload = { nodeId, payload };
} }
const STEP8_CLICK_RETRY_DELAY_MS = 200; const STEP8_CLICK_RETRY_DELAY_MS = 200;
@@ -167,7 +167,7 @@ const executor = self.MultiPageBackgroundStep9.createStep9Executor({
chrome, chrome,
cleanupStep8NavigationListeners, cleanupStep8NavigationListeners,
clickWithDebugger, clickWithDebugger,
completeStepFromBackground, completeNodeFromBackground,
ensureStep8SignupPageReady, ensureStep8SignupPageReady,
getOAuthFlowRemainingMs, getOAuthFlowRemainingMs,
getOAuthFlowStepTimeoutMs, getOAuthFlowStepTimeoutMs,
@@ -226,7 +226,7 @@ return {
assert.strictEqual(snapshot.cleanupCalls >= 1, true, 'step9 should cleanup navigation listeners'); assert.strictEqual(snapshot.cleanupCalls >= 1, true, 'step9 should cleanup navigation listeners');
assert.strictEqual(snapshot.hasPendingReject, false, 'step9 should clear pending reject after completion'); assert.strictEqual(snapshot.hasPendingReject, false, 'step9 should clear pending reject after completion');
assert.deepStrictEqual(snapshot.completePayload, { assert.deepStrictEqual(snapshot.completePayload, {
step: 9, nodeId: 'confirm-oauth',
payload: { payload: {
localhostUrl: 'http://localhost:1455/auth/callback?code=abc&state=xyz', 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}`); }, removeItem(key) { storage.delete(`session:${key}`); },
}, },
log() {}, log() {},
reportComplete(step, payload) { reportComplete(nodeId, payload) {
context.completed.push({ step, payload }); context.completed.push({ nodeId, payload });
}, },
reportReady() {}, reportReady() {},
reportError() {}, 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(exchangeCall.body.proxy_id, 7);
assert.equal(createCall.body.proxy_id, 7); assert.equal(createCall.body.proxy_id, 7);
assert.equal(createCall.body.group_ids[0], 5); 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 () => { 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(exchangeCall.body.code, 'callback-code');
assert.equal(createCall.body.group_ids[0], 5); 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 () => { 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 assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
const source = fs.readFileSync('background/verification-flow.js', 'utf8'); const source = fs.readFileSync('background/verification-flow.js', 'utf8');
const globalScope = {}; const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundVerificationFlow;`)(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 = {}) { function createVerificationFlowTestHelpers(overrides = {}) {
return api.createVerificationFlowHelpers({ return rawCreateVerificationFlowHelpers({
addLog: async () => {}, addLog: async () => {},
buildVerificationPollPayload: null, buildVerificationPollPayload: null,
chrome: { chrome: {
@@ -18,8 +63,9 @@ function createVerificationFlowTestHelpers(overrides = {}) {
}, },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
CLOUD_MAIL_PROVIDER: 'cloudmail', CLOUD_MAIL_PROVIDER: 'cloudmail',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getNodeIdByStepForState: getTestNodeIdByStepForState,
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0, getHotmailVerificationRequestTimestamp: () => 0,
getState: async () => ({}), getState: async () => ({}),
@@ -36,14 +82,16 @@ function createVerificationFlowTestHelpers(overrides = {}) {
sendToContentScript: async () => ({}), sendToContentScript: async () => ({}),
sendToMailContentScriptResilient: async () => ({}), sendToMailContentScriptResilient: async () => ({}),
setState: async () => {}, setState: async () => {},
setStepStatus: async () => {}, setNodeStatus: async () => {},
sleepWithStop: async () => {}, sleepWithStop: async () => {},
throwIfStopped: () => {}, throwIfStopped: () => {},
VERIFICATION_POLL_MAX_ROUNDS: 5, VERIFICATION_POLL_MAX_ROUNDS: 5,
...overrides, ...normalizeVerificationFlowTestOverrides(overrides),
}); });
} }
api.createVerificationFlowHelpers = createVerificationFlowTestHelpers;
test('verification flow prefers injected verification poll payload builder when provided', () => { test('verification flow prefers injected verification poll payload builder when provided', () => {
const helpers = createVerificationFlowTestHelpers({ const helpers = createVerificationFlowTestHelpers({
buildVerificationPollPayload: (step, state, overrides = {}) => ({ buildVerificationPollPayload: (step, state, overrides = {}) => ({
@@ -85,7 +133,7 @@ test('verification flow keeps 2925 polling cadence in the default payload', () =
addLog: async () => {}, addLog: async () => {},
chrome: { tabs: { update: async () => {} } }, chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0, getHotmailVerificationRequestTimestamp: () => 0,
@@ -184,7 +232,7 @@ test('verification flow only enables 2925 target email matching in receive mode'
addLog: async () => {}, addLog: async () => {},
chrome: { tabs: { update: async () => {} } }, chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0, getHotmailVerificationRequestTimestamp: () => 0,
@@ -233,7 +281,7 @@ test('verification flow runs beforeSubmit hook before filling the code', async (
}, },
}, },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (_step, payload) => { completeNodeFromBackground: async (_step, payload) => {
events.push(['complete', payload.code]); events.push(['complete', payload.code]);
}, },
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), 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', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0, 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', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0, 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'); throw new Error('should not use family cleanup when tracked tab exists');
}, },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0, 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', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (_step, payload) => { completeNodeFromBackground: async (_step, payload) => {
events.push(['complete', payload.code]); events.push(['complete', payload.code]);
}, },
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), 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', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (_step, payload) => { completeNodeFromBackground: async (_step, payload) => {
events.push(['complete', payload.code]); events.push(['complete', payload.code]);
}, },
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), 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', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => { completeNodeFromBackground: async () => {
throw new Error('should not complete step 8'); throw new Error('should not complete step 8');
}, },
confirmCustomVerificationStepBypassRequest: async () => ({ 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', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0, getHotmailVerificationRequestTimestamp: () => 0,
@@ -753,7 +801,7 @@ test('verification flow keeps mail polling response timeout above minimum floor'
}, },
}, },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0, 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', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0, getHotmailVerificationRequestTimestamp: () => 0,
@@ -881,7 +929,7 @@ test('verification flow can run a 2/3/15 2925 resend polling plan', async () =>
addLog: async () => {}, addLog: async () => {},
chrome: { tabs: { update: async () => {} } }, chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0, getHotmailVerificationRequestTimestamp: () => 0,
@@ -953,7 +1001,7 @@ test('verification flow uses full 2925 polling window after a rejected login cod
addLog: async () => {}, addLog: async () => {},
chrome: { tabs: { update: async () => {} } }, chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0, getHotmailVerificationRequestTimestamp: () => 0,
@@ -1026,7 +1074,7 @@ test('verification flow keeps Hotmail request timestamp filtering on the first p
addLog: async () => {}, addLog: async () => {},
chrome: { tabs: { update: async () => {} } }, chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 87654, getHotmailVerificationRequestTimestamp: () => 87654,
@@ -1081,7 +1129,7 @@ test('verification flow keeps fixed filter timestamp after step 4 resend', async
addLog: async () => {}, addLog: async () => {},
chrome: { tabs: { update: async () => {} } }, chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: (_step, state) => Math.max(0, Number(state.signupVerificationRequestedAt || 0) - 15000), 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 () => {}, addLog: async () => {},
chrome: { tabs: { update: async () => {} } }, chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0, getHotmailVerificationRequestTimestamp: () => 0,
@@ -1206,7 +1254,7 @@ test('verification flow uses configured login resend count for step 8', async ()
addLog: async () => {}, addLog: async () => {},
chrome: { tabs: { update: async () => {} } }, chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0, getHotmailVerificationRequestTimestamp: () => 0,
@@ -1265,8 +1313,8 @@ test('verification flow can complete Plus visible login-code step with shared st
addLog: async () => {}, addLog: async () => {},
chrome: { tabs: { update: async () => {} } }, chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (nodeId, payload) => {
completed.push({ step, payload }); completed.push({ nodeId, payload });
}, },
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), 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(fillMessages.map((message) => message.step), [8]);
assert.deepStrictEqual(completed, [ assert.deepStrictEqual(completed, [
{ {
step: 11, nodeId: 'fetch-login-code',
payload: { payload: {
emailTimestamp: 456, emailTimestamp: 456,
code: '654321', code: '654321',
@@ -1328,7 +1376,7 @@ test('verification flow waits during resend cooldown instead of tight-looping',
addLog: async () => {}, addLog: async () => {},
chrome: { tabs: { update: async () => {} } }, chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0, getHotmailVerificationRequestTimestamp: () => 0,
@@ -1389,7 +1437,7 @@ test('verification flow clicks resend before waiting for the next LuckMail /code
addLog: async () => {}, addLog: async () => {},
chrome: { tabs: { update: async () => {} } }, chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0, getHotmailVerificationRequestTimestamp: () => 0,
@@ -1459,7 +1507,7 @@ test('verification flow notifies onResendRequestedAt when resend is triggered',
addLog: async () => {}, addLog: async () => {},
chrome: { tabs: { update: async () => {} } }, chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0, getHotmailVerificationRequestTimestamp: () => 0,
@@ -1532,7 +1580,7 @@ test('verification flow uses resilient signup-page transport when submitting ver
}, },
}, },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0, 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', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0, 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', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (_step, payload) => { completeNodeFromBackground: async (_step, payload) => {
completed.push(payload); completed.push(payload);
}, },
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), 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', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0, getHotmailVerificationRequestTimestamp: () => 0,
@@ -1787,8 +1835,8 @@ test('verification flow keeps combined signup profile skip reason when completin
}, },
}, },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (step, payload) => { completeNodeFromBackground: async (nodeId, payload) => {
completed.push({ step, payload }); completed.push({ nodeId, payload });
}, },
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), 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.equal(resilientCalls[0].payload.signupProfile.firstName, 'Ada');
assert.deepStrictEqual(completed, [ assert.deepStrictEqual(completed, [
{ {
step: 4, nodeId: 'fetch-signup-code',
payload: { payload: {
emailTimestamp: 123, emailTimestamp: 123,
code: '654321', code: '654321',
@@ -1869,7 +1917,7 @@ test('verification flow treats retryable submit transport failure as success whe
}, },
}, },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0, 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', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0, 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', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0, getHotmailVerificationRequestTimestamp: () => 0,
@@ -2043,7 +2091,7 @@ test('verification flow derives iCloud polling response timeout from the configu
}, },
}, },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0, getHotmailVerificationRequestTimestamp: () => 0,
+3 -3
View File
@@ -1,4 +1,4 @@
const assert = require('assert'); const assert = require('assert');
const fs = require('fs'); const fs = require('fs');
const helperSource = fs.readFileSync('background.js', 'utf8'); const helperSource = fs.readFileSync('background.js', 'utf8');
@@ -97,7 +97,7 @@ const helpers = self.MultiPageBackgroundVerificationFlow.createVerificationFlowH
addLog, addLog,
chrome: {}, chrome: {},
CLOUDFLARE_TEMP_EMAIL_PROVIDER, CLOUDFLARE_TEMP_EMAIL_PROVIDER,
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig, getHotmailVerificationPollConfig,
getHotmailVerificationRequestTimestamp: () => 123, getHotmailVerificationRequestTimestamp: () => 123,
@@ -164,7 +164,7 @@ const helpers = self.MultiPageBackgroundVerificationFlow.createVerificationFlowH
addLog, addLog,
chrome, chrome,
CLOUDFLARE_TEMP_EMAIL_PROVIDER, CLOUDFLARE_TEMP_EMAIL_PROVIDER,
completeStepFromBackground: async () => {}, completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 123, getHotmailVerificationRequestTimestamp: () => 123,