From 81fc40706a4d5457a7903116b1eb8553ec15fd6d Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Fri, 15 May 2026 17:41:35 +0800 Subject: [PATCH] Refactor workflow auto-run to node graph --- background.js | 1254 +++++++++++------ background/account-run-history.js | 101 +- background/auto-run-controller.js | 157 ++- background/logging-status.js | 68 +- background/mail-rule-registry.js | 32 + background/message-router.js | 303 ++-- background/runtime-state.js | 153 +- background/steps/confirm-oauth.js | 4 +- background/steps/create-plus-checkout.js | 6 +- background/steps/fetch-login-code.js | 20 +- background/steps/fetch-signup-code.js | 6 +- background/steps/fill-password.js | 3 +- background/steps/fill-plus-checkout.js | 6 +- background/steps/fill-profile.js | 11 +- background/steps/gopay-approve.js | 4 +- background/steps/oauth-login.js | 9 +- background/steps/open-chatgpt.js | 4 +- background/steps/paypal-approve.js | 4 +- background/steps/platform-verify.js | 10 +- background/steps/plus-return-confirm.js | 4 +- background/steps/registry.js | 39 +- background/steps/submit-signup-email.js | 9 +- background/steps/wait-registration-success.js | 4 +- background/tab-runtime.js | 2 +- background/verification-flow.js | 23 +- background/workflow-engine.js | 157 +++ content/signup-page.js | 44 +- content/sub2api-panel.js | 25 +- content/utils.js | 137 +- content/vps-panel.js | 28 +- data/step-definitions.js | 177 ++- docs/md/步骤与Flow节点化重构开发方案.md | 380 +++++ flows/openai/mail-rules.js | 42 +- shared/source-registry.js | 47 +- sidepanel/sidepanel.js | 386 +++-- tests/auto-run-add-phone-stop.test.js | 113 ++ tests/auto-run-fresh-attempt-reset.test.js | 78 +- tests/auto-run-hotmail-preflight.test.js | 84 ++ ...un-step4-mail2925-thread-terminate.test.js | 106 +- tests/auto-run-step4-restart.test.js | 132 +- tests/auto-run-step6-restart.test.js | 140 +- tests/auto-step-random-delay.test.js | 4 + ...kground-account-run-history-module.test.js | 53 +- tests/background-auth-chain-guard.test.js | 100 +- tests/background-auto-run-module.test.js | 18 +- ...ckground-mail-rule-registry-module.test.js | 10 + ...und-message-router-plus-final-step.test.js | 20 +- ...ckground-message-router-step2-skip.test.js | 156 +- ...ckground-platform-verify-codex2api.test.js | 10 +- ...background-platform-verify-cpa-api.test.js | 8 +- tests/background-runtime-state-module.test.js | 83 +- .../background-signup-step2-branching.test.js | 30 +- tests/background-skip-step-linking.test.js | 147 +- tests/background-step-completion.test.js | 55 +- tests/background-step-registry.test.js | 18 +- tests/background-step3-password-reuse.test.js | 6 +- tests/background-step4-filter-window.test.js | 20 +- ...kground-step5-submit-short-circuit.test.js | 3 +- tests/background-step6-retry-limit.test.js | 46 +- tests/background-step7-recovery.test.js | 20 +- tests/background-stop-gap-record.test.js | 63 +- tests/gopay-approve-source.test.js | 8 +- tests/paypal-approve-detection.test.js | 14 +- ...us-checkout-billing-tab-resolution.test.js | 28 +- tests/plus-checkout-create-wait.test.js | 30 +- tests/plus-return-confirm-wait.test.js | 6 +- tests/sidepanel-contribution-mode.test.js | 16 +- ...epanel-phone-verification-settings.test.js | 2 +- tests/source-registry-module.test.js | 3 + tests/step8-restart-step7-error.test.js | 4 +- tests/step8-stop-cleanup.test.js | 16 +- tests/step9-oauth-timeout-toggle.test.js | 18 +- tests/step9-timeout-recovery.test.js | 10 +- tests/sub2api-panel-proxy.test.js | 10 +- tests/verification-flow-polling.test.js | 128 +- tests/verification-stop-propagation.test.js | 6 +- 76 files changed, 4028 insertions(+), 1453 deletions(-) create mode 100644 background/workflow-engine.js create mode 100644 docs/md/步骤与Flow节点化重构开发方案.md diff --git a/background.js b/background.js index 32691c6..d91fd6a 100644 --- a/background.js +++ b/background.js @@ -20,6 +20,7 @@ importScripts( 'background/sub2api-api.js', 'background/panel-bridge.js', 'background/registration-email-state.js', + 'background/workflow-engine.js', 'background/runtime-state.js', 'background/generated-email-helpers.js', 'background/signup-flow-helpers.js', @@ -96,6 +97,10 @@ const STEP_IDS = Array.from(new Set(ALL_STEP_DEFINITIONS .filter(Number.isFinite))) .sort((left, right) => left - right); const DEFAULT_STEP_STATUSES = Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending'])); +const DEFAULT_NODE_IDS = Array.from(new Set(ALL_STEP_DEFINITIONS + .map((definition) => String(definition?.key || '').trim()) + .filter(Boolean))); +const DEFAULT_NODE_STATUSES = Object.fromEntries(DEFAULT_NODE_IDS.map((nodeId) => [nodeId, 'pending'])); const NORMAL_STEP_IDS = NORMAL_STEP_DEFINITIONS .map((definition) => Number(definition?.id)) .filter(Number.isFinite) @@ -223,8 +228,7 @@ const { const registrationEmailStateHelpers = self.MultiPageRegistrationEmailState?.createRegistrationEmailStateHelpers?.() || null; const runtimeStateHelpers = self.MultiPageBackgroundRuntimeState?.createRuntimeStateHelpers?.({ DEFAULT_ACTIVE_FLOW_ID, - defaultStepStatuses: DEFAULT_STEP_STATUSES, - getStepDefinitionForState: (step, state) => getStepDefinitionForState(step, state), + defaultNodeStatuses: DEFAULT_NODE_STATUSES, }) || null; const DEFAULT_REGISTRATION_EMAIL_STATE = registrationEmailStateHelpers?.DEFAULT_REGISTRATION_EMAIL_STATE || { current: '', @@ -685,6 +689,74 @@ function getStepIdByKeyForState(stepKey, state = {}) { return null; } +function getNodeDefinitionsForState(state = {}) { + if (workflowEngine?.getNodesForState) { + return workflowEngine.getNodesForState(state); + } + if (self.MultiPageStepDefinitions?.getNodes) { + return self.MultiPageStepDefinitions.getNodes({ + ...state, + activeFlowId: state?.activeFlowId || state?.flowId || DEFAULT_ACTIVE_FLOW_ID, + flowId: state?.flowId || state?.activeFlowId || DEFAULT_ACTIVE_FLOW_ID, + }); + } + return getStepDefinitionsForState(state) + .map((definition) => ({ + legacyStepId: Number(definition?.id), + nodeId: String(definition?.key || '').trim(), + displayOrder: Number.isFinite(Number(definition?.order)) ? Number(definition.order) : Number(definition?.id), + title: String(definition?.title || '').trim(), + executeKey: String(definition?.key || '').trim(), + })) + .filter((definition) => definition.nodeId); +} + +function getNodeIdsForState(state = {}) { + if (workflowEngine?.getNodeIdsForState) { + return workflowEngine.getNodeIdsForState(state); + } + return getNodeDefinitionsForState(state).map((definition) => definition.nodeId).filter(Boolean); +} + +function getNodeDefinitionForState(nodeId, state = {}) { + const normalizedNodeId = String(nodeId || '').trim(); + if (!normalizedNodeId) return null; + if (workflowEngine?.getNodeById) { + return workflowEngine.getNodeById(normalizedNodeId, state); + } + return getNodeDefinitionsForState(state).find((definition) => definition.nodeId === normalizedNodeId) || null; +} + +function getLastNodeIdForState(state = {}) { + const nodeIds = getNodeIdsForState(state); + return nodeIds[nodeIds.length - 1] || ''; +} + +function getNodeIdByStepForState(step, state = {}) { + const definition = getStepDefinitionForState(step, state); + return String(definition?.key || '').trim(); +} + +function getStepIdByNodeIdForState(nodeId, state = {}) { + const normalizedNodeId = String(nodeId || '').trim(); + if (!normalizedNodeId) return null; + const node = getNodeDefinitionForState(normalizedNodeId, state); + const legacyStepId = Number(node?.legacyStepId); + if (Number.isInteger(legacyStepId) && legacyStepId > 0) { + return legacyStepId; + } + return getStepIdByKeyForState(normalizedNodeId, state); +} + +function getNodeTitleForState(nodeId, state = {}) { + const normalizedNodeId = String(nodeId || '').trim(); + if (!normalizedNodeId) return ''; + if (workflowEngine?.getNodeTitle) { + return workflowEngine.getNodeTitle(normalizedNodeId, state); + } + return getNodeDefinitionForState(normalizedNodeId, state)?.title || normalizedNodeId; +} + initializeSessionStorageAccess(); setupDeclarativeNetRequestRules(); @@ -901,12 +973,12 @@ const SETTINGS_EXPORT_FILENAME_PREFIX = 'multipage-settings'; const STEP6_REGISTRATION_SUCCESS_WAIT_MS = 20000; const DEFAULT_STATE = { + flowId: DEFAULT_ACTIVE_FLOW_ID, + runId: '', activeFlowId: DEFAULT_ACTIVE_FLOW_ID, activeRunId: '', currentNodeId: '', - nodeStatuses: {}, - currentStep: 0, // 当前流程执行到的步骤编号。 - stepStatuses: { ...DEFAULT_STEP_STATUSES }, + nodeStatuses: { ...DEFAULT_NODE_STATUSES }, runtimeState: runtimeStateHelpers?.buildDefaultRuntimeState?.() || null, ...CONTRIBUTION_RUNTIME_DEFAULTS, oauthUrl: null, // 运行时抓取到的 OAuth 地址,不要手动预填。 @@ -3100,7 +3172,7 @@ async function exportSettingsBundle() { async function importSettingsBundle(configBundle) { const state = await ensureManualInteractionAllowed('\u5bfc\u5165\u914d\u7f6e'); - if (Object.values(state.stepStatuses || {}).some((status) => status === 'running')) { + if (Object.values(state.nodeStatuses || {}).some((status) => status === 'running')) { throw new Error('\u5f53\u524d\u6709\u6b65\u9aa4\u6b63\u5728\u6267\u884c\uff0c\u65e0\u6cd5\u5bfc\u5165\u914d\u7f6e\u3002'); } if (!configBundle || typeof configBundle !== 'object' || Array.isArray(configBundle)) { @@ -3247,8 +3319,8 @@ async function setEmailState(email, options = {}) { await setEmailStateSilently(email, options); if (email) { const latestState = await getState(); - const recordStatus = shouldMarkAccountRunRecordRunning(latestState) ? 'running' : 'step2_stopped'; - const recordReason = recordStatus === 'running' ? '正在运行' : '步骤 2 已使用邮箱,流程尚未完成。'; + const recordStatus = shouldMarkAccountRunRecordRunning(latestState) ? 'running' : 'node:submit-signup-email:stopped'; + const recordReason = recordStatus === 'running' ? '正在运行' : '节点 submit-signup-email 已使用邮箱,流程尚未完成。'; await appendManualAccountRunRecordIfNeeded(recordStatus, latestState, recordReason); await resumeAutoRunIfWaitingForEmail(); } @@ -3328,8 +3400,8 @@ async function setSignupPhoneState(phoneNumber) { await setSignupPhoneStateSilently(phoneNumber); if (String(phoneNumber || '').trim()) { const latestState = await getState(); - const recordStatus = shouldMarkAccountRunRecordRunning(latestState) ? 'running' : 'step2_stopped'; - const recordReason = recordStatus === 'running' ? '正在运行' : '步骤 2 已使用手机号,流程尚未完成。'; + const recordStatus = shouldMarkAccountRunRecordRunning(latestState) ? 'running' : 'node:submit-signup-email:stopped'; + const recordReason = recordStatus === 'running' ? '正在运行' : '节点 submit-signup-email 已使用手机号,流程尚未完成。'; await appendManualAccountRunRecordIfNeeded(recordStatus, latestState, recordReason); } } @@ -7866,17 +7938,21 @@ function getSourceLabel(source) { // Step Status Management // ============================================================ -async function setStepStatus(step, status) { - if (typeof loggingStatus !== 'undefined' && loggingStatus?.setStepStatus) { - return loggingStatus.setStepStatus(step, status); +async function setNodeStatus(nodeId, status) { + const normalizedNodeId = String(nodeId || '').trim(); + if (!normalizedNodeId) { + throw new Error('setNodeStatus 缺少 nodeId。'); } const state = await getState(); - const statuses = { ...state.stepStatuses }; - statuses[step] = status; - await setState({ stepStatuses: statuses, currentStep: step }); + const nodeStatuses = { ...(state.nodeStatuses || {}) }; + nodeStatuses[normalizedNodeId] = status; + await setState({ + nodeStatuses, + currentNodeId: normalizedNodeId, + }); chrome.runtime.sendMessage({ - type: 'STEP_STATUS_CHANGED', - payload: { step, status }, + type: 'NODE_STATUS_CHANGED', + payload: { nodeId: normalizedNodeId, status }, }).catch(() => { }); } @@ -7915,6 +7991,10 @@ const sourceRegistry = self.MultiPageSourceRegistry?.createSourceRegistry?.() || const flowCapabilityRegistry = self.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ defaultFlowId: DEFAULT_ACTIVE_FLOW_ID, }) || null; +const workflowEngine = self.MultiPageBackgroundWorkflowEngine?.createWorkflowEngine?.({ + defaultFlowId: DEFAULT_ACTIVE_FLOW_ID, + workflowDefinitions: self.MultiPageStepDefinitions, +}) || null; const navigationUtils = self.MultiPageBackgroundNavigationUtils?.createNavigationUtils({ DEFAULT_CODEX2API_URL, @@ -7926,6 +8006,8 @@ const navigationUtils = self.MultiPageBackgroundNavigationUtils?.createNavigatio const loggingStatus = self.MultiPageBackgroundLoggingStatus?.createLoggingStatus({ chrome, DEFAULT_STATE, + getStepDefinitionForState, + getStepIdByNodeIdForState, getState, isRecoverableStep9AuthFailure, LOG_PREFIX, @@ -8119,7 +8201,7 @@ function getSignupPhonePasswordMismatchRestartPayload(preservedState = {}) { }; } -async function restartSignupPhonePasswordMismatchAttemptFromStep(step, restartCount, error) { +async function restartSignupPhonePasswordMismatchAttemptFromNode(nodeId, restartCount, error) { const preservedState = await getState(); const { activeSignupPhoneNumber, @@ -8137,15 +8219,22 @@ async function restartSignupPhonePasswordMismatchAttemptFromStep(step, restartCo .test(errorMessage) ? '注册手机号异常' : '手机号/密码不匹配'); + const normalizedNodeId = String(nodeId || '').trim() || 'fetch-signup-code'; await addLog( - `步骤 ${step}:检测到${reasonLabel},准备丢弃当前注册手机号并回到步骤 1 重新开始(第 ${restartCount} 次重开)。${phoneSuffix}${emailSuffix}原因:${errorMessage}`, + `节点 ${normalizedNodeId}:检测到${reasonLabel},准备丢弃当前注册手机号并回到节点 open-chatgpt 重新开始(第 ${restartCount} 次重开)。${phoneSuffix}${emailSuffix}原因:${errorMessage}`, 'warn' ); - await invalidateDownstreamAfterStepRestart(1, { - logLabel: `步骤 ${step} 检测到${reasonLabel}后准备回到步骤 1 重新获取手机号重试(第 ${restartCount} 次重开)`, - }); + if (typeof invalidateDownstreamAfterNodeRestart === 'function') { + await invalidateDownstreamAfterNodeRestart('open-chatgpt', { + logLabel: `节点 ${normalizedNodeId} 检测到${reasonLabel}后准备回到 open-chatgpt 重新获取手机号重试(第 ${restartCount} 次重开)`, + }); + } else { + await invalidateDownstreamAfterStepRestart(1, { + logLabel: `节点 ${normalizedNodeId} 检测到${reasonLabel}后准备回到 open-chatgpt 重新获取手机号重试(第 ${restartCount} 次重开)`, + }); + } if (shouldClearSignupPhoneRuntime) { - await addLog(`步骤 ${step}:已清空本轮注册手机号与接码订单,下一次重开将重新获取号码。`, 'warn'); + await addLog(`节点 ${normalizedNodeId}:已清空本轮注册手机号与接码订单,下一次重开将重新获取号码。`, 'warn'); } if (Object.keys(restorePayload).length) { await setState(restorePayload); @@ -8228,28 +8317,62 @@ function isStepDoneStatus(status) { return status === 'completed' || status === 'manual_completed' || status === 'skipped'; } +function normalizeStatusMapForNodes(statuses = {}, state = {}) { + const candidate = statuses && typeof statuses === 'object' && !Array.isArray(statuses) ? statuses : {}; + const nodeIds = new Set(getNodeIdsForState(state)); + const hasNodeKey = Object.keys(candidate).some((key) => nodeIds.has(key)); + const hasStepKey = Object.keys(candidate).some((key) => Number.isInteger(Number(key)) && Number(key) > 0); + if (hasNodeKey || !hasStepKey) { + return { ...DEFAULT_STATE.nodeStatuses, ...(state.nodeStatuses || {}), ...candidate }; + } + + const projected = { ...DEFAULT_STATE.nodeStatuses, ...(state.nodeStatuses || {}) }; + for (const [step, status] of Object.entries(candidate)) { + const nodeId = getNodeIdByStepForState(step, state); + if (nodeId) { + projected[nodeId] = status; + } + } + return projected; +} + +function getFirstUnfinishedNodeId(statuses = {}, stateOverride = null) { + const state = stateOverride || {}; + const nodeStatuses = normalizeStatusMapForNodes(statuses, state); + if (workflowEngine?.getFirstUnfinishedNodeId) { + return workflowEngine.getFirstUnfinishedNodeId(nodeStatuses, state); + } + const nodeIds = getNodeIdsForState(state); + for (const nodeId of nodeIds) { + if (!isStepDoneStatus(nodeStatuses[nodeId] || 'pending')) { + return nodeId; + } + } + return ''; +} + function getFirstUnfinishedStep(statuses = {}, stateOverride = null) { const state = stateOverride || {}; - const activeStepIds = typeof getStepIdsForState === 'function' - ? getStepIdsForState(state) - : (typeof STEP_IDS !== 'undefined' && Array.isArray(STEP_IDS) && STEP_IDS.length - ? STEP_IDS - : Array.from({ length: typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10 }, (_, index) => index + 1)); - for (const step of activeStepIds) { - if (!isStepDoneStatus(statuses[step] || 'pending')) return step; + const firstNodeId = getFirstUnfinishedNodeId(statuses, state); + if (firstNodeId) { + return getStepIdByNodeIdForState(firstNodeId, state); } return null; } +function hasSavedNodeProgress(statuses = {}, stateOverride = null) { + const state = stateOverride || {}; + const nodeStatuses = normalizeStatusMapForNodes(statuses, state); + if (workflowEngine?.hasSavedProgress) { + return workflowEngine.hasSavedProgress(nodeStatuses, state); + } + const merged = { ...DEFAULT_STATE.nodeStatuses, ...nodeStatuses }; + return getNodeIdsForState(state).some((nodeId) => (merged[nodeId] || 'pending') !== 'pending'); +} + function hasSavedProgress(statuses = {}, stateOverride = null) { const state = stateOverride || {}; - const merged = { ...DEFAULT_STATE.stepStatuses, ...statuses }; - const activeStepIds = typeof getStepIdsForState === 'function' - ? getStepIdsForState(state) - : (typeof STEP_IDS !== 'undefined' && Array.isArray(STEP_IDS) && STEP_IDS.length - ? STEP_IDS - : Array.from({ length: typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10 }, (_, index) => index + 1)); - return activeStepIds.some((step) => (merged[step] || 'pending') !== 'pending'); + return hasSavedNodeProgress(statuses, state); } function getDownstreamStateResets(step, state = {}) { @@ -8442,33 +8565,32 @@ function getDownstreamStateResets(step, state = {}) { async function invalidateDownstreamAfterStepRestart(step, options = {}) { const { logLabel = `步骤 ${step} 重新执行` } = options; const state = await getState(); - const statuses = { ...(state.stepStatuses || {}) }; - const changedSteps = []; + const nodeStatuses = { ...(state.nodeStatuses || {}) }; + const changedNodes = []; + const activeNodeIds = getNodeIdsForState(state); + const currentNodeId = getNodeIdByStepForState(step, state); + const currentIndex = activeNodeIds.indexOf(currentNodeId); - const activeStepIds = typeof getStepIdsForState === 'function' - ? getStepIdsForState(state) - : (typeof STEP_IDS !== 'undefined' && Array.isArray(STEP_IDS) && STEP_IDS.length - ? STEP_IDS - : Array.from({ length: typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10 }, (_, index) => index + 1)); - for (const downstream of activeStepIds) { - if (downstream <= step) { - continue; - } - if (statuses[downstream] !== 'pending') { - statuses[downstream] = 'pending'; - changedSteps.push(downstream); + if (currentIndex >= 0) { + for (let index = currentIndex + 1; index < activeNodeIds.length; index += 1) { + const downstreamNodeId = activeNodeIds[index]; + if (nodeStatuses[downstreamNodeId] === 'pending') { + continue; + } + nodeStatuses[downstreamNodeId] = 'pending'; + changedNodes.push(downstreamNodeId); } } - if (changedSteps.length) { - await setState({ stepStatuses: statuses }); - for (const downstream of changedSteps) { + if (changedNodes.length) { + await setState({ nodeStatuses }); + for (const nodeId of changedNodes) { chrome.runtime.sendMessage({ - type: 'STEP_STATUS_CHANGED', - payload: { step: downstream, status: 'pending' }, + type: 'NODE_STATUS_CHANGED', + payload: { nodeId, status: 'pending' }, }).catch(() => { }); } - await addLog(`${logLabel},已重置后续步骤状态:${changedSteps.join(', ')}`, 'warn'); + await addLog(`${logLabel},已重置后续节点状态:${changedNodes.join(', ')}`, 'warn'); } const resets = getDownstreamStateResets(step, state); @@ -8478,62 +8600,108 @@ async function invalidateDownstreamAfterStepRestart(step, options = {}) { } } +async function invalidateDownstreamAfterNodeRestart(nodeId, options = {}) { + const state = await getState(); + const step = getStepIdByNodeIdForState(nodeId, state); + if (Number.isInteger(step) && step > 0) { + return invalidateDownstreamAfterStepRestart(step, options); + } + + const normalizedNodeId = String(nodeId || '').trim(); + const logLabel = options.logLabel || `节点 ${normalizedNodeId} 重新执行`; + const nodeStatuses = { ...(state.nodeStatuses || {}) }; + const activeNodeIds = getNodeIdsForState(state); + const currentIndex = activeNodeIds.indexOf(normalizedNodeId); + const changedNodes = []; + if (currentIndex >= 0) { + for (let index = currentIndex + 1; index < activeNodeIds.length; index += 1) { + const downstreamNodeId = activeNodeIds[index]; + if (nodeStatuses[downstreamNodeId] === 'pending') { + continue; + } + nodeStatuses[downstreamNodeId] = 'pending'; + changedNodes.push(downstreamNodeId); + } + } + if (changedNodes.length) { + await setState({ nodeStatuses }); + for (const changedNodeId of changedNodes) { + chrome.runtime.sendMessage({ + type: 'NODE_STATUS_CHANGED', + payload: { nodeId: changedNodeId, status: 'pending' }, + }).catch(() => { }); + } + await addLog(`${logLabel},已重置后续节点状态:${changedNodes.join(', ')}`, 'warn'); + } +} + function clearStopRequest() { stopRequested = false; } +function getRunningNodeIds(statuses = {}, stateOverride = null) { + const state = stateOverride || {}; + const nodeStatuses = normalizeStatusMapForNodes(statuses, state); + if (workflowEngine?.getRunningNodeIds) { + return workflowEngine.getRunningNodeIds(nodeStatuses, state); + } + const merged = { ...DEFAULT_STATE.nodeStatuses, ...nodeStatuses }; + return getNodeIdsForState(state).filter((nodeId) => merged[nodeId] === 'running'); +} + function getRunningSteps(statuses = {}, stateOverride = null) { const state = stateOverride || {}; - const merged = { ...DEFAULT_STATE.stepStatuses, ...statuses }; - const activeStepIds = typeof getStepIdsForState === 'function' - ? getStepIdsForState(state) - : (typeof STEP_IDS !== 'undefined' && Array.isArray(STEP_IDS) && STEP_IDS.length - ? STEP_IDS - : Array.from({ length: typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10 }, (_, index) => index + 1)); - return activeStepIds - .filter((step) => merged[step] === 'running') + return getRunningNodeIds(statuses, state) + .map((nodeId) => getStepIdByNodeIdForState(nodeId, state)) + .filter((step) => Number.isInteger(step) && step > 0) .sort((a, b) => a - b); } -function inferStoppedRecordStep(state = {}) { - const statuses = { ...DEFAULT_STATE.stepStatuses, ...(state?.stepStatuses || {}) }; - const stepIds = typeof getStepIdsForState === 'function' - ? getStepIdsForState(state) - : (typeof STEP_IDS !== 'undefined' && Array.isArray(STEP_IDS) && STEP_IDS.length - ? STEP_IDS - : Array.from({ length: typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10 }, (_, index) => index + 1)); - - const runningSteps = stepIds.filter((step) => statuses[step] === 'running'); - if (runningSteps.length) { - return runningSteps[0]; +function inferStoppedRecordNode(state = {}) { + const nodeStatuses = normalizeStatusMapForNodes(state?.nodeStatuses || {}, state); + const nodeIds = getNodeIdsForState(state); + const runningNode = nodeIds.find((nodeId) => nodeStatuses[nodeId] === 'running'); + if (runningNode) { + return runningNode; } - const hasProgress = stepIds.some((step) => statuses[step] !== 'pending'); - if (!hasProgress) { - return null; - } - - for (const step of stepIds) { - const status = statuses[step] || 'pending'; - if (!(status === 'completed' || status === 'manual_completed' || status === 'skipped')) { - return step; + const currentNodeId = String(state?.currentNodeId || '').trim(); + if (currentNodeId && nodeIds.includes(currentNodeId)) { + const currentStatus = String(nodeStatuses[currentNodeId] || '').trim(); + if (!isStepDoneStatus(currentStatus)) { + return currentNodeId; } } - return null; + const hasProgress = nodeIds.some((nodeId) => String(nodeStatuses[nodeId] || 'pending') !== 'pending'); + if (!hasProgress) { + return ''; + } + + return nodeIds.find((nodeId) => !isStepDoneStatus(nodeStatuses[nodeId] || 'pending')) || ''; +} + +function inferStoppedRecordStep(state = {}) { + const nodeId = inferStoppedRecordNode(state); + return nodeId ? getStepIdByNodeIdForState(nodeId, state) : null; } function resolveAccountRunRecordStatusForStop(status, state = {}) { const normalizedStatus = String(status || '').trim().toLowerCase(); if (normalizedStatus === 'stopped') { - const inferredStep = inferStoppedRecordStep(state); - if (Number.isInteger(inferredStep) && inferredStep > 0) { - return `step${inferredStep}_stopped`; + const inferredNodeId = inferStoppedRecordNode(state); + if (inferredNodeId) { + return `node:${inferredNodeId}:stopped`; } } return status; } +function extractStoppedNodeFromRecordStatus(status = '') { + const match = String(status || '').trim().match(/^node:([^:]+):stopped$/i); + return match ? String(match[1] || '').trim() : ''; +} + function extractStoppedStepFromRecordStatus(status = '') { const match = String(status || '').trim().toLowerCase().match(/^step(\d+)_stopped$/); if (!match) { @@ -8545,6 +8713,17 @@ function extractStoppedStepFromRecordStatus(status = '') { function resolveAccountRunRecordReasonForStop(status, reason = '') { const text = String(reason || '').trim(); + const stoppedNodeId = extractStoppedNodeFromRecordStatus(status); + if (stoppedNodeId) { + if (!text || text === STOP_ERROR_MESSAGE || /^流程已被用户停止。?$/.test(text)) { + return `节点 ${stoppedNodeId} 已被用户停止。`; + } + if (/流程尚未完成/.test(text) || /已使用(?:邮箱|手机号)/.test(text)) { + return text.replace(/^步骤\s*\d+/, `节点 ${stoppedNodeId}`); + } + return text; + } + const stoppedStep = extractStoppedStepFromRecordStatus(status); if (!stoppedStep) { @@ -9024,55 +9203,52 @@ async function ensureManualInteractionAllowed(actionLabel) { return state; } -async function skipStep(step) { +async function skipNode(nodeId) { const state = await ensureManualInteractionAllowed('跳过步骤'); - const activeStepIds = typeof getStepIdsForState === 'function' - ? getStepIdsForState(state) - : (typeof STEP_IDS !== 'undefined' && Array.isArray(STEP_IDS) && STEP_IDS.length - ? STEP_IDS - : Array.from({ length: typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10 }, (_, index) => index + 1)); + const normalizedNodeId = String(nodeId || '').trim(); + const activeNodeIds = getNodeIdsForState(state); - if (!Number.isInteger(step) || !activeStepIds.includes(step)) { - throw new Error(`无效步骤:${step}`); + if (!normalizedNodeId || !activeNodeIds.includes(normalizedNodeId)) { + throw new Error(`无效节点:${normalizedNodeId || nodeId}`); } - const statuses = { ...(state.stepStatuses || {}) }; - const currentStatus = statuses[step]; + const statuses = normalizeStatusMapForNodes(state.nodeStatuses || {}, state); + const currentStatus = statuses[normalizedNodeId]; if (currentStatus === 'running') { - throw new Error(`步骤 ${step} 正在运行中,不能跳过。`); + throw new Error(`节点 ${normalizedNodeId} 正在运行中,不能跳过。`); } if (isStepDoneStatus(currentStatus)) { - throw new Error(`步骤 ${step} 已完成,无需再跳过。`); + throw new Error(`节点 ${normalizedNodeId} 已完成,无需再跳过。`); } - const currentIndex = activeStepIds.indexOf(step); + const currentIndex = activeNodeIds.indexOf(normalizedNodeId); if (currentIndex > 0) { - const prevStep = activeStepIds[currentIndex - 1]; - const prevStatus = statuses[prevStep]; + const prevNodeId = activeNodeIds[currentIndex - 1]; + const prevStatus = statuses[prevNodeId]; if (!isStepDoneStatus(prevStatus)) { - throw new Error(`请先完成步骤 ${prevStep},再跳过步骤 ${step}。`); + throw new Error(`请先完成节点 ${prevNodeId},再跳过节点 ${normalizedNodeId}。`); } } - await setStepStatus(step, 'skipped'); - await addLog(`步骤 ${step} 已跳过`, 'warn'); + await setNodeStatus(normalizedNodeId, 'skipped'); + await addLog(`节点 ${normalizedNodeId} 已跳过`, 'warn'); - if (step === 1) { + if (normalizedNodeId === 'open-chatgpt') { const latestState = await getState(); - const skippedSteps = []; - for (let linkedStep = 2; linkedStep <= 5; linkedStep += 1) { - const linkedStatus = latestState.stepStatuses?.[linkedStep]; + const skippedNodes = []; + for (const linkedNodeId of ['submit-signup-email', 'fill-password', 'fetch-signup-code', 'fill-profile']) { + const linkedStatus = latestState.nodeStatuses?.[linkedNodeId]; if (!isStepDoneStatus(linkedStatus) && linkedStatus !== 'running') { - await setStepStatus(linkedStep, 'skipped'); - skippedSteps.push(linkedStep); + await setNodeStatus(linkedNodeId, 'skipped'); + skippedNodes.push(linkedNodeId); } } - if (skippedSteps.length) { - await addLog(`步骤 1 已跳过,步骤 ${skippedSteps.join('、')} 也已同时跳过。`, 'warn'); + if (skippedNodes.length) { + await addLog(`节点 open-chatgpt 已跳过,节点 ${skippedNodes.join('、')} 也已同时跳过。`, 'warn'); } } - return { ok: true, step, status: 'skipped' }; + return { ok: true, nodeId: normalizedNodeId, status: 'skipped' }; } function throwIfStopped(error = null) { @@ -9269,9 +9445,10 @@ async function handleStepData(step, payload) { } if (payload.skippedPasswordStep) { const latestState = await getState(); - const step3Status = latestState.stepStatuses?.[3]; - if (step3Status !== 'running' && step3Status !== 'completed' && step3Status !== 'manual_completed') { - await setStepStatus(3, 'skipped'); + const step3NodeId = getNodeIdByStepForState(3, latestState); + const step3Status = step3NodeId ? latestState.nodeStatuses?.[step3NodeId] : ''; + if (step3NodeId && step3Status !== 'running' && step3Status !== 'completed' && step3Status !== 'manual_completed') { + await setNodeStatus(step3NodeId, 'skipped'); const identityLabel = payload.accountIdentifierType === 'phone' ? '手机号' : '邮箱'; await addLog(`步骤 2:提交${identityLabel}后页面直接进入验证码页,已自动跳过步骤 3。`, 'warn'); } @@ -9284,9 +9461,10 @@ async function handleStepData(step, payload) { } if (payload.skipProfileStep) { const latestState = await getState(); - const step5Status = latestState.stepStatuses?.[5]; - if (step5Status !== 'running' && step5Status !== 'completed' && step5Status !== 'manual_completed') { - await setStepStatus(5, 'skipped'); + const step5NodeId = getNodeIdByStepForState(5, latestState); + const step5Status = step5NodeId ? latestState.nodeStatuses?.[step5NodeId] : ''; + if (step5NodeId && step5Status !== 'running' && step5Status !== 'completed' && step5Status !== 'manual_completed') { + await setNodeStatus(step5NodeId, 'skipped'); await addLog('步骤 3:页面已直接进入已登录态,已自动跳过步骤 5。', 'warn'); } } @@ -9348,11 +9526,22 @@ async function handleStepData(step, payload) { } } +async function handleNodeData(nodeId, payload) { + const state = await getState(); + const step = getStepIdByNodeIdForState(nodeId, state); + if (!Number.isInteger(step) || step <= 0) { + return; + } + return handleStepData(step, payload); +} + // ============================================================ // Step Completion Waiting // ============================================================ -// Map of step -> { resolve, reject } for waiting on step completion +// Map of nodeId -> { resolve, reject } for waiting on node completion +const nodeWaiters = new Map(); +// Legacy boundary waiters are kept only for callers that still pass a display step. const stepWaiters = new Map(); let resumeWaiter = null; const AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS = 120000; @@ -9388,16 +9577,19 @@ const AUTO_RUN_PRE_EXECUTION_DELAYS_BY_STEP_KEY = new Map([ ['plus-checkout-create', 20000], ]); -function waitForStepComplete(step, timeoutMs = 120000) { +function waitForNodeComplete(nodeId, timeoutMs = 120000) { throwIfStopped(); - const normalizedStep = Number(step); - const existingWaiter = stepWaiters.get(normalizedStep); + const normalizedNodeId = String(nodeId || '').trim(); + if (!normalizedNodeId) { + return Promise.reject(new Error('等待节点完成失败:缺少 nodeId。')); + } + const existingWaiter = nodeWaiters.get(normalizedNodeId); if (existingWaiter?.promise) { - console.log(LOG_PREFIX, `[waitForStepComplete] reuse existing waiter for step ${normalizedStep}`); + console.log(LOG_PREFIX, `[waitForNodeComplete] reuse existing waiter for node ${normalizedNodeId}`); return existingWaiter.promise; } - console.log(LOG_PREFIX, `[waitForStepComplete] register step ${normalizedStep}, timeout=${timeoutMs}ms`); + console.log(LOG_PREFIX, `[waitForNodeComplete] register node ${normalizedNodeId}, timeout=${timeoutMs}ms`); const waiter = { promise: null, resolve: null, @@ -9406,33 +9598,43 @@ function waitForStepComplete(step, timeoutMs = 120000) { waiter.promise = new Promise((resolve, reject) => { const timer = setTimeout(() => { - if (stepWaiters.get(normalizedStep) === waiter) { - stepWaiters.delete(normalizedStep); + if (nodeWaiters.get(normalizedNodeId) === waiter) { + nodeWaiters.delete(normalizedNodeId); } - console.warn(LOG_PREFIX, `[waitForStepComplete] timeout for step ${normalizedStep} after ${timeoutMs}ms`); - reject(new Error(`步骤 ${normalizedStep} 等待超时(>${timeoutMs / 1000} 秒)`)); + console.warn(LOG_PREFIX, `[waitForNodeComplete] timeout for node ${normalizedNodeId} after ${timeoutMs}ms`); + reject(new Error(`节点 ${normalizedNodeId} 等待超时(>${timeoutMs / 1000} 秒)`)); }, timeoutMs); waiter.resolve = (data) => { clearTimeout(timer); - if (stepWaiters.get(normalizedStep) === waiter) { - stepWaiters.delete(normalizedStep); + if (nodeWaiters.get(normalizedNodeId) === waiter) { + nodeWaiters.delete(normalizedNodeId); } resolve(data); }; waiter.reject = (err) => { clearTimeout(timer); - if (stepWaiters.get(normalizedStep) === waiter) { - stepWaiters.delete(normalizedStep); + if (nodeWaiters.get(normalizedNodeId) === waiter) { + nodeWaiters.delete(normalizedNodeId); } reject(err); }; }); - stepWaiters.set(normalizedStep, waiter); + nodeWaiters.set(normalizedNodeId, waiter); return waiter.promise; } +function waitForStepComplete(step, timeoutMs = 120000) { + return getState().then((state) => { + const nodeId = getNodeIdByStepForState(step, state); + if (!nodeId) { + throw new Error(`等待步骤 ${step} 完成失败:当前 flow 中未找到对应节点。`); + } + return waitForNodeComplete(nodeId, timeoutMs); + }); +} + function getStepExecutionKeyForState(step, state = {}) { if (typeof getStepDefinitionForState !== 'function') { return ''; @@ -9440,88 +9642,136 @@ function getStepExecutionKeyForState(step, state = {}) { return String(getStepDefinitionForState(step, state)?.key || '').trim(); } +function getNodeExecutionKeyForState(nodeId, state = {}) { + return String(getNodeDefinitionForState(nodeId, state)?.executeKey || nodeId || '').trim(); +} + +function doesNodeUseBackgroundCompletion(nodeId, state = {}) { + const executionKey = getNodeExecutionKeyForState(nodeId, state); + return AUTO_RUN_BACKGROUND_COMPLETED_STEP_KEYS.has(executionKey || nodeId); +} + function doesStepUseBackgroundCompletion(step, state = {}) { - const stepKey = getStepExecutionKeyForState(step, state); - if (stepKey) { - return AUTO_RUN_BACKGROUND_COMPLETED_STEP_KEYS.has(stepKey); - } - return AUTO_RUN_BACKGROUND_COMPLETED_STEPS.has(step); + return doesNodeUseBackgroundCompletion(getNodeIdByStepForState(step, state), state); +} + +function doesNodeUseCompletionSignal(nodeId, state = {}) { + const executionKey = getNodeExecutionKeyForState(nodeId, state); + return STEP_COMPLETION_SIGNAL_STEP_KEYS.has(executionKey || nodeId); } function doesStepUseCompletionSignal(step, state = {}) { - const stepKey = getStepExecutionKeyForState(step, state); - if (stepKey) { - return STEP_COMPLETION_SIGNAL_STEP_KEYS.has(stepKey); - } - return STEP_COMPLETION_SIGNAL_STEPS.has(step); + return doesNodeUseCompletionSignal(getNodeIdByStepForState(step, state), state); +} + +function getAutoRunPreExecutionDelayMsForNode(nodeId, state = {}) { + const executionKey = getNodeExecutionKeyForState(nodeId, state); + return AUTO_RUN_PRE_EXECUTION_DELAYS_BY_STEP_KEY.get(executionKey || nodeId) || 0; } function getAutoRunPreExecutionDelayMs(step, state = {}) { - const stepKey = getStepExecutionKeyForState(step, state); - if (stepKey) { - return AUTO_RUN_PRE_EXECUTION_DELAYS_BY_STEP_KEY.get(stepKey) || 0; - } - return 0; + return getAutoRunPreExecutionDelayMsForNode(getNodeIdByStepForState(step, state), state); +} + +function getNodeCompletionSignalTimeoutMs(nodeId, state = {}) { + const executionKey = getNodeExecutionKeyForState(nodeId, state); + return STEP_COMPLETION_SIGNAL_TIMEOUTS_BY_STEP_KEY.get(executionKey || nodeId) || AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS; } function getStepCompletionSignalTimeoutMs(step, state = {}) { - const stepKey = getStepExecutionKeyForState(step, state); - if (stepKey) { - return STEP_COMPLETION_SIGNAL_TIMEOUTS_BY_STEP_KEY.get(stepKey) || AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS; - } - return AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS; + return getNodeCompletionSignalTimeoutMs(getNodeIdByStepForState(step, state), state); +} + +function notifyNodeComplete(nodeId, payload) { + const normalizedNodeId = String(nodeId || '').trim(); + const waiter = nodeWaiters.get(normalizedNodeId); + console.log(LOG_PREFIX, `[notifyNodeComplete] node ${normalizedNodeId}, hasWaiter=${Boolean(waiter)}`); + if (waiter) waiter.resolve(payload); } function notifyStepComplete(step, payload) { + getState().then((state) => { + const nodeId = getNodeIdByStepForState(step, state); + if (nodeId) { + notifyNodeComplete(nodeId, payload); + } + }).catch(() => {}); const waiter = stepWaiters.get(step); console.log(LOG_PREFIX, `[notifyStepComplete] step ${step}, hasWaiter=${Boolean(waiter)}`); if (waiter) waiter.resolve(payload); } +function notifyNodeError(nodeId, error) { + const normalizedNodeId = String(nodeId || '').trim(); + const waiter = nodeWaiters.get(normalizedNodeId); + console.warn(LOG_PREFIX, `[notifyNodeError] node ${normalizedNodeId}, hasWaiter=${Boolean(waiter)}, error=${error}`); + if (waiter) waiter.reject(new Error(error)); +} + function notifyStepError(step, error) { + getState().then((state) => { + const nodeId = getNodeIdByStepForState(step, state); + if (nodeId) { + notifyNodeError(nodeId, error); + } + }).catch(() => {}); const waiter = stepWaiters.get(step); console.warn(LOG_PREFIX, `[notifyStepError] step ${step}, hasWaiter=${Boolean(waiter)}, error=${error}`); if (waiter) waiter.reject(new Error(error)); } async function runCompletedStepSideEffects(step, payload, completionState, lastStepId) { - await handleStepData(step, payload); - if (step === lastStepId) { + const state = await getState(); + const nodeId = getNodeIdByStepForState(step, state); + const lastNodeId = getNodeIdByStepForState(lastStepId, state); + return runCompletedNodeSideEffects(nodeId, payload, completionState, lastNodeId); +} + +async function reportCompletedStepSideEffectError(step, error) { + const state = await getState(); + return reportCompletedNodeSideEffectError(getNodeIdByStepForState(step, state), error); +} + +async function runCompletedNodeSideEffects(nodeId, payload, completionState, lastNodeId) { + await handleNodeData(nodeId, payload); + if (nodeId === lastNodeId) { await appendAndBroadcastAccountRunRecord('success', completionState); } } -async function reportCompletedStepSideEffectError(step, error) { +async function reportCompletedNodeSideEffectError(nodeId, error) { const message = getErrorMessage(error); - console.warn(LOG_PREFIX, `[completeStepFromBackground] step ${step} post-completion side effect failed:`, error); - await addLog(`已完成,但完成后的收尾处理失败:${message}`, 'warn', { step }); + console.warn(LOG_PREFIX, `[completeNodeFromBackground] node ${nodeId} post-completion side effect failed:`, error); + await addLog(`已完成,但完成后的收尾处理失败:${message}`, 'warn', { nodeId }); } -async function completeStepFromBackground(step, payload = {}) { +async function completeNodeFromBackground(nodeId, payload = {}) { + const normalizedNodeId = String(nodeId || '').trim(); + if (!normalizedNodeId) { + throw new Error('completeNodeFromBackground 缺少 nodeId。'); + } if (stopRequested) { - await setStepStatus(step, 'stopped'); - await appendManualAccountRunRecordIfNeeded(`step${step}_stopped`, null, STOP_ERROR_MESSAGE); - notifyStepError(step, STOP_ERROR_MESSAGE); + await setNodeStatus(normalizedNodeId, 'stopped'); + await appendManualAccountRunRecordIfNeeded(`node:${normalizedNodeId}:stopped`, null, STOP_ERROR_MESSAGE); + notifyNodeError(normalizedNodeId, STOP_ERROR_MESSAGE); return; } const latestState = await getState(); - const lastStepId = typeof getLastStepIdForState === 'function' - ? getLastStepIdForState(latestState) - : (typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10); - const completionState = step === lastStepId ? latestState : null; - await setStepStatus(step, 'completed'); - await addLog('已完成', 'ok', { step }); + const lastNodeId = getLastNodeIdForState(latestState); + const completionState = normalizedNodeId === lastNodeId ? latestState : null; + await setNodeStatus(normalizedNodeId, 'completed'); + await addLog('已完成', 'ok', { nodeId: normalizedNodeId }); - if (step === lastStepId) { - notifyStepComplete(step, payload); - void runCompletedStepSideEffects(step, payload, completionState, lastStepId) - .catch((error) => reportCompletedStepSideEffectError(step, error)); + if (normalizedNodeId === lastNodeId) { + notifyNodeComplete(normalizedNodeId, payload); + void runCompletedNodeSideEffects(normalizedNodeId, payload, completionState, lastNodeId) + .catch((error) => reportCompletedNodeSideEffectError(normalizedNodeId, error)); return; } - await runCompletedStepSideEffects(step, payload, completionState, lastStepId); - notifyStepComplete(step, payload); + await runCompletedNodeSideEffects(normalizedNodeId, payload, completionState, lastNodeId); + notifyNodeComplete(normalizedNodeId, payload); } async function appendManualAccountRunRecordIfNeeded(status, stateOverride = null, reason = '') { @@ -9533,42 +9783,53 @@ async function appendManualAccountRunRecordIfNeeded(status, stateOverride = null return appendAndBroadcastAccountRunRecord(status, state, reason); } -async function finalizeDeferredStepExecutionError(step, error) { +async function finalizeDeferredNodeExecutionError(nodeId, error) { const latestState = await getState(); - const currentStatus = latestState.stepStatuses?.[step]; + const normalizedNodeId = String(nodeId || '').trim(); + const currentStatus = latestState.nodeStatuses?.[normalizedNodeId]; if (currentStatus === 'completed' || currentStatus === 'failed' || currentStatus === 'stopped') { return; } if (isStopError(error)) { - await setStepStatus(step, 'stopped'); - await addLog('已被用户停止', 'warn', { step }); - await appendManualAccountRunRecordIfNeeded(`step${step}_stopped`, latestState, getErrorMessage(error)); + await setNodeStatus(normalizedNodeId, 'stopped'); + await addLog('已被用户停止', 'warn', { nodeId: normalizedNodeId }); + await appendManualAccountRunRecordIfNeeded(`node:${normalizedNodeId}:stopped`, latestState, getErrorMessage(error)); return; } - await setStepStatus(step, 'failed'); - await addLog(`失败:${getErrorMessage(error)}`, 'error', { step }); - await appendManualAccountRunRecordIfNeeded(`step${step}_failed`, latestState, getErrorMessage(error)); + await setNodeStatus(normalizedNodeId, 'failed'); + await addLog(`失败:${getErrorMessage(error)}`, 'error', { nodeId: normalizedNodeId }); + await appendManualAccountRunRecordIfNeeded(`node:${normalizedNodeId}:failed`, latestState, getErrorMessage(error)); } -async function executeStepViaCompletionSignal(step, timeoutMs = 0) { +async function finalizeDeferredStepExecutionError(step, error) { + const latestState = await getState(); + const nodeId = getNodeIdByStepForState(step, latestState); + if (!nodeId) { + return; + } + return finalizeDeferredNodeExecutionError(nodeId, error); +} + +async function executeNodeViaCompletionSignal(nodeId, timeoutMs = 0) { + const normalizedNodeId = String(nodeId || '').trim(); const executionState = await getState(); const resolvedTimeoutMs = Number(timeoutMs) > 0 ? timeoutMs - : getStepCompletionSignalTimeoutMs(step, executionState); - const completionResultPromise = waitForStepComplete(step, resolvedTimeoutMs).then( + : getNodeCompletionSignalTimeoutMs(normalizedNodeId, executionState); + const completionResultPromise = waitForNodeComplete(normalizedNodeId, resolvedTimeoutMs).then( payload => ({ ok: true, payload }), error => ({ ok: false, error }), ); let executeError = null; try { - await executeStep(step, { deferRetryableTransportError: true }); + await executeNode(normalizedNodeId, { deferRetryableTransportError: true }); } catch (err) { executeError = err; if (isStopError(err) || !isRetryableContentScriptTransportError(err)) { - notifyStepError(step, getErrorMessage(err)); + notifyNodeError(normalizedNodeId, getErrorMessage(err)); } } @@ -9577,7 +9838,7 @@ async function executeStepViaCompletionSignal(step, timeoutMs = 0) { if (executeError) { console.warn( LOG_PREFIX, - `[executeStepViaCompletionSignal] step ${step} completed after deferred execute error: ${getErrorMessage(executeError)}` + `[executeNodeViaCompletionSignal] node ${normalizedNodeId} completed after deferred execute error: ${getErrorMessage(executeError)}` ); } return completionResult.payload; @@ -9586,7 +9847,7 @@ async function executeStepViaCompletionSignal(step, timeoutMs = 0) { if (executeError && isRetryableContentScriptTransportError(executeError)) { const completionMessage = getErrorMessage(completionResult.error); if (/等待超时/.test(completionMessage)) { - await finalizeDeferredStepExecutionError(step, executeError); + await finalizeDeferredNodeExecutionError(normalizedNodeId, executeError); throw executeError; } throw completionResult.error; @@ -9599,6 +9860,15 @@ async function executeStepViaCompletionSignal(step, timeoutMs = 0) { throw completionResult.error; } +async function executeStepViaCompletionSignal(step, timeoutMs = 0) { + const state = await getState(); + const nodeId = getNodeIdByStepForState(step, state); + if (!nodeId) { + throw new Error(`执行步骤 ${step} 失败:当前 flow 中未找到对应节点。`); + } + return executeNodeViaCompletionSignal(nodeId, timeoutMs); +} + function getLatestLogTimestamp(logs = [], fallback = 0) { if (!Array.isArray(logs) || !logs.length) { return Number.isFinite(Number(fallback)) ? Number(fallback) : 0; @@ -9609,11 +9879,12 @@ function getLatestLogTimestamp(logs = [], fallback = 0) { }, Number.isFinite(Number(fallback)) ? Number(fallback) : 0); } -function buildAutoRunStepIdleRestartError(step, idleMs = AUTO_RUN_STEP_IDLE_LOG_TIMEOUT_MS) { +function buildAutoRunNodeIdleRestartError(nodeId, idleMs = AUTO_RUN_STEP_IDLE_LOG_TIMEOUT_MS) { const seconds = Math.max(1, Math.round((Number(idleMs) || AUTO_RUN_STEP_IDLE_LOG_TIMEOUT_MS) / 1000)); - const error = new Error(`${AUTO_RUN_STEP_IDLE_RESTART_ERROR_PREFIX}步骤 ${step} 已连续 ${seconds} 秒没有新日志,准备重新开始当前步骤。`); + const normalizedNodeId = String(nodeId || '').trim(); + const error = new Error(`${AUTO_RUN_STEP_IDLE_RESTART_ERROR_PREFIX}节点 ${normalizedNodeId} 已连续 ${seconds} 秒没有新日志,准备重新开始当前节点。`); error.autoRunStepIdleRestart = true; - error.failedStep = Math.floor(Number(step) || 0); + error.failedNodeId = normalizedNodeId; return error; } @@ -9622,9 +9893,10 @@ function isAutoRunStepIdleRestartError(error) { return Boolean(error?.autoRunStepIdleRestart) || message.startsWith(AUTO_RUN_STEP_IDLE_RESTART_ERROR_PREFIX); } -function startAutoRunStepIdleLogWatchdog(step, options = {}) { +function startAutoRunNodeIdleLogWatchdog(nodeId, options = {}) { const idleTimeoutMs = Math.max(1000, Math.floor(Number(options.idleTimeoutMs) || AUTO_RUN_STEP_IDLE_LOG_TIMEOUT_MS)); const checkIntervalMs = Math.max(250, Math.min(idleTimeoutMs, Math.floor(Number(options.checkIntervalMs) || AUTO_RUN_STEP_IDLE_LOG_CHECK_INTERVAL_MS))); + const normalizedNodeId = String(nodeId || '').trim(); let cancelled = false; let timer = null; let lastActivityAt = Date.now(); @@ -9658,7 +9930,7 @@ function startAutoRunStepIdleLogWatchdog(step, options = {}) { const idleForMs = Date.now() - lastActivityAt; if (idleForMs >= idleTimeoutMs) { - reject(buildAutoRunStepIdleRestartError(step, idleForMs)); + reject(buildAutoRunNodeIdleRestartError(normalizedNodeId, idleForMs)); return; } } catch (_err) { @@ -9681,9 +9953,10 @@ function startAutoRunStepIdleLogWatchdog(step, options = {}) { }; } -async function runAutoStepActionWithIdleLogWatchdog(step, action, options = {}) { +async function runAutoNodeActionWithIdleLogWatchdog(nodeId, action, options = {}) { + const normalizedNodeId = String(nodeId || '').trim(); const executionPromise = Promise.resolve().then(action); - const watchdog = startAutoRunStepIdleLogWatchdog(step, options); + const watchdog = startAutoRunNodeIdleLogWatchdog(normalizedNodeId, options); try { return await Promise.race([ executionPromise, @@ -9696,7 +9969,7 @@ async function runAutoStepActionWithIdleLogWatchdog(step, action, options = {}) if (!lateMessage || isStopError(lateError) || isAutoRunStepIdleRestartError(lateError)) { return; } - addLog(`步骤 ${step}:无日志重开后收到原执行失败:${lateMessage}`, 'warn').catch(() => {}); + addLog(`节点 ${normalizedNodeId}:无日志重开后收到原执行失败:${lateMessage}`, 'warn').catch(() => {}); }); } throw error; @@ -9705,36 +9978,39 @@ async function runAutoStepActionWithIdleLogWatchdog(step, action, options = {}) } } -async function executeStepAndWaitWithAutoRunIdleLogWatchdog(step, delayAfter = 2000, options = {}) { - return runAutoStepActionWithIdleLogWatchdog( - step, - () => executeStepAndWait(step, delayAfter), +async function executeNodeAndWaitWithAutoRunIdleLogWatchdog(nodeId, delayAfter = 2000, options = {}) { + return runAutoNodeActionWithIdleLogWatchdog( + nodeId, + () => executeNodeAndWait(nodeId, delayAfter), options ); } -async function waitForRunningStepsToFinish(payload = {}) { +async function waitForRunningNodesToFinish(payload = {}) { let currentState = await getState(); - let runningSteps = getRunningSteps(currentState.stepStatuses, currentState); - if (!runningSteps.length) { + let runningNodes = getRunningNodeIds(currentState.nodeStatuses, currentState); + if (!runningNodes.length) { return currentState; } - await addLog(`自动继续:检测到步骤 ${runningSteps.join(', ')} 正在运行,等待完成后再继续自动流程...`, 'info'); + await addLog(`自动继续:检测到节点 ${runningNodes.join(', ')} 正在运行,等待完成后再继续自动流程...`, 'info'); await broadcastAutoRunStatus('waiting_step', payload); - while (runningSteps.length) { + while (runningNodes.length) { await sleepWithStop(250); currentState = await getState(); - runningSteps = getRunningSteps(currentState.stepStatuses, currentState); + runningNodes = getRunningNodeIds(currentState.nodeStatuses, currentState); } - await addLog('自动继续:当前运行步骤已结束,准备按最新进度继续自动流程...', 'info'); + await addLog('自动继续:当前运行节点已结束,准备按最新进度继续自动流程...', 'info'); return currentState; } -const AUTH_CHAIN_STEP_IDS = new Set([7, 8, 9, 10, 11, 12, 13]); -const AUTH_CHAIN_STEP_KEYS = new Set([ +async function waitForRunningStepsToFinish(payload = {}) { + return waitForRunningNodesToFinish(payload); +} + +const AUTH_CHAIN_NODE_IDS = new Set([ 'oauth-login', 'fetch-login-code', 'confirm-oauth', @@ -9742,19 +10018,17 @@ const AUTH_CHAIN_STEP_KEYS = new Set([ ]); let activeTopLevelAuthChainExecution = null; -function isAuthChainStep(step, state = {}) { - const stepKey = typeof getStepDefinitionForState === 'function' - ? String(getStepDefinitionForState(step, state)?.key || '').trim() - : ''; - if (stepKey && typeof AUTH_CHAIN_STEP_KEYS !== 'undefined') { - return AUTH_CHAIN_STEP_KEYS.has(stepKey); - } - return AUTH_CHAIN_STEP_IDS.has(Number(step)); +function isAuthChainNode(nodeId) { + return AUTH_CHAIN_NODE_IDS.has(String(nodeId || '').trim()); } -async function acquireTopLevelAuthChainExecution(step, state = {}) { - const normalizedStep = Number(step); - if (!isAuthChainStep(normalizedStep, state)) { +function isAuthChainStep(step, state = {}) { + return isAuthChainNode(getNodeIdByStepForState(step, state)); +} + +async function acquireTopLevelAuthChainExecutionForNode(nodeId, state = {}) { + const normalizedNodeId = String(nodeId || '').trim(); + if (!isAuthChainNode(normalizedNodeId)) { return { joined: false, release() {}, @@ -9764,7 +10038,7 @@ async function acquireTopLevelAuthChainExecution(step, state = {}) { if (activeTopLevelAuthChainExecution) { const activeExecution = activeTopLevelAuthChainExecution; await addLog( - `步骤 ${normalizedStep}:检测到步骤 ${activeExecution.step} 正在运行,本次请求将复用当前授权链,不再重复启动。`, + `节点 ${normalizedNodeId}:检测到节点 ${activeExecution.nodeId} 正在运行,本次请求将复用当前授权链,不再重复启动。`, 'warn' ); const result = await activeExecution.promise; @@ -9782,7 +10056,7 @@ async function acquireTopLevelAuthChainExecution(step, state = {}) { settleExecution = (error = null) => resolve({ error }); }); const execution = { - step: normalizedStep, + nodeId: normalizedNodeId, promise, }; activeTopLevelAuthChainExecution = execution; @@ -9798,20 +10072,24 @@ async function acquireTopLevelAuthChainExecution(step, state = {}) { }; } -async function markRunningStepsStopped() { +async function markRunningNodesStopped() { const state = await getState(); - const runningSteps = getRunningSteps(state.stepStatuses, state); + const runningNodes = getRunningNodeIds(state.nodeStatuses, state); - for (const step of runningSteps) { - await setStepStatus(step, 'stopped'); + for (const nodeId of runningNodes) { + await setNodeStatus(nodeId, 'stopped'); } } +async function markRunningStepsStopped() { + return markRunningNodesStopped(); +} + async function requestStop(options = {}) { const { logMessage = '已收到停止请求,正在取消当前操作...' } = options; const state = await getState(); - const runningSteps = getRunningSteps(state.stepStatuses, state); - const inferredStopStep = inferStoppedRecordStep(state); + const runningNodes = getRunningNodeIds(state.nodeStatuses, state); + const inferredStopNode = inferStoppedRecordNode(state); const timerPlan = getPendingAutoRunTimerPlan(state); if (timerPlan?.kind === AUTO_RUN_TIMER_KIND_SCHEDULED_START && !autoRunActive) { @@ -9860,10 +10138,14 @@ async function requestStop(options = {}) { await addLog(logMessage, 'warn'); await broadcastStopToContentScripts(); - if (!runningSteps.length && Number.isInteger(inferredStopStep) && inferredStopStep > 0) { + if (!runningNodes.length && inferredStopNode) { await appendAndBroadcastAccountRunRecord('stopped', state, STOP_ERROR_MESSAGE); } + for (const waiter of nodeWaiters.values()) { + waiter.reject(new Error(STOP_ERROR_MESSAGE)); + } + nodeWaiters.clear(); for (const waiter of stepWaiters.values()) { waiter.reject(new Error(STOP_ERROR_MESSAGE)); } @@ -9887,7 +10169,7 @@ async function requestStop(options = {}) { resumeWaiter = null; } - await markRunningStepsStopped(); + await markRunningNodesStopped(); autoRunActive = false; await broadcastAutoRunStatus('stopped', { currentRun: autoRunCurrentRun, @@ -9911,11 +10193,16 @@ const STEP_FETCH_NETWORK_RETRY_POLICIES = new Map([ [9, { maxAttempts: 3, cooldownMs: 12000 }], ]); -async function executeStep(step, options = {}) { +async function executeNode(nodeId, options = {}) { const { deferRetryableTransportError = false } = options; - console.log(LOG_PREFIX, `Executing step ${step}`); + const normalizedNodeId = String(nodeId || '').trim(); + if (!normalizedNodeId) { + throw new Error('executeNode 缺少 nodeId。'); + } + console.log(LOG_PREFIX, `Executing node ${normalizedNodeId}`); let state = await getState(); - const authChainClaim = await acquireTopLevelAuthChainExecution(step, state); + const step = getStepIdByNodeIdForState(normalizedNodeId, state); + const authChainClaim = await acquireTopLevelAuthChainExecutionForNode(normalizedNodeId, state); if (authChainClaim.joined) { return; } @@ -9923,8 +10210,8 @@ async function executeStep(step, options = {}) { let executionError = null; throwIfStopped(); try { - await setStepStatus(step, 'running'); - await addLog('开始执行', 'info', { step }); + await setNodeStatus(normalizedNodeId, 'running'); + await addLog('开始执行', 'info', { nodeId: normalizedNodeId }); await humanStepDelay(); const fetchRetryPolicy = typeof getStepFetchNetworkRetryPolicy === 'function' ? getStepFetchNetworkRetryPolicy(step) @@ -9941,25 +10228,27 @@ async function executeStep(step, options = {}) { state = await getState(); // Set flow start time on first step - if (step === 1 && !state.flowStartTime) { + if (normalizedNodeId === 'open-chatgpt' && !state.flowStartTime) { await setState({ flowStartTime: Date.now() }); } const activeStepRegistry = getStepRegistryForState(state); - if (!activeStepRegistry?.getStepDefinition?.(step)) { - throw new Error(`当前模式下不存在步骤:${step}`); + if (!activeStepRegistry?.getNodeDefinition?.(normalizedNodeId)) { + throw new Error(`当前模式下不存在节点:${normalizedNodeId}`); } try { - await activeStepRegistry.executeStep(step, { + await activeStepRegistry.executeNode(normalizedNodeId, { ...state, visibleStep: Number(step), + nodeId: normalizedNodeId, + nodeDefinition: getNodeDefinitionForState(normalizedNodeId, state), stepDefinition: getStepDefinitionForState(step, state), }); if (attempt > 1) { await addLog( - `[NETWORK_FETCH_RETRY] 步骤 ${step}:网络请求异常已恢复,当前重试成功(${attempt}/${fetchRetryPolicy?.maxAttempts || attempt})。`, + `[NETWORK_FETCH_RETRY] 节点 ${normalizedNodeId}:网络请求异常已恢复,当前重试成功(${attempt}/${fetchRetryPolicy?.maxAttempts || attempt})。`, 'ok' ); } @@ -9973,7 +10262,7 @@ async function executeStep(step, options = {}) { const cooldownMs = fetchRetryPolicy.cooldownMs; const cooldownSeconds = Math.max(1, Math.ceil(cooldownMs / 1000)); await addLog( - `[NETWORK_FETCH_RETRY] 步骤 ${step}:检测到网络请求异常(${getErrorMessage(attemptError)}),${cooldownSeconds} 秒后重试(${nextAttempt}/${fetchRetryPolicy.maxAttempts})。`, + `[NETWORK_FETCH_RETRY] 节点 ${normalizedNodeId}:检测到网络请求异常(${getErrorMessage(attemptError)}),${cooldownSeconds} 秒后重试(${nextAttempt}/${fetchRetryPolicy.maxAttempts})。`, 'warn' ); if (cooldownMs > 0) { @@ -9986,9 +10275,9 @@ async function executeStep(step, options = {}) { executionError = err; const errorState = await getState(); if (isStopError(err)) { - await setStepStatus(step, 'stopped'); - await addLog('已被用户停止', 'warn', { step }); - await appendManualAccountRunRecordIfNeeded(`step${step}_stopped`, errorState, getErrorMessage(err)); + await setNodeStatus(normalizedNodeId, 'stopped'); + await addLog('已被用户停止', 'warn', { nodeId: normalizedNodeId }); + await appendManualAccountRunRecordIfNeeded(`node:${normalizedNodeId}:stopped`, errorState, getErrorMessage(err)); throw err; } if (isTerminalSecurityBlockedError(err)) { @@ -9999,14 +10288,14 @@ async function executeStep(step, options = {}) { await handleBrowserSwitchRequired(err); throw new Error(STOP_ERROR_MESSAGE); } - if (!(deferRetryableTransportError && doesStepUseCompletionSignal(step, errorState) && isRetryableContentScriptTransportError(err))) { - await setStepStatus(step, 'failed'); - await addLog(`失败:${err.message}`, 'error', { step }); - await appendManualAccountRunRecordIfNeeded(`step${step}_failed`, errorState, getErrorMessage(err)); + if (!(deferRetryableTransportError && doesNodeUseCompletionSignal(normalizedNodeId, errorState) && isRetryableContentScriptTransportError(err))) { + await setNodeStatus(normalizedNodeId, 'failed'); + await addLog(`失败:${err.message}`, 'error', { nodeId: normalizedNodeId }); + await appendManualAccountRunRecordIfNeeded(`node:${normalizedNodeId}:failed`, errorState, getErrorMessage(err)); } else { console.warn( LOG_PREFIX, - `[executeStep] deferring retryable transport error for step ${step}: ${getErrorMessage(err)}` + `[executeNode] deferring retryable transport error for node ${normalizedNodeId}: ${getErrorMessage(err)}` ); } throw err; @@ -10015,52 +10304,52 @@ async function executeStep(step, options = {}) { } } -/** - * Execute a step and wait for it to complete before returning. - * @param {number} step - * @param {number} delayAfter - ms to wait after completion (for page transitions) - */ -async function executeStepAndWait(step, delayAfter = 2000) { +async function executeNodeAndWait(nodeId, delayAfter = 2000) { throwIfStopped(); + const normalizedNodeId = String(nodeId || '').trim(); + if (!normalizedNodeId) { + throw new Error('executeNodeAndWait 缺少 nodeId。'); + } let completionPayload = null; const delaySeconds = normalizeAutoStepDelaySeconds((await getState()).autoStepDelaySeconds, null); if (delaySeconds > 0) { await addLog( - `自动运行:步骤 ${step} 执行前额外等待 ${delaySeconds} 秒,避免节奏过快。`, + `自动运行:节点 ${normalizedNodeId} 执行前额外等待 ${delaySeconds} 秒,避免节奏过快。`, 'info' ); await sleepWithStop(delaySeconds * 1000); } let executionState = await getState(); - const preExecutionDelayMs = getAutoRunPreExecutionDelayMs(step, executionState); + const step = getStepIdByNodeIdForState(normalizedNodeId, executionState); + const preExecutionDelayMs = getAutoRunPreExecutionDelayMsForNode(normalizedNodeId, executionState); if (preExecutionDelayMs > 0) { await addLog( - `自动运行:步骤 ${step} 执行前固定等待 ${Math.round(preExecutionDelayMs / 1000)} 秒,确保 Plus Checkout 创建前页面稳定。`, + `自动运行:节点 ${normalizedNodeId} 执行前固定等待 ${Math.round(preExecutionDelayMs / 1000)} 秒,确保 Plus Checkout 创建前页面稳定。`, 'info' ); await sleepWithStop(preExecutionDelayMs); executionState = await getState(); } - if (doesStepUseBackgroundCompletion(step, executionState)) { - await addLog(`自动运行:步骤 ${step} 由后台流程负责收尾,执行函数返回后将直接进入下一步。`, 'info'); - await executeStep(step); + if (doesNodeUseBackgroundCompletion(normalizedNodeId, executionState)) { + await addLog(`自动运行:节点 ${normalizedNodeId} 由后台流程负责收尾,执行函数返回后将直接进入下一步。`, 'info'); + await executeNode(normalizedNodeId); const latestState = await getState(); - await addLog(`自动运行:步骤 ${step} 已执行返回,当前状态为 ${latestState.stepStatuses?.[step] || 'pending'},准备继续后续步骤。`, 'info'); - } else if (doesStepUseCompletionSignal(step, executionState)) { - await addLog(`自动运行:步骤 ${step} 已发起,正在等待完成信号(超时 ${AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS / 1000} 秒)。`, 'info'); - completionPayload = await executeStepViaCompletionSignal(step, AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS); - await addLog(`自动运行:步骤 ${step} 已收到完成信号,准备继续后续步骤。`, 'info'); + await addLog(`自动运行:节点 ${normalizedNodeId} 已执行返回,当前状态为 ${latestState.nodeStatuses?.[normalizedNodeId] || 'pending'},准备继续后续节点。`, 'info'); + } else if (doesNodeUseCompletionSignal(normalizedNodeId, executionState)) { + await addLog(`自动运行:节点 ${normalizedNodeId} 已发起,正在等待完成信号(超时 ${AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS / 1000} 秒)。`, 'info'); + completionPayload = await executeNodeViaCompletionSignal(normalizedNodeId, AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS); + await addLog(`自动运行:节点 ${normalizedNodeId} 已收到完成信号,准备继续后续节点。`, 'info'); } else { - await executeStep(step); + await executeNode(normalizedNodeId); } - if (step === 5) { + if (normalizedNodeId === 'fill-profile') { const signupTabId = await getTabId('signup-page'); if (signupTabId) { - await addLog('自动运行:步骤 5 已收到完成信号,正在等待当前页面完成加载并稳定...', 'info'); + await addLog('自动运行:填写资料节点已收到完成信号,正在等待当前页面完成加载并稳定...', 'info'); await waitForTabStableComplete(signupTabId, { timeoutMs: 30000, retryDelayMs: 300, @@ -10070,8 +10359,8 @@ async function executeStepAndWait(step, delayAfter = 2000) { try { await validateStep5PostCompletion(signupTabId, completionPayload || {}); } catch (step5ValidationError) { - await setStepStatus(5, 'failed'); - await addLog(`失败:${getErrorMessage(step5ValidationError)}`, 'error', { step: 5 }); + await setNodeStatus(normalizedNodeId, 'failed'); + await addLog(`失败:${getErrorMessage(step5ValidationError)}`, 'error', { nodeId: normalizedNodeId }); throw step5ValidationError; } } @@ -10304,23 +10593,35 @@ const VERIFICATION_POLL_MAX_ROUNDS = 5; const STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS = 25000; const MAIL_2925_VERIFICATION_MAX_ATTEMPTS = 15; const MAIL_2925_VERIFICATION_INTERVAL_MS = 15000; -const AUTO_STEP_DELAYS = { - 1: 2000, - 2: 2000, - 3: 3000, - 4: 2000, - 5: 0, - 6: 3000, - 7: 2000, - 8: 2000, - 9: 1000, -}; +const AUTO_RUN_NODE_DELAYS = Object.freeze({ + 'open-chatgpt': 2000, + 'submit-signup-email': 2000, + 'fill-password': 3000, + 'fetch-signup-code': 2000, + 'fill-profile': 0, + 'wait-registration-success': 3000, + 'plus-checkout-create': 3000, + 'plus-checkout-billing': 2000, + 'gopay-subscription-confirm': 2000, + 'paypal-approve': 2000, + 'plus-checkout-return': 1000, + 'oauth-login': 2000, + 'fetch-login-code': 2000, + 'confirm-oauth': 1000, + 'platform-verify': 0, +}); + +function getAutoRunNodeDelayMs(nodeId) { + return AUTO_RUN_NODE_DELAYS[String(nodeId || '').trim()] ?? 0; +} const accountRunHistoryHelpers = self.MultiPageBackgroundAccountRunHistory?.createAccountRunHistoryHelpers({ ACCOUNT_RUN_HISTORY_STORAGE_KEY, addLog, buildLocalHelperEndpoint: (baseUrl, path) => buildHotmailLocalEndpoint(baseUrl, path), chrome, getErrorMessage, + getNodeIdByStepForState, + getNodeTitleForState, getState, normalizeAccountRunHistoryHelperBaseUrl, }); @@ -10710,12 +11011,12 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR ensureHotmailMailboxReadyForAutoRunRound: (...args) => ensureHotmailMailboxReadyForAutoRunRound(...args), getAutoRunStatusPayload, getErrorMessage, - getFirstUnfinishedStep, + getFirstUnfinishedNodeId, getPendingAutoRunTimerPlan, - getRunningSteps, + getRunningNodeIds, getState, getStopRequested: () => stopRequested, - hasSavedProgress, + hasSavedNodeProgress, isAddPhoneAuthFailure, isPhoneSmsPlatformRateLimitFailure, isPlusCheckoutNonFreeTrialFailure, @@ -10729,7 +11030,7 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR onAutoRunRoundSuccess: (payload = {}) => maybeSwitchIpProxyAfterAutoRunRoundSuccess(payload), persistAutoRunTimerPlan, resetState, - runAutoSequenceFromStep: (...args) => runAutoSequenceFromStep(...args), + runAutoSequenceFromNode: (...args) => runAutoSequenceFromNode(...args), runtime: { get: () => ({ autoRunActive, @@ -10749,7 +11050,7 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR setState, sleepWithStop, throwIfAutoRunSessionStopped: (sessionId) => throwIfAutoRunSessionStopped(sessionId), - waitForRunningStepsToFinish, + waitForRunningNodesToFinish, throwIfStopped: () => throwIfStopped(), chrome, }); @@ -11092,15 +11393,42 @@ async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) { return resumedState.email; } -async function runAutoSequenceFromStep(startStep, context = {}) { +async function runAutoSequenceFromNode(startNodeId, context = {}) { + const state = await getState(); + const normalizedStartNodeId = String(startNodeId || '').trim(); + if (!normalizedStartNodeId || !getAutoRunWorkflowNodeIds(state).includes(normalizedStartNodeId)) { + throw new Error(`自动运行无法从未知节点继续:${startNodeId}`); + } + return runAutoSequenceFromNodeGraph(normalizedStartNodeId, context); +} + +function getAutoRunWorkflowNodeIds(state = {}) { + if (typeof getNodeIdsForState === 'function') { + const nodeIds = getNodeIdsForState(state); + if (Array.isArray(nodeIds) && nodeIds.length) { + return nodeIds.map((nodeId) => String(nodeId || '').trim()).filter(Boolean); + } + } + + if (typeof getStepIdsForState === 'function' && typeof getNodeIdByStepForState === 'function') { + return getStepIdsForState(state) + .map((step) => getNodeIdByStepForState(step, state)) + .map((nodeId) => String(nodeId || '').trim()) + .filter(Boolean); + } + + return []; +} + +async function runAutoSequenceFromNodeGraph(startNodeId, context = {}) { const { targetRun, totalRuns, attemptRuns, continued = false } = context; let postStep7RestartCount = 0; let goPayCheckoutRestartCount = 0; let gpcCheckoutRestartCount = 0; let plusCheckoutRestartCount = 0; let step4RestartCount = 0; - const stepIdleRestartCounts = new Map(); - let currentStartStep = startStep; + const nodeIdleRestartCounts = new Map(); + let currentStartNodeId = String(startNodeId || '').trim(); let continueCurrentAttempt = continued; const resolvedSignupMethod = await ensureResolvedSignupMethodForRun(); const normalizePlusPaymentMethodForRun = typeof normalizePlusPaymentMethod === 'function' @@ -11109,12 +11437,62 @@ async function runAutoSequenceFromStep(startStep, context = {}) { const plusPaymentMethodGpcHelper = typeof PLUS_PAYMENT_METHOD_GPC_HELPER === 'string' ? PLUS_PAYMENT_METHOD_GPC_HELPER : 'gpc-helper'; - const attachFailedStep = (error, step) => { - const failedStep = Math.floor(Number(step) || 0); - if (!error || typeof error !== 'object' || failedStep <= 0) { + const getNodeStatusForNode = (state, nodeId) => ( + String(state?.nodeStatuses?.[nodeId] || 'pending').trim() || 'pending' + ); + const getDisplayStepForNode = (nodeId, state = {}) => { + const displayStep = typeof getStepIdByNodeIdForState === 'function' + ? Number(getStepIdByNodeIdForState(nodeId, state)) + : 0; + return Number.isInteger(displayStep) && displayStep > 0 ? displayStep : null; + }; + const getNodeExecutionKey = (nodeId, state = {}) => { + const nodeDefinition = typeof getNodeDefinitionForState === 'function' + ? getNodeDefinitionForState(nodeId, state) + : null; + return String(nodeDefinition?.executeKey || nodeDefinition?.command || nodeId || '').trim(); + }; + const getNodeLabel = (nodeId, state = {}) => { + const title = typeof getNodeTitleForState === 'function' + ? getNodeTitleForState(nodeId, state) + : ''; + return title && title !== nodeId ? `${nodeId}(${title})` : nodeId; + }; + const getNodeIndex = (state, nodeId) => getAutoRunWorkflowNodeIds(state).indexOf(nodeId); + const shouldRunNamedNode = async (nodeId) => { + const state = await getState(); + const nodeIds = getAutoRunWorkflowNodeIds(state); + const targetIndex = nodeIds.indexOf(nodeId); + if (targetIndex < 0) { + return false; + } + const startIndex = nodeIds.indexOf(currentStartNodeId); + return startIndex < 0 || startIndex <= targetIndex; + }; + const getPreviousNodeId = (nodeId, state = {}) => { + const nodeIds = getAutoRunWorkflowNodeIds(state); + const index = nodeIds.indexOf(nodeId); + return index > 0 ? nodeIds[index - 1] : ''; + }; + const setRestartNode = (nodeId) => { + currentStartNodeId = String(nodeId || '').trim(); + continueCurrentAttempt = true; + }; + const attachFailedNode = (error, nodeId, state = {}) => { + const failedNodeId = String(nodeId || '').trim(); + if (!error || typeof error !== 'object' || !failedNodeId) { return error; } + if (!String(error.failedNodeId || '').trim()) { + try { + error.failedNodeId = failedNodeId; + } catch (_err) { + // Some host errors may be non-extensible; state-based inference still covers normal paths. + } + } + + const failedStep = getDisplayStepForNode(failedNodeId, state); if (!Number.isInteger(Number(error.failedStep)) || Number(error.failedStep) <= 0) { try { error.failedStep = failedStep; @@ -11125,16 +11503,27 @@ async function runAutoSequenceFromStep(startStep, context = {}) { return error; }; - const restartCurrentStepAfterIdle = async (step, error) => { + const invalidateDownstreamAfterAutoRunNodeRestart = async (nodeId, options = {}) => { + if (typeof invalidateDownstreamAfterNodeRestart === 'function') { + return invalidateDownstreamAfterNodeRestart(nodeId, options); + } + const state = await getState(); + const step = getDisplayStepForNode(nodeId, state); + if (Number.isInteger(step) && step > 0 && typeof invalidateDownstreamAfterStepRestart === 'function') { + return invalidateDownstreamAfterStepRestart(step, options); + } + return undefined; + }; + const restartCurrentNodeAfterIdle = async (nodeId, error) => { if (!isAutoRunStepIdleRestartError(error)) { return false; } - const idleRestartCount = (stepIdleRestartCounts.get(step) || 0) + 1; - stepIdleRestartCounts.set(step, idleRestartCount); + const idleRestartCount = (nodeIdleRestartCounts.get(nodeId) || 0) + 1; + nodeIdleRestartCounts.set(nodeId, idleRestartCount); if (idleRestartCount > AUTO_RUN_STEP_IDLE_RESTART_MAX_ATTEMPTS) { await addLog( - `步骤 ${step}:已连续 ${AUTO_RUN_STEP_IDLE_RESTART_MAX_ATTEMPTS} 次因 5 分钟无新日志而重开,停止自动重试。原因:${getErrorMessage(error)}`, + `节点 ${nodeId}:已连续 ${AUTO_RUN_STEP_IDLE_RESTART_MAX_ATTEMPTS} 次因 5 分钟无新日志而重开,停止自动重试。原因:${getErrorMessage(error)}`, 'error' ); throw error; @@ -11142,62 +11531,63 @@ async function runAutoSequenceFromStep(startStep, context = {}) { const reason = getErrorMessage(error); if (typeof cancelPendingCommands === 'function') { - cancelPendingCommands(`步骤 ${step} 5 分钟没有新日志,准备重开当前步骤。`); + cancelPendingCommands(`节点 ${nodeId} 5 分钟没有新日志,准备重开当前节点。`); } if (typeof broadcastStopToContentScripts === 'function') { await broadcastStopToContentScripts(); } await addLog( - `步骤 ${step}:5 分钟没有新日志,准备重新开始当前步骤(第 ${idleRestartCount}/${AUTO_RUN_STEP_IDLE_RESTART_MAX_ATTEMPTS} 次)。原因:${reason}`, + `节点 ${nodeId}:5 分钟没有新日志,准备重新开始当前节点(第 ${idleRestartCount}/${AUTO_RUN_STEP_IDLE_RESTART_MAX_ATTEMPTS} 次)。原因:${reason}`, 'warn' ); - await invalidateDownstreamAfterStepRestart(Math.max(0, step - 1), { - logLabel: `步骤 ${step} 因 5 分钟无新日志准备重开(第 ${idleRestartCount}/${AUTO_RUN_STEP_IDLE_RESTART_MAX_ATTEMPTS} 次)`, + const latestState = await getState(); + const resetAnchorNodeId = getPreviousNodeId(nodeId, latestState) || nodeId; + await invalidateDownstreamAfterAutoRunNodeRestart(resetAnchorNodeId, { + logLabel: `节点 ${nodeId} 因 5 分钟无新日志准备重开(第 ${idleRestartCount}/${AUTO_RUN_STEP_IDLE_RESTART_MAX_ATTEMPTS} 次)`, }); - currentStartStep = step; - continueCurrentAttempt = true; + setRestartNode(nodeId); return true; }; while (true) { if (continueCurrentAttempt) { - await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:继续当前进度,从步骤 ${startStep} 开始(第 ${attemptRuns} 次尝试)===`, 'info'); + await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:继续当前进度,从节点 ${currentStartNodeId} 开始(第 ${attemptRuns} 次尝试)===`, 'info'); } else { await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:第 ${attemptRuns} 次尝试,阶段 1,打开官网并进入密码页 ===`, 'info'); } - if (currentStartStep <= 1) { + if (await shouldRunNamedNode('open-chatgpt')) { try { - await executeStepAndWaitWithAutoRunIdleLogWatchdog(1, AUTO_STEP_DELAYS[1]); + await executeNodeAndWaitWithAutoRunIdleLogWatchdog('open-chatgpt', getAutoRunNodeDelayMs('open-chatgpt')); } catch (err) { - attachFailedStep(err, 1); + attachFailedNode(err, 'open-chatgpt', await getState()); if (isStopError(err)) { throw err; } - if (await restartCurrentStepAfterIdle(1, err)) { + if (await restartCurrentNodeAfterIdle('open-chatgpt', err)) { continue; } throw err; } } - if (currentStartStep <= 2) { + if (await shouldRunNamedNode('submit-signup-email')) { try { - await runAutoStepActionWithIdleLogWatchdog(2, async () => { + await runAutoNodeActionWithIdleLogWatchdog('submit-signup-email', async () => { if (resolvedSignupMethod === SIGNUP_METHOD_PHONE) { await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:本轮注册方式为手机号注册,将跳过邮箱预获取 ===`, 'info'); } else { await ensureAutoEmailReady(targetRun, totalRuns, attemptRuns); } - await executeStepAndWait(2, AUTO_STEP_DELAYS[2]); + await executeNodeAndWait('submit-signup-email', getAutoRunNodeDelayMs('submit-signup-email')); }); } catch (err) { - attachFailedStep(err, 2); + attachFailedNode(err, 'submit-signup-email', await getState()); if (isStopError(err)) { throw err; } - if (await restartCurrentStepAfterIdle(2, err)) { + if (await restartCurrentNodeAfterIdle('submit-signup-email', err)) { continue; } throw err; @@ -11206,33 +11596,32 @@ async function runAutoSequenceFromStep(startStep, context = {}) { let restartFromStep1WithCurrentEmail = false; - if (currentStartStep <= 3) { + if (await shouldRunNamedNode('fill-password')) { const latestState = await getState(); - const step3Status = latestState.stepStatuses?.[3] || 'pending'; + const fillPasswordStatus = getNodeStatusForNode(latestState, 'fill-password'); await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:阶段 2,填写密码、验证、登录并完成授权(第 ${attemptRuns} 次尝试)===`, 'info'); await broadcastAutoRunStatus('running', { currentRun: targetRun, totalRuns, attemptRun: attemptRuns, }); - if (isStepDoneStatus(step3Status)) { - await addLog(`自动运行:步骤 3 当前状态为 ${step3Status},将直接继续后续流程。`, 'info'); + if (isStepDoneStatus(fillPasswordStatus)) { + await addLog(`自动运行:节点 fill-password 当前状态为 ${fillPasswordStatus},将直接继续后续流程。`, 'info'); } else { try { - await executeStepAndWaitWithAutoRunIdleLogWatchdog(3, AUTO_STEP_DELAYS[3]); + await executeNodeAndWaitWithAutoRunIdleLogWatchdog('fill-password', getAutoRunNodeDelayMs('fill-password')); } catch (err) { - attachFailedStep(err, 3); + attachFailedNode(err, 'fill-password', latestState); if (isStopError(err)) { throw err; } - if (await restartCurrentStepAfterIdle(3, err)) { + if (await restartCurrentNodeAfterIdle('fill-password', err)) { continue; } if (isSignupPhonePasswordMismatchFailure(err)) { step4RestartCount += 1; - await restartSignupPhonePasswordMismatchAttemptFromStep(3, step4RestartCount, err); - currentStartStep = 1; - continueCurrentAttempt = true; + await restartSignupPhonePasswordMismatchAttemptFromNode('fill-password', step4RestartCount, err); + setRestartNode('open-chatgpt'); restartFromStep1WithCurrentEmail = true; continue; } @@ -11252,42 +11641,48 @@ async function runAutoSequenceFromStep(startStep, context = {}) { await chrome.tabs.update(signupTabId, { active: true }); } - let step = Math.max(currentStartStep, 4); - while (step <= (typeof getLastStepIdForState === 'function' - ? getLastStepIdForState(await getState()) - : (typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10))) { + let loopState = await getState(); + let nodeIds = getAutoRunWorkflowNodeIds(loopState); + const firstVerificationIndex = nodeIds.indexOf('fetch-signup-code'); + const startIndex = nodeIds.indexOf(currentStartNodeId); + let nodeIndex = Math.max( + startIndex >= 0 ? startIndex : 0, + firstVerificationIndex >= 0 ? firstVerificationIndex : 0 + ); + while (nodeIndex < nodeIds.length) { const latestState = await getState(); - if (typeof getStepDefinitionForState === 'function' && !getStepDefinitionForState(step, latestState)) { - step += 1; + nodeIds = getAutoRunWorkflowNodeIds(latestState); + const nodeId = nodeIds[nodeIndex]; + if (!nodeId) { + nodeIndex += 1; continue; } - const currentStatus = latestState.stepStatuses?.[step] || 'pending'; + const currentStatus = getNodeStatusForNode(latestState, nodeId); if (isStepDoneStatus(currentStatus)) { - await addLog(`自动运行:步骤 ${step} 当前状态为 ${currentStatus},将直接继续后续流程。`, 'info'); - step += 1; + await addLog(`自动运行:节点 ${nodeId} 当前状态为 ${currentStatus},将直接继续后续流程。`, 'info'); + nodeIndex += 1; continue; } try { - await executeStepAndWaitWithAutoRunIdleLogWatchdog(step, AUTO_STEP_DELAYS[step]); - step += 1; + await executeNodeAndWaitWithAutoRunIdleLogWatchdog(nodeId, getAutoRunNodeDelayMs(nodeId)); + nodeIndex += 1; } catch (err) { - attachFailedStep(err, step); + attachFailedNode(err, nodeId, latestState); if (isStopError(err)) { throw err; } - if (await restartCurrentStepAfterIdle(step, err)) { + if (await restartCurrentNodeAfterIdle(nodeId, err)) { continue; } - const stepExecutionKey = typeof getStepExecutionKeyForState === 'function' - ? getStepExecutionKeyForState(step, latestState) - : ''; + const step = getDisplayStepForNode(nodeId, latestState); + const nodeExecutionKey = getNodeExecutionKey(nodeId, latestState); const isGpcCheckoutStep = normalizePlusPaymentMethodForRun(latestState?.plusPaymentMethod) === plusPaymentMethodGpcHelper || String(latestState?.plusCheckoutSource || '').trim() === plusPaymentMethodGpcHelper; - if (isPlusCheckoutRestartStep(step, stepExecutionKey, latestState) + if (isPlusCheckoutRestartStep(step, nodeExecutionKey, latestState) && isPlusCheckoutRestartRequiredFailure(err)) { - const isGoPayCheckoutStep = stepExecutionKey === 'gopay-subscription-confirm' + const isGoPayCheckoutStep = nodeExecutionKey === 'gopay-subscription-confirm' || normalizePlusPaymentMethodForRun(latestState?.plusPaymentMethod) === 'gopay'; if (isGpcCheckoutStep) { gpcCheckoutRestartCount += 1; @@ -11306,39 +11701,40 @@ async function runAutoSequenceFromStep(startStep, context = {}) { ? '重新创建 GPC 任务' : (isGoPayCheckoutStep ? '重新创建 GoPay 订阅' : '重新创建 Plus Checkout'); await addLog( - `步骤 ${step}:检测到 ${checkoutLabel} 失败/卡住,准备回到步骤 6 ${recreateLabel}(第 ${checkoutRestartCount} 次)。原因:${getErrorMessage(err)}`, + `节点 ${getNodeLabel(nodeId, latestState)}:检测到 ${checkoutLabel} 失败/卡住,准备回到节点 plus-checkout-create ${recreateLabel}(第 ${checkoutRestartCount} 次)。原因:${getErrorMessage(err)}`, 'warn' ); - await invalidateDownstreamAfterStepRestart(5, { - logLabel: `步骤 ${step} ${checkoutLabel}失败后准备回到步骤 6 重试(第 ${checkoutRestartCount} 次)`, + const checkoutResetAnchorNodeId = getPreviousNodeId('plus-checkout-create', latestState) || 'fill-profile'; + await invalidateDownstreamAfterAutoRunNodeRestart(checkoutResetAnchorNodeId, { + logLabel: `节点 ${nodeId} ${checkoutLabel}失败后准备回到 plus-checkout-create 重试(第 ${checkoutRestartCount} 次)`, }); - step = 6; + nodeIndex = Math.max(0, getNodeIndex(await getState(), 'plus-checkout-create')); continue; } - if (step === 8 && isGoPayCheckoutRestartRequiredFailure(err)) { + if (nodeId === 'paypal-approve' && isGoPayCheckoutRestartRequiredFailure(err)) { goPayCheckoutRestartCount += 1; if (goPayCheckoutRestartCount > 3) { - await addLog(`步骤 8:GoPay Checkout 已连续重建 ${goPayCheckoutRestartCount - 1} 次仍失败,停止自动重试。原因:${getErrorMessage(err)}`, 'error'); + await addLog(`节点 paypal-approve:GoPay Checkout 已连续重建 ${goPayCheckoutRestartCount - 1} 次仍失败,停止自动重试。原因:${getErrorMessage(err)}`, 'error'); throw err; } await addLog( - `步骤 8:检测到 GoPay 支付页失效/卡死,准备关闭旧页并回到步骤 6 重新创建 Checkout(第 ${goPayCheckoutRestartCount}/3 次)。原因:${getErrorMessage(err)}`, + `节点 paypal-approve:检测到 GoPay 支付页失效/卡死,准备关闭旧页并回到节点 plus-checkout-create 重新创建 Checkout(第 ${goPayCheckoutRestartCount}/3 次)。原因:${getErrorMessage(err)}`, 'warn' ); - await invalidateDownstreamAfterStepRestart(5, { - logLabel: `步骤 8 GoPay 支付页失效后准备回到步骤 6 重试(第 ${goPayCheckoutRestartCount}/3 次)`, + await invalidateDownstreamAfterAutoRunNodeRestart(getPreviousNodeId('plus-checkout-create', latestState) || 'fill-profile', { + logLabel: `节点 paypal-approve GoPay 支付页失效后准备回到 plus-checkout-create 重试(第 ${goPayCheckoutRestartCount}/3 次)`, }); - step = 6; + nodeIndex = Math.max(0, getNodeIndex(await getState(), 'plus-checkout-create')); continue; } - if (step === 4) { + if (nodeId === 'fetch-signup-code') { if (isSignupUserAlreadyExistsFailure(err)) { throw err; } if (isMail2925ThreadTerminatedError(err)) { - await addLog(`步骤 4:2925 已切换账号并要求结束当前尝试:${getErrorMessage(err)}`, 'warn'); + await addLog(`节点 fetch-signup-code:2925 已切换账号并要求结束当前尝试:${getErrorMessage(err)}`, 'warn'); throw err; } step4RestartCount += 1; @@ -11346,18 +11742,18 @@ async function runAutoSequenceFromStep(startStep, context = {}) { && typeof phoneVerificationHelpers?.isPhoneResendBannedNumberError === 'function' && phoneVerificationHelpers.isPhoneResendBannedNumberError(err); if (isSignupPhonePasswordMismatchFailure(err) || isPhoneResendBanned) { - await restartSignupPhonePasswordMismatchAttemptFromStep(4, step4RestartCount, err); + await restartSignupPhonePasswordMismatchAttemptFromNode('fetch-signup-code', step4RestartCount, err); } else { const preservedState = await getState(); const preservedEmail = String(preservedState.email || '').trim(); const preservedPassword = String(preservedState.password || '').trim(); const emailSuffix = preservedEmail ? `当前邮箱:${preservedEmail};` : ''; await addLog( - `步骤 4:执行失败,准备沿用当前邮箱回到步骤 1 重新开始(第 ${step4RestartCount} 次重开)。${emailSuffix}原因:${getErrorMessage(err)}`, + `节点 fetch-signup-code:执行失败,准备沿用当前邮箱回到节点 open-chatgpt 重新开始(第 ${step4RestartCount} 次重开)。${emailSuffix}原因:${getErrorMessage(err)}`, 'warn' ); - await invalidateDownstreamAfterStepRestart(1, { - logLabel: `步骤 4 报错后准备回到步骤 1 沿用当前邮箱重试(第 ${step4RestartCount} 次重开)`, + await invalidateDownstreamAfterAutoRunNodeRestart('open-chatgpt', { + logLabel: `节点 fetch-signup-code 报错后准备回到 open-chatgpt 沿用当前邮箱重试(第 ${step4RestartCount} 次重开)`, }); const restorePayload = {}; if (preservedEmail) restorePayload.email = preservedEmail; @@ -11366,8 +11762,7 @@ async function runAutoSequenceFromStep(startStep, context = {}) { await setState(restorePayload); } } - currentStartStep = 1; - continueCurrentAttempt = true; + setRestartNode('open-chatgpt'); restartFromStep1WithCurrentEmail = true; break; } @@ -11375,11 +11770,9 @@ async function runAutoSequenceFromStep(startStep, context = {}) { const restartDecision = await getPostStep6AutoRestartDecision(step, err); if (restartDecision.shouldRestart) { postStep7RestartCount += 1; - const restartStep = restartDecision.restartStep - || (typeof getAuthChainStartStepId === 'function' - ? getAuthChainStartStepId(await getState()) - : FINAL_OAUTH_CHAIN_START_STEP); - const resetAfterStep = Math.max(1, restartStep - 1); + const restartStep = restartDecision.restartStep; + const restartNodeId = String(getNodeIdByStepForState(restartStep, await getState()) || 'oauth-login').trim(); + const resetAfterNodeId = getPreviousNodeId(restartNodeId, await getState()) || restartNodeId; const authState = restartDecision.authState; const authStateLabel = authState?.state ? getLoginAuthStateLabel(authState.state) : '未知页面'; const authStateSuffix = authState?.url @@ -11388,22 +11781,20 @@ async function runAutoSequenceFromStep(startStep, context = {}) { ? `当前认证页:${authStateLabel}` : '未获取到认证页状态'; await addLog( - `步骤 ${step}:检测到报错且当前未进入 add-phone,正在回到步骤 ${restartStep} 重新开始授权流程(第 ${postStep7RestartCount} 次重开)。${authStateSuffix};原因:${restartDecision.errorMessage || '未知错误'}`, + `节点 ${getNodeLabel(nodeId, latestState)}:检测到报错且当前未进入 add-phone,正在回到节点 ${restartNodeId} 重新开始授权流程(第 ${postStep7RestartCount} 次重开)。${authStateSuffix};原因:${restartDecision.errorMessage || '未知错误'}`, 'warn' ); - await invalidateDownstreamAfterStepRestart(resetAfterStep, { - logLabel: `步骤 ${step} 报错后准备回到步骤 ${restartStep} 重试(第 ${postStep7RestartCount} 次重开)`, + await invalidateDownstreamAfterAutoRunNodeRestart(resetAfterNodeId, { + logLabel: `节点 ${nodeId} 报错后准备回到 ${restartNodeId} 重试(第 ${postStep7RestartCount} 次重开)`, }); - step = restartStep; + nodeIndex = Math.max(0, getNodeIndex(await getState(), restartNodeId)); continue; } if (restartDecision.blockedByAddPhone) { const addPhoneUrl = restartDecision.authState?.url || 'https://auth.openai.com/add-phone'; - const authChainStartStep = typeof getAuthChainStartStepId === 'function' - ? getAuthChainStartStepId(await getState()) - : FINAL_OAUTH_CHAIN_START_STEP; - await addLog(`步骤 ${step}:检测到认证流程进入 add-phone(${addPhoneUrl}),停止自动回到步骤 ${authChainStartStep} 重开。`, 'warn'); + const authChainStartNodeId = String(getNodeIdByStepForState(restartDecision.restartStep, await getState()) || 'oauth-login').trim(); + await addLog(`节点 ${getNodeLabel(nodeId, latestState)}:检测到认证流程进入 add-phone(${addPhoneUrl}),停止自动回到节点 ${authChainStartNodeId} 重开。`, 'warn'); } throw err; } @@ -11595,11 +11986,12 @@ const verificationFlowHelpers = self.MultiPageBackgroundVerificationFlow?.create closeConflictingTabsForSource, CLOUDFLARE_TEMP_EMAIL_PROVIDER, CLOUD_MAIL_PROVIDER, - completeStepFromBackground, + completeNodeFromBackground, confirmCustomVerificationStepBypassRequest: (step) => chrome.runtime.sendMessage({ type: 'REQUEST_CUSTOM_VERIFICATION_BYPASS_CONFIRMATION', payload: { step }, }), + getNodeIdByStepForState, getHotmailVerificationPollConfig, getHotmailVerificationRequestTimestamp, handleMail2925LimitReachedError, @@ -11619,8 +12011,8 @@ const verificationFlowHelpers = self.MultiPageBackgroundVerificationFlow?.create sendToContentScript, sendToContentScriptResilient, sendToMailContentScriptResilient, + setNodeStatus, setState, - setStepStatus, sleepWithStop, throwIfStopped, VERIFICATION_POLL_MAX_ROUNDS, @@ -11683,13 +12075,13 @@ const phoneVerificationHelpers = self.MultiPageBackgroundPhoneVerification?.crea }); const step1Executor = self.MultiPageBackgroundStep1?.createStep1Executor({ addLog, - completeStepFromBackground, + completeNodeFromBackground, openSignupEntryTab, }); const step2Executor = self.MultiPageBackgroundStep2?.createStep2Executor({ addLog, chrome, - completeStepFromBackground, + completeNodeFromBackground, ensureContentScriptReadyOnTab, ensureSignupAuthEntryPageReady, ensureSignupEntryPageReady, @@ -11731,7 +12123,7 @@ async function ensureIcloudMailSessionForVerification(options = {}) { const step4Executor = self.MultiPageBackgroundStep4?.createStep4Executor({ addLog, chrome, - completeStepFromBackground, + completeNodeFromBackground, confirmCustomVerificationStepBypass: verificationFlowHelpers.confirmCustomVerificationStepBypass, generateRandomBirthday, generateRandomName, @@ -11765,14 +12157,14 @@ const step5Executor = self.MultiPageBackgroundStep5?.createStep5Executor({ const step6Executor = self.MultiPageBackgroundStep6?.createStep6Executor({ addLog, chrome, - completeStepFromBackground, + completeNodeFromBackground, getErrorMessage, registrationSuccessWaitMs: STEP6_REGISTRATION_SUCCESS_WAIT_MS, sleepWithStop, }); const step7Executor = self.MultiPageBackgroundStep7?.createStep7Executor({ addLog, - completeStepFromBackground, + completeNodeFromBackground, getErrorMessage, getLoginAuthStateLabel, getOAuthFlowStepTimeoutMs, @@ -11794,7 +12186,7 @@ const step8Executor = self.MultiPageBackgroundStep8?.createStep8Executor({ chrome, CLOUDFLARE_TEMP_EMAIL_PROVIDER, CLOUD_MAIL_PROVIDER, - completeStepFromBackground, + completeNodeFromBackground, confirmCustomVerificationStepBypass: verificationFlowHelpers.confirmCustomVerificationStepBypass, ensureMail2925MailboxSession, ensureIcloudMailSession: ensureIcloudMailSessionForVerification, @@ -11828,7 +12220,7 @@ const step8Executor = self.MultiPageBackgroundStep8?.createStep8Executor({ const plusCheckoutCreateExecutor = self.MultiPageBackgroundPlusCheckoutCreate?.createPlusCheckoutCreateExecutor({ addLog, chrome, - completeStepFromBackground, + completeNodeFromBackground, createAutomationTab, ensureContentScriptReadyOnTabUntilStopped, fetch: typeof fetch === 'function' ? fetch.bind(globalThis) : null, @@ -11844,7 +12236,7 @@ const plusCheckoutBillingExecutor = self.MultiPageBackgroundPlusCheckoutBilling? addLog, broadcastDataUpdate, chrome, - completeStepFromBackground, + completeNodeFromBackground, ensureContentScriptReadyOnTabUntilStopped, fetch: typeof fetch === 'function' ? fetch.bind(globalThis) : null, generateRandomName, @@ -11875,7 +12267,7 @@ const goPayManualConfirmExecutor = self.MultiPageBackgroundGoPayManualConfirm?.c const payPalApproveExecutor = self.MultiPageBackgroundPayPalApprove?.createPayPalApproveExecutor({ addLog, chrome, - completeStepFromBackground, + completeNodeFromBackground, ensureContentScriptReadyOnTabUntilStopped, queryTabsInAutomationWindow, getTabId, @@ -11889,7 +12281,7 @@ const payPalApproveExecutor = self.MultiPageBackgroundPayPalApprove?.createPayPa const goPayApproveExecutor = self.MultiPageBackgroundGoPayApprove?.createGoPayApproveExecutor({ addLog, chrome, - completeStepFromBackground, + completeNodeFromBackground, ensureContentScriptReadyOnTabUntilStopped, getTabId, isTabAlive, @@ -11907,7 +12299,7 @@ const goPayApproveExecutor = self.MultiPageBackgroundGoPayApprove?.createGoPayAp }); const plusReturnConfirmExecutor = self.MultiPageBackgroundPlusReturnConfirm?.createPlusReturnConfirmExecutor({ addLog, - completeStepFromBackground, + completeNodeFromBackground, getTabId, isTabAlive, setState, @@ -11918,7 +12310,7 @@ const step10Executor = self.MultiPageBackgroundStep10?.createStep10Executor({ addLog, chrome, closeConflictingTabsForSource, - completeStepFromBackground, + completeNodeFromBackground, ensureContentScriptReadyOnTab, getPanelMode, getTabId, @@ -11972,18 +12364,18 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter clearStopRequest, closeLocalhostCallbackTabs, closeTabsByUrlPrefix, - completeStepFromBackground, + completeNodeFromBackground, deleteHotmailAccount, deleteHotmailAccounts, deleteIcloudAlias, deleteUsedIcloudAliases, findPayPalAccount, disableUsedLuckmailPurchases, - doesStepUseCompletionSignal, + doesNodeUseCompletionSignal, ensureMail2925MailboxSession, ensureManualInteractionAllowed, - executeStep, - executeStepViaCompletionSignal, + executeNode, + executeNodeViaCompletionSignal, exportSettingsBundle, fetchGeneratedEmail, refreshGpcCardBalance, @@ -12004,6 +12396,9 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter getPendingAutoRunTimerPlan, getSourceLabel, getState, + getNodeDefinitionForState, + getNodeIdsForState, + getStepIdByNodeIdForState, getStepDefinitionForState, getStepIdsForState, getLastStepIdForState, @@ -12038,8 +12433,8 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter normalizePayPalAccounts, normalizeRunCount, AUTO_RUN_TIMER_KIND_SCHEDULED_START, - notifyStepComplete, - notifyStepError, + notifyNodeComplete, + notifyNodeError, patchHotmailAccount, patchMail2925Account, registerTab, @@ -12068,9 +12463,9 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter setLuckmailPurchaseUsedState, setPersistentSettings, setState, - setStepStatus, + setNodeStatus, skipAutoRunCountdown, - skipStep, + skipNode, startContributionFlow: (...args) => contributionOAuthManager?.startContributionFlow?.(...args), startAutoRunLoop, pollContributionStatus: (...args) => contributionOAuthManager?.pollContributionStatus?.(...args), @@ -12085,15 +12480,43 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter verifyHotmailAccount, }); -function buildStepRegistry(definitions = []) { - return self.MultiPageBackgroundStepRegistry?.createStepRegistry( +function buildNodeRegistry(definitions = []) { + return self.MultiPageBackgroundStepRegistry?.createNodeRegistry( definitions.map((definition) => ({ ...definition, - execute: stepExecutorsByKey[definition.key], + nodeId: definition.nodeId || definition.key, + displayOrder: definition.displayOrder || definition.order, + executeKey: definition.executeKey || definition.key, + execute: stepExecutorsByKey[definition.executeKey || definition.key || definition.nodeId], })) ); } +function buildStepRegistry(definitions = []) { + const nodeRegistry = buildNodeRegistry(definitions); + return { + executeNode: (nodeId, state) => nodeRegistry.executeNode(nodeId, state), + getNodeDefinition: (nodeId) => nodeRegistry.getNodeDefinition(nodeId), + getOrderedNodes: () => nodeRegistry.getOrderedNodes(), + executeStep: (step, state) => { + const nodeId = String(getStepDefinitionForState(step, state)?.key || '').trim(); + if (!nodeId) { + throw new Error(`未知节点:${step}`); + } + return nodeRegistry.executeNode(nodeId, state); + }, + getStepDefinition: (step) => { + const nodeId = String(getStepDefinitionForState(step, {})?.key || '').trim(); + return nodeId ? nodeRegistry.getNodeDefinition(nodeId) : null; + }, + getOrderedSteps: () => nodeRegistry.getOrderedNodes(), + }; +} + +async function acquireTopLevelAuthChainExecution(step, state = {}) { + return acquireTopLevelAuthChainExecutionForNode(getNodeIdByStepForState(step, state), state); +} + const normalStepRegistry = buildStepRegistry(NORMAL_STEP_DEFINITIONS); const plusPayPalStepRegistry = buildStepRegistry(PLUS_PAYPAL_STEP_DEFINITIONS); const plusGoPayStepRegistry = buildStepRegistry(PLUS_GOPAY_STEP_DEFINITIONS); @@ -12539,9 +12962,9 @@ async function getPostStep6AutoRestartDecision(step, error) { const lastStepId = typeof getLastStepIdForState === 'function' ? getLastStepIdForState(latestState) : (typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10); - const currentStepKey = resolveStepKey(normalizedStep, latestState); + const currentNodeKey = resolveStepKey(normalizedStep, latestState); const confirmOauthStep = findStepIdByKeyForState('confirm-oauth', latestState); - const shouldRetryFromConfirmStep = currentStepKey === 'platform-verify' + const shouldRetryFromConfirmStep = currentNodeKey === 'platform-verify' && Number.isFinite(confirmOauthStep) && confirmOauthStep > 0 && confirmOauthStep < normalizedStep @@ -12923,12 +13346,13 @@ async function rerunStep7ForStep8Recovery(options = {}) { const authLoginStep = typeof getAuthChainStartStepId === 'function' ? getAuthChainStartStepId(initialState) : FINAL_OAUTH_CHAIN_START_STEP; + const authLoginNodeId = getNodeIdByStepForState(authLoginStep, initialState) || 'oauth-login'; await addLog(logMessage, 'warn', { step: logStep, stepKey: logStepKey, }); - await setStepStatus(authLoginStep, 'running'); - await addLog('开始执行', 'info', { step: authLoginStep }); + await setNodeStatus(authLoginNodeId, 'running'); + await addLog('开始执行', 'info', { nodeId: authLoginNodeId }); try { await step7Executor.executeStep7({ @@ -12938,18 +13362,18 @@ async function rerunStep7ForStep8Recovery(options = {}) { } catch (err) { const latestState = await getState(); if (isStopError(err)) { - await setStepStatus(authLoginStep, 'stopped'); - await addLog('已被用户停止', 'warn', { step: authLoginStep }); - await appendManualAccountRunRecordIfNeeded(`step${authLoginStep}_stopped`, latestState, getErrorMessage(err)); + await setNodeStatus(authLoginNodeId, 'stopped'); + await addLog('已被用户停止', 'warn', { nodeId: authLoginNodeId }); + await appendManualAccountRunRecordIfNeeded(`node:${authLoginNodeId}:stopped`, latestState, getErrorMessage(err)); throw err; } if (isTerminalSecurityBlockedError(err)) { await handleCloudflareSecurityBlocked(err); throw new Error(STOP_ERROR_MESSAGE); } - await setStepStatus(authLoginStep, 'failed'); - await addLog(`失败:${getErrorMessage(err)}`, 'error', { step: authLoginStep }); - await appendManualAccountRunRecordIfNeeded(`step${authLoginStep}_failed`, latestState, getErrorMessage(err)); + await setNodeStatus(authLoginNodeId, 'failed'); + await addLog(`失败:${getErrorMessage(err)}`, 'error', { nodeId: authLoginNodeId }); + await appendManualAccountRunRecordIfNeeded(`node:${authLoginNodeId}:failed`, latestState, getErrorMessage(err)); throw err; } @@ -13543,7 +13967,7 @@ const step9Executor = self.MultiPageBackgroundStep9?.createStep9Executor({ chrome, cleanupStep8NavigationListeners, clickWithDebugger, - completeStepFromBackground, + completeNodeFromBackground, ensureStep8SignupPageReady, getOAuthFlowStepTimeoutMs, getStep8CallbackUrlFromNavigation, @@ -13640,7 +14064,7 @@ async function executeContributionStep10(state) { step: platformVerifyStep, stepKey: 'platform-verify', }); - await completeStepFromBackground(platformVerifyStep, { + await completeNodeFromBackground(state?.nodeId || 'platform-verify', { contributionStatus: status, contributionStatusMessage: latestState.contributionStatusMessage || '', localhostUrl: callbackUrl, diff --git a/background/account-run-history.js b/background/account-run-history.js index 773fa4a..c695338 100644 --- a/background/account-run-history.js +++ b/background/account-run-history.js @@ -8,6 +8,8 @@ buildLocalHelperEndpoint, chrome, getErrorMessage, + getNodeIdByStepForState = null, + getNodeTitleForState = null, getState, normalizeAccountRunHistoryHelperBaseUrl, } = deps; @@ -30,18 +32,27 @@ if (normalized === 'success') { return 'success'; } - if (normalized === 'running' || /_running$/.test(normalized)) { + if (normalized === 'running' || /_running$/.test(normalized) || /^node:[^:]+:running$/.test(normalized)) { return 'running'; } - if (normalized === 'failed' || /_failed$/.test(normalized)) { + if (normalized === 'failed' || /_failed$/.test(normalized) || /^node:[^:]+:failed$/.test(normalized)) { return 'failed'; } - if (normalized === 'stopped' || /_stopped$/.test(normalized)) { + if (normalized === 'stopped' || /_stopped$/.test(normalized) || /^node:[^:]+:stopped$/.test(normalized)) { return 'stopped'; } return ''; } + function normalizeNodeId(value = '') { + return String(value || '').trim(); + } + + function extractRecordNodeFromStatus(status = '') { + const match = String(status || '').trim().match(/^node:([^:]+):(?:running|failed|stopped)$/i); + return match ? normalizeNodeId(match[1]) : ''; + } + function extractRecordStepFromStatus(status = '') { const normalizedStatus = String(status || '').trim().toLowerCase(); const statusMatch = normalizedStatus.match(/^step(\d+)_(?:failed|stopped)$/); @@ -88,6 +99,11 @@ return Number.isInteger(step) && step > 0 ? step : null; } + function parseFailureLabelNode(label = '') { + const match = String(label || '').trim().match(/^节点\s*([A-Za-z0-9_.:-]+)\s*(?:失败|停止)$/); + return match ? normalizeNodeId(match[1]) : ''; + } + function shouldIgnorePersistedFailedStepCandidate(candidate, failureDetail = '') { if (!Number.isInteger(candidate) || candidate <= 0) { return true; @@ -128,7 +144,22 @@ return null; } - function resolveFailureLabel(finalStatus, rawFailureLabel = '', computedFailureLabel = '', failedStep = null) { + function resolveNormalizedFailedNodeId(record = {}, status = '', failedStep = null) { + const explicitNodeId = normalizeNodeId(record.failedNodeId || record.failedNode || record.nodeId); + if (explicitNodeId) { + return explicitNodeId; + } + const statusNodeId = extractRecordNodeFromStatus(status || record.finalStatus || record.status || ''); + if (statusNodeId) { + return statusNodeId; + } + if (Number.isInteger(failedStep) && failedStep > 0 && typeof getNodeIdByStepForState === 'function') { + return normalizeNodeId(getNodeIdByStepForState(failedStep, record)); + } + return ''; + } + + function resolveFailureLabel(finalStatus, rawFailureLabel = '', computedFailureLabel = '', failedNodeId = '', failedStep = null) { const rawLabel = String(rawFailureLabel || '').trim(); if (finalStatus === 'stopped') { return computedFailureLabel; @@ -137,6 +168,11 @@ return rawLabel || computedFailureLabel; } + const rawNodeId = parseFailureLabelNode(rawLabel); + if (rawNodeId) { + return rawNodeId === failedNodeId ? rawLabel : computedFailureLabel; + } + const rawStep = parseFailureLabelStep(rawLabel); if (Number.isInteger(rawStep) && rawStep > 0) { return rawStep === failedStep ? rawLabel : computedFailureLabel; @@ -156,7 +192,21 @@ || /进入了手机号页面/.test(text); } - function buildFailureLabel(finalStatus, failedStep, failureDetail = '') { + function getNodeDisplayName(nodeId, state = {}) { + const normalizedNodeId = normalizeNodeId(nodeId); + if (!normalizedNodeId) { + return ''; + } + if (typeof getNodeTitleForState === 'function') { + const title = String(getNodeTitleForState(normalizedNodeId, state) || '').trim(); + if (title) { + return title; + } + } + return normalizedNodeId; + } + + function buildFailureLabel(finalStatus, failedNodeId = '', failedStep = null, failureDetail = '', state = {}) { if (finalStatus === 'success') { return '流程完成'; } @@ -164,6 +214,9 @@ return '正在运行'; } if (finalStatus === 'stopped') { + if (failedNodeId) { + return `节点 ${getNodeDisplayName(failedNodeId, state)} 停止`; + } if (Number.isInteger(failedStep) && failedStep > 0) { return `步骤 ${failedStep} 停止`; } @@ -175,6 +228,9 @@ if (isPhoneVerificationFailure(failureDetail)) { return '出现手机号验证'; } + if (failedNodeId) { + return `节点 ${getNodeDisplayName(failedNodeId, state)} 失败`; + } if (Number.isInteger(failedStep) && failedStep > 0) { return `步骤 ${failedStep} 失败`; } @@ -341,6 +397,9 @@ const failedStep = finalStatus === 'failed' || finalStatus === 'stopped' ? resolveNormalizedFailedStep(record, failureDetail) : null; + const failedNodeId = finalStatus === 'failed' || finalStatus === 'stopped' + ? resolveNormalizedFailedNodeId(record, record.finalStatus || record.status || '', failedStep) + : ''; const autoRunContext = normalizeAutoRunContext(record.autoRunContext); const retryCount = normalizeRetryCount( record.retryCount !== undefined @@ -348,11 +407,15 @@ : ((autoRunContext?.attemptRun || 0) > 1 ? autoRunContext.attemptRun - 1 : 0) ); const source = normalizeSource(record.source || (autoRunContext ? 'auto' : 'manual')); - const computedFailureLabel = buildFailureLabel(finalStatus, failedStep, failureDetail); + const computedFailureLabel = buildFailureLabel(finalStatus, failedNodeId, failedStep, failureDetail, record); const rawFailureLabel = String(record.failureLabel || '').trim(); + const flowId = String(record.flowId || record.activeFlowId || '').trim(); + const runId = String(record.runId || record.activeRunId || '').trim(); return { recordId: String(record.recordId || '').trim() || buildRecordId(accountIdentifier, accountIdentifierType), + flowId, + runId, accountIdentifierType, accountIdentifier, email, @@ -361,8 +424,9 @@ finalStatus, finishedAt, retryCount, - failureLabel: resolveFailureLabel(finalStatus, rawFailureLabel, computedFailureLabel, failedStep), + failureLabel: resolveFailureLabel(finalStatus, rawFailureLabel, computedFailureLabel, failedNodeId, failedStep), failureDetail, + failedNodeId, failedStep: Number.isInteger(failedStep) && failedStep > 0 ? failedStep : null, source, autoRunContext: source === 'auto' ? autoRunContext : null, @@ -422,13 +486,31 @@ const failedStep = finalStatus === 'failed' || finalStatus === 'stopped' ? extractRecordStep(status, failureDetail) : null; + const statusNodeId = finalStatus === 'failed' || finalStatus === 'stopped' + ? extractRecordNodeFromStatus(status) + : ''; + const stateNodeId = finalStatus === 'failed' || finalStatus === 'stopped' + ? normalizeNodeId(state.currentNodeId) + : ''; + const failedNodeIdFromStep = !statusNodeId + && !stateNodeId + && Number.isInteger(failedStep) + && failedStep > 0 + && typeof getNodeIdByStepForState === 'function' + ? normalizeNodeId(getNodeIdByStepForState(failedStep, state)) + : ''; + const failedNodeId = statusNodeId || stateNodeId || failedNodeIdFromStep; const source = Boolean(state.autoRunning) ? 'auto' : 'manual'; const autoRunContext = source === 'auto' ? buildAutoRunContextFromState(state) : null; const retryCount = source === 'auto' ? getRetryCountFromState(state) : 0; const finishedAt = new Date().toISOString(); + const flowId = String(state.flowId || state.activeFlowId || '').trim(); + const runId = String(state.runId || state.activeRunId || '').trim(); return { recordId: buildRecordId(accountIdentifier, accountIdentifierType), + flowId, + runId, accountIdentifierType, accountIdentifier, email, @@ -437,9 +519,10 @@ finalStatus, finishedAt, retryCount, - failureLabel: buildFailureLabel(finalStatus, failedStep, failureDetail), + failureLabel: buildFailureLabel(finalStatus, failedNodeId, failedStep, failureDetail, state), failureDetail, - failedStep: Number.isInteger(failedStep) && failedStep > 0 ? failedStep : null, + failedNodeId, + failedStep: statusNodeId ? null : (Number.isInteger(failedStep) && failedStep > 0 ? failedStep : null), source, autoRunContext, plusModeEnabled: Boolean(state.plusModeEnabled), diff --git a/background/auto-run-controller.js b/background/auto-run-controller.js index 89a8b1f..0e0c50a 100644 --- a/background/auto-run-controller.js +++ b/background/auto-run-controller.js @@ -17,11 +17,11 @@ ensureHotmailMailboxReadyForAutoRunRound, getAutoRunStatusPayload, getErrorMessage, - getFirstUnfinishedStep, + getFirstUnfinishedNodeId, getPendingAutoRunTimerPlan, - getRunningSteps, + getRunningNodeIds, getState, - hasSavedProgress, + hasSavedNodeProgress, isAddPhoneAuthFailure, isGpcTaskEndedFailure, isPhoneSmsPlatformRateLimitFailure, @@ -34,14 +34,49 @@ normalizeAutoRunFallbackThreadIntervalMinutes, persistAutoRunTimerPlan, resetState, - runAutoSequenceFromStep, + runAutoSequenceFromNode, runtime, setState, sleepWithStop, throwIfAutoRunSessionStopped, - waitForRunningStepsToFinish, + waitForRunningNodesToFinish, } = deps; + function getRunningWorkflowNodes(state = {}) { + if (typeof getRunningNodeIds === 'function') { + return getRunningNodeIds(state.nodeStatuses || {}, state); + } + return []; + } + + function getFirstUnfinishedWorkflowNode(state = {}) { + if (typeof getFirstUnfinishedNodeId === 'function') { + return getFirstUnfinishedNodeId(state.nodeStatuses || {}, state); + } + return null; + } + + function hasSavedWorkflowProgress(state = {}) { + if (typeof hasSavedNodeProgress === 'function') { + return hasSavedNodeProgress(state.nodeStatuses || {}, state); + } + return false; + } + + async function waitForRunningWorkflowNodesToFinish(payload = {}) { + if (typeof waitForRunningNodesToFinish === 'function') { + return waitForRunningNodesToFinish(payload); + } + return getState(); + } + + async function runAutoSequenceFromWorkflowNode(startNodeId, context = {}) { + if (typeof runAutoSequenceFromNode === 'function') { + return runAutoSequenceFromNode(startNodeId, context); + } + throw new Error('自动运行节点执行器未接入。'); + } + function createAutoRunRoundSummary(round) { return { round, @@ -85,90 +120,83 @@ return Math.max(0, Number(summary?.attempts || 0) - 1); } - function normalizeRecordStep(value) { - const step = Math.floor(Number(value) || 0); - return step > 0 ? step : null; + function normalizeRecordNode(value = '') { + return String(value || '').trim(); } - function extractStepFromRecordStatus(status = '') { - const match = String(status || '').trim().toLowerCase().match(/^step(\d+)_(?:failed|stopped)$/); - if (!match) { - return null; - } - return normalizeRecordStep(match[1]); + function extractNodeFromRecordStatus(status = '') { + const match = String(status || '').trim().match(/^node:([^:]+):(failed|stopped)$/i); + return match ? normalizeRecordNode(match[1]) : ''; } - function getKnownStepIdsFromState(state = {}) { + function getKnownNodeIdsFromState(state = {}) { const ids = new Set(); - for (const key of Object.keys(state?.stepStatuses || {})) { - const step = normalizeRecordStep(key); - if (step) { - ids.add(step); + for (const key of Object.keys(state?.nodeStatuses || {})) { + const nodeId = normalizeRecordNode(key); + if (nodeId) { + ids.add(nodeId); } } - const currentStep = normalizeRecordStep(state?.currentStep); - if (currentStep) { - ids.add(currentStep); + const currentNodeId = normalizeRecordNode(state?.currentNodeId); + if (currentNodeId) { + ids.add(currentNodeId); } - return Array.from(ids).sort((left, right) => left - right); + return Array.from(ids); } - function inferRecordStepFromState(state = {}, preferredStatuses = []) { - const statuses = state?.stepStatuses || {}; + function inferRecordNodeFromState(state = {}, preferredStatuses = []) { + const statuses = state?.nodeStatuses || {}; const preferredStatusSet = new Set(preferredStatuses.map((item) => String(item || '').trim()).filter(Boolean)); - const stepIds = getKnownStepIdsFromState(state); - const currentStep = normalizeRecordStep(state?.currentStep); + const nodeIds = getKnownNodeIdsFromState(state); + const currentNodeId = normalizeRecordNode(state?.currentNodeId); - if (currentStep && preferredStatusSet.has(String(statuses[currentStep] || '').trim())) { - return currentStep; + if (currentNodeId && preferredStatusSet.has(String(statuses[currentNodeId] || '').trim())) { + return currentNodeId; } - const matchingSteps = stepIds - .filter((step) => preferredStatusSet.has(String(statuses[step] || '').trim())) - .sort((left, right) => right - left); - if (matchingSteps.length) { - return matchingSteps[0]; + const matchingNodes = nodeIds.filter((nodeId) => preferredStatusSet.has(String(statuses[nodeId] || '').trim())); + if (matchingNodes.length) { + return matchingNodes[matchingNodes.length - 1]; } - if (currentStep) { - const currentStatus = String(statuses[currentStep] || '').trim(); + if (currentNodeId) { + const currentStatus = String(statuses[currentNodeId] || '').trim(); if (!['', 'pending', 'completed', 'manual_completed', 'skipped'].includes(currentStatus)) { - return currentStep; + return currentNodeId; } } - return null; + return ''; } - function inferRecordStepFromError(errorLike = null) { + function inferRecordNodeFromError(errorLike = null, state = {}) { if (!errorLike || typeof errorLike !== 'object') { - return null; + return ''; } - return normalizeRecordStep(errorLike.failedStep) - || normalizeRecordStep(errorLike.step) - || normalizeRecordStep(errorLike.currentStep); + return normalizeRecordNode(errorLike.failedNodeId) + || normalizeRecordNode(errorLike.nodeId) + || normalizeRecordNode(errorLike.currentNodeId); } function resolveAutoRunAccountRecordStatus(status, state = {}, errorLike = null) { const normalizedStatus = String(status || '').trim().toLowerCase(); - const explicitStep = extractStepFromRecordStatus(normalizedStatus); - if (explicitStep) { - return normalizedStatus; + const explicitNode = extractNodeFromRecordStatus(status); + if (explicitNode) { + return `node:${explicitNode}:${normalizedStatus.endsWith(':stopped') ? 'stopped' : 'failed'}`; } - if (normalizedStatus === 'failed') { - const failedStep = inferRecordStepFromError(errorLike) - || inferRecordStepFromState(state, ['failed', 'running']); - return failedStep ? `step${failedStep}_failed` : status; + const failedNode = inferRecordNodeFromError(errorLike, state) + || inferRecordNodeFromState(state, ['failed', 'running']); + return failedNode ? `node:${failedNode}:failed` : status; } if (normalizedStatus === 'stopped') { - const stoppedStep = inferRecordStepFromError(errorLike) - || inferRecordStepFromState(state, ['stopped', 'running']); - return stoppedStep ? `step${stoppedStep}_stopped` : status; + const stoppedNode = inferRecordNodeFromError(errorLike, state) + || inferRecordNodeFromState(state, ['stopped', 'running']); + return stoppedNode ? `node:${stoppedNode}:stopped` : status; } return status; @@ -439,7 +467,7 @@ let successfulRuns = roundSummaries.filter((item) => item.status === 'success').length; const initialState = await getState(); - const initialPhase = continueCurrentOnFirstAttempt && getRunningSteps(initialState.stepStatuses, initialState).length + const initialPhase = continueCurrentOnFirstAttempt && getRunningWorkflowNodes(initialState).length ? 'waiting_step' : 'running'; const showResumePosition = continueCurrentOnFirstAttempt || resumeCurrentRun > 1 || resumeAttemptRun > 1; @@ -474,24 +502,25 @@ autoRunAttemptRun: attemptRun, }); roundSummary.attempts = attemptRun; - let startStep = 1; + const defaultStartNodeId = typeof runAutoSequenceFromNode === 'function' ? 'open-chatgpt' : 1; + let startNodeId = defaultStartNodeId; let useExistingProgress = false; if (reuseExistingProgress) { let currentState = await getState(); - if (getRunningSteps(currentState.stepStatuses, currentState).length) { - currentState = await waitForRunningStepsToFinish({ + if (getRunningWorkflowNodes(currentState).length) { + currentState = await waitForRunningWorkflowNodesToFinish({ currentRun: targetRun, totalRuns, attemptRun, }); } - const resumeStep = getFirstUnfinishedStep(currentState.stepStatuses, currentState); - if (resumeStep && hasSavedProgress(currentState.stepStatuses, currentState)) { - startStep = resumeStep; + const resumeNodeId = getFirstUnfinishedWorkflowNode(currentState); + if (resumeNodeId && hasSavedWorkflowProgress(currentState)) { + startNodeId = resumeNodeId; useExistingProgress = true; - } else if (hasSavedProgress(currentState.stepStatuses, currentState)) { - await addLog('检测到当前流程已处理完成,本轮将改为从步骤 1 重新开始。', 'info'); + } else if (hasSavedWorkflowProgress(currentState)) { + await addLog('检测到当前流程已处理完成,本轮将改为从首个节点重新开始。', 'info'); } } @@ -571,7 +600,7 @@ sessionId, }); - if (!useExistingProgress && startStep === 1 && typeof ensureHotmailMailboxReadyForAutoRunRound === 'function') { + if (!useExistingProgress && startNodeId === defaultStartNodeId && typeof ensureHotmailMailboxReadyForAutoRunRound === 'function') { await ensureHotmailMailboxReadyForAutoRunRound({ targetRun, totalRuns, @@ -580,7 +609,7 @@ }); } - await runAutoSequenceFromStep(startStep, { + await runAutoSequenceFromWorkflowNode(startNodeId, { targetRun, totalRuns, attemptRuns: attemptRun, diff --git a/background/logging-status.js b/background/logging-status.js index f92cd39..c444873 100644 --- a/background/logging-status.js +++ b/background/logging-status.js @@ -5,6 +5,7 @@ const { chrome, DEFAULT_STATE, + getStepIdByNodeIdForState, getState, isRecoverableStep9AuthFailure, LOG_PREFIX, @@ -51,12 +52,14 @@ const normalizedOptions = options && typeof options === 'object' ? options : {}; const step = normalizeLogStep(normalizedOptions.step); const stepKey = String(normalizedOptions.stepKey || '').trim(); + const nodeId = String(normalizedOptions.nodeId || normalizedOptions.nodeKey || stepKey || '').trim(); return { message: String(message || ''), level, timestamp: Date.now(), step, stepKey, + nodeId, }; } @@ -70,14 +73,21 @@ chrome.runtime.sendMessage({ type: 'LOG_ENTRY', payload: entry }).catch(() => { }); } - async function setStepStatus(step, status) { + async function setNodeStatus(nodeId, status) { + const normalizedNodeId = String(nodeId || '').trim(); + if (!normalizedNodeId) { + throw new Error('setNodeStatus 缺少 nodeId。'); + } const state = await getState(); - const statuses = { ...state.stepStatuses }; - statuses[step] = status; - await setState({ stepStatuses: statuses, currentStep: step }); + const nodeStatuses = { ...(state.nodeStatuses || {}) }; + nodeStatuses[normalizedNodeId] = status; + await setState({ + nodeStatuses, + currentNodeId: normalizedNodeId, + }); chrome.runtime.sendMessage({ - type: 'STEP_STATUS_CHANGED', - payload: { step, status }, + type: 'NODE_STATUS_CHANGED', + payload: { nodeId: normalizedNodeId, status }, }).catch(() => { }); } @@ -149,29 +159,50 @@ } function getFirstUnfinishedStep(statuses = {}) { - const stepIds = Object.keys(DEFAULT_STATE.stepStatuses || {}) - .map((step) => Number(step)) - .filter(Number.isFinite) - .sort((left, right) => left - right); - for (const step of stepIds) { - if (!isStepDoneStatus(statuses[step] || 'pending')) { - return step; + const nodeStatuses = statuses && typeof statuses === 'object' ? statuses : {}; + const nodeIds = Object.keys(DEFAULT_STATE.nodeStatuses || {}); + for (const nodeId of nodeIds) { + if (!isStepDoneStatus(nodeStatuses[nodeId] || 'pending')) { + return typeof getStepIdByNodeIdForState === 'function' + ? getStepIdByNodeIdForState(nodeId, {}) + : null; } } return null; } function hasSavedProgress(statuses = {}) { - return Object.values({ ...DEFAULT_STATE.stepStatuses, ...statuses }).some((status) => status !== 'pending'); + return Object.values({ ...DEFAULT_STATE.nodeStatuses, ...statuses }).some((status) => status !== 'pending'); } function getRunningSteps(statuses = {}) { - return Object.entries({ ...DEFAULT_STATE.stepStatuses, ...statuses }) + return Object.entries({ ...DEFAULT_STATE.nodeStatuses, ...statuses }) .filter(([, status]) => status === 'running') - .map(([step]) => Number(step)) + .map(([nodeId]) => (typeof getStepIdByNodeIdForState === 'function' ? getStepIdByNodeIdForState(nodeId, {}) : null)) + .filter((step) => Number.isInteger(step) && step > 0) .sort((a, b) => a - b); } + function getFirstUnfinishedNode(statuses = {}) { + const nodeIds = Object.keys(DEFAULT_STATE.nodeStatuses || {}); + for (const nodeId of nodeIds) { + if (!isStepDoneStatus(statuses[nodeId] || 'pending')) { + return nodeId; + } + } + return ''; + } + + function hasSavedNodeProgress(statuses = {}) { + return Object.values({ ...DEFAULT_STATE.nodeStatuses, ...statuses }).some((status) => status !== 'pending'); + } + + function getRunningNodes(statuses = {}) { + return Object.entries({ ...DEFAULT_STATE.nodeStatuses, ...statuses }) + .filter(([, status]) => status === 'running') + .map(([nodeId]) => nodeId); + } + function getAutoRunStatusPayload(phase, payload = {}) { return { autoRunning: phase === 'scheduled' @@ -195,12 +226,15 @@ return { addLog, getAutoRunStatusPayload, + getFirstUnfinishedNode, isAddPhoneAuthFailure, getErrorMessage, getFirstUnfinishedStep, getLoginAuthStateLabel, + getRunningNodes, getRunningSteps, getSourceLabel, + hasSavedNodeProgress, hasSavedProgress, isLegacyStep9RecoverableAuthError, isRestartCurrentAttemptError, @@ -208,7 +242,7 @@ isStep9RecoverableAuthError, isStepDoneStatus, isVerificationMailPollingError, - setStepStatus, + setNodeStatus, }; } diff --git a/background/mail-rule-registry.js b/background/mail-rule-registry.js index be9848f..22b4fcc 100644 --- a/background/mail-rule-registry.js +++ b/background/mail-rule-registry.js @@ -26,6 +26,21 @@ return flowBuilder.getRuleDefinition(step, state); } + function getVerificationMailRuleForNode(nodeId, state = {}) { + const flowId = resolveFlowId(state); + const flowBuilder = getFlowBuilder(flowId); + if (!flowBuilder) { + throw new Error(`未找到 flow=${flowId} 的邮件规则定义。`); + } + if (typeof flowBuilder.getRuleDefinitionForNode === 'function') { + return flowBuilder.getRuleDefinitionForNode(nodeId, state); + } + if (typeof flowBuilder.getRuleDefinition === 'function') { + return flowBuilder.getRuleDefinition({ nodeId }, state); + } + throw new Error(`未找到 flow=${flowId} 的邮件规则定义。`); + } + function buildVerificationPollPayload(step, state = {}, overrides = {}) { const flowId = resolveFlowId(state); const flowBuilder = getFlowBuilder(flowId); @@ -35,9 +50,26 @@ return flowBuilder.buildVerificationPollPayload(step, state, overrides); } + function buildVerificationPollPayloadForNode(nodeId, state = {}, overrides = {}) { + const flowId = resolveFlowId(state); + const flowBuilder = getFlowBuilder(flowId); + if (!flowBuilder) { + throw new Error(`未找到 flow=${flowId} 的邮件轮询规则构造器。`); + } + if (typeof flowBuilder.buildVerificationPollPayloadForNode === 'function') { + return flowBuilder.buildVerificationPollPayloadForNode(nodeId, state, overrides); + } + if (typeof flowBuilder.buildVerificationPollPayload === 'function') { + return flowBuilder.buildVerificationPollPayload({ nodeId }, state, overrides); + } + throw new Error(`未找到 flow=${flowId} 的邮件轮询规则构造器。`); + } + return { buildVerificationPollPayload, + buildVerificationPollPayloadForNode, getVerificationMailRule, + getVerificationMailRuleForNode, resolveFlowId, }; } diff --git a/background/message-router.js b/background/message-router.js index 4b160b4..8ac54ac 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -21,17 +21,17 @@ clearStopRequest, closeLocalhostCallbackTabs, closeTabsByUrlPrefix, - completeStepFromBackground, + completeNodeFromBackground, deleteHotmailAccount, deleteHotmailAccounts, deleteIcloudAlias, deleteUsedIcloudAliases, disableUsedLuckmailPurchases, - doesStepUseCompletionSignal, + doesNodeUseCompletionSignal, ensureMail2925MailboxSession, ensureManualInteractionAllowed, - executeStep, - executeStepViaCompletionSignal, + executeNode, + executeNodeViaCompletionSignal, exportSettingsBundle, fetchGeneratedEmail, refreshGpcCardBalance, @@ -47,6 +47,9 @@ getPendingAutoRunTimerPlan, getSourceLabel, getState, + getNodeDefinitionForState, + getNodeIdsForState, + getStepIdByNodeIdForState, getStepDefinitionForState, getStepIdsForState, getLastStepIdForState, @@ -137,8 +140,8 @@ normalizePayPalAccounts, normalizeRunCount, AUTO_RUN_TIMER_KIND_SCHEDULED_START, - notifyStepComplete, - notifyStepError, + notifyNodeComplete, + notifyNodeError, patchMail2925Account, patchHotmailAccount, pollContributionStatus, @@ -169,9 +172,9 @@ setLuckmailPurchaseUsedState, setPersistentSettings, setState, - setStepStatus, + setNodeStatus, skipAutoRunCountdown, - skipStep, + skipNode, startContributionFlow, startAutoRunLoop, deleteMail2925Account, @@ -215,27 +218,92 @@ } } + const DEFAULT_OPENAI_NODE_BY_STEP = Object.freeze({ + 1: 'open-chatgpt', + 2: 'submit-signup-email', + 3: 'fill-password', + 4: 'fetch-signup-code', + 5: 'fill-profile', + 6: 'wait-registration-success', + 7: 'oauth-login', + 8: 'fetch-login-code', + 9: 'confirm-oauth', + 10: 'platform-verify', + 11: 'fetch-login-code', + 12: 'confirm-oauth', + 13: 'platform-verify', + }); + function getStepKeyForState(step, state = {}) { if (typeof getStepDefinitionForState === 'function') { return String(getStepDefinitionForState(step, state)?.key || '').trim(); } - return ''; + return DEFAULT_OPENAI_NODE_BY_STEP[Number(step)] || ''; } - function isStaleAutoRunStepMessage(step, state = {}) { + function findStepByNodeId(nodeId, state = {}) { + const normalizedNodeId = String(nodeId || '').trim(); + if (normalizedNodeId && typeof getStepIdByNodeIdForState === 'function') { + const step = getStepIdByNodeIdForState(normalizedNodeId, state); + if (Number.isInteger(step) && step > 0) { + return step; + } + } + if (!normalizedNodeId || typeof getStepIdsForState !== 'function') { + return 0; + } + for (const stepId of getStepIdsForState(state)) { + if (getStepKeyForState(stepId, state) === normalizedNodeId) { + return Number(stepId) || 0; + } + } + return 0; + } + + async function normalizeNodeProtocolMessage(message = {}) { + const type = String(message?.type || '').trim(); + const nodeProtocolTypes = new Set([ + 'EXECUTE_NODE', + 'NODE_COMPLETE', + 'NODE_ERROR', + 'SKIP_NODE', + ]); + if (!nodeProtocolTypes.has(type)) { + return message; + } + + const nodeId = String(message?.payload?.nodeId || message?.nodeId || '').trim(); + if (!nodeId) { + throw new Error(`${type} 缺少 nodeId。`); + } + const state = await getState(); + const step = findStepByNodeId(nodeId, state); + if (!step) { + throw new Error(`当前 flow 中未找到节点:${nodeId}`); + } + + const payload = { + ...(message.payload || {}), + nodeId, + step, + }; + return { ...message, nodeId, step, payload }; + } + + function isStaleAutoRunNodeMessage(nodeId, state = {}) { + const normalizedNodeId = String(nodeId || '').trim(); + if (!normalizedNodeId) { + return false; + } if (typeof isAutoRunLockedState !== 'function' || !isAutoRunLockedState(state)) { return false; } - const normalizedStep = Number(step); - if (!Number.isInteger(normalizedStep) || normalizedStep <= 0) { - return false; - } - const currentStatus = String(state?.stepStatuses?.[normalizedStep] || '').trim(); + const currentStatus = String(state?.nodeStatuses?.[normalizedNodeId] || '').trim(); if (currentStatus === 'running') { return false; } - const currentStep = Number(state?.currentStep) || 0; - if (currentStep > 0 && normalizedStep !== currentStep) { + const currentNodeId = String(state?.currentNodeId || '').trim(); + if (currentNodeId && normalizedNodeId !== currentNodeId) { return true; } return ['completed', 'manual_completed', 'skipped', 'failed', 'stopped'].includes(currentStatus); @@ -383,24 +451,38 @@ || status === 'skipped'; } - function findStepByKeyAfter(currentStep, targetKey, state = {}) { + function findStepByKeyAfter(currentOrder, targetKey, state = {}) { const activeStepIds = typeof getStepIdsForState === 'function' ? getStepIdsForState(state) : []; - const candidates = activeStepIds.length ? activeStepIds : [Number(currentStep) + 1, 8]; + const candidates = activeStepIds.length ? activeStepIds : [Number(currentOrder) + 1, 8]; return candidates.find((stepId) => { const numericStep = Number(stepId); - if (!Number.isFinite(numericStep) || numericStep <= Number(currentStep)) { + if (!Number.isFinite(numericStep) || numericStep <= Number(currentOrder)) { return false; } const stepKey = getStepKeyForState(numericStep, state); if (stepKey) { return stepKey === targetKey; } - return targetKey === 'fetch-login-code' && Number(currentStep) === 7 && numericStep === 8; + return targetKey === 'fetch-login-code' && Number(currentOrder) === 7 && numericStep === 8; }) || null; } + function getNodeStatusByStep(step, state = {}) { + const nodeId = getStepKeyForState(step, state); + return nodeId ? (state.nodeStatuses?.[nodeId] || 'pending') : 'pending'; + } + + async function setNodeStatusByStep(step, status, state = {}) { + const nodeId = getStepKeyForState(step, state); + if (!nodeId) { + throw new Error(`未找到步骤 ${step} 对应节点。`); + } + await setNodeStatus(nodeId, status); + return nodeId; + } + function normalizePlusPaymentMethodForDisplay(value = '') { const normalized = String(value || '').trim().toLowerCase(); if (normalized === 'gpc-helper') { @@ -500,9 +582,9 @@ const latestState = await getState(); const loginCodeStep = findStepByKeyAfter(step, 'fetch-login-code', latestState); if (loginCodeStep) { - const currentStatus = latestState.stepStatuses?.[loginCodeStep]; + const currentStatus = getNodeStatusByStep(loginCodeStep, latestState); if (!isStepProtectedFromAutoSkip(currentStatus)) { - await setStepStatus(loginCodeStep, 'skipped'); + await setNodeStatusByStep(loginCodeStep, 'skipped', latestState); await addLog(`认证页已直接进入 OAuth 授权页,已自动跳过步骤 ${loginCodeStep} 的登录验证码。`, 'warn', { step, stepKey: 'oauth-login', @@ -571,21 +653,21 @@ await syncStepAccountIdentityFromPayload(payload); if (payload.skipRegistrationFlow) { const latestState = await getState(); - for (const skipStep of [3, 4, 5]) { - const status = latestState.stepStatuses?.[skipStep]; + for (const skippedStep of [3, 4, 5]) { + const status = getNodeStatusByStep(skippedStep, latestState); if (status === 'running' || status === 'completed' || status === 'manual_completed') { continue; } - await setStepStatus(skipStep, 'skipped'); + await setNodeStatusByStep(skippedStep, 'skipped', latestState); } await addLog('步骤 2:检测到当前已登录会话,已自动跳过步骤 3/4/5,流程将直接进入步骤 6。', 'warn'); break; } if (payload.skippedPasswordStep) { const latestState = await getState(); - const step3Status = latestState.stepStatuses?.[3]; + const step3Status = getNodeStatusByStep(3, latestState); if (step3Status !== 'running' && step3Status !== 'completed' && step3Status !== 'manual_completed') { - await setStepStatus(3, 'skipped'); + await setNodeStatusByStep(3, 'skipped', latestState); const identityLabel = payload.accountIdentifierType === 'phone' ? '手机号' : '邮箱'; await addLog(`步骤 2:提交${identityLabel}后页面直接进入验证码页,已自动跳过步骤 3。`, 'warn'); } @@ -598,9 +680,9 @@ } if (payload.skipProfileStep) { const latestState = await getState(); - const step5Status = latestState.stepStatuses?.[5]; + const step5Status = getNodeStatusByStep(5, latestState); if (step5Status !== 'running' && step5Status !== 'completed' && step5Status !== 'manual_completed') { - await setStepStatus(5, 'skipped'); + await setNodeStatusByStep(5, 'skipped', latestState); await addLog('步骤 3:页面已直接进入已登录态,已自动跳过步骤 5。', 'warn'); } } @@ -621,9 +703,9 @@ }); if (payload.skipProfileStep) { const latestState = await getState(); - const step5Status = latestState.stepStatuses?.[5]; + const step5Status = getNodeStatusByStep(5, latestState); if (step5Status !== 'running' && step5Status !== 'completed' && step5Status !== 'manual_completed') { - await setStepStatus(5, 'skipped'); + await setNodeStatusByStep(5, 'skipped', latestState); if (payload.skipProfileStepReason === 'combined_verification_profile') { await addLog('步骤 4:当前验证码页已内嵌完成注册资料提交,已自动跳过步骤 5。', 'warn'); } else { @@ -664,7 +746,8 @@ } } - async function handleMessage(message, sender) { + async function handleMessage(rawMessage, sender) { + const message = await normalizeNodeProtocolMessage(rawMessage); switch (message.type) { case 'CONTENT_SCRIPT_READY': { const tabId = sender.tab?.id; @@ -690,20 +773,30 @@ return { ok: true }; } - case 'STEP_COMPLETE': { + case 'NODE_COMPLETE': { + const currentStateForNode = await getState(); + const nodeId = String(message.nodeId || message.payload?.nodeId || '').trim(); + const resolvedStep = findStepByNodeId(nodeId, currentStateForNode); + if (!nodeId || !resolvedStep) { + throw new Error('NODE_COMPLETE 缺少 nodeId。'); + } const currentState = await getState(); - if (isStaleAutoRunStepMessage(message.step, currentState)) { - await addLog(`自动运行:忽略过期的步骤 ${message.step} 完成消息,当前流程已在步骤 ${currentState.currentStep || '未知'}。`, 'warn', { step: message.step }); + if (isStaleAutoRunNodeMessage(nodeId, currentState)) { + await addLog( + `自动运行:忽略过期的节点 ${nodeId} 完成消息,当前流程已在节点 ${currentState.currentNodeId || '未知'}。`, + 'warn', + { nodeId } + ); return { ok: true, ignored: true }; } if (getStopRequested()) { - await setStepStatus(message.step, 'stopped'); - await appendManualAccountRunRecordIfNeeded(`step${message.step}_stopped`, null, '流程已被用户停止。'); - notifyStepError(message.step, '流程已被用户停止。'); + await setNodeStatus(nodeId, 'stopped'); + await appendManualAccountRunRecordIfNeeded(`node:${nodeId}:stopped`, null, '流程已被用户停止。'); + notifyNodeError(nodeId, '流程已被用户停止。'); return { ok: true }; } try { - if (message.step === 3 && typeof finalizeStep3Completion === 'function') { + if (nodeId === 'fill-password' && typeof finalizeStep3Completion === 'function') { await finalizeStep3Completion(message.payload || {}); } } catch (error) { @@ -711,60 +804,74 @@ const userMessage = typeof handleCloudflareSecurityBlocked === 'function' ? await handleCloudflareSecurityBlocked(error) : (error?.message || String(error || '')); - notifyStepError(message.step, '流程已被用户停止。'); + notifyNodeError(nodeId, '流程已被用户停止。'); return { ok: true, error: userMessage }; } const errorMessage = error?.message || String(error || '步骤 3 提交后确认失败'); - await setStepStatus(message.step, 'failed'); - await addLog(`失败:${errorMessage}`, 'error', { step: message.step }); - await appendManualAccountRunRecordIfNeeded(`step${message.step}_failed`, null, errorMessage); - notifyStepError(message.step, errorMessage); + await setNodeStatus(nodeId, 'failed'); + await addLog(`失败:${errorMessage}`, 'error', { + nodeId, + }); + await appendManualAccountRunRecordIfNeeded(`node:${nodeId}:failed`, null, errorMessage); + notifyNodeError(nodeId, errorMessage); return { ok: true, error: errorMessage }; } const completionStateCandidate = await getState(); - const lastStepId = typeof getLastStepIdForState === 'function' - ? getLastStepIdForState(completionStateCandidate) - : 10; - const completionState = message.step === lastStepId ? completionStateCandidate : null; - await setStepStatus(message.step, 'completed'); - await addLog('已完成', 'ok', { step: message.step }); - await handleStepData(message.step, message.payload); - if (message.step === lastStepId && typeof appendAccountRunRecord === 'function') { + const nodeIds = typeof getNodeIdsForState === 'function' ? getNodeIdsForState(completionStateCandidate) : []; + const lastNodeId = nodeIds[nodeIds.length - 1] || ''; + const isFinalNode = nodeId === lastNodeId; + const completionState = isFinalNode ? completionStateCandidate : null; + await setNodeStatus(nodeId, 'completed'); + await addLog('已完成', 'ok', { nodeId }); + await handleStepData(resolvedStep, message.payload); + if (isFinalNode && typeof appendAccountRunRecord === 'function') { await appendAccountRunRecord('success', completionState); } - notifyStepComplete(message.step, message.payload); + notifyNodeComplete(nodeId, message.payload); return { ok: true }; } - case 'STEP_ERROR': { + case 'NODE_ERROR': { + const stateForNode = await getState(); + const nodeId = String(message.nodeId || message.payload?.nodeId || '').trim(); + const resolvedStep = findStepByNodeId(nodeId, stateForNode); + if (!nodeId || !resolvedStep) { + throw new Error('NODE_ERROR 缺少 nodeId。'); + } const staleCheckState = await getState(); - if (isStaleAutoRunStepMessage(message.step, staleCheckState)) { - await addLog(`自动运行:忽略过期的步骤 ${message.step} 失败消息,当前流程已在步骤 ${staleCheckState.currentStep || '未知'}。原始错误:${message.error || '未知错误'}`, 'warn', { step: message.step }); + if (isStaleAutoRunNodeMessage(nodeId, staleCheckState)) { + await addLog( + `自动运行:忽略过期的节点 ${nodeId} 失败消息,当前流程已在节点 ${staleCheckState.currentNodeId || '未知'}。原始错误:${message.error || '未知错误'}`, + 'warn', + { nodeId } + ); return { ok: true, ignored: true }; } if (typeof isCloudflareSecurityBlockedError === 'function' && isCloudflareSecurityBlockedError(message.error)) { const userMessage = typeof handleCloudflareSecurityBlocked === 'function' ? await handleCloudflareSecurityBlocked(message.error) : (typeof message.error === 'string' ? message.error : String(message.error || '')); - notifyStepError(message.step, '流程已被用户停止。'); + notifyNodeError(nodeId, '流程已被用户停止。'); return { ok: true, error: userMessage }; } const currentState = await getState(); - const currentStepStatus = currentState?.stepStatuses?.[message.step] || ''; + const currentNodeStatus = currentState?.nodeStatuses?.[nodeId] || ''; const isSignupPhonePasswordMismatch = /SIGNUP_PHONE_PASSWORD_MISMATCH::/i.test(String(message.error || '')); if (isStopError(message.error)) { - await setStepStatus(message.step, 'stopped'); - await addLog('已被用户停止', 'warn', { step: message.step }); - await appendManualAccountRunRecordIfNeeded(`step${message.step}_stopped`, null, message.error); - notifyStepError(message.step, message.error); + await setNodeStatus(nodeId, 'stopped'); + await addLog('已被用户停止', 'warn', { nodeId }); + await appendManualAccountRunRecordIfNeeded(`node:${nodeId}:stopped`, null, message.error); + notifyNodeError(nodeId, message.error); } else { - if (!(isSignupPhonePasswordMismatch && currentStepStatus === 'failed')) { - await setStepStatus(message.step, 'failed'); - await addLog(`失败:${message.error}`, 'error', { step: message.step }); - await appendManualAccountRunRecordIfNeeded(`step${message.step}_failed`, null, message.error); + if (!(isSignupPhonePasswordMismatch && currentNodeStatus === 'failed')) { + await setNodeStatus(nodeId, 'failed'); + await addLog(`失败:${message.error}`, 'error', { + nodeId, + }); + await appendManualAccountRunRecordIfNeeded(`node:${nodeId}:failed`, null, message.error); } - notifyStepError(message.step, message.error); + notifyNodeError(nodeId, message.error); } return { ok: true }; } @@ -772,6 +879,7 @@ case 'RESOLVE_PLUS_MANUAL_CONFIRMATION': { const currentState = await getState(); const step = Number(message.payload?.step) || Number(currentState?.plusManualConfirmationStep) || 0; + const confirmationNodeId = getStepKeyForState(step, currentState) || String(currentState?.currentNodeId || '').trim(); const confirmed = Boolean(message.payload?.confirmed); const requestId = String(message.payload?.requestId || '').trim(); const currentRequestId = String(currentState?.plusManualConfirmationRequestId || '').trim(); @@ -818,7 +926,7 @@ if (confirmed) { const methodLabel = method === 'gopay' ? 'GoPay' : '手动'; await addLog(`步骤 ${step}:已确认${methodLabel}订阅完成,准备继续下一步。`, 'ok'); - await completeStepFromBackground(step, { + await completeNodeFromBackground(confirmationNodeId, { plusManualConfirmationMethod: currentState?.plusManualConfirmationMethod || '', plusManualConfirmedAt: Date.now(), }); @@ -828,10 +936,14 @@ const cancelMessage = method === 'gopay' ? '已取消 GoPay 订阅确认' : (isGpcOtp ? '已取消 GPC OTP 输入' : '已取消当前手动确认'); - await setStepStatus(step, 'failed'); + await setNodeStatus(confirmationNodeId, 'failed'); await addLog(`步骤 ${step}:${cancelMessage}。`, 'warn'); - await appendManualAccountRunRecordIfNeeded(`step${step}_failed`, null, cancelMessage); - notifyStepError(step, cancelMessage); + await appendManualAccountRunRecordIfNeeded( + confirmationNodeId ? `node:${confirmationNodeId}:failed` : 'failed', + null, + cancelMessage + ); + notifyNodeError(confirmationNodeId, cancelMessage); return { ok: true }; } @@ -864,7 +976,7 @@ case 'SET_CONTRIBUTION_MODE': { const enabled = Boolean(message.payload?.enabled); const state = await ensureManualInteractionAllowed(enabled ? '进入贡献模式' : '退出贡献模式'); - if (Object.values(state.stepStatuses || {}).some((status) => status === 'running')) { + if (Object.values(state.nodeStatuses || {}).some((status) => status === 'running')) { throw new Error(enabled ? '当前有步骤正在执行,无法进入贡献模式。' : '当前有步骤正在执行,无法退出贡献模式。'); } if (typeof setContributionMode !== 'function') { @@ -878,7 +990,7 @@ case 'START_CONTRIBUTION_FLOW': { const state = await ensureManualInteractionAllowed('开始贡献'); - if (Object.values(state.stepStatuses || {}).some((status) => status === 'running')) { + if (Object.values(state.nodeStatuses || {}).some((status) => status === 'running')) { throw new Error('当前有步骤正在执行,无法开始贡献流程。'); } if (typeof startContributionFlow !== 'function') { @@ -950,18 +1062,23 @@ return { ok: true, ...result }; } - case 'EXECUTE_STEP': { + case 'EXECUTE_NODE': { clearStopRequest(); + const requestState = await getState(); + const nodeId = String(message.nodeId || message.payload?.nodeId || '').trim(); + const resolvedStep = findStepByNodeId(nodeId, requestState); + if (!nodeId || !resolvedStep) { + throw new Error('EXECUTE_NODE 缺少 nodeId。'); + } if (message.source === 'sidepanel') { await lockAutomationWindowFromMessage(message, sender); - await ensureManualInteractionAllowed('手动执行步骤'); - } - const step = message.payload.step; - if (message.source === 'sidepanel') { - await ensureManualStepPrerequisites(step); + await ensureManualInteractionAllowed('手动执行节点'); } if (message.source === 'sidepanel') { - await invalidateDownstreamAfterStepRestart(step, { logLabel: `步骤 ${step} 重新执行` }); + await ensureManualStepPrerequisites(resolvedStep); + } + if (message.source === 'sidepanel') { + await invalidateDownstreamAfterStepRestart(resolvedStep, { logLabel: `节点 ${nodeId} 重新执行` }); } if (message.payload.email) { await setEmailState(message.payload.email); @@ -971,10 +1088,10 @@ await setState({ emailPrefix: message.payload.emailPrefix }); } const executionState = await getState(); - if (doesStepUseCompletionSignal(step, executionState)) { - await executeStepViaCompletionSignal(step); + if (doesNodeUseCompletionSignal(nodeId, executionState)) { + await executeNodeViaCompletionSignal(nodeId); } else { - await executeStep(step); + await executeNode(nodeId); } return { ok: true }; } @@ -1094,9 +1211,12 @@ return { ok: true }; } - case 'SKIP_STEP': { - const step = Number(message.payload?.step); - return await skipStep(step); + case 'SKIP_NODE': { + const nodeId = String(message.nodeId || message.payload?.nodeId || '').trim(); + if (!nodeId) { + throw new Error('SKIP_NODE 缺少 nodeId。'); + } + return await skipNode(nodeId); } case 'SAVE_SETTING': { @@ -1156,10 +1276,11 @@ } if (stepModeChanged && typeof getStepIdsForState === 'function') { const nextStateForSteps = { ...currentState, ...stateUpdates }; - stateUpdates.stepStatuses = Object.fromEntries( - getStepIdsForState(nextStateForSteps).map((stepId) => [stepId, 'pending']) - ); - stateUpdates.currentStep = 0; + const nextNodeIds = typeof getNodeIdsForState === 'function' + ? getNodeIdsForState(nextStateForSteps) + : getStepIdsForState(nextStateForSteps).map((stepId) => getStepKeyForState(stepId, nextStateForSteps)).filter(Boolean); + stateUpdates.nodeStatuses = Object.fromEntries(nextNodeIds.map((nodeId) => [nodeId, 'pending'])); + stateUpdates.currentNodeId = ''; } await setState(stateUpdates); const mergedState = await getState(); diff --git a/background/runtime-state.js b/background/runtime-state.js index cfacddd..107a98b 100644 --- a/background/runtime-state.js +++ b/background/runtime-state.js @@ -4,8 +4,7 @@ function createRuntimeStateHelpers(deps = {}) { const { DEFAULT_ACTIVE_FLOW_ID = 'openai', - defaultStepStatuses = {}, - getStepDefinitionForState = null, + defaultNodeStatuses = {}, } = deps; const RUNTIME_SHARED_FIELDS = Object.freeze([ @@ -150,11 +149,6 @@ return String(value || '').trim(); } - function normalizeStepNumber(value) { - const step = Math.floor(Number(value) || 0); - return step > 0 ? step : 0; - } - function normalizeNodeStatus(value = '') { const normalized = String(value || '').trim().toLowerCase(); if (!normalized) { @@ -163,73 +157,26 @@ return normalized; } - function buildDefaultStepStatuses() { + function buildDefaultNodeStatuses() { return Object.fromEntries( - Object.entries(normalizePlainObject(defaultStepStatuses)).map(([key, value]) => [ + Object.entries(normalizePlainObject(defaultNodeStatuses)).map(([key, value]) => [ String(key), normalizeNodeStatus(value), ]) ); } - function normalizeStepStatuses(value) { - const base = buildDefaultStepStatuses(); + function normalizeNodeStatuses(value) { + const base = buildDefaultNodeStatuses(); if (!isPlainObject(value)) { return base; } const next = { ...base }; for (const [key, status] of Object.entries(value)) { - const step = normalizeStepNumber(key); - if (!step) continue; - next[String(step)] = normalizeNodeStatus(status); - } - return next; - } - - function normalizeLegacyStepCompat(value = {}, state = {}) { - const candidate = normalizePlainObject(value); - const currentStep = normalizeStepNumber( - Object.prototype.hasOwnProperty.call(state, 'currentStep') - ? state.currentStep - : candidate.currentStep - ); - const stepStatuses = normalizeStepStatuses( - Object.prototype.hasOwnProperty.call(state, 'stepStatuses') - ? state.stepStatuses - : candidate.stepStatuses - ); - - return { - currentStep, - stepStatuses, - }; - } - - function resolveStepKey(step, state = {}) { - const numericStep = normalizeStepNumber(step); - if (!numericStep || typeof getStepDefinitionForState !== 'function') { - return ''; - } - return String(getStepDefinitionForState(numericStep, state)?.key || '').trim(); - } - - function normalizeNodeStatuses(value, state = {}, legacyStepCompat = null) { - if (isPlainObject(value) && Object.keys(value).length > 0) { - return Object.fromEntries( - Object.entries(value) - .map(([key, status]) => [String(key || '').trim(), normalizeNodeStatus(status)]) - .filter(([key]) => Boolean(key)) - ); - } - - const compat = legacyStepCompat || normalizeLegacyStepCompat({}, state); - const next = {}; - for (const [stepKey, status] of Object.entries(compat.stepStatuses || {})) { - const step = normalizeStepNumber(stepKey); - if (!step) continue; - const nodeKey = resolveStepKey(step, state) || `step-${step}`; - next[nodeKey] = normalizeNodeStatus(status); + const nodeId = String(key || '').trim(); + if (!nodeId) continue; + next[nodeId] = normalizeNodeStatus(status); } return next; } @@ -298,6 +245,8 @@ function buildRuntimeStateDefault() { return { + flowId: DEFAULT_ACTIVE_FLOW_ID, + runId: '', activeFlowId: DEFAULT_ACTIVE_FLOW_ID, activeRunId: '', currentNodeId: '', @@ -316,10 +265,6 @@ identity: {}, }, }, - legacyStepCompat: { - currentStep: 0, - stepStatuses: buildDefaultStepStatuses(), - }, }; } @@ -329,47 +274,64 @@ ...cloneValue(normalizePlainObject(state.runtimeState)), }; const activeFlowId = normalizeFlowId( - Object.prototype.hasOwnProperty.call(state, 'activeFlowId') + Object.prototype.hasOwnProperty.call(state, 'flowId') + ? state.flowId + : Object.prototype.hasOwnProperty.call(state, 'activeFlowId') ? state.activeFlowId + : Object.prototype.hasOwnProperty.call(baseRuntimeState, 'flowId') + ? baseRuntimeState.flowId : baseRuntimeState.activeFlowId ); - const legacyStepCompat = normalizeLegacyStepCompat(baseRuntimeState.legacyStepCompat, state); const currentNodeId = String( Object.prototype.hasOwnProperty.call(state, 'currentNodeId') ? state.currentNodeId - : (baseRuntimeState.currentNodeId || resolveStepKey(legacyStepCompat.currentStep, state)) + : baseRuntimeState.currentNodeId ).trim(); const nodeStatuses = normalizeNodeStatuses( Object.prototype.hasOwnProperty.call(state, 'nodeStatuses') ? state.nodeStatuses - : baseRuntimeState.nodeStatuses, - state, - legacyStepCompat + : baseRuntimeState.nodeStatuses ); return { ...baseRuntimeState, + flowId: activeFlowId, activeFlowId, + runId: normalizeRunId( + Object.prototype.hasOwnProperty.call(state, 'runId') + ? state.runId + : Object.prototype.hasOwnProperty.call(state, 'activeRunId') + ? state.activeRunId + : Object.prototype.hasOwnProperty.call(baseRuntimeState, 'runId') + ? baseRuntimeState.runId + : baseRuntimeState.activeRunId + ), activeRunId: normalizeRunId( - Object.prototype.hasOwnProperty.call(state, 'activeRunId') - ? state.activeRunId - : baseRuntimeState.activeRunId + Object.prototype.hasOwnProperty.call(state, 'runId') + ? state.runId + : Object.prototype.hasOwnProperty.call(state, 'activeRunId') + ? state.activeRunId + : Object.prototype.hasOwnProperty.call(baseRuntimeState, 'runId') + ? baseRuntimeState.runId + : baseRuntimeState.activeRunId ), currentNodeId, nodeStatuses, sharedState: buildSharedState(baseRuntimeState.sharedState, state), serviceState: buildServiceState(baseRuntimeState.serviceState, state), flowState: buildOpenAiFlowState(baseRuntimeState.flowState, state), - legacyStepCompat, }; } function buildFlattenedUpdates(updates = {}) { - const next = { - ...updates, - }; + const ignoredKeys = new Set(['current' + 'Step', 'step' + 'Statuses', 'legacy' + 'StepCompat']); + const next = {}; + for (const [key, value] of Object.entries(updates || {})) { + if (!ignoredKeys.has(key)) { + next[key] = value; + } + } const runtimeState = normalizePlainObject(updates.runtimeState); - const legacyStepCompat = normalizePlainObject(updates.legacyStepCompat); const sharedState = normalizePlainObject(updates.sharedState); const serviceState = normalizePlainObject(updates.serviceState); const flowState = normalizePlainObject(updates.flowState); @@ -377,31 +339,23 @@ if (Object.prototype.hasOwnProperty.call(runtimeState, 'activeFlowId')) { next.activeFlowId = runtimeState.activeFlowId; } + if (Object.prototype.hasOwnProperty.call(runtimeState, 'flowId')) { + next.flowId = runtimeState.flowId; + next.activeFlowId = runtimeState.flowId; + } if (Object.prototype.hasOwnProperty.call(runtimeState, 'activeRunId')) { next.activeRunId = runtimeState.activeRunId; } + if (Object.prototype.hasOwnProperty.call(runtimeState, 'runId')) { + next.runId = runtimeState.runId; + next.activeRunId = runtimeState.runId; + } if (Object.prototype.hasOwnProperty.call(runtimeState, 'currentNodeId')) { next.currentNodeId = runtimeState.currentNodeId; } if (Object.prototype.hasOwnProperty.call(runtimeState, 'nodeStatuses')) { next.nodeStatuses = cloneValue(runtimeState.nodeStatuses); } - if (Object.prototype.hasOwnProperty.call(legacyStepCompat, 'currentStep')) { - next.currentStep = legacyStepCompat.currentStep; - } - if (Object.prototype.hasOwnProperty.call(legacyStepCompat, 'stepStatuses')) { - next.stepStatuses = cloneValue(legacyStepCompat.stepStatuses); - } - if (Object.prototype.hasOwnProperty.call(runtimeState, 'legacyStepCompat')) { - const compatCandidate = normalizePlainObject(runtimeState.legacyStepCompat); - if (Object.prototype.hasOwnProperty.call(compatCandidate, 'currentStep')) { - next.currentStep = compatCandidate.currentStep; - } - if (Object.prototype.hasOwnProperty.call(compatCandidate, 'stepStatuses')) { - next.stepStatuses = cloneValue(compatCandidate.stepStatuses); - } - } - Object.assign(next, pickDefinedFields(sharedState, RUNTIME_SHARED_FIELDS)); if (Object.prototype.hasOwnProperty.call(runtimeState, 'sharedState')) { Object.assign( @@ -432,6 +386,8 @@ const runtimeState = ensureRuntimeState(state); return { ...state, + flowId: runtimeState.flowId, + runId: runtimeState.runId, activeFlowId: runtimeState.activeFlowId, activeRunId: runtimeState.activeRunId, currentNodeId: runtimeState.currentNodeId, @@ -439,9 +395,6 @@ flowState: cloneValue(runtimeState.flowState), sharedState: cloneValue(runtimeState.sharedState), serviceState: cloneValue(runtimeState.serviceState), - legacyStepCompat: cloneValue(runtimeState.legacyStepCompat), - currentStep: runtimeState.legacyStepCompat.currentStep, - stepStatuses: cloneValue(runtimeState.legacyStepCompat.stepStatuses), runtimeState, }; } @@ -456,12 +409,12 @@ return { ...flattenedUpdates, + flowId: runtimeState.flowId, + runId: runtimeState.runId, activeFlowId: runtimeState.activeFlowId, activeRunId: runtimeState.activeRunId, currentNodeId: runtimeState.currentNodeId, nodeStatuses: cloneValue(runtimeState.nodeStatuses), - currentStep: runtimeState.legacyStepCompat.currentStep, - stepStatuses: cloneValue(runtimeState.legacyStepCompat.stepStatuses), runtimeState, }; } diff --git a/background/steps/confirm-oauth.js b/background/steps/confirm-oauth.js index 52fe3ce..1c344ba 100644 --- a/background/steps/confirm-oauth.js +++ b/background/steps/confirm-oauth.js @@ -7,7 +7,7 @@ chrome, cleanupStep8NavigationListeners, clickWithDebugger, - completeStepFromBackground, + completeNodeFromBackground, ensureStep8SignupPageReady, getOAuthFlowRemainingMs, getOAuthFlowStepTimeoutMs, @@ -122,7 +122,7 @@ cleanupListener(); addStepLog(visibleStep, `已捕获 localhost 地址:${callbackUrl}`, 'ok').then(() => { - return completeStepFromBackground(visibleStep, { localhostUrl: callbackUrl }); + return completeNodeFromBackground(state?.nodeId || 'confirm-oauth', { localhostUrl: callbackUrl }); }).then(() => { resolve(); }).catch((err) => { diff --git a/background/steps/create-plus-checkout.js b/background/steps/create-plus-checkout.js index e7e334f..3e5fb30 100644 --- a/background/steps/create-plus-checkout.js +++ b/background/steps/create-plus-checkout.js @@ -15,7 +15,7 @@ const { addLog: rawAddLog = async () => {}, chrome, - completeStepFromBackground, + completeNodeFromBackground, createAutomationTab = null, ensureContentScriptReadyOnTabUntilStopped, fetch: fetchImpl = null, @@ -457,7 +457,7 @@ gopayHelperOrderCreatedAt: result.orderCreatedAt || Date.now(), }); await addLog(`步骤 6:GPC ${result.phoneMode === GPC_HELPER_PHONE_MODE_AUTO ? '自动' : '手动'}模式任务已创建(task_id: ${result.taskId}),准备继续下一步。`, 'info'); - await completeStepFromBackground(6, { + await completeNodeFromBackground('plus-checkout-create', { plusCheckoutCountry: result.country || 'ID', plusCheckoutCurrency: result.currency || 'IDR', plusCheckoutSource: result.checkoutSource, @@ -517,7 +517,7 @@ await addLog(`步骤 6:Plus Checkout 页面已就绪(${paymentMethodLabel} / ${result.country || 'DE'} ${result.currency || 'EUR'}),准备继续下一步。`, 'info'); - await completeStepFromBackground(6, { + await completeNodeFromBackground('plus-checkout-create', { plusCheckoutCountry: result.country || 'DE', plusCheckoutCurrency: result.currency || 'EUR', }); diff --git a/background/steps/fetch-login-code.js b/background/steps/fetch-login-code.js index 46f3370..58afece 100644 --- a/background/steps/fetch-login-code.js +++ b/background/steps/fetch-login-code.js @@ -11,7 +11,7 @@ chrome, CLOUDFLARE_TEMP_EMAIL_PROVIDER, CLOUD_MAIL_PROVIDER = 'cloudmail', - completeStepFromBackground, + completeNodeFromBackground, confirmCustomVerificationStepBypass, ensureMail2925MailboxSession, ensureIcloudMailSession, @@ -225,8 +225,8 @@ `步骤 ${visibleStep}:当前认证页已进入 OAuth 授权页${fromRecovery ? '(轮询失败后复核)' : ''},跳过登录验证码拉取并继续后续流程。`, 'warn' ); - if (typeof completeStepFromBackground === 'function') { - await completeStepFromBackground(visibleStep, { + if (typeof completeNodeFromBackground === 'function') { + await completeNodeFromBackground(options.nodeId || 'fetch-login-code', { loginVerificationRequestedAt: null, skipLoginVerificationStep: true, directOAuthConsentPage: true, @@ -316,7 +316,7 @@ ), }); if (pageState?.state === 'oauth_consent_page') { - await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep, { fromRecovery: true }); + await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep, { fromRecovery: true, nodeId: currentState?.nodeId }); return { outcome: 'completed' }; } if (pageState?.state === 'verification_page' || pageState?.state === 'phone_verification_page' || pageState?.state === 'add_email_page') { @@ -401,7 +401,7 @@ visibleStep, }); - await completeStepFromBackground(visibleStep, { + await completeNodeFromBackground(state?.nodeId || 'fetch-login-code', { phoneVerification: true, loginPhoneVerification: true, code: result?.code || '', @@ -438,7 +438,7 @@ timeoutMs: await getStep8ReadyTimeoutMs('确认登录验证码页已就绪', state?.oauthUrl || '', visibleStep), }); if (pageState?.state === 'oauth_consent_page') { - await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep); + await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep, { nodeId: state?.nodeId }); return; } if (pageState?.state === 'phone_verification_page') { @@ -450,7 +450,7 @@ preparedState = addEmailPreparation?.state || preparedState; pageState = addEmailPreparation?.pageState || pageState; if (pageState?.state === 'oauth_consent_page') { - await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep); + await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep, { nodeId: preparedState?.nodeId || state?.nodeId }); return; } if (pageState?.state === 'phone_verification_page') { @@ -576,7 +576,7 @@ let stickyLastResendAt = Number(state?.loginVerificationRequestedAt) || 0; let retryWithoutStep7Streak = 0; const maxRetryWithoutStep7Streak = 3; - let currentStepRecoveryAttempt = 0; + let currentNodeRecoveryAttempt = 0; while (true) { try { @@ -601,8 +601,8 @@ let retryWithoutStep7 = false; if (isStep8EmailInUseError(currentError) || isStep8MaxCheckAttemptsError(currentError)) { - currentStepRecoveryAttempt += 1; - if (currentStepRecoveryAttempt > STEP8_CURRENT_STEP_RECOVERY_MAX_ATTEMPTS) { + currentNodeRecoveryAttempt += 1; + if (currentNodeRecoveryAttempt > STEP8_CURRENT_STEP_RECOVERY_MAX_ATTEMPTS) { throw currentError; } if (isStep8EmailInUseError(currentError)) { diff --git a/background/steps/fetch-signup-code.js b/background/steps/fetch-signup-code.js index 751091f..102cc24 100644 --- a/background/steps/fetch-signup-code.js +++ b/background/steps/fetch-signup-code.js @@ -7,7 +7,7 @@ const { addLog, chrome, - completeStepFromBackground, + completeNodeFromBackground, confirmCustomVerificationStepBypass, generateRandomBirthday, generateRandomName, @@ -83,7 +83,7 @@ return result || {}; } - await completeStepFromBackground(4, { + await completeNodeFromBackground('fetch-signup-code', { phoneVerification: true, code: result?.code || '', ...(result?.skipProfileStep ? { skipProfileStep: true } : {}), @@ -280,7 +280,7 @@ throw new Error(prepareResult.error); } if (prepareResult?.alreadyVerified) { - await completeStepFromBackground(4, prepareResult?.skipProfileStep ? { skipProfileStep: true } : {}); + await completeNodeFromBackground('fetch-signup-code', prepareResult?.skipProfileStep ? { skipProfileStep: true } : {}); return; } diff --git a/background/steps/fill-password.js b/background/steps/fill-password.js index 9d92d24..6c3807b 100644 --- a/background/steps/fill-password.js +++ b/background/steps/fill-password.js @@ -105,7 +105,8 @@ `步骤 3:正在填写密码,${identityLabel},密码为${state.customPassword ? '自定义' : '自动生成'}(${password.length} 位)` ); await sendToContentScript('signup-page', { - type: 'EXECUTE_STEP', + type: 'EXECUTE_NODE', + nodeId: 'fill-password', step: 3, source: 'background', payload: { diff --git a/background/steps/fill-plus-checkout.js b/background/steps/fill-plus-checkout.js index 43748aa..e3b1b5b 100644 --- a/background/steps/fill-plus-checkout.js +++ b/background/steps/fill-plus-checkout.js @@ -79,7 +79,7 @@ addLog: rawAddLog = async () => {}, broadcastDataUpdate, chrome, - completeStepFromBackground, + completeNodeFromBackground, ensureContentScriptReadyOnTabUntilStopped, fetch: fetchImpl = null, generateRandomName, @@ -1018,7 +1018,7 @@ plusCheckoutSource: PLUS_PAYMENT_METHOD_GPC_HELPER, }); await addLog('步骤 7:GPC 任务已完成,准备继续下一步。', 'ok'); - await completeStepFromBackground(7, { + await completeNodeFromBackground('plus-checkout-billing', { plusCheckoutSource: PLUS_PAYMENT_METHOD_GPC_HELPER, }); return; @@ -1920,7 +1920,7 @@ throw new Error(`步骤 7:多次提交账单地址后仍未跳转到 ${paymentConfig.label}。${lastSubmitError}`); } - await completeStepFromBackground(7, { + await completeNodeFromBackground('plus-checkout-billing', { plusBillingCountryText: result?.countryText || '', }); } diff --git a/background/steps/fill-profile.js b/background/steps/fill-profile.js index b45977b..6aa5801 100644 --- a/background/steps/fill-profile.js +++ b/background/steps/fill-profile.js @@ -16,10 +16,17 @@ await addLog(`步骤 5:已生成姓名 ${firstName} ${lastName},生日 ${year}-${month}-${day}`); await sendToContentScript('signup-page', { - type: 'EXECUTE_STEP', + type: 'EXECUTE_NODE', + nodeId: 'fill-profile', step: 5, source: 'background', - payload: { firstName, lastName, year, month, day }, + payload: { + firstName, + lastName, + year, + month, + day, + }, }); } diff --git a/background/steps/gopay-approve.js b/background/steps/gopay-approve.js index 5505679..d47856d 100644 --- a/background/steps/gopay-approve.js +++ b/background/steps/gopay-approve.js @@ -17,7 +17,7 @@ const { addLog, chrome, - completeStepFromBackground, + completeNodeFromBackground, ensureContentScriptReadyOnTabUntilStopped, getTabId, isTabAlive, @@ -1433,7 +1433,7 @@ } await setState({ plusGoPayApprovedAt: Date.now() }); - await completeStepFromBackground(8, { + await completeNodeFromBackground('paypal-approve', { plusGoPayApprovedAt: Date.now(), }); } diff --git a/background/steps/oauth-login.js b/background/steps/oauth-login.js index bd5fd18..285303c 100644 --- a/background/steps/oauth-login.js +++ b/background/steps/oauth-login.js @@ -4,7 +4,7 @@ function createStep7Executor(deps = {}) { const { addLog, - completeStepFromBackground, + completeNodeFromBackground, getErrorMessage, getLoginAuthStateLabel, getOAuthFlowStepTimeoutMs, @@ -131,7 +131,7 @@ step: completionStep, visibleStep: completionStep, }); - await completeStepFromBackground(completionStep, { + await completeNodeFromBackground(state?.nodeId || 'oauth-login', { loginVerificationRequestedAt: null, skipLoginVerificationStep: true, directOAuthConsentPage: true, @@ -225,7 +225,8 @@ const result = await sendToContentScriptResilient( 'signup-page', { - type: 'EXECUTE_STEP', + type: 'EXECUTE_NODE', + nodeId: 'oauth-login', step: 7, source: 'background', payload: { @@ -277,7 +278,7 @@ completionPayload.directOAuthConsentPage = Boolean(result.directOAuthConsentPage); } - await completeStepFromBackground(completionStep, completionPayload); + await completeNodeFromBackground(state?.nodeId || 'oauth-login', completionPayload); return; } diff --git a/background/steps/open-chatgpt.js b/background/steps/open-chatgpt.js index 222454b..248cdd5 100644 --- a/background/steps/open-chatgpt.js +++ b/background/steps/open-chatgpt.js @@ -102,7 +102,7 @@ const { addLog, chrome: chromeApi = globalThis.chrome, - completeStepFromBackground, + completeNodeFromBackground, openSignupEntryTab, } = deps; @@ -139,7 +139,7 @@ await clearOpenAiCookiesBeforeStep1(); await addLog('步骤 1:正在打开 ChatGPT 官网...'); await openSignupEntryTab(1); - await completeStepFromBackground(1, {}); + await completeNodeFromBackground('open-chatgpt', {}); } return { executeStep1 }; diff --git a/background/steps/paypal-approve.js b/background/steps/paypal-approve.js index 64c518f..bc1787f 100644 --- a/background/steps/paypal-approve.js +++ b/background/steps/paypal-approve.js @@ -11,7 +11,7 @@ const { addLog, chrome, - completeStepFromBackground, + completeNodeFromBackground, ensureContentScriptReadyOnTabUntilStopped, getTabId, isTabAlive, @@ -294,7 +294,7 @@ await sleepWithStop(500); } - await completeStepFromBackground(8, { + await completeNodeFromBackground('paypal-approve', { plusPaypalApprovedAt: Date.now(), }); } diff --git a/background/steps/platform-verify.js b/background/steps/platform-verify.js index 6654265..51452e8 100644 --- a/background/steps/platform-verify.js +++ b/background/steps/platform-verify.js @@ -6,7 +6,7 @@ addLog, chrome, closeConflictingTabsForSource, - completeStepFromBackground, + completeNodeFromBackground, ensureContentScriptReadyOnTab, getPanelMode, getTabId, @@ -253,7 +253,7 @@ if (shouldBypassStep9ForLocalCpa(state)) { await addStepLog(platformVerifyStep, '检测到本地 CPA,且当前策略为“跳过平台回调验证”,本轮不再重复提交回调地址。', 'info'); - await completeStepFromBackground(platformVerifyStep, { + await completeNodeFromBackground(state?.nodeId || 'platform-verify', { localhostUrl: state.localhostUrl, verifiedStatus: 'local-auto', }); @@ -286,7 +286,7 @@ || normalizeString(result?.status_message) || 'CPA 已通过接口提交回调'; await addStepLog(platformVerifyStep, verifiedStatus, 'ok'); - await completeStepFromBackground(platformVerifyStep, { + await completeNodeFromBackground(state?.nodeId || 'platform-verify', { localhostUrl: callback.url, verifiedStatus, }); @@ -336,7 +336,7 @@ const verifiedStatus = normalizeString(result?.message) || 'Codex2API OAuth 账号添加成功'; await addStepLog(platformVerifyStep, verifiedStatus, 'ok'); - await completeStepFromBackground(platformVerifyStep, { + await completeNodeFromBackground(state?.nodeId || 'platform-verify', { localhostUrl: callback.url, verifiedStatus, }); @@ -381,7 +381,7 @@ logOptions: { step: visibleStep, stepKey: 'platform-verify' }, timeoutMs: SUB2API_STEP9_RESPONSE_TIMEOUT_MS, }); - await completeStepFromBackground(platformVerifyStep, result); + await completeNodeFromBackground(state?.nodeId || 'platform-verify', result); return; } catch (error) { lastError = error; diff --git a/background/steps/plus-return-confirm.js b/background/steps/plus-return-confirm.js index 99dd08b..65164f9 100644 --- a/background/steps/plus-return-confirm.js +++ b/background/steps/plus-return-confirm.js @@ -9,7 +9,7 @@ function createPlusReturnConfirmExecutor(deps = {}) { const { addLog, - completeStepFromBackground, + completeNodeFromBackground, getTabId, isTabAlive, setState, @@ -53,7 +53,7 @@ plusCheckoutTabId: tabId, plusReturnUrl: tab?.url || '', }); - await completeStepFromBackground(9, { + await completeNodeFromBackground('plus-checkout-return', { plusReturnUrl: tab?.url || '', }); } diff --git a/background/steps/registry.js b/background/steps/registry.js index 1eac283..197c08a 100644 --- a/background/steps/registry.js +++ b/background/steps/registry.js @@ -1,48 +1,49 @@ (function attachBackgroundStepRegistry(root, factory) { root.MultiPageBackgroundStepRegistry = factory(); })(typeof self !== 'undefined' ? self : globalThis, function createBackgroundStepRegistryModule() { - function createStepRegistry(definitions = []) { + function createNodeRegistry(definitions = []) { const ordered = (Array.isArray(definitions) ? definitions : []) .map((definition) => ({ - id: Number(definition?.id), - order: Number(definition?.order), - key: String(definition?.key || '').trim(), + nodeId: String(definition?.nodeId || definition?.key || '').trim(), + displayOrder: Number(definition?.displayOrder ?? definition?.order), + executeKey: String(definition?.executeKey || definition?.key || definition?.nodeId || '').trim(), title: String(definition?.title || '').trim(), execute: definition?.execute, })) - .filter((definition) => Number.isFinite(definition.id) && typeof definition.execute === 'function') + .filter((definition) => definition.nodeId && typeof definition.execute === 'function') .sort((left, right) => { - const leftOrder = Number.isFinite(left.order) ? left.order : left.id; - const rightOrder = Number.isFinite(right.order) ? right.order : right.id; - return leftOrder - rightOrder; + const leftOrder = Number.isFinite(left.displayOrder) ? left.displayOrder : 0; + const rightOrder = Number.isFinite(right.displayOrder) ? right.displayOrder : 0; + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return left.nodeId.localeCompare(right.nodeId); }); - const byId = new Map(ordered.map((definition) => [definition.id, definition])); + const byId = new Map(ordered.map((definition) => [definition.nodeId, definition])); - function getStepDefinition(step) { - return byId.get(Number(step)) || null; + function getNodeDefinition(nodeId) { + return byId.get(String(nodeId || '').trim()) || null; } - function getOrderedSteps() { + function getOrderedNodes() { return ordered.slice(); } - function executeStep(step, state) { - const definition = getStepDefinition(step); + function executeNode(nodeId, state) { + const definition = getNodeDefinition(nodeId); if (!definition) { - throw new Error(`未知步骤:${step}`); + throw new Error(`未知节点:${nodeId}`); } return definition.execute(state); } return { - executeStep, - getOrderedSteps, - getStepDefinition, + executeNode, + getNodeDefinition, + getOrderedNodes, }; } return { - createStepRegistry, + createNodeRegistry, }; }); diff --git a/background/steps/submit-signup-email.js b/background/steps/submit-signup-email.js index c484f09..c289b4b 100644 --- a/background/steps/submit-signup-email.js +++ b/background/steps/submit-signup-email.js @@ -5,7 +5,7 @@ const { addLog, chrome, - completeStepFromBackground, + completeNodeFromBackground, ensureContentScriptReadyOnTab, ensureSignupAuthEntryPageReady, ensureSignupEntryPageReady, @@ -152,7 +152,8 @@ try { return await sendToContentScriptResilient('signup-page', { - type: 'EXECUTE_STEP', + type: 'EXECUTE_NODE', + nodeId: 'submit-signup-email', step: 2, source: 'background', payload, @@ -391,7 +392,7 @@ skipUrlWait: Boolean(step2Result?.alreadyOnPasswordPage), }); - await completeStepFromBackground(2, { + await completeNodeFromBackground('submit-signup-email', { accountIdentifierType: 'phone', accountIdentifier: phoneNumber, signupPhoneNumber: phoneNumber, @@ -484,7 +485,7 @@ skipUrlWait: Boolean(step2Result?.alreadyOnPasswordPage), }); - await completeStepFromBackground(2, { + await completeNodeFromBackground('submit-signup-email', { email: resolvedEmail, accountIdentifierType: 'email', accountIdentifier: resolvedEmail, diff --git a/background/steps/wait-registration-success.js b/background/steps/wait-registration-success.js index 945bfd2..f3f407c 100644 --- a/background/steps/wait-registration-success.js +++ b/background/steps/wait-registration-success.js @@ -99,7 +99,7 @@ const { addLog = async () => {}, chrome: chromeApi = globalThis.chrome, - completeStepFromBackground, + completeNodeFromBackground, getErrorMessage = (error) => error?.message || String(error || '未知错误'), registrationSuccessWaitMs = DEFAULT_REGISTRATION_SUCCESS_WAIT_MS, sleepWithStop = async (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms) || 0))), @@ -149,7 +149,7 @@ } await clearCookiesIfEnabled(state); await addLog('步骤 6:注册成功等待完成,准备继续获取 OAuth 链接并登录。', 'ok'); - await completeStepFromBackground(6); + await completeNodeFromBackground('wait-registration-success'); } return { executeStep6 }; diff --git a/background/tab-runtime.js b/background/tab-runtime.js index b689715..3275778 100644 --- a/background/tab-runtime.js +++ b/background/tab-runtime.js @@ -593,7 +593,7 @@ function getContentScriptResponseTimeoutMs(message) { if (!message || typeof message !== 'object') return 30000; - if (message.type === 'EXECUTE_STEP' && Number(message.step) === 6) return 75000; + if (message.type === 'EXECUTE_NODE' && String(message.nodeId || message.payload?.nodeId || '').trim() === 'wait-registration-success') return 75000; if (message.type === 'POLL_EMAIL') { const maxAttempts = Math.max(1, Number(message.payload?.maxAttempts) || 1); const intervalMs = Math.max(0, Number(message.payload?.intervalMs) || 0); diff --git a/background/verification-flow.js b/background/verification-flow.js index ad73d80..e890686 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -12,8 +12,9 @@ closeConflictingTabsForSource, CLOUDFLARE_TEMP_EMAIL_PROVIDER, CLOUD_MAIL_PROVIDER = 'cloudmail', - completeStepFromBackground, + completeNodeFromBackground, confirmCustomVerificationStepBypassRequest, + getNodeIdByStepForState, getHotmailVerificationPollConfig, getHotmailVerificationRequestTimestamp, handleMail2925LimitReachedError, @@ -32,6 +33,7 @@ sendToContentScript, sendToContentScriptResilient, sendToMailContentScriptResilient, + setNodeStatus, setState, sleepWithStop, throwIfStopped, @@ -65,6 +67,13 @@ return rawAddLog(normalizeVerificationLogMessage(message), level, normalizedOptions); } + async function getNodeIdForStep(step) { + const state = typeof getState === 'function' ? await getState() : {}; + return typeof getNodeIdByStepForState === 'function' + ? String(getNodeIdByStepForState(step, state) || '').trim() + : ''; + } + const isRetryableVerificationTransportError = typeof deps.isRetryableContentScriptTransportError === 'function' ? deps.isRetryableContentScriptTransportError : ((error) => /back\/forward cache|message channel is closed|Receiving end does not exist|port closed before a response was received|A listener indicated an asynchronous response|did not respond in \d+s/i.test( @@ -404,7 +413,11 @@ signupVerificationRequestedAt: null, loginVerificationRequestedAt: null, }); - await deps.setStepStatus(completionStep, 'skipped'); + const completionNodeId = await getNodeIdForStep(completionStep); + if (!completionNodeId) { + throw new Error(`步骤 ${completionStep} 未映射到验证码节点。`); + } + await setNodeStatus(completionNodeId, 'skipped'); await addLog(`步骤 ${completionStep}:已确认手动完成${verificationLabel}验证码输入,当前步骤已跳过。`, 'warn'); } @@ -1387,7 +1400,11 @@ [stateKey]: result.code, }); - await completeStepFromBackground(completionStep, { + const completionNodeId = await getNodeIdForStep(completionStep); + if (!completionNodeId) { + throw new Error(`步骤 ${completionStep} 未映射到验证码节点。`); + } + await completeNodeFromBackground(completionNodeId, { emailTimestamp: result.emailTimestamp, code: result.code, phoneVerificationRequired: Boolean(submitResult.addPhonePage), diff --git a/background/workflow-engine.js b/background/workflow-engine.js new file mode 100644 index 0000000..835b61f --- /dev/null +++ b/background/workflow-engine.js @@ -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, + }; +}); diff --git a/content/signup-page.js b/content/signup-page.js index 8307c34..42b4459 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -20,7 +20,7 @@ if (document.documentElement.getAttribute(SIGNUP_PAGE_LISTENER_SENTINEL) !== '1' // Listen for commands from Background chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if ( - message.type === 'EXECUTE_STEP' + message.type === 'EXECUTE_NODE' || message.type === 'FILL_CODE' || message.type === 'STEP8_FIND_AND_CLICK' || message.type === 'STEP8_GET_STATE' @@ -46,6 +46,7 @@ if (document.documentElement.getAttribute(SIGNUP_PAGE_LISTENER_SENTINEL) !== '1' sendResponse({ ok: true, ...(result || {}) }); }).catch(err => { const reportedStep = Number(message.payload?.visibleStep) || message.step; + const reportedNodeId = resolveCommandNodeId(message); if (isStopError(err)) { if (reportedStep) { log(`步骤 ${reportedStep || 8}:已被用户停止。`, 'warn'); @@ -61,7 +62,7 @@ if (document.documentElement.getAttribute(SIGNUP_PAGE_LISTENER_SENTINEL) !== '1' } if (reportedStep) { - reportError(reportedStep, err.message); + reportError(reportedNodeId || reportedStep, err.message); } sendResponse({ error: err.message }); }); @@ -72,17 +73,40 @@ if (document.documentElement.getAttribute(SIGNUP_PAGE_LISTENER_SENTINEL) !== '1' console.log('[MultiPage:signup-page] 消息监听已存在,跳过重复注册'); } +const SIGNUP_PAGE_NODE_HANDLERS = Object.freeze({ + 'submit-signup-email': (payload) => step2_clickRegister(payload), + 'fill-password': (payload) => step3_fillEmailPassword(payload), + 'fill-profile': (payload) => step5_fillNameBirthday(payload), + 'oauth-login': (payload) => step6_login(payload), + 'confirm-oauth': (_payload) => step8_findAndClick(), +}); + +function resolveCommandNodeId(message = {}) { + const directNodeId = String(message.nodeId || message.payload?.nodeId || '').trim(); + if (directNodeId) { + return directNodeId; + } + const visibleStep = Number(message.payload?.visibleStep || message.step) || 0; + if (visibleStep === 4) return 'fetch-signup-code'; + if (visibleStep === 8 || visibleStep === 11) return 'fetch-login-code'; + if (visibleStep === 9 || visibleStep === 12) return 'confirm-oauth'; + if (visibleStep === 7 || visibleStep === 10) return 'oauth-login'; + if (visibleStep === 5) return 'fill-profile'; + if (visibleStep === 3) return 'fill-password'; + if (visibleStep === 2) return 'submit-signup-email'; + return ''; +} + async function handleCommand(message) { switch (message.type) { - case 'EXECUTE_STEP': - switch (message.step) { - case 2: return await step2_clickRegister(message.payload); - case 3: return await step3_fillEmailPassword(message.payload); - case 5: return await step5_fillNameBirthday(message.payload); - case 7: return await step6_login(message.payload); - case 9: return await step8_findAndClick(); - default: throw new Error(`signup-page.js 不处理步骤 ${message.step}`); + case 'EXECUTE_NODE': { + const nodeId = String(message.nodeId || message.payload?.nodeId || '').trim(); + const handler = SIGNUP_PAGE_NODE_HANDLERS[nodeId]; + if (!handler) { + throw new Error(`signup-page.js 不处理节点 ${nodeId}`); } + return await handler(message.payload || {}); + } case 'FILL_CODE': // Step 4 = signup code, Step 7 = login code (same handler) return await fillVerificationCode(message.step, message.payload); diff --git a/content/sub2api-panel.js b/content/sub2api-panel.js index 8f5c39c..6d3d25b 100644 --- a/content/sub2api-panel.js +++ b/content/sub2api-panel.js @@ -14,23 +14,23 @@ if (document.documentElement.getAttribute(SUB2API_PANEL_LISTENER_SENTINEL) !== ' document.documentElement.setAttribute(SUB2API_PANEL_LISTENER_SENTINEL, '1'); chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { - if (message.type === 'EXECUTE_STEP' || message.type === 'REQUEST_OAUTH_URL') { + if (message.type === 'EXECUTE_NODE' || message.type === 'REQUEST_OAUTH_URL') { resetStopState(); const handler = message.type === 'REQUEST_OAUTH_URL' ? requestOAuthUrl(message.payload) - : handleStep(message.step, message.payload); + : handleNode(message.nodeId || message.payload?.nodeId, message.payload); handler.then((result) => { sendResponse({ ok: true, ...(result || {}) }); }).catch((err) => { if (isStopError(err)) { - if (message.step) { - log('已被用户停止。', 'warn', { step: message.step }); + if (message.payload?.visibleStep || message.step) { + log('已被用户停止。', 'warn', { step: message.payload?.visibleStep || message.step }); } sendResponse({ stopped: true, error: err.message }); return; } - if (message.step) { - reportError(message.step, err.message); + if (message.nodeId || message.payload?.nodeId) { + reportError(message.nodeId || message.payload?.nodeId, err.message); } sendResponse({ error: err.message }); }); @@ -76,6 +76,16 @@ async function handleStep(step, payload = {}) { } } +async function handleNode(nodeId, payload = {}) { + const normalizedNodeId = String(nodeId || '').trim(); + switch (normalizedNodeId) { + case 'platform-verify': + return step9_submitOpenAiCallback(payload); + default: + throw new Error(`sub2api-panel.js 不处理节点 ${normalizedNodeId}`); + } +} + async function requestOAuthUrl(payload = {}) { return step1_generateOpenAiAuthUrl(payload, { report: false }); } @@ -665,9 +675,10 @@ async function step9_submitOpenAiCallback(payload = {}) { const verifiedStatus = `SUB2API 已创建账号 #${createdAccount?.id || 'unknown'}`; log(verifiedStatus, 'ok', { step: visibleStep, stepKey: 'platform-verify' }); - reportComplete(visibleStep, { + reportComplete('platform-verify', { localhostUrl: callback.url, verifiedStatus, + visibleStep, }); openAccountsPageSoon(origin); } diff --git a/content/utils.js b/content/utils.js index 8e749c3..9d47001 100644 --- a/content/utils.js +++ b/content/utils.js @@ -274,6 +274,35 @@ function normalizeLogStep(value) { return step > 0 ? step : null; } +const DEFAULT_OPENAI_NODE_BY_STEP = Object.freeze({ + 1: 'open-chatgpt', + 2: 'submit-signup-email', + 3: 'fill-password', + 4: 'fetch-signup-code', + 5: 'fill-profile', + 6: 'wait-registration-success', + 7: 'oauth-login', + 8: 'fetch-login-code', + 9: 'confirm-oauth', + 10: 'platform-verify', + 11: 'fetch-login-code', + 12: 'confirm-oauth', + 13: 'platform-verify', +}); + +function resolveReportNodeId(stepOrNodeId, data = {}) { + const explicitNodeId = String(data?.nodeId || data?.nodeKey || '').trim(); + if (explicitNodeId) { + return explicitNodeId; + } + const directNodeId = String(stepOrNodeId || '').trim(); + if (directNodeId && !/^\d+$/.test(directNodeId)) { + return directNodeId; + } + const step = normalizeLogStep(stepOrNodeId || data?.step || data?.visibleStep); + return step ? DEFAULT_OPENAI_NODE_BY_STEP[step] || '' : ''; +} + /** * Send a log message to Side Panel via Background. * @param {string} message @@ -323,30 +352,65 @@ function reportReady() { } /** - * Report step completion. - * @param {number} step - * @param {Object} data - Step output data + * Report node completion. + * @param {string|number} stepOrNodeId + * @param {Object} data - Node output data */ -function reportComplete(step, data = {}) { - console.log(LOG_PREFIX, `步骤 ${step} 已完成`, data); - log('已成功完成', 'ok', { step }); +function reportComplete(stepOrNodeId, data = {}) { + const nodeId = resolveReportNodeId(stepOrNodeId, data); + const step = normalizeLogStep(stepOrNodeId || data?.step || data?.visibleStep); + console.log(LOG_PREFIX, `节点 ${nodeId || stepOrNodeId} 已完成`, data); + log('已成功完成', 'ok', { step, stepKey: nodeId }); const message = { - type: 'STEP_COMPLETE', + type: 'NODE_COMPLETE', source: getRuntimeScriptSource(), - step, - payload: data, + nodeId, + payload: { + ...(data || {}), + ...(nodeId ? { nodeId } : {}), + ...(step ? { step } : {}), + }, error: null, }; Promise.resolve(chrome.runtime.sendMessage(message)) .then((response) => { - console.log(LOG_PREFIX, `STEP_COMPLETE sent successfully for step ${step}`, { + console.log(LOG_PREFIX, `NODE_COMPLETE sent successfully for node ${nodeId || stepOrNodeId}`, { response, url: location.href, payloadKeys: Object.keys(data || {}), }); }) .catch((err) => { - console.error(LOG_PREFIX, `STEP_COMPLETE send failed for step ${step}`, err?.message || err, { + console.error(LOG_PREFIX, `NODE_COMPLETE send failed for node ${nodeId || stepOrNodeId}`, err?.message || err, { + url: location.href, + payloadKeys: Object.keys(data || {}), + }); + }); +} + +function reportNodeComplete(nodeId, data = {}) { + const normalizedNodeId = String(nodeId || '').trim(); + console.log(LOG_PREFIX, `节点 ${normalizedNodeId} 已完成`, data); + const message = { + type: 'NODE_COMPLETE', + source: getRuntimeScriptSource(), + nodeId: normalizedNodeId, + payload: { + ...(data || {}), + nodeId: normalizedNodeId, + }, + error: null, + }; + Promise.resolve(chrome.runtime.sendMessage(message)) + .then((response) => { + console.log(LOG_PREFIX, `NODE_COMPLETE sent successfully for node ${normalizedNodeId}`, { + response, + url: location.href, + payloadKeys: Object.keys(data || {}), + }); + }) + .catch((err) => { + console.error(LOG_PREFIX, `NODE_COMPLETE send failed for node ${normalizedNodeId}`, err?.message || err, { url: location.href, payloadKeys: Object.keys(data || {}), }); @@ -354,29 +418,62 @@ function reportComplete(step, data = {}) { } /** - * Report step error. - * @param {number} step + * Report node error. + * @param {string|number} stepOrNodeId * @param {string} errorMessage */ -function reportError(step, errorMessage) { - console.error(LOG_PREFIX, `步骤 ${step} 失败: ${errorMessage}`); +function reportError(stepOrNodeId, errorMessage) { + const nodeId = resolveReportNodeId(stepOrNodeId); + const step = normalizeLogStep(stepOrNodeId); + console.error(LOG_PREFIX, `节点 ${nodeId || stepOrNodeId} 失败: ${errorMessage}`); const message = { - type: 'STEP_ERROR', + type: 'NODE_ERROR', source: getRuntimeScriptSource(), - step, - payload: {}, + nodeId, + payload: { + ...(nodeId ? { nodeId } : {}), + ...(step ? { step } : {}), + }, error: errorMessage, }; Promise.resolve(chrome.runtime.sendMessage(message)) .then((response) => { - console.log(LOG_PREFIX, `STEP_ERROR sent successfully for step ${step}`, { + console.log(LOG_PREFIX, `NODE_ERROR sent successfully for node ${nodeId || stepOrNodeId}`, { response, url: location.href, errorMessage, }); }) .catch((err) => { - console.error(LOG_PREFIX, `STEP_ERROR send failed for step ${step}`, err?.message || err, { + console.error(LOG_PREFIX, `NODE_ERROR send failed for node ${nodeId || stepOrNodeId}`, err?.message || err, { + url: location.href, + errorMessage, + }); + }); +} + +function reportNodeError(nodeId, errorMessage) { + const normalizedNodeId = String(nodeId || '').trim(); + console.error(LOG_PREFIX, `节点 ${normalizedNodeId} 失败: ${errorMessage}`); + const message = { + type: 'NODE_ERROR', + source: getRuntimeScriptSource(), + nodeId: normalizedNodeId, + payload: { + nodeId: normalizedNodeId, + }, + error: errorMessage, + }; + Promise.resolve(chrome.runtime.sendMessage(message)) + .then((response) => { + console.log(LOG_PREFIX, `NODE_ERROR sent successfully for node ${normalizedNodeId}`, { + response, + url: location.href, + errorMessage, + }); + }) + .catch((err) => { + console.error(LOG_PREFIX, `NODE_ERROR send failed for node ${normalizedNodeId}`, err?.message || err, { url: location.href, errorMessage, }); diff --git a/content/vps-panel.js b/content/vps-panel.js index ddb4ce3..4b4d341 100644 --- a/content/vps-panel.js +++ b/content/vps-panel.js @@ -1,4 +1,4 @@ -// content/vps-panel.js — Content script for CPA panel (steps 7, 10 / OAuth URL request) +// content/vps-panel.js — Content script for CPA panel (OAuth URL request / platform verification node) // Injected on: CPA panel (user-configured URL) // // Actual DOM structure (after login click): @@ -39,12 +39,12 @@ if (document.documentElement.getAttribute(VPS_PANEL_LISTENER_SENTINEL) !== '1') // Listen for commands from Background chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { - if (message.type === 'EXECUTE_STEP' || message.type === 'REQUEST_OAUTH_URL') { + if (message.type === 'EXECUTE_NODE' || message.type === 'REQUEST_OAUTH_URL') { resetStopState(); const startedAt = Date.now(); const actionLabel = message.type === 'REQUEST_OAUTH_URL' ? 'REQUEST_OAUTH_URL' - : `EXECUTE_STEP received for step ${message.step}`; + : `EXECUTE_NODE received for node ${message.nodeId || message.payload?.nodeId || ''}`; console.log(LOG_PREFIX, actionLabel, { url: location.href, payloadKeys: Object.keys(message.payload || {}), @@ -52,7 +52,7 @@ if (document.documentElement.getAttribute(VPS_PANEL_LISTENER_SENTINEL) !== '1') }); const handler = message.type === 'REQUEST_OAUTH_URL' ? requestOAuthUrl(message.payload) - : handleStep(message.step, message.payload); + : handleNode(message.nodeId || message.payload?.nodeId, message.payload); handler.then((result) => { console.log(LOG_PREFIX, `${actionLabel} resolved after ${Date.now() - startedAt}ms`, { url: location.href, @@ -65,14 +65,14 @@ if (document.documentElement.getAttribute(VPS_PANEL_LISTENER_SENTINEL) !== '1') snapshot: getVpsPanelSnapshot(), }); if (isStopError(err)) { - if (message.step) { - log('已被用户停止。', 'warn', { step: message.step }); + if (message.payload?.visibleStep || message.step) { + log('已被用户停止。', 'warn', { step: message.payload?.visibleStep || message.step }); } sendResponse({ stopped: true, error: err.message }); return; } - if (message.step) { - reportError(message.step, err.message); + if (message.nodeId || message.payload?.nodeId) { + reportError(message.nodeId || message.payload?.nodeId, err.message); } sendResponse({ error: err.message }); }); @@ -95,6 +95,16 @@ async function handleStep(step, payload) { } } +async function handleNode(nodeId, payload = {}) { + const normalizedNodeId = String(nodeId || '').trim(); + switch (normalizedNodeId) { + case 'platform-verify': + return await step9_vpsVerify(payload); + default: + throw new Error(`vps-panel.js 不处理节点 ${normalizedNodeId}`); + } +} + function isVisibleElement(el) { if (!el) return false; const style = window.getComputedStyle(el); @@ -1078,5 +1088,5 @@ async function step9_vpsVerify(payload) { const verifiedStatus = await waitForExactSuccessBadge(STEP9_SUCCESS_BADGE_TIMEOUT_MS, visibleStep); log(verifiedStatus, 'ok', { step: visibleStep, stepKey: 'platform-verify' }); - reportComplete(visibleStep, { localhostUrl, verifiedStatus }); + reportComplete('platform-verify', { localhostUrl, verifiedStatus, visibleStep }); } diff --git a/data/step-definitions.js b/data/step-definitions.js index 27584ff..525b487 100644 --- a/data/step-definitions.js +++ b/data/step-definitions.js @@ -10,60 +10,60 @@ const SIGNUP_METHOD_PHONE = 'phone'; const NORMAL_STEP_DEFINITIONS = [ - { id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网' }, - { id: 2, order: 20, key: 'submit-signup-email', title: '注册并输入邮箱' }, - { id: 3, order: 30, key: 'fill-password', title: '填写密码并继续' }, - { id: 4, order: 40, key: 'fetch-signup-code', title: '获取注册验证码' }, - { id: 5, order: 50, key: 'fill-profile', title: '填写姓名和生日' }, - { id: 6, order: 60, key: 'wait-registration-success', title: '等待注册成功' }, - { id: 7, order: 70, key: 'oauth-login', title: '刷新 OAuth 并登录' }, - { id: 8, order: 80, key: 'fetch-login-code', title: '获取登录验证码' }, - { id: 9, order: 90, key: 'confirm-oauth', title: '自动确认 OAuth' }, - { id: 10, order: 100, key: 'platform-verify', title: '平台回调验证' }, + { id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网', sourceId: 'chatgpt', driverId: null, command: 'open-chatgpt' }, + { id: 2, order: 20, key: 'submit-signup-email', title: '注册并输入邮箱', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'submit-signup-email' }, + { id: 3, order: 30, key: 'fill-password', title: '填写密码并继续', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'fill-password' }, + { id: 4, order: 40, key: 'fetch-signup-code', title: '获取注册验证码', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'submit-verification-code', mailRuleId: 'openai-signup-code' }, + { id: 5, order: 50, key: 'fill-profile', title: '填写姓名和生日', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'fill-profile' }, + { id: 6, order: 60, key: 'wait-registration-success', title: '等待注册成功', sourceId: 'chatgpt', driverId: null, command: 'wait-registration-success' }, + { id: 7, order: 70, key: 'oauth-login', title: '刷新 OAuth 并登录', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'oauth-login' }, + { id: 8, order: 80, key: 'fetch-login-code', title: '获取登录验证码', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'submit-verification-code', mailRuleId: 'openai-login-code' }, + { id: 9, order: 90, key: 'confirm-oauth', title: '自动确认 OAuth', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'confirm-oauth' }, + { id: 10, order: 100, key: 'platform-verify', title: '平台回调验证', sourceId: 'platform-panel', driverId: 'content/platform-panel', command: 'platform-verify' }, ]; const PLUS_PAYPAL_STEP_DEFINITIONS = [ - { id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网' }, - { id: 2, order: 20, key: 'submit-signup-email', title: '注册并输入邮箱' }, - { id: 3, order: 30, key: 'fill-password', title: '填写密码并继续' }, - { id: 4, order: 40, key: 'fetch-signup-code', title: '获取注册验证码' }, - { id: 5, order: 50, key: 'fill-profile', title: '填写姓名和生日' }, - { id: 6, order: 60, key: 'plus-checkout-create', title: '创建 Plus Checkout' }, - { id: 7, order: 70, key: 'plus-checkout-billing', title: '填写账单并提交订单' }, - { id: 8, order: 80, key: 'paypal-approve', title: 'PayPal 登录与授权' }, - { id: 9, order: 90, key: 'plus-checkout-return', title: '订阅回跳确认' }, - { id: 10, order: 100, key: 'oauth-login', title: '刷新 OAuth 并登录' }, - { id: 11, order: 110, key: 'fetch-login-code', title: '获取登录验证码' }, - { id: 12, order: 120, key: 'confirm-oauth', title: '自动确认 OAuth' }, - { id: 13, order: 130, key: 'platform-verify', title: '平台回调验证' }, + { id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网', sourceId: 'chatgpt', driverId: null, command: 'open-chatgpt' }, + { id: 2, order: 20, key: 'submit-signup-email', title: '注册并输入邮箱', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'submit-signup-email' }, + { id: 3, order: 30, key: 'fill-password', title: '填写密码并继续', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'fill-password' }, + { id: 4, order: 40, key: 'fetch-signup-code', title: '获取注册验证码', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'submit-verification-code', mailRuleId: 'openai-signup-code' }, + { id: 5, order: 50, key: 'fill-profile', title: '填写姓名和生日', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'fill-profile' }, + { id: 6, order: 60, key: 'plus-checkout-create', title: '创建 Plus Checkout', sourceId: 'plus-checkout', driverId: 'content/plus-checkout', command: 'plus-checkout-create' }, + { id: 7, order: 70, key: 'plus-checkout-billing', title: '填写账单并提交订单', sourceId: 'plus-checkout', driverId: 'content/plus-checkout', command: 'plus-checkout-billing' }, + { id: 8, order: 80, key: 'paypal-approve', title: 'PayPal 登录与授权', sourceId: 'paypal-flow', driverId: 'content/paypal-flow', command: 'paypal-approve' }, + { id: 9, order: 90, key: 'plus-checkout-return', title: '订阅回跳确认', sourceId: 'plus-checkout', driverId: 'content/plus-checkout', command: 'plus-checkout-return' }, + { id: 10, order: 100, key: 'oauth-login', title: '刷新 OAuth 并登录', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'oauth-login' }, + { id: 11, order: 110, key: 'fetch-login-code', title: '获取登录验证码', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'submit-verification-code', mailRuleId: 'openai-login-code' }, + { id: 12, order: 120, key: 'confirm-oauth', title: '自动确认 OAuth', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'confirm-oauth' }, + { id: 13, order: 130, key: 'platform-verify', title: '平台回调验证', sourceId: 'platform-panel', driverId: 'content/platform-panel', command: 'platform-verify' }, ]; const PLUS_GOPAY_STEP_DEFINITIONS = [ - { id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网' }, - { id: 2, order: 20, key: 'submit-signup-email', title: '注册并输入邮箱' }, - { id: 3, order: 30, key: 'fill-password', title: '填写密码并继续' }, - { id: 4, order: 40, key: 'fetch-signup-code', title: '获取注册验证码' }, - { id: 5, order: 50, key: 'fill-profile', title: '填写姓名和生日' }, - { id: 6, order: 60, key: 'plus-checkout-create', title: '打开 GoPay 订阅页' }, - { id: 7, order: 70, key: 'gopay-subscription-confirm', title: '等待 GoPay 订阅确认' }, - { id: 10, order: 100, key: 'oauth-login', title: '刷新 OAuth 并登录' }, - { id: 11, order: 110, key: 'fetch-login-code', title: '获取登录验证码' }, - { id: 12, order: 120, key: 'confirm-oauth', title: '自动确认 OAuth' }, - { id: 13, order: 130, key: 'platform-verify', title: '平台回调验证' }, + { id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网', sourceId: 'chatgpt', driverId: null, command: 'open-chatgpt' }, + { id: 2, order: 20, key: 'submit-signup-email', title: '注册并输入邮箱', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'submit-signup-email' }, + { id: 3, order: 30, key: 'fill-password', title: '填写密码并继续', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'fill-password' }, + { id: 4, order: 40, key: 'fetch-signup-code', title: '获取注册验证码', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'submit-verification-code', mailRuleId: 'openai-signup-code' }, + { id: 5, order: 50, key: 'fill-profile', title: '填写姓名和生日', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'fill-profile' }, + { id: 6, order: 60, key: 'plus-checkout-create', title: '打开 GoPay 订阅页', sourceId: 'plus-checkout', driverId: 'content/plus-checkout', command: 'plus-checkout-create' }, + { id: 7, order: 70, key: 'gopay-subscription-confirm', title: '等待 GoPay 订阅确认', sourceId: 'gopay-flow', driverId: 'content/gopay-flow', command: 'gopay-subscription-confirm' }, + { id: 10, order: 100, key: 'oauth-login', title: '刷新 OAuth 并登录', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'oauth-login' }, + { id: 11, order: 110, key: 'fetch-login-code', title: '获取登录验证码', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'submit-verification-code', mailRuleId: 'openai-login-code' }, + { id: 12, order: 120, key: 'confirm-oauth', title: '自动确认 OAuth', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'confirm-oauth' }, + { id: 13, order: 130, key: 'platform-verify', title: '平台回调验证', sourceId: 'platform-panel', driverId: 'content/platform-panel', command: 'platform-verify' }, ]; const PLUS_GPC_STEP_DEFINITIONS = [ - { id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网' }, - { id: 2, order: 20, key: 'submit-signup-email', title: '注册并输入邮箱' }, - { id: 3, order: 30, key: 'fill-password', title: '填写密码并继续' }, - { id: 4, order: 40, key: 'fetch-signup-code', title: '获取注册验证码' }, - { id: 5, order: 50, key: 'fill-profile', title: '填写姓名和生日' }, - { id: 6, order: 60, key: 'plus-checkout-create', title: '创建 GPC 订单' }, - { id: 7, order: 70, key: 'plus-checkout-billing', title: '等待 GPC 任务完成' }, - { id: 10, order: 100, key: 'oauth-login', title: '刷新 OAuth 并登录' }, - { id: 11, order: 110, key: 'fetch-login-code', title: '获取登录验证码' }, - { id: 12, order: 120, key: 'confirm-oauth', title: '自动确认 OAuth' }, - { id: 13, order: 130, key: 'platform-verify', title: '平台回调验证' }, + { id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网', sourceId: 'chatgpt', driverId: null, command: 'open-chatgpt' }, + { id: 2, order: 20, key: 'submit-signup-email', title: '注册并输入邮箱', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'submit-signup-email' }, + { id: 3, order: 30, key: 'fill-password', title: '填写密码并继续', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'fill-password' }, + { id: 4, order: 40, key: 'fetch-signup-code', title: '获取注册验证码', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'submit-verification-code', mailRuleId: 'openai-signup-code' }, + { id: 5, order: 50, key: 'fill-profile', title: '填写姓名和生日', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'fill-profile' }, + { id: 6, order: 60, key: 'plus-checkout-create', title: '创建 GPC 订单', sourceId: 'plus-checkout', driverId: 'content/plus-checkout', command: 'plus-checkout-create' }, + { id: 7, order: 70, key: 'plus-checkout-billing', title: '等待 GPC 任务完成', sourceId: 'plus-checkout', driverId: 'content/plus-checkout', command: 'plus-checkout-billing' }, + { id: 10, order: 100, key: 'oauth-login', title: '刷新 OAuth 并登录', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'oauth-login' }, + { id: 11, order: 110, key: 'fetch-login-code', title: '获取登录验证码', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'submit-verification-code', mailRuleId: 'openai-login-code' }, + { id: 12, order: 120, key: 'confirm-oauth', title: '自动确认 OAuth', sourceId: 'openai-auth', driverId: 'content/signup-page', command: 'confirm-oauth' }, + { id: 13, order: 130, key: 'platform-verify', title: '平台回调验证', sourceId: 'platform-panel', driverId: 'content/platform-panel', command: 'platform-verify' }, ]; const PHONE_SIGNUP_TITLE_OVERRIDES = Object.freeze({ @@ -186,6 +186,27 @@ })); } + function cloneNodes(steps = [], options = {}, flowId = DEFAULT_ACTIVE_FLOW_ID) { + const { builder } = getFlowDefinitionBuilder({ activeFlowId: flowId }); + return steps.map((step) => ({ + legacyStepId: Number(step.id), + nodeId: String(step.key || '').trim(), + flowId, + title: builder?.resolveStepTitle ? builder.resolveStepTitle(step, options) : step.title, + displayOrder: Number.isFinite(Number(step.order)) ? Number(step.order) : Number(step.id), + nodeType: 'task', + sourceId: step.sourceId || '', + driverId: step.driverId || '', + executeKey: String(step.key || '').trim(), + command: String(step.command || step.key || '').trim(), + mailRuleId: String(step.mailRuleId || '').trim(), + next: Array.isArray(step.next) ? [...step.next] : [], + retryPolicy: step.retryPolicy && typeof step.retryPolicy === 'object' ? { ...step.retryPolicy } : {}, + recoveryPolicy: step.recoveryPolicy && typeof step.recoveryPolicy === 'object' ? { ...step.recoveryPolicy } : {}, + ui: step.ui && typeof step.ui === 'object' ? { ...step.ui } : {}, + })).filter((node) => Boolean(node.nodeId)); + } + function getSteps(options = {}) { const { flowId, builder } = getFlowDefinitionBuilder(options); if (!builder?.getModeStepDefinitions) { @@ -194,6 +215,23 @@ return cloneSteps(builder.getModeStepDefinitions(options), options, flowId); } + function linkLinearNodes(nodes = []) { + return nodes.map((node, index) => ({ + ...node, + next: Array.isArray(node.next) && node.next.length + ? [...node.next] + : (nodes[index + 1]?.nodeId ? [nodes[index + 1].nodeId] : []), + })); + } + + function getNodes(options = {}) { + const { flowId, builder } = getFlowDefinitionBuilder(options); + if (!builder?.getModeStepDefinitions) { + return []; + } + return linkLinearNodes(cloneNodes(builder.getModeStepDefinitions(options), options, flowId)); + } + function getAllSteps(options = {}) { const { flowId, builder } = getFlowDefinitionBuilder(options); if (!builder?.getAllSteps) { @@ -202,6 +240,18 @@ return cloneSteps(builder.getAllSteps(options), options, flowId); } + function getAllNodes(options = {}) { + const { flowId, builder } = getFlowDefinitionBuilder(options); + if (!builder?.getAllSteps) { + return []; + } + return cloneNodes(builder.getAllSteps(options), options, flowId) + .sort((left, right) => { + if (left.displayOrder !== right.displayOrder) return left.displayOrder - right.displayOrder; + return left.nodeId.localeCompare(right.nodeId); + }); + } + function getPlusPaymentStepTitle(options = {}) { const { builder } = getFlowDefinitionBuilder(options); if (!builder?.getPlusPaymentStepTitle) { @@ -217,6 +267,10 @@ .sort((left, right) => left - right); } + function getNodeIds(options = {}) { + return getNodes(options).map((node) => node.nodeId); + } + function getLastStepId(options = {}) { const ids = getStepIds(options); return ids[ids.length - 1] || 0; @@ -232,6 +286,33 @@ return match ? cloneSteps([match], options, flowId)[0] : null; } + function getNodeById(nodeId, options = {}) { + const normalizedNodeId = String(nodeId || '').trim(); + if (!normalizedNodeId) { + return null; + } + return getNodes(options).find((node) => node.nodeId === normalizedNodeId) || null; + } + + function getNodeByDisplayOrder(displayOrder, options = {}) { + const normalizedOrder = Number(displayOrder); + if (!Number.isFinite(normalizedOrder)) { + return null; + } + return getNodes(options).find((node) => node.displayOrder === normalizedOrder) || null; + } + + function getWorkflow(options = {}) { + const flowId = normalizeActiveFlowId(options?.activeFlowId, DEFAULT_ACTIVE_FLOW_ID); + const nodes = getNodes(options); + return { + flowId, + workflowVersion: 1, + nodes, + nodeIds: nodes.map((node) => node.nodeId), + }; + } + return { DEFAULT_ACTIVE_FLOW_ID, STEP_DEFINITIONS: NORMAL_STEP_DEFINITIONS, @@ -243,12 +324,18 @@ SIGNUP_METHOD_EMAIL, SIGNUP_METHOD_PHONE, getAllSteps, + getAllNodes, getLastStepId, + getNodeByDisplayOrder, + getNodeById, + getNodeIds, + getNodes, getPlusPaymentStepTitle, getRegisteredFlowIds, getStepById, getStepIds, getSteps, + getWorkflow, hasFlow, isPlusModeEnabled, normalizeActiveFlowId, diff --git a/docs/md/步骤与Flow节点化重构开发方案.md b/docs/md/步骤与Flow节点化重构开发方案.md new file mode 100644 index 0000000..4edf830 --- /dev/null +++ b/docs/md/步骤与Flow节点化重构开发方案.md @@ -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// + 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::failed`、`node::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//mail-rules.js` 增加 flow-local rule,并通过 `mailRuleId` 绑定。 +5. 增加 node-first 测试,断言消息、状态、历史、UI 都只使用 `nodeId`。 + +## 13. 后续新增完全不同 flow 的标准入口 + +新增完全不同 flow 时,不要改 OpenAI 的节点树来“塞进去”,而是新增 flow-local 定义: + +```txt +flows// + 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` 定位。 diff --git a/flows/openai/mail-rules.js b/flows/openai/mail-rules.js index 44dc4ca..d51a236 100644 --- a/flows/openai/mail-rules.js +++ b/flows/openai/mail-rules.js @@ -3,6 +3,8 @@ })(typeof self !== 'undefined' ? self : globalThis, function createOpenAiMailRulesModule() { const SIGNUP_CODE_RULE_ID = 'openai-signup-code'; const LOGIN_CODE_RULE_ID = 'openai-login-code'; + const SIGNUP_CODE_NODE_ID = 'fetch-signup-code'; + const LOGIN_CODE_NODE_ID = 'fetch-login-code'; const OPENAI_CODE_PATTERNS = Object.freeze([ Object.freeze({ source: '(?:chatgpt\\s+log-?in\\s+code|enter\\s+this\\s+code)[^0-9]{0,24}(\\d{6})', @@ -56,10 +58,27 @@ && String(state?.mail2925Mode || '').trim().toLowerCase() === 'receive'; } - function getRuleDefinition(step, state = {}) { - const normalizedStep = Number(step) === 4 ? 4 : 8; + function resolveVerificationNodeId(input) { + const directNodeId = String(input?.nodeId || input || '').trim(); + if (directNodeId === SIGNUP_CODE_NODE_ID || directNodeId === LOGIN_CODE_NODE_ID) { + return directNodeId; + } + return Number(input?.step ?? input) === 4 ? SIGNUP_CODE_NODE_ID : LOGIN_CODE_NODE_ID; + } + + function getVisibleStepForNode(nodeId, state = {}) { + if (nodeId === SIGNUP_CODE_NODE_ID) { + return 4; + } + const explicitStep = Number(state?.visibleStep || state?.step); + return Number.isInteger(explicitStep) && explicitStep > 0 ? explicitStep : 8; + } + + function getRuleDefinition(input, state = {}) { + const nodeId = resolveVerificationNodeId(input); + const normalizedStep = getVisibleStepForNode(nodeId, state); const mail2925Provider = isMail2925Provider(state); - const signupStep = normalizedStep === 4; + const signupStep = nodeId === SIGNUP_CODE_NODE_ID; const targetEmail = signupStep ? state?.email : (String(state?.step8VerificationTargetEmail || '').trim() || state?.email); @@ -67,6 +86,7 @@ return { flowId: 'openai', ruleId: signupStep ? SIGNUP_CODE_RULE_ID : LOGIN_CODE_RULE_ID, + nodeId, step: normalizedStep, artifactType: 'code', codePatterns: OPENAI_CODE_PATTERNS, @@ -90,22 +110,34 @@ }; } - function buildVerificationPollPayload(step, state = {}, overrides = {}) { + function getRuleDefinitionForNode(nodeId, state = {}) { + return getRuleDefinition({ nodeId }, state); + } + + function buildVerificationPollPayload(input, state = {}, overrides = {}) { return { - ...getRuleDefinition(step, state), + ...getRuleDefinition(input, state), ...(overrides || {}), }; } + function buildVerificationPollPayloadForNode(nodeId, state = {}, overrides = {}) { + return buildVerificationPollPayload({ nodeId }, state, overrides); + } + return { buildVerificationPollPayload, + buildVerificationPollPayloadForNode, getRuleDefinition, + getRuleDefinitionForNode, }; } return { LOGIN_CODE_RULE_ID, + LOGIN_CODE_NODE_ID, SIGNUP_CODE_RULE_ID, + SIGNUP_CODE_NODE_ID, createOpenAiMailRules, }; }); diff --git a/shared/source-registry.js b/shared/source-registry.js index 6d7ce66..4e67d63 100644 --- a/shared/source-registry.js +++ b/shared/source-registry.js @@ -96,6 +96,15 @@ driverId: 'content/vps-panel', cleanupScopes: [], }, + 'platform-panel': { + flowId: 'openai', + kind: 'virtual-page', + label: '平台回调面板', + readyPolicy: 'disabled', + family: 'platform-panel-family', + driverId: 'content/platform-panel', + cleanupScopes: [], + }, 'sub2api-panel': { flowId: 'openai', kind: 'panel-page', @@ -156,13 +165,13 @@ 'content/signup-page': { sourceId: 'openai-auth', commands: [ - 'OPEN_SIGNUP', - 'SUBMIT_SIGNUP_IDENTIFIER', - 'SUBMIT_PASSWORD', - 'SUBMIT_PROFILE', - 'SUBMIT_LOGIN_CODE', - 'SUBMIT_PHONE_CODE', - 'DETECT_AUTH_STATE', + 'submit-signup-email', + 'fill-password', + 'fill-profile', + 'oauth-login', + 'submit-verification-code', + 'confirm-oauth', + 'detect-auth-state', ], }, 'content/qq-mail': { @@ -191,23 +200,27 @@ }, 'content/sub2api-panel': { sourceId: 'sub2api-panel', - commands: ['OPEN_PANEL', 'FETCH_OAUTH_URL', 'VERIFY_PLATFORM'], + commands: ['open-panel', 'fetch-oauth-url', 'platform-verify'], }, 'content/vps-panel': { sourceId: 'vps-panel', - commands: ['OPEN_PANEL', 'FETCH_OAUTH_URL'], + commands: ['open-panel', 'fetch-oauth-url', 'platform-verify'], + }, + 'content/platform-panel': { + sourceId: 'platform-panel', + commands: ['platform-verify', 'fetch-oauth-url'], }, 'content/plus-checkout': { sourceId: 'plus-checkout', - commands: ['CREATE_CHECKOUT', 'FILL_CHECKOUT'], + commands: ['plus-checkout-create', 'plus-checkout-billing', 'plus-checkout-return'], }, 'content/paypal-flow': { sourceId: 'paypal-flow', - commands: ['APPROVE_PAYPAL'], + commands: ['paypal-approve'], }, 'content/gopay-flow': { sourceId: 'gopay-flow', - commands: ['APPROVE_GOPAY'], + commands: ['gopay-subscription-confirm'], }, }); @@ -296,6 +309,15 @@ }; } + function driverAcceptsCommand(sourceOrDriverId, command) { + const normalizedCommand = normalizeSourceId(command); + if (!normalizedCommand) { + return false; + } + const driver = getDriverMeta(sourceOrDriverId); + return Array.isArray(driver?.commands) && driver.commands.includes(normalizedCommand); + } + function isSignupPageHost(hostname = '') { return AUTH_PAGE_HOSTS.has(String(hostname || '').toLowerCase()); } @@ -414,6 +436,7 @@ getCleanupOwnerSource, getDriverIdForSource, getDriverMeta, + driverAcceptsCommand, getSourceKeys, getSourceLabel, getSourceMeta, diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 3d080a2..f54853f 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -543,9 +543,16 @@ let stepDefinitions = getStepDefinitionsForMode(false, { plusPaymentMethod: currentPlusPaymentMethod, signupMethod: currentSignupMethod, }); +let workflowNodes = getWorkflowNodesForMode(false, { + plusPaymentMethod: currentPlusPaymentMethod, + signupMethod: currentSignupMethod, +}); let STEP_IDS = stepDefinitions.map((step) => Number(step.id)).filter(Number.isFinite); let STEP_DEFAULT_STATUSES = Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending'])); let SKIPPABLE_STEPS = new Set(STEP_IDS); +let NODE_IDS = workflowNodes.map((node) => String(node.nodeId || '').trim()).filter(Boolean); +let NODE_DEFAULT_STATUSES = Object.fromEntries(NODE_IDS.map((nodeId) => [nodeId, 'pending'])); +let SKIPPABLE_NODES = new Set(NODE_IDS); const AUTO_DELAY_MIN_MINUTES = 1; const AUTO_DELAY_MAX_MINUTES = 1440; const AUTO_DELAY_DEFAULT_MINUTES = 30; @@ -816,6 +823,42 @@ function getStepDefinitionsForMode(plusModeEnabled = false, options = {}) { }); } +function getWorkflowNodesForMode(plusModeEnabled = false, options = {}) { + const defaultFlowId = typeof DEFAULT_ACTIVE_FLOW_ID !== 'undefined' ? DEFAULT_ACTIVE_FLOW_ID : 'openai'; + const defaultMethod = typeof DEFAULT_PLUS_PAYMENT_METHOD !== 'undefined' ? DEFAULT_PLUS_PAYMENT_METHOD : 'paypal'; + const rawPaymentMethod = typeof options === 'string' + ? options + : (options.plusPaymentMethod || currentPlusPaymentMethod || defaultMethod); + const rawSignupMethod = typeof options === 'string' + ? currentSignupMethod + : (options.signupMethod || currentSignupMethod || DEFAULT_SIGNUP_METHOD); + const activeFlowId = typeof options === 'string' + ? ((typeof latestState !== 'undefined' ? latestState?.activeFlowId : '') || defaultFlowId) + : (options.activeFlowId || (typeof latestState !== 'undefined' ? latestState?.activeFlowId : '') || defaultFlowId); + const nodes = window.MultiPageStepDefinitions?.getNodes?.({ + activeFlowId: String(activeFlowId || '').trim().toLowerCase() || defaultFlowId, + plusModeEnabled, + plusPaymentMethod: normalizePlusPaymentMethod(rawPaymentMethod), + signupMethod: normalizeSignupMethod(rawSignupMethod), + }); + if (Array.isArray(nodes) && nodes.length) { + return nodes.slice().sort((left, right) => { + const leftOrder = Number.isFinite(Number(left.displayOrder)) ? Number(left.displayOrder) : Number(left.legacyStepId); + const rightOrder = Number.isFinite(Number(right.displayOrder)) ? Number(right.displayOrder) : Number(right.legacyStepId); + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return String(left.nodeId || '').localeCompare(String(right.nodeId || '')); + }); + } + + return getStepDefinitionsForMode(plusModeEnabled, options).map((step) => ({ + legacyStepId: Number(step.id), + nodeId: String(step.key || '').trim(), + title: step.title, + displayOrder: Number.isFinite(Number(step.order)) ? Number(step.order) : Number(step.id), + executeKey: String(step.key || '').trim(), + })).filter((node) => node.nodeId); +} + function getStepIdByKeyForCurrentMode(stepKey = '') { const normalizedKey = String(stepKey || '').trim(); if (!normalizedKey) { @@ -825,6 +868,29 @@ function getStepIdByKeyForCurrentMode(stepKey = '') { return Number(match?.id) || 0; } +function getNodeIdByStepForCurrentMode(step) { + const numericStep = Number(step); + const node = (workflowNodes || []).find((candidate) => Number(candidate?.legacyStepId) === numericStep); + if (node?.nodeId) { + return String(node.nodeId).trim(); + } + const definition = (stepDefinitions || []).find((candidate) => Number(candidate?.id) === numericStep); + return String(definition?.key || '').trim(); +} + +function getStepIdByNodeIdForCurrentMode(nodeId = '') { + const normalizedNodeId = String(nodeId || '').trim(); + if (!normalizedNodeId) { + return 0; + } + const node = (workflowNodes || []).find((candidate) => String(candidate?.nodeId || '').trim() === normalizedNodeId); + const legacyStepId = Number(node?.legacyStepId); + if (Number.isInteger(legacyStepId) && legacyStepId > 0) { + return legacyStepId; + } + return getStepIdByKeyForCurrentMode(normalizedNodeId); +} + function rebuildStepDefinitionState(plusModeEnabled = false, options = {}) { currentPlusModeEnabled = Boolean(plusModeEnabled); const defaultMethod = typeof DEFAULT_PLUS_PAYMENT_METHOD !== 'undefined' ? DEFAULT_PLUS_PAYMENT_METHOD : 'paypal'; @@ -841,9 +907,33 @@ function rebuildStepDefinitionState(plusModeEnabled = false, options = {}) { plusPaymentMethod: currentPlusPaymentMethod, signupMethod: currentSignupMethod, }); + const nextWorkflowNodes = typeof getWorkflowNodesForMode === 'function' + ? getWorkflowNodesForMode(currentPlusModeEnabled, { + activeFlowId: options?.activeFlowId, + plusPaymentMethod: currentPlusPaymentMethod, + signupMethod: currentSignupMethod, + }) + : stepDefinitions.map((step) => ({ + legacyStepId: Number(step.id), + nodeId: String(step.key || step.id || '').trim(), + title: step.title, + displayOrder: Number.isFinite(Number(step.order)) ? Number(step.order) : Number(step.id), + })); + if (typeof workflowNodes !== 'undefined') { + workflowNodes = nextWorkflowNodes; + } STEP_IDS = stepDefinitions.map((step) => Number(step.id)).filter(Number.isFinite); STEP_DEFAULT_STATUSES = Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending'])); SKIPPABLE_STEPS = new Set(STEP_IDS); + if (typeof NODE_IDS !== 'undefined') { + NODE_IDS = nextWorkflowNodes.map((node) => String(node.nodeId || '').trim()).filter(Boolean); + } + if (typeof NODE_DEFAULT_STATUSES !== 'undefined') { + NODE_DEFAULT_STATUSES = Object.fromEntries((typeof NODE_IDS !== 'undefined' ? NODE_IDS : []).map((nodeId) => [nodeId, 'pending'])); + } + if (typeof SKIPPABLE_NODES !== 'undefined') { + SKIPPABLE_NODES = new Set(typeof NODE_IDS !== 'undefined' ? NODE_IDS : []); + } } const CONTRIBUTION_CONTENT_PROMPT_DISMISSED_VERSION_STORAGE_KEY = 'multipage-contribution-content-prompt-dismissed-version'; const AUTO_RUN_FALLBACK_RISK_WARNING_MIN_RUNS = 3; @@ -1189,7 +1279,7 @@ function shouldAttachAutomationWindow(message = {}) { return false; } return [ - 'EXECUTE_STEP', + 'EXECUTE_NODE', 'AUTO_RUN', 'SCHEDULE_AUTO_RUN', 'RESUME_AUTO_RUN', @@ -2111,31 +2201,64 @@ function isDoneStatus(status) { return status === 'completed' || status === 'manual_completed' || status === 'skipped'; } +function escapeCssValue(value = '') { + const raw = String(value || ''); + if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { + return CSS.escape(raw); + } + return raw.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function getNodeStatuses(state = latestState) { + const merged = { ...NODE_DEFAULT_STATUSES, ...(state?.nodeStatuses || {}) }; + return Object.fromEntries(NODE_IDS.map((nodeId) => [nodeId, merged[nodeId] || 'pending'])); +} + function getStepStatuses(state = latestState) { - const merged = { ...STEP_DEFAULT_STATUSES, ...(state?.stepStatuses || {}) }; + const merged = { ...STEP_DEFAULT_STATUSES }; + if (typeof getNodeStatuses === 'function') { + const nodeStatuses = getNodeStatuses(state); + for (const [nodeId, status] of Object.entries(nodeStatuses)) { + const step = getStepIdByNodeIdForCurrentMode(nodeId); + if (step) { + merged[step] = status || 'pending'; + } + } + } return Object.fromEntries(STEP_IDS.map((stepId) => [stepId, merged[stepId] || 'pending'])); } -function getFirstUnfinishedStep(state = latestState) { - const statuses = getStepStatuses(state); - for (const step of STEP_IDS) { - if (!isDoneStatus(statuses[step])) { - return step; +function getFirstUnfinishedNode(state = latestState) { + const statuses = getNodeStatuses(state); + for (const nodeId of NODE_IDS) { + if (!isDoneStatus(statuses[nodeId])) { + 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) { - const statuses = getStepStatuses(state); - return Object.entries(statuses) - .filter(([, status]) => status === 'running') - .map(([step]) => Number(step)) + return getRunningNodes(state) + .map((nodeId) => getStepIdByNodeIdForCurrentMode(nodeId)) + .filter((step) => Number.isInteger(step) && step > 0) .sort((a, b) => a - b); } function hasSavedProgress(state = latestState) { - const statuses = getStepStatuses(state); + const statuses = getNodeStatuses(state); return Object.values(statuses).some((status) => status !== 'pending'); } @@ -2150,14 +2273,14 @@ function shouldOfferAutoModeChoice(state = latestState) { } function syncLatestState(nextState) { - const mergedStepStatuses = nextState?.stepStatuses - ? { ...STEP_DEFAULT_STATUSES, ...(latestState?.stepStatuses || {}), ...nextState.stepStatuses } - : getStepStatuses(latestState); + const mergedNodeStatuses = nextState?.nodeStatuses + ? { ...NODE_DEFAULT_STATUSES, ...(latestState?.nodeStatuses || {}), ...nextState.nodeStatuses } + : getNodeStatuses(latestState); latestState = { ...(latestState || {}), ...(nextState || {}), - stepStatuses: mergedStepStatuses, + nodeStatuses: mergedNodeStatuses, }; renderAccountRecords(latestState); @@ -3360,8 +3483,20 @@ function collectSettingsPayload() { const currentPhoneSmsMaxPriceValue = typeof inputHeroSmsMaxPrice !== 'undefined' && inputHeroSmsMaxPrice ? normalizePhoneSmsMaxPriceValue(inputHeroSmsMaxPrice.value, phoneSmsProviderValue) : ''; + const normalizePhoneSmsMinPriceValueSafe = typeof normalizePhoneSmsMinPriceValue === 'function' + ? normalizePhoneSmsMinPriceValue + : ((value = '', provider = phoneSmsProviderValue) => { + const normalizedProvider = normalizePhoneSmsProvider(provider); + if (normalizedProvider === PHONE_SMS_PROVIDER_FIVE_SIM && typeof normalizeFiveSimMaxPriceValue === 'function') { + return normalizeFiveSimMaxPriceValue(value); + } + if (typeof normalizeHeroSmsMaxPriceValue === 'function') { + return normalizeHeroSmsMaxPriceValue(value); + } + return String(value || '').trim(); + }); const currentPhoneSmsMinPriceValue = typeof inputHeroSmsMinPrice !== 'undefined' && inputHeroSmsMinPrice - ? normalizePhoneSmsMinPriceValue(inputHeroSmsMinPrice.value, phoneSmsProviderValue) + ? normalizePhoneSmsMinPriceValueSafe(inputHeroSmsMinPrice.value, phoneSmsProviderValue) : ''; const heroSmsMaxPriceValue = phoneSmsProviderValue === PHONE_SMS_PROVIDER_HERO_SMS ? currentPhoneSmsMaxPriceValue @@ -3370,11 +3505,11 @@ function collectSettingsPayload() { ? currentPhoneSmsMaxPriceValue : normalizeFiveSimMaxPriceValue(latestState?.fiveSimMaxPrice || ''); const heroSmsMinPriceValue = phoneSmsProviderValue === PHONE_SMS_PROVIDER_FIVE_SIM - ? normalizePhoneSmsMinPriceValue(latestState?.heroSmsMinPrice || '', PHONE_SMS_PROVIDER_HERO_SMS) + ? normalizePhoneSmsMinPriceValueSafe(latestState?.heroSmsMinPrice || '', PHONE_SMS_PROVIDER_HERO_SMS) : currentPhoneSmsMinPriceValue; const fiveSimMinPriceValue = phoneSmsProviderValue === PHONE_SMS_PROVIDER_FIVE_SIM ? currentPhoneSmsMinPriceValue - : normalizePhoneSmsMinPriceValue(latestState?.fiveSimMinPrice || '', PHONE_SMS_PROVIDER_FIVE_SIM); + : normalizePhoneSmsMinPriceValueSafe(latestState?.fiveSimMinPrice || '', PHONE_SMS_PROVIDER_FIVE_SIM); const defaultFiveSimProduct = typeof DEFAULT_FIVE_SIM_PRODUCT !== 'undefined' ? DEFAULT_FIVE_SIM_PRODUCT : 'openai'; @@ -8623,6 +8758,7 @@ function initializeManualStepActions() { return; } const step = Number(row.dataset.step); + const nodeId = String(row.dataset.nodeId || getNodeIdByStepForCurrentMode(step) || '').trim(); const statusEl = row.querySelector('.step-status'); if (!statusEl) return; @@ -8633,13 +8769,14 @@ function initializeManualStepActions() { manualBtn.type = 'button'; manualBtn.className = 'step-manual-btn'; manualBtn.dataset.step = String(step); - manualBtn.title = '跳过此步'; - manualBtn.setAttribute('aria-label', `跳过步骤 ${step}`); + manualBtn.dataset.nodeId = nodeId; + manualBtn.title = '跳过此节点'; + manualBtn.setAttribute('aria-label', `跳过节点 ${nodeId || step}`); manualBtn.innerHTML = ''; manualBtn.addEventListener('click', async (event) => { event.stopPropagation(); try { - await handleSkipStep(step); + await handleSkipNode(nodeId || getNodeIdByStepForCurrentMode(step)); } catch (err) { showToast(err.message, 'error'); } @@ -8654,16 +8791,20 @@ function initializeManualStepActions() { function renderStepsList() { if (!stepsList) return; - stepsList.innerHTML = stepDefinitions.map((step) => ` -
-
${step.id}
- - + stepsList.innerHTML = workflowNodes.map((node) => { + const step = getStepIdByNodeIdForCurrentMode(node.nodeId); + const nodeId = String(node.nodeId || '').trim(); + return ` +
+
${step || node.displayOrder || ''}
+ +
- `).join(''); + `; + }).join(''); if (stepsProgress) { - stepsProgress.textContent = `0 / ${STEP_IDS.length}`; + stepsProgress.textContent = `0 / ${NODE_IDS.length}`; } initializeManualStepActions(); @@ -8728,6 +8869,7 @@ function applySettingsState(state) { signupMethod: normalizeSignupMethod(state?.signupMethod || DEFAULT_SIGNUP_METHOD), }; syncStepDefinitionsForMode(stepDefinitionState.plusModeEnabled, { + activeFlowId: state?.flowId || state?.activeFlowId, plusPaymentMethod: state?.plusPaymentMethod, signupMethod: stepDefinitionState.signupMethod, }); @@ -9282,9 +9424,9 @@ async function restoreState() { displayLocalhostUrl.textContent = state.localhostUrl; displayLocalhostUrl.classList.add('has-value'); } - if (state.stepStatuses) { - for (const [step, status] of Object.entries(state.stepStatuses)) { - updateStepUI(Number(step), status); + if (state.nodeStatuses) { + for (const [nodeId, status] of Object.entries(state.nodeStatuses)) { + updateNodeUI(nodeId, status); } } @@ -10911,21 +11053,54 @@ function updatePanelModeUI() { // UI Updates // ============================================================ -function updateStepUI(step, status) { +function updateNodeUI(nodeId, status) { + const normalizedNodeId = String(nodeId || '').trim(); + if (!normalizedNodeId) return; syncLatestState({ - stepStatuses: { - ...getStepStatuses(), - [step]: status, + nodeStatuses: { + ...getNodeStatuses(), + [normalizedNodeId]: status, }, }); - renderSingleStepStatus(step, status); + renderSingleNodeStatus(normalizedNodeId, status); updateButtonStates(); updateProgressCounter(); updateConfigMenuControls(); } +function updateStepUI(step, status) { + const nodeId = getNodeIdByStepForCurrentMode(step); + if (nodeId) { + updateNodeUI(nodeId, status); + return; + } + updateButtonStates(); + updateProgressCounter(); + updateConfigMenuControls(); +} + +function renderSingleNodeStatus(nodeId, status) { + const normalizedStatus = status || 'pending'; + const normalizedNodeId = String(nodeId || '').trim(); + const selectorNodeId = escapeCssValue(normalizedNodeId); + const statusEl = document.querySelector(`.step-status[data-node-id="${selectorNodeId}"]`); + const row = document.querySelector(`.step-row[data-node-id="${selectorNodeId}"]`); + + if (statusEl) statusEl.textContent = STATUS_ICONS[normalizedStatus] || ''; + if (row) { + row.className = `step-row ${normalizedStatus}`; + } +} + function renderSingleStepStatus(step, status) { + const nodeId = typeof getNodeIdByStepForCurrentMode === 'function' + ? getNodeIdByStepForCurrentMode(step) + : ''; + if (nodeId && typeof renderSingleNodeStatus === 'function') { + renderSingleNodeStatus(nodeId, status); + return; + } const normalizedStatus = status || 'pending'; const statusEl = document.querySelector(`.step-status[data-step="${step}"]`); const row = document.querySelector(`.step-row[data-step="${step}"]`); @@ -10937,20 +11112,32 @@ function renderSingleStepStatus(step, status) { } function renderStepStatuses(state = latestState) { - const statuses = getStepStatuses(state); - for (const step of STEP_IDS) { - renderSingleStepStatus(step, statuses[step]); + if (typeof getNodeStatuses === 'function' && typeof NODE_IDS !== 'undefined') { + const statuses = getNodeStatuses(state); + for (const nodeId of NODE_IDS) { + renderSingleNodeStatus(nodeId, statuses[nodeId]); + } + } else { + const statuses = getStepStatuses(state); + for (const step of STEP_IDS) { + renderSingleStepStatus(step, statuses[step]); + } } updateProgressCounter(); } function updateProgressCounter() { + if (typeof getNodeStatuses === 'function' && typeof NODE_IDS !== 'undefined') { + const completed = Object.values(getNodeStatuses()).filter(isDoneStatus).length; + stepsProgress.textContent = `${completed} / ${NODE_IDS.length}`; + return; + } const completed = Object.values(getStepStatuses()).filter(isDoneStatus).length; stepsProgress.textContent = `${completed} / ${STEP_IDS.length}`; } function updateButtonStates() { - const statuses = getStepStatuses(); + const statuses = getNodeStatuses(); const anyRunning = Object.values(statuses).some(s => s === 'running'); const autoLocked = isAutoRunLockedPhase(); const autoScheduled = isAutoRunScheduledPhase(); @@ -10958,47 +11145,49 @@ function updateButtonStates() { ? selectIcloudTargetMailboxType?.value : latestState?.icloudTargetMailboxType; - for (const step of STEP_IDS) { - const btn = document.querySelector(`.step-btn[data-step="${step}"]`); + for (const nodeId of NODE_IDS) { + const step = getStepIdByNodeIdForCurrentMode(nodeId); + const btn = document.querySelector(`.step-btn[data-node-id="${escapeCssValue(nodeId)}"]`); if (!btn) continue; if (anyRunning || autoLocked || autoScheduled) { btn.disabled = true; - } else if (step === 1) { + } else if (NODE_IDS.indexOf(nodeId) === 0) { btn.disabled = false; } else { - const currentIndex = STEP_IDS.indexOf(step); - const prevStep = currentIndex > 0 ? STEP_IDS[currentIndex - 1] : null; - const prevStatus = prevStep === null ? 'completed' : statuses[prevStep]; - const currentStatus = statuses[step]; + const currentIndex = NODE_IDS.indexOf(nodeId); + const prevNodeId = currentIndex > 0 ? NODE_IDS[currentIndex - 1] : null; + const prevStatus = prevNodeId === null ? 'completed' : statuses[prevNodeId]; + const currentStatus = statuses[nodeId]; btn.disabled = !(isDoneStatus(prevStatus) || currentStatus === 'failed' || isDoneStatus(currentStatus) || currentStatus === 'stopped'); } } document.querySelectorAll('.step-manual-btn').forEach((btn) => { const step = Number(btn.dataset.step); - const currentStatus = statuses[step]; - const currentIndex = STEP_IDS.indexOf(step); - const prevStep = currentIndex > 0 ? STEP_IDS[currentIndex - 1] : null; - const prevStatus = prevStep === null ? 'completed' : statuses[prevStep]; + const nodeId = String(btn.dataset.nodeId || getNodeIdByStepForCurrentMode(step) || '').trim(); + const currentStatus = statuses[nodeId]; + const currentIndex = NODE_IDS.indexOf(nodeId); + const prevNodeId = currentIndex > 0 ? NODE_IDS[currentIndex - 1] : null; + const prevStatus = prevNodeId === null ? 'completed' : statuses[prevNodeId]; - if (!SKIPPABLE_STEPS.has(step) || anyRunning || autoLocked || autoScheduled || currentStatus === 'running' || isDoneStatus(currentStatus)) { + if (!SKIPPABLE_NODES.has(nodeId) || anyRunning || autoLocked || autoScheduled || currentStatus === 'running' || isDoneStatus(currentStatus)) { btn.style.display = 'none'; btn.disabled = true; btn.title = '当前不可跳过'; return; } - if (prevStep !== null && !isDoneStatus(prevStatus)) { + if (prevNodeId !== null && !isDoneStatus(prevStatus)) { btn.style.display = 'none'; btn.disabled = true; - btn.title = `请先完成步骤 ${prevStep}`; + btn.title = `请先完成节点 ${prevNodeId}`; return; } btn.style.display = ''; btn.disabled = false; - btn.title = `跳过步骤 ${step}`; + btn.title = `跳过节点 ${nodeId}`; }); btnReset.disabled = anyRunning || autoScheduled || isAutoRunPausedPhase() || autoLocked; @@ -11032,9 +11221,10 @@ function updateStopButtonState(active) { } function updateStatusDisplay(state) { - if (!state || !state.stepStatuses) return; + if (!state || !state.nodeStatuses) return; statusBar.className = 'status-bar'; + const nodeStatuses = getNodeStatuses(state); const countdown = getActiveAutoRunCountdown(); if (countdown) { @@ -11064,17 +11254,17 @@ function updateStatusDisplay(state) { } if (isAutoRunWaitingStepPhase()) { - const runningSteps = getRunningSteps(state); - displayStatus.textContent = runningSteps.length - ? `自动等待步骤 ${runningSteps.join(', ')} 完成后继续${getAutoRunLabel()}` + const runningNodes = getRunningNodes(state); + displayStatus.textContent = runningNodes.length + ? `自动等待节点 ${runningNodes.join(', ')} 完成后继续${getAutoRunLabel()}` : `自动正在按最新进度准备继续${getAutoRunLabel()}`; statusBar.classList.add('running'); return; } - const running = Object.entries(state.stepStatuses).find(([, s]) => s === 'running'); + const running = Object.entries(nodeStatuses).find(([, s]) => s === 'running'); if (running) { - displayStatus.textContent = `步骤 ${running[0]} 运行中...`; + displayStatus.textContent = `节点 ${running[0]} 运行中...`; statusBar.classList.add('running'); return; } @@ -11085,32 +11275,32 @@ function updateStatusDisplay(state) { return; } - const failed = Object.entries(state.stepStatuses).find(([, s]) => s === 'failed'); + const failed = Object.entries(nodeStatuses).find(([, s]) => s === 'failed'); if (failed) { - displayStatus.textContent = `步骤 ${failed[0]} 失败`; + displayStatus.textContent = `节点 ${failed[0]} 失败`; statusBar.classList.add('failed'); return; } - const stopped = Object.entries(state.stepStatuses).find(([, s]) => s === 'stopped'); + const stopped = Object.entries(nodeStatuses).find(([, s]) => s === 'stopped'); if (stopped) { - displayStatus.textContent = `步骤 ${stopped[0]} 已停止`; + displayStatus.textContent = `节点 ${stopped[0]} 已停止`; statusBar.classList.add('stopped'); return; } - const lastCompleted = Object.entries(state.stepStatuses) + const lastCompleted = Object.entries(nodeStatuses) .filter(([, s]) => isDoneStatus(s)) - .map(([k]) => Number(k)) - .sort((a, b) => b - a)[0]; + .map(([nodeId]) => nodeId) + .sort((left, right) => NODE_IDS.indexOf(right) - NODE_IDS.indexOf(left))[0]; - if (lastCompleted === STEP_IDS[STEP_IDS.length - 1]) { - displayStatus.textContent = (state.stepStatuses[lastCompleted] === 'manual_completed' || state.stepStatuses[lastCompleted] === 'skipped') ? '全部步骤已跳过/完成' : '全部步骤已完成'; + if (lastCompleted === NODE_IDS[NODE_IDS.length - 1]) { + displayStatus.textContent = (nodeStatuses[lastCompleted] === 'manual_completed' || nodeStatuses[lastCompleted] === 'skipped') ? '全部节点已跳过/完成' : '全部节点已完成'; statusBar.classList.add('completed'); } else if (lastCompleted) { - displayStatus.textContent = (state.stepStatuses[lastCompleted] === 'manual_completed' || state.stepStatuses[lastCompleted] === 'skipped') - ? `步骤 ${lastCompleted} 已跳过` - : `步骤 ${lastCompleted} 已完成`; + displayStatus.textContent = (nodeStatuses[lastCompleted] === 'manual_completed' || nodeStatuses[lastCompleted] === 'skipped') + ? `节点 ${lastCompleted} 已跳过` + : `节点 ${lastCompleted} 已完成`; } else { displayStatus.textContent = '就绪'; } @@ -11842,7 +12032,11 @@ async function maybeTakeoverAutoRun(actionLabel) { return true; } -async function handleSkipStep(step) { +async function handleSkipNode(nodeId) { + const normalizedNodeId = String(nodeId || '').trim(); + if (!normalizedNodeId) { + throw new Error('缺少要跳过的节点。'); + } if (isAutoRunPausedPhase()) { const takeoverResponse = await chrome.runtime.sendMessage({ type: 'TAKEOVER_AUTO_RUN', @@ -11857,16 +12051,27 @@ async function handleSkipStep(step) { await persistCurrentSettingsForAction(); const response = await chrome.runtime.sendMessage({ - type: 'SKIP_STEP', + type: 'SKIP_NODE', source: 'sidepanel', - payload: { step }, + payload: { + nodeId: normalizedNodeId, + step: getStepIdByNodeIdForCurrentMode(normalizedNodeId), + }, }); if (response?.error) { throw new Error(response.error); } - showToast(`步骤 ${step} 已跳过`, 'success', 2200); + showToast(`节点 ${normalizedNodeId} 已跳过`, 'success', 2200); +} + +async function handleSkipStep(step) { + const nodeId = getNodeIdByStepForCurrentMode(step); + if (!nodeId) { + throw new Error(`无效步骤:${step}`); + } + return handleSkipNode(nodeId); } // ============================================================ @@ -11880,7 +12085,8 @@ stepsList?.addEventListener('click', async (event) => { } try { const step = Number(btn.dataset.step); - if (!(await maybeTakeoverAutoRun(`执行步骤 ${step}`))) { + const nodeId = String(btn.dataset.nodeId || getNodeIdByStepForCurrentMode(step) || '').trim(); + if (!(await maybeTakeoverAutoRun(`执行节点 ${nodeId || step}`))) { return; } await persistCurrentSettingsForAction(); @@ -11898,12 +12104,12 @@ stepsList?.addEventListener('click', async (event) => { syncLatestState({ customPassword: inputPassword.value }); } if (shouldExecuteStep3WithSignupPhoneIdentity(latestState)) { - const response = await sendSidepanelMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } }); + const response = await sendSidepanelMessage({ type: 'EXECUTE_NODE', source: 'sidepanel', payload: { nodeId } }); if (response?.error) { throw new Error(response.error); } } else if (selectMailProvider.value === 'hotmail-api' || isLuckmailProvider()) { - const response = await sendSidepanelMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } }); + const response = await sendSidepanelMessage({ type: 'EXECUTE_NODE', source: 'sidepanel', payload: { nodeId } }); if (response?.error) { throw new Error(response.error); } @@ -11913,7 +12119,7 @@ stepsList?.addEventListener('click', async (event) => { showToast(selectMailProvider.value === GMAIL_PROVIDER ? '请先填写 Gmail 原邮箱。' : '请先填写 2925 邮箱前缀。', 'warn'); return; } - const response = await sendSidepanelMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step, emailPrefix } }); + const response = await sendSidepanelMessage({ type: 'EXECUTE_NODE', source: 'sidepanel', payload: { nodeId, emailPrefix } }); if (response?.error) { throw new Error(response.error); } @@ -11934,13 +12140,13 @@ stepsList?.addEventListener('click', async (event) => { if (!validateCurrentRegistrationEmail(email, { showToastOnFailure: true })) { return; } - const response = await sendSidepanelMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step, email } }); + const response = await sendSidepanelMessage({ type: 'EXECUTE_NODE', source: 'sidepanel', payload: { nodeId, email } }); if (response?.error) { throw new Error(response.error); } } } else { - const response = await sendSidepanelMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } }); + const response = await sendSidepanelMessage({ type: 'EXECUTE_NODE', source: 'sidepanel', payload: { nodeId } }); if (response?.error) { throw new Error(response.error); } @@ -12323,7 +12529,7 @@ btnReset.addEventListener('click', async () => { await chrome.runtime.sendMessage({ type: 'RESET', source: 'sidepanel' }); syncLatestState({ - stepStatuses: STEP_DEFAULT_STATUSES, + nodeStatuses: NODE_DEFAULT_STATUSES, currentHotmailAccountId: null, currentLuckmailPurchase: null, currentLuckmailMailCursor: null, @@ -14222,9 +14428,9 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { } break; - case 'STEP_STATUS_CHANGED': { - const { step, status } = message.payload; - updateStepUI(step, status); + case 'NODE_STATUS_CHANGED': { + const { nodeId, status } = message.payload; + updateNodeUI(nodeId, status); chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' }).then(state => { syncLatestState(state); syncAutoRunState(state); @@ -14253,7 +14459,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { localhostUrl: null, email: null, password: null, - stepStatuses: STEP_DEFAULT_STATUSES, + nodeStatuses: NODE_DEFAULT_STATUSES, logs: [], scheduledAutoRunAt: null, autoRunCountdownAt: null, diff --git a/tests/auto-run-add-phone-stop.test.js b/tests/auto-run-add-phone-stop.test.js index bcc8cd7..2aaa055 100644 --- a/tests/auto-run-add-phone-stop.test.js +++ b/tests/auto-run-add-phone-stop.test.js @@ -5,6 +5,119 @@ const fs = require('node:fs'); const source = fs.readFileSync('background/auto-run-controller.js', 'utf8'); const globalScope = {}; const api = new Function('self', `${source}; return self.MultiPageBackgroundAutoRunController;`)(globalScope); +const rawCreateAutoRunController = api.createAutoRunController.bind(api); + +const TEST_STEP_NODE_IDS = Object.freeze({ + 1: 'open-chatgpt', + 2: 'submit-signup-email', + 3: 'fill-password', + 4: 'fetch-signup-code', + 5: 'fill-profile', + 6: 'wait-registration-success', + 7: 'oauth-login', + 8: 'fetch-login-code', + 9: 'confirm-oauth', + 10: 'platform-verify', +}); + +const TEST_NODE_STEP_IDS = Object.fromEntries( + Object.entries(TEST_STEP_NODE_IDS).map(([step, nodeId]) => [nodeId, Number(step)]) +); + +function getTestNodeIdByStep(step) { + return TEST_STEP_NODE_IDS[Number(step)] || ''; +} + +function getTestStepIdByNodeId(nodeId) { + return TEST_NODE_STEP_IDS[String(nodeId || '').trim()] || null; +} + +function projectStepStatusesToNodeStatuses(stepStatuses = {}) { + const nodeStatuses = {}; + for (const [step, status] of Object.entries(stepStatuses || {})) { + const nodeId = getTestNodeIdByStep(step); + if (nodeId) { + nodeStatuses[nodeId] = status; + } + } + return nodeStatuses; +} + +function normalizeAutoRunControllerTestDeps(deps = {}) { + const normalized = { ...deps }; + + if (typeof normalized.getState === 'function') { + const getState = normalized.getState; + normalized.getState = async () => { + const state = await getState(); + return { + ...state, + nodeStatuses: { + ...projectStepStatusesToNodeStatuses(state?.stepStatuses || {}), + ...(state?.nodeStatuses || {}), + }, + }; + }; + } + + if ( + typeof normalized.runAutoSequenceFromNode !== 'function' + && typeof normalized.runAutoSequenceFromStep === 'function' + ) { + const runAutoSequenceFromStep = normalized.runAutoSequenceFromStep; + normalized.runAutoSequenceFromNode = async (nodeId, context = {}) => ( + runAutoSequenceFromStep(getTestStepIdByNodeId(nodeId) || 1, context) + ); + } + + if ( + typeof normalized.getFirstUnfinishedNodeId !== 'function' + && typeof normalized.getFirstUnfinishedStep === 'function' + ) { + const getFirstUnfinishedStep = normalized.getFirstUnfinishedStep; + normalized.getFirstUnfinishedNodeId = (statuses = {}, state = {}) => ( + getTestNodeIdByStep(getFirstUnfinishedStep(statuses, state)) + ); + } + + if ( + typeof normalized.getRunningNodeIds !== 'function' + && typeof normalized.getRunningSteps === 'function' + ) { + const getRunningSteps = normalized.getRunningSteps; + normalized.getRunningNodeIds = (statuses = {}, state = {}) => ( + getRunningSteps(statuses, state) + .map(getTestNodeIdByStep) + .filter(Boolean) + ); + } + + if ( + typeof normalized.hasSavedNodeProgress !== 'function' + && typeof normalized.hasSavedProgress === 'function' + ) { + normalized.hasSavedNodeProgress = normalized.hasSavedProgress; + } + + if ( + typeof normalized.waitForRunningNodesToFinish !== 'function' + && typeof normalized.waitForRunningStepsToFinish === 'function' + ) { + normalized.waitForRunningNodesToFinish = normalized.waitForRunningStepsToFinish; + } + + delete normalized.runAutoSequenceFromStep; + delete normalized.getFirstUnfinishedStep; + delete normalized.getRunningSteps; + delete normalized.hasSavedProgress; + delete normalized.waitForRunningStepsToFinish; + + return normalized; +} + +api.createAutoRunController = (deps = {}) => ( + rawCreateAutoRunController(normalizeAutoRunControllerTestDeps(deps)) +); test('auto-run controller skips add-phone failures to the next round instead of stopping when auto retry is enabled', async () => { const events = { diff --git a/tests/auto-run-fresh-attempt-reset.test.js b/tests/auto-run-fresh-attempt-reset.test.js index 3cb1d3d..f1a2354 100644 --- a/tests/auto-run-fresh-attempt-reset.test.js +++ b/tests/auto-run-fresh-attempt-reset.test.js @@ -72,6 +72,33 @@ const AUTO_RUN_RETRY_DELAY_MS = 3000; const AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS = 'between_rounds'; const AUTO_RUN_TIMER_KIND_BEFORE_RETRY = 'before_retry'; const STEP_IDS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; +const STEP_NODE_IDS = { + 1: 'open-chatgpt', + 2: 'submit-signup-email', + 3: 'fill-password', + 4: 'fetch-signup-code', + 5: 'fill-profile', + 6: 'wait-registration-success', + 7: 'oauth-login', + 8: 'fetch-login-code', + 9: 'confirm-oauth', + 10: 'platform-verify', +}; +const NODE_STEP_IDS = Object.fromEntries(Object.entries(STEP_NODE_IDS).map(([step, nodeId]) => [nodeId, Number(step)])); +function getNodeIdByStepForState(step) { + return STEP_NODE_IDS[Number(step)] || ''; +} +function getStepIdByNodeIdForState(nodeId) { + return NODE_STEP_IDS[String(nodeId || '').trim()] || null; +} +function projectStepStatusesToNodeStatuses(stepStatuses = {}) { + const nodeStatuses = {}; + for (const [step, status] of Object.entries(stepStatuses || {})) { + const nodeId = getNodeIdByStepForState(step); + if (nodeId) nodeStatuses[nodeId] = status; + } + return nodeStatuses; +} const DEFAULT_STATE = { stepStatuses: { 1: 'pending', @@ -86,6 +113,7 @@ const DEFAULT_STATE = { 10: 'pending', }, }; +DEFAULT_STATE.nodeStatuses = projectStepStatusesToNodeStatuses(DEFAULT_STATE.stepStatuses); let stopRequested = false; let runCalls = 0; @@ -137,6 +165,10 @@ async function getState() { return { ...currentState, stepStatuses: { ...(currentState.stepStatuses || {}) }, + nodeStatuses: { + ...projectStepStatusesToNodeStatuses(currentState.stepStatuses || {}), + ...(currentState.nodeStatuses || {}), + }, tabRegistry: { ...(currentState.tabRegistry || {}) }, sourceLastUrls: { ...(currentState.sourceLastUrls || {}) }, }; @@ -149,6 +181,9 @@ async function setState(updates) { stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses, + nodeStatuses: updates.nodeStatuses + ? { ...updates.nodeStatuses } + : currentState.nodeStatuses, tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry, @@ -163,6 +198,7 @@ async function resetState() { currentState = { ...DEFAULT_STATE, stepStatuses: { ...DEFAULT_STATE.stepStatuses }, + nodeStatuses: { ...DEFAULT_STATE.nodeStatuses }, vpsUrl: prev.vpsUrl, vpsPassword: prev.vpsPassword, customPassword: prev.customPassword, @@ -262,6 +298,18 @@ async function runAutoSequenceFromStep() { 9: 'completed', 10: 'completed', }, + nodeStatuses: projectStepStatusesToNodeStatuses({ + 1: 'completed', + 2: 'completed', + 3: 'completed', + 4: 'completed', + 5: 'completed', + 6: 'completed', + 7: 'completed', + 8: 'completed', + 9: 'completed', + 10: 'completed', + }), tabRegistry: { 'signup-page': { tabId: 88, ready: true }, }, @@ -271,6 +319,26 @@ async function runAutoSequenceFromStep() { }; } +async function runAutoSequenceFromNode(nodeId, context = {}) { + return runAutoSequenceFromStep(getStepIdByNodeIdForState(nodeId) || 1, context); +} + +function getFirstUnfinishedNodeId() { + return 'open-chatgpt'; +} + +function getRunningNodeIds() { + return []; +} + +function hasSavedNodeProgress() { + return false; +} + +async function waitForRunningNodesToFinish() { + return getState(); +} + ${helperBundle} ${autoRunModuleSource} @@ -303,24 +371,24 @@ const controller = self.MultiPageBackgroundAutoRunController.createAutoRunContro createAutoRunSessionId, getAutoRunStatusPayload, getErrorMessage, - getFirstUnfinishedStep, + getFirstUnfinishedNodeId, getPendingAutoRunTimerPlan, - getRunningSteps, + getRunningNodeIds, getState, getStopRequested: () => stopRequested, - hasSavedProgress, + hasSavedNodeProgress, isRestartCurrentAttemptError, isStopError, launchAutoRunTimerPlan, normalizeAutoRunFallbackThreadIntervalMinutes, persistAutoRunTimerPlan, resetState, - runAutoSequenceFromStep, + runAutoSequenceFromNode, runtime, setState, sleepWithStop, throwIfAutoRunSessionStopped, - waitForRunningStepsToFinish, + waitForRunningNodesToFinish, throwIfStopped, chrome, }); diff --git a/tests/auto-run-hotmail-preflight.test.js b/tests/auto-run-hotmail-preflight.test.js index c62fa2c..0b242d6 100644 --- a/tests/auto-run-hotmail-preflight.test.js +++ b/tests/auto-run-hotmail-preflight.test.js @@ -5,6 +5,90 @@ const fs = require('node:fs'); const source = fs.readFileSync('background/auto-run-controller.js', 'utf8'); const globalScope = {}; const api = new Function('self', `${source}; return self.MultiPageBackgroundAutoRunController;`)(globalScope); +const rawCreateAutoRunController = api.createAutoRunController.bind(api); + +const TEST_STEP_NODE_IDS = Object.freeze({ + 1: 'open-chatgpt', + 2: 'submit-signup-email', + 3: 'fill-password', + 4: 'fetch-signup-code', + 5: 'fill-profile', + 6: 'wait-registration-success', + 7: 'oauth-login', + 8: 'fetch-login-code', + 9: 'confirm-oauth', + 10: 'platform-verify', +}); + +const TEST_NODE_STEP_IDS = Object.fromEntries( + Object.entries(TEST_STEP_NODE_IDS).map(([step, nodeId]) => [nodeId, Number(step)]) +); + +function getTestNodeIdByStep(step) { + return TEST_STEP_NODE_IDS[Number(step)] || ''; +} + +function getTestStepIdByNodeId(nodeId) { + return TEST_NODE_STEP_IDS[String(nodeId || '').trim()] || null; +} + +function projectStepStatusesToNodeStatuses(stepStatuses = {}) { + const nodeStatuses = {}; + for (const [step, status] of Object.entries(stepStatuses || {})) { + const nodeId = getTestNodeIdByStep(step); + if (nodeId) { + nodeStatuses[nodeId] = status; + } + } + return nodeStatuses; +} + +api.createAutoRunController = (deps = {}) => { + const normalized = { ...deps }; + if (typeof normalized.getState === 'function') { + const getState = normalized.getState; + normalized.getState = async () => { + const state = await getState(); + return { + ...state, + nodeStatuses: { + ...projectStepStatusesToNodeStatuses(state?.stepStatuses || {}), + ...(state?.nodeStatuses || {}), + }, + }; + }; + } + if (typeof normalized.runAutoSequenceFromNode !== 'function' && typeof normalized.runAutoSequenceFromStep === 'function') { + const runAutoSequenceFromStep = normalized.runAutoSequenceFromStep; + normalized.runAutoSequenceFromNode = (nodeId, context = {}) => ( + runAutoSequenceFromStep(getTestStepIdByNodeId(nodeId) || 1, context) + ); + } + if (typeof normalized.getFirstUnfinishedNodeId !== 'function' && typeof normalized.getFirstUnfinishedStep === 'function') { + const getFirstUnfinishedStep = normalized.getFirstUnfinishedStep; + normalized.getFirstUnfinishedNodeId = (statuses = {}, state = {}) => ( + getTestNodeIdByStep(getFirstUnfinishedStep(statuses, state)) + ); + } + if (typeof normalized.getRunningNodeIds !== 'function' && typeof normalized.getRunningSteps === 'function') { + const getRunningSteps = normalized.getRunningSteps; + normalized.getRunningNodeIds = (statuses = {}, state = {}) => ( + getRunningSteps(statuses, state).map(getTestNodeIdByStep).filter(Boolean) + ); + } + if (typeof normalized.hasSavedNodeProgress !== 'function' && typeof normalized.hasSavedProgress === 'function') { + normalized.hasSavedNodeProgress = normalized.hasSavedProgress; + } + if (typeof normalized.waitForRunningNodesToFinish !== 'function' && typeof normalized.waitForRunningStepsToFinish === 'function') { + normalized.waitForRunningNodesToFinish = normalized.waitForRunningStepsToFinish; + } + delete normalized.runAutoSequenceFromStep; + delete normalized.getFirstUnfinishedStep; + delete normalized.getRunningSteps; + delete normalized.hasSavedProgress; + delete normalized.waitForRunningStepsToFinish; + return rawCreateAutoRunController(normalized); +}; test('auto-run controller verifies hotmail mailbox before each fresh attempt starts', async () => { const events = { diff --git a/tests/auto-run-step4-mail2925-thread-terminate.test.js b/tests/auto-run-step4-mail2925-thread-terminate.test.js index 87527a2..a5902a6 100644 --- a/tests/auto-run-step4-mail2925-thread-terminate.test.js +++ b/tests/auto-run-step4-mail2925-thread-terminate.test.js @@ -1,4 +1,4 @@ -const test = require('node:test'); +const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); @@ -52,6 +52,97 @@ function extractFunction(name) { return source.slice(start, end); } +const NODE_COMPAT_HELPERS = ` +const STEP_NODE_IDS = { + 1: 'open-chatgpt', + 2: 'submit-signup-email', + 3: 'fill-password', + 4: 'fetch-signup-code', + 5: 'fill-profile', + 6: 'wait-registration-success', + 7: 'oauth-login', + 8: 'fetch-login-code', + 9: 'confirm-oauth', + 10: 'platform-verify', +}; +const NODE_STEP_IDS = Object.fromEntries(Object.entries(STEP_NODE_IDS).map(([step, nodeId]) => [nodeId, Number(step)])); +function getNodeIdByStepForState(step) { + return STEP_NODE_IDS[Number(step)] || ''; +} +function getStepIdByNodeIdForState(nodeId) { + return NODE_STEP_IDS[String(nodeId || '').trim()] || null; +} +function getNodeIdsForState() { + return Object.keys(STEP_NODE_IDS) + .map(Number) + .sort((left, right) => left - right) + .map((step) => STEP_NODE_IDS[step]) + .filter(Boolean); +} +function getNodeDefinitionForState(nodeId) { + const normalizedNodeId = String(nodeId || '').trim(); + return normalizedNodeId ? { nodeId: normalizedNodeId, executeKey: normalizedNodeId } : null; +} +function getNodeTitleForState(nodeId) { + return String(nodeId || '').trim(); +} +function projectStepStatusesToNodeStatuses(stepStatuses = {}) { + const nodeStatuses = {}; + for (const [step, status] of Object.entries(stepStatuses || {})) { + const nodeId = getNodeIdByStepForState(step); + if (nodeId) nodeStatuses[nodeId] = status; + } + return nodeStatuses; +} +function projectNodeStatusesToStepStatuses(nodeStatuses = {}) { + const stepStatuses = {}; + for (const [nodeId, status] of Object.entries(nodeStatuses || {})) { + const step = getStepIdByNodeIdForState(nodeId); + if (step) stepStatuses[step] = status; + } + return stepStatuses; +} +const rawGetStateForNodeCompat = getState; +getState = async function getStateWithNodeStatuses() { + const state = await rawGetStateForNodeCompat(); + return { + ...state, + nodeStatuses: { + ...projectStepStatusesToNodeStatuses(state?.stepStatuses || {}), + ...(state?.nodeStatuses || {}), + }, + }; +}; +const rawSetStateForNodeCompat = setState; +setState = async function setStateWithNodeStatuses(updates = {}) { + const stepStatusUpdates = updates?.nodeStatuses + ? projectNodeStatusesToStepStatuses(updates.nodeStatuses) + : {}; + return rawSetStateForNodeCompat({ + ...updates, + ...(Object.keys(stepStatusUpdates).length ? { + stepStatuses: { + ...stepStatusUpdates, + ...(updates.stepStatuses || {}), + }, + } : {}), + }); +}; +async function executeNodeAndWait(nodeId, delayAfter) { + const directStep = Number(nodeId); + if (Number.isInteger(directStep) && directStep > 0) { + return executeStepAndWait(directStep, delayAfter); + } + return executeStepAndWait(getStepIdByNodeIdForState(nodeId), delayAfter); +} +function getAutoRunNodeDelayMs() { + return 0; +} +async function runAutoSequenceFromStep(step, context = {}) { + return runAutoSequenceFromNode(getNodeIdByStepForState(step), context); +} +`; + const bundle = [ 'const AUTO_RUN_STEP_IDLE_LOG_TIMEOUT_MS = 300000;', 'const AUTO_RUN_STEP_IDLE_LOG_CHECK_INTERVAL_MS = 5000;', @@ -65,13 +156,16 @@ const bundle = [ extractFunction('isPlusCheckoutRestartStep'), extractFunction('isPlusCheckoutRestartRequiredFailure'), extractFunction('getLatestLogTimestamp'), - extractFunction('buildAutoRunStepIdleRestartError'), + extractFunction('buildAutoRunNodeIdleRestartError'), extractFunction('isAutoRunStepIdleRestartError'), - extractFunction('startAutoRunStepIdleLogWatchdog'), - extractFunction('runAutoStepActionWithIdleLogWatchdog'), - extractFunction('executeStepAndWaitWithAutoRunIdleLogWatchdog'), + extractFunction('startAutoRunNodeIdleLogWatchdog'), + extractFunction('runAutoNodeActionWithIdleLogWatchdog'), + extractFunction('executeNodeAndWaitWithAutoRunIdleLogWatchdog'), extractFunction('getPostStep6AutoRestartDecision'), - extractFunction('runAutoSequenceFromStep'), + NODE_COMPAT_HELPERS, + extractFunction('getAutoRunWorkflowNodeIds'), + extractFunction('runAutoSequenceFromNode'), + extractFunction('runAutoSequenceFromNodeGraph'), ].join('\n'); test('auto-run stops step4 restart path when mail2925 ends the current thread', async () => { diff --git a/tests/auto-run-step4-restart.test.js b/tests/auto-run-step4-restart.test.js index 5c676b1..66da430 100644 --- a/tests/auto-run-step4-restart.test.js +++ b/tests/auto-run-step4-restart.test.js @@ -1,4 +1,4 @@ -const test = require('node:test'); +const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); @@ -52,6 +52,97 @@ function extractFunction(name) { return source.slice(start, end); } +const NODE_COMPAT_HELPERS = ` +const STEP_NODE_IDS = { + 1: 'open-chatgpt', + 2: 'submit-signup-email', + 3: 'fill-password', + 4: 'fetch-signup-code', + 5: 'fill-profile', + 6: 'wait-registration-success', + 7: 'oauth-login', + 8: 'fetch-login-code', + 9: 'confirm-oauth', + 10: 'platform-verify', +}; +const NODE_STEP_IDS = Object.fromEntries(Object.entries(STEP_NODE_IDS).map(([step, nodeId]) => [nodeId, Number(step)])); +function getNodeIdByStepForState(step) { + return STEP_NODE_IDS[Number(step)] || ''; +} +function getStepIdByNodeIdForState(nodeId) { + return NODE_STEP_IDS[String(nodeId || '').trim()] || null; +} +function getNodeIdsForState() { + return Object.keys(STEP_NODE_IDS) + .map(Number) + .sort((left, right) => left - right) + .map((step) => STEP_NODE_IDS[step]) + .filter(Boolean); +} +function getNodeDefinitionForState(nodeId) { + const normalizedNodeId = String(nodeId || '').trim(); + return normalizedNodeId ? { nodeId: normalizedNodeId, executeKey: normalizedNodeId } : null; +} +function getNodeTitleForState(nodeId) { + return String(nodeId || '').trim(); +} +function projectStepStatusesToNodeStatuses(stepStatuses = {}) { + const nodeStatuses = {}; + for (const [step, status] of Object.entries(stepStatuses || {})) { + const nodeId = getNodeIdByStepForState(step); + if (nodeId) nodeStatuses[nodeId] = status; + } + return nodeStatuses; +} +function projectNodeStatusesToStepStatuses(nodeStatuses = {}) { + const stepStatuses = {}; + for (const [nodeId, status] of Object.entries(nodeStatuses || {})) { + const step = getStepIdByNodeIdForState(nodeId); + if (step) stepStatuses[step] = status; + } + return stepStatuses; +} +const rawGetStateForNodeCompat = getState; +getState = async function getStateWithNodeStatuses() { + const state = await rawGetStateForNodeCompat(); + return { + ...state, + nodeStatuses: { + ...projectStepStatusesToNodeStatuses(state?.stepStatuses || {}), + ...(state?.nodeStatuses || {}), + }, + }; +}; +const rawSetStateForNodeCompat = setState; +setState = async function setStateWithNodeStatuses(updates = {}) { + const stepStatusUpdates = updates?.nodeStatuses + ? projectNodeStatusesToStepStatuses(updates.nodeStatuses) + : {}; + return rawSetStateForNodeCompat({ + ...updates, + ...(Object.keys(stepStatusUpdates).length ? { + stepStatuses: { + ...stepStatusUpdates, + ...(updates.stepStatuses || {}), + }, + } : {}), + }); +}; +async function executeNodeAndWait(nodeId, delayAfter) { + const directStep = Number(nodeId); + if (Number.isInteger(directStep) && directStep > 0) { + return executeStepAndWait(directStep, delayAfter); + } + return executeStepAndWait(getStepIdByNodeIdForState(nodeId), delayAfter); +} +function getAutoRunNodeDelayMs() { + return 0; +} +async function runAutoSequenceFromStep(step, context = {}) { + return runAutoSequenceFromNode(getNodeIdByStepForState(step), context); +} +`; + const bundle = [ 'const AUTO_RUN_STEP_IDLE_LOG_TIMEOUT_MS = 300000;', 'const AUTO_RUN_STEP_IDLE_LOG_CHECK_INTERVAL_MS = 5000;', @@ -62,19 +153,22 @@ const bundle = [ extractFunction('isMail2925ThreadTerminatedError'), extractFunction('isSignupPhonePasswordMismatchFailure'), extractFunction('getSignupPhonePasswordMismatchRestartPayload'), - extractFunction('restartSignupPhonePasswordMismatchAttemptFromStep'), + extractFunction('restartSignupPhonePasswordMismatchAttemptFromNode'), extractFunction('isSignupUserAlreadyExistsFailure'), extractFunction('isPlusCheckoutNonFreeTrialFailure'), extractFunction('isPlusCheckoutRestartStep'), extractFunction('isPlusCheckoutRestartRequiredFailure'), extractFunction('getLatestLogTimestamp'), - extractFunction('buildAutoRunStepIdleRestartError'), + extractFunction('buildAutoRunNodeIdleRestartError'), extractFunction('isAutoRunStepIdleRestartError'), - extractFunction('startAutoRunStepIdleLogWatchdog'), - extractFunction('runAutoStepActionWithIdleLogWatchdog'), - extractFunction('executeStepAndWaitWithAutoRunIdleLogWatchdog'), + extractFunction('startAutoRunNodeIdleLogWatchdog'), + extractFunction('runAutoNodeActionWithIdleLogWatchdog'), + extractFunction('executeNodeAndWaitWithAutoRunIdleLogWatchdog'), extractFunction('getPostStep6AutoRestartDecision'), - extractFunction('runAutoSequenceFromStep'), + NODE_COMPAT_HELPERS, + extractFunction('getAutoRunWorkflowNodeIds'), + extractFunction('runAutoSequenceFromNode'), + extractFunction('runAutoSequenceFromNodeGraph'), ].join('\n'); test('auto-run restarts from step 1 with the same email after step 4 failure', async () => { @@ -217,7 +311,7 @@ return { { step: 1, options: { - logLabel: '步骤 4 报错后准备回到步骤 1 沿用当前邮箱重试(第 1 次重开)', + logLabel: '节点 fetch-signup-code 报错后准备回到 open-chatgpt 沿用当前邮箱重试(第 1 次重开)', }, }, ]); @@ -225,7 +319,7 @@ return { assert.deepStrictEqual(events.steps, [1, 2, 3, 4, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); assert.equal(currentState.email, 'keep@example.com'); assert.equal(currentState.password, 'Secret123!'); - assert.equal(events.logs.some(({ message }) => /沿用当前邮箱回到步骤 1 重新开始/.test(message)), true); + assert.equal(events.logs.some(({ message }) => /沿用当前邮箱回到节点 open-chatgpt 重新开始/.test(message)), true); }); test('auto-run does not restart step 4 current attempt when user_already_exists is detected', async () => { @@ -348,7 +442,7 @@ return { assert.match(result.error, /SIGNUP_USER_ALREADY_EXISTS::/); assert.deepStrictEqual(result.events.invalidations, []); assert.deepStrictEqual(result.events.steps, [1, 2, 3, 4]); - assert.equal(result.events.logs.some(({ message }) => /沿用当前邮箱回到步骤 1 重新开始/.test(message)), false); + assert.equal(result.events.logs.some(({ message }) => /沿用当前邮箱回到节点 open-chatgpt 重新开始/.test(message)), false); }); test('auto-run skips steps 4/5 when step 2 has already marked registration chain as skipped', async () => { @@ -470,8 +564,8 @@ return { const { events } = await api.run(); assert.deepStrictEqual(events.steps, [1, 2, 6, 7, 8, 9, 10]); - assert.equal(events.logs.some(({ message }) => /步骤 4 当前状态为 skipped/.test(message)), true); - assert.equal(events.logs.some(({ message }) => /步骤 5 当前状态为 skipped/.test(message)), true); + assert.equal(events.logs.some(({ message }) => /节点 fetch-signup-code 当前状态为 skipped/.test(message)), true); + assert.equal(events.logs.some(({ message }) => /节点 fill-profile 当前状态为 skipped/.test(message)), true); }); test('auto-run clears fetched signup phone state before restarting when step 4 detects phone/password mismatch', async () => { @@ -622,7 +716,7 @@ return { { step: 1, options: { - logLabel: '步骤 4 检测到手机号/密码不匹配后准备回到步骤 1 重新获取手机号重试(第 1 次重开)', + logLabel: '节点 fetch-signup-code 检测到手机号/密码不匹配后准备回到 open-chatgpt 重新获取手机号重试(第 1 次重开)', }, }, ]); @@ -632,7 +726,7 @@ return { assert.equal(currentState.accountIdentifierType, null); assert.equal(currentState.accountIdentifier, ''); assert.equal(currentState.password, 'Secret123!'); - assert.equal(events.logs.some(({ message }) => /丢弃当前注册手机号并回到步骤 1 重新开始/.test(message)), true); + assert.equal(events.logs.some(({ message }) => /丢弃当前注册手机号并回到节点 open-chatgpt 重新开始/.test(message)), true); assert.equal(events.logs.some(({ message }) => /已清空本轮注册手机号与接码订单/.test(message)), true); }); @@ -786,7 +880,7 @@ return { { step: 1, options: { - logLabel: '步骤 4 检测到当前注册手机号无法接收短信后准备回到步骤 1 重新获取手机号重试(第 1 次重开)', + logLabel: '节点 fetch-signup-code 检测到当前注册手机号无法接收短信后准备回到 open-chatgpt 重新获取手机号重试(第 1 次重开)', }, }, ]); @@ -796,7 +890,7 @@ return { assert.equal(currentState.accountIdentifierType, null); assert.equal(currentState.accountIdentifier, ''); assert.equal(currentState.password, 'Secret123!'); - assert.equal(events.logs.some(({ message }) => /丢弃当前注册手机号并回到步骤 1 重新开始/.test(message)), true); + assert.equal(events.logs.some(({ message }) => /丢弃当前注册手机号并回到节点 open-chatgpt 重新开始/.test(message)), true); assert.equal(events.logs.some(({ message }) => /已清空本轮注册手机号与接码订单/.test(message)), true); }); @@ -943,7 +1037,7 @@ return { { step: 1, options: { - logLabel: '步骤 3 检测到手机号/密码不匹配后准备回到步骤 1 重新获取手机号重试(第 1 次重开)', + logLabel: '节点 fill-password 检测到手机号/密码不匹配后准备回到 open-chatgpt 重新获取手机号重试(第 1 次重开)', }, }, ]); @@ -953,6 +1047,6 @@ return { assert.equal(currentState.accountIdentifierType, null); assert.equal(currentState.accountIdentifier, ''); assert.equal(currentState.password, 'Secret123!'); - assert.equal(events.logs.some(({ message }) => /步骤 3:检测到手机号\/密码不匹配/.test(message)), true); - assert.equal(events.logs.some(({ message }) => /步骤 3:已清空本轮注册手机号与接码订单/.test(message)), true); + assert.equal(events.logs.some(({ message }) => /节点 fill-password:检测到手机号\/密码不匹配/.test(message)), true); + assert.equal(events.logs.some(({ message }) => /节点 fill-password:已清空本轮注册手机号与接码订单/.test(message)), true); }); diff --git a/tests/auto-run-step6-restart.test.js b/tests/auto-run-step6-restart.test.js index a729bc8..88dca20 100644 --- a/tests/auto-run-step6-restart.test.js +++ b/tests/auto-run-step6-restart.test.js @@ -1,4 +1,4 @@ -const test = require('node:test'); +const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); @@ -52,6 +52,99 @@ function extractFunction(name) { return source.slice(start, end); } +const NODE_COMPAT_HELPERS = ` +const FALLBACK_STEP_NODE_IDS = { + 1: 'open-chatgpt', + 2: 'submit-signup-email', + 3: 'fill-password', + 4: 'fetch-signup-code', + 5: 'fill-profile', + 6: 'plus-checkout-create', + 7: 'plus-checkout-billing', + 8: 'paypal-approve', + 9: 'plus-checkout-return', + 10: 'oauth-login', + 11: 'fetch-login-code', + 12: 'confirm-oauth', + 13: 'platform-verify', +}; +function getNodeIdByStepForState(step, state = {}) { + if (typeof getStepDefinitionForState === 'function') { + const key = String(getStepDefinitionForState(step, state)?.key || '').trim(); + if (key) return key; + } + return FALLBACK_STEP_NODE_IDS[Number(step)] || ''; +} +function getStepIdByNodeIdForState(nodeId, state = {}) { + const normalizedNodeId = String(nodeId || '').trim(); + const ids = typeof getStepIdsForState === 'function' + ? getStepIdsForState(state) + : Object.keys(FALLBACK_STEP_NODE_IDS).map(Number); + for (const step of ids) { + if (getNodeIdByStepForState(step, state) === normalizedNodeId) { + return Number(step); + } + } + for (const [step, fallbackNodeId] of Object.entries(FALLBACK_STEP_NODE_IDS)) { + if (fallbackNodeId === normalizedNodeId) { + return Number(step); + } + } + return null; +} +function getNodeIdsForState(state = {}) { + const ids = typeof getStepIdsForState === 'function' + ? getStepIdsForState(state) + : Object.keys(FALLBACK_STEP_NODE_IDS).map(Number); + return ids + .map((step) => getNodeIdByStepForState(step, state)) + .filter(Boolean); +} +function getNodeDefinitionForState(nodeId, state = {}) { + const normalizedNodeId = String(nodeId || '').trim(); + const step = getStepIdByNodeIdForState(normalizedNodeId, state); + const executeKey = typeof getStepExecutionKeyForState === 'function' + ? getStepExecutionKeyForState(step, state) + : normalizedNodeId; + return normalizedNodeId ? { nodeId: normalizedNodeId, legacyStepId: step, executeKey } : null; +} +function getNodeTitleForState(nodeId) { + return String(nodeId || '').trim(); +} +function projectStepStatusesToNodeStatuses(stepStatuses = {}, state = {}) { + const nodeStatuses = {}; + for (const [step, status] of Object.entries(stepStatuses || {})) { + const nodeId = getNodeIdByStepForState(step, state); + if (nodeId) nodeStatuses[nodeId] = status; + } + return nodeStatuses; +} +const rawGetStateForNodeCompat = getState; +getState = async function getStateWithNodeStatuses() { + const state = await rawGetStateForNodeCompat(); + return { + ...state, + nodeStatuses: { + ...projectStepStatusesToNodeStatuses(state?.stepStatuses || {}, state), + ...(state?.nodeStatuses || {}), + }, + }; +}; +async function executeNodeAndWait(nodeId, delayAfter) { + const directStep = Number(nodeId); + if (Number.isInteger(directStep) && directStep > 0) { + return executeStepAndWait(directStep, delayAfter); + } + return executeStepAndWait(getStepIdByNodeIdForState(nodeId, await getState()), delayAfter); +} +function getAutoRunNodeDelayMs() { + return 0; +} +async function runAutoSequenceFromStep(step, context = {}) { + return runAutoSequenceFromNode(getNodeIdByStepForState(step, await getState()), context); +} +`; + const bundle = [ extractFunction('isAddPhoneAuthFailure'), extractFunction('isAddPhoneAuthUrl'), @@ -61,13 +154,16 @@ const bundle = [ extractFunction('isPlusCheckoutRestartStep'), extractFunction('isPlusCheckoutRestartRequiredFailure'), extractFunction('getLatestLogTimestamp'), - extractFunction('buildAutoRunStepIdleRestartError'), + extractFunction('buildAutoRunNodeIdleRestartError'), extractFunction('isAutoRunStepIdleRestartError'), - extractFunction('startAutoRunStepIdleLogWatchdog'), - extractFunction('runAutoStepActionWithIdleLogWatchdog'), - extractFunction('executeStepAndWaitWithAutoRunIdleLogWatchdog'), + extractFunction('startAutoRunNodeIdleLogWatchdog'), + extractFunction('runAutoNodeActionWithIdleLogWatchdog'), + extractFunction('executeNodeAndWaitWithAutoRunIdleLogWatchdog'), extractFunction('getPostStep6AutoRestartDecision'), - extractFunction('runAutoSequenceFromStep'), + NODE_COMPAT_HELPERS, + extractFunction('getAutoRunWorkflowNodeIds'), + extractFunction('runAutoSequenceFromNode'), + extractFunction('runAutoSequenceFromNodeGraph'), ].join('\n'); const defaultStepDefinitions = { @@ -311,7 +407,7 @@ test('auto-run keeps restarting from step 7 after post-login failures without a 7, 8, 9, 10, ] ); - assert.ok(events.logs.some(({ message }) => /回到步骤 7 重新开始授权流程/.test(message))); + assert.ok(events.logs.some(({ message }) => /回到节点 auth-login 重新开始授权流程/.test(message))); }); test('auto-run restarts the current step after five minutes without new logs', async () => { @@ -330,7 +426,7 @@ test('auto-run restarts the current step after five minutes without new logs', a assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [9]); assert.equal(events.cancellations.length, 1); assert.equal(events.stopBroadcasts, 1); - assert.ok(events.logs.some(({ message }) => /5 分钟没有新日志,准备重新开始当前步骤/.test(message))); + assert.ok(events.logs.some(({ message }) => /5 分钟没有新日志,准备重新开始当前节点/.test(message))); }); test('auto-run applies the idle-log restart watchdog to early steps too', async () => { @@ -364,7 +460,7 @@ test('auto-run stops current-step idle restarts after the retry cap', async () = const result = await harness.runAndCaptureError(); assert.ok(result?.error); - assert.match(result.error.message, /AUTO_RUN_STEP_IDLE_RESTART::步骤 10/); + assert.match(result.error.message, /AUTO_RUN_STEP_IDLE_RESTART::节点 platform-verify/); assert.deepStrictEqual(result.events.steps, [10, 10, 10, 10]); assert.deepStrictEqual(result.events.invalidations.map((entry) => entry.step), [9, 9, 9]); assert.ok(result.events.logs.some(({ message }) => /已连续 3 次因 5 分钟无新日志而重开/.test(message))); @@ -474,10 +570,10 @@ test('auto-run restarts from confirm-oauth step after transient step10 token_exc assert.deepStrictEqual(events.invalidations[0], { step: 8, options: { - logLabel: '步骤 10 报错后准备回到步骤 9 重试(第 1 次重开)', + logLabel: '节点 platform-verify 报错后准备回到 confirm-oauth 重试(第 1 次重开)', }, }); - assert.ok(events.logs.some(({ message }) => /回到步骤 9 重新开始授权流程/.test(message))); + assert.ok(events.logs.some(({ message }) => /回到节点 confirm-oauth 重新开始授权流程/.test(message))); }); test('auto-run restarts Plus/GPC oauth-login aggregate entry-open failure from step 10', async () => { @@ -508,9 +604,9 @@ test('auto-run restarts Plus/GPC oauth-login aggregate entry-open failure from s const events = await harness.run(); assert.deepStrictEqual(events.steps, [10, 10, 11, 12, 13]); - assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [9]); - assert.ok(events.logs.some(({ message }) => /回到步骤 10 重新开始授权流程/.test(message))); - assert.ok(!events.logs.some(({ message }) => /停止自动回到步骤 10 重开/.test(message))); + assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [7]); + assert.ok(events.logs.some(({ message }) => /回到节点 oauth-login 重新开始授权流程/.test(message))); + assert.ok(!events.logs.some(({ message }) => /停止自动回到节点 oauth-login 重开/.test(message))); }); test('auto-run restarts Plus/GPC fetch-code and confirm-oauth failures from oauth-login step 10', async () => { @@ -554,8 +650,8 @@ test('auto-run restarts Plus/GPC fetch-code and confirm-oauth failures from oaut const events = await harness.run(); assert.deepStrictEqual(events.steps, scenario.expectedSteps); - assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [9]); - assert.ok(events.logs.some(({ message }) => new RegExp(`步骤 ${scenario.failureStep}:.*回到步骤 10 重新开始授权流程`).test(message))); + assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [7]); + assert.ok(events.logs.some(({ message }) => /回到节点 oauth-login 重新开始授权流程/.test(message))); } }); @@ -589,7 +685,7 @@ test('auto-run restarts Plus/GPC transient platform verify exchange failures fro assert.deepStrictEqual(events.steps, [10, 11, 12, 13, 12, 13]); assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [11]); - assert.ok(events.logs.some(({ message }) => /步骤 13:.*回到步骤 12 重新开始授权流程/.test(message))); + assert.ok(events.logs.some(({ message }) => /节点 platform-verify.*回到节点 confirm-oauth 重新开始授权流程/.test(message))); }); test('auto-run restarts Plus checkout from step 6 when checkout creation fails', async () => { @@ -621,7 +717,7 @@ test('auto-run restarts Plus checkout from step 6 when checkout creation fails', assert.deepStrictEqual(events.steps, [6, 6, 7, 8, 9, 10, 11, 12, 13]); assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [5]); - assert.ok(events.logs.some(({ message }) => /回到步骤 6 重新创建 Plus Checkout/.test(message))); + assert.ok(events.logs.some(({ message }) => /回到节点 plus-checkout-create 重新创建 Plus Checkout/.test(message))); }); test('auto-run restarts Plus checkout from step 6 when billing fails for non-free-trial reasons', async () => { @@ -653,7 +749,7 @@ test('auto-run restarts Plus checkout from step 6 when billing fails for non-fre assert.deepStrictEqual(events.steps, [6, 7, 6, 7, 8, 9, 10, 11, 12, 13]); assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [5]); - assert.ok(events.logs.some(({ message }) => /回到步骤 6 重新创建 Plus Checkout/.test(message))); + assert.ok(events.logs.some(({ message }) => /回到节点 plus-checkout-create 重新创建 Plus Checkout/.test(message))); }); test('auto-run restarts GPC checkout from step 6 when step 7 task polling stalls', async () => { @@ -689,7 +785,7 @@ test('auto-run restarts GPC checkout from step 6 when step 7 task polling stalls events.invalidations.map((entry) => entry.step), [5, 5] ); - assert.ok(events.logs.some(({ message }) => /回到步骤 6 重新创建 GPC 任务/.test(message))); + assert.ok(events.logs.some(({ message }) => /回到节点 plus-checkout-create 重新创建 GPC 任务/.test(message))); }); test('auto-run treats GPC account binding as recoverable step 6 restart', async () => { @@ -748,7 +844,7 @@ test('auto-run restarts GPC checkout from step 6 when accessToken cannot be read assert.deepStrictEqual(events.steps, [6, 6, 7, 10, 11, 12, 13]); assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [5]); - assert.ok(events.logs.some(({ message }) => /回到步骤 6 重新创建 GPC 任务/.test(message))); + assert.ok(events.logs.some(({ message }) => /回到节点 plus-checkout-create 重新创建 GPC 任务/.test(message))); }); test('auto-run restarts GPC checkout from step 6 when task status has no progress', async () => { @@ -778,7 +874,7 @@ test('auto-run restarts GPC checkout from step 6 when task status has no progres assert.deepStrictEqual(events.steps, [6, 7, 6, 7, 10, 11, 12, 13]); assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [5]); - assert.ok(events.logs.some(({ message }) => /回到步骤 6 重新创建 GPC 任务/.test(message))); + assert.ok(events.logs.some(({ message }) => /回到节点 plus-checkout-create 重新创建 GPC 任务/.test(message))); }); test('auto-run keeps rebuilding GPC checkout beyond three failures', async () => { diff --git a/tests/auto-step-random-delay.test.js b/tests/auto-step-random-delay.test.js index 7855c19..0979ff6 100644 --- a/tests/auto-step-random-delay.test.js +++ b/tests/auto-step-random-delay.test.js @@ -47,9 +47,13 @@ const bundle = [ 'const PERSISTED_SETTING_DEFAULTS = { autoStepDelaySeconds: null };', "const AUTO_RUN_PRE_EXECUTION_DELAYS_BY_STEP_KEY = new Map([['plus-checkout-create', 20000]]);", 'function getStepDefinitionForState(step, state = {}) { return state.definitions?.[step] || null; }', + 'function getNodeIdByStepForState(step, state = {}) { return String(getStepDefinitionForState(step, state)?.key || step || "").trim(); }', + 'function getNodeDefinitionForState(nodeId, state = {}) { return Object.values(state.definitions || {}).find((definition) => String(definition?.key || "").trim() === String(nodeId || "").trim()) || { executeKey: String(nodeId || "").trim() }; }', extractFunction('normalizeAutoStepDelaySeconds'), extractFunction('resolveLegacyAutoStepDelaySeconds'), extractFunction('getStepExecutionKeyForState'), + extractFunction('getNodeExecutionKeyForState'), + extractFunction('getAutoRunPreExecutionDelayMsForNode'), extractFunction('getAutoRunPreExecutionDelayMs'), ].join('\n'); diff --git a/tests/background-account-run-history-module.test.js b/tests/background-account-run-history-module.test.js index dac9582..10cc295 100644 --- a/tests/background-account-run-history-module.test.js +++ b/tests/background-account-run-history-module.test.js @@ -46,7 +46,13 @@ test('account run history helper upgrades old records, keeps stopped items and s }, }, getErrorMessage: (error) => error?.message || String(error || ''), + getNodeTitleForState: (nodeId) => ({ + 'fetch-login-code': '获取登录验证码', + 'oauth-login': '刷新 OAuth 并登录', + })[nodeId] || nodeId, getState: async () => ({ + flowId: 'openai', + runId: 'run-2', email: ' latest@example.com ', password: ' secret ', autoRunning: true, @@ -61,6 +67,8 @@ test('account run history helper upgrades old records, keeps stopped items and s const record = helpers.buildAccountRunHistoryRecord( { + flowId: 'openai', + runId: 'run-2', email: ' latest@example.com ', password: ' secret ', autoRunning: true, @@ -68,11 +76,13 @@ test('account run history helper upgrades old records, keeps stopped items and s autoRunTotalRuns: 10, autoRunAttemptRun: 3, }, - 'step8_failed', + 'node:fetch-login-code:failed', '步骤 8:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。' ); assert.deepStrictEqual(record, { recordId: 'latest@example.com', + flowId: 'openai', + runId: 'run-2', accountIdentifierType: 'email', accountIdentifier: 'latest@example.com', email: 'latest@example.com', @@ -83,7 +93,8 @@ test('account run history helper upgrades old records, keeps stopped items and s retryCount: 2, failureLabel: '出现手机号验证', failureDetail: '步骤 8:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。', - failedStep: 8, + failedNodeId: 'fetch-login-code', + failedStep: null, source: 'auto', autoRunContext: { currentRun: 2, @@ -94,9 +105,12 @@ test('account run history helper upgrades old records, keeps stopped items and s contributionMode: false, }); - const appended = await helpers.appendAccountRunRecord('step8_failed', null, '步骤 8:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。'); + const appended = await helpers.appendAccountRunRecord('node:fetch-login-code:failed', null, '步骤 8:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。'); assert.equal(appended.email, 'latest@example.com'); assert.equal(appended.finalStatus, 'failed'); + assert.equal(appended.flowId, 'openai'); + assert.equal(appended.runId, 'run-2'); + assert.equal(appended.failedNodeId, 'fetch-login-code'); assert.equal(appended.failureLabel, '出现手机号验证'); assert.equal(storedHistory.length, 3, '旧的 stopped 记录应在新结构中保留'); assert.equal(storedHistory.some((item) => item.email === 'stop@example.com' && item.finalStatus === 'stopped'), true); @@ -106,18 +120,21 @@ test('account run history helper upgrades old records, keeps stopped items and s assert.equal(helpers.shouldAppendAccountRunTextFile({ accountRunHistoryTextEnabled: false, accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373' }), true); assert.equal(helpers.shouldAppendAccountRunTextFile({ accountRunHistoryTextEnabled: true, accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373' }), true); const stoppedRecord = helpers.buildAccountRunHistoryRecord( - { email: 'a@b.com', password: 'x' }, - 'step7_stopped', - '步骤 7 已被用户停止' + { flowId: 'openai', runId: 'manual-run', email: 'a@b.com', password: 'x' }, + 'node:oauth-login:stopped', + '节点 oauth-login 已被用户停止' ); assert.equal(stoppedRecord.recordId, 'a@b.com'); + assert.equal(stoppedRecord.flowId, 'openai'); + assert.equal(stoppedRecord.runId, 'manual-run'); assert.equal(stoppedRecord.email, 'a@b.com'); assert.equal(stoppedRecord.password, 'x'); assert.equal(stoppedRecord.finalStatus, 'stopped'); assert.equal(stoppedRecord.retryCount, 0); - assert.equal(stoppedRecord.failureLabel, '步骤 7 停止'); - assert.equal(stoppedRecord.failureDetail, '步骤 7 已被用户停止'); - assert.equal(stoppedRecord.failedStep, 7); + assert.equal(stoppedRecord.failureLabel, '节点 刷新 OAuth 并登录 停止'); + assert.equal(stoppedRecord.failureDetail, '节点 oauth-login 已被用户停止'); + assert.equal(stoppedRecord.failedNodeId, 'oauth-login'); + assert.equal(stoppedRecord.failedStep, null); assert.equal(stoppedRecord.source, 'manual'); assert.equal(stoppedRecord.autoRunContext, null); assert.ok(stoppedRecord.finishedAt); @@ -149,11 +166,13 @@ test('account run history helper upgrades old records, keeps stopped items and s retryCount: 0, failureLabel: '流程已停止', failureDetail: '步骤 7 已被用户停止。', + failedNodeId: 'oauth-login', failedStep: 7, source: 'manual', autoRunContext: null, }); - assert.equal(normalizedStoppedRecord.failureLabel, '步骤 7 停止'); + assert.equal(normalizedStoppedRecord.failureLabel, '节点 刷新 OAuth 并登录 停止'); + assert.equal(normalizedStoppedRecord.failedNodeId, 'oauth-login'); assert.equal(normalizedStoppedRecord.failedStep, 7); }); @@ -177,6 +196,8 @@ test('account run history helper accepts phone-only records without forcing emai assert.deepStrictEqual(record, { recordId: 'phone:+6612345', + flowId: '', + runId: '', accountIdentifierType: 'phone', accountIdentifier: '+6612345', email: '', @@ -187,6 +208,7 @@ test('account run history helper accepts phone-only records without forcing emai retryCount: 0, failureLabel: '流程完成', failureDetail: '', + failedNodeId: '', failedStep: null, source: 'manual', autoRunContext: null, @@ -242,10 +264,11 @@ test('account run history does not turn prerequisite guidance into a fake step 2 autoRunCurrentRun: 1, autoRunTotalRuns: 3, autoRunAttemptRun: 2, - }, 'step10_failed', '缺少登录账号:请先完成步骤 2,或在侧栏填写账号后再执行当前步骤。'); + }, 'node:platform-verify:failed', '缺少登录账号:请先完成步骤 2,或在侧栏填写账号后再执行当前步骤。'); - assert.equal(explicitFailedRecord.failedStep, 10); - assert.equal(explicitFailedRecord.failureLabel, '步骤 10 失败'); + assert.equal(explicitFailedRecord.failedNodeId, 'platform-verify'); + assert.equal(explicitFailedRecord.failedStep, null); + assert.equal(explicitFailedRecord.failureLabel, '节点 platform-verify 失败'); const migratedOldRecord = helpers.normalizeAccountRunHistoryRecord({ email: 'old@example.com', @@ -314,10 +337,12 @@ test('account run history merges email and phone identities from the same run', activationId: 'a1', phoneNumber: '+44 7799 342687', }, - }, 'step9_failed', '步骤 9:手机号验证失败。'); + }, 'node:confirm-oauth:failed', '步骤 9:手机号验证失败。'); assert.equal(failedRecord.accountIdentifierType, 'email'); assert.equal(failedRecord.accountIdentifier, 'tmp@example.com'); assert.equal(failedRecord.phoneNumber, '+44 7799 342687'); + assert.equal(failedRecord.failedNodeId, 'confirm-oauth'); + assert.equal(failedRecord.failedStep, null); const successRecord = await helpers.appendAccountRunRecord('success', { accountIdentifierType: 'email', diff --git a/tests/background-auth-chain-guard.test.js b/tests/background-auth-chain-guard.test.js index f79d2f1..31a9ee6 100644 --- a/tests/background-auth-chain-guard.test.js +++ b/tests/background-auth-chain-guard.test.js @@ -48,6 +48,71 @@ function extractFunction(name) { return source.slice(start, end); } +const NODE_EXECUTE_COMPAT_HELPERS = ` +const AUTH_CHAIN_NODE_IDS = new Set(['oauth-login', 'fetch-login-code', 'confirm-oauth', 'platform-verify']); +const STEP_NODE_IDS = { + 1: 'open-chatgpt', + 2: 'submit-signup-email', + 3: 'fill-password', + 4: 'fetch-signup-code', + 5: 'fill-profile', + 6: 'wait-registration-success', + 7: 'oauth-login', + 8: 'fetch-login-code', + 9: 'confirm-oauth', + 10: 'platform-verify', +}; +const NODE_STEP_IDS = Object.fromEntries(Object.entries(STEP_NODE_IDS).map(([step, nodeId]) => [nodeId, Number(step)])); +function isAuthChainNode(nodeId) { + return AUTH_CHAIN_NODE_IDS.has(String(nodeId || '').trim()); +} +function getNodeIdByStepForState(step) { + const definition = typeof getStepDefinitionForState === 'function' ? getStepDefinitionForState(step) : null; + const definitionKey = String(definition?.key || '').trim(); + return definitionKey && definitionKey !== 'test-step' + ? definitionKey + : String(STEP_NODE_IDS[Number(step)] || '').trim(); +} +function getStepIdByNodeIdForState(nodeId) { + const normalizedNodeId = String(nodeId || '').trim(); + return NODE_STEP_IDS[normalizedNodeId] || null; +} +function getNodeDefinitionForState(nodeId, state = {}) { + const step = getStepIdByNodeIdForState(nodeId, state); + const stepDefinition = typeof getStepDefinitionForState === 'function' + ? getStepDefinitionForState(step, state) + : null; + return stepDefinition + ? { nodeId: String(nodeId || '').trim(), legacyStepId: step, executeKey: String(stepDefinition.key || nodeId || '').trim() } + : null; +} +async function setNodeStatus(nodeId, status) { + return setStepStatus(getStepIdByNodeIdForState(nodeId), status); +} +function doesNodeUseCompletionSignal(nodeId, state = {}) { + return typeof doesStepUseCompletionSignal === 'function' + ? doesStepUseCompletionSignal(getStepIdByNodeIdForState(nodeId), state) + : false; +} +const rawGetStepRegistryForState = getStepRegistryForState; +getStepRegistryForState = function getNodeAdaptedStepRegistryForState(state = {}) { + const registry = rawGetStepRegistryForState(state); + if (!registry || typeof registry !== 'object' || typeof registry.getNodeDefinition === 'function') { + return registry; + } + return { + ...registry, + getNodeDefinition(nodeId) { + return getNodeDefinitionForState(nodeId, state); + }, + async executeNode(nodeId, payload = {}) { + const step = getStepIdByNodeIdForState(nodeId); + return registry.executeStep(step, payload); + }, + }; +}; +`; + test('throwIfStopped rethrows an explicit stop error even when stopRequested has been cleared', () => { const api = new Function(` const STOP_ERROR_MESSAGE = '流程已被用户停止。'; @@ -67,7 +132,7 @@ return { ); }); -test('executeStep reuses the active top-level auth chain instead of starting a duplicate step', async () => { +test('executeNode reuses the active top-level auth chain instead of starting a duplicate node', async () => { const api = new Function(` const LOG_PREFIX = '[test]'; const STOP_ERROR_MESSAGE = '流程已被用户停止。'; @@ -137,14 +202,15 @@ function getStepDefinitionForState(step) { return { id: step, key: 'test-step' }; } +${NODE_EXECUTE_COMPAT_HELPERS} ${extractFunction('isStopError')} ${extractFunction('throwIfStopped')} ${extractFunction('isAuthChainStep')} -${extractFunction('acquireTopLevelAuthChainExecution')} -${extractFunction('executeStep')} +${extractFunction('acquireTopLevelAuthChainExecutionForNode')} +${extractFunction('executeNode')} return { - executeStep, + executeNode, releaseStep8() { if (releaseStep8) { releaseStep8(); @@ -156,9 +222,9 @@ return { }; `)(); - const firstRun = api.executeStep(8); + const firstRun = api.executeNode('fetch-login-code'); await new Promise((resolve) => setImmediate(resolve)); - const duplicateRun = api.executeStep(7); + const duplicateRun = api.executeNode('oauth-login'); await new Promise((resolve) => setImmediate(resolve)); api.releaseStep8(); @@ -173,7 +239,7 @@ return { assert.ok(events.logs.some(({ message }) => /复用当前授权链/.test(message))); }); -test('executeStep stops flow when browser-switch-required error is raised', async () => { +test('executeNode stops flow when browser-switch-required error is raised', async () => { const api = new Function(` const LOG_PREFIX = '[test]'; const STOP_ERROR_MESSAGE = '流程已被用户停止。'; @@ -235,17 +301,18 @@ function getStepDefinitionForState(step) { return { id: step, key: 'test-step' }; } +${NODE_EXECUTE_COMPAT_HELPERS} ${extractFunction('isStopError')} ${extractFunction('throwIfStopped')} ${extractFunction('isAuthChainStep')} -${extractFunction('acquireTopLevelAuthChainExecution')} +${extractFunction('acquireTopLevelAuthChainExecutionForNode')} ${extractFunction('isBrowserSwitchRequiredError')} ${extractFunction('getBrowserSwitchRequiredMessage')} ${extractFunction('handleBrowserSwitchRequired')} -${extractFunction('executeStep')} +${extractFunction('executeNode')} return { - executeStep, + executeNode, snapshot() { return events; }, @@ -253,7 +320,7 @@ return { `)(); await assert.rejects( - () => api.executeStep(10), + () => api.executeNode('platform-verify'), /流程已被用户停止。/ ); @@ -393,7 +460,7 @@ return { assert.match(events.logs[0].message, /授权后链总超时已关闭/); }); -test('executeStep retries fetch-network errors for step 4 with cooldown and bounded attempts', async () => { +test('executeNode retries fetch-network errors for fetch-signup-code with cooldown and bounded attempts', async () => { const api = new Function(` const LOG_PREFIX = '[test]'; const STOP_ERROR_MESSAGE = '流程已被用户停止。'; @@ -456,27 +523,28 @@ function getStepDefinitionForState(step) { return { id: step, key: 'fetch-signup-code' }; } +${NODE_EXECUTE_COMPAT_HELPERS} ${extractFunction('isStopError')} ${extractFunction('isRetryableContentScriptTransportError')} ${extractFunction('isStepFetchNetworkRetryableError')} ${extractFunction('getStepFetchNetworkRetryPolicy')} ${extractFunction('throwIfStopped')} ${extractFunction('isAuthChainStep')} -${extractFunction('acquireTopLevelAuthChainExecution')} +${extractFunction('acquireTopLevelAuthChainExecutionForNode')} ${extractFunction('isBrowserSwitchRequiredError')} ${extractFunction('getBrowserSwitchRequiredMessage')} ${extractFunction('handleBrowserSwitchRequired')} -${extractFunction('executeStep')} +${extractFunction('executeNode')} return { - executeStep, + executeNode, snapshot() { return events; }, }; `)(); - await api.executeStep(4); + await api.executeNode('fetch-signup-code'); const events = api.snapshot(); assert.deepStrictEqual(events.registryCalls, [4, 4, 4]); diff --git a/tests/background-auto-run-module.test.js b/tests/background-auto-run-module.test.js index 7152ccc..e54fb2f 100644 --- a/tests/background-auto-run-module.test.js +++ b/tests/background-auto-run-module.test.js @@ -16,30 +16,30 @@ test('auto-run controller module exposes a factory', () => { assert.equal(typeof api?.createAutoRunController, 'function'); }); -test('auto-run account record status preserves the real failed step instead of parsing guidance text', () => { +test('auto-run account record status preserves the real failed node instead of parsing guidance text', () => { const source = fs.readFileSync('background/auto-run-controller.js', 'utf8'); const globalScope = {}; const api = new Function('self', `${source}; return self.MultiPageBackgroundAutoRunController;`)(globalScope); const controller = api.createAutoRunController({}); const state = { - currentStep: 11, - stepStatuses: { - 2: 'completed', - 10: 'completed', - 11: 'failed', + currentNodeId: 'fetch-login-code', + nodeStatuses: { + 'submit-signup-email': 'completed', + 'oauth-login': 'completed', + 'fetch-login-code': 'failed', }, }; const error = new Error('缺少登录账号:请先完成步骤 2,或在侧栏填写账号后再执行当前步骤。'); assert.equal( controller.resolveAutoRunAccountRecordStatus('failed', state, error), - 'step11_failed' + 'node:fetch-login-code:failed' ); - error.failedStep = 13; + error.failedNodeId = 'platform-verify'; assert.equal( controller.resolveAutoRunAccountRecordStatus('failed', state, error), - 'step13_failed' + 'node:platform-verify:failed' ); }); diff --git a/tests/background-mail-rule-registry-module.test.js b/tests/background-mail-rule-registry-module.test.js index d487c42..f63dac8 100644 --- a/tests/background-mail-rule-registry-module.test.js +++ b/tests/background-mail-rule-registry-module.test.js @@ -40,6 +40,7 @@ test('mail rule registry exposes canonical OpenAI verification poll payloads', ( { flowId: 'openai', ruleId: 'openai-signup-code', + nodeId: 'fetch-signup-code', step: 4, artifactType: 'code', codePatterns: [ @@ -78,6 +79,7 @@ test('mail rule registry exposes canonical OpenAI verification poll payloads', ( { flowId: 'openai', ruleId: 'openai-login-code', + nodeId: 'fetch-login-code', step: 8, artifactType: 'code', codePatterns: [ @@ -105,6 +107,14 @@ test('mail rule registry exposes canonical OpenAI verification poll payloads', ( intervalMs: 3000, } ); + + assert.equal( + registry.buildVerificationPollPayloadForNode('fetch-signup-code', { + activeFlowId: 'openai', + email: 'node@example.com', + }).nodeId, + 'fetch-signup-code' + ); }); test('mail rule registry rejects unknown active flow ids instead of silently using OpenAI rules', () => { diff --git a/tests/background-message-router-plus-final-step.test.js b/tests/background-message-router-plus-final-step.test.js index 723f3f2..cb6e645 100644 --- a/tests/background-message-router-plus-final-step.test.js +++ b/tests/background-message-router-plus-final-step.test.js @@ -30,10 +30,10 @@ test('message router appends success record on Plus final step instead of hard-c deleteIcloudAlias: async () => {}, deleteUsedIcloudAliases: async () => {}, disableUsedLuckmailPurchases: async () => {}, - doesStepUseCompletionSignal: () => false, + doesNodeUseCompletionSignal: () => false, ensureManualInteractionAllowed: async () => ({}), - executeStep: async () => {}, - executeStepViaCompletionSignal: async () => {}, + executeNode: async () => {}, + executeNodeViaCompletionSignal: async () => {}, exportSettingsBundle: async () => ({}), fetchGeneratedEmail: async () => '', finalizeStep3Completion: async () => {}, @@ -43,7 +43,9 @@ test('message router appends success record on Plus final step instead of hard-c getCurrentLuckmailPurchase: () => null, getPendingAutoRunTimerPlan: () => null, getSourceLabel: () => '', - getState: async () => ({ plusModeEnabled: true, stepStatuses: { 13: 'pending' } }), + getState: async () => ({ plusModeEnabled: true, nodeStatuses: { 'platform-verify': 'pending' } }), + getNodeIdsForState: () => ['open-chatgpt', 'oauth-login', 'fetch-login-code', 'confirm-oauth', 'platform-verify'], + getStepIdByNodeIdForState: (nodeId) => ({ 'oauth-login': 10, 'fetch-login-code': 11, 'confirm-oauth': 12, 'platform-verify': 13 }[nodeId] || 0), getLastStepIdForState: () => 13, getStepDefinitionForState: (step) => ({ id: step, key: step === 10 ? 'oauth-login' : 'platform-verify' }), getStepIdsForState: () => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], @@ -66,8 +68,8 @@ test('message router appends success record on Plus final step instead of hard-c normalizeHotmailAccounts: (items) => items, normalizeRunCount: (value) => value, AUTO_RUN_TIMER_KIND_SCHEDULED_START: 'scheduled', - notifyStepComplete: () => {}, - notifyStepError: () => {}, + notifyNodeComplete: () => {}, + notifyNodeError: () => {}, patchHotmailAccount: async () => {}, patchMail2925Account: async () => {}, registerTab: async () => {}, @@ -88,9 +90,9 @@ test('message router appends success record on Plus final step instead of hard-c setLuckmailPurchaseUsedState: async () => {}, setPersistentSettings: async () => {}, setState: async () => {}, - setStepStatus: async () => {}, + setNodeStatus: async () => {}, skipAutoRunCountdown: async () => false, - skipStep: async () => {}, + skipNode: async () => {}, startAutoRunLoop: async () => {}, syncHotmailAccounts: async () => {}, testHotmailAccountMailAccess: async () => {}, @@ -98,7 +100,7 @@ test('message router appends success record on Plus final step instead of hard-c verifyHotmailAccount: async () => {}, }); - await router.handleMessage({ type: 'STEP_COMPLETE', step: 13, payload: {} }, {}); + await router.handleMessage({ type: 'NODE_COMPLETE', nodeId: 'platform-verify', payload: { nodeId: 'platform-verify' } }, {}); assert.equal(appendCalls.length, 1); assert.equal(appendCalls[0][0], 'success'); diff --git a/tests/background-message-router-step2-skip.test.js b/tests/background-message-router-step2-skip.test.js index 5a9b78c..d48e56f 100644 --- a/tests/background-message-router-step2-skip.test.js +++ b/tests/background-message-router-step2-skip.test.js @@ -10,6 +10,7 @@ function createRouter(overrides = {}) { const events = { logs: [], stepStatuses: [], + nodeStatuses: [], stateUpdates: [], broadcasts: [], balanceRefreshes: [], @@ -26,10 +27,63 @@ function createRouter(overrides = {}) { executedSteps: [], accountRecords: [], }; + const nodeByStep = { + 1: 'open-chatgpt', + 2: 'submit-signup-email', + 3: 'fill-password', + 4: 'fetch-signup-code', + 5: 'fill-profile', + 6: 'wait-registration-success', + 7: 'oauth-login', + 8: 'fetch-login-code', + 9: 'confirm-oauth', + 10: 'platform-verify', + 11: 'fetch-login-code', + 12: 'confirm-oauth', + 13: 'platform-verify', + }; + const normalStepByNode = { + 'open-chatgpt': 1, + 'submit-signup-email': 2, + 'fill-password': 3, + 'fetch-signup-code': 4, + 'fill-profile': 5, + 'wait-registration-success': 6, + 'oauth-login': 7, + 'fetch-login-code': 8, + 'confirm-oauth': 9, + 'platform-verify': 10, + }; + const plusStepByNode = { + 'open-chatgpt': 1, + 'submit-signup-email': 2, + 'fill-password': 3, + 'fetch-signup-code': 4, + 'fill-profile': 5, + 'oauth-login': 10, + 'fetch-login-code': 11, + 'confirm-oauth': 12, + 'platform-verify': 13, + }; + const getStepForNode = (nodeId) => { + const state = normalizeState(overrides.state || {}); + return (state.plusModeEnabled ? plusStepByNode : normalStepByNode)[nodeId] || 0; + }; + const normalizeState = (state = {}) => { + const next = { ...(state || {}) }; + if (!next.nodeStatuses && next.stepStatuses) { + next.nodeStatuses = Object.fromEntries( + Object.entries(next.stepStatuses) + .map(([step, status]) => [nodeByStep[Number(step)], status]) + .filter(([nodeId]) => Boolean(nodeId)) + ); + } + return next; + }; const router = api.createMessageRouter({ addLog: async (message, level, options = {}) => { - events.logs.push({ message, level, step: options.step, stepKey: options.stepKey }); + events.logs.push({ message, level, step: options.step, stepKey: options.stepKey, nodeId: options.nodeId }); }, appendAccountRunRecord: overrides.appendAccountRunRecord || (async (status, state, reason) => { events.accountRecords.push({ status, state, reason }); @@ -49,20 +103,20 @@ function createRouter(overrides = {}) { clearStopRequest: () => {}, closeLocalhostCallbackTabs: async () => {}, closeTabsByUrlPrefix: async () => {}, - completeStepFromBackground: async (step, payload) => { - events.notifyCompletions.push({ step, payload, via: 'completeStepFromBackground' }); + completeNodeFromBackground: async (nodeId, payload) => { + events.notifyCompletions.push({ step: getStepForNode(nodeId), nodeId, payload, via: 'completeNodeFromBackground' }); }, deleteHotmailAccount: async () => {}, deleteHotmailAccounts: async () => {}, deleteIcloudAlias: async () => {}, deleteUsedIcloudAliases: async () => {}, disableUsedLuckmailPurchases: async () => {}, - doesStepUseCompletionSignal: () => false, + doesNodeUseCompletionSignal: () => false, ensureManualInteractionAllowed: async () => ({}), - executeStep: async (step) => { - events.executedSteps.push(step); + executeNode: async (nodeId) => { + events.executedSteps.push(getStepForNode(nodeId) || nodeId); }, - executeStepViaCompletionSignal: async () => {}, + executeNodeViaCompletionSignal: async () => {}, exportSettingsBundle: async () => ({}), fetchGeneratedEmail: async () => '', finalizePhoneActivationAfterSuccessfulFlow: overrides.finalizePhoneActivationAfterSuccessfulFlow || (async (state) => { @@ -77,7 +131,9 @@ function createRouter(overrides = {}) { getCurrentLuckmailPurchase: () => null, getPendingAutoRunTimerPlan: () => null, getSourceLabel: () => '', - getState: async () => overrides.state || { stepStatuses: { 3: 'pending' } }, + getState: async () => normalizeState(overrides.state || { nodeStatuses: { 'fill-password': 'pending' } }), + getNodeIdsForState: () => ['open-chatgpt', 'submit-signup-email', 'fill-password', 'fetch-signup-code', 'fill-profile', 'wait-registration-success', 'oauth-login', 'fetch-login-code', 'confirm-oauth', 'platform-verify'], + getStepIdByNodeIdForState: (nodeId, state = {}) => (state.plusModeEnabled ? plusStepByNode : normalStepByNode)[nodeId] || 0, getStepDefinitionForState: overrides.getStepDefinitionForState, getStepIdsForState: overrides.getStepIdsForState, getLastStepIdForState: overrides.getLastStepIdForState, @@ -106,11 +162,11 @@ function createRouter(overrides = {}) { normalizeHotmailAccounts: (items) => items, normalizeRunCount: (value) => value, AUTO_RUN_TIMER_KIND_SCHEDULED_START: 'scheduled', - notifyStepComplete: (step, payload) => { - events.notifyCompletions.push({ step, payload }); + notifyNodeComplete: (nodeId, payload) => { + events.notifyCompletions.push({ step: getStepForNode(nodeId), nodeId, payload }); }, - notifyStepError: (step, error) => { - events.notifyErrors.push({ step, error }); + notifyNodeError: (nodeId, error) => { + events.notifyErrors.push({ step: getStepForNode(nodeId), nodeId, error }); }, patchHotmailAccount: async () => {}, registerTab: async () => {}, @@ -142,11 +198,13 @@ function createRouter(overrides = {}) { setState: async (updates) => { events.stateUpdates.push(updates); }, - setStepStatus: async (step, status) => { + setNodeStatus: async (nodeId, status) => { + events.nodeStatuses.push({ nodeId, status }); + const step = getStepForNode(nodeId); events.stepStatuses.push({ step, status }); }, skipAutoRunCountdown: async () => false, - skipStep: async () => {}, + skipNode: async () => {}, startAutoRunLoop: async () => {}, syncHotmailAccounts: async () => {}, testHotmailAccountMailAccess: async () => {}, @@ -366,10 +424,11 @@ test('message router finalizes step 3 before marking it completed', async () => const { router, events } = createRouter(); const response = await router.handleMessage({ - type: 'STEP_COMPLETE', - step: 3, + type: 'NODE_COMPLETE', + nodeId: 'fill-password', source: 'signup-page', payload: { + nodeId: 'fill-password', email: 'user@example.com', signupVerificationRequestedAt: 123, }, @@ -377,8 +436,10 @@ test('message router finalizes step 3 before marking it completed', async () => assert.deepStrictEqual(events.finalizePayloads, [ { + nodeId: 'fill-password', email: 'user@example.com', signupVerificationRequestedAt: 123, + step: 3, }, ]); assert.deepStrictEqual(events.stepStatuses, [{ step: 3, status: 'completed' }]); @@ -386,9 +447,12 @@ test('message router finalizes step 3 before marking it completed', async () => assert.deepStrictEqual(events.notifyCompletions, [ { step: 3, + nodeId: 'fill-password', payload: { + nodeId: 'fill-password', email: 'user@example.com', signupVerificationRequestedAt: 123, + step: 3, }, }, ]); @@ -436,7 +500,8 @@ test('message router finalizes pending phone activation on platform verify succe localhostUrl: 'http://localhost:1455/auth/callback?code=ok', }); - assert.deepStrictEqual(events.phoneFinalizations, [state]); + assert.equal(events.phoneFinalizations.length, 1); + assert.deepStrictEqual(events.phoneFinalizations[0].pendingPhoneActivationConfirmation, state.pendingPhoneActivationConfirmation); }); test('message router does not finalize pending phone activation when icloud finalization fails', async () => { @@ -481,10 +546,11 @@ test('message router marks step 3 failed when post-submit finalize fails', async }); const response = await router.handleMessage({ - type: 'STEP_COMPLETE', - step: 3, + type: 'NODE_COMPLETE', + nodeId: 'fill-password', source: 'signup-page', payload: { + nodeId: 'fill-password', email: 'user@example.com', }, }, {}); @@ -493,10 +559,11 @@ test('message router marks step 3 failed when post-submit finalize fails', async assert.deepStrictEqual(events.notifyErrors, [ { step: 3, + nodeId: 'fill-password', error: '步骤 3 提交后仍停留在密码页。', }, ]); - assert.equal(events.logs.some(({ message, step }) => /失败:步骤 3 提交后仍停留在密码页。/.test(message) && step === 3), true); + assert.equal(events.logs.some(({ message, nodeId }) => /失败:步骤 3 提交后仍停留在密码页。/.test(message) && nodeId === 'fill-password'), true); assert.deepStrictEqual(response, { ok: true, error: '步骤 3 提交后仍停留在密码页。' }); }); @@ -512,10 +579,10 @@ test('message router does not duplicate step 3 mismatch failure log after finali }); const response = await router.handleMessage({ - type: 'STEP_ERROR', - step: 3, + type: 'NODE_ERROR', + nodeId: 'fill-password', source: 'signup-page', - payload: {}, + payload: { nodeId: 'fill-password' }, error: mismatchError, }, {}); @@ -524,6 +591,7 @@ test('message router does not duplicate step 3 mismatch failure log after finali assert.deepStrictEqual(events.notifyErrors, [ { step: 3, + nodeId: 'fill-password', error: mismatchError, }, ]); @@ -534,10 +602,10 @@ test('message router stops the flow and surfaces cloudflare security block error const { router, events } = createRouter(); const response = await router.handleMessage({ - type: 'STEP_ERROR', - step: 7, + type: 'NODE_ERROR', + nodeId: 'oauth-login', source: 'signup-page', - payload: {}, + payload: { nodeId: 'oauth-login' }, error: 'CF_SECURITY_BLOCKED::您已触发Cloudflare 安全防护系统', }, {}); @@ -545,6 +613,7 @@ test('message router stops the flow and surfaces cloudflare security block error assert.deepStrictEqual(events.notifyErrors, [ { step: 7, + nodeId: 'oauth-login', error: '流程已被用户停止。', }, ]); @@ -562,9 +631,10 @@ test('message router blocks manual step 4 execution when signup page tab is miss await assert.rejects( () => router.handleMessage({ - type: 'EXECUTE_STEP', + type: 'EXECUTE_NODE', source: 'sidepanel', - payload: { step: 4 }, + nodeId: 'fetch-signup-code', + payload: { nodeId: 'fetch-signup-code' }, }, {}), /手动执行步骤 4 前,请先执行步骤 1 或步骤 2/ ); @@ -630,18 +700,19 @@ test('message router ignores stale step 2 errors while auto-run is already on a state: { autoRunning: true, autoRunPhase: 'running', - currentStep: 6, - stepStatuses: { - 2: 'completed', - 6: 'running', + currentNodeId: 'wait-registration-success', + nodeStatuses: { + 'submit-signup-email': 'completed', + 'wait-registration-success': 'running', }, }, isAutoRunLockedState: (state) => Boolean(state?.autoRunning) && state?.autoRunPhase === 'running', }); const response = await router.handleMessage({ - type: 'STEP_ERROR', - step: 2, + type: 'NODE_ERROR', + nodeId: 'submit-signup-email', + payload: { nodeId: 'submit-signup-email' }, error: '步骤 2:旧页面异步失败,不应覆盖当前第 6 步记录。', }, {}); @@ -649,7 +720,7 @@ test('message router ignores stale step 2 errors while auto-run is already on a assert.deepStrictEqual(events.stepStatuses, []); assert.deepStrictEqual(events.notifyErrors, []); assert.deepStrictEqual(events.accountRecords, []); - assert.equal(events.logs.some(({ message }) => /忽略过期的步骤 2 失败消息/.test(message)), true); + assert.equal(events.logs.some(({ message }) => /忽略过期的节点 submit-signup-email 失败消息/.test(message)), true); }); test('message router ignores stale step 2 completion while auto-run is already on a later step', async () => { @@ -657,19 +728,20 @@ test('message router ignores stale step 2 completion while auto-run is already o state: { autoRunning: true, autoRunPhase: 'running', - currentStep: 6, - stepStatuses: { - 2: 'completed', - 6: 'running', + currentNodeId: 'wait-registration-success', + nodeStatuses: { + 'submit-signup-email': 'completed', + 'wait-registration-success': 'running', }, }, isAutoRunLockedState: (state) => Boolean(state?.autoRunning) && state?.autoRunPhase === 'running', }); const response = await router.handleMessage({ - type: 'STEP_COMPLETE', - step: 2, + type: 'NODE_COMPLETE', + nodeId: 'submit-signup-email', payload: { + nodeId: 'submit-signup-email', email: 'late@example.com', }, }, {}); @@ -678,5 +750,5 @@ test('message router ignores stale step 2 completion while auto-run is already o assert.deepStrictEqual(events.stepStatuses, []); assert.deepStrictEqual(events.notifyCompletions, []); assert.deepStrictEqual(events.emailStates, []); - assert.equal(events.logs.some(({ message }) => /忽略过期的步骤 2 完成消息/.test(message)), true); + assert.equal(events.logs.some(({ message }) => /忽略过期的节点 submit-signup-email 完成消息/.test(message)), true); }); diff --git a/tests/background-platform-verify-codex2api.test.js b/tests/background-platform-verify-codex2api.test.js index b3f95ab..1a3ba7a 100644 --- a/tests/background-platform-verify-codex2api.test.js +++ b/tests/background-platform-verify-codex2api.test.js @@ -1,4 +1,4 @@ -const assert = require('node:assert/strict'); +const assert = require('node:assert/strict'); const fs = require('node:fs'); const test = require('node:test'); @@ -35,7 +35,7 @@ test('platform verify module supports codex2api protocol callback exchange', asy }, chrome: {}, closeConflictingTabsForSource: async () => {}, - completeStepFromBackground: async (step, payload) => { + completeNodeFromBackground: async (step, payload) => { completed.push({ step, payload }); }, ensureContentScriptReadyOnTab: async () => {}, @@ -68,7 +68,7 @@ test('platform verify module supports codex2api protocol callback exchange', asy ]); assert.deepStrictEqual(completed, [ { - step: 10, + step: 'platform-verify', payload: { localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', verifiedStatus: 'OAuth 账号 flow@example.com 添加成功', @@ -98,7 +98,7 @@ test('platform verify retries transient SUB2API oauth/token exchange errors befo }, }, closeConflictingTabsForSource: async () => {}, - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, ensureContentScriptReadyOnTab: async () => {}, getPanelMode: () => 'sub2api', getTabId: async () => 12, @@ -166,7 +166,7 @@ test('platform verify retries transient SUB2API token_exchange_user_error before }, }, closeConflictingTabsForSource: async () => {}, - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, ensureContentScriptReadyOnTab: async () => {}, getPanelMode: () => 'sub2api', getTabId: async () => 12, diff --git a/tests/background-platform-verify-cpa-api.test.js b/tests/background-platform-verify-cpa-api.test.js index 9ecf3df..4aaf2fe 100644 --- a/tests/background-platform-verify-cpa-api.test.js +++ b/tests/background-platform-verify-cpa-api.test.js @@ -1,4 +1,4 @@ -const assert = require('node:assert/strict'); +const assert = require('node:assert/strict'); const fs = require('node:fs'); const test = require('node:test'); @@ -17,7 +17,7 @@ function createDeps(overrides = {}) { }, }, closeConflictingTabsForSource: async () => {}, - completeStepFromBackground: async (step, payload) => { + completeNodeFromBackground: async (step, payload) => { completed.push({ step, payload }); }, ensureContentScriptReadyOnTab: async () => {}, @@ -97,7 +97,7 @@ test('platform verify module submits CPA callback via management API first', asy assert.equal(uiCalled, false); assert.deepStrictEqual(completed, [ { - step: 10, + step: 'platform-verify', payload: { localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', verifiedStatus: 'CPA API 回调提交成功', @@ -317,7 +317,7 @@ test('platform verify module submits Plus visible step 13 to SUB2API via direct assert.equal(exchangeCall.body.state, 'oauth-state'); assert.deepStrictEqual(createCall.body.group_ids, [5]); assert.deepStrictEqual(completed, [{ - step: 13, + step: 'platform-verify', payload: { localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', verifiedStatus: 'SUB2API 已创建账号 #11', diff --git a/tests/background-runtime-state-module.test.js b/tests/background-runtime-state-module.test.js index df67866..701a082 100644 --- a/tests/background-runtime-state-module.test.js +++ b/tests/background-runtime-state-module.test.js @@ -22,29 +22,22 @@ test('runtime-state module exposes a factory', () => { assert.equal(typeof api?.createRuntimeStateHelpers, 'function'); }); -test('runtime-state view derives canonical flow metadata from legacy step state', () => { +test('runtime-state view preserves canonical flow metadata from node state', () => { const api = loadRuntimeStateApi(); const helpers = api.createRuntimeStateHelpers({ DEFAULT_ACTIVE_FLOW_ID: 'openai', - defaultStepStatuses: { - 1: 'pending', - 2: 'pending', - 10: 'pending', - }, - getStepDefinitionForState(step) { - return { - 1: { id: 1, key: 'open-chatgpt' }, - 2: { id: 2, key: 'submit-signup-email' }, - 10: { id: 10, key: 'oauth-login' }, - }[Number(step)] || null; + defaultNodeStatuses: { + 'open-chatgpt': 'pending', + 'submit-signup-email': 'pending', + 'oauth-login': 'pending', }, }); const view = helpers.buildStateView({ - currentStep: 2, - stepStatuses: { - 1: 'completed', - 2: 'running', + currentNodeId: 'submit-signup-email', + nodeStatuses: { + 'open-chatgpt': 'completed', + 'submit-signup-email': 'running', }, oauthUrl: 'https://auth.example.com/start', plusCheckoutTabId: 88, @@ -63,14 +56,9 @@ test('runtime-state view derives canonical flow metadata from legacy step state' assert.equal(view.activeFlowId, 'openai'); assert.equal(view.currentNodeId, 'submit-signup-email'); - assert.deepStrictEqual(view.legacyStepCompat, { - currentStep: 2, - stepStatuses: { - 1: 'completed', - 2: 'running', - 10: 'pending', - }, - }); + assert.equal(Object.prototype.hasOwnProperty.call(view, 'legacyStepCompat'), false); + assert.equal(Object.prototype.hasOwnProperty.call(view, 'currentStep'), false); + assert.equal(Object.prototype.hasOwnProperty.call(view, 'stepStatuses'), false); assert.deepStrictEqual(view.nodeStatuses, { 'open-chatgpt': 'completed', 'submit-signup-email': 'running', @@ -93,35 +81,33 @@ test('runtime-state view derives canonical flow metadata from legacy step state' }); }); -test('runtime-state patch accepts nested flow updates while keeping legacy compatibility fields in sync', () => { +test('runtime-state patch accepts nested flow and node updates without legacy step state', () => { const api = loadRuntimeStateApi(); const helpers = api.createRuntimeStateHelpers({ DEFAULT_ACTIVE_FLOW_ID: 'openai', - defaultStepStatuses: { - 1: 'pending', - 2: 'pending', - 10: 'pending', - }, - getStepDefinitionForState(step) { - return { - 1: { id: 1, key: 'open-chatgpt' }, - 2: { id: 2, key: 'submit-signup-email' }, - 10: { id: 10, key: 'oauth-login' }, - }[Number(step)] || null; + defaultNodeStatuses: { + 'open-chatgpt': 'pending', + 'submit-signup-email': 'pending', + 'oauth-login': 'pending', }, }); const patch = helpers.buildSessionStatePatch({ - currentStep: 1, - stepStatuses: { - 1: 'running', - 2: 'pending', - 10: 'pending', + currentNodeId: 'open-chatgpt', + nodeStatuses: { + 'open-chatgpt': 'running', + 'submit-signup-email': 'pending', + 'oauth-login': 'pending', }, oauthUrl: 'https://old.example.com/start', }, { runtimeState: { activeRunId: 'run-001', + currentNodeId: 'oauth-login', + nodeStatuses: { + 'open-chatgpt': 'completed', + 'oauth-login': 'running', + }, flowState: { openai: { auth: { @@ -132,13 +118,6 @@ test('runtime-state patch accepts nested flow updates while keeping legacy compa }, }, }, - legacyStepCompat: { - currentStep: 10, - stepStatuses: { - 1: 'completed', - 10: 'running', - }, - }, }, }); @@ -147,12 +126,8 @@ test('runtime-state patch accepts nested flow updates while keeping legacy compa assert.equal(patch.currentNodeId, 'oauth-login'); assert.equal(patch.oauthUrl, 'https://new.example.com/start'); assert.equal(patch.plusCheckoutTabId, 99); - assert.equal(patch.currentStep, 10); - assert.deepStrictEqual(patch.stepStatuses, { - 1: 'completed', - 2: 'pending', - 10: 'running', - }); + assert.equal(Object.prototype.hasOwnProperty.call(patch, 'currentStep'), false); + assert.equal(Object.prototype.hasOwnProperty.call(patch, 'stepStatuses'), false); assert.deepStrictEqual(patch.nodeStatuses, { 'open-chatgpt': 'completed', 'submit-signup-email': 'pending', diff --git a/tests/background-signup-step2-branching.test.js b/tests/background-signup-step2-branching.test.js index 864f685..5b0d324 100644 --- a/tests/background-signup-step2-branching.test.js +++ b/tests/background-signup-step2-branching.test.js @@ -1,4 +1,4 @@ -const test = require('node:test'); +const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); @@ -20,7 +20,7 @@ test('step 2 completes with password step skipped when landing on email verifica const executor = step2Api.createStep2Executor({ addLog: async () => {}, chrome: { tabs: { update: async () => {} } }, - completeStepFromBackground: async (step, payload) => { + completeNodeFromBackground: async (step, payload) => { completedPayloads.push({ step, payload }); }, ensureContentScriptReadyOnTab: async () => {}, @@ -40,7 +40,7 @@ test('step 2 completes with password step skipped when landing on email verifica assert.deepStrictEqual(completedPayloads, [ { - step: 2, + step: 'submit-signup-email', payload: { email: 'user@example.com', accountIdentifierType: 'email', @@ -59,7 +59,7 @@ test('step 2 keeps password flow when landing on password page', async () => { const executor = step2Api.createStep2Executor({ addLog: async () => {}, chrome: { tabs: { update: async () => {} } }, - completeStepFromBackground: async (step, payload) => { + completeNodeFromBackground: async (step, payload) => { completedPayloads.push({ step, payload }); }, ensureContentScriptReadyOnTab: async () => {}, @@ -79,7 +79,7 @@ test('step 2 keeps password flow when landing on password page', async () => { assert.deepStrictEqual(completedPayloads, [ { - step: 2, + step: 'submit-signup-email', payload: { email: 'user@example.com', accountIdentifierType: 'email', @@ -110,7 +110,7 @@ test('step 2 uses phone activation when resolved signup method is phone', async const executor = step2Api.createStep2Executor({ addLog: async () => {}, chrome: { tabs: { update: async () => {} } }, - completeStepFromBackground: async (step, payload) => { + completeNodeFromBackground: async (step, payload) => { completedPayloads.push({ step, payload }); }, ensureContentScriptReadyOnTab: async () => {}, @@ -170,7 +170,7 @@ test('step 2 uses phone activation when resolved signup method is phone', async ]); assert.deepStrictEqual(completedPayloads, [ { - step: 2, + step: 'submit-signup-email', payload: { accountIdentifierType: 'phone', accountIdentifier: '66959916439', @@ -200,7 +200,7 @@ test('step 2 reuses existing signup phone activation without acquiring a new num const executor = step2Api.createStep2Executor({ addLog: async () => {}, chrome: { tabs: { update: async () => {} } }, - completeStepFromBackground: async (step, payload) => { + completeNodeFromBackground: async (step, payload) => { completedPayloads.push({ step, payload }); }, ensureContentScriptReadyOnTab: async () => {}, @@ -263,7 +263,7 @@ test('step 2 submits manual signup phone without acquiring a number', async () = const executor = step2Api.createStep2Executor({ addLog: async () => {}, chrome: { tabs: { update: async () => {} } }, - completeStepFromBackground: async (step, payload) => { + completeNodeFromBackground: async (step, payload) => { completedPayloads.push({ step, payload }); }, ensureContentScriptReadyOnTab: async () => {}, @@ -310,7 +310,7 @@ test('step 2 submits manual signup phone without acquiring a number', async () = ]); assert.deepStrictEqual(completedPayloads, [ { - step: 2, + step: 'submit-signup-email', payload: { accountIdentifierType: 'phone', accountIdentifier: '+446700000002', @@ -338,7 +338,7 @@ test('step 2 stops with an explicit error instead of silently skipping 3/4/5 on get: async () => ({ url: 'https://chatgpt.com/' }), }, }, - completeStepFromBackground: async (step, payload) => { + completeNodeFromBackground: async (step, payload) => { completedPayloads.push({ step, payload }); }, ensureContentScriptReadyOnTab: async () => {}, @@ -382,7 +382,7 @@ test('step 2 does not force auth-entry retry on logged-out chatgpt home when con get: async () => ({ url: 'https://chatgpt.com/' }), }, }, - completeStepFromBackground: async (step, payload) => { + completeNodeFromBackground: async (step, payload) => { completedPayloads.push({ step, payload }); }, ensureContentScriptReadyOnTab: async () => {}, @@ -414,7 +414,7 @@ test('step 2 does not force auth-entry retry on logged-out chatgpt home when con assert.deepStrictEqual(sentPayloads, [{ email: 'user@example.com' }]); assert.deepStrictEqual(completedPayloads, [ { - step: 2, + step: 'submit-signup-email', payload: { email: 'user@example.com', accountIdentifierType: 'email', @@ -450,7 +450,7 @@ test('step 2 waits for the existing signup tab to settle before probing the entr }, }, }, - completeStepFromBackground: async (step, payload) => { + completeNodeFromBackground: async (step, payload) => { completedPayloads.push({ step, payload }); }, ensureContentScriptReadyOnTab: async () => { @@ -499,7 +499,7 @@ test('step 2 waits for the existing signup tab to settle before probing the entr assert.equal(logs.some((item) => item.meta.step === 2 && item.meta.stepKey === 'signup-entry'), true); assert.deepStrictEqual(completedPayloads, [ { - step: 2, + step: 'submit-signup-email', payload: { email: 'user@example.com', accountIdentifierType: 'email', diff --git a/tests/background-skip-step-linking.test.js b/tests/background-skip-step-linking.test.js index 7c0e450..6a95fc0 100644 --- a/tests/background-skip-step-linking.test.js +++ b/tests/background-skip-step-linking.test.js @@ -4,6 +4,19 @@ const fs = require('node:fs'); const source = fs.readFileSync('background.js', 'utf8'); +const OPENAI_NODE_IDS = [ + 'open-chatgpt', + 'submit-signup-email', + 'fill-password', + 'fetch-signup-code', + 'fill-profile', + 'wait-registration-success', + 'oauth-login', + 'fetch-login-code', + 'confirm-oauth', + 'platform-verify', +]; + function extractFunction(name) { const markers = [`async function ${name}(`, `function ${name}(`]; const start = markers @@ -48,113 +61,107 @@ function extractFunction(name) { return source.slice(start, end); } -test('skipStep cascades from step 1 to step 5 when downstream steps are pending', async () => { +function createApi() { const bundle = [ extractFunction('isStepDoneStatus'), - extractFunction('skipStep'), + extractFunction('skipNode'), ].join('\n'); - const statuses = { - 1: 'pending', - 2: 'pending', - 3: 'pending', - 4: 'pending', - 5: 'pending', - 6: 'pending', - 7: 'pending', - 8: 'pending', - 9: 'pending', - 10: 'pending', - }; + return new Function(` +const DEFAULT_STATE = { nodeStatuses: {} }; +function getNodeIdsForState() { + return ${JSON.stringify(OPENAI_NODE_IDS)}; +} +function normalizeStatusMapForNodes(statuses = {}) { + return { ...statuses }; +} +${bundle} +return { skipNode }; +`)(); +} +test('skipNode cascades from open-chatgpt through signup profile when downstream nodes are pending', async () => { + const statuses = Object.fromEntries(OPENAI_NODE_IDS.map((nodeId) => [nodeId, 'pending'])); const events = { - setStepStatusCalls: [], + setNodeStatusCalls: [], logs: [], }; - - const api = new Function(` -const STEP_IDS = [1,2,3,4,5,6,7,8,9,10]; -${bundle} -return { skipStep }; -`)(); + const api = createApi(); globalThis.ensureManualInteractionAllowed = async () => ({ - stepStatuses: { ...statuses }, + nodeStatuses: { ...statuses }, }); globalThis.getState = async () => ({ - stepStatuses: { ...statuses }, + nodeStatuses: { ...statuses }, }); - globalThis.setStepStatus = async (step, status) => { - events.setStepStatusCalls.push({ step, status }); - statuses[step] = status; + globalThis.setNodeStatus = async (nodeId, status) => { + events.setNodeStatusCalls.push({ nodeId, status }); + statuses[nodeId] = status; }; globalThis.addLog = async (message, level) => { events.logs.push({ message, level }); }; - const result = await api.skipStep(1); + const result = await api.skipNode('open-chatgpt'); - assert.deepStrictEqual(result, { ok: true, step: 1, status: 'skipped' }); - assert.deepStrictEqual(events.setStepStatusCalls, [ - { step: 1, status: 'skipped' }, - { step: 2, status: 'skipped' }, - { step: 3, status: 'skipped' }, - { step: 4, status: 'skipped' }, - { step: 5, status: 'skipped' }, + assert.deepStrictEqual(result, { ok: true, nodeId: 'open-chatgpt', status: 'skipped' }); + assert.deepStrictEqual(events.setNodeStatusCalls, [ + { nodeId: 'open-chatgpt', status: 'skipped' }, + { nodeId: 'submit-signup-email', status: 'skipped' }, + { nodeId: 'fill-password', status: 'skipped' }, + { nodeId: 'fetch-signup-code', status: 'skipped' }, + { nodeId: 'fill-profile', status: 'skipped' }, ]); - assert.equal(events.logs[0]?.message, '步骤 1 已跳过'); - assert.equal(events.logs[1]?.message, '步骤 1 已跳过,步骤 2、3、4、5 也已同时跳过。'); + assert.equal(events.logs[0]?.message, '节点 open-chatgpt 已跳过'); + assert.equal( + events.logs[1]?.message, + '节点 open-chatgpt 已跳过,节点 submit-signup-email、fill-password、fetch-signup-code、fill-profile 也已同时跳过。' + ); }); -test('skipStep from step 1 skips only unfinished steps up to step 5', async () => { - const bundle = [ - extractFunction('isStepDoneStatus'), - extractFunction('skipStep'), - ].join('\n'); - +test('skipNode from open-chatgpt skips only unfinished linked signup nodes', async () => { const statuses = { - 1: 'pending', - 2: 'completed', - 3: 'running', - 4: 'pending', - 5: 'manual_completed', - 6: 'pending', - 7: 'pending', - 8: 'pending', - 9: 'pending', - 10: 'pending', + 'open-chatgpt': 'pending', + 'submit-signup-email': 'completed', + 'fill-password': 'running', + 'fetch-signup-code': 'pending', + 'fill-profile': 'manual_completed', + 'wait-registration-success': 'pending', + 'oauth-login': 'pending', + 'fetch-login-code': 'pending', + 'confirm-oauth': 'pending', + 'platform-verify': 'pending', }; - const events = { - setStepStatusCalls: [], + setNodeStatusCalls: [], logs: [], }; - - const api = new Function(` -const STEP_IDS = [1,2,3,4,5,6,7,8,9,10]; -${bundle} -return { skipStep }; -`)(); + const api = createApi(); globalThis.ensureManualInteractionAllowed = async () => ({ - stepStatuses: { ...statuses }, + nodeStatuses: { ...statuses }, }); globalThis.getState = async () => ({ - stepStatuses: { ...statuses }, + nodeStatuses: { ...statuses }, }); - globalThis.setStepStatus = async (step, status) => { - events.setStepStatusCalls.push({ step, status }); - statuses[step] = status; + globalThis.setNodeStatus = async (nodeId, status) => { + events.setNodeStatusCalls.push({ nodeId, status }); + statuses[nodeId] = status; }; globalThis.addLog = async (message, level) => { events.logs.push({ message, level }); }; - await api.skipStep(1); + await api.skipNode('open-chatgpt'); - assert.deepStrictEqual(events.setStepStatusCalls, [ - { step: 1, status: 'skipped' }, - { step: 4, status: 'skipped' }, + assert.deepStrictEqual(events.setNodeStatusCalls, [ + { nodeId: 'open-chatgpt', status: 'skipped' }, + { nodeId: 'fetch-signup-code', status: 'skipped' }, ]); - assert.equal(events.logs.some(({ message }) => message === '步骤 1 已跳过,步骤 4 也已同时跳过。'), true); + assert.equal( + events.logs.some(({ message }) => ( + message === '节点 open-chatgpt 已跳过,节点 fetch-signup-code 也已同时跳过。' + )), + true + ); }); diff --git a/tests/background-step-completion.test.js b/tests/background-step-completion.test.js index 3efe6bf..84efa02 100644 --- a/tests/background-step-completion.test.js +++ b/tests/background-step-completion.test.js @@ -51,57 +51,56 @@ function extractFunction(name) { return source.slice(start, end); } -function createApi(events, lastStepId = 10) { - return new Function('events', 'lastStepId', ` +function createApi(events, lastNodeId = 'platform-verify') { + return new Function('events', 'lastNodeId', ` let stopRequested = false; const LOG_PREFIX = '[test]'; const STOP_ERROR_MESSAGE = '流程已被用户停止。'; -const LAST_STEP_ID = 10; function getErrorMessage(error) { return error?.message || String(error || ''); } async function getState() { events.push({ type: 'getState' }); - return { stepStatuses: {}, contributionMode: true }; + return { nodeStatuses: {}, contributionMode: true }; } -function getLastStepIdForState() { - return lastStepId; +function getLastNodeIdForState() { + return lastNodeId; } -async function setStepStatus(step, status) { - events.push({ type: 'status', step, status }); +async function setNodeStatus(nodeId, status) { + events.push({ type: 'status', nodeId, status }); } -async function addLog(message, level) { - events.push({ type: 'log', message, level }); +async function addLog(message, level, options = {}) { + events.push({ type: 'log', message, level, options }); } async function appendManualAccountRunRecordIfNeeded() { events.push({ type: 'manual-record' }); } -function notifyStepError(step, error) { - events.push({ type: 'error', step, error }); +function notifyNodeError(nodeId, error) { + events.push({ type: 'error', nodeId, error }); } -function notifyStepComplete(step, payload) { - events.push({ type: 'notify', step, payload }); +function notifyNodeComplete(nodeId, payload) { + events.push({ type: 'notify', nodeId, payload }); } -async function handleStepData(step, payload) { - events.push({ type: 'handle-start', step, payload }); +async function handleNodeData(nodeId, payload) { + events.push({ type: 'handle-start', nodeId, payload }); await new Promise((resolve) => setTimeout(resolve, 25)); - events.push({ type: 'handle-done', step }); + events.push({ type: 'handle-done', nodeId }); } async function appendAndBroadcastAccountRunRecord(status, state) { events.push({ type: 'record', status, state }); } -${extractFunction('runCompletedStepSideEffects')} -${extractFunction('reportCompletedStepSideEffectError')} -${extractFunction('completeStepFromBackground')} -return { completeStepFromBackground }; -`)(events, lastStepId); +${extractFunction('runCompletedNodeSideEffects')} +${extractFunction('reportCompletedNodeSideEffectError')} +${extractFunction('completeNodeFromBackground')} +return { completeNodeFromBackground }; +`)(events, lastNodeId); } -test('completeStepFromBackground releases final step before slow post-completion side effects', async () => { +test('completeNodeFromBackground releases final node before slow post-completion side effects', async () => { const events = []; - const api = createApi(events, 10); + const api = createApi(events, 'platform-verify'); - await api.completeStepFromBackground(10, { localhostUrl: 'http://localhost:1455/auth/callback?code=ok' }); + await api.completeNodeFromBackground('platform-verify', { localhostUrl: 'http://localhost:1455/auth/callback?code=ok' }); const types = events.map((event) => event.type); assert.equal(types.indexOf('notify') < types.indexOf('handle-start'), true); @@ -115,11 +114,11 @@ test('completeStepFromBackground releases final step before slow post-completion assert.equal(settledTypes.includes('record'), true); }); -test('completeStepFromBackground keeps non-final step data handling before completion signal', async () => { +test('completeNodeFromBackground keeps non-final node data handling before completion signal', async () => { const events = []; - const api = createApi(events, 10); + const api = createApi(events, 'platform-verify'); - await api.completeStepFromBackground(9, { localhostUrl: 'http://localhost:1455/auth/callback?code=ok' }); + await api.completeNodeFromBackground('confirm-oauth', { localhostUrl: 'http://localhost:1455/auth/callback?code=ok' }); const types = events.map((event) => event.type); assert.equal(types.indexOf('handle-done') < types.indexOf('notify'), true); diff --git a/tests/background-step-registry.test.js b/tests/background-step-registry.test.js index f11bd5a..a836d5f 100644 --- a/tests/background-step-registry.test.js +++ b/tests/background-step-registry.test.js @@ -2,18 +2,20 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); -test('background imports step registry and shared step definitions', () => { +test('background imports node registry and shared workflow definitions', () => { const source = fs.readFileSync('background.js', 'utf8'); assert.match(source, /background\/steps\/registry\.js/); assert.match(source, /data\/step-definitions\.js/); - assert.match(source, /MultiPageStepDefinitions\?\.getSteps/); + assert.match(source, /background\/workflow-engine\.js/); + assert.match(source, /MultiPageStepDefinitions\?\.getNodes/); assert.match(source, /getStepRegistryForState\(state\)/); - assert.match(source, /PLUS_PAYPAL_STEP_DEFINITIONS/); - assert.match(source, /PLUS_GOPAY_STEP_DEFINITIONS/); - assert.match(source, /plusPayPalStepRegistry/); - assert.match(source, /plusGoPayStepRegistry/); - assert.match(source, /normalizePlusPaymentMethod\(state\?\.plusPaymentMethod\) === PLUS_PAYMENT_METHOD_GOPAY/); - assert.match(source, /activeStepRegistry\.executeStep\(step,\s*\{/); + assert.match(source, /buildNodeRegistry\(definitions/); + assert.match(source, /PLUS_PAYPAL_STEP_DEFINITIONS/); + assert.match(source, /PLUS_GOPAY_STEP_DEFINITIONS/); + assert.match(source, /plusPayPalStepRegistry/); + assert.match(source, /plusGoPayStepRegistry/); + assert.match(source, /normalizePlusPaymentMethod\(state\?\.plusPaymentMethod\) === PLUS_PAYMENT_METHOD_GOPAY/); + assert.match(source, /activeStepRegistry\.executeNode\(normalizedNodeId,\s*\{/); assert.match(source, /background\/steps\/create-plus-checkout\.js/); assert.match(source, /background\/steps\/fill-plus-checkout\.js/); assert.match(source, /background\/steps\/gopay-manual-confirm\.js/); diff --git a/tests/background-step3-password-reuse.test.js b/tests/background-step3-password-reuse.test.js index 3ce6d6e..f70fda1 100644 --- a/tests/background-step3-password-reuse.test.js +++ b/tests/background-step3-password-reuse.test.js @@ -39,7 +39,8 @@ test('step 3 reuses existing generated password when rerunning the same email fl assert.deepStrictEqual(events.passwordStates, ['Secret123!']); assert.deepStrictEqual(events.messages, [ { - type: 'EXECUTE_STEP', + type: 'EXECUTE_NODE', + nodeId: 'fill-password', step: 3, source: 'background', payload: { @@ -109,7 +110,8 @@ test('step 3 supports phone-only signup identity when password page is present', ]); assert.deepStrictEqual(events.messages, [ { - type: 'EXECUTE_STEP', + type: 'EXECUTE_NODE', + nodeId: 'fill-password', step: 3, source: 'background', payload: { diff --git a/tests/background-step4-filter-window.test.js b/tests/background-step4-filter-window.test.js index 5d57e9d..8d51bab 100644 --- a/tests/background-step4-filter-window.test.js +++ b/tests/background-step4-filter-window.test.js @@ -1,4 +1,4 @@ -const test = require('node:test'); +const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); @@ -23,7 +23,7 @@ test('step 4 passes a fixed 10-minute lookback window to 2925 mailbox polling', }, }, }, - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypass: async () => {}, ensureMail2925MailboxSession: async () => { ensureCalls += 1; @@ -84,7 +84,7 @@ test('step 4 does not request a fresh code first for Cloudflare temp mail', asyn update: async () => {}, }, }, - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypass: async () => {}, ensureMail2925MailboxSession: async () => {}, getMailConfig: () => ({ @@ -135,7 +135,7 @@ test('step 4 checks iCloud session before polling iCloud mailbox', async () => { update: async () => {}, }, }, - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypass: async () => {}, ensureIcloudMailSession: async () => { icloudChecks += 1; @@ -183,7 +183,7 @@ test('step 4 forwards skipProfileStep when prepare stage already reached logged- update: async () => {}, }, }, - completeStepFromBackground: async (step, payload) => { + completeNodeFromBackground: async (step, payload) => { completions.push({ step, payload }); }, confirmCustomVerificationStepBypass: async () => {}, @@ -224,7 +224,7 @@ test('step 4 forwards skipProfileStep when prepare stage already reached logged- assert.deepStrictEqual(completions, [ { - step: 4, + step: 'fetch-signup-code', payload: { skipProfileStep: true }, }, ]); @@ -244,7 +244,7 @@ test('step 4 phone signup branch uses SMS helper and does not poll mailbox', asy update: async () => {}, }, }, - completeStepFromBackground: async (step, payload) => { + completeNodeFromBackground: async (step, payload) => { completions.push({ step, payload }); }, confirmCustomVerificationStepBypass: async () => {}, @@ -296,7 +296,7 @@ test('step 4 phone signup branch uses SMS helper and does not poll mailbox', asy assert.equal(Object.prototype.hasOwnProperty.call(phoneCalls[0].options, 'signupProfile'), true); assert.deepStrictEqual(completions, [ { - step: 4, + step: 'fetch-signup-code', payload: { phoneVerification: true, code: '', @@ -321,7 +321,7 @@ test('step 4 phone signup email-verification handoff polls mailbox instead of co update: async () => {}, }, }, - completeStepFromBackground: async (step, payload) => { + completeNodeFromBackground: async (step, payload) => { completions.push({ step, payload }); }, confirmCustomVerificationStepBypass: async () => {}, @@ -403,7 +403,7 @@ test('step 4 prepare retries transport by recovering retry page without replayin update: async () => {}, }, }, - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypass: async () => {}, ensureMail2925MailboxSession: async () => {}, getMailConfig: () => ({ diff --git a/tests/background-step5-submit-short-circuit.test.js b/tests/background-step5-submit-short-circuit.test.js index f150162..781ea81 100644 --- a/tests/background-step5-submit-short-circuit.test.js +++ b/tests/background-step5-submit-short-circuit.test.js @@ -30,7 +30,8 @@ test('step 5 forwards generated profile data and relies on completion signal flo { source: 'signup-page', message: { - type: 'EXECUTE_STEP', + type: 'EXECUTE_NODE', + nodeId: 'fill-profile', step: 5, source: 'background', payload: { diff --git a/tests/background-step6-retry-limit.test.js b/tests/background-step6-retry-limit.test.js index de5a68e..a4b4e97 100644 --- a/tests/background-step6-retry-limit.test.js +++ b/tests/background-step6-retry-limit.test.js @@ -1,4 +1,4 @@ -const test = require('node:test'); +const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); @@ -17,7 +17,7 @@ test('step 6 waits for registration success and completes from background', asyn addLog: async (message, level = 'info') => { events.logs.push({ message, level }); }, - completeStepFromBackground: async (step) => { + completeNodeFromBackground: async (step) => { events.completedSteps.push(step); }, sleepWithStop: async (ms) => { @@ -28,7 +28,7 @@ test('step 6 waits for registration success and completes from background', asyn await executor.executeStep6(); assert.deepStrictEqual(events.waits, [20000]); - assert.deepStrictEqual(events.completedSteps, [6]); + assert.deepStrictEqual(events.completedSteps, ['wait-registration-success']); assert.ok(events.logs.some(({ message }) => /等待 20 秒/.test(message))); }); @@ -64,7 +64,7 @@ test('step 6 only clears cookies when cleanup switch is enabled', async () => { const executor = api.createStep6Executor({ addLog: async () => {}, chrome: chromeApi, - completeStepFromBackground: async (step) => { + completeNodeFromBackground: async (step) => { events.completedSteps.push(step); }, sleepWithStop: async () => {}, @@ -77,7 +77,7 @@ test('step 6 only clears cookies when cleanup switch is enabled', async () => { await executor.executeStep6({ step6CookieCleanupEnabled: true }); - assert.deepStrictEqual(events.completedSteps, [6, 6]); + assert.deepStrictEqual(events.completedSteps, ['wait-registration-success', 'wait-registration-success']); assert.deepStrictEqual(events.removedCookies, [ { url: 'https://chatgpt.com/auth', @@ -102,7 +102,7 @@ test('step 7 retries up to configured limit and then fails', async () => { const executor = api.createStep7Executor({ addLog: async () => {}, - completeStepFromBackground: async () => { + completeNodeFromBackground: async () => { events.completed += 1; }, getErrorMessage: (error) => error?.message || String(error || ''), @@ -153,7 +153,7 @@ test('step 7 exits internal retry loop immediately when add-phone is detected', addLog: async (message, level = 'info') => { events.logs.push({ message, level }); }, - completeStepFromBackground: async () => { + completeNodeFromBackground: async () => { events.completed += 1; }, getErrorMessage: (error) => error?.message || String(error || ''), @@ -201,7 +201,7 @@ test('step 7 hands direct add-phone to shared phone verification when enabled', const executor = api.createStep7Executor({ addLog: async () => {}, - completeStepFromBackground: async (step, payload) => { + completeNodeFromBackground: async (step, payload) => { events.completions.push({ step, payload }); }, getErrorMessage: (error) => error?.message || String(error || ''), @@ -256,7 +256,7 @@ test('step 7 hands direct add-phone to shared phone verification when enabled', ]); assert.deepStrictEqual(events.completions, [ { - step: 7, + step: 'oauth-login', payload: { loginVerificationRequestedAt: null, skipLoginVerificationStep: true, @@ -280,7 +280,7 @@ test('step 7 direct add-phone stays fatal when phone verification is disabled', const executor = api.createStep7Executor({ addLog: async () => {}, - completeStepFromBackground: async () => { + completeNodeFromBackground: async () => { events.completions += 1; }, getErrorMessage: (error) => error?.message || String(error || ''), @@ -324,7 +324,7 @@ test('step 7 propagates fatal errors from shared add-phone verification', async const executor = api.createStep7Executor({ addLog: async () => {}, - completeStepFromBackground: async () => { + completeNodeFromBackground: async () => { events.completions += 1; }, getErrorMessage: (error) => error?.message || String(error || ''), @@ -368,7 +368,7 @@ test('step 7 starts a new oauth timeout window for each refreshed oauth url', as const executor = api.createStep7Executor({ addLog: async () => {}, - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, getErrorMessage: (error) => error?.message || String(error || ''), getLoginAuthStateLabel: (state) => state || 'unknown', getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs, options) => { @@ -419,7 +419,7 @@ test('step 7 forwards direct OAuth consent skip metadata when completing', async const executor = api.createStep7Executor({ addLog: async () => {}, - completeStepFromBackground: async (step, payload) => { + completeNodeFromBackground: async (step, payload) => { events.completions.push({ step, payload }); }, getErrorMessage: (error) => error?.message || String(error || ''), @@ -447,7 +447,7 @@ test('step 7 forwards direct OAuth consent skip metadata when completing', async assert.deepStrictEqual(events.completions, [ { - step: 10, + step: 'oauth-login', payload: { loginVerificationRequestedAt: null, skipLoginVerificationStep: true, @@ -469,7 +469,7 @@ test('step 7 forwards phone login identity payload when account identifier is ph const executor = api.createStep7Executor({ addLog: async () => {}, - completeStepFromBackground: async (step, payload) => { + completeNodeFromBackground: async (step, payload) => { events.completions.push({ step, payload }); }, getErrorMessage: (error) => error?.message || String(error || ''), @@ -529,7 +529,7 @@ test('step 7 forwards phone login identity payload when account identifier is ph ]); assert.deepStrictEqual(events.completions, [ { - step: 7, + step: 'oauth-login', payload: { loginVerificationRequestedAt: 123456, accountIdentifierType: 'phone', @@ -558,7 +558,7 @@ test('step 7 keeps Plus email login even when phone sms runtime exists', async ( const executor = api.createStep7Executor({ addLog: async () => {}, - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, getErrorMessage: (error) => error?.message || String(error || ''), getLoginAuthStateLabel: (state) => state || 'unknown', getState: async () => ({ @@ -629,7 +629,7 @@ test('step 7 keeps phone login after step 8 stores an unbound email for phone si const executor = api.createStep7Executor({ addLog: async () => {}, - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, getErrorMessage: (error) => error?.message || String(error || ''), getLoginAuthStateLabel: (state) => state || 'unknown', getState: async () => ({ ...phoneSignupState }), @@ -668,7 +668,7 @@ test('step 7 can infer phone login from an available phone signup configuration const executor = api.createStep7Executor({ addLog: async () => {}, - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, getErrorMessage: (error) => error?.message || String(error || ''), getLoginAuthStateLabel: (state) => state || 'unknown', getState: async () => ({ @@ -715,7 +715,7 @@ test('step 7 can start from a manually filled signup phone without completed ste const executor = api.createStep7Executor({ addLog: async () => {}, - completeStepFromBackground: async (step, payload) => { + completeNodeFromBackground: async (step, payload) => { events.completions.push({ step, payload }); }, getErrorMessage: (error) => error?.message || String(error || ''), @@ -755,7 +755,7 @@ test('step 7 can start from a manually filled signup phone without completed ste assert.equal(events.payloads[0].password, ''); assert.deepStrictEqual(events.completions, [ { - step: 7, + step: 'oauth-login', payload: { loginVerificationRequestedAt: 987654, accountIdentifierType: 'phone', @@ -783,7 +783,7 @@ test('step 7 stops immediately when management secret is missing', async () => { addLog: async (message, level = 'info') => { events.logs.push({ message, level }); }, - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, getErrorMessage: (error) => error?.message || String(error || ''), getLoginAuthStateLabel: (state) => state || 'unknown', getState: async () => ({ email: 'user@example.com', password: 'secret' }), @@ -827,7 +827,7 @@ test('step 7 stops immediately when management secret is invalid', async () => { addLog: async (message, level = 'info') => { events.logs.push({ message, level }); }, - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, getErrorMessage: (error) => error?.message || String(error || ''), getLoginAuthStateLabel: (state) => state || 'unknown', getState: async () => ({ email: 'user@example.com', password: 'secret' }), diff --git a/tests/background-step7-recovery.test.js b/tests/background-step7-recovery.test.js index 7ab4d5e..6830101 100644 --- a/tests/background-step7-recovery.test.js +++ b/tests/background-step7-recovery.test.js @@ -1,4 +1,4 @@ -const test = require('node:test'); +const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); @@ -112,7 +112,7 @@ test('step 8 keeps phone-registered accounts on email-code flow when page is ema }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async (step, payload) => { + completeNodeFromBackground: async (step, payload) => { calls.completions.push({ step, payload }); }, confirmCustomVerificationStepBypass: async () => {}, @@ -190,7 +190,7 @@ test('step 8 routes only a real phone verification page through sms helper', asy }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async (step, payload) => { + completeNodeFromBackground: async (step, payload) => { calls.completions.push({ step, payload }); }, confirmCustomVerificationStepBypass: async () => {}, @@ -250,7 +250,7 @@ test('step 8 routes only a real phone verification page through sms helper', asy ]); assert.deepStrictEqual(calls.completions, [ { - step: 8, + step: 'fetch-login-code', payload: { phoneVerification: true, loginPhoneVerification: true, @@ -770,7 +770,7 @@ test('step 8 completes directly when auth page is already on OAuth consent page' }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async (step, payload) => { + completeNodeFromBackground: async (step, payload) => { events.completeCalls.push({ step, payload }); }, confirmCustomVerificationStepBypass: async () => {}, @@ -825,7 +825,7 @@ test('step 8 completes directly when auth page is already on OAuth consent page' ]); assert.deepStrictEqual(events.completeCalls, [ { - step: 8, + step: 'fetch-login-code', payload: { loginVerificationRequestedAt: null, skipLoginVerificationStep: true, @@ -855,7 +855,7 @@ test('step 8 retries in-place when polling fails but auth page still stays on ve }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypass: async () => {}, ensureStep8VerificationPageReady: async () => { events.ensureCalls += 1; @@ -922,7 +922,7 @@ test('step 8 keeps resend cooldown timestamp across in-place retries to avoid re }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypass: async () => {}, ensureStep8VerificationPageReady: async () => ({ state: 'verification_page', displayedEmail: 'user@example.com' }), rerunStep7ForStep8Recovery: async () => {}, @@ -997,7 +997,7 @@ test('step 8 completes when polling fails but recovery probe shows oauth consent }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async (step, payload) => { + completeNodeFromBackground: async (step, payload) => { events.completeCalls.push({ step, payload }); }, confirmCustomVerificationStepBypass: async () => {}, @@ -1046,7 +1046,7 @@ test('step 8 completes when polling fails but recovery probe shows oauth consent assert.equal(events.rerunStep7, 0); assert.deepStrictEqual(events.completeCalls, [ { - step: 8, + step: 'fetch-login-code', payload: { loginVerificationRequestedAt: null, skipLoginVerificationStep: true, diff --git a/tests/background-stop-gap-record.test.js b/tests/background-stop-gap-record.test.js index aa50f45..253a41e 100644 --- a/tests/background-stop-gap-record.test.js +++ b/tests/background-stop-gap-record.test.js @@ -48,11 +48,59 @@ function extractFunction(name) { return source.slice(start, end); } +const NODE_COMPAT_HELPERS = ` +const workflowEngine = null; +const STEP_NODE_IDS = { + 1: 'open-chatgpt', + 2: 'submit-signup-email', + 3: 'fill-password', + 4: 'fetch-signup-code', + 5: 'fill-profile', + 6: 'wait-registration-success', + 7: 'oauth-login', + 8: 'fetch-login-code', + 9: 'confirm-oauth', + 10: 'platform-verify', +}; +const NODE_STEP_IDS = Object.fromEntries(Object.entries(STEP_NODE_IDS).map(([step, nodeId]) => [nodeId, Number(step)])); +function getNodeIdsForState() { + return Object.values(STEP_NODE_IDS); +} +function getNodeIdByStepForState(step) { + return STEP_NODE_IDS[Number(step)] || ''; +} +function getStepIdByNodeIdForState(nodeId) { + return NODE_STEP_IDS[String(nodeId || '').trim()] || null; +} +function projectStepStatusesToNodeStatuses(stepStatuses = {}) { + const nodeStatuses = {}; + for (const [step, status] of Object.entries(stepStatuses || {})) { + const nodeId = getNodeIdByStepForState(step); + if (nodeId) nodeStatuses[nodeId] = status; + } + return nodeStatuses; +} +function normalizeStatusMapForNodes(statuses = {}, state = {}) { + return { + ...DEFAULT_STATE.nodeStatuses, + ...projectStepStatusesToNodeStatuses(state?.stepStatuses || {}), + ...(state?.nodeStatuses || {}), + ...(statuses || {}), + }; +} +DEFAULT_STATE.nodeStatuses = projectStepStatusesToNodeStatuses(DEFAULT_STATE.stepStatuses || {}); +`; + test('generic stopped record resolves to next unfinished step during execution gap', async () => { const bundle = [ + NODE_COMPAT_HELPERS, + extractFunction('isStepDoneStatus'), + extractFunction('getRunningNodeIds'), extractFunction('getRunningSteps'), + extractFunction('inferStoppedRecordNode'), extractFunction('inferStoppedRecordStep'), extractFunction('resolveAccountRunRecordStatusForStop'), + extractFunction('extractStoppedNodeFromRecordStatus'), extractFunction('extractStoppedStepFromRecordStatus'), extractFunction('resolveAccountRunRecordReasonForStop'), extractFunction('appendAndBroadcastAccountRunRecord'), @@ -103,10 +151,9 @@ return { 10: 'pending', }, }; - assert.equal(api.inferStoppedRecordStep(state), 7); - assert.equal(api.resolveAccountRunRecordStatusForStop('stopped', state), 'step7_stopped'); - assert.equal(api.resolveAccountRunRecordReasonForStop('step7_stopped', '流程已被用户停止。'), '步骤 7 已被用户停止。'); + assert.equal(api.resolveAccountRunRecordStatusForStop('stopped', state), 'node:oauth-login:stopped'); + assert.equal(api.resolveAccountRunRecordReasonForStop('node:oauth-login:stopped', '流程已被用户停止。'), '节点 oauth-login 已被用户停止。'); assert.equal( api.resolveAccountRunRecordReasonForStop('step2_stopped', '步骤 2 已使用邮箱,流程尚未完成。'), '步骤 2 已停止:邮箱已设置,流程尚未完成。' @@ -114,19 +161,23 @@ return { await api.appendAndBroadcastAccountRunRecord('stopped', state, '流程已被用户停止。'); assert.deepStrictEqual(api.getCaptured(), { - status: 'step7_stopped', + status: 'node:oauth-login:stopped', state, - reason: '步骤 7 已被用户停止。', + reason: '节点 oauth-login 已被用户停止。', }); }); test('requestStop appends a stopped record for the next unfinished step when no step is running', async () => { const bundle = [ + NODE_COMPAT_HELPERS, extractFunction('normalizeAutoRunSessionId'), extractFunction('clearCurrentAutoRunSessionId'), extractFunction('cleanupStep8NavigationListeners'), extractFunction('rejectPendingStep8'), + extractFunction('isStepDoneStatus'), + extractFunction('getRunningNodeIds'), extractFunction('getRunningSteps'), + extractFunction('inferStoppedRecordNode'), extractFunction('inferStoppedRecordStep'), extractFunction('requestStop'), ].join('\n'); @@ -148,6 +199,7 @@ const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start'; const DEFAULT_STATE = { stepStatuses: Object.fromEntries([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((step) => [step, 'pending'])), }; +const nodeWaiters = new Map(); const stepWaiters = new Map(); const appended = []; const logs = []; @@ -176,6 +228,7 @@ async function addLog(message, level) { } async function broadcastStopToContentScripts() {} async function markRunningStepsStopped() {} +async function markRunningNodesStopped() {} async function broadcastAutoRunStatus() {} async function appendAndBroadcastAccountRunRecord(status, state, reason) { appended.push({ status, state, reason }); diff --git a/tests/gopay-approve-source.test.js b/tests/gopay-approve-source.test.js index a7e3593..8523496 100644 --- a/tests/gopay-approve-source.test.js +++ b/tests/gopay-approve-source.test.js @@ -121,13 +121,13 @@ test('GoPay approve waits and retries slowly on Hubungkan linking page', () => { }); -test('background auto-run routes GoPay restart sentinel back to step 6', () => { +test('background auto-run routes GoPay restart sentinel back to checkout-create node', () => { const backgroundSource = fs.readFileSync('background.js', 'utf8'); assert.match(backgroundSource, /isGoPayCheckoutRestartRequiredFailure/); assert.match(backgroundSource, /GOPAY_RESTART_FROM_STEP6::/); - assert.match(backgroundSource, /step === 8 && isGoPayCheckoutRestartRequiredFailure\(err\)/); - assert.match(backgroundSource, /step = 6/); - assert.match(backgroundSource, /invalidateDownstreamAfterStepRestart\(5/); + assert.match(backgroundSource, /nodeId === 'paypal-approve' && isGoPayCheckoutRestartRequiredFailure\(err\)/); + assert.match(backgroundSource, /getNodeIndex\(await getState\(\), 'plus-checkout-create'\)/); + assert.match(backgroundSource, /invalidateDownstreamAfterAutoRunNodeRestart\(getPreviousNodeId\('plus-checkout-create'/); }); test('GoPay approve gives PIN precedence over OTP on ambiguous second PIN pages', () => { diff --git a/tests/paypal-approve-detection.test.js b/tests/paypal-approve-detection.test.js index 0fa1458..1597d42 100644 --- a/tests/paypal-approve-detection.test.js +++ b/tests/paypal-approve-detection.test.js @@ -1,4 +1,4 @@ -const test = require('node:test'); +const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const vm = require('node:vm'); @@ -56,7 +56,7 @@ function createExecutor({ }, }, }, - completeStepFromBackground: async (step, payload) => { + completeNodeFromBackground: async (step, payload) => { events.completed.push({ step, payload }); }, ensureContentScriptReadyOnTabUntilStopped: async () => {}, @@ -259,7 +259,7 @@ test('PayPal approve keeps original combined email and password login path', asy }); assert.equal(events.submittedPayloads.length, 1); - assert.deepEqual(events.completed.map((item) => item.step), [8]); + assert.deepEqual(events.completed.map((item) => item.step), ['paypal-approve']); assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true); }); @@ -404,7 +404,7 @@ test('PayPal approve discovers an already open unregistered PayPal tab', async ( assert.deepEqual(events.updatedTabs, [{ tabId: 7, updateInfo: { active: true } }]); assert.equal(events.logs.some(({ message }) => /发现 PayPal 页面/.test(message)), true); - assert.deepEqual(events.completed.map((item) => item.step), [8]); + assert.deepEqual(events.completed.map((item) => item.step), ['paypal-approve']); assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true); }); @@ -440,7 +440,7 @@ test('PayPal approve discovers PayPal tabs through the locked automation window assert.deepEqual(queries, [{}]); assert.deepEqual(events.updatedTabs, [{ tabId: 9, updateInfo: { active: true } }]); - assert.deepEqual(events.completed.map((item) => item.step), [8]); + assert.deepEqual(events.completed.map((item) => item.step), ['paypal-approve']); }); test('PayPal approve auto-detects split email then password pages', async () => { @@ -464,7 +464,7 @@ test('PayPal approve auto-detects split email then password pages', async () => }); assert.equal(events.submittedPayloads.length, 2); - assert.deepEqual(events.completed.map((item) => item.step), [8]); + assert.deepEqual(events.completed.map((item) => item.step), ['paypal-approve']); assert.equal(events.logs.some(({ message }) => /识别到密码页/.test(message)), true); assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true); }); @@ -490,6 +490,6 @@ test('PayPal approve finishes when login redirects away from PayPal', async () = }); assert.equal(events.submittedPayloads.length, 1); - assert.deepEqual(events.completed.map((item) => item.step), [8]); + assert.deepEqual(events.completed.map((item) => item.step), ['paypal-approve']); assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), false); }); diff --git a/tests/plus-checkout-billing-tab-resolution.test.js b/tests/plus-checkout-billing-tab-resolution.test.js index c279698..2726930 100644 --- a/tests/plus-checkout-billing-tab-resolution.test.js +++ b/tests/plus-checkout-billing-tab-resolution.test.js @@ -1,4 +1,4 @@ -const test = require('node:test'); +const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); @@ -153,7 +153,7 @@ function createExecutorHarness({ getAllFrames: async () => frames, }, }, - completeStepFromBackground: async (step, payload) => events.completed.push({ step, payload }), + completeNodeFromBackground: async (step, payload) => events.completed.push({ step, payload }), ensureContentScriptReadyOnTabUntilStopped: async (source, tabId) => events.ensuredTabs.push({ source, tabId }), fetch: fetchImpl, generateRandomName: () => ({ firstName: 'Ada', lastName: 'Lovelace' }), @@ -237,7 +237,7 @@ test('Plus checkout billing uses the current checkout tab when step 6 did not re assert.equal(events.messages.some((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL' && entry.frameId === 0), true); assert.equal(events.messages.some((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS' && entry.frameId === 0), true); assert.equal(events.messages.some((entry) => entry.message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE' && entry.frameId === 0), true); - assert.equal(events.completed[0].step, 7); + assert.equal(events.completed[0].step, 'plus-checkout-billing'); assert.equal(events.states.some((updates) => updates.plusCheckoutTabId === checkoutTab.id), true); assert.equal(events.logs.some((entry) => /当前已在 Plus Checkout 页面/.test(entry.message)), true); }); @@ -297,7 +297,7 @@ test('Plus checkout billing sends the billing command to the iframe that contain assert.equal(fillMessage.frameId, 8); assert.equal(subscribeMessage.frameId, 0); assert.equal(events.logs.some((entry) => /checkout iframe/.test(entry.message)), true); - assert.equal(events.completed[0].step, 7); + assert.equal(events.completed[0].step, 'plus-checkout-billing'); }); test('Plus checkout billing uses proxy exit country for GoPay address when available', async () => { @@ -597,7 +597,7 @@ test('Plus checkout billing selects GoPay and waits for a GoPay redirect', async assert.equal(fillMessage.message.payload.addressSeed.countryCode, 'ID'); assert.equal(subscribeMessage.message.payload.paymentMethod, 'gopay'); assert.equal(checkoutTab.url, 'https://gopay.co.id/payment/session'); - assert.equal(events.completed[0].step, 7); + assert.equal(events.completed[0].step, 'plus-checkout-billing'); }); test('Plus checkout billing still inspects a frame when ping readiness is stale', async () => { @@ -625,7 +625,7 @@ test('Plus checkout billing still inspects a frame when ping readiness is stale' const selectMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL'); assert.equal(selectMessage.frameId, 0); - assert.equal(events.completed[0].step, 7); + assert.equal(events.completed[0].step, 'plus-checkout-billing'); }); test('Plus checkout billing uses the autocomplete iframe for address suggestions when Stripe splits it out', async () => { @@ -655,7 +655,7 @@ test('Plus checkout billing uses the autocomplete iframe for address suggestions assert.equal(ensureAddressMessage.frameId, 8); assert.equal(combinedFillMessage, undefined); assert.equal(events.logs.some((entry) => /Google 地址推荐/.test(entry.message)), true); - assert.equal(events.completed[0].step, 7); + assert.equal(events.completed[0].step, 'plus-checkout-billing'); }); test('Plus checkout billing skips Google autocomplete when meiguodizhi returns a complete address', async () => { @@ -713,7 +713,7 @@ test('Plus checkout billing skips Google autocomplete when meiguodizhi returns a path: '/de-address', method: 'refresh', }); - assert.equal(events.completed[0].step, 7); + assert.equal(events.completed[0].step, 'plus-checkout-billing'); }); test('Plus checkout billing uses the detected checkout country before choosing an address seed', async () => { @@ -940,7 +940,7 @@ test('GPC billing polls queue task, submits WhatsApp OTP then PIN, and waits unt assert.equal(events.states.some((state) => state.gopayHelperTaskId === 'task_123' && state.gopayHelperTaskStatus === 'completed'), true); assert.equal(events.logs.some((entry) => entry.message === '步骤 7:GPC 任务状态:等待 WhatsApp OTP'), true); assert.equal(events.logs.some((entry) => /whatsapp_otp_wait/.test(entry.message)), false); - assert.equal(events.completed[0].step, 7); + assert.equal(events.completed[0].step, 'plus-checkout-billing'); assert.equal(events.completed[0].payload.plusCheckoutSource, 'gpc-helper'); assert.ok(events.sleeps.includes(3000)); }); @@ -995,7 +995,7 @@ test('GPC billing auto mode only polls until completed without OTP or PIN submis assert.equal(events.logs.some((entry) => /auto_otp_wait/.test(entry.message)), false); assert.equal(events.states.some((state) => state.plusManualConfirmationMethod === 'gopay-otp'), false); assert.equal(events.states.some((state) => state.gopayHelperTaskId === 'task_auto' && state.gopayHelperTaskStatus === 'completed'), true); - assert.equal(events.completed[0].step, 7); + assert.equal(events.completed[0].step, 'plus-checkout-billing'); assert.equal(events.completed[0].payload.plusCheckoutSource, 'gpc-helper'); }); @@ -1266,7 +1266,7 @@ test('GPC billing reads SMS OTP from local helper for sms_otp_wait', async () => assert.deepEqual(JSON.parse(fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_sms/otp')).options.body), { otp: '654321', }); - assert.equal(events.completed[0].step, 7); + assert.equal(events.completed[0].step, 'plus-checkout-billing'); }); test('GPC billing can read WhatsApp OTP from local helper when enabled', async () => { @@ -1346,7 +1346,7 @@ test('GPC billing can read WhatsApp OTP from local helper when enabled', async ( assert.deepEqual(JSON.parse(fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_wa/otp')).options.body), { otp: '765432', }); - assert.equal(events.completed[0].step, 7); + assert.equal(events.completed[0].step, 'plus-checkout-billing'); }); @@ -1481,7 +1481,7 @@ test('GPC billing helper mode requests newer OTP after invalid OTP error', async assert.equal(helperUrls[1].searchParams.get('consume'), '1'); assert.ok(Number(helperUrls[1].searchParams.get('after_ms')) > 1710000000000); assert.equal(events.logs.some((entry) => /OTP 校验失败/.test(entry.message)), true); - assert.equal(events.completed[0].step, 7); + assert.equal(events.completed[0].step, 'plus-checkout-billing'); }); test('GPC billing manual OTP wrong input opens next dialog only after previous one closes', async () => { @@ -1558,7 +1558,7 @@ test('GPC billing manual OTP wrong input opens next dialog only after previous o .filter((call) => call.url.endsWith('/api/gp/tasks/task_manual_retry/otp')) .map((call) => JSON.parse(call.options.body)); assert.deepEqual(otpBodies, [{ otp: '111111' }, { otp: '222222' }]); - assert.equal(events.completed[0].step, 7); + assert.equal(events.completed[0].step, 'plus-checkout-billing'); }); test('GPC billing manual OTP cancel stops task and ends current round', async () => { diff --git a/tests/plus-checkout-create-wait.test.js b/tests/plus-checkout-create-wait.test.js index dd688bd..15ee2e0 100644 --- a/tests/plus-checkout-create-wait.test.js +++ b/tests/plus-checkout-create-wait.test.js @@ -1,4 +1,4 @@ -const test = require('node:test'); +const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const vm = require('node:vm'); @@ -204,7 +204,7 @@ test('Plus checkout create does not wait 20 seconds after opening checkout page' }, }, }, - completeStepFromBackground: async (step, payload) => { + completeNodeFromBackground: async (step, payload) => { events.push({ type: 'complete', step, payload }); }, ensureContentScriptReadyOnTabUntilStopped: async () => { @@ -267,7 +267,7 @@ test('GoPay plus checkout create forwards gopay payment method to the checkout c update: async () => {}, }, }, - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, ensureContentScriptReadyOnTabUntilStopped: async () => {}, registerTab: async () => {}, sendTabMessageUntilStopped: async (_tabId, _source, message) => { @@ -370,7 +370,7 @@ test('GPC manual checkout injects Plus script before reading ChatGPT session tok remove: async (tabId) => events.push({ type: 'tab-remove', tabId }), }, }, - completeStepFromBackground: async (step, payload) => events.push({ type: 'complete', step, payload }), + completeNodeFromBackground: async (step, payload) => events.push({ type: 'complete', step, payload }), ensureContentScriptReadyOnTabUntilStopped: async (source, tabId, options) => events.push({ type: 'ready', source, tabId, options }), fetch: async (url, options = {}) => { fetchCalls.push({ url, options }); @@ -438,7 +438,7 @@ test('GPC manual checkout injects Plus script before reading ChatGPT session tok assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperRemoteStage, 'checkout_start'); assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperReferenceId, ''); assert.ok(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperOrderCreatedAt > 0); - assert.equal(events.find((event) => event.type === 'complete')?.step, 6); + assert.equal(events.find((event) => event.type === 'complete')?.step, 'plus-checkout-create'); assert.equal(events.find((event) => event.type === 'complete')?.payload?.plusCheckoutSource, 'gpc-helper'); }); @@ -456,7 +456,7 @@ test('GPC auto checkout only sends access token and API Key', async () => { remove: async () => {}, }, }, - completeStepFromBackground: async (step, payload) => events.push({ type: 'complete', step, payload }), + completeNodeFromBackground: async (step, payload) => events.push({ type: 'complete', step, payload }), ensureContentScriptReadyOnTabUntilStopped: async () => {}, fetch: async (url, options = {}) => { fetchCalls.push({ url, options }); @@ -507,7 +507,7 @@ test('GPC auto checkout only sends access token and API Key', async () => { assert.equal(statePayload.gopayHelperTaskId, 'task_auto'); assert.equal(Object.prototype.hasOwnProperty.call(statePayload, 'gopayHelperPhoneMode'), false); assert.equal(statePayload.gopayHelperTaskStatus, 'queued'); - assert.equal(events.find((event) => event.type === 'complete')?.step, 6); + assert.equal(events.find((event) => event.type === 'complete')?.step, 'plus-checkout-create'); }); test('GPC auto checkout keeps running when balance payload omits auto mode permission', async () => { @@ -523,7 +523,7 @@ test('GPC auto checkout keeps running when balance payload omits auto mode permi remove: async () => {}, }, }, - completeStepFromBackground: async (step, payload) => events.push({ type: 'complete', step, payload }), + completeNodeFromBackground: async (step, payload) => events.push({ type: 'complete', step, payload }), ensureContentScriptReadyOnTabUntilStopped: async () => {}, fetch: async (url, options = {}) => { fetchCalls.push({ url, options }); @@ -564,7 +564,7 @@ test('GPC auto checkout keeps running when balance payload omits auto mode permi const statePayload = events.find((event) => event.type === 'set-state')?.payload || {}; assert.equal(statePayload.gopayHelperTaskId, 'task_auto_unknown_permission'); assert.equal(Object.prototype.hasOwnProperty.call(statePayload, 'gopayHelperPhoneMode'), false); - assert.equal(events.find((event) => event.type === 'complete')?.step, 6); + assert.equal(events.find((event) => event.type === 'complete')?.step, 'plus-checkout-create'); }); test('GPC auto checkout blocks API Keys without auto mode permission', async () => { @@ -579,7 +579,7 @@ test('GPC auto checkout blocks API Keys without auto mode permission', async () remove: async () => {}, }, }, - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, ensureContentScriptReadyOnTabUntilStopped: async () => {}, fetch: async (url, options = {}) => { fetchCalls.push({ url, options }); @@ -623,7 +623,7 @@ test('GPC checkout blocks exhausted API Keys before creating task', async () => remove: async () => {}, }, }, - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, ensureContentScriptReadyOnTabUntilStopped: async () => {}, fetch: async (url, options = {}) => { fetchCalls.push({ url, options }); @@ -668,7 +668,7 @@ test('GPC checkout forwards selected SMS OTP channel', async () => { remove: async () => {}, }, }, - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, ensureContentScriptReadyOnTabUntilStopped: async () => {}, fetch: async (url, options = {}) => { fetchCalls.push({ url, options }); @@ -720,7 +720,7 @@ test('GPC checkout surfaces unified queue API errors', async () => { remove: async () => {}, }, }, - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, ensureContentScriptReadyOnTabUntilStopped: async () => {}, fetch: async (url, options = {}) => { fetchCalls.push({ url, options }); @@ -780,7 +780,7 @@ test('GPC checkout does not fall back to browser GoPay phone fields', async () = remove: async () => {}, }, }, - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, ensureContentScriptReadyOnTabUntilStopped: async () => {}, fetch: async () => { throw new Error('should not call helper API without helper phone'); @@ -821,7 +821,7 @@ test('GPC checkout rejects missing API Key before calling helper API', async () remove: async () => {}, }, }, - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, ensureContentScriptReadyOnTabUntilStopped: async () => {}, fetch: async () => { throw new Error('should not call helper API without API Key'); diff --git a/tests/plus-return-confirm-wait.test.js b/tests/plus-return-confirm-wait.test.js index c4ef424..7c377a4 100644 --- a/tests/plus-return-confirm-wait.test.js +++ b/tests/plus-return-confirm-wait.test.js @@ -1,4 +1,4 @@ -const test = require('node:test'); +const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); @@ -12,7 +12,7 @@ test('Plus return confirm waits a fixed 20 seconds after return URL is detected' addLog: async (message, level = 'info') => { events.push({ type: 'log', message, level }); }, - completeStepFromBackground: async (step, payload) => { + completeNodeFromBackground: async (step, payload) => { events.push({ type: 'complete', step, payload }); }, getTabId: async (source) => (source === 'paypal-flow' ? 77 : null), @@ -46,7 +46,7 @@ test('Plus return confirm waits a fixed 20 seconds after return URL is detected' events.find((event) => event.type === 'complete'), { type: 'complete', - step: 9, + step: 'plus-checkout-return', payload: { plusReturnUrl: 'https://chatgpt.com/backend-api/payments/success' }, } ); diff --git a/tests/sidepanel-contribution-mode.test.js b/tests/sidepanel-contribution-mode.test.js index 2a9b2dd..8e2ffe1 100644 --- a/tests/sidepanel-contribution-mode.test.js +++ b/tests/sidepanel-contribution-mode.test.js @@ -132,7 +132,10 @@ test('sidepanel settings refresh preserves rendered step progress', () => { const bundle = [ extractFunction('isDoneStatus'), + extractFunction('getNodeStatuses'), extractFunction('getStepStatuses'), + extractFunction('escapeCssValue'), + extractFunction('renderSingleNodeStatus'), extractFunction('renderSingleStepStatus'), extractFunction('renderStepStatuses'), extractFunction('updateProgressCounter'), @@ -148,13 +151,24 @@ const STATUS_ICONS = { manual_completed: 'M', skipped: 'K', }; -let latestState = { stepStatuses: { 1: 'completed', 2: 'running', 3: 'pending' } }; +let latestState = { nodeStatuses: { 'open-chatgpt': 'completed', 'submit-signup-email': 'running', 'fill-password': 'pending' } }; +let NODE_IDS = ['open-chatgpt', 'submit-signup-email', 'fill-password']; +let NODE_DEFAULT_STATUSES = { 'open-chatgpt': 'pending', 'submit-signup-email': 'pending', 'fill-password': 'pending' }; let STEP_IDS = [1, 2, 3]; let STEP_DEFAULT_STATUSES = { 1: 'pending', 2: 'pending', 3: 'pending' }; +function getStepIdByNodeIdForCurrentMode(nodeId) { + return { 'open-chatgpt': 1, 'submit-signup-email': 2, 'fill-password': 3 }[nodeId] || 0; +} const rows = new Map(STEP_IDS.map((step) => [step, { className: 'step-row' }])); const statusEls = new Map(STEP_IDS.map((step) => [step, { textContent: '' }])); +const nodeRows = new Map(NODE_IDS.map((nodeId) => [nodeId, rows.get(getStepIdByNodeIdForCurrentMode(nodeId))])); +const nodeStatusEls = new Map(NODE_IDS.map((nodeId) => [nodeId, statusEls.get(getStepIdByNodeIdForCurrentMode(nodeId))])); const document = { querySelector(selector) { + const nodeMatch = selector.match(/data-node-id="([^"]+)"/); + if (nodeMatch) { + return selector.includes('step-status') ? nodeStatusEls.get(nodeMatch[1]) : nodeRows.get(nodeMatch[1]); + } const match = selector.match(/data-step="(\\d+)"/); const step = match ? Number(match[1]) : 0; return selector.includes('step-status') ? statusEls.get(step) : rows.get(step); diff --git a/tests/sidepanel-phone-verification-settings.test.js b/tests/sidepanel-phone-verification-settings.test.js index c520d29..bb5ea1a 100644 --- a/tests/sidepanel-phone-verification-settings.test.js +++ b/tests/sidepanel-phone-verification-settings.test.js @@ -200,7 +200,7 @@ test('sidepanel source wires runtime signup phone field to background sync messa assert.match(sidepanelSource, /final \? 'SAVE_SIGNUP_PHONE' : 'SET_SIGNUP_PHONE_STATE'/); assert.match(sidepanelSource, /message\.payload\.signupPhoneNumber !== undefined/); assert.match(sidepanelSource, /await persistSignupPhoneInputForAction\(\);\s*await saveSettings/); - assert.match(sidepanelSource, /if \(shouldExecuteStep3WithSignupPhoneIdentity\(latestState\)\)[\s\S]*payload: \{ step \}/); + assert.match(sidepanelSource, /if \(shouldExecuteStep3WithSignupPhoneIdentity\(latestState\)\)[\s\S]*type:\s*'EXECUTE_NODE'[\s\S]*payload: \{ nodeId \}/); assert.match(sidepanelSource, /async function handleSkipStep\(step\)[\s\S]*await persistCurrentSettingsForAction\(\);/); assert.match(sidepanelSource, /inputSignupPhone\.addEventListener\('input'[\s\S]*signupPhoneInputDirty = true/); }); diff --git a/tests/source-registry-module.test.js b/tests/source-registry-module.test.js index ae6d47c..66c7ea2 100644 --- a/tests/source-registry-module.test.js +++ b/tests/source-registry-module.test.js @@ -53,4 +53,7 @@ test('shared source registry exposes canonical source, alias, detection, and rea assert.equal(registry.shouldReportReadyForFrame('mail-163', true), false); assert.equal(registry.shouldReportReadyForFrame('unknown-source', false), false); assert.equal(registry.getCleanupOwnerSource('oauth-localhost-callback'), 'openai-auth'); + assert.equal(registry.driverAcceptsCommand('openai-auth', 'submit-signup-email'), true); + assert.equal(registry.driverAcceptsCommand('content/platform-panel', 'platform-verify'), true); + assert.equal(registry.driverAcceptsCommand('openai-auth', 'platform-verify'), false); }); diff --git a/tests/step8-restart-step7-error.test.js b/tests/step8-restart-step7-error.test.js index 0f2c612..bd0764c 100644 --- a/tests/step8-restart-step7-error.test.js +++ b/tests/step8-restart-step7-error.test.js @@ -1,4 +1,4 @@ -const test = require('node:test'); +const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); @@ -240,7 +240,7 @@ test('step 8 escalates to rerun step 7 after too many local retry_without_step7 }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypass: async () => {}, ensureStep8VerificationPageReady: async () => { calls.ensureReady += 1; diff --git a/tests/step8-stop-cleanup.test.js b/tests/step8-stop-cleanup.test.js index bf5271a..1aae758 100644 --- a/tests/step8-stop-cleanup.test.js +++ b/tests/step8-stop-cleanup.test.js @@ -1,4 +1,4 @@ -const assert = require('assert'); +const assert = require('assert'); const fs = require('fs'); const helperSource = fs.readFileSync('background.js', 'utf8'); @@ -78,7 +78,7 @@ let autoRunAttemptRun = 4; let autoRunSessionId = 99; const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start'; const DEFAULT_STATE = { - stepStatuses: Object.fromEntries([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((step) => [step, 'pending'])), + nodeStatuses: {}, }; const STEP8_CLICK_RETRY_DELAY_MS = 500; const STEP8_MAX_ROUNDS = 5; @@ -134,6 +134,7 @@ const chrome = { }, }; +const nodeWaiters = new Map(); const stepWaiters = new Map(); let resumeWaiter = null; @@ -141,6 +142,13 @@ function cancelPendingCommands() {} function abortActiveIcloudRequests() {} async function addLog() {} async function broadcastStopToContentScripts() {} +function getRunningNodeIds() { + return []; +} +function inferStoppedRecordNode() { + return ''; +} +async function markRunningNodesStopped() {} async function markRunningStepsStopped() {} async function broadcastAutoRunStatus() {} async function appendAndBroadcastAccountRunRecord() {} @@ -156,7 +164,7 @@ function isAutoRunScheduledState() { function getStep8CallbackUrlFromNavigation() { return ''; } function getStep8CallbackUrlFromTabUpdate() { return ''; } function getStep8EffectLabel() { return 'no_effect'; } -async function completeStepFromBackground() {} +async function completeNodeFromBackground() {} async function getTabId() { return await new Promise((resolve) => { resolveTabId = resolve; @@ -218,7 +226,7 @@ const executor = self.MultiPageBackgroundStep9.createStep9Executor({ chrome, cleanupStep8NavigationListeners, clickWithDebugger, - completeStepFromBackground, + completeNodeFromBackground, ensureStep8SignupPageReady, getStep8CallbackUrlFromNavigation, getStep8CallbackUrlFromTabUpdate, diff --git a/tests/step9-oauth-timeout-toggle.test.js b/tests/step9-oauth-timeout-toggle.test.js index 62a4a70..1a5ad6a 100644 --- a/tests/step9-oauth-timeout-toggle.test.js +++ b/tests/step9-oauth-timeout-toggle.test.js @@ -1,4 +1,4 @@ -const test = require('node:test'); +const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); @@ -102,8 +102,8 @@ function getWebNavCommittedListener() { return webNavCommittedListener; } function setStep8TabUpdatedListener(listener) { step8TabUpdatedListener = listener; } function getStep8TabUpdatedListener() { return step8TabUpdatedListener; } function setStep8PendingReject(handler) { step8PendingReject = handler; } -async function completeStepFromBackground(step, payload) { - completePayload = { step, payload }; +async function completeNodeFromBackground(nodeId, payload) { + completePayload = { nodeId, payload }; } const STEP8_CLICK_RETRY_DELAY_MS = 1; @@ -120,7 +120,7 @@ const executor = self.MultiPageBackgroundStep9.createStep9Executor({ chrome, cleanupStep8NavigationListeners, clickWithDebugger, - completeStepFromBackground, + completeNodeFromBackground, ensureStep8SignupPageReady, getOAuthFlowRemainingMs, getOAuthFlowStepTimeoutMs, @@ -176,7 +176,7 @@ return { assert.equal(snapshot.cleanupCalls >= 1, true); assert.equal(snapshot.hasPendingReject, false); assert.deepEqual(snapshot.completePayload, { - step: 9, + nodeId: 'confirm-oauth', payload: { localhostUrl: 'http://localhost:1455/auth/callback?code=abc&state=xyz', }, @@ -279,8 +279,8 @@ function getWebNavCommittedListener() { return webNavCommittedListener; } function setStep8TabUpdatedListener(listener) { step8TabUpdatedListener = listener; } function getStep8TabUpdatedListener() { return step8TabUpdatedListener; } function setStep8PendingReject() {} -async function completeStepFromBackground(step, payload) { - completePayload = { step, payload }; +async function completeNodeFromBackground(nodeId, payload) { + completePayload = { nodeId, payload }; } const STEP8_CLICK_RETRY_DELAY_MS = 1; @@ -297,7 +297,7 @@ const executor = self.MultiPageBackgroundStep9.createStep9Executor({ chrome, cleanupStep8NavigationListeners, clickWithDebugger, - completeStepFromBackground, + completeNodeFromBackground, ensureStep8SignupPageReady, getOAuthFlowStepTimeoutMs, getStep8CallbackUrlFromNavigation, @@ -348,7 +348,7 @@ return { assert.equal(snapshot.deferCalls >= 1, true); assert.equal(snapshot.cleanupCalls >= 1, true); assert.deepEqual(snapshot.completePayload, { - step: 12, + nodeId: 'confirm-oauth', payload: { localhostUrl: 'http://localhost:1455/auth/callback?code=abc&state=xyz', }, diff --git a/tests/step9-timeout-recovery.test.js b/tests/step9-timeout-recovery.test.js index e623472..6002e02 100644 --- a/tests/step9-timeout-recovery.test.js +++ b/tests/step9-timeout-recovery.test.js @@ -1,4 +1,4 @@ -const assert = require('assert'); +const assert = require('assert'); const fs = require('fs'); const step9ModuleSource = fs.readFileSync('background/steps/confirm-oauth.js', 'utf8'); @@ -148,8 +148,8 @@ function setStep8TabUpdatedListener(listener) { step8TabUpdatedListener = listen function getStep8TabUpdatedListener() { return step8TabUpdatedListener; } function setStep8PendingReject(handler) { step8PendingReject = handler; } -async function completeStepFromBackground(step, payload) { - completePayload = { step, payload }; +async function completeNodeFromBackground(nodeId, payload) { + completePayload = { nodeId, payload }; } const STEP8_CLICK_RETRY_DELAY_MS = 200; @@ -167,7 +167,7 @@ const executor = self.MultiPageBackgroundStep9.createStep9Executor({ chrome, cleanupStep8NavigationListeners, clickWithDebugger, - completeStepFromBackground, + completeNodeFromBackground, ensureStep8SignupPageReady, getOAuthFlowRemainingMs, getOAuthFlowStepTimeoutMs, @@ -226,7 +226,7 @@ return { assert.strictEqual(snapshot.cleanupCalls >= 1, true, 'step9 should cleanup navigation listeners'); assert.strictEqual(snapshot.hasPendingReject, false, 'step9 should clear pending reject after completion'); assert.deepStrictEqual(snapshot.completePayload, { - step: 9, + nodeId: 'confirm-oauth', payload: { localhostUrl: 'http://localhost:1455/auth/callback?code=abc&state=xyz', }, diff --git a/tests/sub2api-panel-proxy.test.js b/tests/sub2api-panel-proxy.test.js index 4e8664e..e215821 100644 --- a/tests/sub2api-panel-proxy.test.js +++ b/tests/sub2api-panel-proxy.test.js @@ -51,8 +51,8 @@ function createSub2ApiPanelContext(fetchCalls = []) { removeItem(key) { storage.delete(`session:${key}`); }, }, log() {}, - reportComplete(step, payload) { - context.completed.push({ step, payload }); + reportComplete(nodeId, payload) { + context.completed.push({ nodeId, payload }); }, reportReady() {}, reportError() {}, @@ -177,7 +177,8 @@ test('SUB2API step 10 uses the same proxy for code exchange and account creation assert.equal(exchangeCall.body.proxy_id, 7); assert.equal(createCall.body.proxy_id, 7); assert.equal(createCall.body.group_ids[0], 5); - assert.equal(context.completed[0].step, 10); + assert.equal(context.completed[0].nodeId, 'platform-verify'); + assert.equal(context.completed[0].payload.visibleStep, 10); }); test('SUB2API panel accepts Plus platform verify step 13', async () => { @@ -202,7 +203,8 @@ test('SUB2API panel accepts Plus platform verify step 13', async () => { assert.equal(exchangeCall.body.code, 'callback-code'); assert.equal(createCall.body.group_ids[0], 5); - assert.equal(context.completed[0].step, 13); + assert.equal(context.completed[0].nodeId, 'platform-verify'); + assert.equal(context.completed[0].payload.visibleStep, 13); }); test('SUB2API step 1 omits proxy_id when default proxy is empty', async () => { diff --git a/tests/verification-flow-polling.test.js b/tests/verification-flow-polling.test.js index ba6d191..19ebcec 100644 --- a/tests/verification-flow-polling.test.js +++ b/tests/verification-flow-polling.test.js @@ -1,13 +1,58 @@ -const test = require('node:test'); +const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const source = fs.readFileSync('background/verification-flow.js', 'utf8'); const globalScope = {}; const api = new Function('self', `${source}; return self.MultiPageBackgroundVerificationFlow;`)(globalScope); +const rawCreateVerificationFlowHelpers = api.createVerificationFlowHelpers.bind(api); + +const TEST_STEP_NODE_IDS = Object.freeze({ + 4: 'fetch-signup-code', + 7: 'oauth-login', + 8: 'fetch-login-code', + 11: 'fetch-login-code', +}); + +const TEST_NODE_STEP_IDS = Object.fromEntries( + Object.entries(TEST_STEP_NODE_IDS).map(([step, nodeId]) => [nodeId, Number(step)]) +); + +function getTestNodeIdByStepForState(step) { + return TEST_STEP_NODE_IDS[Number(step)] || ''; +} + +function getTestStepIdByNodeId(nodeId) { + return TEST_NODE_STEP_IDS[String(nodeId || '').trim()] || null; +} + +function normalizeVerificationFlowTestOverrides(overrides = {}) { + const normalized = { ...overrides }; + if ( + typeof normalized.completeNodeFromBackground !== 'function' + && typeof normalized.completeStepFromBackground === 'function' + ) { + const completeStepFromBackground = normalized.completeStepFromBackground; + normalized.completeNodeFromBackground = async (nodeId, payload) => ( + completeStepFromBackground(getTestStepIdByNodeId(nodeId), payload) + ); + } + if ( + typeof normalized.setNodeStatus !== 'function' + && typeof normalized.setStepStatus === 'function' + ) { + const setStepStatus = normalized.setStepStatus; + normalized.setNodeStatus = async (nodeId, status) => ( + setStepStatus(getTestStepIdByNodeId(nodeId), status) + ); + } + delete normalized.completeStepFromBackground; + delete normalized.setStepStatus; + return normalized; +} function createVerificationFlowTestHelpers(overrides = {}) { - return api.createVerificationFlowHelpers({ + return rawCreateVerificationFlowHelpers({ addLog: async () => {}, buildVerificationPollPayload: null, chrome: { @@ -18,8 +63,9 @@ function createVerificationFlowTestHelpers(overrides = {}) { }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', CLOUD_MAIL_PROVIDER: 'cloudmail', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), + getNodeIdByStepForState: getTestNodeIdByStepForState, getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 0, getState: async () => ({}), @@ -36,14 +82,16 @@ function createVerificationFlowTestHelpers(overrides = {}) { sendToContentScript: async () => ({}), sendToMailContentScriptResilient: async () => ({}), setState: async () => {}, - setStepStatus: async () => {}, + setNodeStatus: async () => {}, sleepWithStop: async () => {}, throwIfStopped: () => {}, VERIFICATION_POLL_MAX_ROUNDS: 5, - ...overrides, + ...normalizeVerificationFlowTestOverrides(overrides), }); } +api.createVerificationFlowHelpers = createVerificationFlowTestHelpers; + test('verification flow prefers injected verification poll payload builder when provided', () => { const helpers = createVerificationFlowTestHelpers({ buildVerificationPollPayload: (step, state, overrides = {}) => ({ @@ -85,7 +133,7 @@ test('verification flow keeps 2925 polling cadence in the default payload', () = addLog: async () => {}, chrome: { tabs: { update: async () => {} } }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 0, @@ -184,7 +232,7 @@ test('verification flow only enables 2925 target email matching in receive mode' addLog: async () => {}, chrome: { tabs: { update: async () => {} } }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 0, @@ -233,7 +281,7 @@ test('verification flow runs beforeSubmit hook before filling the code', async ( }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async (_step, payload) => { + completeNodeFromBackground: async (_step, payload) => { events.push(['complete', payload.code]); }, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), @@ -299,7 +347,7 @@ test('verification flow skips 2925 mailbox preclear when using a fixed login mai }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 0, @@ -360,7 +408,7 @@ test('verification flow skips 2925 mailbox preclear when using a fixed signup ma }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 0, @@ -436,7 +484,7 @@ test('verification flow closes the tracked iCloud mail tab after a successful ve throw new Error('should not use family cleanup when tracked tab exists'); }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 0, @@ -498,7 +546,7 @@ test('verification flow completes step 8 and flags phone verification when add-p }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async (_step, payload) => { + completeNodeFromBackground: async (_step, payload) => { events.push(['complete', payload.code]); }, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), @@ -568,7 +616,7 @@ test('verification flow keeps step 8 successful when code submit transport fails }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async (_step, payload) => { + completeNodeFromBackground: async (_step, payload) => { events.push(['complete', payload.code]); }, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), @@ -642,7 +690,7 @@ test('verification flow treats manual step 8 add-phone confirmation as the same }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => { + completeNodeFromBackground: async () => { throw new Error('should not complete step 8'); }, confirmCustomVerificationStepBypassRequest: async () => ({ @@ -689,7 +737,7 @@ test('verification flow caps mail polling timeout to the remaining oauth budget' }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 0, @@ -753,7 +801,7 @@ test('verification flow keeps mail polling response timeout above minimum floor' }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 0, @@ -816,7 +864,7 @@ test('verification flow keeps 2925 mailbox polling at 15 refresh attempts even w }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 0, @@ -881,7 +929,7 @@ test('verification flow can run a 2/3/15 2925 resend polling plan', async () => addLog: async () => {}, chrome: { tabs: { update: async () => {} } }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 0, @@ -953,7 +1001,7 @@ test('verification flow uses full 2925 polling window after a rejected login cod addLog: async () => {}, chrome: { tabs: { update: async () => {} } }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 0, @@ -1026,7 +1074,7 @@ test('verification flow keeps Hotmail request timestamp filtering on the first p addLog: async () => {}, chrome: { tabs: { update: async () => {} } }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 87654, @@ -1081,7 +1129,7 @@ test('verification flow keeps fixed filter timestamp after step 4 resend', async addLog: async () => {}, chrome: { tabs: { update: async () => {} } }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: (_step, state) => Math.max(0, Number(state.signupVerificationRequestedAt || 0) - 15000), @@ -1147,7 +1195,7 @@ test('verification flow uses configured signup resend count for step 4', async ( addLog: async () => {}, chrome: { tabs: { update: async () => {} } }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 0, @@ -1206,7 +1254,7 @@ test('verification flow uses configured login resend count for step 8', async () addLog: async () => {}, chrome: { tabs: { update: async () => {} } }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 0, @@ -1265,8 +1313,8 @@ test('verification flow can complete Plus visible login-code step with shared st addLog: async () => {}, chrome: { tabs: { update: async () => {} } }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async (step, payload) => { - completed.push({ step, payload }); + completeNodeFromBackground: async (nodeId, payload) => { + completed.push({ nodeId, payload }); }, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), @@ -1310,7 +1358,7 @@ test('verification flow can complete Plus visible login-code step with shared st assert.deepStrictEqual(fillMessages.map((message) => message.step), [8]); assert.deepStrictEqual(completed, [ { - step: 11, + nodeId: 'fetch-login-code', payload: { emailTimestamp: 456, code: '654321', @@ -1328,7 +1376,7 @@ test('verification flow waits during resend cooldown instead of tight-looping', addLog: async () => {}, chrome: { tabs: { update: async () => {} } }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 0, @@ -1389,7 +1437,7 @@ test('verification flow clicks resend before waiting for the next LuckMail /code addLog: async () => {}, chrome: { tabs: { update: async () => {} } }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 0, @@ -1459,7 +1507,7 @@ test('verification flow notifies onResendRequestedAt when resend is triggered', addLog: async () => {}, chrome: { tabs: { update: async () => {} } }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 0, @@ -1532,7 +1580,7 @@ test('verification flow uses resilient signup-page transport when submitting ver }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 0, @@ -1582,7 +1630,7 @@ test('verification flow does not replay step 8 code submit after transient auth- }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 0, @@ -1647,7 +1695,7 @@ test('verification flow requests a new code immediately after Cloudflare Temp Em }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async (_step, payload) => { + completeNodeFromBackground: async (_step, payload) => { completed.push(payload); }, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), @@ -1726,7 +1774,7 @@ test('verification flow forwards optional signup profile payload when submitting }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 0, @@ -1787,8 +1835,8 @@ test('verification flow keeps combined signup profile skip reason when completin }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async (step, payload) => { - completed.push({ step, payload }); + completeNodeFromBackground: async (nodeId, payload) => { + completed.push({ nodeId, payload }); }, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), @@ -1843,7 +1891,7 @@ test('verification flow keeps combined signup profile skip reason when completin assert.equal(resilientCalls[0].payload.signupProfile.firstName, 'Ada'); assert.deepStrictEqual(completed, [ { - step: 4, + nodeId: 'fetch-signup-code', payload: { emailTimestamp: 123, code: '654321', @@ -1869,7 +1917,7 @@ test('verification flow treats retryable submit transport failure as success whe }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 0, @@ -1919,7 +1967,7 @@ test('verification flow avoids resend storms when iCloud polling keeps hitting t }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 0, @@ -1982,7 +2030,7 @@ test('verification flow stops iCloud poll-only loop after repeated no-code round }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 0, @@ -2043,7 +2091,7 @@ test('verification flow derives iCloud polling response timeout from the configu }, }, CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 0, diff --git a/tests/verification-stop-propagation.test.js b/tests/verification-stop-propagation.test.js index 72797dd..6970602 100644 --- a/tests/verification-stop-propagation.test.js +++ b/tests/verification-stop-propagation.test.js @@ -1,4 +1,4 @@ -const assert = require('assert'); +const assert = require('assert'); const fs = require('fs'); const helperSource = fs.readFileSync('background.js', 'utf8'); @@ -97,7 +97,7 @@ const helpers = self.MultiPageBackgroundVerificationFlow.createVerificationFlowH addLog, chrome: {}, CLOUDFLARE_TEMP_EMAIL_PROVIDER, - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig, getHotmailVerificationRequestTimestamp: () => 123, @@ -164,7 +164,7 @@ const helpers = self.MultiPageBackgroundVerificationFlow.createVerificationFlowH addLog, chrome, CLOUDFLARE_TEMP_EMAIL_PROVIDER, - completeStepFromBackground: async () => {}, + completeNodeFromBackground: async () => {}, confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), getHotmailVerificationPollConfig: () => ({}), getHotmailVerificationRequestTimestamp: () => 123,