auto-run-add-phone-stop.test.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. const test = require('node:test');
  2. const assert = require('node:assert/strict');
  3. const fs = require('node:fs');
  4. const source = fs.readFileSync('background/auto-run-controller.js', 'utf8');
  5. const globalScope = {};
  6. const api = new Function('self', `${source}; return self.MultiPageBackgroundAutoRunController;`)(globalScope);
  7. test('auto-run controller does not retry add-phone failures even when auto retry is enabled', async () => {
  8. const events = {
  9. logs: [],
  10. broadcasts: [],
  11. accountRecords: [],
  12. runCalls: 0,
  13. };
  14. let currentState = {
  15. stepStatuses: {},
  16. vpsUrl: 'https://example.com/vps',
  17. vpsPassword: 'secret',
  18. customPassword: '',
  19. autoRunSkipFailures: true,
  20. autoRunFallbackThreadIntervalMinutes: 0,
  21. autoRunDelayEnabled: false,
  22. autoRunDelayMinutes: 30,
  23. autoStepDelaySeconds: null,
  24. mailProvider: '163',
  25. emailGenerator: 'duck',
  26. gmailBaseEmail: '',
  27. mail2925BaseEmail: '',
  28. emailPrefix: 'demo',
  29. inbucketHost: '',
  30. inbucketMailbox: '',
  31. cloudflareDomain: '',
  32. cloudflareDomains: [],
  33. tabRegistry: {},
  34. sourceLastUrls: {},
  35. autoRunRoundSummaries: [],
  36. };
  37. const runtime = {
  38. state: {
  39. autoRunActive: false,
  40. autoRunCurrentRun: 0,
  41. autoRunTotalRuns: 1,
  42. autoRunAttemptRun: 0,
  43. autoRunSessionId: 0,
  44. },
  45. get() {
  46. return { ...this.state };
  47. },
  48. set(updates = {}) {
  49. this.state = { ...this.state, ...updates };
  50. },
  51. };
  52. let sessionSeed = 0;
  53. const controller = api.createAutoRunController({
  54. addLog: async (message, level = 'info') => {
  55. events.logs.push({ message, level });
  56. },
  57. appendAccountRunRecord: async (status, _state, reason) => {
  58. events.accountRecords.push({ status, reason });
  59. return { status, reason };
  60. },
  61. AUTO_RUN_MAX_RETRIES_PER_ROUND: 3,
  62. AUTO_RUN_RETRY_DELAY_MS: 3000,
  63. AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry',
  64. AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds',
  65. broadcastAutoRunStatus: async (phase, payload = {}) => {
  66. events.broadcasts.push({ phase, ...payload });
  67. currentState = {
  68. ...currentState,
  69. autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
  70. autoRunPhase: phase,
  71. autoRunCurrentRun: payload.currentRun ?? runtime.state.autoRunCurrentRun,
  72. autoRunTotalRuns: payload.totalRuns ?? runtime.state.autoRunTotalRuns,
  73. autoRunAttemptRun: payload.attemptRun ?? runtime.state.autoRunAttemptRun,
  74. autoRunSessionId: payload.sessionId ?? runtime.state.autoRunSessionId,
  75. };
  76. },
  77. broadcastStopToContentScripts: async () => {},
  78. cancelPendingCommands: () => {},
  79. clearStopRequest: () => {},
  80. createAutoRunSessionId: () => {
  81. sessionSeed += 1;
  82. return sessionSeed;
  83. },
  84. getAutoRunStatusPayload: (phase, payload = {}) => ({
  85. autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
  86. autoRunPhase: phase,
  87. autoRunCurrentRun: payload.currentRun ?? 0,
  88. autoRunTotalRuns: payload.totalRuns ?? 1,
  89. autoRunAttemptRun: payload.attemptRun ?? 0,
  90. autoRunSessionId: payload.sessionId ?? 0,
  91. }),
  92. getErrorMessage: (error) => error?.message || String(error || ''),
  93. getFirstUnfinishedStep: () => 1,
  94. getPendingAutoRunTimerPlan: () => null,
  95. getRunningSteps: () => [],
  96. getState: async () => ({
  97. ...currentState,
  98. stepStatuses: { ...(currentState.stepStatuses || {}) },
  99. tabRegistry: { ...(currentState.tabRegistry || {}) },
  100. sourceLastUrls: { ...(currentState.sourceLastUrls || {}) },
  101. }),
  102. getStopRequested: () => false,
  103. hasSavedProgress: () => false,
  104. isAddPhoneAuthFailure: (error) => /add-phone|手机号页面|手机号页|手机号码|手机号/i.test(error?.message || String(error || '')),
  105. isRestartCurrentAttemptError: () => false,
  106. isStopError: (error) => (error?.message || String(error || '')) === '流程已被用户停止。',
  107. launchAutoRunTimerPlan: async () => false,
  108. normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Math.floor(Number(value) || 0)),
  109. persistAutoRunTimerPlan: async () => ({}),
  110. resetState: async () => {
  111. currentState = {
  112. ...currentState,
  113. stepStatuses: {},
  114. tabRegistry: {},
  115. sourceLastUrls: {},
  116. };
  117. },
  118. runAutoSequenceFromStep: async () => {
  119. events.runCalls += 1;
  120. throw new Error('步骤 8:验证码提交后页面进入手机号页面,当前流程无法继续自动授权。 URL: https://auth.openai.com/add-phone');
  121. },
  122. runtime,
  123. setState: async (updates = {}) => {
  124. currentState = {
  125. ...currentState,
  126. ...updates,
  127. stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses,
  128. tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry,
  129. sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls,
  130. };
  131. },
  132. sleepWithStop: async () => {},
  133. throwIfAutoRunSessionStopped: (sessionId) => {
  134. if (sessionId && sessionId !== runtime.state.autoRunSessionId) {
  135. throw new Error('流程已被用户停止。');
  136. }
  137. },
  138. waitForRunningStepsToFinish: async () => currentState,
  139. chrome: {
  140. runtime: {
  141. sendMessage() {
  142. return Promise.resolve();
  143. },
  144. },
  145. },
  146. });
  147. await controller.autoRunLoop(1, {
  148. autoRunSkipFailures: true,
  149. mode: 'restart',
  150. });
  151. assert.equal(events.runCalls, 1, 'add-phone fatal failure should stop before the next auto attempt starts');
  152. assert.equal(events.broadcasts.some(({ phase }) => phase === 'retrying'), false, 'add-phone fatal failure should not enter retrying phase');
  153. assert.equal(events.accountRecords.length, 1, 'fatal add-phone should still persist a failed round record');
  154. assert.equal(events.accountRecords[0].status, 'failed');
  155. assert.match(events.accountRecords[0].reason, /add-phone/);
  156. assert.ok(events.logs.some(({ message }) => /add-phone\/手机号页/.test(message)));
  157. assert.equal(runtime.state.autoRunActive, false);
  158. assert.equal(runtime.state.autoRunSessionId, 0);
  159. });
  160. test('auto-run controller parks 30~60 minutes and continues next round after add-phone when more runs remain', async () => {
  161. const events = {
  162. logs: [],
  163. broadcasts: [],
  164. accountRecords: [],
  165. timerPlans: [],
  166. runCalls: 0,
  167. };
  168. let currentState = {
  169. stepStatuses: {},
  170. vpsUrl: 'https://example.com/vps',
  171. vpsPassword: 'secret',
  172. customPassword: '',
  173. autoRunSkipFailures: false,
  174. autoRunFallbackThreadIntervalMinutes: 0,
  175. autoRunDelayEnabled: false,
  176. autoRunDelayMinutes: 30,
  177. autoStepDelaySeconds: null,
  178. mailProvider: '163',
  179. emailGenerator: 'duck',
  180. gmailBaseEmail: '',
  181. mail2925BaseEmail: '',
  182. emailPrefix: 'demo',
  183. inbucketHost: '',
  184. inbucketMailbox: '',
  185. cloudflareDomain: '',
  186. cloudflareDomains: [],
  187. tabRegistry: {},
  188. sourceLastUrls: {},
  189. autoRunRoundSummaries: [],
  190. };
  191. const runtime = {
  192. state: {
  193. autoRunActive: false,
  194. autoRunCurrentRun: 0,
  195. autoRunTotalRuns: 1,
  196. autoRunAttemptRun: 0,
  197. autoRunSessionId: 0,
  198. },
  199. get() {
  200. return { ...this.state };
  201. },
  202. set(updates = {}) {
  203. this.state = { ...this.state, ...updates };
  204. },
  205. };
  206. let sessionSeed = 100;
  207. const broadcastAutoRunStatus = async (phase, payload = {}, extraState = {}) => {
  208. events.broadcasts.push({ phase, ...payload });
  209. currentState = {
  210. ...currentState,
  211. ...extraState,
  212. autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
  213. autoRunPhase: phase,
  214. autoRunCurrentRun: payload.currentRun ?? runtime.state.autoRunCurrentRun,
  215. autoRunTotalRuns: payload.totalRuns ?? runtime.state.autoRunTotalRuns,
  216. autoRunAttemptRun: payload.attemptRun ?? runtime.state.autoRunAttemptRun,
  217. autoRunSessionId: payload.sessionId ?? runtime.state.autoRunSessionId,
  218. };
  219. };
  220. const controller = api.createAutoRunController({
  221. addLog: async (message, level = 'info') => {
  222. events.logs.push({ message, level });
  223. },
  224. appendAccountRunRecord: async (status, _state, reason) => {
  225. events.accountRecords.push({ status, reason });
  226. return { status, reason };
  227. },
  228. AUTO_RUN_MAX_RETRIES_PER_ROUND: 3,
  229. AUTO_RUN_RETRY_DELAY_MS: 3000,
  230. AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry',
  231. AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds',
  232. broadcastAutoRunStatus,
  233. broadcastStopToContentScripts: async () => {},
  234. cancelPendingCommands: () => {},
  235. chooseAddPhonePauseMinutes: () => 30,
  236. clearStopRequest: () => {},
  237. createAutoRunSessionId: () => {
  238. sessionSeed += 1;
  239. return sessionSeed;
  240. },
  241. getAutoRunStatusPayload: (phase, payload = {}) => ({
  242. autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
  243. autoRunPhase: phase,
  244. autoRunCurrentRun: payload.currentRun ?? 0,
  245. autoRunTotalRuns: payload.totalRuns ?? 1,
  246. autoRunAttemptRun: payload.attemptRun ?? 0,
  247. autoRunSessionId: payload.sessionId ?? 0,
  248. }),
  249. getErrorMessage: (error) => error?.message || String(error || ''),
  250. getFirstUnfinishedStep: () => 1,
  251. getPendingAutoRunTimerPlan: () => null,
  252. getRunningSteps: () => [],
  253. getState: async () => ({
  254. ...currentState,
  255. stepStatuses: { ...(currentState.stepStatuses || {}) },
  256. tabRegistry: { ...(currentState.tabRegistry || {}) },
  257. sourceLastUrls: { ...(currentState.sourceLastUrls || {}) },
  258. }),
  259. getStopRequested: () => false,
  260. hasSavedProgress: () => false,
  261. isAddPhoneAuthFailure: (error) => /add-phone|手机号页面|手机号页|手机号码|手机号/i.test(error?.message || String(error || '')),
  262. isRestartCurrentAttemptError: () => false,
  263. isStopError: (error) => (error?.message || String(error || '')) === '流程已被用户停止。',
  264. launchAutoRunTimerPlan: async () => false,
  265. normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Math.floor(Number(value) || 0)),
  266. persistAutoRunTimerPlan: async (plan, extraState = {}) => {
  267. events.timerPlans.push({ plan, extraState });
  268. await broadcastAutoRunStatus('waiting_interval', {
  269. currentRun: plan.currentRun,
  270. totalRuns: plan.totalRuns,
  271. attemptRun: plan.attemptRun,
  272. sessionId: plan.autoRunSessionId,
  273. }, {
  274. ...extraState,
  275. autoRunTimerPlan: plan,
  276. });
  277. return plan;
  278. },
  279. resetState: async () => {
  280. currentState = {
  281. ...currentState,
  282. stepStatuses: {},
  283. tabRegistry: {},
  284. sourceLastUrls: {},
  285. };
  286. },
  287. runAutoSequenceFromStep: async () => {
  288. events.runCalls += 1;
  289. throw new Error('步骤 8:验证码提交后页面进入手机号页面,当前流程无法继续自动授权。 URL: https://auth.openai.com/add-phone');
  290. },
  291. runtime,
  292. setState: async (updates = {}) => {
  293. currentState = {
  294. ...currentState,
  295. ...updates,
  296. stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses,
  297. tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry,
  298. sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls,
  299. };
  300. },
  301. sleepWithStop: async () => {},
  302. throwIfAutoRunSessionStopped: (sessionId) => {
  303. if (sessionId && sessionId !== runtime.state.autoRunSessionId) {
  304. throw new Error('流程已被用户停止。');
  305. }
  306. },
  307. waitForRunningStepsToFinish: async () => currentState,
  308. chrome: {
  309. runtime: {
  310. sendMessage() {
  311. return Promise.resolve();
  312. },
  313. },
  314. },
  315. });
  316. await controller.autoRunLoop(2, {
  317. autoRunSkipFailures: false,
  318. mode: 'restart',
  319. });
  320. assert.equal(events.runCalls, 1, 'add-phone should stop current round immediately without retrying the same round');
  321. assert.equal(events.accountRecords.length, 1, 'failed round should still be recorded');
  322. assert.equal(events.accountRecords[0].status, 'failed');
  323. assert.equal(events.timerPlans.length, 1, 'should schedule a delayed continue timer');
  324. assert.equal(events.timerPlans[0].plan.kind, 'between_rounds');
  325. assert.equal(events.timerPlans[0].plan.currentRun, 1);
  326. assert.equal(events.timerPlans[0].plan.totalRuns, 2);
  327. assert.equal(events.timerPlans[0].plan.countdownTitle, '手机号冷却中');
  328. assert.match(events.logs.find(({ message }) => /等待 30 分钟后继续下一轮/.test(message))?.message || '', /等待 30 分钟后继续下一轮/);
  329. assert.equal(runtime.state.autoRunActive, false);
  330. assert.equal(events.broadcasts.some(({ phase }) => phase === 'retrying'), false, 'should not retry same round after add-phone');
  331. assert.equal(events.broadcasts.some(({ phase }) => phase === 'waiting_interval'), true, 'should enter waiting_interval phase');
  332. });